feat: consult embeddings UI, vector search FLOAT32 fix, and base64 compartment decode

- Add Consult Embeddings sub-menu with chat-like Q&A interface
- Fix VECTOR_DISTANCE FLOAT32/FLOAT64 mismatch (array 'd' → 'f')
- Decode base64 compartment_id in _resolve_embed_config
- Sidebar sub-item navigation for Embeddings hierarchy
- Fallback to raw document display when no GenAI config available
This commit is contained in:
nogueiraguh
2026-03-11 03:06:53 -03:00
parent 2a56712665
commit 749f4e479b
3 changed files with 136 additions and 8 deletions

View File

@@ -159,6 +159,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **Purge & re-embed**: option to purge old embeddings by tenancy before re-embedding updated reports
- **Preview chunks before embedding**: review generated sections before creating embeddings
- **OCI GenAI Embeddings**: uses Cohere Embed models (v4.0, multilingual) via OCI GenAI `embed_text` API
- **Consult Embeddings**: dedicated sub-menu under Embeddings with chat-like interface — query the vector store in natural language, AI searches all active tables via cosine similarity and returns contextual answers with source citations
- Browse and inspect embeddings from the ADB vector store
- 3 embedding models supported: Cohere Embed v4.0, OpenAI Text Embedding 3 Large/Small
@@ -536,6 +537,7 @@ oci-cis-agent/
| POST | `/api/embeddings/report/{rid}` | Generate embeddings from CIS report (chunked by section, accepts `table_name`, `report_date`) |
| POST | `/api/embeddings/upload` | Upload file (.txt/.pdf/.csv/.json/.md) and generate embeddings |
| POST | `/api/embeddings/upload-url` | Import URL (web page or PDF), extract text, and generate embeddings |
| POST | `/api/embeddings/consult` | Query embeddings via vector search + GenAI (natural language Q&A) |
| POST | `/api/embeddings/{vid}/purge` | Purge old embeddings by table + tenancy before re-embedding |
| GET | `/api/embeddings/{vid}/list` | List embeddings in ADB (paginated, accepts `table_name` query param) |
| DELETE | `/api/embeddings/{vid}/{doc_id}` | Delete individual embedding (accepts `table_name` query param) |
@@ -667,7 +669,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
| **v2.3** | 2026-03 | **Knowledge Base & RAG Enhancements**: Knowledge Base (Base de Conhecimento) with file upload + URL import to `engineerknowledgebase` vector table, CIS Recommendations upload to `cisrecom` table, URL content extraction (HTML tag/script stripping, remote PDF parsing), auto-resolve embedding config from OCI credentials (no separate GenAI config required), `report_date` metadata tracking for embedding freshness, purge & re-embed flow for updated reports. **ADB Vector Improvements**: case-insensitive table matching with quoted identifiers for Oracle ADB, table validation endpoint against `user_tables`, tables section moved inside edit connection card, case-preserving table registration (removed forced uppercase). **Embeddings tab cleanup**: removed delete buttons, simplified to read-only view, updated descriptions. **Dependencies**: added PyPDF2 for PDF text extraction |
| **v2.3** | 2026-03 | **Consult Embeddings**: dedicated sub-menu with chat-like interface for natural language Q&A against vector store — queries all active tables via cosine similarity, returns contextual answers with source citations (works with or without GenAI config). **Knowledge Base & RAG Enhancements**: Knowledge Base (Base de Conhecimento) with file upload + URL import to `engineerknowledgebase` vector table, CIS Recommendations upload to `cisrecom` table, URL content extraction (HTML tag/script stripping, remote PDF parsing), auto-resolve embedding config from OCI credentials (no separate GenAI config required), `report_date` metadata tracking for embedding freshness, purge & re-embed flow for updated reports. **ADB Vector Improvements**: case-insensitive table matching with quoted identifiers for Oracle ADB, table validation endpoint against `user_tables`, tables section moved inside edit connection card, case-preserving table registration (removed forced uppercase). **Vector search fix**: FLOAT32 compatibility for VECTOR_DISTANCE queries, base64-decoded compartment_id for auto-resolved OCI configs. **Embeddings tab cleanup**: removed delete buttons, simplified to read-only view, updated descriptions. **Dependencies**: added PyPDF2 for PDF text extraction |
| **v2.2** | 2026-03 | **UI Modernization (Phases 1B-5)**: Oracle Dark Premium theme, 45+ Lucide SVG icons replacing all emojis, KPI dashboard with compliance gauge + Chart.js donut/bar charts, report summary API endpoint, Phase 5 animations (staggered fade-in, smooth theme transitions, hover effects) — animations scoped to tab switch only (no flickering on re-render). **Terraform Intelligence**: official resource docs injection from registry.terraform.io (Example Usage + Argument Reference, on-demand fetch + SQLite cache), smart tfvars auto-generation from declared variables, multi-region provider management with `var.region` support, RPC anti-cycle rule, system prompt auto-sync to DB, 60-min polling timeout for long-running applies. **ADB fix**: wallet_password always passed for thin mode mTLS (fixes DPY-6005). CIS_EMBEDDINGS default removed from ADB config |
| **v2.1** | 2026-03 | Terraform Agent (AI-powered IaC generation with plan/apply/destroy lifecycle, workspace management, Terraform CLI in container, inline file editor, split-panel terminal, multi-file generation via `// filename:` markers, **smart file correction** with merge-based updates, **14-point validation checklist**, 100K max_tokens), **3-layer auto-split pipeline** (frontend + backend monolithic HCL splitting into categorized files + backend deduplication), **HCL syntax auto-fix** (single-line blocks expanded to multi-line), **SQLite-based resource type validation** (~937 types with `difflib` suggestions for invalid types), **Terraform Resource Reference** (auto-generated OCI provider catalog from `terraform providers schema -json`, generated at startup to survive volume mounts, stored in SQLite, refreshable via menu button), compact system prompt for Terraform agent, ChatGPT-style chat history for both Chat Agent and Terraform Agent (rename/delete sessions), OCI Resource Actions (start/stop instances and Autonomous DBs from **OCI Account Explorer**), OCI Explorer expanded to 40+ resource types across 8 categories with tree-view navigation and resizable panels, compartment filtering for MCP CIS scans, chat sidebar with model parameters, chat audit logs, collection error tracking in MCP server, tool timeout increased to 30min with auto-retry, memory compaction tuned (6K threshold, 20 recent messages), model catalog trimmed from 69 to 15 curated models, improved GenAI response extraction with fallback for empty responses, fixed layout overflow (no page scroll), provider alias detection with brace-matching parser, multi-region Terraform support |
| **v2.0** | 2026-03 | Async background chat processing (no more 504 timeouts), frontend polling with timestamps, 8 uvicorn workers + 16-thread chat executor for ~12 simultaneous chats, parallelized MCP data collection (5-thread base + 8-thread regional), 2-hour MCP session cache, 5-min tool timeout, full dead code cleanup across backend/frontend/MCP |

