sagadeck 1.2.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 (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +305 -0
  3. package/SKILL.md +148 -0
  4. package/bin/sagadeck.js +511 -0
  5. package/docs/ANDAMENTO.md +1255 -0
  6. package/docs/EXPERIENCIAS.md +39 -0
  7. package/docs/PLANO-EXPERIENCIAS.md +39 -0
  8. package/docs/PLANO-TESTE-AULAS-TECNICAS.md +44 -0
  9. package/docs/PLANO-TESTE-DESINFORMACAO.md +38 -0
  10. package/docs/REFERENCIA.md +1131 -0
  11. package/package.json +59 -0
  12. package/src/adaptive-layouts.js +18 -0
  13. package/src/ai/art-direction.js +8 -0
  14. package/src/ai/commands.js +130 -0
  15. package/src/ai/context.js +245 -0
  16. package/src/ai/deck-ai.js +1135 -0
  17. package/src/ai/directions.js +74 -0
  18. package/src/ai/document-materials.js +49 -0
  19. package/src/ai/document-visuals.js +215 -0
  20. package/src/ai/evaluation.js +31 -0
  21. package/src/ai/ground.js +55 -0
  22. package/src/ai/llm.js +292 -0
  23. package/src/ai/quality.js +51 -0
  24. package/src/ai/relay.js +45 -0
  25. package/src/ai/staged-generation.js +24 -0
  26. package/src/ai/transform.js +858 -0
  27. package/src/ai/usage.js +17 -0
  28. package/src/ai/variety.js +80 -0
  29. package/src/ai/video-frames.js +24 -0
  30. package/src/ai/video-generation.js +126 -0
  31. package/src/ai/web-operation.js +31 -0
  32. package/src/algo-catalog.js +153 -0
  33. package/src/algo-trace.js +139 -0
  34. package/src/api-client.js +514 -0
  35. package/src/api-collections.js +14 -0
  36. package/src/api-demo.js +222 -0
  37. package/src/aspect.js +40 -0
  38. package/src/build.js +323 -0
  39. package/src/carousel.js +56 -0
  40. package/src/chrome.js +65 -0
  41. package/src/code-highlight.js +68 -0
  42. package/src/code-lab-browser.js +22 -0
  43. package/src/code-lab.js +3 -0
  44. package/src/code-language.js +52 -0
  45. package/src/csv.js +74 -0
  46. package/src/decision-lab.js +20 -0
  47. package/src/deck-file.js +119 -0
  48. package/src/diagram/napkin-examples.js +13 -0
  49. package/src/diagram/napkin.js +539 -0
  50. package/src/docx.js +128 -0
  51. package/src/dynamics/git-sim.js +200 -0
  52. package/src/dynamics/layouts.js +112 -0
  53. package/src/elements.js +554 -0
  54. package/src/experiences.js +124 -0
  55. package/src/exploration.js +31 -0
  56. package/src/export/browser.js +53 -0
  57. package/src/export/estudo.js +67 -0
  58. package/src/export/pptx.js +475 -0
  59. package/src/export/roteiro.js +40 -0
  60. package/src/export/shots.js +182 -0
  61. package/src/figures/charts.js +383 -0
  62. package/src/figures/diagrams.js +117 -0
  63. package/src/figures/icons.js +166 -0
  64. package/src/figures/material-pictos.js +20 -0
  65. package/src/figures/pictos.js +255 -0
  66. package/src/figures/points.js +106 -0
  67. package/src/figures/qr.js +29 -0
  68. package/src/figures/ufmap.js +48 -0
  69. package/src/fiscal/autofix.js +377 -0
  70. package/src/fiscal/normalize.js +115 -0
  71. package/src/graph-lab.js +24 -0
  72. package/src/hologram.js +30 -0
  73. package/src/identity.js +86 -0
  74. package/src/import/crop.js +59 -0
  75. package/src/import/index.js +132 -0
  76. package/src/import/legacy-word.js +42 -0
  77. package/src/import/merge.js +45 -0
  78. package/src/import/office.js +123 -0
  79. package/src/import/omml.js +69 -0
  80. package/src/import/pdf-render.js +56 -0
  81. package/src/import/pptx.js +701 -0
  82. package/src/import/xml.js +42 -0
  83. package/src/infographic.js +401 -0
  84. package/src/layouts.js +723 -0
  85. package/src/lessons.js +171 -0
  86. package/src/library.js +399 -0
  87. package/src/markup.js +116 -0
  88. package/src/master.js +214 -0
  89. package/src/mcp/server.js +371 -0
  90. package/src/meeting.js +42 -0
  91. package/src/motion.js +56 -0
  92. package/src/package.js +116 -0
  93. package/src/playground.js +19 -0
  94. package/src/portal-scene.js +20 -0
  95. package/src/preferences.js +61 -0
  96. package/src/protect.js +51 -0
  97. package/src/purpose.js +24 -0
  98. package/src/pytrace.js +1092 -0
  99. package/src/research/duckduckgo.js +73 -0
  100. package/src/research/research.js +202 -0
  101. package/src/research/svg-image.js +17 -0
  102. package/src/research/visuals.js +52 -0
  103. package/src/runtime/api-collection-ui.js +5 -0
  104. package/src/runtime/api-collections.js +28 -0
  105. package/src/runtime/api-core.js +530 -0
  106. package/src/runtime/api-ui.js +966 -0
  107. package/src/runtime/base.css +1774 -0
  108. package/src/runtime/calc.js +139 -0
  109. package/src/runtime/code-lab.js +371 -0
  110. package/src/runtime/decision-lab.js +49 -0
  111. package/src/runtime/diagram.js +341 -0
  112. package/src/runtime/fit.js +268 -0
  113. package/src/runtime/fonts/caveat.css +2 -0
  114. package/src/runtime/fonts/comic-neue.css +3 -0
  115. package/src/runtime/fonts/fredoka.css +2 -0
  116. package/src/runtime/fonts/index.json +10 -0
  117. package/src/runtime/fonts/inter.css +2 -0
  118. package/src/runtime/fonts/patrick-hand.css +2 -0
  119. package/src/runtime/fonts/plus-jakarta-sans.css +2 -0
  120. package/src/runtime/fonts/press-start-2p.css +2 -0
  121. package/src/runtime/fonts/vt323.css +2 -0
  122. package/src/runtime/formula.js +148 -0
  123. package/src/runtime/graph-lab.js +72 -0
  124. package/src/runtime/meeting.css +7 -0
  125. package/src/runtime/meeting.js +10 -0
  126. package/src/runtime/motion.js +15 -0
  127. package/src/runtime/playground.js +11 -0
  128. package/src/runtime/points.js +19 -0
  129. package/src/runtime/portal-scene.css +6 -0
  130. package/src/runtime/portal-scene.js +22 -0
  131. package/src/runtime/runtime.js +827 -0
  132. package/src/runtime/science.js +89 -0
  133. package/src/runtime/skins/aluminio.css +31 -0
  134. package/src/runtime/skins/arcade.css +25 -0
  135. package/src/runtime/skins/aurora.css +17 -0
  136. package/src/runtime/skins/bauhaus.css +19 -0
  137. package/src/runtime/skins/editorial.css +19 -0
  138. package/src/runtime/skins/jornal.css +17 -0
  139. package/src/runtime/skins/manual-noite.css +14 -0
  140. package/src/runtime/skins/manual.css +14 -0
  141. package/src/runtime/skins/noite.css +21 -0
  142. package/src/runtime/skins/oceano.css +17 -0
  143. package/src/runtime/skins/pop.css +23 -0
  144. package/src/runtime/skins/prata.css +18 -0
  145. package/src/runtime/skins/prisma.css +28 -0
  146. package/src/runtime/skins/rabisco.css +18 -0
  147. package/src/runtime/skins/relevo.css +33 -0
  148. package/src/runtime/skins/sinal.css +16 -0
  149. package/src/runtime/skins/terminal.css +23 -0
  150. package/src/runtime/vendor/KATEX-LICENSE.txt +21 -0
  151. package/src/runtime/vendor/MERMAID-LICENSE.txt +21 -0
  152. package/src/runtime/vendor/PLOTLY-LICENSE.txt +21 -0
  153. package/src/runtime/vendor/katex.css +1 -0
  154. package/src/runtime/vendor/mermaid.min.js +3636 -0
  155. package/src/runtime/vendor/plotly.min.js +3620 -0
  156. package/src/runtime/video-player.js +35 -0
  157. package/src/science.js +162 -0
  158. package/src/studio/ai-response.js +36 -0
  159. package/src/studio/api-routes.js +152 -0
  160. package/src/studio/assets/contexto/leia-me.md +12 -0
  161. package/src/studio/assets/contexto/vendas.csv +7 -0
  162. package/src/studio/assets/imagens/colecao-arquitetura.png +0 -0
  163. package/src/studio/assets/imagens/colecao-cor.png +0 -0
  164. package/src/studio/assets/imagens/colecao-equipe.png +0 -0
  165. package/src/studio/assets/imagens/lavanda-cover.jpg +0 -0
  166. package/src/studio/code-routes.js +32 -0
  167. package/src/studio/command-approval.js +48 -0
  168. package/src/studio/demo-decks.js +98 -0
  169. package/src/studio/direction-routes.js +29 -0
  170. package/src/studio/errors.js +65 -0
  171. package/src/studio/exploration-demo.js +17 -0
  172. package/src/studio/exporting.js +105 -0
  173. package/src/studio/generation-report.js +10 -0
  174. package/src/studio/instance.js +48 -0
  175. package/src/studio/layout-samples.js +152 -0
  176. package/src/studio/project.js +371 -0
  177. package/src/studio/public/api-collections.js +28 -0
  178. package/src/studio/public/app.js +5265 -0
  179. package/src/studio/public/art-preview.js +10 -0
  180. package/src/studio/public/calc-fields.js +11 -0
  181. package/src/studio/public/chat-progress.js +47 -0
  182. package/src/studio/public/developer-fields.js +22 -0
  183. package/src/studio/public/explorer.js +443 -0
  184. package/src/studio/public/history.js +58 -0
  185. package/src/studio/public/index.html +867 -0
  186. package/src/studio/public/inspector.js +270 -0
  187. package/src/studio/public/lab-fields.js +5 -0
  188. package/src/studio/public/library.css +275 -0
  189. package/src/studio/public/library.html +55 -0
  190. package/src/studio/public/library.js +471 -0
  191. package/src/studio/public/merge-decks.js +155 -0
  192. package/src/studio/public/review-ui.js +100 -0
  193. package/src/studio/public/screenshot-editor.js +140 -0
  194. package/src/studio/public/share-ui.js +72 -0
  195. package/src/studio/public/slide-clipboard.js +23 -0
  196. package/src/studio/public/slide-form.js +1263 -0
  197. package/src/studio/public/studio-awareness.js +154 -0
  198. package/src/studio/public/studio-next.css +303 -0
  199. package/src/studio/public/style.css +1262 -0
  200. package/src/studio/public/ui-icons.js +2 -0
  201. package/src/studio/public/viewers.js +372 -0
  202. package/src/studio/public/visual-editor.js +326 -0
  203. package/src/studio/public/workspace.js +18 -0
  204. package/src/studio/public-files.js +1 -0
  205. package/src/studio/request-helpers.js +41 -0
  206. package/src/studio/server.js +1583 -0
  207. package/src/studio/share-routes.js +146 -0
  208. package/src/studio/slide-copy-routes.js +26 -0
  209. package/src/studio/slide-copy.js +30 -0
  210. package/src/studio/snapshot.js +160 -0
  211. package/src/studio/style-routes.js +103 -0
  212. package/src/studio/template-collections.js +97 -0
  213. package/src/studio/versions.js +95 -0
  214. package/src/studio/video-download.js +29 -0
  215. package/src/studio/visual-keys.js +52 -0
  216. package/src/studio/workspaces.js +39 -0
  217. package/src/table.js +93 -0
  218. package/src/templates/scaffold.js +372 -0
  219. package/src/themes.js +604 -0
  220. package/src/trace-view.js +450 -0
  221. package/src/uid.js +19 -0
  222. package/src/video-player.js +40 -0
  223. package/src/visual-edits.js +99 -0
  224. package/src/ws.js +134 -0
  225. package/templates/cenario/Texto no cen/303/241rio.yaml" +171 -0
  226. package/templates/cenario/imagens/cidade.svg +30 -0
  227. package/templates/cenario/imagens/pessoa.svg +15 -0
  228. package/templates/cenario-e-ciencia.yaml +47 -0
  229. package/templates/ensaio-api.yaml +160 -0
  230. package/templates/exemplo-alegre.yaml +75 -0
  231. package/templates/exemplo-keynote.yaml +90 -0
  232. package/templates/exemplo-napkin.yaml +139 -0
  233. package/templates/exemplo.yaml +228 -0
@@ -0,0 +1,1583 @@
1
+ import { lookAt, readBody, readJSON } from "./request-helpers.js";
2
+ import { workspaceResolver, scopePreviewHTML, staleTab } from "./workspaces.js";
3
+ import { cancellable, respond } from "./ai-response.js";
4
+ import {chatErrorResult,installErrorResponses} from './errors.js';
5
+ // sagadeck Studio · Servidor HTTP local para o editor visual PowerPoint + Chat Lateral IA
6
+ import { slideCopyRoutes } from "./slide-copy-routes.js";
7
+ import { PUBLIC_SCRIPTS } from "./public-files.js";
8
+ import {meetingRoutes,meetingPreview} from '../meeting.js';
9
+ import { codeRoutes } from "./code-routes.js";
10
+ import { directionRoutes } from "./direction-routes.js";
11
+ import http from "node:http";
12
+ import fs from "node:fs";
13
+ import crypto from "node:crypto";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { spawn } from "node:child_process";
18
+ import YAML from "yaml";
19
+ import { buildHTML, renderSlide, loadSpec, inferLayout, setFitDefaults } from "../build.js";
20
+ import { loadPreferences, savePreferences, preferencesFile, PREF_SCHEMA } from "../preferences.js";
21
+ import { THEMES, PALETTES } from "../themes.js";
22
+ import { writeDeckFile } from "../deck-file.js";
23
+ import { snapshotVersion, versionRoutes } from "./versions.js";
24
+ import "./public/merge-decks.js"; // globalThis.SagadeckMerge (o mesmo que o Studio usa no navegador)
25
+ import { LAYOUTS } from "../layouts.js";
26
+ import { listIcons } from "../figures/icons.js";
27
+ import { autofixSlide, autofixDeck } from "../fiscal/autofix.js";
28
+ import { normalizeSpec } from "../fiscal/normalize.js";
29
+ import { varietyReport } from "../ai/variety.js";
30
+ import { EXPERIENCES, createExperienceDeck } from "../experiences.js";
31
+ import { packDeck, unpackDeck, EXTENSION, MIME } from "../package.js";
32
+ import { openLibrary, defaultLibraryRoot, safeName } from "../library.js";
33
+ import { ApiEnvironments, defaultEnvFile, readRecordings, writeRecording, mimeOf } from "../api-client.js";
34
+ import { startMockApi, demoEnv, DEMO_FILES } from "../api-demo.js";
35
+ import { slideSnapshots, diagramCheck } from "./snapshot.js";
36
+ import { reviewExperience } from '../ai/quality.js';
37
+ import { saveGenReport } from './generation-report.js';
38
+ import { runCommand, logCommand } from "../ai/commands.js";
39
+ import { needsApproval, approvalLabel, awaitApproval, commandEnv, estimateSubmitCost } from "./command-approval.js";
40
+ import { demoDeck, demoAssets, demoProjectFiles } from "./demo-decks.js";
41
+ import { llmAvailable, llmConfig } from "../ai/llm.js";
42
+ import { editDeck, textToSlide, generateDeck, toYaml, materializeImages } from "../ai/deck-ai.js";
43
+ import { transformDeck, jobStatus, sourceHash } from "../ai/transform.js";
44
+ import { sendExport, sendHtml, lightVariant } from "./exporting.js";
45
+ import { styleRoutes, styleAction } from "./style-routes.js";
46
+ import { shareRoutes } from "./share-routes.js";
47
+ import { apiRoutes } from "./api-routes.js";
48
+ export { lightVariant };
49
+ import { VERSION, buildInfo } from "./instance.js";
50
+ import { extractDocText, fetchUrlText, CONTEXT_STORE_CHARS, CONTEXT_MAX_DOCS, pastedUrls } from "../ai/context.js";
51
+ import * as Project from "./project.js";
52
+ import { prepareDocumentMaterials, takeMaterials } from '../ai/document-materials.js';
53
+ import { webOnlyRunner } from '../ai/web-operation.js';
54
+ import { docxToHtml } from "../docx.js";
55
+ import { ensureUids } from "../uid.js";
56
+ import { migrateLegacyKeys, carryVisualEdits } from "./visual-keys.js";
57
+ import { createRequire } from "node:module";
58
+ // pdf.js do node_modules (já é dependência para ler PDF de contexto); sem ele, o explorador usa o leitor do navegador
59
+ const PDFJS_DIR = (() => { try { return path.dirname(createRequire(import.meta.url).resolve("pdfjs-dist/build/pdf.min.mjs")); } catch { return null; } })();
60
+ import { parseAspect, convertAspect, slideSize } from "../aspect.js";
61
+ import { chat as llmChat } from "../ai/llm.js";
62
+
63
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
64
+ // Caminhos que existem no repositório (src/studio/…) OU no motor empacotado do pip (engine/studio/…, engine/runtime/…)
65
+ const firstDir = (...dirs) => dirs.find((d) => fs.existsSync(d)) || dirs[0];
66
+ const PUBLIC_DIR = firstDir(path.join(HERE, "public"), path.join(HERE, "studio", "public"));
67
+ const RUNTIME_DIR = firstDir(path.join(HERE, "..", "runtime"), path.join(HERE, "runtime"));
68
+ // imagens dos modelos de fábrica (em imagens/, como num deck): a prévia usa esta pasta direto, sem copiar nada
69
+ const MODEL_ASSETS_DIR = firstDir(path.join(HERE, "assets"), path.join(HERE, "studio", "assets"));
70
+ // templates de exemplo do pacote (repositório: ../../templates · motor empacotado: ./templates)
71
+ const TEMPLATE_DIRS = [path.resolve(HERE, "..", "..", "templates"), path.resolve(HERE, "templates")];
72
+ const isBundledTemplate = (f) => !!f && TEMPLATE_DIRS.some((d) => path.resolve(f).startsWith(d + path.sep));
73
+ const templateFile = (name) => TEMPLATE_DIRS.map((d) => path.join(d, name)).find((f) => fs.existsSync(f));
74
+
75
+ const slugify = (s) => String(s || "deck").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "deck";
77
+
78
+ export function createStudioServer(deckPath = null, opts = {}) {
79
+ setFitDefaults(loadPreferences().texto); // mínimos do ajuste para caber (Preferências), valem no Studio e na exportação
80
+ // Área de trabalho: o que cada pessoa tem aberto (deck, arquivo, última prévia) + a biblioteca dela.
81
+ // Modo local (Windows do banco, só você): uma área só, biblioteca em SAGADECK_HOME ou ~/sagadeck.
82
+ // Modo multiusuário (servidor atrás do BabsDeck): uma área por usuário, biblioteca <raiz>/usuarios/<usuário>.
83
+ const libraryRoot = path.resolve(opts.library || defaultLibraryRoot());
84
+ const STARTED = new Date().toISOString();
85
+ const BUILD = buildInfo(); // carimbo calculado uma vez na subida (git pode demorar)
86
+ const workspaces = new Map();
87
+ const slideClipboard = new Map();
88
+ function sampleSpec() {
89
+ const samplePath = TEMPLATE_DIRS.map((d) => path.join(d, "exemplo.yaml")).find((f) => fs.existsSync(f)) || "";
90
+ if (samplePath) return { spec: loadSpec(samplePath), file: samplePath };
91
+ return {
92
+ file: null,
93
+ spec: {
94
+ title: "Minha Apresentação", theme: "sinal", duration: 15,
95
+ slides: [
96
+ { layout: "cover", title: "Título da Apresentação", subtitle: "Criado com SagaDeck", author: "Seu Nome" },
97
+ { layout: "statement", kicker: "Destaque", text: "Uma ideia forte por slide muda tudo." },
98
+ ],
99
+ },
100
+ };
101
+ }
102
+ function newWorkspace(user) {
103
+ const W = { user, library: openLibrary(user ? path.join(libraryRoot, "usuarios", safeName(user, "usuario")) : libraryRoot),
104
+ file: null, spec: null, lastPreview: { ok: true, error: null, warnings: [] } };
105
+ Object.assign(W, sampleSpec());
106
+ return W;
107
+ }
108
+ const localWs = newWorkspace(null);
109
+ if (deckPath) {
110
+ const f = path.resolve(deckPath);
111
+ if (fs.existsSync(f)) {
112
+ try { localWs.spec = loadSpec(f); localWs.file = f; }
113
+ catch (e) { console.warn(`[Studio] Aviso ao carregar ${f}: ${e.message}`); }
114
+ } else {
115
+ localWs.file = f; // arquivo novo: nasce do exemplo e é salvo aí
116
+ }
117
+ }
118
+ workspaces.set("", localWs);
119
+ // quem é: no modo multiusuário, o cabeçalho que o proxy (nginx + BabsDeck) põe; senão, a área local
120
+ const USER_HEADER = String(opts.userHeader || "x-sagadeck-user").toLowerCase();
121
+ const workspaceOf = workspaceResolver({ opts, USER_HEADER, workspaces, newWorkspace });
122
+ // links de ver, só leitura (Arquivo › Compartilhar link; src/studio/share-routes.js): a biblioteca de quem compartilhou
123
+ const shares = shareRoutes({ libraryRoot, multiuser: !!opts.multiuser, libraryOf: (u) => {
124
+ if (!workspaces.has(u || "")) workspaces.set(u || "", newWorkspace(u || null));
125
+ return workspaces.get(u || "").library;
126
+ } });
127
+ // Arquivo esperado protege gravações de clientes antigos sem escopo de aba.
128
+ const layoutPreviewCache = new Map(); // tema -> { layout: html }
129
+
130
+ // Slide "api": ambientes (dev/hom…) e token ficam na máquina, fora do deck.
131
+ const apiEnv = new ApiEnvironments(opts.apiEnvFile || defaultEnvFile()); // LOOPBACK: definido abaixo, junto da trava de origem
132
+ const rtSessions = new Map(); // conversas em tempo real abertas: sid -> { conn, buffer, res }
133
+ // Ambiente embutido "ensaio": a API de mentira (src/api-demo.js), para o deck de exemplo rodar sem VPN e
134
+ // sem configurar nada. Só no Studio local; sobe na primeira vez que um slide api precisa e fecha com o Studio.
135
+ let ensaio = null;
136
+ function ensureOpenRouter() {
137
+ apiEnv.builtin.openrouter ??= {
138
+ label: "OpenRouter · chamada real",
139
+ vars: { base: "https://openrouter.ai/api/v1" },
140
+ secrets: { openrouter_api_key: { env: "OPENROUTER_API_KEY" } },
141
+ };
142
+ }
143
+ function ensureEnsaio() {
144
+ if (opts.multiuser) return Promise.resolve(null);
145
+ if (opts.ensaio === false) {
146
+ ensureOpenRouter();
147
+ return Promise.resolve(null);
148
+ }
149
+ ensaio ??= startMockApi().then((mock) => {
150
+ apiEnv.builtin.ensaio = { ...demoEnv(mock), label: "API de mentira" };
151
+ ensureOpenRouter();
152
+ return mock;
153
+ }).catch((e) => {
154
+ ensureOpenRouter();
155
+ console.error("[Studio] API de ensaio não subiu:", e.message);
156
+ return null;
157
+ });
158
+ return ensaio;
159
+ }
160
+ // O que a IA sabe do ambiente dos slides api: nome, variáveis (endereços), nomes dos segredos. Nunca valores de segredo.
161
+ function apiContextFor(req, W) {
162
+ if (opts.multiuser) return null;
163
+ try {
164
+ const st = apiEnv.state();
165
+ const cur = st.envs.find((e) => e.name === st.current);
166
+ if (!cur) return null;
167
+ return { env: cur.name, live: !apiBlocked(req), vars: cur.vars, secrets: Object.keys(apiEnv.env().secrets || {}), saved: W.apiVars || {} };
168
+ } catch { return null; }
169
+ }
170
+
171
+ // Comandos da IA (src/ai/commands.js): só no Studio local, com a resposta ao vivo (stream: é por ela que se pergunta),
172
+ // com a apresentação salva na biblioteca (a pasta dela é a pasta de trabalho) e só com a aprovação da pessoa: o
173
+ // pedido vai como {phase: "approve", id, command} e a resposta chega em /api/ai/approve. body.autoRun: ela liberou
174
+ // os próximos desta conversa (o Studio só guarda isso enquanto a página está aberta). Sem resposta em 10 min: recusado.
175
+ // Quem pode: no Studio local, a pessoa da máquina (mesmas travas do slide api: endereço local, própria página). No
176
+ // servidor multiusuário, só quem está em --agentes / SAGADECK_AGENTES (o comando roda NA MÁQUINA DO SERVIDOR); os
177
+ // outros usam tudo (gerar slides, conversar, imagens), só sem comandos, e a IA deles nem sabe que existem.
178
+ const AGENTS = new Set([].concat(opts.agentUsers ?? String(process.env.SAGADECK_AGENTES || "").split(",")).map((u) => String(u).trim()).filter(Boolean));
179
+ function commandsAllowed(req, W) {
180
+ if (!W) return false;
181
+ return opts.multiuser ? !!W.user && AGENTS.has(W.user) : !apiBlocked(req);
182
+ }
183
+ const approvals = new Map(); // id -> { user, answer }
184
+ // transformações em andamento (continuam se o navegador fechar; o andamento e o Parar vêm pelas rotas /api/ai/transform/*)
185
+ const transforms = new Map(); // "<arquivo do deck>|<modo>" -> { controller, mode, progress, started }
186
+ function commandRunner(req, emit, body, W, signal) {
187
+ if (!body.stream || !W.file || isBundledTemplate(W.file)) return null;
188
+ if (!commandsAllowed(req,W)) return webOnlyRunner({signal,onProgress:emit});
189
+ const cwd = path.dirname(W.file);
190
+ return async (command) => {
191
+ signal?.throwIfAborted();
192
+ if (needsApproval({ autoRun: body.autoRun, command })) {
193
+ const cost = await estimateSubmitCost(command, { commandEnv: (W) => commandEnv(W, apiEnv), W });
194
+ const decision = await awaitApproval({ command, cost, user: W.user || "", signal, emit, approvals });
195
+ if (decision === "deny") { logCommand({ user: W.user || null, deck: W.file, cwd, language: command.language, why: command.why, code: command.code, decision: "recusado" }); return { denied: true }; }
196
+ if (decision === "always") body.autoRun = true;
197
+ }
198
+ signal?.throwIfAborted();
199
+ emit({ phase: "command", command, text: `Rodando: ${command.why || command.language}…` });
200
+ const { env, mask } = await commandEnv(W, apiEnv);
201
+ const t0 = Date.now();
202
+ const dec = () => approvalLabel({ autoRun: body.autoRun, command });
203
+ let result;
204
+ try { result = await runCommand(command, { cwd, env, mask, signal }); }
205
+ catch (e) { logCommand({ user: W.user || null, deck: W.file, cwd, language: command.language, why: command.why, code: command.code, decision: dec(), error: e.message }, { mask }); throw e; }
206
+ logCommand({ user: W.user || null, deck: W.file, cwd, language: command.language, why: command.why, code: command.code, decision: dec(), exit: result.exitCode ?? result.code ?? null, ms: Date.now() - t0, output: [result.stdout, result.stderr].filter(Boolean).join("\n") }, { mask });
207
+ emit({ phase: "command-result", command, result, text: "Analisando o resultado…" });
208
+ return result;
209
+ };
210
+ }
211
+ // variáveis, segredos e token do ambiente ativo: ver commandEnv em ./command-approval.js
212
+
213
+ // Motivo para NÃO executar pedidos, ou null. Vale para toda rota /api/http/* que executa algo.
214
+ function apiBlocked(req) {
215
+ if (opts.multiuser) return { code: 403, message: "No servidor (multiusuário) o slide API só mostra a última gravação; executar é no Studio local." };
216
+ if (opts.host && !LOOPBACK.has(opts.host)) return { code: 403, message: "O Studio está aberto para a rede (--host): executar pedidos fica desligado." };
217
+ const host = (() => { try { return new URL(`http://${req.headers.host || ""}`).hostname; } catch { return ""; } })();
218
+ if (!LOOPBACK.has(host)) return { code: 403, message: "Executar pedidos só pelo endereço desta máquina (127.0.0.1)." };
219
+ const origin = req.headers.origin;
220
+ if (origin) { // "null" (HTML aberto do disco) também é outra página
221
+ let oh = "";
222
+ try { oh = new URL(origin).host; } catch {}
223
+ if (oh !== req.headers.host) return { code: 403, message: "Pedido vindo de outra página: bloqueado." };
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // Salva o deck atual no arquivo aberto — nunca por cima dos exemplos que vêm no pacote.
229
+ // Grava o deck (src/deck-file.js): só o que mudou, preservando comentários e formatação, de forma atômica.
230
+ // text: o YAML que a pessoa escreveu na gaveta vai para o arquivo exatamente como ela escreveu.
231
+ // Modelo de fábrica aberto em prévia (W.preview): nada é gravado até a pessoa mudar algo. A primeira mudança (ou
232
+ // "Usar como base") cria a cópia na biblioteca, com as imagens, e o Studio passa a gravar nela.
233
+ function copyModelAssets(L, id, kind) {
234
+ for (const asset of demoAssets(kind)) {
235
+ const dest = path.join(path.dirname(L.resolveId(id)), "imagens");
236
+ fs.mkdirSync(dest, { recursive: true });
237
+ fs.copyFileSync(path.join(MODEL_ASSETS_DIR, "imagens", asset), path.join(dest, asset));
238
+ }
239
+ // arquivos do projeto do modelo (ex.: contexto/vendas.csv da demo de novidades)
240
+ for (const rel of demoProjectFiles(kind)) {
241
+ const dest = path.join(path.dirname(L.resolveId(id)), ...rel.split("/"));
242
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
243
+ fs.copyFileSync(path.join(MODEL_ASSETS_DIR, ...rel.split("/")), dest);
244
+ }
245
+ }
246
+ // Tudo o que a vitrine "Modelo pronto" oferece, por chave (model-*, exp-*, example-*): o deck, a pasta de onde a
247
+ // prévia lê as imagens e o que copiar para a pasta da cópia quando ela nasce.
248
+ function galleryEntry(key) {
249
+ key = String(key || "");
250
+ if (key.startsWith("model-")) { const kind = key.slice(6); return { spec: demoDeck(kind), dir: MODEL_ASSETS_DIR, copy: (L, id) => copyModelAssets(L, id, kind) }; }
251
+ if (key.startsWith("exp-")) return { spec: createExperienceDeck(key.slice(4)), dir: null, copy: () => {} };
252
+ if (key === "example-cenario") {
253
+ const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
254
+ if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
255
+ const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
256
+ return { spec: YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")), dir: dir0,
257
+ copy: (L, id) => fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false }) };
258
+ }
259
+ if (key === "example-api") {
260
+ const tpl = templateFile("ensaio-api.yaml");
261
+ if (!tpl) throw new Error("o exemplo não veio no pacote (templates/ensaio-api.yaml)");
262
+ return { spec: YAML.parse(fs.readFileSync(tpl, "utf8")), dir: path.dirname(tpl),
263
+ copy: (L, id) => { const dir = path.dirname(L.resolveId(id)); for (const [name, text] of Object.entries(DEMO_FILES)) fs.writeFileSync(path.join(dir, name), text); } };
264
+ }
265
+ throw new Error("Modelo não encontrado.");
266
+ }
267
+ // editor?model=lavanda (tipo do modelo) ou a chave completa da vitrine (exp-executivo, example-api…)
268
+ const galleryKey = (v) => /^(model|exp|example)-/.test(String(v || "")) ? String(v) : `model-${v}`;
269
+ function materializePreview(W) {
270
+ if (!W.preview || W.file) return null;
271
+ const { key, topic } = W.preview;
272
+ W.preview = null;
273
+ const clean = Object.fromEntries(Object.entries(W.spec || {}).filter(([k]) => !k.startsWith("_")));
274
+ const id = W.library.createDeck(topic, clean);
275
+ galleryEntry(key).copy(W.library, id);
276
+ W.file = W.library.resolveId(id);
277
+ W.spec = loadSpec(W.file);
278
+ return { id, title: W.spec.title, topic };
279
+ }
280
+ function persist(W, text = null) {
281
+ if (!W.file || isBundledTemplate(W.file)) return;
282
+ if (text == null && W.spec) ensureUids(W.spec); // decks antigos ganham uid aqui; slide novo (da pessoa ou da IA) também
283
+ try {
284
+ if (text != null) {
285
+ const tmp = W.file + ".tmp-" + crypto.randomUUID();
286
+ try { fs.writeFileSync(tmp, text, "utf8"); fs.renameSync(tmp, W.file); }
287
+ finally { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }
288
+ }
289
+ else writeDeckFile(W.file, W.spec);
290
+ try { snapshotVersion(W.file, fs.readFileSync(W.file, "utf8")); } catch {}
291
+ } catch (e) { console.error("[Studio] Erro ao salvar:", e.message); throw e; }
292
+ }
293
+
294
+ // Onde a IA grava imagens geradas: pasta "imagens" ao lado do deck (ou na pasta atual, se o deck é um exemplo).
295
+ function imageOptions(W, spec) {
296
+ materializePreview(W); // a IA vai gravar imagens: a prévia vira uma cópia antes
297
+ const deckDir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : process.cwd();
298
+ return { baseDir: spec._dir || deckDir, assetsDir: path.join(deckDir, "imagens") };
299
+ }
300
+
301
+ function withBase(W, spec) {
302
+ if (!spec._dir && W.file) return { ...spec, _dir: path.dirname(W.file), _file: W.file };
303
+ return spec;
304
+ }
305
+
306
+ // Anexos por sessão; persistidos no projeto antes de gerar.
307
+ // O modelo recebe texto e inventário visual, nunca o binário.
308
+ function keepMaterial(W, name, text, detail, bytes) {
309
+ if (!(W.contextDocs instanceof Map)) W.contextDocs = new Map();
310
+ const id = crypto.randomBytes(4).toString("hex");
311
+ W.contextDocs.set(id, { name: String(name).slice(0, 120), text: String(text).slice(0, CONTEXT_STORE_CHARS), detail, bytes, at: Date.now() });
312
+ while (W.contextDocs.size > CONTEXT_MAX_DOCS) W.contextDocs.delete(W.contextDocs.keys().next().value);
313
+ return { id, name, chars: text.length, detail };
314
+ }
315
+ // Gerar com IA (editor ou biblioteca): pasta nova na biblioteca ("Gerando…"), o deck gravado nela com as imagens
316
+ // em imagens/, e a pasta renomeada para o título. Falhou: a pasta vai para a lixeira. Minutos, estilo, anexos.
317
+ async function generateIntoLibrary(W, topic, b, emit) {
318
+ const L = W.library;
319
+ const id = L.createDeck(topic, { title: "Gerando…", slides: [{ layout: "cover", title: "Gerando…" }] });
320
+ const file = L.resolveId(id), dir = path.dirname(file);
321
+ try {
322
+ const prefs = loadPreferences().ia;
323
+ const gen = await generateDeck(String(b.briefing || ""), {
324
+ ask: prefs.perguntar !== false && !b.answer, answer: b.answer ? String(b.answer).slice(0, 500) : "",
325
+ author: prefs.autor || "", language: prefs.idioma || "auto",
326
+ theme: b.theme || undefined,
327
+ style: b.style || undefined,
328
+ slides: Number(b.slides) || undefined,
329
+ duration: Number(b.duration) || undefined,
330
+ direction: b.direction || undefined,
331
+ materials: await prepareDocumentMaterials(takeMaterials(W, b.materials), dir, {onProgress:emit}),
332
+ images: prefs.imagens !== false, // o briefing diz se quer imagens (e onde); Preferências podem desligar
333
+ imageOptions: { baseDir: dir, assetsDir: path.join(dir, "imagens") },
334
+ onEvent: emit,
335
+ drawCheck: diagramCheck,
336
+ reviewCheck: (deck, indices) => reviewExperience({ ...deck, _dir: dir }, indices, { snapshot: slideSnapshots, onProgress: emit, briefing:b.briefing }),
337
+ // pesquisa na web quando a IA decidir que precisa (Preferências › IA pode desligar; SAGADECK_WEB=0 no banco);
338
+ // as fontes lidas ficam em contexto/pesquisa/ do deck
339
+ research: prefs.pesquisa === false ? false : "auto",
340
+ researchDir: dir,
341
+ });
342
+ if (gen.question) { L.trashDeck(id); return { question: gen.question }; } // a IA quer saber para que serve o material
343
+ // estilo padrão da biblioteca (Brand Kit), se a pessoa não escolheu um tema para esta
344
+ if (!b.theme) gen.spec = L.withDefaultStyle(gen.spec, dir);
345
+ fs.writeFileSync(file, toYaml(gen.spec), "utf8");
346
+ saveGenReport(dir, gen);
347
+ const finalId = L.renameDeck(id, gen.spec.title || "Nova apresentação");
348
+ return { id: finalId, file: L.resolveId(finalId), images: gen.images, research: gen.research, quality: gen.quality, variety: gen.variety };
349
+ } catch (e) {
350
+ L.trashDeck(id);
351
+ throw e;
352
+ }
353
+ }
354
+ // tópico do deck aberto, se ele mora na biblioteca ("" = Sem tópico)
355
+ function topicOfOpen(W) {
356
+ if (!W.file || isBundledTemplate(W.file)) return "";
357
+ const rel = path.relative(W.library.root, W.file);
358
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return "";
359
+ const parts = rel.split(/[\\/]/);
360
+ return parts.length > 1 ? parts[0] : "";
361
+ }
362
+ // Links colados na mensagem: o servidor lê sozinho (até 2) e conta nas ações; falha não trava o pedido.
363
+ async function readPastedLinks(W, text, actions) {
364
+ const docs = [];
365
+ for (const u of pastedUrls(text)) {
366
+ try {
367
+ const d = await fetchUrlText(u, { allowLocal: process.env.SAGADECK_CONTEXT_ALLOW_LOCAL === "1" });
368
+ keepMaterial(W, d.name, d.text, d.detail); // guarda para os próximos pedidos
369
+ docs.push({ name: d.name, text: d.text.slice(0, CONTEXT_STORE_CHARS), detail: d.detail });
370
+ actions.push(`Li o link ${u} (${d.detail}, ${d.text.length} caracteres).`);
371
+ } catch (e) { actions.push(`Não consegui ler o link ${u}: ${e.message}`); }
372
+ }
373
+ return docs;
374
+ }
375
+
376
+ // Só a própria página usa o Studio. Ele roda na máquina da pessoa (no banco, dentro da VPN): sem isto,
377
+ // qualquer site aberto no navegador faria fetch para 127.0.0.1 e leria a biblioteca, apagaria decks, gastaria a IA.
378
+ // Por isso não há CORS: nenhum Access-Control-Allow-*, e pedido de outra origem para /api/* leva 403.
379
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
380
+ const hostnameOf = (h) => { try { return new URL(`http://${h}`).hostname; } catch { return ""; } };
381
+ // Escutando só nesta máquina, o Host tem que ser desta máquina: barra o DNS rebinding
382
+ // (malicioso.exemplo resolvendo para 127.0.0.1 tem Origin igual ao Host, mas não é a página do Studio).
383
+ const loopbackOnly = !opts.multiuser && LOOPBACK.has(opts.host);
384
+ function wrongHost(req) {
385
+ if (!loopbackOnly) return false;
386
+ const h = hostnameOf(req.headers.host || "");
387
+ return !(LOOPBACK.has(h) || h.endsWith(".localhost"));
388
+ }
389
+ // Pedido feito por outra página? O navegador manda Origin em todo POST e em todo pedido de outra origem;
390
+ // a própria página, no GET, não manda. "null" (HTML aberto do disco) também é outra página.
391
+ function foreignOrigin(req) {
392
+ const origin = req.headers.origin;
393
+ if (!origin) return false;
394
+ let oh = "";
395
+ try { oh = new URL(origin).host.toLowerCase(); } catch { return true; }
396
+ const own = [req.headers.host];
397
+ // atrás do proxy (BabsDeck) o Host pode ser o do Studio (127.0.0.1:porta); o endereço do portal vem em X-Forwarded-Host
398
+ if (opts.multiuser && req.headers["x-forwarded-host"]) own.push(String(req.headers["x-forwarded-host"]).split(",")[0].trim());
399
+ return !own.some((h) => h && String(h).toLowerCase() === oh);
400
+ }
401
+
402
+ const server = http.createServer(async (req, res) => {
403
+ // Iframe só na própria origem: a apresentação dentro do editor (#pres-frame carrega "preview").
404
+ // Nenhum outro site embute o Studio (clickjacking); CORP same-origin: outro site não carrega nem as capas.
405
+ res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
406
+ res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
407
+
408
+ installErrorResponses(res);
409
+ const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
410
+ const pathname = url.pathname;
411
+
412
+ if (wrongHost(req) || (pathname.startsWith("/api/") && foreignOrigin(req))) {
413
+ res.writeHead(403, { "Content-Type": "application/json; charset=utf-8" });
414
+ res.end(JSON.stringify({ error: "Pedido vindo de outra página: bloqueado." }));
415
+ return;
416
+ }
417
+
418
+ // link de ver (só leitura): o público não tem usuário; o "ver" exige o do portal no multiusuário
419
+ if (shares.serveView(req, res, pathname, opts.multiuser ? String(req.headers[USER_HEADER] || "").trim() : "")) return;
420
+
421
+ let W;
422
+ try { W = workspaceOf(req); } catch (e) {
423
+ res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message })); return;
424
+ }
425
+ if (!W) { // multiusuário sem o cabeçalho do proxy: ninguém autenticado
426
+ res.writeHead(401, { "Content-Type": "application/json" });
427
+ res.end(JSON.stringify({ error: "sessão ausente: entre pelo portal" }));
428
+ return;
429
+ }
430
+
431
+ // sem CORS: o preflight responde, mas não libera nada (o navegador barra o pedido de outra origem)
432
+ if (req.method === "OPTIONS") {
433
+ res.writeHead(204);
434
+ res.end();
435
+ return;
436
+ }
437
+
438
+ if (req.method === "HEAD" && (pathname === "/" || pathname === "/index.html" || pathname === "/editor" || pathname === "/biblioteca")) {
439
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
440
+ res.end();
441
+ return;
442
+ }
443
+
444
+ try {
445
+ // 1. Arquivos estáticos da pasta public e downloads
446
+ if (pathname === "/v1.2.0.patch" || pathname === "/patch") {
447
+ const patchPath = path.join(HERE, "..", "..", "v1.2.0.patch");
448
+ if (fs.existsSync(patchPath)) {
449
+ const patchContent = fs.readFileSync(patchPath);
450
+ res.writeHead(200, {
451
+ "Content-Type": "text/plain; charset=utf-8",
452
+ "Content-Disposition": 'attachment; filename="v1.2.0.patch"',
453
+ });
454
+ res.end(patchContent);
455
+ return;
456
+ }
457
+ }
458
+
459
+ if (pathname === "/sagadeck-v1.2.0.tar.gz" || pathname === "/tarball") {
460
+ const tarPath = path.join(HERE, "..", "..", "sagadeck-v1.2.0.tar.gz");
461
+ if (fs.existsSync(tarPath)) {
462
+ const tarContent = fs.readFileSync(tarPath);
463
+ res.writeHead(200, {
464
+ "Content-Type": "application/gzip",
465
+ "Content-Disposition": 'attachment; filename="sagadeck-v1.2.0.tar.gz"',
466
+ });
467
+ res.end(tarContent);
468
+ return;
469
+ }
470
+ }
471
+
472
+ // "/" = biblioteca; com um deck passado na linha de comando ("sagadeck studio x.yaml"), "/" é o editor dele
473
+ const page = pathname === "/biblioteca" ? "library.html" : pathname === "/editor" || pathname === "/index.html" ? "index.html"
474
+ : pathname === "/" ? (deckPath ? "index.html" : "library.html") : null;
475
+ if (page) {
476
+ const html = fs.readFileSync(path.join(PUBLIC_DIR, page), "utf8");
477
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
478
+ res.end(html);
479
+ return;
480
+ }
481
+ if (["/style.css", "/library.css", "/studio-next.css"].includes(pathname)) {
482
+ const css = fs.readFileSync(path.join(PUBLIC_DIR, pathname.slice(1)), "utf8");
483
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
484
+ res.end(css);
485
+ return;
486
+ }
487
+ if (pathname === "/fonts.css") { // as fontes de todos os temas (embutidas, sem Google), para o editor e as miniaturas
488
+ const index = JSON.parse(fs.readFileSync(path.join(RUNTIME_DIR, "fonts", "index.json"), "utf8"));
489
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8", "Cache-Control": "max-age=86400" });
490
+ res.end(Object.values(index).map((f) => fs.readFileSync(path.join(RUNTIME_DIR, "fonts", f), "utf8")).join("\n"));
491
+ return;
492
+ }
493
+ if (pathname === "/katex.css") {
494
+ res.writeHead(200, {"Content-Type":"text/css; charset=utf-8"});
495
+ res.end(fs.readFileSync(path.join(RUNTIME_DIR,"vendor/katex.css")));return;
496
+ }
497
+ if (pathname === "/decision-lab.js") {
498
+ res.writeHead(200, {"Content-Type":"application/javascript"});
499
+ res.end(fs.readFileSync(path.join(RUNTIME_DIR,"decision-lab.js"))); return;
500
+ }
501
+ if (["/diagram.js", "/mermaid.min.js"].includes(pathname)) {
502
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
503
+ res.end(fs.readFileSync(path.join(RUNTIME_DIR, pathname === "/diagram.js" ? "diagram.js" : "vendor/mermaid.min.js")));
504
+ return;
505
+ }
506
+ if (["/science.js", "/formula.js", "/calc.js", "/plotly.min.js"].includes(pathname)) {
507
+ res.writeHead(200, {"Content-Type":"application/javascript; charset=utf-8"});
508
+ res.end(fs.readFileSync(path.join(RUNTIME_DIR, pathname === "/plotly.min.js" ? "vendor/plotly.min.js" : pathname.slice(1))));
509
+ return;
510
+ }
511
+ if (pathname === "/vendor/pdf.min.mjs" || pathname === "/vendor/pdf.worker.min.mjs") { // leitor de PDF do explorador (sem internet)
512
+ const file = PDFJS_DIR && path.join(PDFJS_DIR, pathname.split("/").pop());
513
+ if (!file || !fs.existsSync(file)) { res.writeHead(404); res.end(); return; }
514
+ res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "max-age=86400" });
515
+ res.end(fs.readFileSync(file));
516
+ return;
517
+ }
518
+ if (pathname === "/csv.js") { // o mesmo leitor de CSV do motor (src/csv.js), para o realce e a planilha
519
+ res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
520
+ res.end(fs.readFileSync(path.join(path.dirname(RUNTIME_DIR), "csv.js")));
521
+ return;
522
+ }
523
+ if (pathname === "/fit.js") { // o mesmo ajuste da apresentação (src/runtime/fit.js)
524
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
525
+ res.end(fs.readFileSync(path.join(RUNTIME_DIR, "fit.js"), "utf8"));
526
+ return;
527
+ }
528
+ if (PUBLIC_SCRIPTS.has(pathname)) {
529
+ const js = fs.readFileSync(path.join(PUBLIC_DIR, pathname.slice(1)), "utf8");
530
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
531
+ res.end(js);
532
+ return;
533
+ }
534
+
535
+ // 2. Visualização Completa (Preview Standalone)
536
+ if (pathname === "/preview") {
537
+ let out;
538
+ try {
539
+ out = buildHTML(W.spec,{audience:url.searchParams.get('audience')||undefined});
540
+ } catch (e) {
541
+ W.lastPreview = { ok: false, error: e.message, warnings: [] };
542
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
543
+ res.end(`Não consegui montar a apresentação: ${e.message}`);
544
+ return;
545
+ }
546
+ // avisos de montagem (ex.: CSS/widget ao lado do YAML que não foi achado) para o Studio mostrar
547
+ W.lastPreview = { ok: true, error: null, warnings: out.warnings.filter((w) => !/palavras \(limite/.test(w)) };
548
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
549
+ res.end(scopePreviewHTML(meetingPreview(out.html,W.spec,url.searchParams.get('audience')),req));
550
+ return;
551
+ }
552
+
553
+ if (pathname === "/api/preview-status") {
554
+ res.writeHead(200, { "Content-Type": "application/json" });
555
+ res.end(JSON.stringify(W.lastPreview));
556
+ return;
557
+ }
558
+
559
+ // 3. API Endpoints
560
+ // Identidades (fontes da empresa, arquivo local ~/.sagadeck/identidades.yaml; ver src/identity.js).
561
+ // Ler vale sempre; criar o modelo e abrir a pasta, só no Studio local (é a máquina da pessoa).
562
+ // Preferências desta máquina (~/.sagadeck/preferencias.json): tela Preferências do Studio
563
+ if (pathname === "/api/preferences") {
564
+ const send = (obj) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
565
+ if (req.method === "GET") return send({ prefs: loadPreferences(), file: preferencesFile(), schema: PREF_SCHEMA });
566
+ if (req.method === "POST") {
567
+ const body = await readJSON(req);
568
+ const prefs = savePreferences(body.prefs || {});
569
+ setFitDefaults(prefs.texto);
570
+ return send({ prefs, file: preferencesFile() });
571
+ }
572
+ }
573
+ if (pathname === "/api/identities" && req.method === "GET") {
574
+ const { loadIdentities } = await import("../identity.js");
575
+ const d = loadIdentities();
576
+ res.writeHead(200, { "Content-Type": "application/json" });
577
+ res.end(JSON.stringify({ file: d.file, exists: d.exists, error: d.error, identities: Object.values(d.identities).map((x) => ({ id: x.id, name: x.name, fonts: x.fonts, palette: typeof x.palette === "string" ? x.palette : x.palette ? "propria" : null })) }));
578
+ return;
579
+ }
580
+ if (await directionRoutes({ req, res, pathname, W, readJSON, apiBlocked })) return;
581
+ if (pathname === "/api/identities/setup" && req.method === "POST") { const blocked = apiBlocked(req);
582
+ if (blocked) { res.writeHead(blocked.code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Configurar identidades só no Studio desta máquina." })); return; }
583
+ const { ensureIdentitiesFile } = await import("../identity.js");
584
+ const file = ensureIdentitiesFile();
585
+ if (process.platform === "win32") spawn("explorer.exe", [`/select,${file}`], { detached: true, stdio: "ignore" }).unref();
586
+ res.writeHead(200, { "Content-Type": "application/json" });
587
+ res.end(JSON.stringify({ file }));
588
+ return;
589
+ }
590
+
591
+ // quem é esta instância (o CLI confere antes de abrir outra na mesma porta)
592
+ if (pathname === "/api/instance" && req.method === "GET") {
593
+ res.writeHead(200, { "Content-Type": "application/json" });
594
+ res.end(JSON.stringify({ app: "sagadeck-studio", ...BUILD, library: libraryRoot, started: STARTED, multiuser: !!opts.multiuser }));
595
+ return;
596
+ }
597
+ if (pathname === "/api/deck" && req.method === "GET") {
598
+ if (W.spec) { ensureUids(W.spec); migrateLegacyKeys(W.spec); } // identidade dos slides e ajustes pelo conteúdo (gravados no próximo salvar)
599
+ const rawYaml = toYaml(W.spec); // sem os campos internos (_dir, _file)
600
+ res.writeHead(200, { "Content-Type": "application/json" });
601
+ res.end(JSON.stringify({
602
+ spec: W.spec,
603
+ yaml: rawYaml,
604
+ file: W.file,
605
+ preview: W.preview && !W.file ? { key: W.preview.key, kind: W.preview.key.replace(/^model-/, ""), title: W.spec?.title, topic: W.preview.topic } : null,
606
+ themes: Object.keys(THEMES),
607
+ // para a galeria de temas: nome curto + cores de fundo, texto e destaque
608
+ themeMeta: Object.fromEntries(Object.entries(THEMES).map(([k, t]) => [k, {
609
+ label: String(t.label || k).split(/\s+[—–-]\s+/)[0],
610
+ desc: String(t.label || "").split(/\s+[—–-]\s+/)[1] || "",
611
+ paper: `#${t.colors.paper}`, ink: `#${t.colors.ink}`, accent: `#${t.colors.accent}`, pair: t.pair || null, dark: !!t.dark,
612
+ }])),
613
+ // paletas (só cores, valem em qualquer tema): para a galeria de paletas
614
+ palettes: Object.fromEntries(Object.entries(PALETTES).map(([k, p]) => [k, { label: p.label, colors: [p.paper, p.ink, p.accent, p.alert, ...(p.family || [])].map((c) => `#${c}`) }])),
615
+ layouts: Object.keys(LAYOUTS),
616
+ }));
617
+ return;
618
+ }
619
+
620
+ if (pathname === "/api/deck" && req.method === "POST") {
621
+ const body = await readJSON(req);
622
+ if (staleTab(body, W, res)) return;
623
+ const before = W.spec;
624
+ if (body.yaml) {
625
+ try {
626
+ const parsed = YAML.parse(body.yaml);
627
+ if (!parsed || !Array.isArray(parsed.slides)) throw new Error('falta a lista "slides:"');
628
+ // o YAML editado não traz os campos internos: mantém a pasta do deck (imagens, CSS, widgets)
629
+ const keep = body.source === "browser-file" ? {} : Object.fromEntries(Object.entries(W.spec || {}).filter(([k]) => k.startsWith("_")));
630
+ W.spec = { ...parsed, ...keep };
631
+ W.yamlText = body.yaml;
632
+ } catch (e) {
633
+ res.writeHead(400, { "Content-Type": "application/json" });
634
+ res.end(JSON.stringify({ error: "YAML inválido: " + e.message }));
635
+ return;
636
+ }
637
+ } else if (body.spec) {
638
+ W.spec = body.spec;
639
+ }
640
+ if (before && W.spec && before !== W.spec) carryVisualEdits(before, W.spec); // texto mudou: o ajuste vai junto
641
+ if (body.filepath) {
642
+ W.file = path.resolve(body.filepath);
643
+ } else if (body.source === "browser-file") {
644
+ // Deck aberto pelo navegador (seletor/arrastar): o servidor não sabe o caminho dele.
645
+ // Esquece o arquivo anterior — senão as edições deste deck iam parar por cima daquele.
646
+ W.file = null;
647
+ }
648
+ if (body.filepath || body.source === "browser-file") W.preview = null;
649
+ const materialized = body.saveToFile !== false ? materializePreview(W) : null;
650
+ if (body.saveToFile !== false) persist(W, body.yaml && body.source !== "browser-file" ? W.yamlText : null);
651
+ W.yamlText = null;
652
+ res.writeHead(200, { "Content-Type": "application/json" });
653
+ res.end(JSON.stringify({ ok: true, spec: W.spec, file: W.file, materialized }));
654
+ return;
655
+ }
656
+
657
+ if (pathname === "/api/open-file" && req.method === "POST") {
658
+ const body = await readJSON(req);
659
+ if (!body.path) {
660
+ res.writeHead(400, { "Content-Type": "application/json" });
661
+ res.end(JSON.stringify({ error: "Caminho do arquivo não informado" }));
662
+ return;
663
+ }
664
+ const targetPath = path.resolve(body.path);
665
+ if (!fs.existsSync(targetPath)) {
666
+ res.writeHead(404, { "Content-Type": "application/json" });
667
+ res.end(JSON.stringify({ error: `Arquivo não encontrado: ${targetPath}` }));
668
+ return;
669
+ }
670
+ try {
671
+ W.spec = loadSpec(targetPath);
672
+ W.file = targetPath;
673
+ const rawYaml = toYaml(W.spec);
674
+ res.writeHead(200, { "Content-Type": "application/json" });
675
+ res.end(JSON.stringify({
676
+ ok: true,
677
+ spec: W.spec,
678
+ yaml: rawYaml,
679
+ file: W.file,
680
+ }));
681
+ } catch (e) {
682
+ res.writeHead(500, { "Content-Type": "application/json" });
683
+ res.end(JSON.stringify({ error: `Erro ao carregar YAML: ${e.message}` }));
684
+ }
685
+ return;
686
+ }
687
+
688
+ // YAML de um slide só (gaveta de YAML em "Slide atual")
689
+ // "Gerar imagem agora": transforma os image_prompt de UM slide em imagens de verdade
690
+ if (pathname === "/api/ai/slide-images" && req.method === "POST") {
691
+ const body = await readJSON(req);
692
+ const i = Number(body.index);
693
+ const slide = W.spec?.slides?.[i];
694
+ const send = (code, obj) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(obj)); };
695
+ if (!slide) return send(404, { error: "slide não existe" });
696
+ try {
697
+ const wrapper = { ...withBase(W, W.spec), slides: [slide] };
698
+ const r = await materializeImages(wrapper, { ...imageOptions(W, wrapper), keepFailed: true });
699
+ persist(W);
700
+ send(r.done.length || !r.failed.length ? 200 : 502, { ok: !!r.done.length, done: r.done.length, failed: r.failed, error: r.failed[0]?.error, spec: W.spec });
701
+ } catch (e) {
702
+ send(500, { error: e.message });
703
+ }
704
+ return;
705
+ }
706
+
707
+ if (await codeRoutes({req,res,pathname,W,readJSON,commandsAllowed,commandEnv:(W) => commandEnv(W, apiEnv),isBundledTemplate})) return;
708
+ if (await versionRoutes({req,res,pathname,W,readJSON})) return;
709
+ if (await meetingRoutes({req,res,pathname,W,readJSON,persist})) return;
710
+ if (await styleRoutes({ req, res, pathname, W, persist, readJSON })) return;
711
+ if (await shares.api({ req, res, pathname, W, readJSON })) return;
712
+ if (pathname === "/api/aspect" && req.method === "POST") {
713
+ const b = await readJSON(req);
714
+ if (!parseAspect(b.aspect)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `Proporção "${b.aspect}" não entendida. Use 16:9, 4:3, 1:1, 9:16 ou largura:altura.` })); return; }
715
+ W.spec = convertAspect(W.spec, String(b.aspect) === "16:9" ? undefined : b.aspect);
716
+ if (W.spec.aspect === undefined) delete W.spec.aspect;
717
+ persist(W);
718
+ res.writeHead(200, { "Content-Type": "application/json" });
719
+ res.end(JSON.stringify({ ok: true, spec: W.spec, label: slideSize(W.spec).label }));
720
+ return;
721
+ }
722
+ if (pathname === "/api/slide-yaml" && req.method === "GET") {
723
+ const i = Number(url.searchParams.get("i"));
724
+ const slide = W.spec?.slides?.[i];
725
+ res.writeHead(slide ? 200 : 404, { "Content-Type": "application/json" });
726
+ res.end(JSON.stringify(slide ? { yaml: YAML.stringify(slide, { indent: 2 }) } : { error: "slide não existe" }));
727
+ return;
728
+ }
729
+ if (pathname === "/api/slide-yaml" && req.method === "POST") {
730
+ const body = await readJSON(req);
731
+ if (staleTab(body, W, res)) return;
732
+ const i = Number(body.index);
733
+ let slide;
734
+ try {
735
+ let raw = YAML.parse(body.yaml || "");
736
+ if (Array.isArray(raw)) raw = raw[0];
737
+ if (!raw || typeof raw !== "object") throw new Error("o slide precisa ser um objeto (ex.: layout: statement)");
738
+ slide = normalizeSpec({ slides: [raw] }).slides[0];
739
+ renderSlide(slide, i, W.spec); // valida: layout existe, elementos reconhecidos
740
+ } catch (e) {
741
+ res.writeHead(400, { "Content-Type": "application/json" });
742
+ res.end(JSON.stringify({ error: e.message }));
743
+ return;
744
+ }
745
+ W.spec.slides[i] = slide;
746
+ persist(W);
747
+ res.writeHead(200, { "Content-Type": "application/json" });
748
+ res.end(JSON.stringify({ ok: true, spec: W.spec, file: W.file }));
749
+ return;
750
+ }
751
+
752
+ if (pathname === "/api/experiences" && req.method === "GET") {
753
+ res.writeHead(200, { "Content-Type": "application/json" });
754
+ res.end(JSON.stringify({ experiences: EXPERIENCES }));
755
+ return;
756
+ }
757
+ if (pathname === "/api/layout-sample" && req.method === "GET") {
758
+ const { LAYOUT_SAMPLES } = await import("./layout-samples.js");
759
+ const name = url.searchParams.get("layout");
760
+ const sample = Object.hasOwn(LAYOUT_SAMPLES, name) ? LAYOUT_SAMPLES[name] : null;
761
+ res.writeHead(sample ? 200 : 404, { "Content-Type": "application/json" });
762
+ res.end(JSON.stringify(sample ? { slide: sample } : { error: "Cena não encontrada" }));
763
+ return;
764
+ }
765
+
766
+ // Galeria de layouts: um exemplo de cada, desenhado no tema do deck (cache por tema)
767
+ if (pathname === "/api/layout-previews") {
768
+ const { LAYOUT_INFO, LAYOUT_SAMPLES } = await import("./layout-samples.js");
769
+ const theme = W.spec?.theme || "sinal";
770
+ const identity = W.spec?.identity, palette = W.spec?.palette;
771
+ const key = `${theme}|${palette || ""}|${identity || ""}|${W.spec?.markStyle || ""}`;
772
+ if (!layoutPreviewCache.has(key)) {
773
+ const spec = { theme, palette, identity, markStyle: W.spec?.markStyle, title: "", footer: false, slides: [] };
774
+ const out = {};
775
+ for (const [name, sample] of Object.entries(LAYOUT_SAMPLES)) {
776
+ try { out[name] = renderSlide(sample, 0, spec).html; } catch (e) { out[name] = ""; }
777
+ }
778
+ layoutPreviewCache.set(key, out);
779
+ }
780
+ const r = renderSlide({ layout: "statement", text: "x" }, 0, { theme, palette, identity });
781
+ res.writeHead(200, { "Content-Type": "application/json" });
782
+ res.end(JSON.stringify({ info: LAYOUT_INFO, html: layoutPreviewCache.get(key), baseCSS: r.baseCSS, themeCSS: r.themeCSS }));
783
+ return;
784
+ }
785
+
786
+ if (pathname === "/api/render-slide" && req.method === "POST") {
787
+ const { slide, index, spec } = await readJSON(req);
788
+ const deckSpec = spec || W.spec;
789
+ const rendered = renderSlide(slide, index ?? 0, deckSpec);
790
+ res.writeHead(200, { "Content-Type": "application/json" });
791
+ res.end(JSON.stringify(rendered));
792
+ return;
793
+ }
794
+
795
+ if (pathname === "/api/icons" && req.method === "GET") {
796
+ const q = url.searchParams.get("q") || "";
797
+ const limit = Math.min(Number(url.searchParams.get("limit") || 80), 150);
798
+ const icons = listIcons(q).slice(0, limit);
799
+ const { iconSVG } = await import("../figures/icons.js");
800
+ const results = icons.map((name) => {
801
+ try {
802
+ return { name, svg: iconSVG(name, { size: 32 }) };
803
+ } catch {
804
+ return { name, svg: "" };
805
+ }
806
+ });
807
+ res.writeHead(200, { "Content-Type": "application/json" });
808
+ res.end(JSON.stringify(results));
809
+ return;
810
+ }
811
+
812
+ // variedade do deck (painel Ritmo)
813
+ if (pathname === "/api/variety" && req.method === "POST") {
814
+ const body = await readJSON(req);
815
+ res.writeHead(200, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify(varietyReport(body.spec || W.spec)));
817
+ return;
818
+ }
819
+
820
+ if (pathname === "/api/napkin-examples" && req.method === "GET") {
821
+ const { NAPKIN_EXAMPLES } = await import("../diagram/napkin-examples.js");
822
+ res.writeHead(200, { "Content-Type": "application/json" });
823
+ res.end(JSON.stringify(NAPKIN_EXAMPLES));
824
+ return;
825
+ }
826
+
827
+ if (pathname === "/api/napkin" && req.method === "POST") {
828
+ const body = await readJSON(req);
829
+ const { textToVisualSlide, textToVisualDeck } = await import("../diagram/napkin.js");
830
+ const YAML = (await import("yaml")).default;
831
+ const opts = { theme: body.theme, tone: body.tone, title: body.title, kicker: body.kicker, layout: body.layout };
832
+ let result = null;
833
+ let mode = "rules";
834
+ let notice = "";
835
+ if (body.mode !== "rules" && body.text && await llmAvailable()) {
836
+ try {
837
+ result = await textToSlide(body.text, { ...opts, images: true, imageOptions: imageOptions(W, withBase(W, W.spec)), drawCheck: diagramCheck });
838
+ mode = "llm";
839
+ } catch (e) {
840
+ notice = `A IA falhou (${e.message}); usei as regras locais.`;
841
+ }
842
+ }
843
+ if (!result) result = textToVisualSlide(body.text || "", opts);
844
+ const fullDeck = textToVisualDeck(body.text || "");
845
+ res.writeHead(200, { "Content-Type": "application/json" });
846
+ res.end(JSON.stringify({
847
+ ok: true,
848
+ slide: result.slide,
849
+ detectedType: result.detectedType,
850
+ confidence: result.confidence,
851
+ rationale: result.rationale,
852
+ mode,
853
+ notice,
854
+ yaml: YAML.stringify(result.slide, { indent: 2 }),
855
+ deckYaml: YAML.stringify(fullDeck, { indent: 2 }),
856
+ }));
857
+ return;
858
+ }
859
+
860
+ if (pathname === "/api/autofix" && req.method === "POST") {
861
+ const { slideIndex, spec, issues } = await readJSON(req);
862
+ const targetSpec = spec || W.spec;
863
+ if (typeof slideIndex === "number" && targetSpec.slides[slideIndex]) {
864
+ const resFix = autofixSlide(targetSpec.slides[slideIndex], targetSpec, issues || []);
865
+ targetSpec.slides[slideIndex] = resFix.slide;
866
+ W.spec = targetSpec;
867
+ persist(W);
868
+ res.writeHead(200, { "Content-Type": "application/json" });
869
+ res.end(JSON.stringify({ ok: true, slide: resFix.slide, actions: resFix.actions, spec: targetSpec }));
870
+ } else {
871
+ const resDeck = autofixDeck(targetSpec, issues || []);
872
+ W.spec = resDeck.spec;
873
+ persist(W);
874
+ res.writeHead(200, { "Content-Type": "application/json" });
875
+ res.end(JSON.stringify({ ok: true, ...resDeck }));
876
+ }
877
+ return;
878
+ }
879
+
880
+ if (pathname === "/api/search") {
881
+ const query = url.searchParams.get("q") || "";
882
+ const limit = Number(url.searchParams.get("limit") || 5);
883
+ if (!query) {
884
+ res.writeHead(400, { "Content-Type": "application/json" });
885
+ res.end(JSON.stringify({ error: "Parâmetro 'q' é obrigatório" }));
886
+ return;
887
+ }
888
+ try {
889
+ const { searchDuckDuckGo } = await import("../research/duckduckgo.js");
890
+ const results = await searchDuckDuckGo(query, { limit });
891
+ res.writeHead(200, { "Content-Type": "application/json" });
892
+ res.end(JSON.stringify({ query, count: results.length, results }));
893
+ } catch (err) {
894
+ res.writeHead(500, { "Content-Type": "application/json" });
895
+ res.end(JSON.stringify({ error: err.message, query, results: [] }));
896
+ }
897
+ return;
898
+ }
899
+
900
+ // Tela de configuração da IA: é do modelrelay (vale para todos os apps). Só aparece quando ele
901
+ // roda nesta máquina e tem a tela; no multiusuário quem configura é o admin, pelo portal.
902
+ if (pathname === "/api/ai/setup" && req.method === "GET") {
903
+ let setup = null;
904
+ const base = llmConfig().url.replace(/\/v1$/, "");
905
+ if (!opts.multiuser && /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(base)) {
906
+ try {
907
+ const r = await fetch(base + "/api/console/config", { signal: AbortSignal.timeout(1500) });
908
+ if (r.ok) setup = base + "/";
909
+ } catch {}
910
+ }
911
+ res.writeHead(200, { "Content-Type": "application/json" });
912
+ res.end(JSON.stringify({ url: setup }));
913
+ return;
914
+ }
915
+
916
+ if (pathname === "/api/ai/status" && req.method === "GET") {
917
+ const cfg = llmConfig();
918
+ const available = await llmAvailable({ force: url.searchParams.has("refresh") });
919
+ res.writeHead(200, { "Content-Type": "application/json" });
920
+ res.end(JSON.stringify({ available, url: cfg.url, textModel: cfg.textModel, imageModel: cfg.imageModel }));
921
+ return;
922
+ }
923
+
924
+ // Materiais de contexto: a pessoa anexa arquivo (dataUrl) ou link; o servidor extrai o texto
925
+ // e devolve um id. O texto fica na sessão; o chat e a geração mandam os ids.
926
+ if (pathname === "/api/ai/context" && req.method === "POST") {
927
+ const body = await readJSON(req);
928
+ try {
929
+ let doc;
930
+ if (body.url) {
931
+ doc = await fetchUrlText(String(body.url), { allowLocal: process.env.SAGADECK_CONTEXT_ALLOW_LOCAL === "1" });
932
+ } else if (body.name && body.dataUrl) {
933
+ const m = String(body.dataUrl).match(/^data:([^;]+);base64,([\s\S]*)$/);
934
+ if (!m) throw new Error("Anexo inválido.");
935
+ const fname = String(body.name).split(/[\\/]/).pop();
936
+ const bytes = Buffer.from(m[2], "base64");
937
+ doc = { ...(await extractDocText(fname, bytes)), name: fname, bytes };
938
+ } else throw new Error("Mande { name, dataUrl } ou { url }.");
939
+ if (!doc.text.trim()) {
940
+ if (doc.bytes && /\.(pdf|docx)$/i.test(doc.name)) doc.text = 'Documento sem texto extraível: examine o inventário visual; não invente conteúdo.';
941
+ else throw new Error("Não achei texto legível no material.");
942
+ }
943
+ const kept = keepMaterial(W, doc.name, doc.text, doc.detail, doc.bytes);
944
+ res.writeHead(200, { "Content-Type": "application/json" });
945
+ res.end(JSON.stringify({ ...kept, preview: doc.text.slice(0, 200) }));
946
+ } catch (e) {
947
+ res.writeHead(400, { "Content-Type": "application/json" });
948
+ res.end(JSON.stringify({ error: e.message }));
949
+ }
950
+ return;
951
+ }
952
+
953
+ if (pathname === "/api/ai/generate" && req.method === "POST") {
954
+ const body = await readJSON(req);
955
+ if (!String(body.briefing || "").trim()) {
956
+ res.writeHead(400, { "Content-Type": "application/json" });
957
+ res.end(JSON.stringify({ error: "Descreva a apresentação (briefing)." }));
958
+ return;
959
+ }
960
+ if (!(await llmAvailable({ force: true }))) {
961
+ res.writeHead(503, { "Content-Type": "application/json" });
962
+ res.end(JSON.stringify({ error: `Nenhum LLM respondendo em ${llmConfig().url}. Rode "modelrelay serve" ou ajuste SAGADECK_LLM_URL.` }));
963
+ return;
964
+ }
965
+ // O deck novo vai para uma pasta própria na biblioteca (regra do CLAUDE.md), no tópico do deck aberto
966
+ await respond(res, body.stream, async (emit) => {
967
+ const r = await generateIntoLibrary(W, topicOfOpen(W), body, emit);
968
+ if (r.question) return { ok: true, question: r.question }; // a IA perguntou para que serve o material
969
+ W.file = r.file;
970
+ W.spec = loadSpec(r.file);
971
+ return { ok: true, spec: W.spec, file: W.file, id: r.id, images: r.images, research: r.research, quality: r.quality, variety: r.variety };
972
+ });
973
+ return;
974
+ }
975
+
976
+ // Projeto (a pasta da apresentação, como no VS Code): árvore, arquivos, planilhas e sugestões de gráfico
977
+ if (pathname.startsWith("/api/project/")) {
978
+ const done = (obj = {}, code = 200) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify(code === 200 ? { ok: true, ...obj } : obj)); };
979
+ try {
980
+ const P = W.file && !isBundledTemplate(W.file) ? Project.projectOf(W.file) : null;
981
+ if (!P) return done({ error: W.preview ? "Modelo em prévia: a pasta do projeto nasce na primeira mudança." : "Esta apresentação não tem uma pasta só dela (abra pela biblioteca)." }, 409);
982
+ if (pathname === "/api/project/tree" && req.method === "GET") return done(Project.tree(P));
983
+ if (pathname === "/api/project/file" && (req.method === "GET" || req.method === "HEAD")) {
984
+ const abs = Project.resolveIn(P, url.searchParams.get("path"));
985
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return done({ error: "Arquivo não existe." }, 404);
986
+ res.writeHead(200, { "Content-Type": mimeOf(abs) || "application/octet-stream", "Cache-Control": "no-store" });
987
+ res.end(req.method === "HEAD" ? undefined : fs.readFileSync(abs));
988
+ return;
989
+ }
990
+ if (pathname === "/api/project/sheet" && req.method === "GET") {
991
+ const rel = url.searchParams.get("path");
992
+ const { sheets, editable, delimiter } = await Project.readSheet(Project.resolveIn(P, rel));
993
+ const all = url.searchParams.get("all") === "1";
994
+ return done({ editable: !!editable, delimiter, sheets: sheets.map((sh) => { const info = Project.inferColumns(sh.rows); return { name: sh.name, rows: all ? sh.rows.slice(0, 20000) : sh.rows.slice(0, 1000), total: sh.rows.length, columns: info.columns, hasHeader: info.hasHeader }; }) });
995
+ }
996
+ if (pathname === "/api/project/docx" && req.method === "GET") {
997
+ const abs = Project.resolveIn(P, url.searchParams.get("path"));
998
+ if (!/\.docx$/i.test(abs) || !fs.existsSync(abs)) return done({ error: "Arquivo .docx não existe." }, 404);
999
+ return done({ html: await docxToHtml(fs.readFileSync(abs)) });
1000
+ }
1001
+ if (req.method !== "POST") return done({ error: "método não suportado" }, 405);
1002
+ const b = await readJSON(req);
1003
+ switch (pathname) {
1004
+ case "/api/project/write": return done(Project.writeText(P, b.path, b.text));
1005
+ case "/api/project/sheet-write": return done(Project.writeSheet(P, b.path, b.rows));
1006
+ case "/api/project/create": return done(Project.createFile(P, b.dir || "", b.name, b.text || ""));
1007
+ case "/api/project/mkdir": return done(Project.mkdir(P, b.dir || "", b.name));
1008
+ case "/api/project/rename": return done(Project.rename(P, b.path, b.name));
1009
+ case "/api/project/move": return done(Project.move(P, b.path, b.dir || ""));
1010
+ case "/api/project/delete": return done(Project.remove(P, b.path));
1011
+ case "/api/project/restore": return done(Project.restore(P, b.path));
1012
+ case "/api/project/upload": {
1013
+ const m = String(b.dataUrl || "").match(/^data:([^;,]*)(;base64)?,([\s\S]*)$/);
1014
+ if (!m) throw new Error("Arquivo inválido.");
1015
+ const buf = m[2] ? Buffer.from(m[3], "base64") : Buffer.from(decodeURIComponent(m[3]), "utf8");
1016
+ return done(Project.upload(P, b.dir ?? Project.CONTEXT, b.name, buf));
1017
+ }
1018
+ case "/api/project/chart-suggestions": {
1019
+ const { sheets } = await Project.readSheet(Project.resolveIn(P, b.path));
1020
+ const sh = sheets.find((x) => x.name === b.sheet) || sheets[0];
1021
+ const info = Project.inferColumns(sh.rows);
1022
+ const where = { file: b.path, ...(sheets.length > 1 ? { sheet: sh.name } : {}) };
1023
+ let list = Project.suggestCharts(info, where), ai = null;
1024
+ if (b.ai) {
1025
+ // a IA classifica as colunas e escolhe os gráficos que respondem a perguntas de verdade, com título e eixos
1026
+ if (!(await llmAvailable({ force: true }))) throw new Error(`A IA está desligada (nenhum LLM em ${llmConfig().url}).`);
1027
+ const r = await llmChat([
1028
+ { role: "system", content: `Você é analista de dados e monta gráficos para slides. Recebe as colunas de uma planilha (nome, tipo visto pelos valores, exemplos e faixa). Corrija o tipo quando o nome mostrar outra coisa (ex.: "ano" é tempo; "código" é categoria, não número) e proponha de 2 a 4 gráficos que respondam a perguntas úteis. Tipos: line (tendência no tempo), bar (comparar categorias, ordenado), column (poucas categorias ou várias séries lado a lado), donut (partes de um todo, até 6), scatter (relação entre dois números). Título: a conclusão ou a pergunta, curto, em português. Eixos: nomes claros com unidade quando der.
1029
+ Responda só com JSON: {"colunas": [{"nome": "…", "tipo": "tempo|categoria|numero|porcentagem|texto"}], "graficos": [{"tipo": "line|bar|column|donut|scatter", "x": "coluna", "y": ["coluna"], "titulo": "…", "eixoX": "…", "eixoY": "…", "porque": "…"}]}` },
1030
+ { role: "user", content: `Planilha "${b.path}"${sh.name ? `, aba "${sh.name}"` : ""} (${info.rows.length} linhas):\n${Project.columnsSummary(info)}` },
1031
+ ], { temperature: 0.2 });
1032
+ const j = JSON.parse(String(r.text || "").match(/\{[\s\S]*\}/)?.[0] || "{}");
1033
+ ai = { columns: Array.isArray(j.colunas) ? j.colunas : [] };
1034
+ const made = (Array.isArray(j.graficos) ? j.graficos : []).slice(0, 4).map((g, i) => {
1035
+ try { const slide = Project.buildChart(info, { type: g.tipo, x: g.x, ys: g.y, title: g.titulo, xLabel: g.eixoX, yLabel: g.eixoY }, where); return { id: `ia-${i + 1}`, title: slide.title, why: String(g.porque || ""), slide, ai: true }; }
1036
+ catch { return null; }
1037
+ }).filter(Boolean);
1038
+ if (made.length) list = [...made, ...list.filter((x) => !made.some((m) => m.slide.chart?.chart === x.slide.chart?.chart && m.slide.layout === x.slide.layout))];
1039
+ }
1040
+ // prévia de cada sugestão: o slide desenhado com o tema do deck
1041
+ const deck = withBase(W, W.spec);
1042
+ const previews = list.slice(0, 6).map((x) => { try { return { ...x, html: renderSlide(x.slide, 0, deck).html }; } catch (e) { return { ...x, error: e.message }; } });
1043
+ return done({ suggestions: previews, columns: info.columns, ai });
1044
+ }
1045
+ case "/api/project/refresh-chart": {
1046
+ // gráfico que veio de planilha (from:): relê o arquivo e troca os dados, mantendo o resto do slide
1047
+ const i = Number(b.index), slide = W.spec.slides[i];
1048
+ if (!slide?.from?.file) throw new Error("Este slide não veio de uma planilha.");
1049
+ const { sheets } = await Project.readSheet(Project.resolveIn(P, slide.from.file));
1050
+ const sh = sheets.find((x) => x.name === slide.from.sheet) || sheets[0];
1051
+ const [x, ...ys] = slide.from.columns || [];
1052
+ const type = slide.layout === "science" ? "scatter" : slide.from.type || slide.chart?.chart;
1053
+ const fresh = Project.buildChart(Project.inferColumns(sh.rows), { type, x, ys, title: slide.title, xLabel: slide.chart?.xLabel, yLabel: slide.chart?.yLabel }, { file: slide.from.file, sheet: slide.from.sheet });
1054
+ const next = slide.layout === "science" ? { ...slide, plot: { ...slide.plot, points: fresh.plot.points, x: fresh.plot.x } } : { ...slide, chart: { ...slide.chart, ...fresh.chart } };
1055
+ if (next.chart?.series) { delete next.chart.data; delete next.chart.parts; } else if (next.chart?.data) { delete next.chart.labels; delete next.chart.series; }
1056
+ W.spec.slides[i] = next;
1057
+ persist(W);
1058
+ return done({ slide: next });
1059
+ }
1060
+ }
1061
+ return done({ error: "rota do projeto desconhecida" }, 404);
1062
+ } catch (e) {
1063
+ return done({ error: e.message }, 400);
1064
+ }
1065
+ }
1066
+
1067
+ // Conversa do chat: uma por apresentação, num arquivo ao lado do deck (<deck>.conversa.json). Deck sem arquivo
1068
+ // (aberto pelo navegador, exemplo embutido): só na memória desta sessão.
1069
+ if (pathname === "/api/chat/history") {
1070
+ const P = W.file && !isBundledTemplate(W.file) ? Project.projectOf(W.file) : null;
1071
+ const file = P ? Project.conversationFile(P) : W.file && !isBundledTemplate(W.file) ? W.file.replace(/\.ya?ml$/i, ".conversa.json") : null;
1072
+ if (req.method === "GET") {
1073
+ let history = W.chatHistory || [];
1074
+ if (file) try { history = JSON.parse(fs.readFileSync(file, "utf8")).history || []; } catch { history = []; }
1075
+ res.writeHead(200, { "Content-Type": "application/json" });
1076
+ res.end(JSON.stringify({ history }));
1077
+ return;
1078
+ }
1079
+ if (req.method === "POST") {
1080
+ const body = await readJSON(req);
1081
+ const history = (Array.isArray(body.history) ? body.history : []).filter((m) => m && typeof m.text === "string")
1082
+ .slice(-400).map((m) => ({ role: m.role === "user" ? "user" : "assistant", text: m.text.slice(0, 8000), ...(m.talk ? { talk: true } : {}) }));
1083
+ W.chatHistory = history;
1084
+ if (file) try { const tmp = `${file}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify({ history }, null, 1)); fs.renameSync(tmp, file); } catch (e) { console.error("[Studio] conversa:", e.message); }
1085
+ res.writeHead(200, { "Content-Type": "application/json" });
1086
+ res.end(JSON.stringify({ ok: true }));
1087
+ return;
1088
+ }
1089
+ }
1090
+
1091
+ // A pessoa responde a um pedido de comando da IA (ver commandRunner): run | always | deny
1092
+ if (pathname === "/api/ai/approve" && req.method === "POST") {
1093
+ const body = await readJSON(req);
1094
+ const allowed = commandsAllowed(req, W);
1095
+ // só quem fez o pedido responde (no servidor, outra pessoa não aprova o comando de ninguém)
1096
+ const pending = allowed && approvals.get(String(body.id || ""));
1097
+ const mine = pending && pending.user === (W.user || "");
1098
+ res.writeHead(!allowed ? 403 : mine ? 200 : 404, { "Content-Type": "application/json" });
1099
+ if (mine) pending.answer(["run", "always"].includes(body.decision) ? body.decision : "deny");
1100
+ res.end(JSON.stringify(!allowed ? { error: "Comandos não estão liberados para você aqui." } : { ok: !!mine }));
1101
+ return;
1102
+ }
1103
+
1104
+ // melhorar / recriar a apresentação importada (src/ai/transform.js), a pedido do chat. A tarefa segue no servidor
1105
+ // mesmo se o navegador fechar (a página reaberta acompanha por /api/ai/transform/status); pedir de novo retoma.
1106
+ const transformLimits = () => ({ calls: Number(process.env.SAGADECK_TRANSFORM_CALLS) || 0, tokens: Number(process.env.SAGADECK_TRANSFORM_TOKENS) || 0, minutes: Number(process.env.SAGADECK_TRANSFORM_MINUTES) || 0 });
1107
+ async function runTransform(W, spec, result, emit, request, visualReferences = [], signal) {
1108
+ if (!W.file || isBundledTemplate(W.file)) return { ...result, reply: "Para transformar, a apresentação precisa estar salva na biblioteca.", spec, talk: true };
1109
+ // o deck da tarefa fica guardado: a pessoa pode abrir outra apresentação durante os minutos de trabalho, e o
1110
+ // resultado tem de ir para ESTE arquivo (W.file passa a ser o outro)
1111
+ const file = W.file;
1112
+ const dir = path.dirname(file);
1113
+ const { mode, pedido } = result.transform;
1114
+ const key = `${file}|${mode}`;
1115
+ if (transforms.has(key)) return { ...result, reply: `Já estou ${mode === "melhorar" ? "melhorando" : "recriando"} esta apresentação; o andamento aparece aqui no chat.`, spec, talk: true };
1116
+ const job = { controller: new AbortController(), mode, progress: null, started: Date.now() };
1117
+ transforms.set(key, job);
1118
+ const abort = () => job.controller.abort();
1119
+ signal?.addEventListener("abort", abort, { once: true });
1120
+ const tell = (ev) => { job.progress = ev; try { emit(ev); } catch {} };
1121
+ // retomar o melhorar: a fonte é o original importado (o deck pode já ter a parte pronta aplicada)
1122
+ const origFile = path.join(dir, "original", "original.yaml");
1123
+ let source = spec;
1124
+ const saved = jobStatus(dir, mode);
1125
+ if (mode === "melhorar" && saved && saved.status !== "concluido" && fs.existsSync(origFile)) {
1126
+ try { const o = YAML.parse(fs.readFileSync(origFile, "utf8")); if (sourceHash(o) === saved.srcHash) source = { ...o, _dir: dir }; } catch {}
1127
+ }
1128
+ tell({ phase: "transform", text: mode === "melhorar" ? "Vou melhorar a apresentação em etapas…" : "Vou recriar a apresentação em etapas…", mode });
1129
+ let t;
1130
+ try { t = await transformDeck({ spec: source, dir, mode, request: request || pedido, visualReferences, onProgress: (ev) => tell({ ...ev, mode }), signal: job.controller.signal, limits: transformLimits() }); }
1131
+ catch (e) {
1132
+ if (e.kind) return { ...result, reply: `Parei antes de terminar o plano (${e.message}). Peça de novo para continuar de onde parou.`, spec, talk: true };
1133
+ console.error("[Studio] transformação:", e.message);
1134
+ return { ...result, reply: `A transformação parou por um erro (${e.message}). Nada mudou na apresentação; o que ficou pronto está guardado e pedir de novo continua de onde parou.`, spec, talk: true };
1135
+ } finally { transforms.delete(key); signal?.removeEventListener("abort", abort); }
1136
+ const r = t.report;
1137
+ if (r.status === "parcial" && job.controller.signal.aborted) {
1138
+ return { ...result, reply: `Parei a pedido. O que já estava pronto (${jobStatus(dir, mode)?.feitos || 0} de ${t.plan.slides.length} itens) ficou guardado: peça de novo para continuar de onde parou.`, spec, talk: true, transformReport: r };
1139
+ }
1140
+ const count = (k) => t.plan.slides.filter((it) => it.acao === k).length;
1141
+ const cost = Object.entries(r.usage).map(([m, u]) => `${m}: ${u.calls} chamada(s), ${Math.round(u.in / 1000)} mil tokens de entrada e ${Math.round(u.out / 1000)} mil de saída`).join("; ");
1142
+ const STATUS = { concluido: "Concluído.", revisar: "Concluído, com pontos para você revisar.", parcial: `Parcial (${r.parou}): o que não chegou a ser feito ficou como no original; peça de novo para continuar de onde parou.` };
1143
+ const lines = [
1144
+ `${STATUS[r.status]}${r.retomada ? " (retomei de onde tinha parado)" : ""}`,
1145
+ `${t.spec.slides.length} slides a partir dos ${(source.slides || []).filter((s) => s.original).length} do original (${count("manter")} mantidos, ${count("juntar")} juntados, ${count("escrever")} reescritos, ${count("novo")} novos) em ${Math.round(r.seconds / 60)} min.`,
1146
+ ...(r.alertas.length ? [`Possíveis erros no original (para você conferir): ${r.alertas.join(" · ")}`] : []),
1147
+ ...(r.pendentes.length ? [`${r.pendentes.length} proposta(s) pendente(s): o original ficou ao lado, porque faltava algo dele (${r.pendentes.join(" · ")})`] : []),
1148
+ ...(r.revisar.length ? [`${r.revisar.length} ponto(s) de desenho para conferir: ${r.revisar.join(" · ")}`] : []),
1149
+ ...(r.problemas.length ? [`Problemas: ${r.problemas.join(" · ")}`] : []),
1150
+ `Onde foi parar cada trecho do original: ${r.cobertura.arquivo} (${r.cobertura.localizados} de ${r.cobertura.trechos} trechos encontrados como estavam; os outros foram reescritos).`,
1151
+ `Modelos: ${cost}.`,
1152
+ ];
1153
+ if (mode === "melhorar") {
1154
+ if (!fs.existsSync(origFile)) { fs.mkdirSync(path.dirname(origFile), { recursive: true }); fs.writeFileSync(origFile, YAML.stringify(spec, { lineWidth: 0 })); }
1155
+ // o estilo do original pode virar um estilo salvo (Brand Kit): a resposta oferece o botão
1156
+ const offerStyle = { name: String(spec.import?.from || spec.title || "Estilo").replace(/\.[a-z]+$/i, "").slice(0, 80) };
1157
+ if (W.file === file) { W.spec = { ...t.spec, _dir: dir }; persist(W); }
1158
+ else { writeDeckFile(file, t.spec); return { ...result, reply: `Pronto: "${W.library.idOf(file)}" foi melhorada (você está em outra apresentação agora). Cada slide mudado está marcado para validar.\n${lines.join("\n")}`, spec: W.spec, talk: true, transformReport: r }; }
1159
+ return { ...result, reply: `Pronto. Cada slide mudado está marcado (Revisar › Mudanças) para você validar.\n${lines.join("\n")}`, spec: W.spec, actions: [...(result.actions || [])], transformReport: r, offerStyle };
1160
+ }
1161
+ // recriar: apresentação nova no mesmo tópico, com as imagens que ela usa. A retomada grava na MESMA
1162
+ // apresentação nova (não cria outra a cada pedaço).
1163
+ const side = path.join(dir, ".sagadeck", "transform", "recriar-deck.json");
1164
+ let prev = null;
1165
+ try { prev = JSON.parse(fs.readFileSync(side, "utf8")); } catch {}
1166
+ const id = W.library.idOf(file);
1167
+ const topic = id.split("/").length === 3 ? id.split("/")[0] : "";
1168
+ const title = t.spec.title || spec.title || "Recriada";
1169
+ let newId = null, newFile = null;
1170
+ if (prev?.srcHash === sourceHash(spec)) { try { const f = W.library.resolveId(prev.id); if (fs.existsSync(f)) { newId = prev.id; newFile = f; } } catch {} }
1171
+ if (newFile) writeDeckFile(newFile, { ...t.spec, title });
1172
+ else { newId = W.library.createDeck(topic, { ...t.spec, title }); newFile = W.library.resolveId(newId); }
1173
+ if (r.status === "parcial") fs.writeFileSync(side, JSON.stringify({ srcHash: sourceHash(spec), id: newId }));
1174
+ else fs.rmSync(side, { force: true });
1175
+ const newDir = path.dirname(newFile);
1176
+ const used = new Set();
1177
+ const walk = (v, k) => { if (k === "image" && typeof v === "string") used.add(v); else if (typeof v === "string" && k === "drawing") for (const m of v.matchAll(/href="media:([^"]+)"/g)) used.add(m[1]); else if (v && typeof v === "object") for (const [kk, vv] of Object.entries(v)) walk(vv, kk); };
1178
+ walk(t.spec);
1179
+ for (const rel of [...used, r.cobertura.arquivo, r.cobertura.arquivo.replace(/\.md$/, ".json")]) {
1180
+ const src = path.resolve(dir, rel);
1181
+ if (!src.startsWith(dir) || !fs.existsSync(src)) continue;
1182
+ const dst = path.join(newDir, ...rel.split("/"));
1183
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
1184
+ fs.copyFileSync(src, dst);
1185
+ }
1186
+ return { ...result, reply: `Pronto: ${prev && newId === prev.id ? "atualizei" : "criei"} "${title}" na biblioteca, do zero.\n${lines.join("\n")}`, spec, talk: true, createdDeck: { id: newId, title }, transformReport: r };
1187
+ }
1188
+ if (pathname === "/api/ai/transform/status" && req.method === "GET") {
1189
+ const dir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
1190
+ const running = ["melhorar", "recriar"].map((mode) => transforms.get(`${W.file}|${mode}`) && { mode, progress: transforms.get(`${W.file}|${mode}`).progress, started: transforms.get(`${W.file}|${mode}`).started }).filter(Boolean);
1191
+ const jobs = dir ? ["melhorar", "recriar"].map((mode) => jobStatus(dir, mode)).filter(Boolean).map(({ srcHash, ...j }) => j) : [];
1192
+ res.writeHead(200, { "Content-Type": "application/json" });
1193
+ res.end(JSON.stringify({ running, jobs }));
1194
+ return;
1195
+ }
1196
+ if (pathname === "/api/ai/transform/cancel" && req.method === "POST") {
1197
+ const body = await readJSON(req);
1198
+ let n = 0;
1199
+ for (const mode of ["melhorar", "recriar"]) {
1200
+ const job = transforms.get(`${W.file}|${mode}`);
1201
+ if (job && (!body.mode || body.mode === mode)) { job.controller.abort(); n++; }
1202
+ }
1203
+ res.writeHead(200, { "Content-Type": "application/json" });
1204
+ res.end(JSON.stringify({ ok: true, stopped: n }));
1205
+ return;
1206
+ }
1207
+ if (await slideCopyRoutes({pathname,req,res,W,readJSON,staleTab,slideClipboard,isBundledTemplate,materializePreview})) return;
1208
+ if (pathname === "/api/ai/chat/cancel" && req.method === "POST") {
1209
+ const body = await readJSON(req);
1210
+ const job = W.aiChats?.get(body.requestId);
1211
+ job?.abort();
1212
+ res.writeHead(200, { "Content-Type": "application/json" });
1213
+ res.end(JSON.stringify({ ok: true, stopped: !!job }));
1214
+ return;
1215
+ }
1216
+ if (pathname === "/api/ai/chat" && req.method === "POST") {
1217
+ const body = await readJSON(req);
1218
+ if (staleTab(body, W, res)) return;
1219
+ const owner = W;
1220
+ owner.aiChats ||= new Map();
1221
+ const requestId = body.requestId || crypto.randomUUID();
1222
+ const controller = new AbortController();
1223
+ owner.aiChats.set(requestId, controller);
1224
+ const signal = controller.signal;
1225
+ const onClose = () => { if (!res.writableEnded && !['melhorar','recriar'].some(m => transforms.has(`${taskWorkspace.file}|${m}`))) controller.abort(); };
1226
+ res.on('close', onClose);
1227
+ // Capture o arquivo da tarefa: abrir outro deck não redireciona a escrita.
1228
+ const taskWorkspace = { ...owner, spec: structuredClone(owner.spec) };
1229
+ const prompt = body.message || "";
1230
+ const spec = body.spec || taskWorkspace.spec;
1231
+ const issues = body.issues || [];
1232
+ // a IA vê (e testa) os slides api no ambiente atual, que pode ser o embutido "ensaio"
1233
+ if (!apiBlocked(req) && (spec.slides || []).some((sl) => sl && sl.layout === "api")) await ensureEnsaio();
1234
+
1235
+ try { await respond(res, body.stream, async (send) => cancellable(async () => {
1236
+ const emit = ev => { signal.throwIfAborted(); send(ev); };
1237
+ let result;
1238
+ const history = Array.isArray(body.history) ? body.history : [];
1239
+ // o cache pode ter guardado uma queda de segundos do relay: confere de novo antes de desistir
1240
+ if (!(await llmAvailable()) && !(await llmAvailable({ force: true }))) {
1241
+ // Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
1242
+ // (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
1243
+ return {
1244
+ reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}), então não mexi em nada. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL e mande de novo.`,
1245
+ spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
1246
+ };
1247
+ }
1248
+ try {
1249
+ const target = typeof body.targetSlide === "number" ? body.targetSlide : null;
1250
+ const visuals = await lookAt(withBase(taskWorkspace, spec), target, prompt, emit);
1251
+ // anexos: imagens vão como visão; documentos (id do /api/ai/context) vão como texto
1252
+ const materials = await prepareDocumentMaterials(takeMaterials(taskWorkspace, (Array.isArray(body.attachments) ? body.attachments : [])
1253
+ .filter((a) => a && typeof a === "object" && a.type === "doc").map((a) => a.id)), path.dirname(taskWorkspace.file), {signal,onProgress:emit});
1254
+ for (const [i, url] of (Array.isArray(body.attachments) ? body.attachments : []).entries()) {
1255
+ if (typeof url === "string" && url.startsWith("data:image/")) visuals.push({ label: `imagem colada pelo usuário ${i + 1}`, dataUrl: url });
1256
+ }
1257
+ // arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
1258
+ const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
1259
+ if (P) for (const doc of await Project.contextMaterials(P)) if (!materials.some((m) => m.name === doc.name || m.name === doc.name.split("/").pop())) materials.push(doc);
1260
+ // links colados na mensagem: o servidor lê sozinho e conta nas ações
1261
+ const linkActions = [];
1262
+ for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
1263
+ result = await editDeck({
1264
+ spec: withBase(taskWorkspace, spec), signal,
1265
+ instruction: prompt,
1266
+ targetSlide: target,
1267
+ issues,
1268
+ images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
1269
+ imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, spec)),
1270
+ history,
1271
+ onProgress: emit,
1272
+ visuals,
1273
+ materials,
1274
+ renderNotes: Array.isArray(body.renderNotes) ? body.renderNotes.slice(0, 8) : [],
1275
+ apiContext: apiContextFor(req, taskWorkspace),
1276
+ drawCheck: diagramCheck,
1277
+ runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
1278
+ reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
1279
+ styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
1280
+ });
1281
+ signal.throwIfAborted();
1282
+ if (linkActions.length) result.actions = [...linkActions, ...(result.actions || [])];
1283
+ // a IA decidiu transformar a apresentação inteira (transform:): o trabalho em etapas, com o andamento aqui
1284
+ if (result.transform) {
1285
+ const transformed = await runTransform(taskWorkspace, withBase(taskWorkspace, spec), result, emit, prompt, visuals.filter(v => v.label.startsWith("imagem colada pelo usuário")), signal);
1286
+ return owner.file === taskWorkspace.file ? transformed : { ...transformed, spec: owner.spec, talk: true, reply: transformed.reply + '\nO resultado ficou no arquivo original; você está em outra apresentação.' };
1287
+ }
1288
+ // a IA decidiu mexer no estilo (estilo:): aplicar, salvar, tirar ou padrão das novas
1289
+ if (result.style) return styleAction({ W: taskWorkspace, spec: withBase(taskWorkspace, spec), result, persist, isBundledTemplate });
1290
+ // Slides api: a IA pediu para testar (test: [n]) → o Studio executa, devolve o relatório e ela
1291
+ // corrige, até 3 rodadas. Quem decide testar e o que corrigir é a IA; aqui só executa.
1292
+ const convo = [...history, { role: "user", text: prompt }]
1293
+ for (let round = 1; result.test?.length && round <= 3; round++) {
1294
+ if (apiBlocked(req)) { result.actions.push("Testes de slides api só no Studio local."); break; }
1295
+ const reports = [];
1296
+ for (const i of result.test) {
1297
+ const slide = result.spec.slides[i];
1298
+ emit({ phase: "test", text: `Testando o slide ${i + 1} (${apiEnv.currentName() || "sem ambiente"})…` });
1299
+ const r = await apiEnv.runSlide(slide, { vars: taskWorkspace.apiVars || {}, deckDir: taskWorkspace.file ? path.dirname(taskWorkspace.file) : null });
1300
+ signal.throwIfAborted();
1301
+ if (r.saved) taskWorkspace.apiVars = { ...(taskWorkspace.apiVars || {}), ...r.saved };
1302
+ if (r.record) {
1303
+ const key = globalThis.SagadeckApiCore.key(slide);
1304
+ if (taskWorkspace.file && !isBundledTemplate(taskWorkspace.file)) writeRecording(taskWorkspace.file, key, r.record);
1305
+ else (taskWorkspace.apiRecordings = taskWorkspace.apiRecordings || {})[key] = { ...r.record, at: new Date().toISOString() };
1306
+ }
1307
+ reports.push({ slide: i + 1, ...r.report });
1308
+ result.actions.push(`Teste do slide ${i + 1}: ${r.report.ok ? "funcionou" : "falhou, " + String(r.report.erro || (r.report.status ? `HTTP ${r.report.status}` : "falhou")).slice(0, 140)}`);
1309
+ }
1310
+ convo.push({ role: "assistant", text: result.reply });
1311
+ const instruction = `Resultado do teste (rodada ${round} de 3), executado no ambiente ${apiEnv.currentName()}:\n\`\`\`json\n${JSON.stringify(reports, null, 2).slice(0, 12000)}\n\`\`\`\nSe algo falhou ou tem "NÃO EXISTE", corrija os slides com base na resposta real e peça test de novo. Se tudo funcionou, confirme em uma frase, sem yaml.`;
1312
+ emit({ phase: "test", text: reports.every((x) => x.ok) ? "Os testes passaram; conferindo…" : "Corrigindo com base no resultado…" });
1313
+ const next = await editDeck({
1314
+ spec: withBase(taskWorkspace, result.spec), signal, instruction, targetSlide: target, images: false,
1315
+ imageOptions: imageOptions(taskWorkspace, withBase(taskWorkspace, result.spec)), history: convo, onProgress: emit, apiContext: apiContextFor(req, taskWorkspace),
1316
+ drawCheck: diagramCheck, runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
1317
+ });
1318
+ convo.push({ role: "user", text: instruction });
1319
+ result = { ...next, actions: [...result.actions, ...(next.actions || [])], spec: next.spec };
1320
+ }
1321
+ result.mode = "llm";
1322
+ } catch (e) {
1323
+ if (signal.aborted) return { reply: "Parado a pedido. Nenhuma resposta pendente foi aplicada.", spec, actions: [], talk: true, mode: "cancelled" };
1324
+ // Falhou no meio: não "chuta" com as regras (poderiam fazer outra coisa); deck fica como estava.
1325
+ console.error("[Studio] IA falhou:", e.message);
1326
+ return chatErrorResult(e,spec,body.targetSlide);
1327
+ }
1328
+ // o que a pessoa salvou enquanto a IA pensava não some: junção a três (base = o que foi para a IA)
1329
+ signal.throwIfAborted();
1330
+ const current = taskWorkspace.file && fs.existsSync(taskWorkspace.file) ? loadSpec(taskWorkspace.file) : owner.spec;
1331
+ const merged = globalThis.SagadeckMerge.mergeDecks(spec, current || spec, result.spec);
1332
+ taskWorkspace.spec = merged.deck;
1333
+ carryVisualEdits(spec, taskWorkspace.spec); // a IA mudou o texto de um objeto ajustado: o ajuste acompanha
1334
+ persist(taskWorkspace);
1335
+ if (owner.file === taskWorkspace.file) owner.spec = taskWorkspace.spec;
1336
+ return { ...result, spec: taskWorkspace.spec, conflicts: merged.conflicts, kept: merged.kept };
1337
+ }, signal)); } finally { owner.aiChats.delete(requestId); res.off("close", onClose); }
1338
+ return;
1339
+ }
1340
+
1341
+ // ── Biblioteca (tópicos e apresentações da área de trabalho) ──
1342
+ // ---------- slide "api": executa pedidos HTTP (ver src/api-client.js) ----------
1343
+ // Só no modo local, só pelo endereço desta máquina e só da própria página: nenhum outro site
1344
+ // (nem outro computador da rede) usa a sua VPN e o seu token por aqui.
1345
+ // slide API: ambientes, variáveis, envio, tempo real, gravação (src/studio/api-routes.js)
1346
+ if (pathname.startsWith("/api/http/")) return apiRoutes({ req, res, pathname, url, W, apiEnv, rtSessions, apiBlocked, ensureEnsaio, readJSON, isBundledTemplate });
1347
+
1348
+ if (pathname.startsWith("/api/library")) {
1349
+ const L = W.library;
1350
+ const ok = (obj = {}) => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, ...obj })); };
1351
+ const fail = (e, code = 400) => { res.writeHead(code, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: e.message || String(e) })); };
1352
+ try {
1353
+ if (pathname === "/api/library" && req.method === "GET") {
1354
+ const openId = W.file && W.file.startsWith(L.root + path.sep) ? L.idOf(W.file) : null;
1355
+ return ok({ ...L.list(), user: W.user, multiuser: !!opts.multiuser, open: openId });
1356
+ }
1357
+ if (pathname === "/api/library/gallery-cover" && req.method === "GET") {
1358
+ const key = galleryKey(url.searchParams.get("key")), entry = galleryEntry(key);
1359
+ const spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
1360
+ const cacheDir = path.join(L.root, ".cache", "vitrine");
1361
+ const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${key}|${JSON.stringify(entry.spec)}`).digest("hex")}.jpg`);
1362
+ if (!fs.existsSync(cached)) {
1363
+ const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
1364
+ fs.mkdirSync(cacheDir, { recursive: true });
1365
+ fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
1366
+ }
1367
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=86400" });
1368
+ res.end(fs.readFileSync(cached));
1369
+ return;
1370
+ }
1371
+ if (pathname === "/api/library/cover" && req.method === "GET") {
1372
+ const file = L.resolveId(url.searchParams.get("id"));
1373
+ const st = fs.statSync(file);
1374
+ const cacheDir = path.join(L.root, ".cache", "capas");
1375
+ const cached = path.join(cacheDir, `${crypto.createHash("sha1").update(`${L.idOf(file)}|${st.mtimeMs}`).digest("hex")}.jpg`);
1376
+ if (!fs.existsSync(cached)) {
1377
+ const spec = loadSpec(file);
1378
+ const [shot] = await slideSnapshots(spec, 0, { mode: "final", width: 480 });
1379
+ fs.mkdirSync(cacheDir, { recursive: true });
1380
+ fs.writeFileSync(cached, Buffer.from(shot.dataUrl.split(",")[1], "base64"));
1381
+ }
1382
+ res.writeHead(200, { "Content-Type": "image/jpeg", "Cache-Control": "private, max-age=31536000" });
1383
+ res.end(fs.readFileSync(cached));
1384
+ return;
1385
+ }
1386
+ if (pathname === "/api/library/download" && req.method === "GET") {
1387
+ const file = L.resolveId(url.searchParams.get("id"));
1388
+ const kind = url.searchParams.get("kind") || "sagadeck";
1389
+ if (!["sagadeck", "pptx", "pdf", "roteiro", "tudo", "estudo", "estudo-html"].includes(kind)) throw new Error("formato inválido");
1390
+ await sendExport(res, kind, loadSpec(file), path.basename(file).replace(/\.ya?ml$/i, ""), { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0" });
1391
+ return;
1392
+ }
1393
+ if (pathname === "/api/library/import" && req.method === "POST") {
1394
+ const name = url.searchParams.get("name") || "Importada";
1395
+ // PowerPoint: importação fiel (src/import); .sagadeck/.zip: pacote do sagadeck
1396
+ if (/\.pptx$/i.test(name)) { const r = await L.importOffice(await readBody(req), url.searchParams.get("topic") || "", name); return ok({ id: r.id, slides: r.slides, snapshots: r.snapshots, snapBy: r.snapBy }); }
1397
+ const id = await L.importPackage(await readBody(req), url.searchParams.get("topic") || "", name);
1398
+ return ok({ id });
1399
+ }
1400
+ if (req.method !== "POST") return fail(new Error("método não suportado"), 405);
1401
+ const b = await readJSON(req);
1402
+ switch (pathname) {
1403
+ case "/api/library/experience": {
1404
+ const spec = createExperienceDeck(b.experience, { title: b.title });
1405
+ buildHTML(spec); // Valida antes de criar o arquivo na biblioteca.
1406
+ return ok({ id: L.createDeck(b.topic || "", spec) });
1407
+ }
1408
+ case "/api/library/topics": return ok({ id: L.createTopic(b.name, b.color) });
1409
+ case "/api/library/topics/update": return ok({ id: L.updateTopic(b.id, b) });
1410
+ case "/api/library/topics/delete": L.deleteTopic(b.id); return ok();
1411
+ case "/api/library/decks": {
1412
+ // nova: em branco (uma capa) — "com IA" usa /api/library/decks/ai
1413
+ const title = String(b.title || "Nova apresentação").trim();
1414
+ const id = L.createDeck(b.topic || "", { title, theme: b.theme || "bauhaus", duration: 10,
1415
+ slides: [{ layout: "cover", title, subtitle: "Subtítulo", author: "" }] });
1416
+ // estilo padrão da biblioteca (Brand Kit): sem tema escolhido, a nova já nasce nele
1417
+ if (!b.theme && L.defaultStyle()) { const f = L.resolveId(id); writeDeckFile(f, L.withDefaultStyle(YAML.parse(fs.readFileSync(f, "utf8")), path.dirname(f))); }
1418
+ return ok({ id });
1419
+ }
1420
+ case "/api/library/decks/model": {
1421
+ // modelos de fábrica (src/studio/demo-decks.js): viram uma apresentação nova na biblioteca
1422
+ const id = L.createDeck(b.topic || "Modelos", demoDeck(b.kind));
1423
+ copyModelAssets(L, id, b.kind);
1424
+ return ok({ id });
1425
+ }
1426
+ case "/api/library/decks/model-preview": {
1427
+ // abre o item da vitrine sem criar arquivo; a cópia só nasce na primeira mudança
1428
+ const key = galleryKey(b.key || b.kind), entry = galleryEntry(key);
1429
+ W.spec = entry.dir ? { ...entry.spec, _dir: entry.dir } : entry.spec;
1430
+ W.file = null;
1431
+ W.preview = { key, topic: String(b.topic ?? "Modelos") }; // "" = sem tópico
1432
+ W.chatHistory = [];
1433
+ return ok({ spec: W.spec, preview: { key, title: W.spec.title, topic: W.preview.topic } });
1434
+ }
1435
+ case "/api/library/decks/model-use": {
1436
+ // "Usar como base": cria a cópia agora, mesmo sem mudança
1437
+ const made = materializePreview(W);
1438
+ if (!made) throw new Error("Não há um modelo em prévia aberto.");
1439
+ return ok(made);
1440
+ }
1441
+ case "/api/library/decks/example-cenario": {
1442
+ // demonstração do Texto no cenário: o YAML e as imagens de exemplo (fundo e recorte transparente)
1443
+ const dir0 = TEMPLATE_DIRS.map((d) => path.join(d, "cenario")).find((d) => fs.existsSync(d));
1444
+ if (!dir0) throw new Error("o exemplo não veio no pacote (templates/cenario)");
1445
+ const tpl = fs.readdirSync(dir0).find((f) => f.endsWith(".yaml"));
1446
+ const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(path.join(dir0, tpl), "utf8")));
1447
+ fs.cpSync(path.join(dir0, "imagens"), path.join(path.dirname(L.resolveId(id)), "imagens"), { recursive: true, force: false });
1448
+ return ok({ id });
1449
+ }
1450
+ case "/api/library/decks/example": {
1451
+ // exemplo de slides api (aula de APIs de IA): roda no ambiente embutido "ensaio", sem configurar nada
1452
+ const tpl = templateFile("ensaio-api.yaml");
1453
+ if (!tpl) throw new Error("o exemplo não veio no pacote (templates/ensaio-api.yaml)");
1454
+ const id = L.createDeck(b.topic || "", YAML.parse(fs.readFileSync(tpl, "utf8")));
1455
+ const dir = path.dirname(L.resolveId(id));
1456
+ for (const [name, text] of Object.entries(DEMO_FILES)) fs.writeFileSync(path.join(dir, name), text);
1457
+ return ok({ id });
1458
+ }
1459
+ case "/api/library/decks/move": return ok({ id: L.moveDeck(b.id, b.topic || "") });
1460
+ case "/api/library/decks/rename": {
1461
+ const id = L.renameDeck(b.id, String(b.title || "").trim() || "Sem título");
1462
+ if (W.file === L.resolveId(b.id)) { W.file = L.resolveId(id); W.spec = loadSpec(W.file); }
1463
+ return ok({ id });
1464
+ }
1465
+ case "/api/library/decks/duplicate": return ok({ id: L.duplicateDeck(b.id) });
1466
+ case "/api/library/decks/trash": return ok({ slot: L.trashDeck(b.id) });
1467
+ case "/api/library/decks/restore": return ok({ id: L.restoreDeck(b.slot) });
1468
+ case "/api/library/decks/purge": L.purgeDeck(b.slot); return ok();
1469
+ case "/api/library/trash/empty": return ok({ purged: L.emptyTrash() });
1470
+ case "/api/library/open": {
1471
+ W.preview = null;
1472
+ const file = L.resolveId(b.id);
1473
+ W.spec = loadSpec(file);
1474
+ W.file = file;
1475
+ return ok({ spec: W.spec, file: W.file, id: b.id });
1476
+ }
1477
+ case "/api/library/decks/ai": {
1478
+ // gera com IA direto numa pasta nova da biblioteca (as imagens ficam dentro dela)
1479
+ if (!(await llmAvailable({ force: true }))) return fail(new Error(`Nenhum LLM respondendo em ${llmConfig().url}.`), 503);
1480
+ await respond(res, b.stream, async (emit) => {
1481
+ const r = await generateIntoLibrary(W, b.topic || "", b, emit);
1482
+ if (r.question) return { ok: true, question: r.question }; // o Studio mostra a pergunta e gera de novo com a resposta
1483
+ return { ok: true, id: r.id, images: r.images, research: r.research, quality: r.quality, variety: r.variety };
1484
+ });
1485
+ return;
1486
+ }
1487
+ }
1488
+ return fail(new Error("rota da biblioteca desconhecida"), 404);
1489
+ } catch (e) {
1490
+ return fail(e);
1491
+ }
1492
+ }
1493
+
1494
+ // Baixar o deck aberto: .sagadeck (YAML + imagens, CSS, widgets), PowerPoint, PDF ou roteiro
1495
+ const exportKind = (pathname.match(/^\/api\/export\/(sagadeck|pptx|pdf|roteiro|tudo|estudo|estudo-html)$/) || [])[1];
1496
+ if (exportKind) {
1497
+ const spec = withBase(W, W.spec);
1498
+ const name = W.file && !isBundledTemplate(W.file) ? path.basename(W.file).replace(/\.ya?ml$/i, "") : slugify(spec.title);
1499
+ // ?ver=1: o material de estudo abre na aba (o que o aluno recebe), em vez de baixar
1500
+ await sendExport(res, exportKind, spec, name, { audience:url.searchParams.get('audience')||undefined, notes: url.searchParams.get("notas") !== "0", inline: exportKind === "estudo-html" && url.searchParams.get("ver") === "1" });
1501
+ return;
1502
+ }
1503
+
1504
+ // Abrir um .sagadeck (ou .zip) vindo do navegador: extrai numa pasta de verdade e abre de lá
1505
+ // (assim as edições são salvas; o navegador não informa o caminho do arquivo original).
1506
+ if (pathname === "/api/open-package" && req.method === "POST") {
1507
+ const name = url.searchParams.get("name") || "apresentacao";
1508
+ try {
1509
+ // entra na biblioteca (tópico "Importados", ou o tópico pedido) e abre de lá
1510
+ const id = await W.library.importPackage(await readBody(req), url.searchParams.get("topic") || "", name);
1511
+ const file = W.library.resolveId(id);
1512
+ W.spec = loadSpec(file);
1513
+ W.file = file;
1514
+ res.writeHead(200, { "Content-Type": "application/json" });
1515
+ res.end(JSON.stringify({ ok: true, spec: W.spec, file: W.file, dir: path.dirname(file), id }));
1516
+ } catch (e) {
1517
+ res.writeHead(400, { "Content-Type": "application/json" });
1518
+ res.end(JSON.stringify({ error: e.message }));
1519
+ }
1520
+ return;
1521
+ }
1522
+
1523
+ if (pathname.startsWith("/api/export/")) {
1524
+ const format = pathname.replace("/api/export/", "");
1525
+ if (format === "yaml") {
1526
+ const y = toYaml(W.spec); // sem campos internos (_dir, _file: caminhos desta máquina)
1527
+ res.writeHead(200, {
1528
+ "Content-Type": "text/yaml; charset=utf-8",
1529
+ "Content-Disposition": 'attachment; filename="apresentacao.yaml"',
1530
+ });
1531
+ res.end(y);
1532
+ return;
1533
+ }
1534
+ if (format === "html") {
1535
+ return sendHtml(res,W.spec,url.searchParams.get('audience')||undefined);
1536
+ }
1537
+ if (format === "patch") {
1538
+ const patchPath = path.resolve("sagadeck-v1.2.0.patch");
1539
+ if (fs.existsSync(patchPath)) {
1540
+ res.writeHead(200, {
1541
+ "Content-Type": "text/plain; charset=utf-8",
1542
+ "Content-Disposition": 'attachment; filename="sagadeck-v1.2.0.patch"',
1543
+ });
1544
+ fs.createReadStream(patchPath).pipe(res);
1545
+ return;
1546
+ }
1547
+ }
1548
+ if (format === "wheel") {
1549
+ const wheelPath = path.resolve("dist/sagadeck-1.2.0-py3-none-any.whl");
1550
+ if (fs.existsSync(wheelPath)) {
1551
+ res.writeHead(200, {
1552
+ "Content-Type": "application/octet-stream",
1553
+ "Content-Disposition": 'attachment; filename="sagadeck-1.2.0-py3-none-any.whl"',
1554
+ });
1555
+ fs.createReadStream(wheelPath).pipe(res);
1556
+ return;
1557
+ }
1558
+ }
1559
+ if (format === "source" || format === "tar") {
1560
+ const tarPath = path.resolve("sagadeck-v1.2.0-source.tar.gz");
1561
+ if (fs.existsSync(tarPath)) {
1562
+ res.writeHead(200, {
1563
+ "Content-Type": "application/gzip",
1564
+ "Content-Disposition": 'attachment; filename="sagadeck-v1.2.0-source.tar.gz"',
1565
+ });
1566
+ fs.createReadStream(tarPath).pipe(res);
1567
+ return;
1568
+ }
1569
+ }
1570
+ }
1571
+
1572
+ res.writeHead(404, { "Content-Type": "text/plain" });
1573
+ res.end("Not Found");
1574
+ } catch (err) {
1575
+ console.error("[Studio Error]", err);
1576
+ res.writeHead(500, { "Content-Type": "application/json" });
1577
+ res.end(JSON.stringify({ error: err.message, stack: err.stack }));
1578
+ }
1579
+ });
1580
+
1581
+ server.on("close", () => { ensaio?.then((mock) => mock?.close()); });
1582
+ return server;
1583
+ }