sagadeck 1.2.0 → 1.3.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 (41) hide show
  1. package/README.md +11 -2
  2. package/bin/sagadeck.js +36 -3
  3. package/docs/ANDAMENTO.md +37 -0
  4. package/docs/REFERENCIA.md +4 -3
  5. package/package.json +1 -1
  6. package/src/ai/deck-ai.js +44 -11
  7. package/src/ai/document-visuals.js +90 -15
  8. package/src/ai/quality.js +1 -1
  9. package/src/ai/transform.js +51 -2
  10. package/src/diff.js +44 -0
  11. package/src/export/pptx.js +8 -6
  12. package/src/figures/charts.js +4 -2
  13. package/src/figures/diagrams.js +37 -9
  14. package/src/fiscal/autofix.js +1 -1
  15. package/src/fiscal/enxugar.js +22 -0
  16. package/src/library.js +74 -2
  17. package/src/motion.js +1 -1
  18. package/src/package.js +16 -1
  19. package/src/research/brave.js +12 -0
  20. package/src/research/chat-research.js +29 -0
  21. package/src/research/links.js +37 -0
  22. package/src/research/research.js +42 -2
  23. package/src/research/wikipedia.js +15 -0
  24. package/src/runtime/base.css +2 -2
  25. package/src/runtime/motion.js +1 -1
  26. package/src/runtime/runtime.js +15 -2
  27. package/src/studio/ai-chat-route.js +158 -0
  28. package/src/studio/exporting.js +2 -2
  29. package/src/studio/library-routes.js +158 -0
  30. package/src/studio/public/app.js +44 -508
  31. package/src/studio/public/chat.js +461 -0
  32. package/src/studio/public/index.html +17 -12
  33. package/src/studio/public/library.js +13 -2
  34. package/src/studio/public/slide-clipboard.js +20 -7
  35. package/src/studio/public/slide-select.js +73 -0
  36. package/src/studio/public/studio-next.css +28 -1
  37. package/src/studio/public/style.css +7 -1
  38. package/src/studio/public-files.js +1 -1
  39. package/src/studio/server.js +14 -285
  40. package/src/studio/slide-copy-routes.js +7 -4
  41. package/src/themes.js +32 -2