View File

@@ -2419,7 +2419,7 @@ def _resolve_embed_config(oci_config_id: str = None, genai_cfg: dict = None) ->
"oci_config_id": oc["id"],
"genai_region": oc["region"],
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
"compartment_id": (dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
"compartment_id": _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
}
# Last resort: any genai config
with db() as c:
@@ -2432,7 +2432,7 @@ def _resolve_embed_config(oci_config_id: str = None, genai_cfg: dict = None) ->
"oci_config_id": oc["id"],
"genai_region": oc["region"],
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
"compartment_id": (dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
"compartment_id": _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
}
raise HTTPException(400, "Nenhuma credencial OCI configurada para gerar embeddings.")
@@ -2466,7 +2466,7 @@ def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name:
conn = _get_adb_connection(cfg)
try:
cur = conn.cursor()
vec = array.array('d', query_embedding)
vec = array.array('f', query_embedding)
if tenancy:
# Filter by tenancy in METADATA JSON field using LIKE for broad compatibility
# Matches both structured JSON {"tenancy":"X",...} and legacy "tenancy: X, ..."
@@ -3319,6 +3319,83 @@ async def embed_upload_url(
_audit(u["id"], u["username"], "embed_url", url, f"{len(documents)} chunks")
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "url": url}
class ConsultQuery(BaseModel):
query: str
table_name: str = ""
top_k: int = 10
@app.post("/api/embeddings/consult")
async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
"""Query embeddings via vector search + GenAI to get a formatted answer."""
if not req.query.strip():
raise HTTPException(400, "Query não pode ser vazia")
adb_configs = _get_active_adb_configs(u["id"])
if not adb_configs:
raise HTTPException(400, "Nenhuma conexão ADB ativa configurada")
# Collect results from all active ADB configs + tables
all_docs = []
for adb_cfg in adb_configs:
log.info(f"Consult: processing ADB {adb_cfg['id']} ({adb_cfg.get('config_name','')})")
try:
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"))
log.info(f"Consult: resolved embed config, compartment={emb_genai.get('compartment_id','?')[:30]}, region={emb_genai.get('genai_region','?')}")
except Exception as e:
log.warning(f"Consult: resolve config failed for {adb_cfg['id']}: {e}")
continue
emb_model = adb_cfg.get("embedding_model_id", "")
if not emb_model:
log.warning(f"Consult: no embedding_model_id for {adb_cfg['id']}")
continue
try:
query_embedding = _embed_text(req.query, emb_genai, emb_model)
log.info(f"Consult: embedded query, dims={len(query_embedding)}")
except Exception as e:
log.warning(f"Consult: embed failed for {adb_cfg['id']}: {e}")
continue
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
log.info(f"Consult: {len(tables)} active tables, filter='{req.table_name}'")
if req.table_name:
tables = [t for t in tables if t["table_name"] == req.table_name]
for tbl in tables:
try:
docs = _vector_search(adb_cfg, query_embedding, top_k=req.top_k, table_name=tbl["table_name"])
for d in docs:
d["source"] = f"{tbl['table_name']}"
all_docs.extend(docs)
except Exception as e:
log.warning(f"Consult: search failed on {tbl['table_name']}: {type(e).__name__}: {e}")
if not all_docs:
return {"answer": "Nenhum resultado encontrado nas bases vetoriais.", "documents": [], "total": 0}
# Sort by distance and take top results
all_docs.sort(key=lambda d: d.get("distance", 999))
top_docs = all_docs[:req.top_k]
# Build context and call GenAI
rag_context = _build_rag_context(top_docs)
augmented = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=req.query)
# Get GenAI config for answering
with db() as c:
gc_row = c.execute("SELECT * FROM genai_configs WHERE is_default=1 ORDER BY created_at DESC").fetchone()
if not gc_row:
gc_row = c.execute("SELECT * FROM genai_configs ORDER BY created_at DESC").fetchone()
if not gc_row:
# No GenAI config — return raw documents formatted as answer
parts = []
for i, d in enumerate(top_docs, 1):
content = d.get("content", "")
if len(content) > 800: content = content[:800] + "..."
parts.append(f"**Documento {i}** — `{d.get('source', '?')}` (distância: {d.get('distance', 0):.4f})\n\n{content}")
raw_answer = "**Resultados da busca vetorial** (sem GenAI configurado para sumarizar):\n\n---\n\n" + "\n\n---\n\n".join(parts)
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
return {"answer": raw_answer, "documents": doc_list, "total": len(all_docs)}
gc = dict(gc_row)
try:
answer, _, _ = _call_genai(gc, [{"role": "user", "content": augmented}], system_prompt=RAG_DEFAULT_SYSTEM_PROMPT)
except Exception as e:
log.error(f"Consult GenAI error: {e}")
answer = f"Erro ao consultar GenAI: {str(e)[:300]}"
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
return {"answer": answer, "documents": doc_list, "total": len(all_docs)}
@app.get("/api/embeddings/{vid}/list")
async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)):
with db() as c:

