sagadeck 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/bin/sagadeck.js +36 -3
- package/docs/ANDAMENTO.md +37 -0
- package/docs/REFERENCIA.md +4 -3
- package/package.json +1 -1
- package/src/ai/deck-ai.js +54 -12
- package/src/ai/document-visuals.js +230 -24
- package/src/ai/quality.js +1 -1
- package/src/ai/transform.js +51 -2
- package/src/diff.js +44 -0
- package/src/elements.js +37 -2
- package/src/export/pptx.js +8 -6
- package/src/figures/charts.js +11 -3
- package/src/figures/diagrams.js +37 -9
- package/src/fiscal/autofix.js +1 -1
- package/src/fiscal/enxugar.js +22 -0
- package/src/fiscal/normalize.js +19 -2
- package/src/import/crop.js +10 -3
- package/src/layouts.js +5 -3
- package/src/library.js +74 -2
- package/src/motion.js +1 -1
- package/src/package.js +16 -1
- package/src/research/brave.js +12 -0
- package/src/research/chat-research.js +29 -0
- package/src/research/links.js +37 -0
- package/src/research/research.js +42 -2
- package/src/research/wikipedia.js +15 -0
- package/src/runtime/base.css +6 -6
- package/src/runtime/motion.js +1 -1
- package/src/runtime/runtime.js +15 -2
- package/src/studio/ai-chat-route.js +158 -0
- package/src/studio/exporting.js +2 -2
- package/src/studio/library-routes.js +158 -0
- package/src/studio/public/app.js +44 -508
- package/src/studio/public/chat.js +461 -0
- package/src/studio/public/index.html +17 -12
- package/src/studio/public/library.js +13 -2
- package/src/studio/public/slide-clipboard.js +20 -7
- package/src/studio/public/slide-select.js +73 -0
- package/src/studio/public/studio-next.css +28 -1
- package/src/studio/public/style.css +7 -1
- package/src/studio/public-files.js +1 -1
- package/src/studio/server.js +14 -285
- package/src/studio/slide-copy-routes.js +7 -4
- package/src/studio/snapshot.js +3 -0
- package/src/themes.js +32 -2
package/src/studio/public/app.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"use strict";
|
|
7
7
|
|
|
8
8
|
const scopedURL = window.SagaWorkspace();
|
|
9
|
-
let chatJob = null;
|
|
10
9
|
|
|
11
10
|
// Estado da Aplicação
|
|
12
11
|
const state = {
|
|
13
12
|
deck: null,
|
|
14
13
|
currentSlideIndex: 0,
|
|
14
|
+
selectedSlides: [],
|
|
15
15
|
zoomScale: 0.6,
|
|
16
16
|
autoFit: true,
|
|
17
17
|
showGuides: true,
|
|
@@ -920,452 +920,6 @@
|
|
|
920
920
|
|
|
921
921
|
// ==========================================================================
|
|
922
922
|
// CHAT LATERAL COM IA
|
|
923
|
-
// ==========================================================================
|
|
924
|
-
// --------------------------------------------------------------------------
|
|
925
|
-
// CONVERSA (brainstorm) sem escolher modo: o servidor percebe se é pedido de mudança ou conversa
|
|
926
|
-
// (src/ai/intent.js). Resposta de conversa não mexe no deck, traz opções clicáveis e libera
|
|
927
|
-
// "Transformar em slides".
|
|
928
|
-
// --------------------------------------------------------------------------
|
|
929
|
-
function updateBrainstormApply() {
|
|
930
|
-
const recent = state.chatHistory.slice(-4);
|
|
931
|
-
document.getElementById("btn-brainstorm-apply").classList.toggle("hidden", !recent.some((m) => m.talk));
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function renderChatOptions(msg, options) {
|
|
935
|
-
if (!options?.length) return;
|
|
936
|
-
const row = document.createElement("div");
|
|
937
|
-
row.className = "bs-options";
|
|
938
|
-
for (const o of options) {
|
|
939
|
-
const b = document.createElement("button");
|
|
940
|
-
b.type = "button";
|
|
941
|
-
b.className = "bs-option";
|
|
942
|
-
b.textContent = o;
|
|
943
|
-
b.onclick = () => {
|
|
944
|
-
row.querySelectorAll("button").forEach((x) => (x.disabled = true));
|
|
945
|
-
dom.chatInput.value = o;
|
|
946
|
-
handleChatSubmit();
|
|
947
|
-
};
|
|
948
|
-
row.append(b);
|
|
949
|
-
}
|
|
950
|
-
// nenhuma das opções serve: a pessoa escreve a sua (a IA entende texto livre)
|
|
951
|
-
const other = document.createElement("button");
|
|
952
|
-
other.type = "button";
|
|
953
|
-
other.className = "bs-option bs-option-other";
|
|
954
|
-
other.innerHTML = '<i class="ic" data-ic="pencil"></i><span>Outra resposta…</span>';
|
|
955
|
-
other.onclick = () => {
|
|
956
|
-
dom.chatInput.value = "";
|
|
957
|
-
dom.chatInput.placeholder = "Escreva a sua resposta…";
|
|
958
|
-
dom.chatInput.focus();
|
|
959
|
-
};
|
|
960
|
-
row.append(other);
|
|
961
|
-
hydrateIcons(row);
|
|
962
|
-
msg.querySelector(".ai-content").append(row);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// atalho depois de uma conversa: autoriza a IA a aplicar o que foi combinado
|
|
966
|
-
function applyBrainstorm() {
|
|
967
|
-
dom.chatInput.value = "Pode fazer: aplique nos slides o que combinamos nesta conversa.";
|
|
968
|
-
handleChatSubmit();
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// Versões de um slide lado a lado; nada muda até escolher uma
|
|
972
|
-
async function renderVariants(msg, variants) {
|
|
973
|
-
const box = document.createElement("div");
|
|
974
|
-
box.className = "variants";
|
|
975
|
-
msg.querySelector(".ai-content").append(box);
|
|
976
|
-
for (const [k, v] of variants.options.entries()) {
|
|
977
|
-
const card = document.createElement("div");
|
|
978
|
-
card.className = "variant";
|
|
979
|
-
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>`;
|
|
980
|
-
card.querySelector("b").textContent = v.label;
|
|
981
|
-
if (v.direction) { card.querySelector('b').title = v.direction.rationale; card.querySelector('button').textContent = 'Usar direção no deck'; }
|
|
982
|
-
const btn = card.querySelector("button");
|
|
983
|
-
btn.dataset.variant = k;
|
|
984
|
-
btn.onclick = () => {
|
|
985
|
-
const s = JSON.parse(JSON.stringify(v.slide));
|
|
986
|
-
if (v.direction) { state.deck.theme = v.direction.theme; delete state.deck.palette; delete s.theme; }
|
|
987
|
-
if (variants.insert) state.deck.slides.splice(variants.index, 0, s);
|
|
988
|
-
else state.deck.slides[variants.index] = s;
|
|
989
|
-
state.currentSlideIndex = variants.index;
|
|
990
|
-
box.querySelectorAll(".variant").forEach((c) => c.classList.toggle("chosen", c === card));
|
|
991
|
-
box.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
|
992
|
-
btn.textContent = "Escolhida";
|
|
993
|
-
state.chatHistory.push({ role: "user", text: `Escolhi a versão "${v.label}" (já apliquei no slide ${variants.index + 1}).` });
|
|
994
|
-
saveChatHistory();
|
|
995
|
-
syncDeckToServer();
|
|
996
|
-
renderThumbnails();
|
|
997
|
-
renderCurrentSlide();
|
|
998
|
-
showToast(`Versão "${v.label}" aplicada no slide ${variants.index + 1}`, 2200);
|
|
999
|
-
};
|
|
1000
|
-
box.append(card);
|
|
1001
|
-
try {
|
|
1002
|
-
const r = await (await fetch("api/render-slide", { method: "POST", headers: { "Content-Type": "application/json" },
|
|
1003
|
-
body: JSON.stringify({ slide: v.slide, index: variants.index }) })).json();
|
|
1004
|
-
const prev = card.querySelector(".variant-prev");
|
|
1005
|
-
prev.querySelector(".thumb-render").innerHTML = r.html;
|
|
1006
|
-
requestAnimationFrame(() => fitRendered(prev));
|
|
1007
|
-
prev.style.setProperty("--thumb-scale", String(prev.clientWidth / 1920));
|
|
1008
|
-
if (v.direction) window.SagaArtPreview(prev, r, v.label);
|
|
1009
|
-
} catch {}
|
|
1010
|
-
}
|
|
1011
|
-
box.scrollIntoView({ block: "nearest" });
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
async function handleChatSubmit(e) {
|
|
1015
|
-
if (e) e.preventDefault();
|
|
1016
|
-
if (chatJob) { if(e?.type === "submit") chatJob.stop(); return; }
|
|
1017
|
-
const message = dom.chatInput.value.trim();
|
|
1018
|
-
if (state.chatAttachments.some(a => a.t === "doc" && !a.id)) { showToast("Aguarde a leitura do anexo antes de enviar.", 4000); return; }
|
|
1019
|
-
if (!message) return;
|
|
1020
|
-
|
|
1021
|
-
// Adicionar bolha do usuário (com os anexos: imagens e documentos)
|
|
1022
|
-
const bubble = appendChatMessage("user", message);
|
|
1023
|
-
if (state.chatAttachments.length) {
|
|
1024
|
-
const row = document.createElement("div");
|
|
1025
|
-
row.className = "msg-atts";
|
|
1026
|
-
state.chatAttachments.forEach((a) => {
|
|
1027
|
-
if (a.t === "img") { const img = document.createElement("img"); img.src = a.url; row.appendChild(img); }
|
|
1028
|
-
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); }
|
|
1029
|
-
});
|
|
1030
|
-
bubble.querySelector(".user-content")?.appendChild(row);
|
|
1031
|
-
}
|
|
1032
|
-
dom.chatInput.value = "";
|
|
1033
|
-
autoGrowChat();
|
|
1034
|
-
|
|
1035
|
-
// a IA sempre sabe qual slide está na tela; outros slides (ou o deck todo) a pessoa diz no pedido
|
|
1036
|
-
const targetIdx = state.currentSlideIndex;
|
|
1037
|
-
|
|
1038
|
-
// Indicador de progresso ao vivo (etapa, segundos, texto chegando)
|
|
1039
|
-
const job = chatJob = { file: state.file, id: crypto.randomUUID(), controller: new AbortController() };
|
|
1040
|
-
job.stop = async () => {
|
|
1041
|
-
job.controller.abort();
|
|
1042
|
-
await fetch("api/ai/chat/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: job.id }) }).catch(() => {});
|
|
1043
|
-
};
|
|
1044
|
-
const work = createProgressBubble(state.ai.available
|
|
1045
|
-
? `Enviando para o LLM (${state.ai.textModel})…`
|
|
1046
|
-
: "Verificando se a IA está no ar…");
|
|
1047
|
-
dom.chatSend.disabled = false;
|
|
1048
|
-
dom.chatSend.title = 'Parar processamento'; dom.chatSend.setAttribute('aria-label','Parar');
|
|
1049
|
-
dom.chatSend.innerHTML = '<i class="ic" data-ic="square"></i>'; hydrateIcons(dom.chatSend);
|
|
1050
|
-
dom.chatInput.disabled = false;
|
|
1051
|
-
// a conversa inteira deste deck (o servidor compacta as mensagens antigas; nada é esquecido)
|
|
1052
|
-
const history = state.chatHistory.slice();
|
|
1053
|
-
const baseDeck = JSON.parse(JSON.stringify(state.deck));
|
|
1054
|
-
if (state.chatAttachments.some((a) => a.t === "doc" && !a.id)) {
|
|
1055
|
-
showToast("Aguarde a leitura do anexo antes de enviar.", 4000);
|
|
1056
|
-
return;
|
|
1057
|
-
}
|
|
1058
|
-
state.chatHistory.push({ role: "user", text: message });
|
|
1059
|
-
// imagens vão embutidas (como antes); documentos vão por id (o texto já está no servidor)
|
|
1060
|
-
const attachments = state.chatAttachments.map((a) => (a.t === "img" ? a.url : { type: "doc", id: a.id }));
|
|
1061
|
-
clearChatAttachments();
|
|
1062
|
-
|
|
1063
|
-
try {
|
|
1064
|
-
const data = await streamAI("api/ai/chat", {
|
|
1065
|
-
message, requestId: job.id,
|
|
1066
|
-
targetSlide: targetIdx,
|
|
1067
|
-
spec: state.deck,
|
|
1068
|
-
issues: state.issues,
|
|
1069
|
-
history,
|
|
1070
|
-
attachments,
|
|
1071
|
-
renderNotes: state.renderNotes || [],
|
|
1072
|
-
autoRun: !!state.autoRunCommands, // a pessoa liberou os comandos desta conversa (só enquanto a página está aberta)
|
|
1073
|
-
expectFile: state.file,
|
|
1074
|
-
}, (ev) => { if (!commandEvent(ev)) work.update(ev); }, job.controller.signal);
|
|
1075
|
-
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; }
|
|
1076
|
-
if (!data.spec) throw new Error(data.error || "resposta sem deck");
|
|
1077
|
-
// conversa salva antes de dispensar o indicador: quem espera o fim da resposta já encontra o arquivo gravado
|
|
1078
|
-
const finishWork = async () => { await saveChatHistory(); work.done(); };
|
|
1079
|
-
if (data.variants) {
|
|
1080
|
-
state.chatHistory.push({ role: "assistant", text: `${data.reply}\n(versões: ${data.variants.options.map((o) => o.label).join(" | ")})`, talk: true });
|
|
1081
|
-
await finishWork();
|
|
1082
|
-
const msg = appendChatMessage("ai", data.reply, data.actions);
|
|
1083
|
-
await renderVariants(msg, data.variants);
|
|
1084
|
-
updateBrainstormApply();
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
if (data.talk) {
|
|
1088
|
-
// conversa: nada muda nos slides (com a IA desligada o aviso não entra na conversa com o modelo)
|
|
1089
|
-
if (data.mode === "off") state.chatHistory.pop();
|
|
1090
|
-
else state.chatHistory.push({ role: "assistant", text: data.reply + (data.options?.length ? `\n(opções: ${data.options.join(" | ")})` : ""), talk: true });
|
|
1091
|
-
await finishWork();
|
|
1092
|
-
const msg = appendChatMessage("ai", data.reply, data.actions);
|
|
1093
|
-
msg.classList.add("bs");
|
|
1094
|
-
const tag = document.createElement("div");
|
|
1095
|
-
tag.className = "bs-tag";
|
|
1096
|
-
tag.innerHTML = '<i class="ic" data-ic="message-square-text"></i> Conversa — nada mudou nos slides'; hydrateIcons(tag);
|
|
1097
|
-
msg.querySelector(".ai-content").prepend(tag);
|
|
1098
|
-
renderChatOptions(msg, data.options);
|
|
1099
|
-
// a IA criou uma apresentação nova (recriar): um clique para abrir
|
|
1100
|
-
if (data.createdDeck?.id) {
|
|
1101
|
-
tag.innerHTML = '<i class="ic" data-ic="presentation"></i> Apresentação nova na biblioteca'; hydrateIcons(tag);
|
|
1102
|
-
const open = document.createElement("button");
|
|
1103
|
-
open.type = "button"; open.className = "btn btn-primary btn-sm chat-open-deck";
|
|
1104
|
-
open.innerHTML = '<i class="ic" data-ic="folder-open"></i> Abrir a apresentação nova'; hydrateIcons(open);
|
|
1105
|
-
open.onclick = () => { location.href = `editor?deck=${encodeURIComponent(data.createdDeck.id)}`; };
|
|
1106
|
-
msg.querySelector(".ai-content").append(open);
|
|
1107
|
-
}
|
|
1108
|
-
updateBrainstormApply();
|
|
1109
|
-
if (data.mode === "off") refreshAIStatus(); // o selo "IA ligada/desligada" acompanha
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
state.chatHistory.push({ role: "assistant", text: data.reply });
|
|
1113
|
-
|
|
1114
|
-
// Atualizar o deck com as modificações da IA sem perder o que a pessoa mexeu enquanto ela pensava
|
|
1115
|
-
// (junção a três: base = o deck quando o pedido saiu; ver merge-decks.js)
|
|
1116
|
-
const merged = window.SagadeckMerge.mergeDecks(baseDeck, state.deck, data.spec);
|
|
1117
|
-
state.deck = merged.deck;
|
|
1118
|
-
trackDeck("Assistente de IA");
|
|
1119
|
-
if (JSON.stringify(merged.deck) !== JSON.stringify(data.spec)) syncDeckToServer();
|
|
1120
|
-
const conflicts = [...(data.conflicts || []), ...merged.conflicts];
|
|
1121
|
-
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);
|
|
1122
|
-
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);
|
|
1123
|
-
if (typeof data.targetSlide === "number" && data.targetSlide < state.deck.slides.length) {
|
|
1124
|
-
state.currentSlideIndex = data.targetSlide;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// Re-renderizar
|
|
1128
|
-
renderThumbnails();
|
|
1129
|
-
await renderCurrentSlide();
|
|
1130
|
-
|
|
1131
|
-
// Substituir o indicador pela resposta completa
|
|
1132
|
-
await finishWork();
|
|
1133
|
-
const reply = appendChatMessage("ai", data.reply, data.actions);
|
|
1134
|
-
// depois de melhorar seguindo o estilo do original: salvar esse estilo para usar em outras apresentações
|
|
1135
|
-
if (data.offerStyle && reply) {
|
|
1136
|
-
const b = document.createElement("button");
|
|
1137
|
-
b.type = "button"; b.className = "btn btn-sm chat-save-style";
|
|
1138
|
-
b.innerHTML = '<i class="ic" data-ic="swatch-book"></i> Salvar este estilo'; hydrateIcons(b);
|
|
1139
|
-
b.title = "Guarda a moldura, as cores e as fontes do original para usar em outras apresentações (Design › Estilo)";
|
|
1140
|
-
b.onclick = async () => {
|
|
1141
|
-
const name = window.prompt("Nome do estilo", data.offerStyle.name);
|
|
1142
|
-
if (!name) return;
|
|
1143
|
-
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); }
|
|
1144
|
-
};
|
|
1145
|
-
reply.querySelector(".ai-content")?.append(b);
|
|
1146
|
-
}
|
|
1147
|
-
updateBrainstormApply();
|
|
1148
|
-
} catch (err) {
|
|
1149
|
-
if (job.controller.signal.aborted) { work.done(); appendChatMessage("ai", "Parado a pedido. Nenhuma resposta pendente foi aplicada."); await saveChatHistory(); }
|
|
1150
|
-
else work.fail(err.message);
|
|
1151
|
-
} finally {
|
|
1152
|
-
chatJob = null;
|
|
1153
|
-
dom.chatSend.title = "Enviar (Enter)"; dom.chatSend.setAttribute("aria-label","Enviar");
|
|
1154
|
-
dom.chatSend.innerHTML = '<i class="ic" data-ic="send"></i>'; hydrateIcons(dom.chatSend);
|
|
1155
|
-
dom.chatSend.disabled = false;
|
|
1156
|
-
dom.chatInput.disabled = false;
|
|
1157
|
-
dom.chatInput.focus();
|
|
1158
|
-
await saveChatHistory();
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// A conversa é desta apresentação e fica ao lado dela (<deck>.conversa.json): volta ao reabrir o deck.
|
|
1163
|
-
// Devolve a promise: quem chama espera antes de liberar a tela, senão recarregar na mesma hora perde a troca.
|
|
1164
|
-
function saveChatHistory() {
|
|
1165
|
-
return fetch("api/chat/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ history: state.chatHistory }) }).catch(() => {});
|
|
1166
|
-
}
|
|
1167
|
-
// ---- comandos da IA: a pessoa vê o código e clica para rodar (a liberação vale só nesta página e neste deck).
|
|
1168
|
-
// Ações gratuitas de vídeo (plan/status/frame/download) rodam direto, sem clique; só o que pode cobrar pede.
|
|
1169
|
-
state.autoRunCommands = false;
|
|
1170
|
-
function commandCard(cmd) {
|
|
1171
|
-
const msg = appendChatMessage("ai", "");
|
|
1172
|
-
msg.classList.add("cmd-msg");
|
|
1173
|
-
const box = msg.querySelector(".ai-content");
|
|
1174
|
-
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>`;
|
|
1175
|
-
box.querySelector("b").textContent = `A IA quer rodar um comando (${cmd.language})`;
|
|
1176
|
-
box.querySelector(".cmd-why").textContent = cmd.why || "";
|
|
1177
|
-
box.querySelector(".cmd-code").textContent = cmd.code && typeof cmd.code === "object" ? JSON.stringify(cmd.code, null, 1) : cmd.code;
|
|
1178
|
-
hydrateIcons(box);
|
|
1179
|
-
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
1180
|
-
return box;
|
|
1181
|
-
}
|
|
1182
|
-
function commandEvent(ev) {
|
|
1183
|
-
if (ev.type !== "progress") return false;
|
|
1184
|
-
if (ev.phase === "approve" && ev.command) {
|
|
1185
|
-
const box = commandCard(ev.command), acts = box.querySelector(".cmd-actions");
|
|
1186
|
-
if (ev.cost) {
|
|
1187
|
-
const tag = document.createElement("div");
|
|
1188
|
-
tag.className = "cmd-cost";
|
|
1189
|
-
tag.textContent = `Custo estimado: ${ev.cost} (só cobra se executar)`;
|
|
1190
|
-
acts.before(tag);
|
|
1191
|
-
}
|
|
1192
|
-
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>`;
|
|
1193
|
-
hydrateIcons(acts);
|
|
1194
|
-
acts.querySelectorAll("[data-d]").forEach((b) => b.onclick = async () => {
|
|
1195
|
-
const decision = b.dataset.d;
|
|
1196
|
-
if (decision === "always") state.autoRunCommands = true;
|
|
1197
|
-
acts.querySelectorAll("button").forEach((x) => { x.disabled = true; });
|
|
1198
|
-
try { await fetch("api/ai/approve", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: ev.id, decision }) }); } catch {}
|
|
1199
|
-
acts.innerHTML = `<span class="cmd-state">${decision === "deny" ? "Não executado" : decision === "always" ? "Executando (próximos liberados nesta conversa)" : "Executando…"}</span>`;
|
|
1200
|
-
if (decision !== "deny") box.dataset.waiting = "1"; // o resultado vem para este cartão
|
|
1201
|
-
});
|
|
1202
|
-
return false; // a linha de progresso também mostra "Esperando você autorizar"
|
|
1203
|
-
}
|
|
1204
|
-
if (ev.phase === "command" && ev.command) {
|
|
1205
|
-
// aprovado agora: o cartão já está na tela; liberado antes: aparece um cartão novo, já rodando
|
|
1206
|
-
if (!document.querySelector(".cmd-msg [data-waiting]")) {
|
|
1207
|
-
const box = commandCard(ev.command);
|
|
1208
|
-
box.dataset.waiting = "1";
|
|
1209
|
-
box.querySelector(".cmd-actions").innerHTML = `<span class="cmd-state">Executando (liberado nesta conversa)…</span>`;
|
|
1210
|
-
}
|
|
1211
|
-
return false;
|
|
1212
|
-
}
|
|
1213
|
-
if (ev.phase === "command-result") {
|
|
1214
|
-
const box = document.querySelector(".cmd-msg [data-waiting]");
|
|
1215
|
-
if (box) {
|
|
1216
|
-
delete box.dataset.waiting;
|
|
1217
|
-
const r = ev.result || {}, ok = r.exitCode === 0 && !r.timedOut;
|
|
1218
|
-
const st = box.querySelector(".cmd-state") || box.querySelector(".cmd-actions");
|
|
1219
|
-
st.textContent = r.timedOut ? "Tempo esgotado" : ok ? "Rodou (saída 0)" : `Rodou com erro (saída ${r.exitCode ?? "?"})`;
|
|
1220
|
-
st.classList.toggle("cmd-bad", !ok);
|
|
1221
|
-
const out = document.createElement("details");
|
|
1222
|
-
out.className = "cmd-out";
|
|
1223
|
-
out.innerHTML = "<summary>Ver a saída</summary><pre></pre>";
|
|
1224
|
-
out.querySelector("pre").textContent = [r.stdout, r.stderr].filter(Boolean).join("\n").slice(0, 6000) || "(sem saída)";
|
|
1225
|
-
box.appendChild(out);
|
|
1226
|
-
}
|
|
1227
|
-
return false;
|
|
1228
|
-
}
|
|
1229
|
-
return false;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
async function loadChatHistory() {
|
|
1233
|
-
state.autoRunCommands = false; // outra apresentação: nada fica liberado
|
|
1234
|
-
try {
|
|
1235
|
-
const { history } = await (await fetch("api/chat/history")).json();
|
|
1236
|
-
state.chatHistory = Array.isArray(history) ? history : [];
|
|
1237
|
-
state.loadingHistory = true; // conversa guardada não é resposta nova (o atalho do chat não acende)
|
|
1238
|
-
setTimeout(() => { state.loadingHistory = false; });
|
|
1239
|
-
// o que ficou entre parênteses no fim ("opções: …") é para o modelo; na tela, só a mensagem
|
|
1240
|
-
for (const m of state.chatHistory) appendChatMessage(m.role === "user" ? "user" : "ai", String(m.text).replace(/\n\((opções|versões): [^\n]*\)$/, ""));
|
|
1241
|
-
} catch { state.chatHistory = []; }
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// ---- anexos do chat (imagem: colar/arrastar/escolher · documento: escolher → o servidor lê) ----
|
|
1245
|
-
state.chatAttachments = [];
|
|
1246
|
-
function clearChatAttachments() {
|
|
1247
|
-
state.chatAttachments = [];
|
|
1248
|
-
renderChatAttachments();
|
|
1249
|
-
}
|
|
1250
|
-
function renderChatAttachments() {
|
|
1251
|
-
const box = dom.chatAttachments;
|
|
1252
|
-
box.hidden = !state.chatAttachments.length;
|
|
1253
|
-
box.innerHTML = "";
|
|
1254
|
-
state.chatAttachments.forEach((a, i) => {
|
|
1255
|
-
const it = document.createElement("div");
|
|
1256
|
-
it.className = "chat-att";
|
|
1257
|
-
if (a.t === "img") {
|
|
1258
|
-
it.innerHTML = `<img alt=""><button type="button" title="Remover"><i class="ic" data-ic="x"></i></button>`; hydrateIcons(it);
|
|
1259
|
-
it.querySelector("img").src = a.url;
|
|
1260
|
-
} else {
|
|
1261
|
-
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);
|
|
1262
|
-
}
|
|
1263
|
-
it.querySelector("button").onclick = () => { state.chatAttachments.splice(i, 1); renderChatAttachments(); };
|
|
1264
|
-
box.appendChild(it);
|
|
1265
|
-
});
|
|
1266
|
-
}
|
|
1267
|
-
// reduz para no máximo 1280 px (a IA não precisa de mais, e a requisição fica leve)
|
|
1268
|
-
function addChatImage(file) {
|
|
1269
|
-
if (!file || !file.type.startsWith("image/")) return;
|
|
1270
|
-
if (state.chatAttachments.length >= 4) { showToast("Até 4 imagens por mensagem."); return; }
|
|
1271
|
-
const img = new Image();
|
|
1272
|
-
img.onload = () => {
|
|
1273
|
-
const k = Math.min(1, 1280 / Math.max(img.width, img.height));
|
|
1274
|
-
const c = document.createElement("canvas");
|
|
1275
|
-
c.width = Math.round(img.width * k); c.height = Math.round(img.height * k);
|
|
1276
|
-
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
|
1277
|
-
state.chatAttachments.push({ t: "img", url: c.toDataURL("image/jpeg", 0.85) });
|
|
1278
|
-
URL.revokeObjectURL(img.src);
|
|
1279
|
-
renderChatAttachments();
|
|
1280
|
-
openPane("chat");
|
|
1281
|
-
};
|
|
1282
|
-
img.src = URL.createObjectURL(file);
|
|
1283
|
-
}
|
|
1284
|
-
// documento (pdf, docx, xlsx, pptx, txt…): sobe na hora; o servidor extrai o texto e devolve um id.
|
|
1285
|
-
// O binário nunca vai para a IA — só o texto extraído, na hora de enviar a mensagem.
|
|
1286
|
-
function addChatDoc(file) {
|
|
1287
|
-
if (!file) return;
|
|
1288
|
-
if (file.size > 15 * 1024 * 1024) { showToast("Arquivo grande demais (limite 15 MB)."); return; }
|
|
1289
|
-
const provisional = { t: "doc", id: null, name: file.name, chars: "lendo…" };
|
|
1290
|
-
state.chatAttachments.push(provisional);
|
|
1291
|
-
renderChatAttachments();
|
|
1292
|
-
openPane("chat");
|
|
1293
|
-
const rd = new FileReader();
|
|
1294
|
-
rd.onload = async () => {
|
|
1295
|
-
try {
|
|
1296
|
-
const res = await fetch("api/ai/context", {
|
|
1297
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
1298
|
-
body: JSON.stringify({ name: file.name, dataUrl: rd.result }),
|
|
1299
|
-
});
|
|
1300
|
-
const data = await res.json();
|
|
1301
|
-
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
|
1302
|
-
Object.assign(provisional, { id: data.id, chars: data.chars });
|
|
1303
|
-
} catch (e) {
|
|
1304
|
-
state.chatAttachments.splice(state.chatAttachments.indexOf(provisional), 1);
|
|
1305
|
-
showToast(`Não deu para ler "${file.name}": ${e.message}`, 6000);
|
|
1306
|
-
}
|
|
1307
|
-
renderChatAttachments();
|
|
1308
|
-
};
|
|
1309
|
-
rd.readAsDataURL(file);
|
|
1310
|
-
}
|
|
1311
|
-
function addChatFile(file) {
|
|
1312
|
-
if (!file) return;
|
|
1313
|
-
window.SagaProject?.uploadFile(file, "contexto").then(() => window.SagaProject.refreshTree()).catch(() => {}); // sem projeto: só na conversa
|
|
1314
|
-
if (file.type.startsWith("image/")) addChatImage(file);
|
|
1315
|
-
else addChatDoc(file);
|
|
1316
|
-
}
|
|
1317
|
-
// Altura da caixa do chat = o conteúdo (ou o placeholder, se vazia) + a borda; barra de rolagem só no limite.
|
|
1318
|
-
function autoGrowChat() {
|
|
1319
|
-
const el = dom.chatInput;
|
|
1320
|
-
const empty = !el.value;
|
|
1321
|
-
if (empty) el.value = el.placeholder; // scrollHeight ignora o placeholder: mede com ele no lugar
|
|
1322
|
-
el.style.height = "auto";
|
|
1323
|
-
const need = el.scrollHeight + (el.offsetHeight - el.clientHeight);
|
|
1324
|
-
if (empty) el.value = "";
|
|
1325
|
-
el.style.height = `${Math.min(180, need)}px`;
|
|
1326
|
-
el.style.overflowY = need > 180 ? "auto" : "hidden";
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function appendChatMessage(sender, text, actions = []) {
|
|
1330
|
-
dom.chatEmpty?.remove();
|
|
1331
|
-
// resposta da IA com o chat fora da vista: o atalho flutuante acende (some quando o chat abre)
|
|
1332
|
-
if (sender !== "user" && !state.loadingHistory && currentPane() !== "chat") document.getElementById("chat-badge")?.classList.add("show");
|
|
1333
|
-
const msgDiv = document.createElement("div");
|
|
1334
|
-
msgDiv.className = sender === "user" ? "user-msg" : "ai-msg";
|
|
1335
|
-
|
|
1336
|
-
const avatar = document.createElement("div");
|
|
1337
|
-
avatar.className = sender === "user" ? "user-avatar" : "ai-avatar";
|
|
1338
|
-
if (sender === "user") avatar.textContent = "EU"; else { avatar.innerHTML = '<i class="ic" data-ic="sparkles"></i>'; hydrateIcons(avatar); }
|
|
1339
|
-
msgDiv.appendChild(avatar);
|
|
1340
|
-
|
|
1341
|
-
const content = document.createElement("div");
|
|
1342
|
-
content.className = sender === "user" ? "user-content" : "ai-content";
|
|
1343
|
-
|
|
1344
|
-
// Formatar quebras de linha e negritos
|
|
1345
|
-
const formatted = String(text)
|
|
1346
|
-
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
1347
|
-
.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>")
|
|
1348
|
-
.replace(/\n/g, "<br>");
|
|
1349
|
-
content.innerHTML = formatted;
|
|
1350
|
-
|
|
1351
|
-
// Exibir badges de ações realizadas pela IA
|
|
1352
|
-
if (actions && actions.length > 0) {
|
|
1353
|
-
const actionsContainer = document.createElement("div");
|
|
1354
|
-
actionsContainer.style.marginTop = "8px";
|
|
1355
|
-
actions.forEach((a) => {
|
|
1356
|
-
const pill = document.createElement("span");
|
|
1357
|
-
pill.className = "action-badge-pill";
|
|
1358
|
-
pill.innerHTML = `<i class="ic" data-ic="${/falhou|falha|erro|não enxerga/i.test(a) ? "circle-alert" : "check"}"></i> ${escHtml(a)}`; hydrateIcons(pill);
|
|
1359
|
-
actionsContainer.appendChild(pill);
|
|
1360
|
-
});
|
|
1361
|
-
content.appendChild(actionsContainer);
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
msgDiv.appendChild(content);
|
|
1365
|
-
dom.chatMessages.appendChild(msgDiv);
|
|
1366
|
-
dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
|
|
1367
|
-
return msgDiv;
|
|
1368
|
-
}
|
|
1369
923
|
|
|
1370
924
|
// ==========================================================================
|
|
1371
925
|
// SÍNTESE DE ÁUDIO WEB AUDIO API (MICRO-INTERAÇÕES HÁPTICAS)
|
|
@@ -2319,7 +1873,8 @@
|
|
|
2319
1873
|
|
|
2320
1874
|
state.deck.slides.forEach((slide, idx) => {
|
|
2321
1875
|
const card = document.createElement("div");
|
|
2322
|
-
|
|
1876
|
+
const selNow = new Set(selectedSlides());
|
|
1877
|
+
card.className = `thumb-card ${idx === state.currentSlideIndex ? "active" : ""}${selNow.has(idx) ? " selected" : ""}`;
|
|
2323
1878
|
card.dataset.idx = idx;
|
|
2324
1879
|
card.title = `${idx + 1}. ${plainTitle(slide, idx)} — ${layoutLabel(slide.layout)}`;
|
|
2325
1880
|
|
|
@@ -2372,7 +1927,7 @@
|
|
|
2372
1927
|
});
|
|
2373
1928
|
card.appendChild(actions);
|
|
2374
1929
|
|
|
2375
|
-
card.addEventListener("click", () => { selectSlide(idx); dom.thumbnailsList.focus({ preventScroll: true }); });
|
|
1930
|
+
card.addEventListener("click", (e) => { selectSlide(idx, { add: e.ctrlKey || e.metaKey, range: e.shiftKey }); dom.thumbnailsList.focus({ preventScroll: true }); });
|
|
2376
1931
|
// arrastar para reordenar
|
|
2377
1932
|
card.draggable = true;
|
|
2378
1933
|
card.addEventListener("dragstart", (e) => {
|
|
@@ -2452,20 +2007,15 @@
|
|
|
2452
2007
|
}
|
|
2453
2008
|
|
|
2454
2009
|
function markActiveThumb() {
|
|
2010
|
+
const sel = new Set(selectedSlides());
|
|
2455
2011
|
dom.thumbnailsList.querySelectorAll(".thumb-card").forEach((c) => {
|
|
2456
2012
|
const on = +c.dataset.idx === state.currentSlideIndex;
|
|
2457
2013
|
c.classList.toggle("active", on);
|
|
2014
|
+
c.classList.toggle("selected", sel.has(+c.dataset.idx));
|
|
2458
2015
|
if (on) c.scrollIntoView({ block: "nearest" });
|
|
2459
2016
|
});
|
|
2460
2017
|
}
|
|
2461
2018
|
|
|
2462
|
-
function selectSlide(idx) {
|
|
2463
|
-
if (idx < 0 || idx >= state.deck.slides.length) return;
|
|
2464
|
-
if (idx !== state.currentSlideIndex) state.editorStep = "all"; // outro slide: volta a mostrar tudo
|
|
2465
|
-
state.currentSlideIndex = idx;
|
|
2466
|
-
markActiveThumb();
|
|
2467
|
-
renderCurrentSlide();
|
|
2468
|
-
}
|
|
2469
2019
|
|
|
2470
2020
|
function moveSlide(idx, dir) {
|
|
2471
2021
|
moveSlideTo(idx, idx + dir);
|
|
@@ -2479,6 +2029,8 @@
|
|
|
2479
2029
|
const item = state.deck.slides.splice(from, 1)[0];
|
|
2480
2030
|
state.deck.slides.splice(to, 0, item);
|
|
2481
2031
|
state.currentSlideIndex = state.deck.slides.indexOf(current);
|
|
2032
|
+
// a seleção acompanha os slides (o movido vai para `to`, os do meio andam um)
|
|
2033
|
+
state.selectedSlides = selectedSlides().map((i) => i === from ? to : from < to && i > from && i <= to ? i - 1 : from > to && i >= to && i < from ? i + 1 : i);
|
|
2482
2034
|
syncDeckToServer();
|
|
2483
2035
|
renderThumbnails();
|
|
2484
2036
|
renderCurrentSlide();
|
|
@@ -2667,13 +2219,15 @@
|
|
|
2667
2219
|
const at = state.currentSlideIndex + 1;
|
|
2668
2220
|
state.deck.slides.splice(at, 0, newSlide);
|
|
2669
2221
|
state.currentSlideIndex = at;
|
|
2222
|
+
state.selectedSlides = [at];
|
|
2670
2223
|
syncDeckToServer();
|
|
2671
2224
|
renderThumbnails();
|
|
2672
2225
|
renderCurrentSlide();
|
|
2673
2226
|
showToast(`Slide adicionado na posição ${at + 1}`);
|
|
2674
2227
|
}
|
|
2675
2228
|
|
|
2676
|
-
const {
|
|
2229
|
+
const { copySelectedSlides, pasteCopiedSlide } = window.SagaSlideClipboard({ state, showToast, trackDeck, syncDeckToServer, renderThumbnails, renderCurrentSlide });
|
|
2230
|
+
const { selectedSlides, selectSlide, bindSlideListKeys } = window.SagaSlideSelect({ state, dom, markActiveThumb, renderCurrentSlide, copySelectedSlides, pasteCopiedSlide, deleteCurrentSlide });
|
|
2677
2231
|
function duplicateCurrentSlide() {
|
|
2678
2232
|
const cur = state.deck.slides[state.currentSlideIndex];
|
|
2679
2233
|
if (!cur) return;
|
|
@@ -2681,66 +2235,36 @@
|
|
|
2681
2235
|
const at = state.currentSlideIndex + 1;
|
|
2682
2236
|
state.deck.slides.splice(at, 0, clone);
|
|
2683
2237
|
state.currentSlideIndex = at;
|
|
2238
|
+
state.selectedSlides = [at];
|
|
2684
2239
|
syncDeckToServer();
|
|
2685
2240
|
renderThumbnails();
|
|
2686
2241
|
renderCurrentSlide();
|
|
2687
2242
|
showToast(`Slide duplicado na posição ${at + 1}`);
|
|
2688
2243
|
}
|
|
2689
2244
|
|
|
2690
|
-
// Delete/Backspace com o foco na lista de slides (à esquerda) exclui o slide selecionado. Só ali: digitando
|
|
2691
|
-
// num campo ou mexendo num elemento do slide, as teclas continuam fazendo o que já faziam.
|
|
2692
|
-
function bindSlideListKeys() {
|
|
2693
|
-
document.getElementById("btn-copy-slide").onclick = copyCurrentSlide;
|
|
2694
|
-
document.getElementById("btn-paste-slide").onclick = pasteCopiedSlide;
|
|
2695
|
-
if (dom.thumbnailsList._keys) return;
|
|
2696
|
-
dom.thumbnailsList._keys = true;
|
|
2697
|
-
dom.thumbnailsList.tabIndex = 0;
|
|
2698
|
-
dom.thumbnailsList.setAttribute("aria-label", "Slides (Delete exclui o selecionado)");
|
|
2699
|
-
dom.thumbnailsList.addEventListener("keydown", (e) => {
|
|
2700
|
-
if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest("input, textarea, select, [contenteditable]")) return;
|
|
2701
|
-
// ↑/↓ (e Home/End) trocam de slide, como no PowerPoint; nunca rolam a visualização
|
|
2702
|
-
if ((e.ctrlKey || e.metaKey) && ["c","v"].includes(e.key.toLowerCase())) { e.preventDefault(); e.key.toLowerCase()==="c" ? copyCurrentSlide() : pasteCopiedSlide(); return; }
|
|
2703
|
-
const go = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -1, ArrowRight: 1 }[e.key];
|
|
2704
|
-
if (go || e.key === "Home" || e.key === "End") {
|
|
2705
|
-
e.preventDefault();
|
|
2706
|
-
const last = state.deck.slides.length - 1;
|
|
2707
|
-
const to = e.key === "Home" ? 0 : e.key === "End" ? last : Math.max(0, Math.min(last, state.currentSlideIndex + go));
|
|
2708
|
-
if (to !== state.currentSlideIndex) selectSlide(to);
|
|
2709
|
-
// rola só a lista (scrollIntoView rolaria também a tela em volta)
|
|
2710
|
-
const card = dom.thumbnailsList.querySelector(`.thumb-card[data-idx="${to}"]`), list = dom.thumbnailsList;
|
|
2711
|
-
if (card) {
|
|
2712
|
-
const c = card.getBoundingClientRect(), l = list.getBoundingClientRect();
|
|
2713
|
-
if (c.top < l.top) list.scrollTop -= l.top - c.top + 8;
|
|
2714
|
-
else if (c.bottom > l.bottom) list.scrollTop += c.bottom - l.bottom + 8;
|
|
2715
|
-
}
|
|
2716
|
-
return;
|
|
2717
|
-
}
|
|
2718
|
-
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
2719
|
-
e.preventDefault();
|
|
2720
|
-
deleteCurrentSlide();
|
|
2721
|
-
dom.thumbnailsList.focus({ preventScroll: true });
|
|
2722
|
-
});
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
2245
|
function deleteCurrentSlide() {
|
|
2726
|
-
|
|
2727
|
-
|
|
2246
|
+
const sel = selectedSlides();
|
|
2247
|
+
if (state.deck.slides.length - sel.length < 1) {
|
|
2248
|
+
showToast(sel.length > 1 ? "Não é possível excluir todos os slides da apresentação." : "Não é possível excluir o único slide da apresentação.");
|
|
2728
2249
|
return;
|
|
2729
2250
|
}
|
|
2730
|
-
const
|
|
2731
|
-
|
|
2732
|
-
state.currentSlideIndex = Math.min(
|
|
2251
|
+
const removed = sel.map((i) => ({ idx: i, slide: state.deck.slides[i] }));
|
|
2252
|
+
state.deck.slides = state.deck.slides.filter((_, i) => !sel.includes(i));
|
|
2253
|
+
state.currentSlideIndex = Math.min(sel[0], state.deck.slides.length - 1);
|
|
2254
|
+
state.selectedSlides = [state.currentSlideIndex];
|
|
2733
2255
|
syncDeckToServer();
|
|
2734
2256
|
renderThumbnails();
|
|
2735
2257
|
renderCurrentSlide();
|
|
2736
|
-
// sem desfazer geral no editor: o aviso devolve
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
state.
|
|
2258
|
+
// sem desfazer geral no editor: o aviso devolve os slides nos mesmos lugares
|
|
2259
|
+
const nome = sel.length > 1 ? `${sel.length} slides excluídos.` : `Slide ${sel[0] + 1} excluído.`;
|
|
2260
|
+
showToast(nome, 8000, { label: "Desfazer", fn: () => {
|
|
2261
|
+
for (const { idx, slide } of removed) state.deck.slides.splice(Math.min(idx, state.deck.slides.length), 0, slide);
|
|
2262
|
+
state.currentSlideIndex = sel[0];
|
|
2263
|
+
state.selectedSlides = [...sel];
|
|
2740
2264
|
syncDeckToServer();
|
|
2741
2265
|
renderThumbnails();
|
|
2742
2266
|
renderCurrentSlide();
|
|
2743
|
-
showToast(`Slide ${
|
|
2267
|
+
showToast(sel.length > 1 ? `${sel.length} slides de volta.` : `Slide ${sel[0] + 1} de volta.`);
|
|
2744
2268
|
} });
|
|
2745
2269
|
}
|
|
2746
2270
|
|
|
@@ -3554,6 +3078,7 @@
|
|
|
3554
3078
|
dom.aiDeckStyle.value = "";
|
|
3555
3079
|
state.deckMaterials = [];
|
|
3556
3080
|
renderDeckMaterials();
|
|
3081
|
+
dom.modalAiDeck.querySelector("details").open = false; // simples por padrão, sempre
|
|
3557
3082
|
dom.modalAiDeck.classList.remove("hidden");
|
|
3558
3083
|
dom.aiDeckBriefing.focus();
|
|
3559
3084
|
}
|
|
@@ -3755,7 +3280,6 @@
|
|
|
3755
3280
|
tick();
|
|
3756
3281
|
}
|
|
3757
3282
|
|
|
3758
|
-
const createProgressBubble = window.SagaChatProgress({ dom, appendChatMessage, hydrateIcons, getChatJob: () => chatJob });
|
|
3759
3283
|
|
|
3760
3284
|
// ==========================================================================
|
|
3761
3285
|
// ESTRUTURA DA TELA: faixa de opções, popovers, painel lateral, anotações
|
|
@@ -4407,6 +3931,11 @@ ${ta.value}`;
|
|
|
4407
3931
|
requestAnimationFrame(() => state.autoFit && updateCanvasScale());
|
|
4408
3932
|
}
|
|
4409
3933
|
|
|
3934
|
+
// Chat lateral com IA (chat.js): mesma API, agora em módulo próprio.
|
|
3935
|
+
const chatApi = window.SagaChat({ state, dom, escHtml, hydrateIcons, showToast, openPane, currentPane,
|
|
3936
|
+
streamAI, syncDeckToServer, renderThumbnails, renderCurrentSlide, trackDeck, fitRendered, refreshAIStatus });
|
|
3937
|
+
const { handleChatSubmit, loadChatHistory, appendChatMessage, createProgressBubble, autoGrowChat, applyBrainstorm, addChatFile } = chatApi;
|
|
3938
|
+
|
|
4410
3939
|
function setNotesVisible(on) {
|
|
4411
3940
|
dom.notesBar.classList.toggle("hidden", !on);
|
|
4412
3941
|
dom.btnNotesToggle.classList.toggle("active", on);
|
|
@@ -4533,16 +4062,23 @@ ${ta.value}`;
|
|
|
4533
4062
|
const card = e.target.closest(".thumb-card"); if (!card) return;
|
|
4534
4063
|
e.preventDefault();
|
|
4535
4064
|
const idx = +card.dataset.idx;
|
|
4536
|
-
|
|
4065
|
+
// na seleção múltipla, o clique direito foca sem desmarcar; fora dela, seleciona só este
|
|
4066
|
+
if (selectedSlides().includes(idx)) { state.currentSlideIndex = idx; markActiveThumb(); renderCurrentSlide(); }
|
|
4067
|
+
else selectSlide(idx);
|
|
4068
|
+
const sel = selectedSlides(), n = sel.length, varios = n > 1;
|
|
4069
|
+
const nome = (um, muitos) => (varios ? muitos.replace("{}", String(n)) : um);
|
|
4537
4070
|
contextMenu(e, [
|
|
4071
|
+
{ label: nome("Copiar slide", "Copiar {} slides"), ic: "copy", fn: () => copySelectedSlides() },
|
|
4072
|
+
{ label: "Colar slide", ic: "clipboard-paste", fn: () => pasteCopiedSlide() },
|
|
4073
|
+
{ sep: true },
|
|
4538
4074
|
{ label: "Novo slide depois deste", ic: "plus", fn: () => openSceneLibrary() },
|
|
4539
|
-
{ label: "Duplicar slide", ic: "copy", fn: () => duplicateCurrentSlide() },
|
|
4540
|
-
{ label: "Mover para cima", ic: "arrow-up", disabled: idx === 0, fn: () => moveSlide(idx, -1) },
|
|
4541
|
-
{ label: "Mover para baixo", ic: "arrow-down", disabled: idx === state.deck.slides.length - 1, fn: () => moveSlide(idx, 1) },
|
|
4075
|
+
{ label: "Duplicar slide", ic: "copy", fn: () => duplicateCurrentSlide(), disabled: varios },
|
|
4076
|
+
{ label: "Mover para cima", ic: "arrow-up", disabled: varios || idx === 0, fn: () => moveSlide(idx, -1) },
|
|
4077
|
+
{ label: "Mover para baixo", ic: "arrow-down", disabled: varios || idx === state.deck.slides.length - 1, fn: () => moveSlide(idx, 1) },
|
|
4542
4078
|
{ label: "Pedir à IA para melhorar este slide", ic: "sparkles", fn: () => askAI(`Melhore o slide ${idx + 1}: `) },
|
|
4543
4079
|
{ label: "Apresentar a partir daqui", ic: "play", fn: () => startPresentation() },
|
|
4544
4080
|
{ sep: true },
|
|
4545
|
-
{ label: "Excluir slide", ic: "trash-2", danger: true, fn: () => deleteCurrentSlide() },
|
|
4081
|
+
{ label: nome("Excluir slide", "Excluir {} slides"), ic: "trash-2", danger: true, fn: () => deleteCurrentSlide() },
|
|
4546
4082
|
]);
|
|
4547
4083
|
});
|
|
4548
4084
|
|