@@ -0,0 +1,461 @@
1
+ // Chat lateral com IA: bolhas, anexos, comandos, histórico e envio.
2
+ // Saiu do app.js pela trava de tamanho dos monolitos; o comportamento é o mesmo.
3
+ // Fábrica no molde de slide-select.js: recebe o estado e os ajudantes, devolve a API.
4
+ window.SagaChat = function ({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
5
+ streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus }) {
6
+ let chatJob = null;
7
+ const getChatJob = () => chatJob;
8
+ // ==========================================================================
9
+ // --------------------------------------------------------------------------
10
+ // CONVERSA (brainstorm) sem escolher modo: o servidor percebe se é pedido de mudança ou conversa
11
+ // (src/ai/intent.js). Resposta de conversa não mexe no deck, traz opções clicáveis e libera
12
+ // "Transformar em slides".
13
+ // --------------------------------------------------------------------------
14
+ function updateBrainstormApply() {
15
+ const recent = state.chatHistory.slice(-4);
16
+ document.getElementById("btn-brainstorm-apply").classList.toggle("hidden", !recent.some((m) => m.talk));
17
+ }
18
+
19
+ function renderChatOptions(msg, options) {
20
+ if (!options?.length) return;
21
+ const row = document.createElement("div");
22
+ row.className = "bs-options";
23
+ for (const o of options) {
24
+ const b = document.createElement("button");
25
+ b.type = "button";
26
+ b.className = "bs-option";
27
+ b.textContent = o;
28
+ b.onclick = () => {
29
+ row.querySelectorAll("button").forEach((x) => (x.disabled = true));
30
+ dom.chatInput.value = o;
31
+ handleChatSubmit();
32
+ };
33
+ row.append(b);
34
+ }
35
+ // nenhuma das opções serve: a pessoa escreve a sua (a IA entende texto livre)
36
+ const other = document.createElement("button");
37
+ other.type = "button";
38
+ other.className = "bs-option bs-option-other";
39
+ other.innerHTML = '<i class="ic" data-ic="pencil"></i><span>Outra resposta…</span>';
40
+ other.onclick = () => {
41
+ dom.chatInput.value = "";
42
+ dom.chatInput.placeholder = "Escreva a sua resposta…";
43
+ dom.chatInput.focus();
44
+ };
45
+ row.append(other);
46
+ hydrateIcons(row);
47
+ msg.querySelector(".ai-content").append(row);
48
+ }
49
+
50
+ // atalho depois de uma conversa: autoriza a IA a aplicar o que foi combinado
51
+ function applyBrainstorm() {
52
+ dom.chatInput.value = "Pode fazer: aplique nos slides o que combinamos nesta conversa.";
53
+ handleChatSubmit();
54
+ }
55
+
56
+ // Versões de um slide lado a lado; nada muda até escolher uma
57
+ async function renderVariants(msg, variants) {
58
+ const box = document.createElement("div");
59
+ box.className = "variants";
60
+ msg.querySelector(".ai-content").append(box);
61
+ for (const [k, v] of variants.options.entries()) {
62
+ const card = document.createElement("div");
63
+ card.className = "variant";
64
+ card.innerHTML = `<div class="variant-prev"><div class="thumb-render"></div></div><div class="variant-foot"><b></b><button type="button" class="btn btn-secondary btn-sm">Usar esta</button></div>`;
65
+ card.querySelector("b").textContent = v.label;
66
+ if (v.direction) { card.querySelector('b').title = v.direction.rationale; card.querySelector('button').textContent = 'Usar direção no deck'; }
67
+ const btn = card.querySelector("button");
68
+ btn.dataset.variant = k;
69
+ btn.onclick = () => {
70
+ const s = JSON.parse(JSON.stringify(v.slide));
71
+ if (v.direction) { state.deck.theme = v.direction.theme; delete state.deck.palette; delete s.theme; }
72
+ if (variants.insert) state.deck.slides.splice(variants.index, 0, s);
73
+ else state.deck.slides[variants.index] = s;
74
+ state.currentSlideIndex = variants.index;
75
+ box.querySelectorAll(".variant").forEach((c) => c.classList.toggle("chosen", c === card));
76
+ box.querySelectorAll("button").forEach((b) => (b.disabled = true));
77
+ btn.textContent = "Escolhida";
78
+ state.chatHistory.push({ role: "user", text: `Escolhi a versão "${v.label}" (já apliquei no slide ${variants.index + 1}).` });
79
+ saveChatHistory();
80
+ syncDeckToServer();
81
+ renderThumbnails();
82
+ renderCurrentSlide();
83
+ showToast(`Versão "${v.label}" aplicada no slide ${variants.index + 1}`, 2200);
84
+ };
85
+ box.append(card);
86
+ try {
87
+ const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ slide: v.slide, index: variants.index }) })).json();
89
+ const prev = card.querySelector(".variant-prev");
90
+ prev.querySelector(".thumb-render").innerHTML = r.html;
91
+ requestAnimationFrame(() => fitRendered(prev));
92
+ prev.style.setProperty("--thumb-scale", String(prev.clientWidth / 1920));
93
+ if (v.direction) window.SagaArtPreview(prev, r, v.label);
94
+ } catch {}
95
+ }
96
+ box.scrollIntoView({ block: "nearest" });
97
+ }
98
+
99
+ async function handleChatSubmit(e) {
100
+ if (e) e.preventDefault();
101
+ if (chatJob) { if(e?.type === "submit") chatJob.stop(); return; }
102
+ const message = dom.chatInput.value.trim();
103
+ if (state.chatAttachments.some(a => a.t === "doc" && !a.id)) { showToast("Aguarde a leitura do anexo antes de enviar.", 4000); return; }
104
+ if (!message) return;
105
+
106
+ // Adicionar bolha do usuário (com os anexos: imagens e documentos)
107
+ const bubble = appendChatMessage("user", message);
108
+ if (state.chatAttachments.length) {
109
+ const row = document.createElement("div");
110
+ row.className = "msg-atts";
111
+ state.chatAttachments.forEach((a) => {
112
+ if (a.t === "img") { const img = document.createElement("img"); img.src = a.url; row.appendChild(img); }
113
+ else { const chip = document.createElement("span"); chip.className = "msg-doc"; chip.innerHTML = `<i class="ic" data-ic="file-text"></i> `; hydrateIcons(chip); chip.appendChild(document.createTextNode(a.name)); row.appendChild(chip); }
114
+ });
115
+ bubble.querySelector(".user-content")?.appendChild(row);
116
+ }
117
+ dom.chatInput.value = "";
118
+ autoGrowChat();
119
+
120
+ // a IA sempre sabe qual slide está na tela; outros slides (ou o deck todo) a pessoa diz no pedido
121
+ const targetIdx = state.currentSlideIndex;
122
+
123
+ // Indicador de progresso ao vivo (etapa, segundos, texto chegando)
124
+ const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
125
+ job.stop = async () => {
126
+ job.controller.abort();
127
+ await fetch("api/ai/chat/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: job.id }) }).catch(() => {});
128
+ };
129
+ const work = createProgressBubble(state.ai.available
130
+ ? `Enviando para o LLM (${state.ai.textModel})…`
131
+ : "Verificando se a IA está no ar…");
132
+ dom.chatSend.disabled = false;
133
+ dom.chatSend.title = 'Parar processamento'; dom.chatSend.setAttribute('aria-label','Parar');
134
+ dom.chatSend.innerHTML = '<i class="ic" data-ic="square"></i>'; hydrateIcons(dom.chatSend);
135
+ dom.chatInput.disabled = false;
136
+ // a conversa inteira deste deck (o servidor compacta as mensagens antigas; nada é esquecido)
137
+ const history = state.chatHistory.slice();
138
+ const baseDeck = JSON.parse(JSON.stringify(state.deck));
139
+ if (state.chatAttachments.some((a) => a.t === "doc" && !a.id)) {
140
+ showToast("Aguarde a leitura do anexo antes de enviar.", 4000);
141
+ return;
142
+ }
143
+ state.chatHistory.push({ role: "user", text: message });
144
+ // imagens vão embutidas (como antes); documentos vão por id (o texto já está no servidor)
145
+ const attachments = state.chatAttachments.map((a) => (a.t === "img" ? a.url : { type: "doc", id: a.id }));
146
+ clearChatAttachments();
147
+
148
+ try {
149
+ const data = await streamAI("api/ai/chat", {
150
+ message, requestId: job.id,
151
+ targetSlide: targetIdx,
152
+ spec: state.deck,
153
+ issues: state.issues,
154
+ history,
155
+ attachments,
156
+ renderNotes: state.renderNotes || [],
157
+ autoRun: !!state.autoRunCommands, // a pessoa liberou os comandos desta conversa (só enquanto a página está aberta)
158
+ expectFile: state.file,
159
+ }, (ev) => { if (!commandEvent(ev)) work.update(ev); }, job.controller.signal);
160
+ if (state.file !== job.file) { work.done(); showToast("O pedido terminou na apresentação em que começou; a apresentação atual foi preservada.", 6000); return; }
161
+ if (!data.spec) throw new Error(data.error || "resposta sem deck");
162
+ // conversa salva antes de dispensar o indicador: quem espera o fim da resposta já encontra o arquivo gravado
163
+ const finishWork = async () => { await saveChatHistory(); work.done(); };
164
+ if (data.variants) {
165
+ state.chatHistory.push({ role: "assistant", text: `${data.reply}\n(versões: ${data.variants.options.map((o) => o.label).join(" | ")})`, talk: true });
166
+ await finishWork();
167
+ const msg = appendChatMessage("ai", data.reply, data.actions);
168
+ await renderVariants(msg, data.variants);
169
+ updateBrainstormApply();
170
+ return;
171
+ }
172
+ if (data.talk) {
173
+ // conversa: nada muda nos slides (com a IA desligada o aviso não entra na conversa com o modelo)
174
+ if (data.mode === "off") state.chatHistory.pop();
175
+ else state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
176
+ await finishWork();
177
+ const msg = appendChatMessage("ai", data.reply, data.actions);
178
+ msg.classList.add("bs");
179
+ const tag = document.createElement("div");
180
+ tag.className = "bs-tag";
181
+ tag.innerHTML = '<i class="ic" data-ic="message-square-text"></i> Conversa — nada mudou nos slides'; hydrateIcons(tag);
182
+ msg.querySelector(".ai-content").prepend(tag);
183
+ renderChatOptions(msg, data.options);
184
+ // a IA criou uma apresentação nova (recriar): um clique para abrir
185
+ if (data.createdDeck?.id) {
186
+ tag.innerHTML = '<i class="ic" data-ic="presentation"></i> Apresentação nova na biblioteca'; hydrateIcons(tag);
187
+ const open = document.createElement("button");
188
+ open.type = "button"; open.className = "btn btn-primary btn-sm chat-open-deck";
189
+ open.innerHTML = '<i class="ic" data-ic="folder-open"></i> Abrir a apresentação nova'; hydrateIcons(open);
190
+ open.onclick = () => { location.href = `editor?deck=${encodeURIComponent(data.createdDeck.id)}`; };
191
+ msg.querySelector(".ai-content").append(open);
192
+ }
193
+ updateBrainstormApply();
194
+ if (data.mode === "off") refreshAIStatus(); // o selo "IA ligada/desligada" acompanha
195
+ return;
196
+ }
197
+ state.chatHistory.push({ role: "assistant", text: data.reply });
198
+
199
+ // Atualizar o deck com as modificações da IA sem perder o que a pessoa mexeu enquanto ela pensava
200
+ // (junção a três: base = o deck quando o pedido saiu; ver merge-decks.js)
201
+ const merged = window.SagadeckMerge.mergeDecks(baseDeck, state.deck, data.spec);
202
+ state.deck = merged.deck;
203
+ trackDeck("Assistente de IA");
204
+ if (JSON.stringify(merged.deck) !== JSON.stringify(data.spec)) syncDeckToServer();
205
+ const conflicts = [...(data.conflicts || []), ...merged.conflicts];
206
+ if (merged.kept || data.kept) showToast("Você reorganizou os slides enquanto a IA trabalhava: mantive a sua versão. Peça de novo se quiser a mudança dela.", 7000);
207
+ else if (conflicts.length) showToast(`O slide ${[...new Set(conflicts)].map((i) => i + 1).join(", ")} mudou dos dois lados enquanto a IA trabalhava: ficou a sua versão.`, 7000);
208
+ if (typeof data.targetSlide === "number" && data.targetSlide < state.deck.slides.length) {
209
+ state.currentSlideIndex = data.targetSlide;
210
+ }
211
+
212
+ // Re-renderizar
213
+ renderThumbnails();
214
+ await renderCurrentSlide();
215
+
216
+ // Substituir o indicador pela resposta completa
217
+ await finishWork();
218
+ const reply = appendChatMessage("ai", data.reply, data.actions);
219
+ // depois de melhorar seguindo o estilo do original: salvar esse estilo para usar em outras apresentações
220
+ if (data.offerStyle && reply) {
221
+ const b = document.createElement("button");
222
+ b.type = "button"; b.className = "btn btn-sm chat-save-style";
223
+ b.innerHTML = '<i class="ic" data-ic="swatch-book"></i> Salvar este estilo'; hydrateIcons(b);
224
+ b.title = "Guarda a moldura, as cores e as fontes do original para usar em outras apresentações (Design › Estilo)";
225
+ b.onclick = async () => {
226
+ const name = window.prompt("Nome do estilo", data.offerStyle.name);
227
+ if (!name) return;
228
+ try { const r = await fetch("api/styles/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); const j = await r.json(); if (!r.ok || j.error) throw new Error(j.error || r.status); b.disabled = true; showToast(`Estilo "${j.name}" salvo: Design › Estilo aplica em outra apresentação ou usa em toda nova.`, 6000); } catch (e) { showToast(e.message); }
229
+ };
230
+ reply.querySelector(".ai-content")?.append(b);
231
+ }
232
+ updateBrainstormApply();
233
+ } catch (err) {
234
+ if (job.controller.signal.aborted) { work.done(); appendChatMessage("ai", "Parado a pedido. Nenhuma resposta pendente foi aplicada."); await saveChatHistory(); }
235
+ else work.fail(err.message);
236
+ } finally {
237
+ chatJob = null;
238
+ dom.chatSend.title = "Enviar (Enter)"; dom.chatSend.setAttribute("aria-label","Enviar");
239
+ dom.chatSend.innerHTML = '<i class="ic" data-ic="send"></i>'; hydrateIcons(dom.chatSend);
240
+ dom.chatSend.disabled = false;
241
+ dom.chatInput.disabled = false;
242
+ dom.chatInput.focus();
243
+ await saveChatHistory();
244
+ }
245
+ }
246
+
247
+ // A conversa é desta apresentação e fica ao lado dela (<deck>.conversa.json): volta ao reabrir o deck.
248
+ // Devolve a promise: quem chama espera antes de liberar a tela, senão recarregar na mesma hora perde a troca.
249
+ function saveChatHistory() {
250
+ return fetch("api/chat/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ history: state.chatHistory }) }).catch(() => {});
251
+ }
252
+ // ---- comandos da IA: a pessoa vê o código e clica para rodar (a liberação vale só nesta página e neste deck).
253
+ // Ações gratuitas de vídeo (plan/status/frame/download) rodam direto, sem clique; só o que pode cobrar pede.
254
+ state.autoRunCommands = false;
255
+ function commandCard(cmd) {
256
+ const msg = appendChatMessage("ai", "");
257
+ msg.classList.add("cmd-msg");
258
+ const box = msg.querySelector(".ai-content");
259
+ box.innerHTML = `<div class="cmd-head"><i class="ic" data-ic="terminal"></i><b></b></div><div class="cmd-why"></div><pre class="cmd-code"></pre><div class="cmd-actions"></div>`;
260
+ box.querySelector("b").textContent = `A IA quer rodar um comando (${cmd.language})`;
261
+ box.querySelector(".cmd-why").textContent = cmd.why || "";
262
+ box.querySelector(".cmd-code").textContent = cmd.code && typeof cmd.code === "object" ? JSON.stringify(cmd.code, null, 1) : cmd.code;
263
+ hydrateIcons(box);
264
+ dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
265
+ return box;
266
+ }
267
+ function commandEvent(ev) {
268
+ if (ev.type !== "progress") return false;
269
+ if (ev.phase === "approve" && ev.command) {
270
+ const box = commandCard(ev.command), acts = box.querySelector(".cmd-actions");
271
+ if (ev.cost) {
272
+ const tag = document.createElement("div");
273
+ tag.className = "cmd-cost";
274
+ tag.textContent = `Custo estimado: ${ev.cost} (só cobra se executar)`;
275
+ acts.before(tag);
276
+ }
277
+ acts.innerHTML = `<button type="button" class="btn btn-primary" data-d="run"><i class="ic" data-ic="play"></i> Executar</button><button type="button" class="btn" data-d="always">Executar e liberar os próximos</button><button type="button" class="btn" data-d="deny"><i class="ic" data-ic="x"></i> Não executar</button>`;
278
+ hydrateIcons(acts);
279
+ acts.querySelectorAll("[data-d]").forEach((b) => b.onclick = async () => {
280
+ const decision = b.dataset.d;
281
+ if (decision === "always") state.autoRunCommands = true;
282
+ acts.querySelectorAll("button").forEach((x) => { x.disabled = true; });
283
+ try { await fetch("api/ai/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: ev.id, decision }) }); } catch {}
284
+ acts.innerHTML = `<span class="cmd-state">${decision === "deny" ? "Não executado" : decision === "always" ? "Executando (próximos liberados nesta conversa)" : "Executando…"}</span>`;
285
+ if (decision !== "deny") box.dataset.waiting = "1"; // o resultado vem para este cartão
286
+ });
287
+ return false; // a linha de progresso também mostra "Esperando você autorizar"
288
+ }
289
+ if (ev.phase === "command" && ev.command) {
290
+ // aprovado agora: o cartão já está na tela; liberado antes: aparece um cartão novo, já rodando
291
+ if (!document.querySelector(".cmd-msg [data-waiting]")) {
292
+ const box = commandCard(ev.command);
293
+ box.dataset.waiting = "1";
294
+ box.querySelector(".cmd-actions").innerHTML = `<span class="cmd-state">Executando (liberado nesta conversa)…</span>`;
295
+ }
296
+ return false;
297
+ }
298
+ if (ev.phase === "command-result") {
299
+ const box = document.querySelector(".cmd-msg [data-waiting]");
300
+ if (box) {
301
+ delete box.dataset.waiting;
302
+ const r = ev.result || {}, ok = r.exitCode === 0 && !r.timedOut;
303
+ const st = box.querySelector(".cmd-state") || box.querySelector(".cmd-actions");
304
+ st.textContent = r.timedOut ? "Tempo esgotado" : ok ? "Rodou (saída 0)" : `Rodou com erro (saída ${r.exitCode ?? "?"})`;
305
+ st.classList.toggle("cmd-bad", !ok);
306
+ const out = document.createElement("details");
307
+ out.className = "cmd-out";
308
+ out.innerHTML = "<summary>Ver a saída</summary><pre></pre>";
309
+ out.querySelector("pre").textContent = [r.stdout, r.stderr].filter(Boolean).join("\n").slice(0, 6000) || "(sem saída)";
310
+ box.appendChild(out);
311
+ }
312
+ return false;
313
+ }
314
+ return false;
315
+ }
316
+
317
+ async function loadChatHistory() {
318
+ state.autoRunCommands = false; // outra apresentação: nada fica liberado
319
+ try {
320
+ const { history } = await (await fetch("api/chat/history")).json();
321
+ state.chatHistory = Array.isArray(history) ? history : [];
322
+ state.loadingHistory = true; // conversa guardada não é resposta nova (o atalho do chat não acende)
323
+ setTimeout(() => { state.loadingHistory = false; });
324
+ // o que ficou entre parênteses no fim ("opções: …") é para o modelo; na tela, só a mensagem
325
+ for (const m of state.chatHistory) appendChatMessage(m.role === "user" ? "user" : "ai", String(m.text).replace(/\n\((opções|versões): [^\n]*\)$/, ""));
326
+ } catch { state.chatHistory = []; }
327
+ }
328
+
329
+ // ---- anexos do chat (imagem: colar/arrastar/escolher · documento: escolher → o servidor lê) ----
330
+ state.chatAttachments = [];
331
+ function clearChatAttachments() {
332
+ state.chatAttachments = [];
333
+ renderChatAttachments();
334
+ }
335
+ function renderChatAttachments() {
336
+ const box = dom.chatAttachments;
337
+ box.hidden = !state.chatAttachments.length;
338
+ box.innerHTML = "";
339
+ state.chatAttachments.forEach((a, i) => {
340
+ const it = document.createElement("div");
341
+ it.className = "chat-att";
342
+ if (a.t === "img") {
343
+ it.innerHTML = `<img alt=""><button type="button" title="Remover"><i class="ic" data-ic="x"></i></button>`; hydrateIcons(it);
344
+ it.querySelector("img").src = a.url;
345
+ } else {
346
+ it.innerHTML = `<span class="chat-doc" title="${escHtml(a.name)}"><i class="ic" data-ic="file-text"></i><b>${escHtml(a.name)}</b><small>${a.chars} caracteres</small></span><button type="button" title="Remover"><i class="ic" data-ic="x"></i></button>`; hydrateIcons(it);
347
+ }
348
+ it.querySelector("button").onclick = () => { state.chatAttachments.splice(i, 1); renderChatAttachments(); };
349
+ box.appendChild(it);
350
+ });
351
+ }
352
+ // reduz para no máximo 1280 px (a IA não precisa de mais, e a requisição fica leve)
353
+ function addChatImage(file) {
354
+ if (!file || !file.type.startsWith("image/")) return;
355
+ if (state.chatAttachments.length >= 4) { showToast("Até 4 imagens por mensagem."); return; }
356
+ const img = new Image();
357
+ img.onload = () => {
358
+ const k = Math.min(1, 1280 / Math.max(img.width, img.height));
359
+ const c = document.createElement("canvas");
360
+ c.width = Math.round(img.width * k); c.height = Math.round(img.height * k);
361
+ c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
362
+ state.chatAttachments.push({ t: "img", url: c.toDataURL("image/jpeg", 0.85) });
363
+ URL.revokeObjectURL(img.src);
364
+ renderChatAttachments();
365
+ openPane("chat");
366
+ };
367
+ img.src = URL.createObjectURL(file);
368
+ }
369
+ // documento (pdf, docx, xlsx, pptx, txt…): sobe na hora; o servidor extrai o texto e devolve um id.
370
+ // O binário nunca vai para a IA — só o texto extraído, na hora de enviar a mensagem.
371
+ function addChatDoc(file) {
372
+ if (!file) return;
373
+ if (file.size > 15 * 1024 * 1024) { showToast("Arquivo grande demais (limite 15 MB)."); return; }
374
+ const provisional = { t: "doc", id: null, name: file.name, chars: "lendo…" };
375
+ state.chatAttachments.push(provisional);
376
+ renderChatAttachments();
377
+ openPane("chat");
378
+ const rd = new FileReader();
379
+ rd.onload = async () => {
380
+ try {
381
+ const res = await fetch("api/ai/context", {
382
+ method: "POST", headers: { "Content-Type": "application/json" },
383
+ body: JSON.stringify({ name: file.name, dataUrl: rd.result }),
384
+ });
385
+ const data = await res.json();
386
+ if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
387
+ Object.assign(provisional, { id: data.id, chars: data.chars });
388
+ } catch (e) {
389
+ state.chatAttachments.splice(state.chatAttachments.indexOf(provisional), 1);
390
+ showToast(`Não deu para ler "${file.name}": ${e.message}`, 6000);
391
+ }
392
+ renderChatAttachments();
393
+ };
394
+ rd.readAsDataURL(file);
395
+ }
396
+ function addChatFile(file) {
397
+ if (!file) return;
398
+ window.SagaProject?.uploadFile(file, "contexto").then(() => window.SagaProject.refreshTree()).catch(() => {}); // sem projeto: só na conversa
399
+ if (file.type.startsWith("image/")) addChatImage(file);
400
+ else addChatDoc(file);
401
+ }
402
+ // Altura da caixa do chat = o conteúdo (ou o placeholder, se vazia) + a borda; barra de rolagem só no limite.
403
+ function autoGrowChat() {
404
+ const el = dom.chatInput;
405
+ const empty = !el.value;
406
+ if (empty) el.value = el.placeholder; // scrollHeight ignora o placeholder: mede com ele no lugar
407
+ el.style.height = "auto";
408
+ const need = el.scrollHeight + (el.offsetHeight - el.clientHeight);
409
+ if (empty) el.value = "";
410
+ el.style.height = `${Math.min(180, need)}px`;
411
+ el.style.overflowY = need > 180 ? "auto" : "hidden";
412
+ }
413
+
414
+ function appendChatMessage(sender, text, actions = []) {
415
+ dom.chatEmpty?.remove();
416
+ // resposta da IA com o chat fora da vista: o atalho flutuante acende (some quando o chat abre)
417
+ if (sender !== "user" && !state.loadingHistory && currentPane() !== "chat") document.getElementById("chat-badge")?.classList.add("show");
418
+ const msgDiv = document.createElement("div");
419
+ msgDiv.className = sender === "user" ? "user-msg" : "ai-msg";
420
+
421
+ const avatar = document.createElement("div");
422
+ avatar.className = sender === "user" ? "user-avatar" : "ai-avatar";
423
+ if (sender === "user") avatar.textContent = "EU"; else { avatar.innerHTML = '<i class="ic" data-ic="sparkles"></i>'; hydrateIcons(avatar); }
424
+ msgDiv.appendChild(avatar);
425
+
426
+ const content = document.createElement("div");
427
+ content.className = sender === "user" ? "user-content" : "ai-content";
428
+
429
+ // Formatar quebras de linha e negritos
430
+ const formatted = String(text)
431
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
432
+ .replace(/\*\*(.*?)\*\*/g, "<b>$1</b>")
433
+ .replace(/\n/g, "<br>");
434
+ content.innerHTML = formatted;
435
+
436
+ // Exibir badges de ações realizadas pela IA
437
+ if (actions && actions.length > 0) {
438
+ const actionsContainer = document.createElement("div");
439
+ actionsContainer.style.marginTop = "8px";
440
+ actions.forEach((a) => {
441
+ const pill = document.createElement("span");
442
+ pill.className = "action-badge-pill";
443
+ pill.innerHTML = `<i class="ic" data-ic="${/falhou|falha|erro|não enxerga/i.test(a) ? "circle-alert" : "check"}"></i> ${escHtml(a)}`; hydrateIcons(pill);
444
+ actionsContainer.appendChild(pill);
445
+ });
446
+ content.appendChild(actionsContainer);
447
+ }
448
+
449
+ msgDiv.appendChild(content);
450
+ dom.chatMessages.appendChild(msgDiv);
451
+ dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
452
+ return msgDiv;
453
+ }
454
+ // Bolha de progresso do chat e de outros fluxos (transformação usa a mesma).
455
+ const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob });
456
+ const api = { getChatJob, handleChatSubmit, loadChatHistory, saveChatHistory, appendChatMessage,
457
+ createProgressBubble, commandEvent, updateBrainstormApply, renderVariants, applyBrainstorm,
458
+ renderChatOptions, clearChatAttachments, renderChatAttachments, addChatImage, addChatDoc,
459
+ addChatFile, autoGrowChat, commandCard };
460
+ return api;
461
+ };
@@ -809,18 +809,21 @@
809
809
  <div class="modal-dialog-body">
810
810
  <label class="field-label" for="ai-deck-briefing">Sobre o que é, para quem e com que objetivo?</label>
811
811
  <textarea id="ai-deck-briefing" class="form-control" rows="5" placeholder="ex.: Palestra de 20 minutos para gerentes de agência sobre usar IA com segurança no atendimento. Comece com um caso real, mostre 3 cuidados e feche com um checklist.&#10;(imagens: peça no texto — 'com fotos', 'você decide onde ilustrar'… sem pedir, não gera)"></textarea>
812
- <div class="form-row">
813
- <label class="field-label">Minutos <input id="ai-deck-minutes" type="number" min="3" max="120" value="15" class="form-control" title="A quantidade de slides sai daqui (~1 a cada 1,5 min)"></label>
814
- <label class="field-label">Slides <input id="ai-deck-slides" type="number" min="3" max="40" value="10" class="form-control" title="Ajuste fino: mexer aqui vale até trocar os minutos"></label>
815
- </div>
816
- <div class="form-row">
817
- <label class="field-label">Estilo <select id="ai-deck-style" class="form-control"><option value="">A IA escolhe</option><option value="perspectiva">Perspectiva — corporativo fotográfico</option><option value="essencial">Essencial — minimalismo</option><option value="revista">Revista — editorial</option><option value="cromatico">Cromático — cor e fotografia</option><option value="tracos">Traços — geometria criativa</option><option value="manual">Documentação técnica — clara</option><option value="manual-noite">Documentação técnica — escura</option></select></label>
818
- <label class="field-label">Tema <select id="ai-deck-theme" class="form-control"><option value="">A IA escolhe</option></select></label>
819
- </div>
820
- <div class="form-row">
821
- <label class="field-label">Arquivos <input id="ai-deck-files" type="file" multiple accept=".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv" class="form-control" title="pdf, docx, xlsx, pptx, txt, md, csv: a IA lê o conteúdo"></label>
822
- <label class="field-label">Link <span class="form-inline"><input id="ai-deck-link" type="url" placeholder="https://…" class="form-control"><button type="button" id="ai-deck-add-link" class="btn btn-secondary">Anexar</button></span></label>
823
- </div>
812
+ <details id="ai-deck-advanced">
813
+ <summary>Opções avançadas (minutos, estilo, arquivos…)</summary>
814
+ <div class="form-row">
815
+ <label class="field-label">Minutos <input id="ai-deck-minutes" type="number" min="3" max="120" value="15" class="form-control" title="A quantidade de slides sai daqui (~1 a cada 1,5 min)"></label>
816
+ <label class="field-label">Slides <input id="ai-deck-slides" type="number" min="3" max="40" value="10" class="form-control" title="Ajuste fino: mexer aqui vale até trocar os minutos"></label>
817
+ </div>
818
+ <div class="form-row">
819
+ <label class="field-label">Estilo <select id="ai-deck-style" class="form-control"><option value="">A IA escolhe</option><option value="perspectiva">Perspectiva — corporativo fotográfico</option><option value="essencial">Essencial — minimalismo</option><option value="revista">Revista — editorial</option><option value="cromatico">Cromático — cor e fotografia</option><option value="tracos">Traços — geometria criativa</option><option value="manual">Documentação técnica — clara</option><option value="manual-noite">Documentação técnica — escura</option></select></label>
820
+ <label class="field-label">Tema <select id="ai-deck-theme" class="form-control"><option value="">A IA escolhe</option></select></label>
821
+ </div>
822
+ <div class="form-row">
823
+ <label class="field-label">Arquivos <input id="ai-deck-files" type="file" multiple accept=".pdf,.docx,.xlsx,.pptx,.txt,.md,.csv" class="form-control" title="pdf, docx, xlsx, pptx, txt, md, csv: a IA lê o conteúdo"></label>
824
+ <label class="field-label">Link <span class="form-inline"><input id="ai-deck-link" type="url" placeholder="https://…" class="form-control"><button type="button" id="ai-deck-add-link" class="btn btn-secondary">Anexar</button></span></label>
825
+ </div>
826
+ </details>
824
827
  <div id="ai-deck-materials" class="chat-attachments" hidden></div>
825
828
  <div id="ai-deck-status" class="status-line"></div>
826
829
  </div>
@@ -859,9 +862,11 @@
859
862
  <script src="review-ui.js"></script>
860
863
  <script src="share-ui.js"></script>
861
864
  <script src="slide-clipboard.js"></script>
865
+ <script src="slide-select.js"></script>
862
866
  <script src="chat-progress.js"></script>
863
867
  <script src="workspace.js"></script>
864
868
  <script src="studio-awareness.js"></script>
869
+ <script src="chat.js"></script>
865
870
  <script src="app.js"></script>
866
871
  </body>
867
872
  </html>
@@ -39,10 +39,21 @@
39
39
  }
40
40
  async function load() {
41
41
  data = await api("api/library");
42
+ // atrás do portal, o id cru não diz nada: mostra o username (/whoami, mesma origem).
43
+ // só pergunta quando há usuário (no Studio local o 404 sujaria o console sem motivo).
44
+ let name = data.user;
42
45
  if (data.user) {
46
+ try {
47
+ const r = await fetch("/whoami");
48
+ const w = r.ok ? await r.json() : null;
49
+ if (w && w.logged_in) name = w.username || w.email || name;
50
+ } catch {}
51
+ }
52
+ if (name) {
43
53
  $("#user").hidden = false;
44
- $("#avatar").textContent = String(data.user).slice(0, 1);
45
- $("#uname").textContent = data.user;
54
+ $("#avatar").textContent = String(name).trim().slice(0, 1).toUpperCase();
55
+ $("#uname").textContent = name;
56
+ $("#user").title = data.user && data.user !== name ? data.user : "";
46
57
  }
47
58
  if (view !== "recentes" && view !== "todas" && view !== "lixeira" && !topicOf(view)) view = "recentes";
48
59
  render();
@@ -1,23 +1,36 @@
1
1
  window.SagaSlideClipboard = function ({ state, showToast, trackDeck, syncDeckToServer, renderThumbnails, renderCurrentSlide }) {
2
- async function copyCurrentSlide() {
2
+ // os marcados na lista (multisselecao); sem marca, o atual
3
+ const pick = () => {
4
+ const n = state.deck?.slides.length || 0;
5
+ const sel = (Array.isArray(state.selectedSlides) ? state.selectedSlides : []).filter((i) => Number.isInteger(i) && i >= 0 && i < n);
6
+ return sel.length ? [...new Set(sel)].sort((a, b) => a - b) : [Math.min(state.currentSlideIndex, Math.max(0, n - 1))];
7
+ };
8
+ async function copySelectedSlides(idxs = pick()) {
3
9
  try {
4
- const r = await fetch('api/slides/copy', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ slide:state.deck.slides[state.currentSlideIndex], expectFile:state.file }) });
10
+ const slides = idxs.map((i) => state.deck.slides[i]).filter(Boolean);
11
+ if (!slides.length) throw Error("Selecione um slide para copiar.");
12
+ const r = await fetch('api/slides/copy', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ slides, expectFile:state.file }) });
5
13
  const data = await r.json(); if(!r.ok || data.error) throw Error(data.error || r.status);
6
14
  localStorage.setItem('sagadeck-slide-clipboard', data.token);
7
15
  await navigator.clipboard?.writeText('sagadeck-slide:'+data.token).catch(()=>{});
8
- showToast('Slide copiado. Abra outra apresentação e clique em Colar.');
16
+ showToast(slides.length > 1 ? `${slides.length} slides copiados. Abra outra apresentação e clique em Colar.` : 'Slide copiado. Abra outra apresentação e clique em Colar.');
9
17
  } catch(e) { showToast(e.message,6000); }