View File

@@ -613,7 +613,7 @@ function rApp(){return`<div class="app">${rSb()}<div class="mc">${rTb()}<div cla
const TF_IC='<svg viewBox="0 0 16 16" width="14" height="14" style="vertical-align:-2px;margin-right:2px"><path d="M5.6 1v4.2l3.6 2.1V3.1L5.6 1zm4.4 2.1v4.2l3.6-2.1V1L10 3.1zM1.4 3.5v4.2l3.6 2.1V5.6L1.4 3.5zM5.6 8.1v4.2L9.2 14.4V10.2L5.6 8.1z" fill="#7b42bc"/></svg>';
function rSb(){
const tabs=[['chat',IC.chat,'Chat Agent'],['terraform',TF_IC,'Terraform'],['explorer',IC.search,'OCI Explorer'],['report',IC.chart,'Reports'],['downloads',IC.folder,'Downloads']];
const ctabs=[['oci-config',IC.cloud,'Credenciais OCI'],['genai',IC.brain,'GenAI Config'],['mcp',IC.plug,'MCP Servers'],['adb',IC.db,'ADB Vector'],['embeddings',IC.dna,'Embeddings']];
const ctabs=[['oci-config',IC.cloud,'Credenciais OCI'],['genai',IC.brain,'GenAI Config'],['mcp',IC.plug,'MCP Servers'],['adb',IC.db,'ADB Vector'],['embeddings',IC.dna,'Embeddings'],['emb-consult',IC.search,'Consultar Embeddings','sub']];
const atabs=[['users',IC.users,'Usuários'],['mfa',IC.lock,'MFA'],['audit',IC.log,'Audit Log']];
const i=(S.user?.first_name||S.user?.username||'?')[0].toUpperCase();
return`<div class="sb">
@@ -621,17 +621,17 @@ function rSb(){
<div class="nav"><div class="nl">Principal</div>
${tabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')"><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}
<div class="nl">Configuração</div>
${ctabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')"><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}
${ctabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')" ${t[3]==='sub'?'style="padding-left:2.2rem;font-size:.78rem;opacity:.9"':''}><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}
${S.user?.role==='admin'?`<div class="nl">Administração</div>
${atabs.map(t=>`<div class="ni ${S.tab===t[0]?'on':''}" onclick="switchTab('${t[0]}')"><span class="ic">${t[1]}</span>${t[2]}</div>`).join('')}`:''}</div>
<div class="sb-f"><div class="ui"><div class="ua">${i}</div><div><div class="un">${S.user?.first_name?S.user.first_name+' '+S.user.last_name:S.user?.username}</div><div class="ur">${S.user?.role}</div></div>
<div class="lo-btn" onclick="toggleTheme()" title="Alternar tema">${document.documentElement.classList.contains('dark')?IC.sun:IC.moon}</div>
<div class="lo-btn" onclick="doLogout()" title="Sair">⏻</div></div></div></div>`}
function rTb(){const t={'chat':IC.chat+' AI Agent Chat','terraform':TF_IC+' Terraform Agent','explorer':IC.search+' OCI Account Explorer','report':IC.chart+' Compliance Reports','downloads':IC.folder+' Downloads','oci-config':IC.cloud+' Credenciais OCI','genai':IC.brain+' OCI Generative AI','mcp':IC.plug+' MCP Servers','adb':IC.db+' Autonomous DB Vector','embeddings':IC.dna+' Embeddings','users':IC.users+' Gerenciar Usuários','mfa':IC.lock+' Autenticação MFA','audit':IC.log+' Audit Log'};
function rTb(){const t={'chat':IC.chat+' AI Agent Chat','terraform':TF_IC+' Terraform Agent','explorer':IC.search+' OCI Account Explorer','report':IC.chart+' Compliance Reports','downloads':IC.folder+' Downloads','oci-config':IC.cloud+' Credenciais OCI','genai':IC.brain+' OCI Generative AI','mcp':IC.plug+' MCP Servers','adb':IC.db+' Autonomous DB Vector','embeddings':IC.dna+' Embeddings','emb-consult':IC.search+' Consultar Embeddings','users':IC.users+' Gerenciar Usuários','mfa':IC.lock+' Autenticação MFA','audit':IC.log+' Audit Log'};
return`<div class="tb"><div class="tb-t">${t[S.tab]||''}</div><div class="tb-a"><span class="tag">v${V}</span></div></div>`}
function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
function rPg(){switch(S.tab){case'chat':return rChat();case'terraform':return rTerraform();case'explorer':return rExplorer();case'report':return rReport();case'downloads':return rDl();case'oci-config':return rOci();case'genai':return rGenAI();case'mcp':return rMCP();case'adb':return rADB();case'embeddings':return rEmbeddings();case'emb-consult':return rEmbConsultPage();case'users':return rUsers();case'mfa':return rMfa();case'audit':return rAudit();default:return''}}
/* ── Chat ── */
function rChat(){
@@ -2592,6 +2592,8 @@ async function removeTable(vid,tid){if(!confirm('Excluir esta tabela do registro
/* ── 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 tblOptsAll(vid){const cfg=S.adbCfg.find(c=>c.id===vid);if(!cfg||!(cfg.tables||[]).length)return'<option value="">Todas as tabelas</option>';
return'<option value="">Todas as tabelas</option>'+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.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('');
@@ -2686,6 +2688,53 @@ async function delEmb(vid,docId){if(!confirm('Excluir este embedding?'))return;
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)}}
/* ── Consult Embeddings ── */
if(!S.consultHistory)S.consultHistory=[];
function rEmbConsultPage(){
if(!S.adbCfg.length)return`<div class="cd"><div class="emp"><div class="eic">${IC.search}</div><p>Nenhuma conexão ADB configurada.</p><p style="font-size:.74rem;margin-top:.35rem">Configure em <strong>ADB Vector</strong>.</p></div></div>`;
const firstVid=S.adbCfg.find(c=>c.is_active)?.id||S.adbCfg[0]?.id||'';
return rEmbConsult(firstVid)}
function rEmbConsult(firstVid){
const msgs=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('');
return`<div class="cd" style="display:flex;flex-direction:column;height:calc(100vh - 180px)">
<div class="ct">${IC.search} Consultar Embeddings</div>
<div class="cdesc">Faça perguntas sobre os dados armazenados nas bases vetoriais. O modelo de IA consulta os embeddings e retorna uma resposta contextualizada.</div>
<div style="display:flex;gap:.5rem;margin-bottom:.75rem;flex-wrap:wrap">
<div class="ig" style="flex:1;min-width:180px"><label style="font-size:.72rem">Tabela (opcional)</label><select id="ecTbl" style="font-size:.78rem">${tblOptsAll(firstVid)}</select></div>
<div class="ig" style="width:80px"><label style="font-size:.72rem">Top-K</label><input type="number" id="ecTopK" value="10" min="1" max="30" style="font-size:.78rem;text-align:center"></div>
</div>
<div style="flex:1;overflow-y:auto;border:1px solid var(--brd);border-radius:var(--r);padding:.75rem;background:var(--bg2);margin-bottom:.75rem" id="ecMsgs">
${msgs||`<div class="emp"><div class="eic">${IC.search}</div><p>Faça uma consulta para pesquisar nos embeddings.</p><p style="font-size:.74rem;margin-top:.35rem">Ex: "findings para CIS 1.1", "recomendações de networking", "como corrigir MFA"</p></div>`}
</div>
<div style="display:flex;gap:.5rem">
<input type="text" id="ecQuery" placeholder="Digite sua consulta..." onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();consultEmb()}" style="flex:1;padding:.55rem .75rem;border:1px solid var(--brd);border-radius:var(--r);background:var(--bg1);color:var(--t1);font-size:.82rem">
<button class="btn bp" id="ecBtn" onclick="consultEmb()" style="white-space:nowrap">${IC.search} Consultar</button>
</div></div>`}
async function consultEmb(){
const q=document.getElementById('ecQuery').value.trim();if(!q)return;
const tbl=document.getElementById('ecTbl')?.value||'';
const topK=parseInt(document.getElementById('ecTopK')?.value)||10;
S.consultHistory.push({r:'user',c:q});
document.getElementById('ecQuery').value='';
const msgEl=document.getElementById('ecMsgs');
msgEl.innerHTML=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('')+
'<div class="cm cm-bot"><div class="cb"><span class="spinner" style="display:inline-block;width:14px;height:14px;vertical-align:middle"></span> Consultando bases vetoriais...</div></div>';
msgEl.scrollTop=msgEl.scrollHeight;
const btn=document.getElementById('ecBtn');btn.disabled=true;
try{
const d=await $api('/embeddings/consult',{method:'POST',body:{query:q,table_name:tbl,top_k:topK}});
let resp=d.answer||'Sem resposta do modelo.';
if(d.documents?.length){resp+='\n\n---\n**Fontes consultadas ('+d.documents.length+' documentos, '+d.total+' total):**\n';
d.documents.forEach((doc,i)=>{resp+='\n'+(i+1)+'. **'+doc.source+'** (distância: '+doc.distance+')'});
}
S.consultHistory.push({r:'bot',c:resp});
}catch(e){S.consultHistory.push({r:'bot',c:'Erro: '+e.message})}
btn.disabled=false;
msgEl.innerHTML=S.consultHistory.map(m=>`<div class="cm cm-${m.r}"><div class="cb">${m.r==='bot'?fm(m.c):m.c}</div></div>`).join('');
msgEl.scrollTop=msgEl.scrollHeight;
}
/* ── Users ── */
function rUsers(){return`<div class="cd"><div class="ct">${IC.users} Gerenciamento de Usuários</div><div id="um"></div>
<table style="margin-top:.65rem"><thead><tr><th>Nome</th><th>Usuário</th><th>Email</th><th>Role</th><th>MFA</th><th>Status</th><th>Último Login</th><th>Ações</th></tr></thead><tbody>