/* profiles.js — Seção "Perfis" do dashboard. Listagem (cards), tela de detalhes e formulário criar/editar com seções e arrays dinâmicos (dependências e modelos). Usa os globais API e UI. Exposto como ProfilesPage.render(container); app.js chama em Pages.profiles. */ const ProfilesPage = (() => { let enums = null; let formLlamaArgs = null; // llamaArgs do perfil em edição (preservado no save) const esc = UI.escapeHtml; async function ensureEnums() { if (!enums) enums = await API.profileEnums(); return enums; } function handleErr(ex) { if (ex && ex.status === 401) { location.reload(); return; } UI.toast("Erro: " + (ex && ex.message ? ex.message : ex), "bad"); } // Formata erros 422 do servidor (lista Pydantic) em texto legível. function serverErrors(ex) { const d = ex && ex.data && ex.data.detail; if (Array.isArray(d)) { return d.map((e) => { const loc = (e.loc || []).filter((x) => x !== "body").join(" › "); return `${loc}: ${e.msg}`; }); } return [ex && ex.message ? ex.message : "erro desconhecido"]; } const execBadge = (t) => { const map = { gpu: ["GPU", "ok"], cpu: ["CPU", "idle"], hybrid: ["Híbrido", "warn"] }; const [label, cls] = map[t] || [t || "—", "idle"]; return `${label}`; }; // Taxonomia hierárquica — 4 eixos independentes const TAX_AXIS = [ { key: "funcPurpose", label: "Propósito", title: "Propósito funcional (Eixo 1)" }, { key: "modality", label: "Modalidade", title: "Modalidade (Eixo 2)" }, { key: "architecture", label: "Arquitetura", title: "Arquitetura (Eixo 3)" }, { key: "cognitiveStyle", label: "Cognição", title: "Cognição (Eixo 4 — C-AGT é acumulativo)" }, ]; const taxonomyBadges = (p) => { const taxMeta = (enums && enums.taxonomy) || {}; const badges = TAX_AXIS.flatMap(({ key }) => (p[key] || []).map((id) => { const info = (taxMeta[key] || []).find((m) => m.id === id); return { id, label: info ? info.label : id }; }) ); if (!badges.length) return ""; return `
${badges.map(({ id, label }) => `${esc(label)}` ).join("")}
`; }; const svcStateBadge = (state) => { const map = { active: ["ativo", "ok"], inactive: ["parado", "idle"], failed: ["falha", "bad"], activating: ["iniciando", "warn"], deactivating: ["parando", "warn"] }; const [label, cls] = map[state] || ["desconhecido", "idle"]; return `${label}`; }; const num = (v, suf) => (v === null || v === undefined || v === "" ? "—" : `${v}${suf || ""}`); // Badge + botões do arquivo do modelo (status vem de p.model, do backend). const modelBadge = (m) => { if (!m) return ""; if (m.downloading) return `baixando…${m.progress != null ? " " + m.progress + "%" : ""}`; if (m.downloaded) return `modelo baixado`; return `modelo não baixado`; }; const modelBtns = (p) => { const m = p.model; if (!m || m.downloading) return ""; if (m.downloaded) return ``; if (m.canDownload) return ``; return `sem URL no catálogo (config/models.yaml)`; }; // Badge + botões do projetor multimodal (visão). Status vem de p.mmproj. const mmprojBadge = (m) => { if (!m) return ""; if (m.downloading) return `mmproj baixando…${m.progress != null ? " " + m.progress + "%" : ""}`; if (m.downloaded) return `mmproj baixado`; return `mmproj não baixado`; }; const mmprojBtns = (p) => { const m = p.mmproj; if (!m || m.downloading) return ""; if (m.downloaded) return ``; if (m.canDownload) return ``; return `sem URL de mmproj`; }; // Polling do progresso de download (um timer por perfil/arquivo). const pollers = {}; function pollModel(container, id, detailId) { if (pollers[id]) return; pollers[id] = setInterval(async () => { const stop = () => { clearInterval(pollers[id]); delete pollers[id]; }; const box = document.getElementById(`pf-model-${id}`); if (!box) { stop(); return; } // usuário saiu da tela try { const m = await API.profileModelStatus(id); if (m.downloading) { const b = box.querySelector(".badge"); if (b) b.innerHTML = `baixando…${m.progress != null ? " " + m.progress + "%" : ""}`; return; } stop(); if (m.error) UI.toast("Falha no download: " + m.error, "bad"); else if (m.downloaded) UI.toast("Modelo baixado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch { stop(); } }, 2500); } async function onModelDownload(container, id, name, detailId) { const ok = await UI.confirm({ title: `Baixar o modelo de “${name}”?`, text: "O arquivo GGUF (vários GB) será baixado do catálogo para o diretório primário de modelos.", confirmLabel: "Baixar", danger: false, }); if (!ok) return; try { await API.profileModelDownload(id); UI.toast("Download iniciado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch (ex) { handleErr(ex); } } async function onModelDelete(container, id, name, detailId) { const ok = await UI.confirm({ title: `Apagar o modelo de “${name}”?`, text: "O arquivo GGUF será removido do disco. Para usar o perfil novamente será preciso baixá-lo de novo.", confirmLabel: "Apagar arquivo", danger: true, }); if (!ok) return; try { await API.profileModelDelete(id); UI.toast("Arquivo do modelo apagado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch (ex) { handleErr(ex); } } // --- mmproj (projetor multimodal / visão): mesmo fluxo do modelo --- function pollMmproj(container, id, detailId) { const key = "mmproj:" + id; if (pollers[key]) return; pollers[key] = setInterval(async () => { const stop = () => { clearInterval(pollers[key]); delete pollers[key]; }; const box = document.getElementById(`pf-mmproj-${id}`); if (!box) { stop(); return; } try { const m = await API.profileMmprojStatus(id); if (m.downloading) { const b = box.querySelector(".badge"); if (b) b.innerHTML = `mmproj baixando…${m.progress != null ? " " + m.progress + "%" : ""}`; return; } stop(); if (m.error) UI.toast("Falha no download do mmproj: " + m.error, "bad"); else if (m.downloaded) UI.toast("mmproj baixado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch { stop(); } }, 2500); } async function onMmprojDownload(container, id, name, detailId) { const ok = await UI.confirm({ title: `Baixar o mmproj de “${name}”?`, text: "O projetor multimodal (necessário para entrada de imagem) será baixado para o diretório de modelos.", confirmLabel: "Baixar", danger: false, }); if (!ok) return; try { await API.profileMmprojDownload(id); UI.toast("Download do mmproj iniciado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch (ex) { handleErr(ex); } } async function onMmprojDelete(container, id, name, detailId) { const ok = await UI.confirm({ title: `Apagar o mmproj de “${name}”?`, text: "O arquivo mmproj será removido do disco. Sem ele a visão deixa de funcionar.", confirmLabel: "Apagar arquivo", danger: true, }); if (!ok) return; try { await API.profileMmprojDelete(id); UI.toast("mmproj apagado.", "ok"); detailId ? renderDetail(container, detailId) : renderList(container); } catch (ex) { handleErr(ex); } } // ====================================================================== ENTRY async function render(container) { try { await ensureEnums(); await renderList(container); } catch (ex) { handleErr(ex); } } // Filtros persistem enquanto a seção fica aberta. // typeFilter é um Set — vazio = mostrar todos; qualquer seleção = OR entre os selecionados. const TYPE_FILTERS = [ { id: "gpu", label: "GPU" }, { id: "cpu", label: "CPU" }, { id: "hybrid", label: "CPU + GPU" }, ]; let typeFilter = new Set(); let taxFilter = { funcPurpose: new Set(), modality: new Set(), architecture: new Set(), cognitiveStyle: new Set() }; let enabledFilter = "enabled"; const ENABLED_FILTERS = [ { id: "all", label: "Todos" }, { id: "enabled", label: "Habilitados" }, { id: "disabled", label: "Desabilitados" }, ]; // ======================================================================= LISTA async function renderList(container) { container.innerHTML = `
Carregando perfis…
`; const [data, activeData] = await Promise.all([API.profiles(), API.profilesActive()]); const items = data.items || []; const active = activeData.active || {}; const afterType = typeFilter.size === 0 ? items : items.filter((p) => typeFilter.has(p.executionType)); const afterTax = TAX_AXIS.reduce((acc, { key }) => { const f = taxFilter[key]; return f.size === 0 ? acc : acc.filter((p) => (p[key] || []).some((t) => f.has(t))); }, afterType); const shown = enabledFilter === "all" ? afterTax : afterTax.filter((p) => enabledFilter === "enabled" ? p.enabled !== false : p.enabled === false); const hasTaxFilter = TAX_AXIS.some(({ key }) => taxFilter[key].size > 0); const filterDesc = shown.length !== items.length ? ` de ${items.length}` : ""; let html = `

Perfis cadastrados (${shown.length}${filterDesc})

Configurações reutilizáveis de serviços, modelos e requisitos
`; // Filtro por tipo de execução (multi-seleção; vazio = todos). html += `
${TYPE_FILTERS.map((f) => ``).join("")}
`; // Filtro por habilitado/desabilitado. html += `
${ENABLED_FILTERS.map((f) => ``).join("")}
`; // Filtros de taxonomia (sempre visíveis, duas colunas). const taxMeta = (enums.taxonomy || {}); const taxAxisRow = (key) => { const axis = TAX_AXIS.find((a) => a.key === key); if (!axis) return ""; return `
${esc(axis.label)}: ${(taxMeta[key] || []).map((o) => ``).join("")}
`; }; html += `
${hasTaxFilter ? `
` : ""}
${taxAxisRow("funcPurpose")}${taxAxisRow("modality")}
${taxAxisRow("architecture")}${taxAxisRow("cognitiveStyle")}
`; if (!items.length) { html += `
Nenhum perfil cadastrado. Clique em “Novo perfil”.
`; } else if (!shown.length) { html += `
Nenhum perfil do tipo selecionado.
`; } else { html += `
` + shown.map((p) => cardHtml(p, active)).join("") + `
`; } html += `
${UI.icon("docs")}

Histórico de ações

Carregando…
`; container.innerHTML = html; document.getElementById("pf-new").onclick = async () => { const mode = await createModeModal(); if (mode === "manual") renderForm(container, null); else if (mode === "auto") renderAutoForm(container); }; container.querySelectorAll("[data-act]").forEach((b) => (b.onclick = () => onAction(container, b.dataset.act, b.dataset.id, b.dataset.name, b.dataset.inuse === "1"))); container.querySelectorAll("[data-svc-act]").forEach((b) => (b.onclick = () => onServiceAction(container, b.dataset.id, b.dataset.svcAct, null))); container.querySelectorAll("[data-activate]").forEach((b) => (b.onclick = () => onActivate(container, b.dataset.activate, b.dataset.name, false))); container.querySelectorAll("[data-deactivate]").forEach((b) => (b.onclick = () => onActivate(container, b.dataset.deactivate, b.dataset.name, true))); container.querySelectorAll("[data-model-dl]").forEach((b) => (b.onclick = () => onModelDownload(container, b.dataset.modelDl, b.dataset.name, null))); container.querySelectorAll("[data-model-del]").forEach((b) => (b.onclick = () => onModelDelete(container, b.dataset.modelDel, b.dataset.name, null))); container.querySelectorAll("[data-mmproj-dl]").forEach((b) => (b.onclick = () => onMmprojDownload(container, b.dataset.mmprojDl, b.dataset.name, null))); container.querySelectorAll("[data-mmproj-del]").forEach((b) => (b.onclick = () => onMmprojDelete(container, b.dataset.mmprojDel, b.dataset.name, null))); container.querySelectorAll("[data-bench]").forEach((b) => (b.onclick = () => onRunBenchmark(b.dataset.bench, b.dataset.name))); container.querySelectorAll("[data-filter]").forEach((b) => (b.onclick = () => { const id = b.dataset.filter; typeFilter.has(id) ? typeFilter.delete(id) : typeFilter.add(id); renderList(container); })); container.querySelectorAll("[data-enabled-filter]").forEach((b) => (b.onclick = () => { enabledFilter = b.dataset.enabledFilter; renderList(container); })); container.querySelectorAll("[data-tax-filter]").forEach((b) => (b.onclick = () => { const [axis, id] = b.dataset.taxFilter.split(":"); if (taxFilter[axis]) { taxFilter[axis].has(id) ? taxFilter[axis].delete(id) : taxFilter[axis].add(id); renderList(container); } })); const taxClearBtn = document.getElementById("pf-tax-clear"); if (taxClearBtn) taxClearBtn.onclick = () => { TAX_AXIS.forEach(({ key }) => taxFilter[key].clear()); renderList(container); }; container.querySelectorAll("[data-menu-btn]").forEach((b) => (b.onclick = (e) => { e.stopPropagation(); toggleMenu(b); })); items.filter((p) => p.model && p.model.downloading) .forEach((p) => pollModel(container, p.id, null)); items.filter((p) => p.mmproj && p.mmproj.downloading) .forEach((p) => pollMmproj(container, p.id, null)); document.getElementById("pf-audit-reload").onclick = () => loadAudit(); loadAudit(); } // Abre/fecha o menu hambúrguer de um card (fecha os demais). function toggleMenu(btn) { const menu = btn.parentElement.querySelector("[data-menu]"); const isOpen = menu && !menu.hidden; closeAllMenus(); if (menu) menu.hidden = isOpen; } function closeAllMenus() { document.querySelectorAll(".pf-menu [data-menu]").forEach((m) => { m.hidden = true; }); } // Clique fora fecha qualquer menu aberto (registrado uma única vez). document.addEventListener("click", (e) => { if (!e.target.closest(".pf-menu")) closeAllMenus(); }); function cardHtml(p, active) { active = active || {}; const isRunning = p.linkedService && active[p.linkedService] === p.id; const statusBadge = p.enabled ? `habilitado` : `desabilitado`; const remote = p.remoteUseAllowed ? `remoto` : ""; const canActivate = p.enabled !== false && p.linkedService && p.modelPath && p.model && p.model.downloaded; const execLabel = { gpu: "GPU", cpu: "CPU", hybrid: "Híbrido" }[p.executionType] || ""; return `
${esc(p.name)}
${execLabel ? `${esc(execLabel)}: ` : ""}${esc(p.description || "")}
${statusBadge}${remote}
${taxonomyBadges(p)}
VRAM mín. ${num(p.minimumVramGb, " GB")} RAM mín. ${num(p.minimumRamGb, " GB")}
${p.modelPath ? `
${p.model && (p.model.downloading || !p.model.downloaded) ? modelBadge(p.model) : ""} ${modelBtns(p)} ${p.model && p.model.error ? `falhou: ${esc(p.model.error)}` : ""}
` : ""} ${(p.mmprojPath || p.mmprojUrl) ? `
${mmprojBadge(p.mmproj)} ${mmprojBtns(p)} ${p.mmproj && p.mmproj.error ? `falhou: ${esc(p.mmproj.error)}` : ""}
` : ""} ${p.linkedService ? `
${canActivate ? `` : ""} ${canActivate && !isRunning ? `` : ""} ${isRunning ? `` : ""}
${kebabMenu(p)}
` : `
${kebabMenu(p)}
`}
`; } // Menu hambúrguer com as ações do perfil (Ver, Editar, Duplicar, etc.). function kebabMenu(p) { return `
`; } async function loadAudit() { const box = document.getElementById("pf-audit"); if (!box) return; try { const data = await API.profileAudit(30); const items = data.items || []; if (!items.length) { box.innerHTML = `
Nenhuma ação registrada ainda.
`; return; } box.innerHTML = ` ${items.map((e) => ``).join("")}
QuandoUsuárioAçãoPerfil
${esc((e.ts || "").replace("T", " ").replace("+00:00", " UTC"))} ${esc(e.user || "—")} ${esc(e.action)} ${esc(e.profileName || e.profileId || "—")}
`; } catch (ex) { box.innerHTML = `
Não foi possível carregar o histórico.
`; } } // ====================================================================== AÇÕES async function onAction(container, act, id, name, inUse) { try { if (act === "view") return renderDetail(container, id); if (act === "edit") { const p = await API.profile(id); return renderForm(container, p); } if (act === "duplicate") { const newName = await promptText("Duplicar perfil", `Nome do novo perfil (cópia de “${name}”):`, `${name} (cópia)`); if (!newName) return; await API.profileDuplicate(id, newName); UI.toast("Perfil duplicado.", "ok"); return renderList(container); } if (act === "toggle") { const p = await API.profile(id); await API.profileToggle(id, !p.enabled); UI.toast(p.enabled ? "Perfil desabilitado." : "Perfil habilitado.", "ok"); return renderList(container); } if (act === "delete") return onDelete(container, id, name, inUse); } catch (ex) { handleErr(ex); } } async function onActivate(container, profileId, name, isDeactivate) { const ok = await UI.confirm({ title: isDeactivate ? `Desativar modelo "${name}"?` : `Ativar perfil "${name}"?`, text: isDeactivate ? "O serviço será reiniciado com o modelo padrão (servers.yaml)." : "O serviço vinculado será reiniciado com o modelo deste perfil. Pode levar alguns segundos.", confirmLabel: isDeactivate ? "Desativar" : "Ativar", danger: false, }); if (!ok) return; try { if (isDeactivate) await API.profileDeactivate(profileId); else await API.profileActivate(profileId); UI.toast(isDeactivate ? "Modelo padrão restaurado." : "Perfil ativado — serviço reiniciando…", "ok"); renderList(container); } catch (ex) { handleErr(ex); } } async function onServiceAction(container, profileId, action, detailId) { if (action === "stop" || action === "restart") { const ok = await UI.confirm({ title: action === "stop" ? "Parar serviço?" : "Reiniciar serviço?", text: action === "stop" ? "O serviço será parado. Confirmar?" : "O serviço será reiniciado. Confirmar?", confirmLabel: action === "stop" ? "Parar" : "Reiniciar", danger: true, }); if (!ok) return; } try { await API.profileServiceAction(profileId, action); UI.toast(action === "start" ? "Serviço iniciado." : action === "stop" ? "Serviço parado." : "Serviço reiniciado.", "ok"); if (detailId) renderDetail(container, detailId); else renderList(container); } catch (ex) { handleErr(ex); } } async function onDelete(container, id, name, inUse) { const ok = await UI.confirm({ title: `Excluir “${name}”?`, text: inUse ? "Este perfil está EM USO por um serviço ativo. A exclusão forçada é permitida, mas pode afetar o serviço. Digite EXCLUIR para confirmar." : "Esta ação remove o perfil permanentemente.", confirmLabel: "Excluir perfil", danger: true, requireText: inUse ? "EXCLUIR" : undefined, }); if (!ok) return; try { await API.profileDelete(id, inUse); UI.toast("Perfil excluído.", "ok"); renderList(container); } catch (ex) { if (ex.status === 409) { // Em uso detectado no servidor: oferece exclusão forçada. const force = await UI.confirm({ title: "Perfil em uso", text: "O servidor indicou que o perfil está em uso por um serviço ativo. Forçar a exclusão mesmo assim? Digite EXCLUIR para confirmar.", confirmLabel: "Forçar exclusão", danger: true, requireText: "EXCLUIR", }); if (!force) return; try { await API.profileDelete(id, true); UI.toast("Perfil excluído (forçado).", "ok"); renderList(container); } catch (e2) { handleErr(e2); } } else handleErr(ex); } } // ==================================================================== DETALHES async function renderDetail(container, id) { container.innerHTML = `
Carregando perfil…
`; const p = await API.profile(id); const r = p.requirements || {}; const rm = p.remote || {}; const kv = (label, value) => `${esc(label)}${value}`; const yesno = (b) => (b ? `sim` : `não`); let html = `
`; html += `

${esc(p.name)}

${execBadge(p.executionType)} ${p.enabled ? `habilitado` : `desabilitado`} ${p.inUse ? `em uso` : ""}

${esc(p.description || "")}

id: ${esc(p.id)} · atualizado ${esc((p.updatedAt || "").replace("T", " "))}
`; const hasTax = TAX_AXIS.some(({ key }) => (p[key] || []).length > 0); if (hasTax) { const taxMeta = (enums.taxonomy || {}); html += `
${UI.icon("filter")}

Taxonomia

${TAX_AXIS.map(({ key, label }) => { const tags = p[key] || []; if (!tags.length) return ""; const meta = taxMeta[key] || []; return `
${esc(label)}: ${tags.map((t) => { const info = meta.find((m) => m.id === t); return `${esc(info ? info.label : t)}${esc(t)}`; }).join("")}
`; }).join("")}
`; } if (p.linkedService) { const detailActive = p.serviceState === "active"; html += `
${UI.icon("services")}

Serviço vinculado

${esc(UI.svcLabel(p.linkedService))} ${svcStateBadge(p.serviceState)}
${p.modelPath && p.model && p.model.downloaded ? `` : ""} ${p.modelPath && p.model && p.model.downloaded ? `` : ""}
${p.modelPath ? `
${esc(p.modelPath)}
` : `
Configure "Caminho do modelo" para poder ativar este perfil.
`} ${p.llamaArgs && p.llamaArgs.ctx_size ? `
Contexto (ctx_size): ${Number(p.llamaArgs.ctx_size).toLocaleString()} tokens — sobrepõe o padrão do serviço ao ativar.
` : ""}
`; } if (p.modelPath) { const m = p.model; html += `
${UI.icon("bolt")}

Arquivo do modelo

${modelBadge(m)} ${m && m.sizeBytes ? `${(m.sizeBytes / 2 ** 30).toFixed(2)} GiB` : ""}
${modelBtns(p)}
${esc((m && m.path) || p.modelPath)}
${p.modelUrl ? `` : ""} ${m && m.error ? `
falhou: ${esc(m.error)}
` : ""}
`; } if (p.mmprojPath || p.mmprojUrl) { const mm = p.mmproj; html += `
${UI.icon("bolt")}

Projetor multimodal (visão)

${mmprojBadge(mm)} ${mm && mm.sizeBytes ? `${(mm.sizeBytes / 2 ** 30).toFixed(2)} GiB` : ""}
${mmprojBtns(p)}
${esc((mm && mm.path) || p.mmprojPath || "")}
${p.mmprojUrl ? `` : ""} ${mm && mm.error ? `
falhou: ${esc(mm.error)}
` : ""}
Necessário para entrada de imagem. Ao ativar o perfil, é passado ao llama-server via --mmproj.
`; } html += `
${UI.icon("cpu")}

Requisitos de sistema

${kv("GPU obrigatória", yesno(r.gpuRequired))} ${kv("Apenas CPU", yesno(r.cpuOnly))} ${kv("VRAM mínima", num(r.minimumVramGb, " GB"))} ${kv("RAM mínima", num(r.minimumRamGb, " GB"))} ${kv("Disco mínimo", num(r.minimumDiskGb, " GB"))} ${kv("GPU recomendada", esc(r.recommendedGpu || "—"))} ${kv("CPU recomendada", esc(r.recommendedCpu || "—"))} ${r.notes ? kv("Observações", esc(r.notes)) : ""}
`; const deps = p.dependencies || []; html += `
${UI.icon("services")}

Dependências (${deps.length})

${deps.length ? ` ${deps.map((d) => ``).join("")}
NomeTipoVersãoObrigatóriaLink
${esc(d.name)}${d.notes ? `
${esc(d.notes)}
` : ""}
${esc(d.type)}${esc(d.version || "—")} ${yesno(d.required)} ${d.installUrl ? `abrir` : "—"}
` : `
Nenhuma dependência.
`}
`; const models = p.models || []; html += `
${UI.icon("bolt")}

Modelos associados (${models.length})

${models.length ? ` ${models.map((m) => ``).join("")}
ModeloDesenvolvedorTipoFormatoTam.ContextoObrig.
${esc(m.name)}${m.url ? ` ` : ""}${m.notes ? `
${esc(m.notes)}
` : ""}
${esc(m.developer)}${esc(m.type || "—")} ${esc(m.format || "—")}${num(m.sizeGb, " GB")} ${m.contextSize ? m.contextSize.toLocaleString() : "—"}${yesno(m.required)}
` : `
Nenhum modelo associado.
`}
`; html += `
${UI.icon("remote")}

Uso remoto

${kv("Uso remoto permitido", yesno(rm.remoteUseAllowed))} ${kv("Exige autenticação", yesno(rm.requiresAuthentication))} ${kv("Expõe dados sensíveis", yesno(rm.exposesSensitiveData))} ${kv("Nível de segurança", `${esc(rm.securityLevel || "—")}`)} ${rm.remoteUseNotes ? kv("Observações", esc(rm.remoteUseNotes)) : ""}
`; if (p.notes) { html += `
${UI.icon("docs")}

Observações

${esc(p.notes)}

`; } container.innerHTML = html; document.getElementById("pf-back").onclick = () => renderList(container); container.querySelectorAll("[data-act]").forEach((b) => (b.onclick = () => onAction(container, b.dataset.act, b.dataset.id, b.dataset.name, b.dataset.inuse === "1"))); container.querySelectorAll("[data-svc-act]").forEach((b) => (b.onclick = () => onServiceAction(container, b.dataset.id, b.dataset.svcAct, id))); container.querySelectorAll("[data-activate]").forEach((b) => (b.onclick = () => onActivate(container, b.dataset.activate, b.dataset.name, false))); container.querySelectorAll("[data-deactivate]").forEach((b) => (b.onclick = () => onActivate(container, b.dataset.deactivate, b.dataset.name, true))); container.querySelectorAll("[data-model-dl]").forEach((b) => (b.onclick = () => onModelDownload(container, b.dataset.modelDl, b.dataset.name, id))); container.querySelectorAll("[data-model-del]").forEach((b) => (b.onclick = () => onModelDelete(container, b.dataset.modelDel, b.dataset.name, id))); container.querySelectorAll("[data-mmproj-dl]").forEach((b) => (b.onclick = () => onMmprojDownload(container, b.dataset.mmprojDl, b.dataset.name, id))); container.querySelectorAll("[data-mmproj-del]").forEach((b) => (b.onclick = () => onMmprojDelete(container, b.dataset.mmprojDel, b.dataset.name, id))); container.querySelectorAll("[data-bench]").forEach((b) => (b.onclick = () => onRunBenchmark(b.dataset.bench, b.dataset.name))); if (p.model && p.model.downloading) pollModel(container, id, id); if (p.mmproj && p.mmproj.downloading) pollMmproj(container, id, id); } // ============================================================== BENCHMARK // Modal de seleção do tipo de benchmark (reusa o shell #modal-bg). function benchmarkModal(name, types) { return new Promise((resolve) => { const bg = document.getElementById("modal-bg"); const okBtn = document.getElementById("modal-ok"); const cancelBtn = document.getElementById("modal-cancel"); const field = document.getElementById("modal-confirm-field"); const textEl = document.getElementById("modal-text"); document.getElementById("modal-title").textContent = `Executar benchmark — ${name}`; document.getElementById("modal-ico").className = "m-ico warn"; field.style.display = "none"; okBtn.className = "btn primary"; okBtn.textContent = "Iniciar benchmark"; okBtn.disabled = false; cancelBtn.textContent = "Cancelar"; textEl.innerHTML = `
${types.map((t, i) => ` `).join("")}
`; const close = (val) => { bg.classList.remove("show"); okBtn.onclick = cancelBtn.onclick = null; textEl.innerHTML = ""; resolve(val); }; okBtn.onclick = () => { const sel = document.querySelector('input[name="bm-type"]:checked'); close(sel ? sel.value : null); }; cancelBtn.onclick = () => close(null); bg.onclick = (e) => { if (e.target === bg) close(null); }; bg.classList.add("show"); }); } async function onRunBenchmark(profileId, name) { let types; try { types = (await API.benchmarkTypes()).items || []; } catch (ex) { return handleErr(ex); } if (!types.length) { UI.toast("Nenhum tipo de benchmark disponível.", "bad"); return; } const typeId = await benchmarkModal(name, types); if (!typeId) return; try { await API.benchmarkCreate(profileId, typeId); UI.toast("Benchmark adicionado à fila.", "ok"); location.hash = "benchmarks"; } catch (ex) { handleErr(ex); } } // ============================================================ CRIADOR AUTO // Modal de escolha do modo de criação (reusa o shell #modal-bg, estilo radio). function createModeModal() { return new Promise((resolve) => { const bg = document.getElementById("modal-bg"); const okBtn = document.getElementById("modal-ok"); const cancelBtn = document.getElementById("modal-cancel"); const field = document.getElementById("modal-confirm-field"); const textEl = document.getElementById("modal-text"); document.getElementById("modal-title").textContent = "Novo perfil"; document.getElementById("modal-ico").className = "m-ico warn"; field.style.display = "none"; okBtn.className = "btn primary"; okBtn.textContent = "Continuar"; okBtn.disabled = false; cancelBtn.textContent = "Cancelar"; const opts = [ { id: "auto", label: "Automático (com IA)", desc: "Descreva o modelo; o sistema gera um prompt para uma IA sugerir 2–4 perfis prontos para você revisar." }, { id: "manual", label: "Manual", desc: "Preencher você mesmo o formulário completo do perfil." }, ]; textEl.innerHTML = `
${opts.map((o, i) => ` `).join("")}
`; const close = (val) => { bg.classList.remove("show"); okBtn.onclick = cancelBtn.onclick = bg.onclick = null; textEl.innerHTML = ""; resolve(val); }; okBtn.onclick = () => { const sel = document.querySelector('input[name="cm-mode"]:checked'); close(sel ? sel.value : null); }; cancelBtn.onclick = () => close(null); bg.onclick = (e) => { if (e.target === bg) close(null); }; bg.classList.add("show"); }); } // Tela (inline) do modo automático: descreve o modelo e gera o prompt. function renderAutoForm(container) { const svcOptions = (enums.services || []).map((s) => ``).join(""); const sec = (title, body, id) => `

${title}

${body}
`; let html = `

Novo perfil — automático

`; html += sec("1 · Modelo desejado", ` ${fText("modelName", "", "Nome do Modelo (ex.: \"Qwen2.5\", \"DeepSeek Coder\" ou \"Llama-3.3-70B-Instruct-Q4_K_M.gguf\")", true)}
`, "af-general"); html += sec("2 · Requisitos de sistema", `
`, "af-req"); html += sec("3 · Uso remoto", `
${fCheck("remoteUseAllowed", false, "Uso remoto permitido")} ${fCheck("requiresAuthentication", true, "Exige autenticação")} ${fCheck("exposesSensitiveData", false, "Expõe dados sensíveis")}
${fSelect("securityLevel", "local_only", "Nível de segurança", enums.securityLevel)}
`, "af-remote"); html += sec("4 · Observações", `${fArea("notes", "", "Observações (opcional)")}`, "af-notes"); html += `
`; container.innerHTML = html; const back = () => renderList(container); document.getElementById("af-cancel").onclick = back; document.getElementById("af-cancel2").onclick = back; document.getElementById("af-generate").onclick = () => onGeneratePrompt(container); } function showAutoErrors(list) { const box = document.getElementById("af-errors"); if (!box) return; if (!list.length) { box.classList.remove("show"); box.innerHTML = ""; return; } box.innerHTML = list.map((x) => `• ${esc(x)}`).join("
"); box.classList.add("show"); box.scrollIntoView({ behavior: "smooth", block: "center" }); } async function onGeneratePrompt(container) { const g = container.querySelector("#af-general"); const rmS = container.querySelector("#af-remote"); const no = container.querySelector("#af-notes"); const txt = (scope, f) => { const el = scope && scope.querySelector(`[data-f="${f}"]`); const s = el ? (el.value || "").trim() : ""; return s || null; }; const chk = (scope, f) => { const el = scope && scope.querySelector(`[data-f="${f}"]`); return el ? !!el.checked : false; }; const modelName = txt(g, "modelName"); if (!modelName) { showAutoErrors(["Nome do Modelo é obrigatório."]); return; } showAutoErrors([]); const hwReq = (container.querySelector('input[name="af-hw"]:checked') || {}).value || "gpu"; const body = { modelName, hardwareReq: hwReq, linkedService: txt(g, "linkedService"), remote: { remoteUseAllowed: chk(rmS, "remoteUseAllowed"), requiresAuthentication: chk(rmS, "requiresAuthentication"), exposesSensitiveData: chk(rmS, "exposesSensitiveData"), securityLevel: txt(rmS, "securityLevel") || "local_only", }, notes: txt(no, "notes"), }; const btn = document.getElementById("af-generate"); btn.disabled = true; btn.textContent = "Gerando…"; try { const res = await API.profileAutoPrompt(body); autoResultModal(container, res.prompt || ""); } catch (ex) { if (ex.status === 422 || ex.status === 400) showAutoErrors(serverErrors(ex)); else handleErr(ex); } finally { btn.disabled = false; btn.innerHTML = `${UI.icon("bolt", 15)} Gerar prompt`; } } // Modal com o prompt gerado (somente leitura + copiar) e a caixa para colar a // resposta da IA. Overlay próprio (não cabe no shell #modal-bg de 1 campo). function autoResultModal(container, prompt) { const ta = "width:100%;background:#12151e;color:#e8eaf0;border:1px solid var(--border);border-radius:6px;padding:12px;font-size:12px;font-family:monospace;resize:vertical"; const lbl = "font-size:11px;font-weight:600;color:#8b8fa8;text-transform:uppercase;letter-spacing:.7px;margin-bottom:6px"; const overlay = document.createElement("div"); overlay.className = "modal-bg show"; overlay.innerHTML = ``; document.body.appendChild(overlay); const close = () => overlay.remove(); document.getElementById("ap-copy").onclick = () => UI.copy(prompt).then((ok) => UI.toast(ok ? "Prompt copiado." : "Não foi possível copiar — selecione e copie manualmente.", ok ? "ok" : "bad")); document.getElementById("ap-close").onclick = close; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); document.getElementById("ap-parse").onclick = () => { const errBox = document.getElementById("ap-error"); errBox.classList.remove("show"); errBox.innerHTML = ""; let cands; try { cands = parseCandidates(document.getElementById("ap-response").value); } catch (e) { errBox.textContent = e.message; errBox.classList.add("show"); return; } close(); chooseCandidate(container, cands); }; setTimeout(() => { const r = document.getElementById("ap-response"); if (r) r.focus(); }, 60); } // Extrai o JSON da resposta (bloco ```json ... ``` ou recorte do 1º [ ao último ]). function parseCandidates(text) { if (!text || !text.trim()) throw new Error("Cole a resposta da IA na caixa."); const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); let raw = (fence ? fence[1] : text).trim(); if (!fence) { const i = raw.indexOf("["), j = raw.lastIndexOf("]"); if (i !== -1 && j > i) raw = raw.slice(i, j + 1); } let data; try { data = JSON.parse(raw); } catch (e) { throw new Error("Não consegui ler o JSON. Verifique se colou a resposta completa (bloco json)."); } const arr = Array.isArray(data) ? data : (data && typeof data === "object" ? [data] : []); const cands = arr.filter((x) => x && typeof x === "object" && !Array.isArray(x)); if (!cands.length) throw new Error("Nenhum perfil encontrado no JSON colado."); return cands; } // Limpa campos que não pertencem ao formulário e tenta deixar o perfil pronto // para download: se a IA não preencheu modelUrl/modelPath mas deixou um link // direto de .gguf (no próprio modelUrl ou em algum models[].url), deriva ambos. function sanitizeCandidate(c) { const o = { ...c }; delete o.id; delete o.createdAt; delete o.updatedAt; delete o._resumo; const isGguf = (u) => typeof u === "string" && /\.gguf(\?|#|$)/i.test(u.trim()); const isMmproj = (u) => isGguf(u) && /mmproj/i.test(u); const basename = (u) => u.split(/[?#]/)[0].split("/").pop(); const urls = (o.models || []).map((m) => m && m.url).filter(Boolean); // modelo principal: um .gguf que NÃO seja o mmproj if (!isGguf(o.modelUrl) || isMmproj(o.modelUrl)) { const direct = urls.find((u) => isGguf(u) && !isMmproj(u)); if (direct) o.modelUrl = direct.trim(); } if (!o.modelPath && isGguf(o.modelUrl) && !isMmproj(o.modelUrl)) { o.modelPath = basename(o.modelUrl); // basename basta (download/ativação resolvem por nome) } // projetor multimodal (visão): um .gguf cujo nome contém "mmproj" if (!isMmproj(o.mmprojUrl)) { const mm = urls.find(isMmproj); if (mm) o.mmprojUrl = mm.trim(); } if (!o.mmprojPath && isMmproj(o.mmprojUrl)) { o.mmprojPath = basename(o.mmprojUrl); } return o; } // Lista os 2–4 candidatos; ao escolher, abre o formulário manual pré-preenchido. function chooseCandidate(container, cands) { const overlay = document.createElement("div"); overlay.className = "modal-bg show"; const cardFor = (c, i) => { const req = c.requirements || {}; const tags = [ (c.executionType || "—").toUpperCase(), req.minimumVramGb != null ? `VRAM ${req.minimumVramGb} GB` : "", req.minimumRamGb != null ? `RAM ${req.minimumRamGb} GB` : "", `${(c.models || []).length} modelo(s)`, ].filter(Boolean); return ``; }; overlay.innerHTML = ``; document.body.appendChild(overlay); const close = () => overlay.remove(); document.getElementById("cc-cancel").onclick = close; overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); document.getElementById("cc-pick").onclick = () => { const sel = overlay.querySelector('input[name="cand"]:checked'); const idx = sel ? Number(sel.value) : 0; const chosen = sanitizeCandidate(cands[idx] || {}); close(); renderForm(container, chosen); }; } // ===================================================================== FORM // helpers de campo (data-f marca o nome do campo dentro do escopo da seção) const fText = (f, v, ph, req) => `
`; const fNum = (f, v, ph) => `
`; const fArea = (f, v, ph) => `
`; const fCheck = (f, v, ph) => ``; const fSelect = (f, v, ph, options, includeEmpty) => `
`; function depRow(d) { d = d || {}; return `
DEPENDÊNCIA
${fText("name", d.name, "Nome", true)} ${fSelect("type", d.type, "Tipo", enums.dependencyType)} ${fText("version", d.version, "Versão")} ${fText("installUrl", d.installUrl, "URL de instalação")}
${fArea("notes", d.notes, "Observações")} ${fCheck("required", d.required !== false, "Obrigatória")}
`; } function modelRow(m) { m = m || {}; return `
MODELO
${fText("name", m.name, "Nome", true)} ${fText("developer", m.developer, "Desenvolvedor", true)} ${fText("url", m.url, "URL oficial")} ${fSelect("type", m.type || "llm", "Tipo", enums.modelType)} ${fSelect("format", m.format, "Formato", enums.modelFormat, true)} ${fNum("sizeGb", m.sizeGb, "Tamanho (GB)")} ${fNum("contextSize", m.contextSize, "Contexto (tokens)")}
${fArea("notes", m.notes, "Observações")} ${fCheck("required", m.required !== false, "Obrigatório")}
`; } // editing = só quando o perfil já existe (tem id). Um candidato pré-preenchido // (sem id) abre em modo "criar", aproveitando o formulário e a validação. function renderForm(container, profile) { const editing = !!(profile && profile.id); const p = profile || {}; const r = p.requirements || {}; const rm = p.remote || {}; // Preserva o llamaArgs completo (threads, numa, cache, temp…) para o save: // o formulário só edita ctx_size; os demais parâmetros precisam sobreviver. formLlamaArgs = p.llamaArgs ? { ...p.llamaArgs } : null; const sec = (title, body, id) => `

${title}

${body}
`; let html = `

${editing ? "Editar perfil" : "Novo perfil"}

`; // 1. Informações gerais const svcOptions = (enums.services || []).map((s) => `` ).join(""); html += sec("1 · Informações gerais", `
${fText("name", p.name, "Nome", true)} ${fSelect("executionType", p.executionType, "Tipo de execução", enums.executionType, true)}
${fArea("description", p.description, "Descrição *")}
${fText("modelPath", p.modelPath, "Caminho do modelo no servidor (ex.: /home/fac/AI/models/arquivo.gguf)")} ${fText("modelUrl", p.modelUrl, "URL de download do modelo (.gguf direto) — habilita o botão Baixar modelo")} ${fText("mmprojPath", p.mmprojPath, "Caminho do mmproj no servidor (modelos de visão; ex.: /mnt/.../mmproj-modelo.gguf)")} ${fText("mmprojUrl", p.mmprojUrl, "URL de download do mmproj (.gguf direto) — necessário para entrada de imagem")} ${fNum("ctxSize", p.llamaArgs && p.llamaArgs.ctx_size, "Contexto do llama-server (ctx_size, tokens) — sobrepõe servers.yaml ao ativar; vazio = padrão do serviço")} ${fNum("parallelSlots", p.llamaArgs && p.llamaArgs.parallel, "Slots paralelos (parallel, -np) — o contexto é DIVIDIDO entre os slots; use 1 para o contexto inteiro por requisição. Vazio = padrão do serviço")} ${fCheck("enabled", editing ? p.enabled : true, "Perfil ativo")} `, "pf-general"); // 2. Requisitos de sistema html += sec("2 · Requisitos de sistema", `
${fCheck("gpuRequired", r.gpuRequired, "GPU obrigatória")} ${fCheck("cpuOnly", r.cpuOnly, "Apenas CPU")}
${fNum("minimumVramGb", r.minimumVramGb, "VRAM mínima (GB) — se GPU")} ${fNum("minimumRamGb", r.minimumRamGb, "RAM mínima (GB) — se CPU")} ${fNum("minimumDiskGb", r.minimumDiskGb, "Disco mínimo (GB)")} ${fText("recommendedGpu", r.recommendedGpu, "GPU recomendada")} ${fText("recommendedCpu", r.recommendedCpu, "CPU recomendada")}
${fArea("notes", r.notes, "Observações técnicas")} `, "pf-req"); // 3. Dependências html += `

3 · Dependências

${(p.dependencies || []).map(depRow).join("")}
Nenhuma dependência. Use “Adicionar”.
`; // 4. Modelos html += `

4 · Modelos associados

${(p.models || []).map(modelRow).join("")}
Nenhum modelo. Use “Adicionar”.
`; // 5. Uso remoto html += sec("5 · Uso remoto", `
${fCheck("remoteUseAllowed", rm.remoteUseAllowed, "Uso remoto permitido")} ${fCheck("requiresAuthentication", editing ? rm.requiresAuthentication : true, "Exige autenticação")} ${fCheck("exposesSensitiveData", rm.exposesSensitiveData, "Expõe dados sensíveis")}
${fSelect("securityLevel", rm.securityLevel || "local_only", "Nível de segurança", enums.securityLevel)}
${fArea("remoteUseNotes", rm.remoteUseNotes, "Observações sobre uso remoto")} `, "pf-remote"); // 6. Taxonomia const fTax = (axis, selected) => { const opts = (enums.taxonomy || {})[axis] || []; return `
${opts.map((o) => ``).join("")}
`; }; html += sec("6 · Taxonomia", `
Classifique o modelo por 4 eixos independentes. Escolha uma tag por eixo (C-AGT é acumulativo no Eixo 4).
${TAX_AXIS.map(({ key, title }) => `
${fTax(key, p[key])}
`).join("")} `, "pf-taxonomy"); // 7. Observações html += sec("7 · Observações", `${fArea("notes", p.notes, "Observações gerais")}`, "pf-notes"); html += `
`; container.innerHTML = html; // Wire: cancelar const back = () => renderList(container); document.getElementById("pf-cancel").onclick = back; document.getElementById("pf-cancel2").onclick = back; // Wire: add/remove dependências e modelos (delegação) document.getElementById("pf-add-dep").onclick = () => { document.getElementById("pf-deps-list").insertAdjacentHTML("beforeend", depRow({})); document.getElementById("pf-deps-empty").style.display = "none"; }; document.getElementById("pf-add-model").onclick = () => { document.getElementById("pf-models-list").insertAdjacentHTML("beforeend", modelRow({})); document.getElementById("pf-models-empty").style.display = "none"; }; container.addEventListener("click", (e) => { const rm2 = e.target.closest("[data-remove]"); if (rm2) { e.preventDefault(); rm2.closest(".item-card").remove(); } }); document.getElementById("pf-save").onclick = () => onSave(container, editing ? p.id : null); } // ---- coleta + validação + envio ---- function collect(container) { const sc = (id) => container.querySelector("#" + id); const txt = (scope, f) => { const el = scope && scope.querySelector(`[data-f="${f}"]`); const s = el ? (el.value || "").trim() : ""; return s || null; }; const numv = (scope, f) => { const el = scope && scope.querySelector(`[data-f="${f}"]`); const s = el ? (el.value || "").trim() : ""; return s === "" ? null : Number(s); }; const chk = (scope, f) => { const el = scope && scope.querySelector(`[data-f="${f}"]`); return el ? !!el.checked : false; }; const g = sc("pf-general"), rq = sc("pf-req"), rmS = sc("pf-remote"), no = sc("pf-notes"), tx = sc("pf-taxonomy"); const taxV = (axis) => tx ? [...tx.querySelectorAll(`[data-tax="${axis}"] input[type="checkbox"]:checked`)].map((cb) => cb.value) : []; const deps = [...container.querySelectorAll("#pf-deps-list .item-card")].map((c) => ({ name: txt(c, "name"), type: txt(c, "type"), version: txt(c, "version"), required: chk(c, "required"), installUrl: txt(c, "installUrl"), notes: txt(c, "notes"), })).filter((d) => d.name || d.version || d.installUrl || d.notes); const models = [...container.querySelectorAll("#pf-models-list .item-card")].map((c) => ({ name: txt(c, "name"), developer: txt(c, "developer"), url: txt(c, "url"), type: txt(c, "type") || "llm", format: txt(c, "format"), sizeGb: numv(c, "sizeGb"), contextSize: numv(c, "contextSize"), required: chk(c, "required"), notes: txt(c, "notes"), })).filter((m) => m.name || m.developer || m.url || m.notes); // llamaArgs: preserva os demais parâmetros do perfil e sobrepõe ctx_size e parallel. // Campo vazio remove a chave correspondente; se nada sobrar, envia null. const ctxSize = numv(g, "ctxSize"); const parallelSlots = numv(g, "parallelSlots"); let llamaArgs = formLlamaArgs ? { ...formLlamaArgs } : null; if (ctxSize != null) llamaArgs = { ...(llamaArgs || {}), ctx_size: ctxSize }; else if (llamaArgs) delete llamaArgs.ctx_size; if (parallelSlots != null) llamaArgs = { ...(llamaArgs || {}), parallel: parallelSlots }; else if (llamaArgs) delete llamaArgs.parallel; if (llamaArgs) { llamaArgs = Object.fromEntries( Object.entries(llamaArgs).filter(([, v]) => v !== null && v !== undefined && v !== "")); if (!Object.keys(llamaArgs).length) llamaArgs = null; } return { name: txt(g, "name"), description: txt(g, "description"), enabled: chk(g, "enabled"), executionType: txt(g, "executionType"), linkedService: txt(g, "linkedService") || null, modelPath: txt(g, "modelPath") || null, modelUrl: txt(g, "modelUrl") || null, mmprojPath: txt(g, "mmprojPath") || null, mmprojUrl: txt(g, "mmprojUrl") || null, llamaArgs, requirements: { gpuRequired: chk(rq, "gpuRequired"), cpuOnly: chk(rq, "cpuOnly"), minimumVramGb: numv(rq, "minimumVramGb"), minimumRamGb: numv(rq, "minimumRamGb"), minimumDiskGb: numv(rq, "minimumDiskGb"), recommendedGpu: txt(rq, "recommendedGpu"), recommendedCpu: txt(rq, "recommendedCpu"), notes: txt(rq, "notes"), }, dependencies: deps, models, remote: { remoteUseAllowed: chk(rmS, "remoteUseAllowed"), remoteUseNotes: txt(rmS, "remoteUseNotes"), requiresAuthentication: chk(rmS, "requiresAuthentication"), exposesSensitiveData: chk(rmS, "exposesSensitiveData"), securityLevel: txt(rmS, "securityLevel") || "local_only", }, notes: txt(no, "notes"), funcPurpose: taxV("funcPurpose"), modality: taxV("modality"), architecture: taxV("architecture"), cognitiveStyle: taxV("cognitiveStyle"), }; } function validate(d) { const e = []; const urlBad = (u) => u && !/^https?:\/\/[^\s]+$/i.test(u); if (!d.name) e.push("Nome é obrigatório."); if (!d.description) e.push("Descrição é obrigatória."); if (!d.executionType) e.push("Tipo de execução é obrigatório."); if (d.llamaArgs && d.llamaArgs.ctx_size != null && (d.llamaArgs.ctx_size < 512 || d.llamaArgs.ctx_size > 2000000)) e.push("Contexto (ctx_size) deve ficar entre 512 e 2.000.000 tokens."); if (d.llamaArgs && d.llamaArgs.parallel != null && (d.llamaArgs.parallel < 1 || d.llamaArgs.parallel > 64)) e.push("Slots paralelos (parallel) deve ficar entre 1 e 64."); if (["gpu", "hybrid"].includes(d.executionType) && !d.requirements.minimumVramGb) e.push("VRAM mínima é obrigatória para execução GPU ou híbrida."); if (["cpu", "hybrid"].includes(d.executionType) && !d.requirements.minimumRamGb) e.push("RAM mínima é obrigatória para execução CPU ou híbrida."); d.dependencies.forEach((x, i) => { if (!x.name || !x.type) e.push(`Dependência #${i + 1}: nome e tipo são obrigatórios.`); }); d.models.forEach((m, i) => { if (!m.name || !m.developer) e.push(`Modelo #${i + 1}: nome e desenvolvedor são obrigatórios.`); }); if (urlBad(d.modelUrl)) e.push("URL de download do modelo inválida (use http:// ou https://)."); if (urlBad(d.mmprojUrl)) e.push("URL de download do mmproj inválida (use http:// ou https://)."); if (d.models.some((m) => urlBad(m.url))) e.push("Há URL de modelo inválida (use http:// ou https://)."); if (d.dependencies.some((x) => urlBad(x.installUrl))) e.push("Há URL de dependência inválida (use http:// ou https://)."); return e; } function showErrors(list) { const box = document.getElementById("pf-errors"); if (!list.length) { box.classList.remove("show"); box.innerHTML = ""; return; } box.innerHTML = list.map((x) => `• ${esc(x)}`).join("
"); box.classList.add("show"); box.scrollIntoView({ behavior: "smooth", block: "center" }); } async function onSave(container, id) { const data = collect(container); const errs = validate(data); if (errs.length) { showErrors(errs); return; } showErrors([]); const btn = document.getElementById("pf-save"); btn.disabled = true; btn.textContent = "Salvando…"; try { if (id) { await API.profileUpdate(id, data); UI.toast("Perfil atualizado.", "ok"); } else { await API.profileCreate(data); UI.toast("Perfil criado.", "ok"); } renderList(container); } catch (ex) { if (ex.status === 422 || ex.status === 400) showErrors(serverErrors(ex)); else handleErr(ex); btn.disabled = false; btn.textContent = id ? "Salvar alterações" : "Criar perfil"; } } // ---- mini-prompt (nome para duplicar), usando o modal de confirmação ---- function promptText(title, label, def) { return new Promise((resolve) => { const bg = document.getElementById("modal-bg"); const okBtn = document.getElementById("modal-ok"); const cancelBtn = document.getElementById("modal-cancel"); const field = document.getElementById("modal-confirm-field"); const input = document.getElementById("modal-confirm-input"); const lbl = document.getElementById("modal-confirm-label"); document.getElementById("modal-title").textContent = title; document.getElementById("modal-text").textContent = ""; document.getElementById("modal-ico").className = "m-ico warn"; okBtn.className = "btn primary"; okBtn.textContent = "Confirmar"; okBtn.disabled = false; field.style.display = "block"; lbl.textContent = label; input.value = def || ""; input.oninput = null; const close = (val) => { bg.classList.remove("show"); okBtn.onclick = cancelBtn.onclick = null; resolve(val); }; okBtn.onclick = () => { const v = input.value.trim(); v ? close(v) : input.focus(); }; cancelBtn.onclick = () => close(null); bg.onclick = (e) => { if (e.target === bg) close(null); }; bg.classList.add("show"); setTimeout(() => { input.focus(); input.select(); }, 50); }); } return { render }; })();