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.
- 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 +44 -11
- package/src/ai/document-visuals.js +90 -15
- package/src/ai/quality.js +1 -1
- package/src/ai/transform.js +51 -2
- package/src/diff.js +44 -0
- package/src/export/pptx.js +8 -6
- package/src/figures/charts.js +4 -2
- package/src/figures/diagrams.js +37 -9
- package/src/fiscal/autofix.js +1 -1
- package/src/fiscal/enxugar.js +22 -0
- 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 +2 -2
- 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/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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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. (imagens: peça no texto — 'com fotos', 'você decide onde ilustrar'… sem pedir, não gera)"></textarea>
|
|
812
|
-
<
|
|
813
|
-
<
|
|
814
|
-
<
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
<
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
<
|
|
823
|
-
|
|
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(
|
|
45
|
-
$("#uname").textContent =
|
|
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
|
-
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
};
|