/* Módulo de Configurações — RF-CONFIG-01 a 14 (ESP-0010, ADR-0008).
Vive dentro do shell, como `view_users.jsx`, e não como takeover.
Duas seções, sem endpoint nem tela em comum entre elas (RF-CONFIG-02):
Parâmetros (RF-CONFIG-07, valor sempre visível) e Segredos (RF-CONFIG-03,
write-only — o valor nunca volta do backend, só `prefixo`/`sufixo`
mascarados). Esconder o item de menu para quem não é administrador não é
proteção — a autorização é do backend (RF-CONFIG-01). */
const TIPOS_DE_PARAMETRO = [
["bool", "Sim/Não"],
["enum", "Lista de opções"],
["int", "Número inteiro"],
];
const CATEGORIAS_DE_SEGREDO = [
["llm", "Modelo de linguagem (LiteLLM)"],
["tts", "Síntese de voz (TTS)"],
["litellm_admin", "Master key do LiteLLM"],
];
const NOME_DA_CATEGORIA = Object.fromEntries(CATEGORIAS_DE_SEGREDO);
// Só os provedores com testador implementado em `services/credential_test.py`
// (ADR-0008 item 8: documentado por provedor, não genérico).
const PROVIDERS_POR_CATEGORIA = {
llm: [
["openai", "OpenAI"],
["anthropic", "Anthropic"],
["gemini", "Google Gemini"],
],
tts: [
["openai", "OpenAI TTS"],
["azure_speech", "Azure Speech"],
["elevenlabs", "ElevenLabs"],
],
litellm_admin: [["litellm", "LiteLLM"]],
};
const ERROS_DE_CONFIGURACAO = {
parametro_invalido: null, // a mensagem do backend já orienta
segredo_invalido: null,
segredo_nao_encontrado: "Credencial não encontrada. Atualize a listagem.",
cooldown_ativo: null, // o backend já informa o tempo restante
etapa_indisponivel: null,
litellm_registro_falhou: null,
};
function mensagemDeErroDeConfiguracao(erro) {
if (!erro) return "";
const especifica = ERROS_DE_CONFIGURACAO[erro.code];
if (especifica) return especifica;
return erro.message || "Não foi possível concluir a solicitação.";
}
/* --- Parâmetros simples (RF-CONFIG-07) ------------------------------------ */
// RF-CONFIG-15: inteiro não-negativo. O ` ` pode devolver
// "" (campo vazio) — nesse estado o Salvar fica travado, o backend recusaria.
function IntInput({ valor, onChange }) {
return (
{
const cru = e.target.value;
if (cru === "") { onChange(""); return; }
const n = parseInt(cru, 10);
onChange(Number.isFinite(n) ? Math.max(0, n) : "");
}} />
);
}
function ParameterRow({ parametro, onSalvar, busy }) {
const [valor, setValor] = React.useState(parametro.valor);
const alterado = JSON.stringify(valor) !== JSON.stringify(parametro.valor);
const intInvalido = parametro.tipo === "int" && !Number.isInteger(valor);
return (
{parametro.rotulo ? (
<>
{parametro.rotulo}
{parametro.chave}
>
) : (
{parametro.chave}
)}
{parametro.descricao || sem descrição }
{parametro.tipo === "bool" ? (
) : parametro.tipo === "int" ? (
) : (
[o, o])} />
)}
onSalvar({ ...parametro, valor })}>
Salvar
);
}
function NovoParametroForm({ onCriar, busy }) {
const [chave, setChave] = React.useState("");
const [tipo, setTipo] = React.useState("bool");
const [valorBool, setValorBool] = React.useState(false);
const [valorEnum, setValorEnum] = React.useState("");
const [valorInt, setValorInt] = React.useState(0);
const [opcoesTexto, setOpcoesTexto] = React.useState("");
const [descricao, setDescricao] = React.useState("");
const [rotulo, setRotulo] = React.useState("");
const listaDeOpcoes = opcoesTexto.split(",").map(o => o.trim()).filter(Boolean);
const podeCriar = chave.trim() && (
tipo === "bool"
|| (tipo === "int" && Number.isInteger(valorInt))
|| (tipo === "enum" && listaDeOpcoes.length > 0 && valorEnum)
);
const valorPorTipo = { bool: valorBool, int: valorInt, enum: valorEnum };
const submeter = () => {
if (!podeCriar || busy) return;
onCriar({
chave: chave.trim(),
tipo,
valor: valorPorTipo[tipo],
opcoes: tipo === "enum" ? listaDeOpcoes : null,
descricao: descricao.trim(),
rotulo: rotulo.trim() || null,
});
setChave(""); setDescricao(""); setRotulo(""); setOpcoesTexto(""); setValorEnum(""); setValorBool(false); setValorInt(0);
};
return (
{tipo === "bool" ? (
Valor inicial
) : tipo === "int" ? (
Valor inicial
) : (
<>
>
)}
);
}
function ParametersTab({ onToast }) {
const [itens, setItens] = React.useState([]);
const [carregando, setCarregando] = React.useState(true);
const [erro, setErro] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const carregar = React.useCallback(() => {
setCarregando(true);
setErro(null);
window.API.listSettings().then(itens => {
setItens(itens || []);
setCarregando(false);
}).catch(erro => {
setErro(erro);
setCarregando(false);
});
}, []);
React.useEffect(() => { carregar(); }, [carregar]);
const salvar = (parametro) => {
setBusy(true);
setErro(null);
window.API.updateSetting(parametro.chave, {
tipo: parametro.tipo,
valor: parametro.valor,
opcoes: parametro.opcoes,
descricao: parametro.descricao,
rotulo: parametro.rotulo,
}).then(() => {
setBusy(false);
onToast("Parâmetro salvo.");
carregar();
}).catch(erro => {
setBusy(false);
setErro(erro);
});
};
return (
{erro && (
setErro(null)}>
{mensagemDeErroDeConfiguracao(erro)}
)}
{carregando ? (
Carregando...
) : (
Chave Descrição Valor Ações
{itens.map(parametro => (
))}
)}
{!carregando && !itens.length && (
Nenhum parâmetro cadastrado ainda.
)}
);
}
/* --- Segredos / credenciais externas (RF-CONFIG-03 a 14) ------------------ */
function TestResultBadge({ resultado }) {
if (!resultado) return nunca testada nesta sessão ;
if (resultado.sucesso) {
return etapa {resultado.etapa === "litellm" ? "LiteLLM" : "provedor"} ok ;
}
return {resultado.mensagem || "falhou"} ;
}
function SecretForm({ segredo, busy, erro, testando, resultadoTeste, onSalvar, onTestar, onCancelar }) {
const editando = !!segredo;
const [chave, setChave] = React.useState(segredo ? segredo.chave : "");
const [categoria, setCategoria] = React.useState(segredo ? segredo.categoria : "llm");
const [provider, setProvider] = React.useState(
segredo ? segredo.provider : PROVIDERS_POR_CATEGORIA.llm[0][0]
);
const [roteado, setRoteado] = React.useState(segredo ? segredo.roteado_por_litellm : false);
const [modelName, setModelName] = React.useState(segredo && segredo.model_name ? segredo.model_name : "");
const [regiao, setRegiao] = React.useState(
segredo && segredo.parametros ? (segredo.parametros.regiao || "") : ""
);
const [valor, setValor] = React.useState("");
// Trocar de categoria muda o conjunto de provedores válidos; RF-CONFIG-06
// proíbe TTS/master-key roteados pelo LiteLLM.
React.useEffect(() => {
const opcoes = PROVIDERS_POR_CATEGORIA[categoria] || [];
if (!opcoes.some(([v]) => v === provider)) setProvider(opcoes[0] ? opcoes[0][0] : "");
if (categoria !== "llm" && roteado) setRoteado(false);
}, [categoria]);
const precisaRegiao = provider === "azure_speech";
const podeTestar = chave.trim() && valor.trim() && !testando;
const podeSalvar =
chave.trim() &&
provider &&
valor.trim() &&
(!roteado || modelName.trim()) &&
(!precisaRegiao || regiao.trim());
const submeter = () => {
if (!podeSalvar || busy) return;
onSalvar(chave.trim(), {
categoria,
provider,
roteado_por_litellm: roteado,
model_name: roteado ? modelName.trim() : null,
parametros: precisaRegiao ? { regiao: regiao.trim() } : null,
valor,
});
};
return (
{erro &&
{mensagemDeErroDeConfiguracao(erro)} }
{categoria === "llm" && (
Roteada pelo LiteLLM
)}
{roteado && (
)}
{precisaRegiao && (
)}
{resultadoTeste && (
{resultadoTeste.sucesso ? "O provedor aceitou a chave." : resultadoTeste.mensagem}
)}
onTestar({ chave: chave.trim(), etapa: "provedor", valor, provider })}>
{testando ? "Testando..." : "Testar antes de salvar"}
Cancelar
{busy ? "Salvando..." : (editando ? "Rotacionar credencial" : "Cadastrar credencial")}
);
}
function SecretRow({ segredo, onEditar, onTestar, testando, resultadoTeste }) {
return (
{segredo.chave}
{segredo.provider}
{segredo.roteado_por_litellm && segredo.model_name ? " · " + segredo.model_name : ""}
{NOME_DA_CATEGORIA[segredo.categoria] || segredo.categoria}
{segredo.roteado_por_litellm && roteada }
{segredo.prefixo}••••••{segredo.sufixo}
{new Date(segredo.atualizado_em).toLocaleString("pt-BR")}
{testando ? testando... : }
onEditar(segredo)} />
onTestar(segredo, "provedor")} />
{segredo.roteado_por_litellm && (
onTestar(segredo, "litellm")} />
)}
);
}
function SecretsTab({ onToast }) {
const [itens, setItens] = React.useState([]);
const [carregando, setCarregando] = React.useState(true);
const [erroLista, setErroLista] = React.useState(null);
const [editando, setEditando] = React.useState(null); // null | "novo" | segredo
const [busy, setBusy] = React.useState(false);
const [erroForm, setErroForm] = React.useState(null);
const [testandoForm, setTestandoForm] = React.useState(false);
const [resultadoForm, setResultadoForm] = React.useState(null);
// Resultado do último teste por linha — só nesta sessão de tela. O backend
// não guarda "último resultado" no segredo, só em registros de auditoria
// (RF-CONFIG-14), que não são o que esta lista consulta.
const [resultadosPorChave, setResultadosPorChave] = React.useState({});
const [testandoChave, setTestandoChave] = React.useState(null);
const carregar = React.useCallback(() => {
setCarregando(true);
setErroLista(null);
window.API.listSecrets().then(itens => {
setItens(itens || []);
setCarregando(false);
}).catch(erro => {
setErroLista(erro);
setCarregando(false);
});
}, []);
React.useEffect(() => { carregar(); }, [carregar]);
const salvar = (chave, dados) => {
setBusy(true);
setErroForm(null);
window.API.updateSecret(chave, dados).then(() => {
setBusy(false);
const eraNovo = editando === "novo";
setEditando(null);
setResultadoForm(null);
onToast(eraNovo ? "Credencial cadastrada." : "Credencial rotacionada.");
carregar();
}).catch(erro => {
setBusy(false);
setErroForm(erro);
});
};
const testarNoFormulario = (pedido) => {
setTestandoForm(true);
setResultadoForm(null);
const { chave, ...corpo } = pedido;
window.API.testSecret(chave, corpo).then(resultado => {
setTestandoForm(false);
setResultadoForm(resultado);
}).catch(erro => {
setTestandoForm(false);
setResultadoForm({ sucesso: false, mensagem: mensagemDeErroDeConfiguracao(erro) });
});
};
const testarNaLista = (segredo, etapa) => {
setTestandoChave(segredo.chave);
setErroLista(null);
window.API.testSecret(segredo.chave, { etapa }).then(resultado => {
setTestandoChave(null);
setResultadosPorChave(anterior => ({ ...anterior, [segredo.chave]: resultado }));
}).catch(erro => {
setTestandoChave(null);
setResultadosPorChave(anterior => ({
...anterior,
[segredo.chave]: { sucesso: false, etapa, mensagem: mensagemDeErroDeConfiguracao(erro) },
}));
});
};
if (editando) {
return (
{editando === "novo" ? "Nova credencial" : "Rotacionar " + editando.chave}
O valor nunca é reexibido depois de salvo — só os últimos caracteres, como metadado.
{ setEditando(null); setErroForm(null); setResultadoForm(null); }}
/>
);
}
return (
{erroLista && (
setErroLista(null)}>
{mensagemDeErroDeConfiguracao(erroLista)}
)}
setEditando("novo")}>Nova credencial
{carregando ? (
Carregando...
) : (
Credencial Categoria Valor
Atualizada em Último teste Ações
{itens.map(segredo => (
))}
)}
{!carregando && !itens.length && (
Nenhuma credencial cadastrada ainda.
)}
);
}
/* --- Tela --------------------------------------------------------------- */
function SettingsView({ onToast }) {
const [aba, setAba] = React.useState("parametros");
return (
Administração
Configurações
Parâmetros do Portal e credenciais externas de IA — sem redeploy.
setAba("parametros")}>Parâmetros
setAba("segredos")}>Segredos
{aba === "parametros" ?
:
}
);
}
Object.assign(window, { SettingsView });