/* 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 `Remover arquivo `;
if (m.canDownload)
return `Baixar modelo `;
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 `Apagar mmproj `;
if (m.canDownload)
return `Baixar mmproj `;
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
${UI.icon("plus", 16)} Novo perfil
`;
// Filtro por tipo de execução (multi-seleção; vazio = todos).
html += `
${TYPE_FILTERS.map((f) => `${f.label} `).join("")}
`;
// Filtro por habilitado/desabilitado.
html += `
${ENABLED_FILTERS.map((f) => `${f.label} `).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) => `${esc(o.label)} `).join("")}
`;
};
html += `
${hasTaxFilter ? `
Limpar filtros
` : ""}
${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
Atualizar
`;
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 ? `
Executar benchmark ` : ""}
${canActivate && !isRunning ? `
Ativar ` : ""}
${isRunning ? `
Desativar ` : ""}
${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 = `Quando Usuário Ação Perfil
${items.map((e) => `
${esc((e.ts || "").replace("T", " ").replace("+00:00", " UTC"))}
${esc(e.user || "—")}
${esc(e.action)}
${esc(e.profileName || e.profileId || "—")}
`).join("")}
`;
} 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 = `
${UI.icon("back", 15)} Voltar
${UI.icon("edit", 14)} Editar
${UI.icon("copy", 14)} Duplicar
${UI.icon("trash", 14)} Excluir
`;
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 ? `
Ativar perfil ` : ""}
${p.modelPath && p.model && p.model.downloaded ? `
${UI.icon("benchmarks", 14)} Benchmark ` : ""}
Iniciar
Reiniciar
Parar
${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 ? `
Nome Tipo Versão Obrigatória Link
${deps.map((d) => `${esc(d.name)} ${d.notes ? `${esc(d.notes)}
` : ""}
${esc(d.type)} ${esc(d.version || "—")}
${yesno(d.required)}
${d.installUrl ? `abrir ` : "—"} `).join("")}
` : `
Nenhuma dependência.
`}
`;
const models = p.models || [];
html += `${UI.icon("bolt")}
Modelos associados (${models.length})
${models.length ? `
Modelo Desenvolvedor Tipo Formato Tam. Contexto Obrig.
${models.map((m) => `${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)} `).join("")}
` : `
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) => `
${esc(t.label)} ${esc(t.description)}
`).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) => `
${esc(o.label)} ${esc(o.desc)}
`).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) =>
`${esc(s.label)} (${esc(s.name)}) `).join("");
const sec = (title, body, id) => ``;
let html = `
${UI.icon("back", 15)} Cancelar
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)}
Serviço vinculado (opcional)
— nenhum — ${svcOptions}
`, "af-general");
html += sec("2 · Requisitos de sistema", `
GPU obrigatória
Apenas CPU
`, "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 += `
${UI.icon("bolt", 15)} Gerar prompt
Cancelar
`;
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 = `
${UI.icon("bolt", 16)} Criar perfil com IA
1 · Copie e cole numa IA (ChatGPT, Claude, etc.)
${UI.icon("copy", 13)} Copiar
2 · Cole aqui a resposta da IA (JSON com 2–4 perfis)
`;
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 `
${esc(c.name || "Perfil " + (i + 1))}
${esc(c._resumo || c.description || "")}
${tags.map((t) => `${esc(t)} `).join("")}
`;
};
overlay.innerHTML = `
${UI.icon("bolt", 16)} Perfis sugeridos (${cands.length})
Escolha um para revisar no formulário antes de salvar.
${cands.map(cardFor).join("")}
`;
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) => `${ph}${req ? " *" : ""}
`;
const fNum = (f, v, ph) => `${ph}
`;
const fArea = (f, v, ph) => `${ph}
`;
const fCheck = (f, v, ph) => ` ${ph} `;
const fSelect = (f, v, ph, options, includeEmpty) => `${ph}
${includeEmpty ? `— selecione — ` : ""}
${options.map((o) => `${esc(o)} `).join("")}
`;
function depRow(d) {
d = d || {};
return `
DEPENDÊNCIA
${UI.icon("trash", 13)}
${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
${UI.icon("trash", 13)}
${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) => ``;
let html = `
${UI.icon("back", 15)} Cancelar
${editing ? "Editar perfil" : "Novo perfil"}
`;
// 1. Informações gerais
const svcOptions = (enums.services || []).map((s) =>
`${esc(s.label)} (${esc(s.name)}) `
).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 *")}
Serviço vinculado (opcional)
— nenhum — ${svcOptions}
${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 += ``;
// 4. Modelos
html += ``;
// 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) => `
${esc(o.label)} (${esc(o.id)})
`).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 }) => `${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 += `
${editing ? "Salvar alterações" : "Criar perfil"}
Cancelar
`;
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 };
})();