10
18
  }
19
+ async function copyCurrentSlide() { return copySelectedSlides([state.currentSlideIndex]); }
11
20
  async function pasteCopiedSlide() {
12
21
  try {
13
22
  const { token } = await (await fetch('api/slides/clipboard')).json();
14
23
  if(!token) throw Error('Copie um slide primeiro.');
15
24
  const r = await fetch('api/slides/paste', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token,expectFile:state.file}) });
16
25
  const data = await r.json(); if(!r.ok || data.error) throw Error(data.error || r.status);
17
- const at = state.currentSlideIndex+1; state.deck.slides.splice(at,0,data.slide); state.currentSlideIndex=at;
18
- trackDeck('Slide colado'); await syncDeckToServer(); renderThumbnails(); await renderCurrentSlide();
19
- showToast('Slide colado com seus arquivos.');
26
+ const slides = Array.isArray(data.slides) && data.slides.length ? data.slides : [data.slide];
27
+ const at = state.currentSlideIndex+1;
28
+ state.deck.slides.splice(at,0,...slides);
29
+ state.currentSlideIndex = at + slides.length - 1;
30
+ state.selectedSlides = slides.map((_, k) => at + k);
31
+ trackDeck(slides.length > 1 ? `${slides.length} slides colados` : 'Slide colado'); await syncDeckToServer(); renderThumbnails(); await renderCurrentSlide();
32
+ showToast(slides.length > 1 ? `${slides.length} slides colados com seus arquivos.` : 'Slide colado com seus arquivos.');
20
33
  } catch(e) { showToast(e.message,6000); }
21
34
  }
22
- return { copyCurrentSlide, pasteCopiedSlide };
35
+ return { copyCurrentSlide, copySelectedSlides, pasteCopiedSlide };
23
36
  };