feat: integrate MCP servers as tools in Chat Agent with function calling

Add MCP tool discovery (auto + manual), tool execution via MCP SDK,
and GenAI function calling loop (Cohere + Generic formats) so the
chat agent can invoke MCP server tools during conversations.

- Add mcp SDK dependency
- New endpoints: discover-tools, update tools
- Modify _call_genai to support tools/tool_results and return (text, tool_calls)
- Tool use loop in chat endpoint (max 5 iterations)
- Frontend: tools management in MCP cards, chat toggle, tools_used display
- Multi-table ADB vector search, enriched embeddings, preview chunks
- Orphaned report cleanup on container restart
- Nginx no-cache headers, searchable dropdowns, editable vector tables
This commit is contained in:
nogueiraguh
2026-03-04 23:15:27 -03:00
parent 7448f98ce0
commit 0f5c805601
5 changed files with 2309 additions and 253 deletions

View File

@@ -214,7 +214,9 @@ const LOGO_R=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width=
const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],genaiCfg:[],adbCfg:[],mcpSvr:[],users:[],auditLogs:[],models:{},regions:[],embModels:{},selGenai:'',expData:null,editing:null,
chatModel:'',chatOci:'',chatRegion:'',chatCompartment:'',
chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatParamsOpen:false};
chatParams:{temperature:1,max_tokens:6000,top_p:0.95,top_k:1,frequency_penalty:0,presence_penalty:0},chatParamsOpen:false,chatUseTools:true,
chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'',
rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:''};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
@@ -228,8 +230,9 @@ async function doLogout(){try{await $api('/auth/logout',{method:'POST'})}catch(e
async function checkAuth(){const t=localStorage.getItem('t');if(!t)return;S.token=t;try{S.user=await $api('/users/me');await loadData()}catch(e){S.token=null;localStorage.removeItem('t')}}
async function loadData(){try{
[S.ociCfg,S.reports,S.genaiCfg,S.mcpSvr]=await Promise.all([$api('/oci/configs'),$api('/reports'),$api('/genai/configs'),$api('/mcp/servers')]);
try{const m=await $api('/genai/models');S.models=m.models;S.regions=m.regions;S.embModels=m.embedding_models||{}}catch(e){}
try{const m=await $api('/genai/models');S.models=m.models;S.regions=m.regions;S.embModels=m.embedding_models||{};S.ociRegions=m.oci_regions||{}}catch(e){}
try{S.adbCfg=await $api('/adb/configs')}catch(e){S.adbCfg=[]}
try{S.chatPrompts=await $api('/prompts/chat')}catch(e){S.chatPrompts=[]}
if(S.user.role==='admin'){try{S.users=await $api('/users')}catch(e){}}
}catch(e){console.error(e)}}
function R(){document.getElementById('app').innerHTML=S.user?rApp():rLogin();bind();if(S.editing?.type==='mcp')setTimeout(mcpTypeFields,0)}
@@ -275,7 +278,7 @@ function rPg(){switch(S.tab){case'chat':return rChat();case'explorer':return rEx
/* ── Chat ── */
function rChat(){
const ms=S.msgs.length===0?'<div class="emp"><div class="eic">🤖</div><p>Inicie uma conversa com o agente.</p><p style="font-size:.74rem;margin-top:.35rem">Selecione um modelo ou use o agente local.</p></div>'
const ms=S.msgs.length===0?'<div class="emp"><div class="eic">🤖</div><p>Inicie uma conversa com o agente.</p><p style="font-size:.74rem;margin-top:.35rem">Selecione um modelo para começar.</p></div>'
:S.msgs.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${fm(m.c)}</div></div>`).join('');
// Build custom dropdown items
// Skip non-chat models: Codex (completions only), Image, Audio, Guard, ProtectAI, GPT-oss, Pro (Responses API only)
@@ -284,11 +287,11 @@ function rChat(){
'openai.gpt-oss-120b','openai.gpt-oss-20b']);
const provs={};for(const[mid,info]of Object.entries(S.models)){if(skip.has(mid))continue;const p=info.provider||'other';if(!provs[p])provs[p]=[];provs[p].push({id:mid,name:info.name})}
const provOrder=['openai','google','meta','xai'];
let ddItems=`<div class="itm${!S.chatModel?' sel':''}" onclick="pickModel('')">Agente Local (sem IA)</div>`;
let ddItems='';
if(S.genaiCfg.length){ddItems+=`<div class="grp">Configs Salvas</div>`;S.genaiCfg.forEach(g=>{const mi=S.models[g.model_id];ddItems+=`<div class="itm${S.chatModel==='cfg:'+g.id?' sel':''}" onclick="pickModel('cfg:${g.id}')">${g.name||mi?.name||g.model_id} (${g.genai_region})</div>`})}
provOrder.filter(p=>provs[p]).forEach(p=>{ddItems+=`<div class="grp">${p.charAt(0).toUpperCase()+p.slice(1)}</div>`;provs[p].forEach(m=>{ddItems+=`<div class="itm${S.chatModel===m.id?' sel':''}" onclick="pickModel('${m.id}')">${m.name}</div>`})});
// Current label
let curLabel='Agente Local (sem IA)';
let curLabel='Selecione um modelo...';
if(S.chatModel.startsWith('cfg:')){const g=S.genaiCfg.find(x=>x.id===S.chatModel.slice(4));const mi=g?S.models[g.model_id]:null;curLabel=g?(g.name||mi?.name||g.model_id)+' ('+g.genai_region+')':'Config...'}
else if(S.chatModel){const mi=S.models[S.chatModel];curLabel=mi?mi.name:S.chatModel}
const isDirect=S.chatModel&&!S.chatModel.startsWith('cfg:');
@@ -307,7 +310,9 @@ function rChat(){
<div><label>Top K</label><input type="number" value="${pp.top_k}" min="-1" max="500" onchange="S.chatParams.top_k=+this.value"></div>
<div><label>Freq Penalty</label><input type="number" value="${pp.frequency_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.frequency_penalty=+this.value"></div>
<div><label>Pres Penalty</label><input type="number" value="${pp.presence_penalty}" step="0.1" min="0" max="2" onchange="S.chatParams.presence_penalty=+this.value"></div>
</div></div>`:'';
</div><div style="margin-top:.5rem;display:flex;align-items:center;gap:.5rem;padding:.3rem 0">
<label style="display:flex;align-items:center;gap:.35rem;cursor:pointer;font-size:.76rem"><input type="checkbox" ${S.chatUseTools?'checked':''} onchange="S.chatUseTools=this.checked"> 🔌 Usar MCP Tools no Chat</label>
${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).length?`<span style="font-size:.66rem;color:var(--t4)">(${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).reduce((a,m)=>a+m.tools.length,0)} tools disponíveis)</span>`:''}</div></div>`:'';
return`<div class="ch-c">
<div class="ch-t"><label>🧠 Modelo:</label><div class="mdrop"><div class="mdrop-btn" onclick="toggleModelDrop()">${curLabel} <span class="arr">▼</span></div><div class="mdrop-dd" id="mdd"><input type="text" id="mds" placeholder="Buscar modelo..." oninput="filterModels(this.value)"><div class="mdrop-list" id="mdl">${ddItems}</div></div></div>
${ociSel}${gearBtn}${ragBadge}<div style="flex:1"></div><button class="btn bd bsm" onclick="clrChat()">Limpar</button></div>
@@ -323,13 +328,24 @@ function filterModels(q){const list=document.getElementById('mdl');const items=l
items.forEach(el=>{if(el.classList.contains('grp')){if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none';lastGrp=el;grpHasVisible=false}
else{const show=!q||el.textContent.toLowerCase().includes(ql);el.style.display=show?'':'none';if(show)grpHasVisible=true}});
if(lastGrp)lastGrp.style.display=grpHasVisible?'':'none'}
document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open')})
document.addEventListener('click',e=>{const dd=document.getElementById('mdd');if(dd&&dd.classList.contains('open')&&!e.target.closest('.mdrop'))dd.classList.remove('open');
if(!e.target.closest('.dd')){let changed=false;if(S.rptOciOpen){S.rptOciOpen=false;changed=true}if(S.rptRselOpen){S.rptRselOpen=false;changed=true}if(S.ociFormRegOpen){S.ociFormRegOpen=false;changed=true}if(S.rptRegionsOpen){S.rptRegionsOpen=false;changed=true}if(changed)R()}})
function chatOciChanged(){S.chatOci=document.getElementById('coci').value;const c=S.ociCfg.find(x=>x.id===S.chatOci);if(c){S.chatRegion=c.region;S.chatCompartment=c.compartment_id||''}}
function toggleChatParams(){S.chatParamsOpen=!S.chatParamsOpen;R()}
function editPrompt(id){S.editingPrompt=id;R()}
async function savePrompt(){const name=document.getElementById('spname').value;const content=document.getElementById('gsysp').value;
if(!name||!content)return sm('spm','Preencha nome e conteúdo do prompt.','e');
try{if(S.editingPrompt){await $api('/prompts/'+S.editingPrompt,{method:'PUT',body:{name,content}})}
else{await $api('/prompts',{method:'POST',body:{agent:'chat',name,content,is_active:!S.chatPrompts.length}})}
S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');sm('spm','✅ Prompt salvo!','s');R()}catch(e){sm('spm',e.message,'e')}}
async function activatePrompt(id){try{await $api('/prompts/'+id,{method:'PUT',body:{is_active:true}});S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}}
async function delPrompt(id){if(!confirm('Excluir prompt?'))return;try{await $api('/prompts/'+id,{method:'DELETE'});S.editingPrompt=null;S.chatPrompts=await $api('/prompts/chat');R()}catch(e){sm('spm',e.message,'e')}}
function fm(t){return t.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/`(.*?)`/g,'<code>$1</code>').replace(/\n/g,'<br>')}
async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();if(!m)return;el.value='';
async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();if(!m)return;
if(!S.chatModel){S.msgs.push({r:'assistant',c:'⚠️ Selecione um modelo antes de enviar uma mensagem.'});R();return}
el.value='';
S.msgs.push({r:'user',c:m});R();scCh();
try{const body={message:m,session_id:S.sid};
try{const body={message:m,session_id:S.sid,use_tools:S.chatUseTools};
if(S.chatModel.startsWith('cfg:')){
body.genai_config_id=S.chatModel.slice(4);
body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p;
@@ -339,7 +355,10 @@ async function sChat(){const el=document.getElementById('chi');const m=el.value.
body.model_id=S.chatModel;body.oci_config_id=S.chatOci;body.genai_region=S.chatRegion;body.compartment_id=S.chatCompartment;
body.temperature=S.chatParams.temperature;body.max_tokens=S.chatParams.max_tokens;body.top_p=S.chatParams.top_p;
body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty}
const d=await $api('/chat',{method:'POST',body});S.sid=d.session_id;S.msgs.push({r:'assistant',c:d.response});R();scCh()}
const d=await $api('/chat',{method:'POST',body});S.sid=d.session_id;
let resp=d.response;
if(d.tools_used&&d.tools_used.length){resp+='\n\n🔧 **Tools utilizadas:** '+d.tools_used.map(t=>t.name).join(', ')}
S.msgs.push({r:'assistant',c:resp});R();scCh()}
catch(e){S.msgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}}
function scCh(){setTimeout(()=>{const e=document.getElementById('chm');if(e)e.scrollTop=e.scrollHeight},50)}
async function clrChat(){if(S.sid)try{await $api('/chat/'+S.sid,{method:'DELETE'})}catch(e){}S.msgs=[];S.sid=null;R()}
@@ -367,25 +386,101 @@ ${keys.filter(k=>!['display_name','name','db_name','lifecycle_state'].includes(k
/* ── Report ── */
function rReport(){const comp=S.reports.filter(r=>r.status==='completed');
if(!comp.length)return`<div class="cd"><div class="ct">📊 Compliance Reports</div><div class="emp"><div class="eic">📄</div><p>Nenhum relatório gerado ainda.</p></div></div>${rRunRpt()}`;
const running=S.reports.filter(r=>r.status==='running');
let progressHtml='';
if(S.trackingReportId||running.length){
if(!S.trackingReportId&&running.length)S.trackingReportId=running[0].id;
const rr=running.find(r=>r.id===S.trackingReportId);
const lines=(rr?.progress||'').split('\n').filter(Boolean);
const last=lines[lines.length-1]||'Iniciando...';
const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0;
progressHtml=`<div id="rpt-progress">${rProgressCard('running',last,pct,lines,S.trackingReportId)}</div>`;
if(!_rptPollTimer)startRptPoll()}
if(!comp.length)return`${progressHtml}<div class="cd"><div class="ct">📊 Compliance Reports</div><div class="emp"><div class="eic">📄</div><p>Nenhum relatório gerado ainda.</p></div></div>${rRunRpt()}`;
const lt=comp[0];
return`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem"><span style="font-size:.78rem;color:var(--t3)">Report HTML — ${lt.tenancy_name}</span>
<select id="rsel" onchange="ldRpt(this.value)" style="max-width:280px">${comp.map(r=>`<option value="${r.id}">${r.tenancy_name}${r.created_at}</option>`).join('')}</select></div>
<iframe class="rif" id="rfr" src="${API}/reports/${lt.id}/html"></iframe>${rRunRpt()}`}
const selRpt=S.rptRselVal?comp.find(r=>r.id===S.rptRselVal):lt;
if(!S.rptRselVal)S.rptRselVal=lt.id;
const rselF=S.rptRselFilter.toLowerCase();
const rselLabel=selRpt?`${selRpt.tenancy_name}${selRpt.created_at}`:'Selecione relatório...';
let rselDdItems=comp.filter(r=>!rselF||r.tenancy_name.toLowerCase().includes(rselF)||r.created_at.includes(rselF)).map(r=>`<div class="itm${S.rptRselVal===r.id?' sel':''}" onclick="rptPickRsel('${r.id}')">${r.tenancy_name}${r.created_at}</div>`).join('');
return`${progressHtml}<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem"><span style="font-size:.78rem;color:var(--t3)">Report HTML — ${(selRpt||lt).tenancy_name}</span>
<div class="dd" style="position:relative;max-width:340px;flex:1;margin-left:.5rem"><div onclick="S.rptRselOpen=!S.rptRselOpen;R()" style="display:flex;align-items:center;cursor:pointer;padding:.3rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.74rem;justify-content:space-between;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<span style="overflow:hidden;text-overflow:ellipsis">${rselLabel}</span><span style="font-size:.6rem;color:var(--t4);margin-left:.3rem">▼</span></div>
${S.rptRselOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:220px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar relatório..." value="${S.rptRselFilter}" oninput="S.rptRselFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
${rselDdItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhum relatório encontrado</div>'}
</div>`:''}
</div></div>
<iframe class="rif" id="rfr" src="${API}/reports/${(selRpt||lt).id}/html"></iframe>${rRunRpt()}`}
function rRunRpt(){if(S.user?.role==='viewer')return'';
const selTags=S.rptSelRegions.map(r=>`<span class="tag" style="background:var(--b2);font-size:.62rem;display:inline-flex;align-items:center;gap:.2rem">${r}<span onclick="rptRemoveRegion('${r}')" style="cursor:pointer;font-weight:700;color:var(--err)">&times;</span></span>`).join(' ');
const f=S.rptRegionFilter.toLowerCase();
let ddItems='';
for(const[grp,regs] of Object.entries(S.ociRegions)){
const filtered=regs.filter(r=>!S.rptSelRegions.includes(r)&&(!f||r.includes(f)));
if(!filtered.length)continue;
ddItems+=`<div class="grp">${grp}</div>`;
filtered.forEach(r=>{ddItems+=`<div class="itm" onclick="rptPickRegion('${r}')">${r}</div>`})}
const ociF=S.rptOciFilter.toLowerCase();
const selOci=S.ociCfg.find(c=>c.id===S.rptOciVal);
const ociLabel=selOci?`${selOci.tenancy_name} (${selOci.region})`:'Selecione config OCI...';
let ociDdItems=S.ociCfg.filter(c=>!ociF||c.tenancy_name.toLowerCase().includes(ociF)||c.region.toLowerCase().includes(ociF)).map(c=>`<div class="itm${S.rptOciVal===c.id?' sel':''}" onclick="rptPickOci('${c.id}')">${c.tenancy_name} (${c.region})</div>`).join('');
return`<div class="cd" style="margin-top:.85rem"><div class="ct">🚀 Executar Relatório</div>
<div class="g3"><div class="ig"><label>Config OCI</label><select id="rc"><option value="">Selecione...</option>
${S.ociCfg.map(c=>`<option value="${c.id}">${c.tenancy_name} (${c.region})</option>`).join('')}</select></div>
<div class="g3"><div class="ig"><label>Config OCI</label>
<div class="dd" style="position:relative"><div onclick="S.rptOciOpen=!S.rptOciOpen;R()" style="min-height:34px;display:flex;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem;justify-content:space-between">
<span style="${selOci?'':'color:var(--t4)'}">${ociLabel}</span><span style="font-size:.6rem;color:var(--t4)">▼</span></div>
${S.rptOciOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:220px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar credencial..." value="${S.rptOciFilter}" oninput="S.rptOciFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
${ociDdItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma credencial encontrada</div>'}
</div>`:''}
</div></div>
<div class="ig"><label>MCP Server (opcional)</label><select id="rmcp"><option value="">Padrão (stub)</option>
${S.mcpSvr.filter(m=>m.is_active).map(m=>`<option value="${m.id}">${m.name}${m.linked_adb_id?' 🗄️':''}</option>`).join('')}</select></div>
<div class="ig"><label>Regiões (opcional)</label><input type="text" id="rr" placeholder="sa-saopaulo-1, us-ashburn-1"></div></div>
<div class="ig"><label>Regiões (opcional — vazio = todas)</label>
<div class="dd" style="position:relative"><div onclick="S.rptRegionsOpen=!S.rptRegionsOpen;R()" style="min-height:34px;display:flex;flex-wrap:wrap;gap:.25rem;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem">
${S.rptSelRegions.length?selTags:'<span style="color:var(--t4);font-size:.78rem">Todas as regiões</span>'}</div>
${S.rptRegionsOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:260px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar região..." value="${S.rptRegionFilter}" oninput="S.rptRegionFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma região encontrada</div>'}
</div>`:''}
</div></div></div>
<button class="btn bp" onclick="runRpt()">▶ Executar CIS Compliance</button></div>`}
function ldRpt(id){const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html'}
async function runRpt(){const c=document.getElementById('rc').value;if(!c)return alert('Selecione config OCI');
const rg=document.getElementById('rr').value.split(',').map(r=>r.trim()).filter(Boolean);
function rptPickRegion(r){S.rptSelRegions.push(r);S.rptRegionFilter='';R()}
function rptRemoveRegion(r){S.rptSelRegions=S.rptSelRegions.filter(x=>x!==r);R()}
function rptPickOci(id){S.rptOciVal=id;S.rptOciOpen=false;S.rptOciFilter='';R()}
function rptPickRsel(id){S.rptRselVal=id;S.rptRselOpen=false;S.rptRselFilter='';const f=document.getElementById('rfr');if(f)f.src=API+'/reports/'+id+'/html';R()}
async function runRpt(){if(S.reports.some(r=>r.status==='running'))return alert('Já existe um relatório em execução. Aguarde ou cancele antes de iniciar outro.');
const c=S.rptOciVal;if(!c)return alert('Selecione config OCI');
const rg=S.rptSelRegions;
const mcp=document.getElementById('rmcp').value||null;
S.rptRegionsOpen=false;
try{const d=await $api('/reports/run',{method:'POST',body:{config_id:c,mcp_server_id:mcp,regions:rg.length?rg:null}});
alert('Relatório iniciado! ID: '+d.report_id);S.reports=await $api('/reports');R()}catch(e){alert('Erro: '+e.message)}}
S.trackingReportId=d.report_id;S.rptSelRegions=[];S.rptOciVal='';S.reports=await $api('/reports');R();startRptPoll()}catch(e){alert('Erro: '+e.message)}}
let _rptPollTimer=null;
function startRptPoll(){stopRptPoll();_rptPollTimer=setInterval(pollRptProgress,3000)}
function stopRptPoll(){if(_rptPollTimer){clearInterval(_rptPollTimer);_rptPollTimer=null}}
async function pollRptProgress(){if(!S.trackingReportId)return stopRptPoll();
try{const p=await $api('/reports/'+S.trackingReportId+'/progress');
const el=document.getElementById('rpt-progress');
if(el){const lines=(p.progress||'').split('\n').filter(Boolean);
const last=lines[lines.length-1]||'Iniciando...';
const m=last.match(/\[(\d+)\/(\d+)\]/);const pct=m?Math.round((parseInt(m[1])/parseInt(m[2]))*100):0;
el.innerHTML=rProgressCard(p.status,last,pct,lines,S.trackingReportId)}
if(p.status==='completed'||p.status==='failed'||p.status==='cancelled'){stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}}catch(e){}}
async function cancelRpt(rid){if(!confirm('Cancelar este relatório?'))return;
try{await $api('/reports/'+rid+'/cancel',{method:'POST'});stopRptPoll();S.trackingReportId=null;S.reports=await $api('/reports');R()}catch(e){alert('Erro: '+e.message)}}
function rProgressCard(status,lastStep,pct,lines,rid){
if(status==='completed')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--ok)"><div style="padding:.75rem"><strong style="color:var(--ok)">Relatório concluído!</strong></div></div>';
if(status==='failed')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--err)"><div style="padding:.75rem"><strong style="color:var(--err)">Relatório falhou</strong><p style="font-size:.72rem;color:var(--t4);margin-top:.25rem">'+lastStep+'</p></div></div>';
if(status==='cancelled')return'<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--t4)"><div style="padding:.75rem"><strong style="color:var(--t4)">Relatório cancelado</strong></div></div>';
const cancelBtn=rid?` <button class="btn bs bsm" style="color:var(--err);border-color:var(--err);font-size:.68rem;padding:.15rem .5rem" onclick="cancelRpt('${rid}')">Cancelar</button>`:'';
return`<div class="cd" style="margin-bottom:.75rem;border-left:3px solid var(--ac)"><div style="padding:.75rem">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.4rem"><strong style="font-size:.82rem">Gerando relatório...${cancelBtn}</strong><span style="font-size:.72rem;color:var(--t4)">${pct}%</span></div>
<div style="background:var(--b2);border-radius:6px;height:8px;overflow:hidden;margin-bottom:.4rem"><div style="background:var(--ac);height:100%;border-radius:6px;width:${pct}%;transition:width .5s ease"></div></div>
<p style="font-size:.72rem;color:var(--t3);margin:0">${lastStep}</p>
${lines.length>1?`<details style="margin-top:.35rem"><summary style="font-size:.68rem;color:var(--t4);cursor:pointer">Ver log completo</summary><pre style="font-size:.62rem;color:var(--t4);margin-top:.25rem;max-height:120px;overflow:auto;white-space:pre-wrap">${lines.join('\n')}</pre></details>`:''}
</div></div>`}
/* ── Downloads ── */
function rDl(){return`<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.55rem">
@@ -393,53 +488,74 @@ function rDl(){return`<div style="display:flex;justify-content:space-between;ali
<button class="btn bs bsm" onclick="refreshDl()">🔄 Atualizar</button></div>
<div class="cd"><table><thead><tr><th>Tenancy</th><th>Status</th><th>Criado</th><th>Concluído</th><th>Ações</th></tr></thead>
<tbody>${S.reports.map(r=>`<tr><td><strong>${r.tenancy_name}</strong></td>
<td><span class="badge ${r.status==='completed'?'b-pass':r.status==='failed'?'b-fail':'b-pend'}">${r.status}</span></td>
<td><span class="badge ${r.status==='completed'?'b-pass':r.status==='failed'||r.status==='cancelled'?'b-fail':'b-pend'}">${r.status==='cancelled'?'cancelado':r.status}</span>${r.status==='running'&&r.progress?`<div style="font-size:.62rem;color:var(--t4);margin-top:.2rem">${(r.progress||'').split('\\n').filter(Boolean).pop()||''}</div>`:''}</td>
<td style="font-size:.72rem">${r.created_at||'—'}</td><td style="font-size:.72rem">${r.completed_at||'—'}</td>
<td>${r.status==='completed'?`<button class="btn bs bsm" onclick="dlRpt('${r.id}','json')">JSON</button> <button class="btn bs bsm" onclick="dlRpt('${r.id}','html')">HTML</button>`:'<span style="color:var(--t4)">—</span>'}</td></tr>`).join('')}</tbody></table>
<td>${r.status==='completed'?`<button class="btn bs bsm" onclick="dlRpt('${r.id}','json')">JSON</button> <button class="btn bs bsm" onclick="dlRpt('${r.id}','html')">HTML</button>`:r.status==='running'?`<button class="btn bs bsm" style="color:var(--err);border-color:var(--err)" onclick="cancelRpt('${r.id}')">Cancelar</button>`:'<span style="color:var(--t4)">—</span>'}</td></tr>`).join('')}</tbody></table>
${!S.reports.length?'<div class="emp"><div class="eic">📂</div><p>Nenhum relatório.</p></div>':''}</div>`}
function dlRpt(id,f){window.open(API+'/reports/'+id+'/download?fmt='+f,'_blank')}
async function refreshDl(){S.reports=await $api('/reports');R()}
/* ── Edit helpers ── */
function editCfg(type,id){S.editing={type,id};R();
function editCfg(type,id){S.editing={type,id};if(type==='oci'){const c=S.ociCfg.find(x=>x.id===id);S.ociFormRegVal=c?c.region:'';S.ociFormRegFilter=''}R();
const form=document.querySelector('.cd:has(#obtn), .cd:has(#gbtn), .cd:has(#abtn), .cd:has(#mcbtn)');if(form)form.scrollIntoView({behavior:'smooth',block:'start'})}
function cancelEdit(){S.editing=null;R()}
function cancelEdit(){S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';R()}
/* ── OCI Config ── */
function rOciRegionDd(eo){
if(eo&&!S.ociFormRegVal)S.ociFormRegVal=eo.region||'';
const val=S.ociFormRegVal;const f=S.ociFormRegFilter.toLowerCase();
let ddItems='';
for(const[grp,regs] of Object.entries(S.ociRegions)){
const filtered=regs.filter(r=>!f||r.includes(f));
if(!filtered.length)continue;
ddItems+=`<div class="grp">${grp}</div>`;
filtered.forEach(r=>{ddItems+=`<div class="itm${val===r?' sel':''}" onclick="ociFormPickReg('${r}')">${r}</div>`})}
return`<div class="dd" style="position:relative"><div onclick="S.ociFormRegOpen=!S.ociFormRegOpen;R()" style="min-height:34px;display:flex;align-items:center;cursor:pointer;padding:.35rem .65rem;background:var(--bg2);border:1.5px solid var(--bd);border-radius:10px;font-size:.78rem;justify-content:space-between">
<span style="${val?'':'color:var(--t4)'}">${val||'Selecione a região...'}</span><span style="font-size:.6rem;color:var(--t4)">▼</span></div>
${S.ociFormRegOpen?`<div class="mdrop-list" style="position:absolute;top:100%;left:0;right:0;background:var(--bg);border:1px solid var(--b3);border-radius:6px;max-height:260px;overflow:auto;z-index:50;box-shadow:0 4px 12px rgba(0,0,0,.15)">
<div style="padding:.3rem;border-bottom:1px solid var(--b2)"><input type="text" placeholder="Buscar região..." value="${S.ociFormRegFilter}" oninput="S.ociFormRegFilter=this.value;R()" style="width:100%;border:none;outline:none;font-size:.75rem;padding:.25rem .4rem;background:transparent;color:var(--t1)" onclick="event.stopPropagation()"></div>
${ddItems||'<div style="padding:.5rem;color:var(--t4);font-size:.72rem">Nenhuma região encontrada</div>'}
</div>`:''}</div>`}
function ociFormPickReg(r){S.ociFormRegVal=r;S.ociFormRegOpen=false;S.ociFormRegFilter='';R()}
function rOci(){const eo=S.editing?.type==='oci'?S.ociCfg.find(c=>c.id===S.editing.id):null;
return`<div class="cd"><div class="ct">☁️ Credenciais Registradas</div><div id="ocm"></div>
<table><thead><tr><th>Tenancy</th><th>Region</th><th>Compartment</th><th>Criado</th><th>Ações</th></tr></thead><tbody>
${S.ociCfg.map(c=>`<tr${eo&&eo.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.tenancy_name}</strong></td><td><span class="tag">${c.region}</span></td>
<td style="font-family:var(--fm);font-size:.66rem;color:var(--t4)">${c.compartment_id?(c.compartment_id.slice(0,22)+'…'):'—'}</td><td style="font-size:.72rem">${c.created_at}</td>
<table><thead><tr><th>Tenancy</th><th>Region</th><th>Detalhes</th><th>Ações</th></tr></thead><tbody>
${S.ociCfg.map(c=>`<tr${eo&&eo.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.tenancy_name}</strong><br><span style="font-size:.66rem;color:var(--t4)">${c.created_at}</span></td><td><span class="tag">${c.region}</span></td>
<td style="font-family:var(--fm);font-size:.66rem;color:var(--t4);line-height:1.6"><span title="Fingerprint">🔑 ••••••••••••••••••••</span><br><span title="Tenancy OCID">🏢 ${c.tenancy_ocid}</span><br><span title="User OCID">👤 ${c.user_ocid}</span><br><span title="Compartment">📦 ${c.compartment_id||'—'}</span></td>
<td><button class="btn bs bsm" onclick="editCfg('oci','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tOci('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dOci('${c.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
${!S.ociCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma credencial registrada.</p></div>':''}</div>
<div class="cd"><div class="ct">${eo?'✏️ Editar Credencial':' Nova Credencial'}</div>
<div class="cdesc">${eo?'Editando: <strong>'+eo.tenancy_name+'</strong>':'Adicione as credenciais da sua conta OCI para executar reports e acessar serviços.'}</div><div id="om"></div>
<div class="g2">
<div class="ig"><label>Tenancy Name</label><input type="text" id="otn" placeholder="minha-empresa" value="${eo?eo.tenancy_name:''}"></div>
<div class="ig"><label>OCID User</label><input type="text" id="ouo" placeholder="ocid1.user.oc1.." value="${eo?eo.user_ocid||'':''}"></div>
<div class="ig"><label>Fingerprint</label><input type="text" id="ofp" placeholder="aa:bb:cc:dd:..." value="${eo?eo.fingerprint||'':''}"></div>
<div class="ig"><label>OCID Tenancy</label><input type="text" id="oto" placeholder="ocid1.tenancy.oc1.." value="${eo?eo.tenancy_ocid:''}"></div>
<div class="ig"><label>Region</label><input type="text" id="org" placeholder="sa-saopaulo-1" value="${eo?eo.region:''}"></div>
<div class="ig"><label>Compartment OCID</label><input type="text" id="ocp" placeholder="ocid1.compartment.oc1.." value="${eo?eo.compartment_id||'':''}"></div></div>
<div class="ig"><label>OCID User</label><input type="password" id="ouo" placeholder="ocid1.user.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.user_ocid+'</code></div>':''}</div>
<div class="ig"><label>Fingerprint</label><input type="password" id="ofp" placeholder="aa:bb:cc:dd:..." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.fingerprint+'</code></div>':''}</div>
<div class="ig"><label>OCID Tenancy</label><input type="password" id="oto" placeholder="ocid1.tenancy.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+eo.tenancy_ocid+'</code></div>':''}</div>
<div class="ig"><label>Region</label>${rOciRegionDd(eo)}</div>
<div class="ig"><label>Compartment OCID</label><input type="password" id="ocp" placeholder="ocid1.compartment.oc1.." value="${eo?'':''}" autocomplete="off">${eo?'<div class="ht">Preencha para alterar. Atual: <code>'+(eo.compartment_id||'—')+'</code></div>':''}</div></div>
<div class="g2"><div class="ig"><label>Private Key (.pem)</label>${eo?'<div class="ht">Deixe vazio para manter a chave atual</div>':''}<input type="file" id="osk" accept=".pem"></div>
<div class="ig"><label>Public Key (.pem)</label><input type="file" id="opk" accept=".pem"></div></div>
<div class="g2"><div class="ig"><label>Key Passphrase</label><div class="ht">Apenas se a chave privada for protegida por senha</div><input type="password" id="okp" placeholder="(opcional)"></div>
<div class="ig"></div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="obtn" onclick="sOci()">${eo?'Salvar Alterações':'Salvar Credencial'}</button>${eo?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
`+rConfigLogs('oci')}
async function sOci(){const fd=new FormData();fd.append('tenancy_name',document.getElementById('otn').value);fd.append('tenancy_ocid',document.getElementById('oto').value);
async function sOci(){const tn=document.getElementById('otn').value;
if(!tn)return sm('om','Preencha o Tenancy Name','e');
const fd=new FormData();fd.append('tenancy_name',tn);fd.append('tenancy_ocid',document.getElementById('oto').value);
fd.append('user_ocid',document.getElementById('ouo').value);fd.append('fingerprint',document.getElementById('ofp').value);
fd.append('region',document.getElementById('org').value);fd.append('compartment_id',document.getElementById('ocp').value);
fd.append('region',S.ociFormRegVal||'');fd.append('compartment_id',document.getElementById('ocp').value);
fd.append('key_passphrase',document.getElementById('okp').value||'');
const sk=document.getElementById('osk').files[0];
const isEdit=S.editing?.type==='oci';
if(!isEdit&&!sk)return sm('om','Selecione a chave privada','e');
if(!isEdit){
if(!document.getElementById('oto').value||!document.getElementById('ouo').value||!document.getElementById('ofp').value)return sm('om','Preencha OCID Tenancy, OCID User e Fingerprint','e');
if(!sk)return sm('om','Selecione a chave privada','e');
}
if(sk)fd.append('private_key',sk);
const pk=document.getElementById('opk').files[0];if(pk)fd.append('public_key',pk);
const btn=document.getElementById('obtn');btn.disabled=true;btn.textContent='Salvando...';sm('om','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Salvando credencial...','i');
try{const url=isEdit?'/oci/configs/'+S.editing.id:'/oci/config';const method=isEdit?'PUT':'POST';
await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}}
await $api(url,{method,body:fd,headers:{}});S.editing=null;S.ociFormRegVal='';S.ociFormRegFilter='';S.ociCfg=await $api('/oci/configs');sm('om','✅ Credencial '+(isEdit?'atualizada':'salva')+' com sucesso!','s');R()}catch(e){sm('om',e.message,'e');btn.disabled=false;btn.textContent=isEdit?'Salvar Alterações':'Salvar Credencial'}}
async function tOci(id){try{const d=await $api('/oci/test/'+id,{method:'POST'});sm('ocm',d.status==='success'?'✅ Conexão OK!':'❌ '+d.message,d.status==='success'?'s':'e');refreshCLogs('oci')}catch(e){sm('ocm',e.message,'e')}}
async function dOci(id){if(!confirm('Excluir credencial?'))return;await $api('/oci/configs/'+id,{method:'DELETE'});S.ociCfg=await $api('/oci/configs');R()}
@@ -454,6 +570,16 @@ ${S.genaiCfg.map(g=>{const mi=S.models[g.model_id]||{};return`<tr${eg&&eg.id===g
<td style="font-family:var(--fm);font-size:.7rem">${g.genai_region}</td>
<td><button class="btn bs bsm" onclick="editCfg('genai','${g.id}')">Editar</button> <button class="btn bs bsm" onclick="tGenai('${g.id}')">Testar</button> <button class="btn bd bsm" onclick="dGenai('${g.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
${!S.genaiCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhum modelo configurado.</p></div>':''}</div>
<div class="cd"><div class="ct">💬 System Prompts Chat Agent</div>
<div class="cdesc">Prompts de sistema para o <strong>Chat Agent</strong>. O prompt <strong>ativo</strong> é enviado como instrução do sistema em todas as mensagens. Máximo 10 prompts. Outros agents (ex.: provisionamento) terão seus próprios prompts.</div><div id="spm"></div>
<table><thead><tr><th>Nome</th><th>Status</th><th>Ações</th></tr></thead><tbody>
${(S.chatPrompts||[]).map(p=>`<tr${S.editingPrompt===p.id?' style="background:var(--bg3)"':''}><td><strong>${p.name}</strong></td>
<td>${p.is_active?'<span class="tag" style="background:var(--gnl);color:var(--gn)">Ativo</span>':'<span class="tag">Inativo</span>'}</td>
<td><button class="btn bs bsm" onclick="editPrompt('${p.id}')">Editar</button> ${!p.is_active?`<button class="btn bs bsm" onclick="activatePrompt('${p.id}')">Ativar</button>`:''} <button class="btn bd bsm" onclick="delPrompt('${p.id}')">Excluir</button></td></tr>`).join('')}</tbody></table>
${!(S.chatPrompts||[]).length?'<div class="emp" style="padding:.6rem"><p>Nenhum prompt configurado.</p></div>':''}
<div style="margin-top:10px"><div class="g3"><div class="ig" style="grid-column:1/-1"><label>${S.editingPrompt?'Editar Prompt':'Novo Prompt'}</label><input type="text" id="spname" placeholder="Nome do prompt" value="${S.editingPrompt?(S.chatPrompts||[]).find(p=>p.id===S.editingPrompt)?.name||'':''}"></div></div>
<textarea id="gsysp" rows="10" style="width:100%;font-family:var(--fm);font-size:.78rem;resize:vertical;border:1px solid var(--bd);border-radius:6px;padding:10px;background:var(--bg2);color:var(--t1);line-height:1.5" placeholder="Você é um assistente especializado em...">${S.editingPrompt?((S.chatPrompts||[]).find(x=>x.id===S.editingPrompt)||{}).content||'':''}</textarea>
<div style="display:flex;gap:8px;margin-top:8px"><button class="btn bp" onclick="savePrompt()">${S.editingPrompt?'Salvar Alterações':'Criar Prompt'}</button>${S.editingPrompt?'<button class="btn bs" onclick="S.editingPrompt=null;R()">Cancelar</button>':''}</div></div></div>
<div class="cd"><div class="ct">${eg?'✏️ Editar Modelo GenAI':' Novo Modelo GenAI'}</div>
<div class="cdesc">${eg?'Editando: <strong>'+eg.name+'</strong>':'Adicione modelos personalizados que não estão no catálogo do Chat Agent, ou crie presets com credenciais específicas.'}</div><div id="gm"></div>
<div class="g3"><div class="ig"><label>Nome da Config</label><input type="text" id="gname" placeholder="Meu Modelo Custom" value="${eg?eg.name:''}"></div>
@@ -508,13 +634,29 @@ ${m.command?`<div><span style="color:var(--t3);font-weight:600">Comando:</span>
${m.url?`<div><span style="color:var(--t3);font-weight:600">URL:</span> <code style="font-family:var(--fm);font-size:.66rem">${m.url}</code></div>`:''}
${m.module_path?`<div><span style="color:var(--t3);font-weight:600">Módulo:</span> <code style="font-family:var(--fm);font-size:.66rem">${m.module_path}</code></div>`:''}
<div><span style="color:var(--t3);font-weight:600">ADB:</span> ${adb?`<span class="badge b-pass">${adb.config_name}</span>`:'<span>Nenhum</span>'}</div></div>
${(()=>{const tools=Array.isArray(m.tools)?m.tools:[];return tools.length?`
<div style="margin-top:.65rem;border-top:1px solid var(--b2);padding-top:.5rem">
<div style="font-size:.74rem;font-weight:600;color:var(--t2);margin-bottom:.35rem">🔧 Tools (${tools.length})</div>
${tools.map((t,i)=>`<div style="display:flex;align-items:flex-start;gap:.5rem;padding:.3rem .5rem;background:var(--bg);border-radius:6px;margin-bottom:.25rem;font-size:.72rem">
<div style="flex:1"><strong>${typeof t==='string'?t:t.name}</strong>${typeof t==='object'&&t.description?` <span style="color:var(--t4)">— ${t.description}</span>`:''}
${typeof t==='object'&&t.input_schema&&t.input_schema.properties?`<div style="margin-top:.2rem;font-size:.64rem;color:var(--t4)">Params: ${Object.keys(t.input_schema.properties).join(', ')}</div>`:''}</div>
<button class="btn bd bsm" style="font-size:.6rem;padding:.15rem .4rem" onclick="removeMcpTool('${m.id}',${i})">✕</button></div>`).join('')}
</div>`:`<div style="margin-top:.5rem;font-size:.72rem;color:var(--t4)">Nenhuma tool registrada</div>`})()}
<div id="mcpm_${m.id}"></div>
<div style="display:flex;gap:.5rem;margin-top:.65rem;align-items:center;flex-wrap:wrap">
<button class="btn bp bsm" id="mdb_${m.id}" onclick="discoverMcpTools('${m.id}')">🔍 Descobrir Tools</button>
<button class="btn bs bsm" onclick="toggleAddTool('${m.id}')"> Adicionar Tool</button>
<input type="file" id="muf_${m.id}" accept=".py" style="font-size:.68rem;max-width:200px">
<button class="btn bs bsm" id="mub_${m.id}" onclick="uMcp('${m.id}')">Upload .py</button>
<select id="mlnk_${m.id}" style="font-size:.72rem;padding:.28rem .5rem;border-radius:6px;border:1px solid var(--bd)">
<option value="">Sem ADB</option>${adbOpts}</select>
<button class="btn bs bsm" id="mlb_${m.id}" onclick="lnkMcp('${m.id}')">Vincular ADB</button></div></div>`}).join('')
<button class="btn bs bsm" id="mlb_${m.id}" onclick="lnkMcp('${m.id}')">Vincular ADB</button></div>
<div id="mctf_${m.id}" style="display:none;margin-top:.5rem;padding:.6rem;border:1px solid var(--bd);border-radius:8px;background:var(--bg)">
<div class="g2" style="gap:.5rem"><div class="ig"><label style="font-size:.7rem">Nome da Tool</label><input type="text" id="mctn_${m.id}" placeholder="list_resources" style="font-size:.74rem"></div>
<div class="ig"><label style="font-size:.7rem">Descrição</label><input type="text" id="mctd_${m.id}" placeholder="Lista recursos OCI" style="font-size:.74rem"></div></div>
<div class="ig" style="margin-top:.35rem"><label style="font-size:.7rem">Input Schema (JSON)</label><input type="text" id="mcts_${m.id}" placeholder='{"type":"object","properties":{"region":{"type":"string"}}}' style="font-size:.7rem;font-family:var(--fm)"></div>
<div style="display:flex;gap:.4rem;margin-top:.4rem"><button class="btn bp bsm" onclick="addMcpTool('${m.id}')">Salvar Tool</button><button class="btn bs bsm" onclick="toggleAddTool('${m.id}')">Cancelar</button></div>
</div></div>`}).join('')
:'<div class="emp" style="padding:.85rem"><p>Nenhum MCP server registrado.</p></div>'}</div>
<div class="cd"><div class="ct">${em?'✏️ Editar Server':' Registrar Novo Server'}</div>
<div class="cdesc">${em?'Editando: <strong>'+em.name+'</strong>':'Configure um novo servidor MCP. Os campos se ajustam ao tipo selecionado.'}</div><div id="mcm"></div>
@@ -556,16 +698,33 @@ async function uMcp(id){const fd=new FormData();const f=document.getElementById(
async function lnkMcp(id){const aid=document.getElementById('mlnk_'+id)?.value||'';
const btn=document.getElementById('mlb_'+id);btn.disabled=true;btn.textContent='Vinculando...';
try{await $api('/mcp/servers/'+id+'/link-adb?adb_id='+aid,{method:'PUT'});S.mcpSvr=await $api('/mcp/servers');sm('mcpm_'+id,'✅ '+(aid?'Vinculado ao ADB!':'ADB desvinculado!'),'s');R()}catch(e){sm('mcpm_'+id,e.message,'e');btn.disabled=false;btn.textContent='Vincular ADB'}}
async function discoverMcpTools(mid){const btn=document.getElementById('mdb_'+mid);btn.disabled=true;btn.textContent='Descobrindo...';
sm('mcpm_'+mid,'<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Conectando ao MCP server para descobrir tools...','i');
try{const d=await $api('/mcp/servers/'+mid+'/discover-tools',{method:'POST'});S.mcpSvr=await $api('/mcp/servers');
sm('mcpm_'+mid,`${d.discovered} tool(s) descoberta(s), ${d.total} total`,'s');R()}catch(e){sm('mcpm_'+mid,'❌ '+e.message,'e');btn.disabled=false;btn.textContent='🔍 Descobrir Tools'}}
function toggleAddTool(mid){const el=document.getElementById('mctf_'+mid);el.style.display=el.style.display==='none'?'':'none'}
async function addMcpTool(mid){const name=document.getElementById('mctn_'+mid)?.value?.trim();const desc=document.getElementById('mctd_'+mid)?.value?.trim()||'';
const schemaStr=document.getElementById('mcts_'+mid)?.value?.trim();
if(!name)return sm('mcpm_'+mid,'Nome da tool é obrigatório','e');
let schema={};if(schemaStr)try{schema=JSON.parse(schemaStr)}catch(e){return sm('mcpm_'+mid,'Input Schema JSON inválido','e')}
const srv=S.mcpSvr.find(m=>m.id===mid);const existing=Array.isArray(srv?.tools)?srv.tools:[];
existing.push({name,description:desc,input_schema:schema});
try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools:existing}});S.mcpSvr=await $api('/mcp/servers');
sm('mcpm_'+mid,'✅ Tool adicionada!','s');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}}
async function removeMcpTool(mid,idx){const srv=S.mcpSvr.find(m=>m.id===mid);const tools=Array.isArray(srv?.tools)?[...srv.tools]:[];
if(idx<0||idx>=tools.length)return;const name=typeof tools[idx]==='string'?tools[idx]:tools[idx].name;
if(!confirm(`Remover tool "${name}"?`))return;tools.splice(idx,1);
try{await $api('/mcp/servers/'+mid+'/tools',{method:'PUT',body:{tools}});S.mcpSvr=await $api('/mcp/servers');R()}catch(e){sm('mcpm_'+mid,e.message,'e')}}
/* ── ADB Vector ── */
function rADB(){const ea=S.editing?.type==='adb'?S.adbCfg.find(c=>c.id===S.editing.id):null;
return`<div class="cd"><div class="ct">🗄️ Conexões Registradas</div>
<table><thead><tr><th>Nome</th><th>DSN</th><th>User</th><th>Tabela</th><th>mTLS</th><th>Wallet</th><th>Embed</th><th>Ações</th></tr></thead><tbody>
${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];return`<tr${ea&&ea.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.config_name}</strong></td><td style="font-family:var(--fm);font-size:.68rem;color:var(--t4)">${c.dsn}</td>
<td>${c.username}</td><td><span class="tag">${c.table_name}</span></td>
<table><thead><tr><th>Nome</th><th>DSN</th><th>User</th><th>Tabelas</th><th>mTLS</th><th>Wallet</th><th>Embed</th><th>Ações</th></tr></thead><tbody>
${S.adbCfg.map(c=>{const em=S.embModels[c.embedding_model_id];const tbls=c.tables||[];return`<tr${ea&&ea.id===c.id?' style="background:var(--bg3)"':''}><td><strong>${c.config_name}</strong></td><td style="font-family:var(--fm);font-size:.68rem;color:var(--t4)">${c.dsn}</td>
<td>${c.username}</td><td>${tbls.length?tbls.map(t=>'<span class="tag" style="font-size:.62rem">'+t.table_name+'</span>').join(' '):'<span style="color:var(--t4);font-size:.72rem">—</span>'}</td>
<td>${c.use_mtls?'✅':'—'}</td><td>${c.wallet_dir?'✅':'❌'}</td>
<td>${c.genai_config_id?'<span class="tag" style="background:var(--gnl);color:var(--gn)">'+(em?em.name:c.embedding_model_id)+'</span>':'<span style="color:var(--t4)">—</span>'}</td>
<td><button class="btn bs bsm" onclick="editCfg('adb','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bs bsm" onclick="ensureTable('${c.id}')">Criar Tabela</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
<td><button class="btn bs bsm" onclick="editCfg('adb','${c.id}')">Editar</button> <button class="btn bs bsm" onclick="tAdb('${c.id}')">Testar</button> <button class="btn bd bsm" onclick="dAdb('${c.id}')">Excluir</button></td></tr>`}).join('')}</tbody></table>
${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão ADB registrada.</p></div>':''}</div>
<div class="cd"><div class="ct">${ea?'✏️ Editar Conexão':' Nova Conexão'}</div>
<div class="cdesc">${ea?'Editando: <strong>'+ea.config_name+'</strong>':'Conexão via <code style="font-size:.72rem">python-oracledb</code> Thin mode com Wallet (mTLS) para Oracle Autonomous Database. Envie o Wallet ZIP para preencher o DSN automaticamente.'}</div><div id="am"></div>
@@ -577,12 +736,25 @@ ${!S.adbCfg.length?'<div class="emp" style="padding:.85rem"><p>Nenhuma conexão
<div class="ig"><label>Username</label><input type="text" id="auser" placeholder="ADMIN" value="${ea?ea.username:''}"></div>
<div class="ig"><label>Password</label><input type="password" id="apw" placeholder="${ea?'(manter atual)':''}"></div>
<div class="ig"><label>Wallet Password</label><input type="password" id="awpw" placeholder="(opcional)"></div>
<div class="ig"><label>Tabela de Embeddings</label><input type="text" id="atbl" value="${ea?ea.table_name:'CIS_EMBEDDINGS'}"></div>
<div class="ig"><label>Config GenAI (Embeddings)</label><div class="ht">Credenciais OCI usadas para gerar embeddings via GenAI.</div>
<select id="agcfg"><option value="">Nenhum (sem RAG)</option>${S.genaiCfg.map(g=>'<option value="'+g.id+'"'+(ea&&ea.genai_config_id===g.id?' selected':'')+'>'+(g.name||g.model_id)+' · '+g.genai_region+'</option>').join('')}</select></div>
<div class="ig"><label>Modelo de Embedding</label><div class="ht">Modelo OCI GenAI para gerar vetores.</div>
<select id="aemb">${Object.entries(S.embModels).map(([k,v])=>'<option value="'+k+'"'+(ea&&ea.embedding_model_id===k?' selected':'')+'>'+v.name+' ('+v.dims+'d)</option>').join('')}</select></div></div>
<div style="display:flex;gap:8px"><button class="btn bp" id="abtn" onclick="sAdb()">${ea?'Salvar Alterações':'Salvar Conexão'}</button>${ea?'<button class="btn bs" onclick="cancelEdit()">Cancelar</button>':''}</div></div>
${ea?`<div class="cd"><div class="ct">📋 Tabelas de Vetores — ${ea.config_name}</div>
<div class="cdesc">Tabelas no ADB com dados vetorizados. A busca RAG consultará todas as tabelas ativas.</div><div id="tbm"></div>
${(ea.tables||[]).length?`<table><thead><tr><th>Tabela</th><th>Descrição</th><th>Ativa</th><th>Ações</th></tr></thead><tbody>
${(ea.tables||[]).map(t=>`<tr${!t.is_active?' style="opacity:.55"':''}>
<td><input type="text" value="${t.table_name}" id="tn_${t.id}" style="font-family:var(--fm);font-size:.72rem;text-transform:uppercase;width:100%;max-width:180px"></td>
<td><input type="text" value="${t.description||''}" id="td_${t.id}" placeholder="Descrição..." style="font-size:.72rem;width:100%"></td>
<td><button class="btn bs bsm" style="font-size:.62rem" onclick="toggleTable('${ea.id}','${t.id}',${t.is_active?0:1})">${t.is_active?'✅ Sim':'❌ Não'}</button></td>
<td style="white-space:nowrap"><button class="btn bs bsm" onclick="saveTable('${ea.id}','${t.id}')">Salvar</button> <button class="btn bd bsm" onclick="removeTable('${ea.id}','${t.id}')">Excluir</button></td>
</tr>`).join('')}</tbody></table>`:'<div class="emp" style="padding:.5rem"><p style="font-size:.74rem">Nenhuma tabela registrada.</p></div>'}
<div style="display:flex;gap:6px;margin-top:.65rem;align-items:end">
<div class="ig" style="flex:1;margin:0"><label>Nova Tabela</label><input type="text" id="newTblName" placeholder="NOME_DA_TABELA" style="text-transform:uppercase"></div>
<div class="ig" style="flex:1;margin:0"><label>Descrição</label><input type="text" id="newTblDesc" placeholder="Ex: Dados de compliance CIS"></div>
<button class="btn bs" onclick="addTable('${ea.id}')">+ Adicionar</button>
</div></div>`:''}
${S.adbCfg.length?`<div class="cd"><div class="ct">📤 Atualizar Wallet</div><div id="wm"></div>
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="awsel">${S.adbCfg.map(c=>`<option value="${c.id}">${c.config_name}</option>`).join('')}</select></div>
<div class="ig"><label>Wallet ZIP</label><input type="file" id="awf2" accept=".zip"></div></div>
@@ -600,7 +772,7 @@ async function sAdb(){const btn=document.getElementById('abtn');btn.disabled=tru
try{const fd=new FormData();fd.append('config_name',document.getElementById('an').value);
fd.append('dsn',document.getElementById('adsn').value);fd.append('username',document.getElementById('auser').value);
fd.append('password',document.getElementById('apw').value);fd.append('wallet_password',document.getElementById('awpw').value||'');
fd.append('table_name',document.getElementById('atbl').value);fd.append('use_mtls','true');
fd.append('use_mtls','true');
fd.append('genai_config_id',document.getElementById('agcfg').value||'');
fd.append('embedding_model_id',document.getElementById('aemb').value||'cohere.embed-v4.0');
const wf=document.getElementById('awf').files[0];if(wf)fd.append('wallet',wf);
@@ -613,31 +785,48 @@ async function uWallet(){const fd=new FormData();const f=document.getElementById
fd.append('wallet',f);const id=document.getElementById('awsel').value;if(!id)return sm('wm','Selecione uma conexão ADB.','e');
const btn=document.getElementById('wbtn');btn.disabled=true;btn.textContent='Enviando...';sm('wm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando wallet...','i');
try{const d=await $api('/adb/'+id+'/upload-wallet',{method:'POST',body:fd,headers:{}});S.adbCfg=await $api('/adb/configs');sm('wm','✅ Wallet enviada! Arquivos: '+d.files.join(', '),'s');R()}catch(e){sm('wm',e.message,'e');btn.disabled=false;btn.textContent='Upload Wallet'}}
async function ensureTable(id){try{const d=await $api('/adb/'+id+'/ensure-table',{method:'POST'});sm('am','✅ Tabela criada/verificada: '+d.table,'s')}catch(e){sm('am','Erro: '+e.message,'e')}}
async function addTable(vid){const name=document.getElementById('newTblName').value.trim();if(!name)return sm('tbm','Digite o nome da tabela.','e');
const desc=document.getElementById('newTblDesc').value.trim();
try{await $api('/adb/'+vid+'/tables',{method:'POST',body:{table_name:name,description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela '+name+' registrada!','s');R()}catch(e){sm('tbm',e.message,'e')}}
async function saveTable(vid,tid){const name=document.getElementById('tn_'+tid)?.value.trim();const desc=document.getElementById('td_'+tid)?.value.trim();
if(!name)return sm('tbm','Nome da tabela é obrigatório.','e');
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{table_name:name.toUpperCase(),description:desc}});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela atualizada.','s');R()}catch(e){sm('tbm',e.message,'e')}}
async function toggleTable(vid,tid,val){try{await $api('/adb/'+vid+'/tables/'+tid,{method:'PUT',body:{is_active:val}});S.adbCfg=await $api('/adb/configs');R()}catch(e){sm('tbm',e.message,'e')}}
async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro?'))return;
try{await $api('/adb/'+vid+'/tables/'+tid,{method:'DELETE'});S.adbCfg=await $api('/adb/configs');sm('tbm','✅ Tabela removida.','s');R()}catch(e){sm('tbm',e.message,'e')}}
/* ── Embeddings ── */
function tblOpts(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Padrão</option>';
return cfg.tables.filter(t=>t.is_active).map(t=>'<option value="'+t.table_name+'">'+t.table_name+'</option>').join('')}
function rEmbeddings(){
const adbOpts=S.adbCfg.filter(c=>c.genai_config_id).map(c=>'<option value="'+c.id+'">'+c.config_name+'</option>').join('');
const rptOpts=S.reports.filter(r=>r.status==='completed').map(r=>'<option value="'+r.id+'">'+r.tenancy_name+' ('+r.created_at?.substring(0,10)+')</option>').join('');
if(!S.adbCfg.filter(c=>c.genai_config_id).length)return`<div class="cd"><div class="emp"><div class="eic">🧬</div><p>Nenhuma conexão ADB com GenAI configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong> vinculando uma Config GenAI.</p></div></div>`;
const firstVid=S.adbCfg.filter(c=>c.genai_config_id)[0]?.id||'';
return`<div class="cd"><div class="ct">📋 Embeddings Existentes</div>
<div class="cdesc">Consulte os documentos armazenados na tabela de embeddings do ADB.</div>
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="elsel">${adbOpts}</select></div>
<div class="cdesc">Consulte os documentos armazenados nas tabelas de embeddings do ADB.</div>
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="elsel" onchange="document.getElementById('eltbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
<div class="ig"><label>Tabela</label><select id="eltbl">${tblOpts(firstVid)}</select></div>
<div class="ig" style="align-self:end"><button class="btn bs" onclick="loadEmbs()">Carregar</button></div></div>
<div id="emb-list"></div></div>
<div class="cd"><div class="ct">📊 Embed Relatório CIS</div>
<div class="cdesc">Gere embeddings a partir de relatórios CIS. Cada seção (IAM, Networking, etc.) gera um embedding separado.</div><div id="erm"></div>
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="ersel">${adbOpts}</select></div>
<div class="cdesc">Visualize os chunks gerados antes de criar os embeddings. Cada seção do relatório gera um chunk com tenancy, regiões e compartments.</div><div id="erm"></div>
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="ersel" onchange="document.getElementById('ertbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
<div class="ig"><label>Tabela</label><select id="ertbl">${tblOpts(firstVid)}</select></div>
<div class="ig"><label>Relatório</label><select id="errpt">${rptOpts||'<option value="">Nenhum relatório disponível</option>'}</select></div></div>
<button class="btn bp" id="erbtn" onclick="embedReport()">Gerar Embeddings do Relatório</button></div>
<div style="display:flex;gap:8px;margin-top:.35rem"><button class="btn bs" id="epbtn" onclick="previewChunks()">👁 Visualizar Chunks</button>
<button class="btn bp" id="erbtn" onclick="embedReport()" style="display:none">Gerar Embeddings</button></div>
<div id="chunk-preview"></div></div>
<div class="cd"><div class="ct">📄 Upload de Arquivo</div>
<div class="cdesc">Faça upload de um arquivo .txt para gerar embeddings automaticamente (chunking por parágrafos).</div><div id="efm"></div>
<div class="g2"><div class="ig"><label>Conexão ADB</label><select id="efsel">${adbOpts}</select></div>
<div class="g3"><div class="ig"><label>Conexão ADB</label><select id="efsel" onchange="document.getElementById('eftbl').innerHTML=tblOpts(this.value)">${adbOpts}</select></div>
<div class="ig"><label>Tabela</label><select id="eftbl">${tblOpts(firstVid)}</select></div>
<div class="ig"><label>Arquivo (.txt)</label><input type="file" id="eff" accept=".txt"></div></div>
<button class="btn bp" id="efbtn" onclick="embedFile()">Upload e Gerar Embeddings</button></div>`}
async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!vid)return;
try{const d=await $api('/embeddings/'+vid+'/list');
const tbl=document.getElementById('eltbl')?.value||'';
try{const d=await $api('/embeddings/'+vid+'/list'+(tbl?'?table_name='+encodeURIComponent(tbl):''));
if(!d.documents.length){document.getElementById('emb-list').innerHTML='<div class="emp" style="padding:.65rem"><p>Nenhum embedding encontrado.</p></div>';return}
document.getElementById('emb-list').innerHTML='<table style="margin-top:.65rem"><thead><tr><th>ID</th><th>Source</th><th>Metadata</th><th>Data</th><th>Ações</th></tr></thead><tbody>'+
d.documents.map(e=>'<tr><td style="font-family:var(--fm);font-size:.62rem">'+e.id.substring(0,8)+'...</td><td>'+
@@ -646,19 +835,45 @@ async function loadEmbs(){const vid=document.getElementById('elsel').value;if(!v
'</tbody></table><div style="padding:.5rem;font-size:.72rem;color:var(--t4)">Total: '+d.total+' documentos</div>'}
catch(e){document.getElementById('emb-list').innerHTML='<div style="color:var(--rd);padding:.5rem">Erro: '+e.message+'</div>'}}
async function previewChunks(){const rid=document.getElementById('errpt').value;
if(!rid)return sm('erm','Selecione um relatório.','e');
const btn=document.getElementById('epbtn');btn.disabled=true;btn.textContent='Carregando...';
try{const d=await $api('/embeddings/preview/'+rid);
let html='<div style="margin-top:.75rem;border:1px solid var(--bd);border-radius:var(--r);overflow:hidden">';
html+='<div style="padding:.65rem .85rem;background:var(--bg2);border-bottom:1px solid var(--bd);display:flex;justify-content:space-between;align-items:center">';
html+='<div><strong style="font-size:.82rem">'+d.tenancy+'</strong>';
if(d.regions?.length)html+=' <span style="font-size:.68rem;color:var(--t4)">Regiões: '+d.regions.join(', ')+'</span>';
html+='</div><span class="tag">'+d.total_chunks+' chunks</span></div>';
if(d.compartments?.length)html+='<div style="padding:.4rem .85rem;background:var(--bg2);border-bottom:1px solid var(--bd);font-size:.68rem;color:var(--t3)">Compartments: '+d.compartments.join(', ')+'</div>';
d.chunks.forEach((c,i)=>{
const preview=c.content.length>300?c.content.substring(0,300)+'...':c.content;
html+='<details style="border-bottom:1px solid var(--bd)">';
html+='<summary style="padding:.5rem .85rem;cursor:pointer;font-size:.74rem;display:flex;justify-content:space-between;align-items:center">';
html+='<span><strong>#'+(i+1)+'</strong> '+c.source+'</span>';
html+='<span style="font-size:.64rem;color:var(--t4)">'+c.metadata+'</span></summary>';
html+='<pre style="padding:.5rem .85rem;font-size:.64rem;color:var(--t3);white-space:pre-wrap;max-height:200px;overflow:auto;background:var(--bg2);margin:0">'+preview+'</pre>';
html+='</details>'});
html+='</div>';
document.getElementById('chunk-preview').innerHTML=html;
document.getElementById('erbtn').style.display='';
sm('erm','✅ '+d.total_chunks+' chunks prontos para embedding.','s');
}catch(e){sm('erm',e.message,'e')}finally{btn.disabled=false;btn.textContent='👁 Visualizar Chunks'}}
async function embedReport(){const vid=document.getElementById('ersel').value;const rid=document.getElementById('errpt').value;
if(!vid||!rid)return sm('erm','Selecione conexão ADB e relatório.','e');
const tbl=document.getElementById('ertbl')?.value||'';
const btn=document.getElementById('erbtn');btn.disabled=true;btn.textContent='Gerando...';sm('erm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Gerando embeddings do relatório...','i');
try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid}});sm('erm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}}
try{const d=await $api('/embeddings/report/'+rid,{method:'POST',body:{adb_config_id:vid,table_name:tbl||undefined}});sm('erm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}catch(e){sm('erm',e.message,'e');btn.disabled=false;btn.textContent='Gerar Embeddings do Relatório'}}
async function embedFile(){const vid=document.getElementById('efsel').value;const f=document.getElementById('eff').files[0];
if(!vid||!f)return sm('efm','Selecione conexão ADB e arquivo.','e');
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);
const tbl=document.getElementById('eftbl')?.value||'';
const fd=new FormData();fd.append('file',f);fd.append('adb_config_id',vid);if(tbl)fd.append('table_name',tbl);
const btn=document.getElementById('efbtn');btn.disabled=true;btn.textContent='Enviando...';sm('efm','<span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Enviando e gerando embeddings...','i');
try{const d=await $api('/embeddings/upload',{method:'POST',body:fd,headers:{}});sm('efm','✅ '+d.message,'s');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}catch(e){sm('efm',e.message,'e');btn.disabled=false;btn.textContent='Upload e Gerar Embeddings'}}
async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return;
try{await $api('/embeddings/'+vid+'/'+docId,{method:'DELETE'});loadEmbs()}catch(e){alert('Erro: '+e.message)}}
const tbl=document.getElementById('eltbl')?.value||'';
try{await $api('/embeddings/'+vid+'/'+docId+(tbl?'?table_name='+encodeURIComponent(tbl):''),{method:'DELETE'});loadEmbs()}catch(e){alert('Erro: '+e.message)}}
/* ── Users ── */
function rUsers(){return`<div class="cd"><div class="ct">👥 Gerenciamento de Usuários</div><div id="um"></div>