sagadeck 1.5.0 → 1.7.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 +65 -67
- package/SKILL.md +19 -11
- package/bin/sagadeck.js +14 -11
- package/docs/ANDAMENTO.md +117 -2
- package/docs/REFERENCIA.md +8 -5
- package/package.json +5 -6
- package/src/ai/deck-ai.js +1 -1
- package/src/ai/ia-config.js +107 -0
- package/src/ai/llm.js +62 -26
- package/src/api-client.js +106 -5
- package/src/library.js +8 -4
- package/src/motion.js +2 -2
- package/src/preferences.js +3 -0
- package/src/research/research.js +6 -5
- package/src/runtime/motion.js +5 -1
- package/src/studio/ai-chat-route.js +1 -1
- package/src/studio/api-routes.js +11 -0
- package/src/studio/banner.js +18 -0
- package/src/studio/ia-routes.js +53 -0
- package/src/studio/open-browser.js +17 -0
- package/src/studio/public/ai-settings.js +86 -0
- package/src/studio/public/api-envs-form.js +148 -0
- package/src/studio/public/app.js +6 -24
- package/src/studio/public/index.html +6 -0
- package/src/studio/public/library.css +4 -0
- package/src/studio/public/library.html +3 -1
- package/src/studio/public/library.js +8 -4
- package/src/studio/public/preferences-ui.js +3 -0
- package/src/studio/public/style.css +23 -0
- package/src/studio/server.js +6 -18
- package/src/studio/slide-copy.js +3 -1
- package/src/ai/relay.js +0 -45
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Tela "Configurar IA" do Studio: ler (a chave nunca sai inteira), salvar e testar a configuração da IA desta máquina
|
|
2
|
+
// (src/ai/ia-config.js). No multiusuário quem configura é quem administra o servidor (arquivo ou variáveis), não a tela.
|
|
3
|
+
import { PROVIDERS, ROLES, RECOMMENDED, iaFile, loadIA, saveIA, maskedIA, keyOf } from "../ai/ia-config.js";
|
|
4
|
+
import { chat, llmConfig, llmAvailable } from "../ai/llm.js";
|
|
5
|
+
|
|
6
|
+
const json = (res, status, data) => { res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify(data)); };
|
|
7
|
+
|
|
8
|
+
// o que veio da tela; a chave em branco mantém a que já está gravada
|
|
9
|
+
function draft(body, current) {
|
|
10
|
+
const provider = String(body.provider || "");
|
|
11
|
+
const preset = PROVIDERS[provider];
|
|
12
|
+
return {
|
|
13
|
+
provider: preset ? provider : "outro",
|
|
14
|
+
url: String(body.url || preset?.url || "").trim(),
|
|
15
|
+
key: String(body.key || "").trim() || current?.key || "",
|
|
16
|
+
keyEnv: body.key ? "" : String(body.keyEnv ?? current?.keyEnv ?? preset?.keyEnv ?? ""),
|
|
17
|
+
models: Object.fromEntries(ROLES.map((r) => [r, String(body.models?.[r] || "").trim()])),
|
|
18
|
+
...(current?.headers ? { headers: current.headers } : {}),
|
|
19
|
+
...(current?.adaptador ? { adaptador: current.adaptador } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function iaRoutes({ req, res, pathname, opts, readJSON }) {
|
|
24
|
+
if (pathname === "/api/ia" && req.method === "GET") {
|
|
25
|
+
const cfg = llmConfig();
|
|
26
|
+
json(res, 200, { config: maskedIA(loadIA()), file: iaFile(), source: cfg.source, configured: !!(cfg.url || cfg.adaptador), providers: PROVIDERS, roles: ROLES, recommended: RECOMMENDED, editable: !opts.multiuser });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if ((pathname === "/api/ia" || pathname === "/api/ia/test") && req.method === "POST") {
|
|
30
|
+
if (opts.multiuser) { json(res, 403, { error: "No servidor multiusuário, a IA é configurada por quem administra." }); return true; }
|
|
31
|
+
const body = await readJSON(req);
|
|
32
|
+
const next = draft(body, loadIA());
|
|
33
|
+
if (!next.url) { json(res, 400, { error: "Falta o endereço do provedor." }); return true; }
|
|
34
|
+
if (!next.models.text) { json(res, 400, { error: "Falta o modelo de texto." }); return true; }
|
|
35
|
+
if (pathname === "/api/ia") {
|
|
36
|
+
try { const saved = saveIA(next); await llmAvailable({ force: true }); json(res, 200, { ok: true, config: maskedIA(saved) }); }
|
|
37
|
+
catch (e) { json(res, 400, { error: e.message }); }
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
// testar: uma pergunta curta com o que está na tela (antes de salvar)
|
|
41
|
+
const base = llmConfig();
|
|
42
|
+
const cfg = { ...base, url: next.url.replace(/\/+$/, ""), key: keyOf(next), headers: next.headers || {}, adaptador: next.adaptador || "",
|
|
43
|
+
textModel: next.models.text, visionModel: next.models.vision || next.models.text, imageModel: next.models.image || "image", searchModel: next.models.search || "",
|
|
44
|
+
timeoutMs: 60_000, firstTimeoutMs: 60_000 };
|
|
45
|
+
const t0 = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
const r = await chat([{ role: "user", content: "Responda só com a palavra: ok" }], { cfg, maxTokens: 200, think: false, allowTruncated: true, retries: 0 });
|
|
48
|
+
json(res, 200, { ok: true, text: String(r.text || "").trim().slice(0, 80), model: r.model || cfg.textModel, ms: Date.now() - t0 });
|
|
49
|
+
} catch (e) { json(res, 200, { ok: false, error: e.message, ms: Date.now() - t0 }); }
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `sagadeck studio` abre o navegador na página certa: quem acabou de instalar não precisa achar o endereço no meio do
|
|
2
|
+
// que o terminal escreveu. Só quando faz sentido: alguém no terminal (não um serviço, não um teste, não a CI), sem
|
|
3
|
+
// servidor multiusuário e sem --sem-navegador / SAGADECK_NO_BROWSER=1.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export function shouldOpenBrowser({ flags = {}, env = process.env, isTTY = process.stdout.isTTY, multiuser = false, platform = process.platform } = {}) {
|
|
7
|
+
if (flags["sem-navegador"] || flags["no-browser"] || env.SAGADECK_NO_BROWSER === "1") return false;
|
|
8
|
+
if (multiuser || env.CI || !isTTY) return false;
|
|
9
|
+
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false; // servidor sem tela
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function openBrowser(url, { platform = process.platform, spawnFn = spawn } = {}) {
|
|
14
|
+
const [cmd, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
15
|
+
try { const child = spawnFn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }); child.on?.("error", () => {}); child.unref?.(); return true; }
|
|
16
|
+
catch { return false; }
|
|
17
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Tela "Configurar IA" (editor e biblioteca): provedor, chave e modelos desta máquina, com Testar antes de salvar.
|
|
2
|
+
// A chave fica só nesta máquina (~/.sagadeck/ia.json) e nunca volta inteira para a página.
|
|
3
|
+
window.SagaAISettings = (() => {
|
|
4
|
+
const css = `.ai-cfg{max-width:min(560px,calc(100vw - 32px));width:100%;border:1px solid var(--border,#c9ced6);border-radius:12px;padding:18px 20px;background:var(--panel,var(--bg,#fff));color:var(--text,#1d2433);font:14px/1.45 system-ui,sans-serif}
|
|
5
|
+
.ai-cfg::backdrop{background:rgba(0,0,0,.35)}.ai-cfg h2{margin:0 0 6px;font-size:18px}.ai-cfg p{margin:4px 0 10px;color:var(--text-2,#555)}
|
|
6
|
+
.ai-cfg label{display:block;margin:10px 0 3px;font-weight:600;font-size:12px}.ai-cfg input,.ai-cfg select{width:100%;box-sizing:border-box;padding:7px 9px;border:1px solid var(--border,#c9ced6);border-radius:8px;background:var(--bg,#fff);color:inherit;font:inherit}
|
|
7
|
+
.ai-cfg .grid{display:grid;grid-template-columns:1fr 1fr;gap:0 12px}.ai-cfg small{color:var(--text-3,#777);font-size:12px}.ai-cfg .warn{background:#fef3c7;color:#78350f;border-radius:8px;padding:8px 10px}
|
|
8
|
+
.ai-cfg .row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;flex-wrap:wrap}.ai-cfg button{padding:7px 14px;border-radius:8px;border:1px solid var(--border,#c9ced6);background:transparent;color:inherit;cursor:pointer;font:inherit}
|
|
9
|
+
.ai-cfg .rec{background:var(--selected,#eef);border-radius:8px;padding:8px 10px;margin:6px 0 4px}.ai-cfg .rec button{margin-top:6px;display:block}
|
|
10
|
+
.ai-cfg button.primary{background:var(--brand,#5b4bdb);border-color:var(--brand,#5b4bdb);color:#fff}.ai-cfg [data-status]{margin-top:10px;min-height:1.4em}.ai-cfg [data-status].ok{color:#15803d}.ai-cfg [data-status].err{color:#b91c1c}`;
|
|
11
|
+
const LABEL = { text: "Modelo de texto", vision: "Modelo que vê imagens", image: "Modelo de imagem", search: "Modelo de busca na web" };
|
|
12
|
+
const HINT = { text: "obrigatório", vision: "opcional; vazio = o de texto", image: "opcional; para gerar ilustrações", search: "opcional; no OpenRouter, um modelo :online" };
|
|
13
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
14
|
+
let dialog, info, onSaved = () => {};
|
|
15
|
+
|
|
16
|
+
function build() {
|
|
17
|
+
if (!document.getElementById("ai-cfg-style")) { const st = document.createElement("style"); st.id = "ai-cfg-style"; st.textContent = css; document.head.append(st); }
|
|
18
|
+
dialog = document.createElement("dialog");
|
|
19
|
+
dialog.className = "ai-cfg";
|
|
20
|
+
dialog.id = "ai-settings-dialog";
|
|
21
|
+
document.body.append(dialog);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function form() {
|
|
25
|
+
const c = info.config || {}, providers = info.providers || {};
|
|
26
|
+
const provider = c.provider && (providers[c.provider] || c.provider === "outro") ? c.provider : (c.url ? "outro" : "openrouter");
|
|
27
|
+
dialog.innerHTML = `<h2>Configurar IA</h2>
|
|
28
|
+
<p>O sagadeck fala direto com o provedor que você escolher. A chave fica só nesta máquina, em <code>${esc(info.file)}</code>.</p>
|
|
29
|
+
${info.source === "variável" ? '<p class="warn">As variáveis SAGADECK_LLM_URL / SAGADECK_LLM_KEY estão definidas e valem por cima desta tela.</p>' : ""}
|
|
30
|
+
${info.editable ? "" : '<p class="warn">Neste servidor, quem configura a IA é quem administra.</p>'}
|
|
31
|
+
${info.recommended ? `<div class="rec"><b>Recomendado para começar:</b> ${esc(providers[info.recommended.provider]?.label || info.recommended.provider)}, com ${esc(info.recommended.models.text)} (texto e visão), ${esc(info.recommended.models.image)} (imagem) e ${esc(info.recommended.models.search)} (busca). Se a IA não responder nem com isso, o problema é a rede ou a chave, não o modelo. Depois, troque pelo modelo que quiser. <button type="button" data-rec>Usar a recomendação</button></div>` : ""}
|
|
32
|
+
<label for="ai-cfg-provider">Provedor</label>
|
|
33
|
+
<select id="ai-cfg-provider" data-f="provider">${Object.entries(providers).map(([k, p]) => `<option value="${k}">${esc(p.label)}</option>`).join("")}<option value="outro">Outro compatível com a API da OpenAI</option></select>
|
|
34
|
+
<label for="ai-cfg-url">Endereço da API</label>
|
|
35
|
+
<input id="ai-cfg-url" data-f="url" placeholder="https://…/v1" spellcheck="false">
|
|
36
|
+
<label for="ai-cfg-key">Chave</label>
|
|
37
|
+
<input id="ai-cfg-key" data-f="key" type="password" autocomplete="off" spellcheck="false" placeholder="${c.keySet ? `configurada (${esc(c.keyHint)}${c.keyFrom === "variável" ? `, da variável ${esc(c.keyEnv)}` : ""}); em branco mantém` : "cole a chave do provedor"}">
|
|
38
|
+
<div class="grid">${(info.roles || []).map((r) => `<div><label for="ai-cfg-${r}">${LABEL[r] || r}</label><input id="ai-cfg-${r}" data-model="${r}" value="${esc(c.models?.[r])}" placeholder="${info.recommended?.models?.[r] ? `ex.: ${esc(info.recommended.models[r])}` : ""}" spellcheck="false"><small>${HINT[r] || ""}</small></div>`).join("")}</div>
|
|
39
|
+
<p><small>Texto e visão funcionam em qualquer provedor compatível com a API da OpenAI. Gerar imagem e buscar na web dependem do provedor: funcionam no OpenRouter com modelos de imagem e modelos <code>:online</code>; em outros, a pesquisa usa buscadores diretos e a imagem pode não sair.</small></p>
|
|
40
|
+
<div data-status role="status"></div>
|
|
41
|
+
<div class="row"><button type="button" data-close>Fechar</button><button type="button" data-test>Testar</button><button type="button" class="primary" data-save>Salvar</button></div>`;
|
|
42
|
+
const sel = dialog.querySelector('[data-f="provider"]'), url = dialog.querySelector('[data-f="url"]');
|
|
43
|
+
sel.value = provider;
|
|
44
|
+
url.value = c.url || providers[provider]?.url || "";
|
|
45
|
+
const sync = () => { const p = providers[sel.value]; if (p) url.value = p.url; url.readOnly = !!p; };
|
|
46
|
+
sync();
|
|
47
|
+
sel.onchange = sync;
|
|
48
|
+
if (!info.editable) dialog.querySelectorAll("input,select,[data-save],[data-test]").forEach((el) => (el.disabled = true));
|
|
49
|
+
dialog.querySelector("[data-close]").onclick = () => dialog.close();
|
|
50
|
+
const rec = dialog.querySelector("[data-rec]");
|
|
51
|
+
if (rec) rec.onclick = () => {
|
|
52
|
+
sel.value = info.recommended.provider; sync();
|
|
53
|
+
for (const [r, m] of Object.entries(info.recommended.models)) { const el = dialog.querySelector(`[data-model="${r}"]`); if (el) el.value = m; }
|
|
54
|
+
const st = dialog.querySelector("[data-status]"); st.className = ""; st.textContent = info.config?.keySet ? "Preenchido. Clique em Testar." : "Preenchido. Cole a chave do OpenRouter e clique em Testar.";
|
|
55
|
+
dialog.querySelector('[data-f="key"]').focus();
|
|
56
|
+
};
|
|
57
|
+
dialog.querySelector("[data-test]").onclick = () => submit("api/ia/test");
|
|
58
|
+
dialog.querySelector("[data-save]").onclick = () => submit("api/ia");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function submit(endpoint) {
|
|
62
|
+
const status = dialog.querySelector("[data-status]");
|
|
63
|
+
const body = { provider: dialog.querySelector('[data-f="provider"]').value, url: dialog.querySelector('[data-f="url"]').value, key: dialog.querySelector('[data-f="key"]').value,
|
|
64
|
+
models: Object.fromEntries([...dialog.querySelectorAll("[data-model]")].map((el) => [el.dataset.model, el.value])) };
|
|
65
|
+
status.className = ""; status.textContent = endpoint.endsWith("test") ? "Testando…" : "Salvando…";
|
|
66
|
+
try {
|
|
67
|
+
const r = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
68
|
+
const j = await r.json();
|
|
69
|
+
if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
|
|
70
|
+
if (endpoint.endsWith("test")) { status.className = "ok"; status.textContent = `Funcionou: o modelo ${j.model} respondeu "${j.text}" em ${(j.ms / 1000).toFixed(1)} s.`; return; }
|
|
71
|
+
info.config = j.config;
|
|
72
|
+
status.className = "ok"; status.textContent = "Salvo.";
|
|
73
|
+
onSaved(j.config);
|
|
74
|
+
} catch (e) { status.className = "err"; status.textContent = (endpoint.endsWith("test") ? "Não funcionou: " : "Não salvou: ") + e.message; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async open(saved) {
|
|
79
|
+
onSaved = typeof saved === "function" ? saved : () => {};
|
|
80
|
+
if (!dialog) build();
|
|
81
|
+
info = await (await fetch("api/ia")).json();
|
|
82
|
+
form();
|
|
83
|
+
if (!dialog.open) dialog.showModal();
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
})();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Ambientes dos slides de API por campos: quem instala do zero preenche endereço, variáveis, segredos e autenticação
|
|
2
|
+
// sem escrever YAML (o arquivo é o mesmo, ~/.sagadeck/ambientes.yaml; a aba "Como texto" continua para quem prefere).
|
|
3
|
+
// Segredo digitado vai cifrado para o arquivo e nunca volta para a página. No servidor multiusuário (ou Studio aberto
|
|
4
|
+
// para a rede) os slides de API não executam: o botão Ambientes nem aparece.
|
|
5
|
+
window.SagaApiEnvsForm = (() => {
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
8
|
+
const svg = (n) => `<i class="ic" data-ic="${n}"><svg viewBox="0 0 24 24" aria-hidden="true">${(window.UI_ICONS || {})[n] || ""}</svg></i>`;
|
|
9
|
+
let st = null, tab = "form";
|
|
10
|
+
const status = (text, kind = "") => { const el = $("api-envs-status"); if (el) { el.textContent = text; el.className = `sf-hint api-envs-status ${kind}`; } };
|
|
11
|
+
|
|
12
|
+
// sem execução neste Studio (multiusuário, aberto para a rede): o botão some
|
|
13
|
+
fetch("api/http/state").then((r) => r.json()).then((s) => { if (s && s.live === false) $("btn-api-envs")?.setAttribute("hidden", ""); }).catch(() => {});
|
|
14
|
+
|
|
15
|
+
const row = (kind, item = {}) => kind === "var"
|
|
16
|
+
? `<div class="aef-row" data-row="var"><input data-k="name" placeholder="nome (ex.: modelo)" value="${esc(item.name)}" spellcheck="false" aria-label="Nome da variável"><input data-k="value" placeholder="valor" value="${esc(item.value)}" spellcheck="false" aria-label="Valor da variável"><button type="button" class="icon-btn" data-del title="Tirar">${svg("x")}</button></div>`
|
|
17
|
+
: `<div class="aef-row" data-row="secret"><input data-k="name" placeholder="nome (ex.: senha)" value="${esc(item.name)}" spellcheck="false" aria-label="Nome do segredo"><input data-k="value" type="password" autocomplete="off" placeholder="${item.set ? "guardado; em branco mantém" : "valor (guardado cifrado)"}" aria-label="Valor do segredo"><input data-k="env" placeholder="ou variável de ambiente" value="${esc(item.env)}" spellcheck="false" aria-label="Variável de ambiente do segredo"><button type="button" class="icon-btn" data-del title="Tirar">${svg("x")}</button></div>`;
|
|
18
|
+
|
|
19
|
+
function card(e, current) {
|
|
20
|
+
const a = e.auth || { type: "nenhuma" };
|
|
21
|
+
return `<section class="aef-env" data-env>
|
|
22
|
+
<div class="aef-head"><input data-k="envname" value="${esc(e.name)}" placeholder="nome (dev, hom, prod)" spellcheck="false" aria-label="Nome do ambiente">
|
|
23
|
+
<label class="aef-cur"><input type="radio" name="aef-current" ${e.name === current ? "checked" : ""}> em uso</label>
|
|
24
|
+
<button type="button" class="btn btn-secondary btn-sm" data-test>Testar</button>
|
|
25
|
+
<button type="button" class="icon-btn" data-remove title="Tirar este ambiente">${svg("trash-2")}</button></div>
|
|
26
|
+
<label>Endereço base <small>vira <code>{{base}}</code> nos slides</small></label>
|
|
27
|
+
<input data-k="base" value="${esc(e.base)}" placeholder="https://api.exemplo.com/v1" spellcheck="false">
|
|
28
|
+
<label>Variáveis <small><code>{{nome}}</code> nos slides</small></label>
|
|
29
|
+
<div data-list="var">${(e.vars || []).map((v) => row("var", v)).join("")}</div>
|
|
30
|
+
<button type="button" class="btn-link" data-add="var">+ variável</button>
|
|
31
|
+
<label>Segredos <small><code>{{secret.nome}}</code> nos slides; nunca vão para o deck</small></label>
|
|
32
|
+
<div data-list="secret">${(e.secrets || []).map((v) => row("secret", v)).join("")}</div>
|
|
33
|
+
<button type="button" class="btn-link" data-add="secret">+ segredo</button>
|
|
34
|
+
<label>Autenticação</label>
|
|
35
|
+
<select data-k="auth"><option value="nenhuma">Nenhuma</option><option value="bearer">Token fixo (Authorization: Bearer)</option><option value="token">Token que expira (client credentials)</option></select>
|
|
36
|
+
<div class="aef-auth" data-auth="bearer"><input data-k="bearer" type="password" autocomplete="off" placeholder="${a.type === "bearer" && a.secretSet ? "token guardado; em branco mantém" : "o token"}" aria-label="Token fixo"><input data-k="bearerEnv" placeholder="ou variável de ambiente" value="${esc(a.type === "bearer" ? a.secretEnv : "")}" spellcheck="false" aria-label="Variável do token"></div>
|
|
37
|
+
<div class="aef-auth" data-auth="token"><input data-k="tokenUrl" placeholder="endereço do token (https://…/token)" value="${esc(a.url)}" spellcheck="false" aria-label="Endereço do token"><input data-k="clientId" placeholder="client id" value="${esc(a.client_id)}" spellcheck="false" aria-label="Client id"><input data-k="clientSecret" type="password" autocomplete="off" placeholder="${a.type === "token" && a.secretSet ? "client secret guardado; em branco mantém" : "client secret"}" aria-label="Client secret"><input data-k="clientSecretEnv" placeholder="ou variável de ambiente do secret" value="${esc(a.type === "token" ? a.secretEnv : "")}" spellcheck="false" aria-label="Variável do client secret"><input data-k="field" placeholder="campo do token na resposta (access_token)" value="${esc(a.field)}" spellcheck="false" aria-label="Campo do token"></div>
|
|
38
|
+
${e.advanced ? '<p class="muted aef-note">Este ambiente tem configurações avançadas (certificado, outros cabeçalhos); elas ficam como estão. Veja na aba Como texto.</p>' : ""}
|
|
39
|
+
<div class="aef-result" data-result></div>
|
|
40
|
+
</section>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function wire(root) {
|
|
44
|
+
root.querySelectorAll("[data-env]").forEach((sec) => {
|
|
45
|
+
const sel = sec.querySelector('[data-k="auth"]');
|
|
46
|
+
const show = () => sec.querySelectorAll("[data-auth]").forEach((d) => { d.hidden = d.dataset.auth !== sel.value; });
|
|
47
|
+
sel.onchange = show;
|
|
48
|
+
sec.querySelector("[data-remove]").onclick = () => { sec.remove(); empty(); };
|
|
49
|
+
sec.querySelector("[data-test]").onclick = () => test(sec);
|
|
50
|
+
sec.querySelectorAll("[data-add]").forEach((b) => (b.onclick = () => { sec.querySelector(`[data-list="${b.dataset.add}"]`).insertAdjacentHTML("beforeend", row(b.dataset.add)); wireRows(sec); }));
|
|
51
|
+
wireRows(sec);
|
|
52
|
+
show();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const wireRows = (sec) => sec.querySelectorAll("[data-del]").forEach((b) => (b.onclick = () => b.closest(".aef-row").remove()));
|
|
56
|
+
function empty() {
|
|
57
|
+
const intro = $("api-envs-form").querySelector("[data-intro]");
|
|
58
|
+
if (intro) intro.hidden = !!$("api-envs-form").querySelector("[data-env]");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function render() {
|
|
62
|
+
const box = $("api-envs-form");
|
|
63
|
+
box.innerHTML = `<div class="aef-intro" data-intro><p><b>Você ainda não tem ambientes seus.</b> O <b>ENSAIO</b> (uma API de mentira que vem com o sagadeck) já funciona nos slides de API, sem configurar nada.</p><p>Para ligar a sua API, adicione um ambiente: o endereço base, as variáveis, os segredos e como ela autentica. Fica gravado nesta máquina, em <code>${esc(st.file)}</code>.</p></div>
|
|
64
|
+
<div data-envs>${st.envs.map((e) => card(e, st.current)).join("")}</div>
|
|
65
|
+
<button type="button" class="btn btn-secondary" data-new>${svg("plus")} Adicionar ambiente</button>`;
|
|
66
|
+
box.querySelectorAll("[data-env]").forEach((sec, i) => { sec.querySelector('[data-k="auth"]').value = st.envs[i].auth?.type || "nenhuma"; });
|
|
67
|
+
box.querySelector("[data-new]").onclick = () => {
|
|
68
|
+
const names = new Set([...box.querySelectorAll('[data-k="envname"]')].map((i) => i.value));
|
|
69
|
+
const name = ["dev", "hom", "prod"].find((n) => !names.has(n)) || `ambiente${names.size + 1}`;
|
|
70
|
+
box.querySelector("[data-envs]").insertAdjacentHTML("beforeend", card({ name, base: "", vars: [], secrets: [], auth: { type: "nenhuma" } }, names.size ? st.current : name));
|
|
71
|
+
wire(box.querySelector("[data-envs]").lastElementChild.parentElement);
|
|
72
|
+
empty();
|
|
73
|
+
box.querySelector("[data-envs]").lastElementChild.querySelector('[data-k="base"]').focus();
|
|
74
|
+
};
|
|
75
|
+
wire(box);
|
|
76
|
+
empty();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collect() {
|
|
80
|
+
const envs = [], box = $("api-envs-form");
|
|
81
|
+
let current = null;
|
|
82
|
+
for (const sec of box.querySelectorAll("[data-env]")) {
|
|
83
|
+
const v = (k) => sec.querySelector(`[data-k="${k}"]`)?.value.trim() || "";
|
|
84
|
+
const name = v("envname");
|
|
85
|
+
if (sec.querySelector('input[name="aef-current"]').checked) current = name;
|
|
86
|
+
const rows = (kind) => [...sec.querySelectorAll(`[data-row="${kind}"]`)].map((r) => Object.fromEntries([...r.querySelectorAll("[data-k]")].map((i) => [i.dataset.k, i.value.trim()]))).filter((r) => r.name);
|
|
87
|
+
const type = v("auth");
|
|
88
|
+
const auth = type === "bearer" ? { type, value: v("bearer"), secretEnv: v("bearerEnv") }
|
|
89
|
+
: type === "token" ? { type, url: v("tokenUrl"), client_id: v("clientId"), value: v("clientSecret"), secretEnv: v("clientSecretEnv"), field: v("field") }
|
|
90
|
+
: { type: "nenhuma" };
|
|
91
|
+
envs.push({ name, base: v("base"), vars: rows("var"), secrets: rows("secret"), auth });
|
|
92
|
+
}
|
|
93
|
+
return { envs, current };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function save() {
|
|
97
|
+
status("Salvando…");
|
|
98
|
+
const r = await fetch("api/http/ambientes/form", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()) });
|
|
99
|
+
const j = await r.json().catch(() => ({}));
|
|
100
|
+
if (!r.ok) { status(`Não salvou: ${j.error || r.status}`, "err"); return false; }
|
|
101
|
+
st = j;
|
|
102
|
+
render();
|
|
103
|
+
status(`Salvo em ${st.file}. Os slides de API já usam os ambientes novos.`, "ok");
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function test(sec) {
|
|
108
|
+
const out = sec.querySelector("[data-result]");
|
|
109
|
+
out.className = "aef-result"; out.textContent = "Salvando e testando…";
|
|
110
|
+
if (!(await save())) { out.textContent = ""; return; }
|
|
111
|
+
const name = sec.querySelector('[data-k="envname"]')?.value.trim();
|
|
112
|
+
const again = [...$("api-envs-form").querySelectorAll("[data-env]")].find((s) => s.querySelector('[data-k="envname"]').value === name);
|
|
113
|
+
const res = again?.querySelector("[data-result]") || out;
|
|
114
|
+
try {
|
|
115
|
+
const j = await (await fetch("api/http/ambientes/testar", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) })).json();
|
|
116
|
+
if (j.error) throw new Error(j.error);
|
|
117
|
+
const parts = [j.base ? (j.base.ok ? `endereço respondeu (HTTP ${j.base.status})` : `endereço não respondeu: ${j.base.error}`) : "sem endereço base",
|
|
118
|
+
...(j.token ? [j.token.ok ? `token obtido (…${j.token.last4})` : `token falhou: ${j.token.error}`] : [])];
|
|
119
|
+
const ok = (!j.base || j.base.ok) && (!j.token || j.token.ok);
|
|
120
|
+
res.className = `aef-result ${ok ? "ok" : "err"}`;
|
|
121
|
+
res.textContent = (ok ? "Funcionou: " : "Problema: ") + parts.join("; ") + ".";
|
|
122
|
+
} catch (e) { res.className = "aef-result err"; res.textContent = `Não testou: ${e.message}`; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setTab(t) {
|
|
126
|
+
tab = t;
|
|
127
|
+
document.querySelectorAll("[data-envs-tab]").forEach((b) => b.classList.toggle("active", b.dataset.envsTab === t));
|
|
128
|
+
$("api-envs-form").hidden = t !== "form";
|
|
129
|
+
$("api-envs-textmode").hidden = t !== "text";
|
|
130
|
+
$("btn-api-envs-save").disabled = false;
|
|
131
|
+
status("");
|
|
132
|
+
if (t === "text") $("api-envs-text").dispatchEvent(new Event("input")); // a aba de texto valida antes de liberar o Salvar
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function open() {
|
|
136
|
+
document.querySelectorAll("[data-envs-tab]").forEach((b) => (b.onclick = async () => { if (b.dataset.envsTab === "text") { const j = await (await fetch("api/http/ambientes")).json().catch(() => ({})); if (j.text != null) $("api-envs-text").value = j.text; } else await load(); setTab(b.dataset.envsTab); }));
|
|
137
|
+
await load();
|
|
138
|
+
setTab("form");
|
|
139
|
+
}
|
|
140
|
+
async function load() {
|
|
141
|
+
const r = await fetch("api/http/ambientes/form");
|
|
142
|
+
st = await r.json().catch(() => ({ envs: [] }));
|
|
143
|
+
if (!r.ok) { status(st.error || `HTTP ${r.status}`, "err"); st = { envs: [], file: "" }; }
|
|
144
|
+
render();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { open, save, active: () => tab === "form" };
|
|
148
|
+
})();
|
package/src/studio/public/app.js
CHANGED
|
@@ -3032,27 +3032,8 @@
|
|
|
3032
3032
|
// ==========================================================================
|
|
3033
3033
|
// LLM: STATUS E GERAÇÃO DE DECK
|
|
3034
3034
|
// ==========================================================================
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
const setupWindow = window.open("about:blank", "sagadeck-ai-setup");
|
|
3038
|
-
let dialog = document.getElementById("ai-settings-dialog");
|
|
3039
|
-
if (!dialog) {
|
|
3040
|
-
dialog = document.createElement("dialog"); dialog.id = "ai-settings-dialog"; dialog.className = "ai-settings-dialog";
|
|
3041
|
-
dialog.innerHTML = '<div class="ai-settings-head"><h2>Configurar IA</h2><button type="button" data-close>Fechar</button></div><p data-ai-info>Verificando a conexão...</p><a data-ai-link target="_blank" rel="noopener" hidden>Abrir configuração do modelrelay</a><button type="button" data-ai-refresh>Verificar conexão</button>';
|
|
3042
|
-
document.body.append(dialog);dialog.querySelector('[data-close]').onclick=()=>dialog.close();
|
|
3043
|
-
dialog.addEventListener('close',()=>refreshAIStatus(true));
|
|
3044
|
-
dialog.querySelector('[data-ai-refresh]').onclick=()=>openAISettings();
|
|
3045
|
-
}
|
|
3046
|
-
if (!dialog.open) dialog.showModal();
|
|
3047
|
-
await refreshAIStatus(true);
|
|
3048
|
-
const info=dialog.querySelector('[data-ai-info]'), link=dialog.querySelector('[data-ai-link]');
|
|
3049
|
-
try {
|
|
3050
|
-
const setup=await (await fetch('api/ai/setup')).json();
|
|
3051
|
-
info.textContent=state.ai.available ? `Conectada · modelo de texto: ${state.ai.textModel}. A configuração abre em uma aba própria.` : 'O serviço de IA não está respondendo. Inicie modelrelay serve neste computador e clique em Verificar conexão.';
|
|
3052
|
-
if(setup.url){link.href=setup.url;link.hidden=false;if(setupWindow){setupWindow.opener=null;setupWindow.location.href=setup.url;dialog.close();}}
|
|
3053
|
-
else {setupWindow?.close();link.hidden=true;}
|
|
3054
|
-
} catch { setupWindow?.close();info.textContent='Não foi possível consultar a configuração. Verifique a conexão e tente novamente.'; }
|
|
3055
|
-
}
|
|
3035
|
+
// Configurar IA: provedor, chave e modelos desta máquina (ai-settings.js)
|
|
3036
|
+
function openAISettings() { window.SagaAISettings.open(() => refreshAIStatus(true)); }
|
|
3056
3037
|
|
|
3057
3038
|
async function refreshAIStatus(force = false) {
|
|
3058
3039
|
try {
|
|
@@ -3065,7 +3046,7 @@
|
|
|
3065
3046
|
dom.aiStatus.textContent = on ? "IA ligada" : "IA desligada";
|
|
3066
3047
|
dom.aiStatus.title = on
|
|
3067
3048
|
? `LLM em ${state.ai.url} · texto: ${state.ai.textModel} · imagem: ${state.ai.imageModel}`
|
|
3068
|
-
: `Nenhum LLM em ${state.ai.url
|
|
3049
|
+
: state.ai.configured ? `Nenhum LLM respondendo em ${state.ai.url}. Clique para conferir o provedor e a chave.` : "A IA não está configurada. Clique para informar provedor e chave.";
|
|
3069
3050
|
dom.aiStatus.classList.toggle("on", on);
|
|
3070
3051
|
dom.aiStatus.classList.toggle("off", !on);
|
|
3071
3052
|
}
|
|
@@ -3073,7 +3054,7 @@
|
|
|
3073
3054
|
function openAiDeckModal() {
|
|
3074
3055
|
dom.aiDeckTheme.innerHTML = '<option value="">A IA escolhe</option>' +
|
|
3075
3056
|
(state.themes || []).map((t) => `<option value="${t}">${t}</option>`).join("");
|
|
3076
|
-
dom.aiDeckStatus.textContent = state.ai.available ? "" :
|
|
3057
|
+
dom.aiDeckStatus.textContent = state.ai.available ? "" : "IA desligada: clique em IA desligada, no alto, para configurar antes de gerar.";
|
|
3077
3058
|
// cada abertura recomeça do sensato: 15 min viram 10 slides
|
|
3078
3059
|
dom.aiDeckMinutes.value = "15";
|
|
3079
3060
|
dom.aiDeckSlides.value = "10";
|
|
@@ -3449,6 +3430,7 @@ ${ta.value}`;
|
|
|
3449
3430
|
renderApiEnvChips(st);
|
|
3450
3431
|
if (!j.exists) apiEnvsStatus("Este arquivo ainda não existe. Validando o modelo antes de habilitar Salvar…");
|
|
3451
3432
|
void validateApiEnvs();
|
|
3433
|
+
await window.SagaApiEnvsForm.open(); // por campos é o padrão (api-envs-form.js); o texto fica na outra aba
|
|
3452
3434
|
}
|
|
3453
3435
|
function closeApiEnvs() { apiEnvsModal().classList.add("hidden"); }
|
|
3454
3436
|
async function saveApiEnvs() {
|
|
@@ -3934,7 +3916,7 @@ ${ta.value}`;
|
|
|
3934
3916
|
document.getElementById("btn-api-envs").addEventListener("click", openApiEnvs);
|
|
3935
3917
|
document.getElementById("btn-close-api-envs").addEventListener("click", closeApiEnvs);
|
|
3936
3918
|
document.getElementById("btn-api-envs-cancel").addEventListener("click", closeApiEnvs);
|
|
3937
|
-
document.getElementById("btn-api-envs-save").addEventListener("click", saveApiEnvs);
|
|
3919
|
+
document.getElementById("btn-api-envs-save").addEventListener("click", async () => { if (!window.SagaApiEnvsForm.active()) return saveApiEnvs(); if (await window.SagaApiEnvsForm.save()) await refreshStudioVars(); });
|
|
3938
3920
|
document.getElementById("btn-api-vars-studio").addEventListener("click", () => openPane("vars"));
|
|
3939
3921
|
document.getElementById("api-envs-text").addEventListener("input", scheduleApiEnvValidation);
|
|
3940
3922
|
// grade de variáveis: grava ao sair da célula (change) ou no Enter; Esc desfaz; a linha em branco do fim cria
|
|
@@ -748,11 +748,15 @@
|
|
|
748
748
|
<div class="modal-dialog-body api-envs-body">
|
|
749
749
|
<p class="muted api-envs-intro">Endereços, tokens e segredos ficam na sua máquina, nunca no deck (o deck pode ir para o GitHub).
|
|
750
750
|
Nos slides, <code>{{base}}</code> vira o <code>vars.base</code> do ambiente em uso; segredos entram como <code>{{secret.nome}}</code>.</p>
|
|
751
|
+
<div class="api-envs-tabs" role="tablist"><button type="button" role="tab" data-envs-tab="form" class="active">Por campos</button><button type="button" role="tab" data-envs-tab="text">Como texto (avançado)</button></div>
|
|
752
|
+
<div id="api-envs-form" class="api-envs-form"></div>
|
|
753
|
+
<div id="api-envs-textmode" hidden>
|
|
751
754
|
<p class="muted api-envs-safety">O YAML é validado antes de salvar. Uma cópia da versão anterior fica ao lado do arquivo como <code>.bak</code>.</p>
|
|
752
755
|
<div class="napkin-label">Ambiente em uso <span class="muted">— o selo no slide também troca</span></div>
|
|
753
756
|
<div id="api-envs-list" class="api-envs-list"></div>
|
|
754
757
|
<div class="napkin-label">Arquivo <code id="api-envs-file" class="muted"></code></div>
|
|
755
758
|
<textarea id="api-envs-text" class="form-control mono" rows="20" spellcheck="false" aria-label="Conteúdo do ambientes.yaml"></textarea>
|
|
759
|
+
</div>
|
|
756
760
|
<div id="api-envs-status" class="sf-hint api-envs-status"></div>
|
|
757
761
|
</div>
|
|
758
762
|
<div class="modal-dialog-footer">
|
|
@@ -842,6 +846,8 @@
|
|
|
842
846
|
<script src="fit.js"></script>
|
|
843
847
|
<script src="ui-icons.js"></script>
|
|
844
848
|
<script src="update-notice.js"></script>
|
|
849
|
+
<script src="ai-settings.js"></script>
|
|
850
|
+
<script src="api-envs-form.js"></script>
|
|
845
851
|
<script src="calc-fields.js"></script>
|
|
846
852
|
<script src="art-preview.js"></script>
|
|
847
853
|
<script src="api-collections.js"></script>
|
|
@@ -28,6 +28,10 @@ body.lib { display: grid; grid-template-columns: 218px minmax(0, 1fr); grid-temp
|
|
|
28
28
|
.lib-search { display: flex; align-items: center; gap: 9px; width: 228px; height: 37px; color: var(--text-3); padding: 0 11px; background: transparent; border: 1px solid var(--ui-line); border-radius: 8px; }
|
|
29
29
|
.lib-search input { background: none; color: var(--text); border: 0; outline: 0; width: 100%; min-width: 0; font-size: 11px; }
|
|
30
30
|
.lib-search kbd { border: 1px solid var(--ui-line); border-radius: 3px; font-size: 10px; padding: 0 4px; }
|
|
31
|
+
.ai-banner { display: flex; align-items: center; gap: 12px; margin: 10px 24px 0; padding: 10px 14px; border-radius: 10px; background: #fef3c7; color: #78350f; border: 1px solid #f59e0b; font-size: 13px; }
|
|
32
|
+
.ai-banner[hidden] { display: none; }
|
|
33
|
+
.ai-banner span { flex: 1; }
|
|
34
|
+
.ai-banner .ic { width: 18px; height: 18px; flex: none; }
|
|
31
35
|
.lib-btn { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 38px; padding: 0 15px; border-radius: 8px; border: 1px solid transparent; font-size: 12px; font-weight: 600; text-decoration: none; transition: background .15s, transform .15s; }
|
|
32
36
|
.lib-btn.primary { background: var(--brand-fill); color: var(--on-brand); box-shadow: 0 3px 8px #634ce916; }
|
|
33
37
|
.lib-btn.primary:hover { background: var(--brand-fill-hover); transform: translateY(-1px); }
|
|
@@ -28,10 +28,11 @@
|
|
|
28
28
|
<button class="lib-icon-btn" id="btn-theme" title="Alternar tema claro e escuro" aria-label="Alternar tema claro e escuro"><i class="ic" data-ic="sun-moon"></i></button>
|
|
29
29
|
<div class="lib-user" id="user" hidden><div class="lib-avatar" id="avatar"></div><span class="uname" id="uname"></span></div>
|
|
30
30
|
</header>
|
|
31
|
+
<div class="ai-banner" id="ai-banner" role="status" hidden><i class="ic" data-ic="sparkles"></i><span>A IA ainda não está configurada. Configure para gerar apresentações e conversar no chat (o resto já funciona).</span><button type="button" class="lib-btn primary" id="ai-banner-btn">Configurar IA</button></div>
|
|
31
32
|
<div class="lib-body">
|
|
32
33
|
<aside class="lib-side">
|
|
33
34
|
<nav id="side" aria-label="Biblioteca"></nav>
|
|
34
|
-
<
|
|
35
|
+
<button type="button" class="lib-btn ghost ai-settings" id="btn-ai" hidden title="Provedores, chaves e modelos de IA"><i class="ic" data-ic="sliders-horizontal"></i><span class="lbl">Configurar IA</span></button>
|
|
35
36
|
</aside>
|
|
36
37
|
<main class="lib-main" id="main" tabindex="-1" aria-live="polite"><div class="initial-loading"><span class="loading-orbit"></span>Preparando seu estúdio…</div></main>
|
|
37
38
|
</div>
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
<div class="ltoast" id="toast" role="status"></div>
|
|
52
53
|
<script src="ui-icons.js"></script>
|
|
53
54
|
<script src="update-notice.js"></script>
|
|
55
|
+
<script src="ai-settings.js"></script>
|
|
54
56
|
<script src="library.js"></script>
|
|
55
57
|
</body>
|
|
56
58
|
</html>
|
|
@@ -58,8 +58,12 @@
|
|
|
58
58
|
if (view !== "recentes" && view !== "todas" && view !== "lixeira" && !topicOf(view)) view = "recentes";
|
|
59
59
|
render();
|
|
60
60
|
}
|
|
61
|
-
// botão IA:
|
|
62
|
-
|
|
61
|
+
// botão IA: provedor, chave e modelos desta máquina (ai-settings.js); no multiusuário quem configura é quem administra
|
|
62
|
+
// e, enquanto a IA não estiver configurada, a faixa "Configure a IA" (some ao salvar)
|
|
63
|
+
const aiSaved = () => { $("#ai-banner").hidden = true; };
|
|
64
|
+
api("api/ia").then(({ editable, configured }) => { $("#btn-ai").hidden = !editable; $("#ai-banner").hidden = !editable || configured; hydrate($("#ai-banner")); }).catch(() => {});
|
|
65
|
+
$("#btn-ai").onclick = () => window.SagaAISettings.open(aiSaved);
|
|
66
|
+
$("#ai-banner-btn").onclick = () => window.SagaAISettings.open(aiSaved);
|
|
63
67
|
const openEditor = (id, present) => { location.href = `editor?deck=${encodeURIComponent(id)}${present ? "&present=1" : ""}`; };
|
|
64
68
|
|
|
65
69
|
// ---------------------------------------------------------------- barra lateral
|
|
@@ -111,7 +115,7 @@
|
|
|
111
115
|
const match = (d) => !q || d.title.toLowerCase().includes(q);
|
|
112
116
|
const main = $("#main");
|
|
113
117
|
if (view === "lixeira") {
|
|
114
|
-
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por um dia; depois é apagado de vez.</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
118
|
+
main.innerHTML = `<div class="head"><div><h1>Lixeira</h1><div class="sub">Fica aqui por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}; depois é apagado de vez (o prazo se muda em Preferências).</div></div>${data.trash.length ? `<button class="lib-btn ghost danger" data-empty-trash>${ic("trash-2")}Esvaziar lixeira</button>` : ""}</div>` +
|
|
115
119
|
(data.trash.length ? data.trash.map((t) => `<div class="trash-row"><div class="t"><b>${esc(t.title)}</b><div class="sub">Era de ${esc(t.topic || NO_TOPIC.name)} · ${t.slides || 0} slides · excluída ${ago(t.deleted)}</div></div>
|
|
116
120
|
<button class="lib-btn ghost" data-restore="${esc(t.id)}">${ic("rotate-ccw")}Restaurar</button><button class="lib-btn ghost danger" data-purge="${esc(t.id)}">Excluir de vez</button></div>`).join("")
|
|
117
121
|
: `<div class="empty"><div class="big">${ic("trash-2")}</div><h2>Lixeira vazia</h2></div>`);
|
|
@@ -211,7 +215,7 @@
|
|
|
211
215
|
if (a === "open" || a === "present") return openEditor(id, a === "present");
|
|
212
216
|
if (a === "rename") return nameDialog("Renomear apresentação", d.title, (title) => act(() => api("api/library/decks/rename", { id, title }), "Renomeada"));
|
|
213
217
|
if (a === "dup") return act(() => api("api/library/decks/duplicate", { id }), "Cópia criada");
|
|
214
|
-
if (a === "trash") return act(() => api("api/library/decks/trash", { id }),
|
|
218
|
+
if (a === "trash") return act(() => api("api/library/decks/trash", { id }), `Na lixeira — dá para restaurar por ${data.trashDays === 1 ? "um dia" : `${data.trashDays || 30} dias`}`);
|
|
215
219
|
});
|
|
216
220
|
m.querySelectorAll("[data-move]").forEach((b) => b.onclick = () => { closeMenus(); act(() => api("api/library/decks/move", { id, topic: b.dataset.move }), `Movida para ${topicOf(b.dataset.move).name}`); });
|
|
217
221
|
m.querySelectorAll("[data-dl]").forEach((b) => b.onclick = () => {
|
|
@@ -15,6 +15,9 @@ window.SagaPrefs = function ({ dom, store, escHtml, escAttr, fold, setAppTheme,
|
|
|
15
15
|
{ k: "ia.autor", type: "text", label: "Seu nome", hint: "Vai na capa e no rodapé das apresentações novas (no lugar de \"Seu Nome\")." },
|
|
16
16
|
{ k: "ia.idioma", type: "select", options: [["auto", "O do pedido"], ["português do Brasil", "Português (Brasil)"], ["inglês", "Inglês"], ["espanhol", "Espanhol"]], label: "Idioma do conteúdo gerado" },
|
|
17
17
|
] },
|
|
18
|
+
{ id: "biblioteca", title: "Biblioteca", items: [
|
|
19
|
+
{ k: "biblioteca.lixeiraDias", type: "select", options: [["1", "1 dia"], ["7", "7 dias"], ["30", "30 dias"], ["90", "90 dias"]], label: "Quanto tempo a lixeira guarda", hint: "Apresentação excluída fica restaurável por esse tempo; depois é apagada de vez." },
|
|
20
|
+
] },
|
|
18
21
|
{ id: "exportacao", title: "Exportação", items: [
|
|
19
22
|
{ k: "exportacao.pdfClaro", type: "bool", label: "PDF na versão clara do tema", hint: "Tema escuro com par claro (ex.: Manual noite) sai claro no PDF, melhor para imprimir." },
|
|
20
23
|
] },
|
|
@@ -812,6 +812,29 @@ textarea.form-control.napkin-input { min-height: 220px; resize: vertical; font-f
|
|
|
812
812
|
.bs-option:disabled { opacity: .5; }
|
|
813
813
|
.ai-msg.bs .ai-avatar { background: #8b5cf6; }
|
|
814
814
|
.bs-tag { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
|
|
815
|
+
/* Ambientes por campos (api-envs-form.js) */
|
|
816
|
+
.api-envs-tabs { display: flex; gap: 6px; margin: 4px 0 10px; }
|
|
817
|
+
.api-envs-tabs button { border: 1px solid var(--border, rgba(127,127,127,.3)); background: transparent; color: inherit; border-radius: 999px; padding: 4px 12px; cursor: pointer; font-size: 12px; }
|
|
818
|
+
.api-envs-tabs button.active { background: var(--selected); border-color: var(--brand); color: var(--brand); }
|
|
819
|
+
.api-envs-form { display: flex; flex-direction: column; gap: 10px; }
|
|
820
|
+
.aef-intro p { margin: 0 0 6px; }
|
|
821
|
+
.aef-env { border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 4px; }
|
|
822
|
+
.aef-env label { font-size: 12px; font-weight: 600; margin-top: 6px; }
|
|
823
|
+
.aef-env label small { font-weight: 400; color: var(--text-3); }
|
|
824
|
+
.aef-env input, .aef-env select { width: 100%; box-sizing: border-box; padding: 5px 8px; border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 6px; background: var(--bg, transparent); color: inherit; font: inherit; font-size: 13px; }
|
|
825
|
+
.aef-head { display: flex; gap: 8px; align-items: center; }
|
|
826
|
+
.aef-head input[data-k="envname"] { font-weight: 600; max-width: 180px; }
|
|
827
|
+
.aef-head .aef-cur { display: flex; align-items: center; gap: 4px; margin: 0; font-weight: 400; white-space: nowrap; }
|
|
828
|
+
.aef-head .aef-cur input { width: auto; }
|
|
829
|
+
.aef-head [data-test] { margin-left: auto; }
|
|
830
|
+
.aef-row, .aef-auth { display: flex; gap: 6px; align-items: center; }
|
|
831
|
+
.aef-auth { flex-wrap: wrap; margin-top: 4px; }
|
|
832
|
+
.aef-auth input { flex: 1 1 180px; }
|
|
833
|
+
.aef-env .btn-link { align-self: flex-start; background: none; border: 0; color: var(--brand); cursor: pointer; padding: 2px 0; font-size: 12px; }
|
|
834
|
+
.aef-note { font-size: 12px; margin: 6px 0 0; }
|
|
835
|
+
.aef-result { font-size: 12px; min-height: 1em; }
|
|
836
|
+
.aef-result.ok { color: #15803d; } .aef-result.err { color: var(--danger, #b91c1c); }
|
|
837
|
+
|
|
815
838
|
/* leitura crítica: um cartão por ponto, ligado ao slide, com Aplicar e Ignorar */
|
|
816
839
|
.crit-list { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
|
|
817
840
|
.crit-item { border: 1px solid var(--border, rgba(127,127,127,.3)); border-radius: 10px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
package/src/studio/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import {chatErrorResult,installErrorResponses} from './errors.js';
|
|
|
5
5
|
// sagadeck Studio · Servidor HTTP local para o editor visual PowerPoint + Chat Lateral IA
|
|
6
6
|
import { slideCopyRoutes } from "./slide-copy-routes.js";
|
|
7
7
|
import { publicScript } from "./public-files.js";
|
|
8
|
+
import { iaRoutes } from "./ia-routes.js";
|
|
8
9
|
import { codeWatch } from "./code-version.js";
|
|
9
10
|
import {meetingRoutes,meetingPreview} from '../meeting.js';
|
|
10
11
|
import { codeRoutes } from "./code-routes.js";
|
|
@@ -40,7 +41,7 @@ import { generateForStudio } from "./generate.js";
|
|
|
40
41
|
import { runCommand, logCommand } from "../ai/commands.js";
|
|
41
42
|
import { needsApproval, approvalLabel, awaitApproval, commandEnv, estimateSubmitCost } from "./command-approval.js";
|
|
42
43
|
import { demoDeck, demoAssets, demoProjectFiles } from "./demo-decks.js";
|
|
43
|
-
import { llmAvailable, llmConfig } from "../ai/llm.js";
|
|
44
|
+
import { llmAvailable, llmConfig, llmConfigured } from "../ai/llm.js";
|
|
44
45
|
import { editDeck, textToSlide, toYaml, materializeImages } from "../ai/deck-ai.js";
|
|
45
46
|
import { transformDeck, jobStatus, sourceHash } from "../ai/transform.js";
|
|
46
47
|
import { sendExport, sendHtml, lightVariant } from "./exporting.js";
|
|
@@ -892,27 +893,14 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
892
893
|
return;
|
|
893
894
|
}
|
|
894
895
|
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
if (pathname === "/api/ai/setup" && req.method === "GET") {
|
|
898
|
-
let setup = null;
|
|
899
|
-
const base = llmConfig().url.replace(/\/v1$/, "");
|
|
900
|
-
if (!opts.multiuser && /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(base)) {
|
|
901
|
-
try {
|
|
902
|
-
const r = await fetch(base + "/api/console/config", { signal: AbortSignal.timeout(1500) });
|
|
903
|
-
if (r.ok) setup = base + "/";
|
|
904
|
-
} catch {}
|
|
905
|
-
}
|
|
906
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
907
|
-
res.end(JSON.stringify({ url: setup }));
|
|
908
|
-
return;
|
|
909
|
-
}
|
|
896
|
+
// Configurar IA (provedor, chave, modelos desta máquina): src/studio/ia-routes.js
|
|
897
|
+
if (pathname.startsWith("/api/ia") && await iaRoutes({ req, res, pathname, opts, readJSON })) return;
|
|
910
898
|
|
|
911
899
|
if (pathname === "/api/ai/status" && req.method === "GET") {
|
|
912
900
|
const cfg = llmConfig();
|
|
913
901
|
const available = await llmAvailable({ force: url.searchParams.has("refresh") });
|
|
914
902
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
915
|
-
res.end(JSON.stringify({ available, url: cfg.url, textModel: cfg.textModel, imageModel: cfg.imageModel }));
|
|
903
|
+
res.end(JSON.stringify({ available, configured: llmConfigured(cfg), source: cfg.source, url: cfg.url, textModel: cfg.textModel, imageModel: cfg.imageModel }));
|
|
916
904
|
return;
|
|
917
905
|
}
|
|
918
906
|
|
|
@@ -954,7 +942,7 @@ export function createStudioServer(deckPath = null, opts = {}) {
|
|
|
954
942
|
}
|
|
955
943
|
if (!(await llmAvailable({ force: true }))) {
|
|
956
944
|
res.writeHead(503, { "Content-Type": "application/json" });
|
|
957
|
-
res.end(JSON.stringify({ error: `Nenhum LLM respondendo em ${llmConfig().url}.
|
|
945
|
+
res.end(JSON.stringify({ error: `${llmConfigured() ? `Nenhum LLM respondendo em ${llmConfig().url}. Confira o provedor e a chave em Configurar IA.` : "A IA não está configurada: clique em Configurar IA e informe provedor e chave."}` }));
|
|
958
946
|
return;
|
|
959
947
|
}
|
|
960
948
|
// O deck novo vai para uma pasta própria na biblioteca (regra do CLAUDE.md), no tópico do deck aberto
|
package/src/studio/slide-copy.js
CHANGED
|
@@ -11,7 +11,9 @@ export async function copySlideAssets(slide, sourceFile, targetFile) {
|
|
|
11
11
|
async function file(ref,from=source){
|
|
12
12
|
const abs=path.resolve(from,ref.split(/[?#]/)[0]);const rel=path.relative(source,abs);
|
|
13
13
|
if(rel.startsWith('..')||path.isAbsolute(rel))throw Error('A mídia deve estar dentro da pasta da apresentação de origem.');
|
|
14
|
-
|
|
14
|
+
// os dois lados pelo caminho real: a pasta aberta por junção (ou nome curto do Windows, RUNNER~1) não bate com o
|
|
15
|
+
// caminho resolvido do arquivo e toda mídia parecia "fora da apresentação"
|
|
16
|
+
const real=await fs.realpath(abs);const rr=path.relative(await fs.realpath(source),real);if(rr.startsWith('..')||path.isAbsolute(rr))throw Error('Referência de mídia fora da apresentação.');
|
|
15
17
|
const out=path.join(target,prefix,rel);
|
|
16
18
|
if(!copied.has(abs)){copied.add(abs);await fs.mkdir(path.dirname(out),{recursive:true});await fs.copyFile(abs,out);
|
|
17
19
|
if(/\.(html?|css)$/i.test(abs)){const text=await fs.readFile(abs,'utf8');for(const m of text.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']|url\(\s*["']?([^)'"\s]+)["']?\s*\)/g)){const dep=m[1]||m[2];if(local(dep))await file(dep,path.dirname(abs));}}
|