feat: multimodal chat, region-specific MCP scanning, and resilience improvements

Add image/PDF/text file upload in chat via new /api/chat/upload endpoint
with OCI GenAI ImageContent and DocumentContent support. Frontend shows
attach button, file preview, and handles multipart upload seamlessly.

Add optional regions parameter to all CIS MCP scan tools for targeting
specific OCI regions instead of full tenancy. Session cache scoped by
config+regions for concurrent multi-region scans.

Add orphaned report auto-detection on progress poll (marks as failed if
process not found). Increase nginx proxy timeout to 15 minutes. Improve
$api error handling for non-JSON responses (504 HTML pages).

Update README to v1.9.
This commit is contained in:
nogueiraguh
2026-03-05 13:27:17 -03:00
parent 1d52234b1a
commit ef43eaa7ba
5 changed files with 221 additions and 47 deletions

View File

@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-1.8-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/version-1.9-C74634?style=flat-square" alt="Version">
<img src="https://img.shields.io/badge/python-3.12-3776AB?style=flat-square" alt="Python">
<img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square" alt="FastAPI">
<img src="https://img.shields.io/badge/OCI-GenAI-C74634?style=flat-square" alt="OCI">
@@ -34,6 +34,7 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- **RAG (Retrieval-Augmented Generation)**: automatically queries ADB vector store for relevant context before generating responses
- **MCP Tool Use (Function Calling)**: GenAI models can call tools from registered MCP servers during chat — supports both Cohere and Generic (OpenAI-style) function calling formats with automatic tool execution loop (max 5 iterations)
- **Chat Memory Compaction**: automatic summarization of older messages when conversation exceeds ~8000 tokens — keeps 6 recent messages intact and generates an LLM-based summary of older context, similar to Claude Code's context compression
- **Multimodal Chat**: upload images (PNG/JPG/GIF/WebP), PDFs, and text files directly in the chat for AI analysis — supports up to 5 files per message via OCI GenAI `ImageContent` and `DocumentContent`
- **Thinking Indicator**: button disables and shows spinner + "Pensando..." while waiting for GenAI response
- 69 chat models + 11 embedding models across 6 providers: **Cohere**, **Meta**, **Google**, **OpenAI** (GPT-5.3/5.2/5.1/5/4.1/4o, Codex, Image, Audio, o1/o3/o4-mini, GPT-oss), **xAI** (Grok 4.1/4/3), **ProtectAI**
- OCID-based model resolution: catalog maps model IDs to OCI resource IDs per region for reliable API calls
@@ -74,7 +75,8 @@ The platform combines security compliance scanning, AI-powered chat with **RAG (
- `cis_get_check` / `cis_get_remediation` — detailed findings and remediation guidance
- `cis_get_scan_status` / `cis_invalidate_cache` — session status and cache management
- **Per-section data collection**: each scan tool collects only the OCI data needed for that section, avoiding unnecessary API calls
- **Session caching**: collected data is cached per config, so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
- **Region-specific scanning**: all scan tools accept optional `regions` parameter to target specific OCI regions (e.g., `["us-ashburn-1"]`) instead of scanning the entire tenancy
- **Session caching**: collected data is cached per config+regions scope, so subsequent scans on different sections reuse shared prerequisites (compartments, identity domains)
- Based on Oracle's official `cis_reports.py` (6660 lines, 48 CIS + 11 OBP checks)
### 🔌 MCP Server Registry + Tool Discovery
@@ -418,6 +420,7 @@ oci-cis-agent/
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/chat` | Send message (with RAG + MCP tool use, accepts `use_tools` flag) |
| POST | `/api/chat/upload` | Send message with file attachments (multipart, images/PDFs/text) |
| POST | `/api/reports/run` | Execute CIS report |
| GET | `/api/reports` | List reports |
| GET | `/api/reports/{id}/html` | View HTML report |
@@ -576,6 +579,7 @@ All models include OCID mapping for `us-ashburn-1`. For other regions, use the "
| Version | Date | Changes |
|---------|------|---------|
| **v1.9** | 2026-03 | Multimodal chat (image/PDF/text file upload with OCI GenAI ImageContent/DocumentContent), region-specific MCP scanning (`regions` param on all scan tools), orphaned report auto-detection on progress poll, nginx timeout increased to 15min, improved API error handling for non-JSON responses |
| **v1.8** | 2026-03 | CIS Engine auto-update from Oracle GitHub with automatic patch reapplication, version check UI card (admin), new `/api/cis-engine/*` endpoints, report file listing and individual download endpoints, reorganized Reports tab (execution history + status) and Downloads tab (file browser only with expandable cards per report), CIS Level description tooltip, persistent log expand during report generation |
| **v1.7** | 2026-03 | Oracle official CIS report engine (replaces lightweight checker), granular report parameters (Level, OBP, Raw Data, Redact), per-report file storage with category browser, tenancy filter in Downloads, individual file download |
| **v1.6** | 2026-03 | Granular CIS MCP server (12 per-section scan tools: IAM, Networking, Compute, Logging/Monitoring, Storage, Asset Management), chat memory compaction with LLM-based summarization, GenAI tool use loop fix (accumulated conversation), chat thinking indicator, auto-registered CIS MCP server |

View File

@@ -1013,7 +1013,8 @@ def _compact_history(session_id: str, user_id: str, genai_cfg: dict, history: li
def _call_genai(gc: dict, message: str, history: list = None, tools: list = None,
tool_results_cohere: list = None,
extra_messages: list = None) -> tuple:
extra_messages: list = None,
attachments: list = None) -> tuple:
"""
Call OCI Generative AI with optional tool use support.
Returns (text, tool_calls, tool_calls_raw) tuple.
@@ -1127,12 +1128,29 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
msg.content = [content]
messages.append(msg)
# Current user message
content = oci.generative_ai_inference.models.TextContent()
content.text = message
# Current user message (with optional multimodal attachments)
content_parts = []
if attachments:
for att in attachments:
if att["type"] == "image":
img_url = oci.generative_ai_inference.models.ImageUrl()
img_url.url = att["data_uri"]
img_url.detail = "AUTO"
img_content = oci.generative_ai_inference.models.ImageContent()
img_content.image_url = img_url
content_parts.append(img_content)
elif att["type"] == "document":
doc_url = oci.generative_ai_inference.models.DocumentUrl()
doc_url.url = att["data_uri"]
doc_content = oci.generative_ai_inference.models.DocumentContent()
doc_content.document_url = doc_url
content_parts.append(doc_content)
text_content = oci.generative_ai_inference.models.TextContent()
text_content.text = message
content_parts.append(text_content)
user_message = oci.generative_ai_inference.models.Message()
user_message.role = "USER"
user_message.content = [content]
user_message.content = content_parts
messages.append(user_message)
# Append accumulated tool use loop messages (assistant+tool_calls → tool_results → ...)
@@ -2096,6 +2114,12 @@ async def get_report_progress(rid: str, u=Depends(current_user)):
with db() as c:
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
# Detect orphaned "running" reports: status is running but no live process
if r["status"] == "running" and rid not in _running_reports:
with db() as c:
c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: processo não encontrado', completed_at=datetime('now') WHERE id=? AND status='running'", (rid,))
log.warning(f"Report {rid} marked as failed: no live process found")
return {**dict(r), "status": "failed", "error_msg": "Interrompido: processo não encontrado", "completed_at": datetime.utcnow().isoformat()}
return dict(r)
@app.post("/api/reports/{rid}/cancel")
@@ -2170,8 +2194,10 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
# ── Chat Agent ────────────────────────────────────────────────────────────────
@app.post("/api/chat")
async def chat(msg: ChatMsg, u=Depends(current_user)):
# (endpoints defined below _chat_core, after _agent_respond)
async def _chat_core_impl(msg: ChatMsg, u, attachments: list = None):
"""Internal chat implementation — called by both /api/chat and /api/chat/upload."""
sid = msg.session_id or str(uuid.uuid4())
with db() as c:
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id) VALUES (?,?,?,?,?,?)",
@@ -2284,7 +2310,7 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
hist = history[:-1] if len(history) > 1 else None
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs)
resp_text, tool_calls, tool_calls_raw = _call_genai(cfg_dict, augmented_message, hist, tools=tool_defs, attachments=attachments)
# Tool use loop (max 5 iterations)
# Accumulate extra_messages so the model sees the full tool use conversation
@@ -2360,6 +2386,90 @@ async def chat(msg: ChatMsg, u=Depends(current_user)):
result["tools_used"] = [{"name": tr["name"], "result_preview": tr["content"][:200]} for tr in all_tool_results]
return result
MAX_UPLOAD_FILES = 5
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
DOC_MIMES = {"application/pdf"}
@app.post("/api/chat/upload")
async def chat_with_files(
message: str = Form(""),
session_id: Optional[str] = Form(None),
genai_config_id: Optional[str] = Form(None),
model_id: Optional[str] = Form(None),
oci_config_id: Optional[str] = Form(None),
genai_region: Optional[str] = Form(None),
compartment_id: Optional[str] = Form(None),
use_tools: Optional[bool] = Form(True),
temperature: Optional[float] = Form(None),
max_tokens: Optional[int] = Form(None),
top_p: Optional[float] = Form(None),
top_k: Optional[int] = Form(None),
frequency_penalty: Optional[float] = Form(None),
presence_penalty: Optional[float] = Form(None),
files: List[UploadFile] = File(default=[]),
u=Depends(current_user),
):
if len(files) > MAX_UPLOAD_FILES:
raise HTTPException(400, f"Máximo {MAX_UPLOAD_FILES} arquivos por mensagem")
# Process uploaded files into attachments
attachments = []
file_names = []
for f in files:
data = await f.read()
if len(data) > MAX_UPLOAD_SIZE:
raise HTTPException(400, f"Arquivo {f.filename} excede 10MB")
mime = f.content_type or "application/octet-stream"
b64 = base64.b64encode(data).decode()
data_uri = f"data:{mime};base64,{b64}"
if mime in IMAGE_MIMES:
attachments.append({"type": "image", "mime": mime, "data_uri": data_uri})
elif mime in DOC_MIMES:
attachments.append({"type": "document", "mime": mime, "data_uri": data_uri})
else:
# Text-based files: read content and append to message
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
file_names.append(f.filename)
# Build a synthetic ChatMsg and delegate to the chat logic
msg = ChatMsg(
message=message or "Analise os arquivos anexados.",
session_id=session_id,
genai_config_id=genai_config_id,
model_id=model_id,
oci_config_id=oci_config_id,
genai_region=genai_region,
compartment_id=compartment_id,
use_tools=use_tools if use_tools is not None else True,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
top_k=top_k,
frequency_penalty=frequency_penalty,
presence_penalty=presence_penalty,
)
# Store attachments in request state for _call_genai
result = await _chat_core_impl(msg, u, attachments=attachments if attachments else None)
if file_names:
# Save file reference in user message
with db() as c:
c.execute("UPDATE chat_messages SET content = content || ? WHERE session_id=? AND role='user' ORDER BY created_at DESC LIMIT 1",
(f"\n[📎 {', '.join(file_names)}]", result["session_id"]))
return result
@app.post("/api/chat")
async def chat(msg: ChatMsg, u=Depends(current_user)):
return await _chat_core_impl(msg, u)
def _agent_respond(msg, user):
m = msg.lower().strip()
if any(k in m for k in ["status","health","como está","saúde"]):

View File

@@ -25,10 +25,17 @@ from mcp.types import Tool, TextContent
# ──────────────────────────────────────────────────────────────────────
server = Server("cis-compliance")
# config_id -> {checker, sections_collected: set, sections_analyzed: set, ...}
# session_key -> {checker, sections_collected: set, sections_analyzed: set, ...}
_sessions: dict = {}
SESSION_TTL = 1800
def _session_key(config_id: str, regions: list[str] | None = None) -> str:
"""Build cache key that includes region filter so different scopes are cached separately."""
if not regions:
return config_id
return f"{config_id}:{','.join(sorted(regions))}"
OCI_CONFIGS_DIR = os.environ.get("OCI_CONFIGS_DIR", "/data/oci_configs")
# ──────────────────────────────────────────────────────────────────────
@@ -164,12 +171,13 @@ def _list_available_configs() -> list[dict]:
def _get_or_create_checker(config_id: str, regions: list[str] | None = None):
"""Return cached session or create a new CIS_Report instance (fast — no data collection)."""
key = _session_key(config_id, regions)
now = time.time()
if config_id in _sessions:
s = _sessions[config_id]
if key in _sessions:
s = _sessions[key]
if now - s["created_at"] < SESSION_TTL:
return s
del _sessions[config_id]
del _sessions[key]
import cis_reports
@@ -197,9 +205,10 @@ def _get_or_create_checker(config_id: str, regions: list[str] | None = None):
session = {
"checker": checker, "created_at": now, "config_id": config_id,
"regions": regions, "session_key": key,
"base_collected": False, "sections_collected": set(), "sections_analyzed": set(),
}
_sessions[config_id] = session
_sessions[key] = session
return session
@@ -446,37 +455,44 @@ TOOLS = [
description="Coleta dados IAM e executa checks CIS 1.1-1.17: políticas, usuários, MFA, API keys, passwords, dynamic groups. Rápido (~30s).",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_networking",
description="Coleta dados de rede e executa checks CIS 2.1-2.8: security lists, NSGs, VCNs, subnets, OIC, OAC, ADB.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_compute",
description="Coleta instâncias Compute e executa checks CIS 3.1-3.3: metadata v2, secure boot, in-transit encryption. Rápido.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_logging_monitoring",
description="Coleta dados de logging/monitoring e executa checks CIS 4.1-4.18: tags, notificações, events, Cloud Guard, KMS, logs.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_storage",
description="Coleta dados de storage e executa checks CIS 5.1.1-5.3.1: buckets, block/boot volumes, file storage, CMK, versioning.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_scan_asset_management",
description="Coleta dados de assets e executa checks CIS 6.1-6.2: compartments, recursos no root.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id"]}),
Tool(name="cis_get_check",
description="Retorna findings detalhados de um check CIS individual por ID (ex: '1.7', '2.1'). Requer scan da seção correspondente.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"check_id": {"type": "string", "description": "ID do check CIS (ex: '1.7')"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões específicas (ex: ['us-ashburn-1']). Vazio = todas."},
}, "required": ["config_id", "check_id"]}),
Tool(name="cis_get_remediation",
description="Orientação de remediação para um check CIS: descrição, passos, link docs Oracle. Estático, sem conexão OCI.",
@@ -484,14 +500,16 @@ TOOLS = [
"check_id": {"type": "string", "description": "ID do check CIS (ex: '1.7')"},
}, "required": ["check_id"]}),
Tool(name="cis_get_scan_status",
description="Status da sessão: quais seções já foram coletadas/analisadas, TTL restante.",
description="Status da sessão: quais seções já foram coletadas/analisadas, TTL restante, regiões filtradas.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões da sessão a consultar. Vazio = sessão sem filtro."},
}, "required": ["config_id"]}),
Tool(name="cis_invalidate_cache",
description="Limpa cache de sessão. Próximo scan coletará dados novamente.",
inputSchema={"type": "object", "properties": {
"config_id": {"type": "string", "description": "ID da config OCI"},
"regions": {"type": "array", "items": {"type": "string"}, "description": "Regiões da sessão a limpar. Vazio = sessão sem filtro."},
}, "required": ["config_id"]}),
]
@@ -515,16 +533,21 @@ def _handle_cis_list_checks(args):
def _handle_scan_section(args, section: str):
config_id = args["config_id"]
session = _get_or_create_checker(config_id)
regions = args.get("regions") or None
session = _get_or_create_checker(config_id, regions=regions)
_analyze_section(session, section)
return _section_summary(session["checker"], section)
result = _section_summary(session["checker"], section)
if regions:
result["regions_filter"] = regions
return result
def _handle_cis_get_check(args):
config_id = args["config_id"]; check_id = args["check_id"]
regions = args.get("regions") or None
section = _check_id_to_section(check_id)
if section == "unknown":
return {"error": f"Check '{check_id}' não encontrado. Use cis_list_checks."}
session = _get_or_create_checker(config_id)
session = _get_or_create_checker(config_id, regions=regions)
_analyze_section(session, section)
benchmark = _get_benchmark(session["checker"])
if check_id not in benchmark:
@@ -549,12 +572,14 @@ def _handle_cis_get_remediation(args):
def _handle_cis_get_scan_status(args):
config_id = args["config_id"]
if config_id not in _sessions:
return {"config_id": config_id, "status": "not_initialized", "sections_collected": [], "sections_analyzed": []}
s = _sessions[config_id]
regions = args.get("regions") or None
key = _session_key(config_id, regions)
if key not in _sessions:
return {"config_id": config_id, "regions": regions, "status": "not_initialized", "sections_collected": [], "sections_analyzed": []}
s = _sessions[key]
elapsed = time.time() - s["created_at"]
return {
"config_id": config_id, "status": "active",
"config_id": config_id, "regions": s.get("regions"), "status": "active",
"base_collected": s["base_collected"],
"sections_collected": list(s["sections_collected"]),
"sections_analyzed": list(s["sections_analyzed"]),
@@ -564,10 +589,12 @@ def _handle_cis_get_scan_status(args):
def _handle_cis_invalidate_cache(args):
config_id = args["config_id"]
if config_id in _sessions:
del _sessions[config_id]
return {"status": "cleared", "config_id": config_id}
return {"status": "not_found", "config_id": config_id}
regions = args.get("regions") or None
key = _session_key(config_id, regions)
if key in _sessions:
del _sessions[key]
return {"status": "cleared", "config_id": config_id, "regions": regions}
return {"status": "not_found", "config_id": config_id, "regions": regions}
TOOL_HANDLERS = {

View File

@@ -218,12 +218,14 @@ const S={user:null,token:null,tab:'chat',msgs:[],sid:null,reports:[],ociCfg:[],g
chatPrompts:[],editingPrompt:null,trackingReportId:null,ociRegions:{},rptSelRegions:[],rptRegionsOpen:false,rptRegionFilter:'',
rptOciOpen:false,rptOciFilter:'',rptOciVal:'',rptRselOpen:false,rptRselFilter:'',rptRselVal:'',ociFormRegOpen:false,ociFormRegFilter:'',ociFormRegVal:'',
rptLevel:2,rptObp:false,rptRaw:false,rptRedact:false,reportFiles:{},dlExpandedRid:null,dlTenancyFilter:'',
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:''};
cisVer:null,cisCheckResult:null,cisUpdating:false,rptHistFilter:'',chatFiles:[]};
const API='/api';
async function $api(p,o={}){const h={...(o.headers||{})};if(S.token)h['Authorization']='Bearer '+S.token;
if(o.body&&!(o.body instanceof FormData)){h['Content-Type']='application/json';o.body=JSON.stringify(o.body)}
const r=await fetch(API+p,{...o,headers:h});if(r.status===401){S.user=null;S.token=null;localStorage.removeItem('t');R();throw new Error('Unauthorized')}
const ct=r.headers.get('content-type')||'';
if(!ct.includes('application/json')){const txt=await r.text();throw new Error(r.ok?'Resposta inesperada do servidor':`HTTP ${r.status}: ${txt.slice(0,120).replace(/<[^>]*>/g,'').trim()||'Erro do servidor'}`)}
const d=await r.json();if(!r.ok)throw new Error(d.detail||d.message||'Erro');return d;}
async function doLogin(u,pw,totp){const b={username:u,password:pw};if(totp)b.totp_code=totp;
const d=await $api('/auth/login',{method:'POST',body:b});if(d.mfa_required)return d;
@@ -320,7 +322,10 @@ ${S.mcpSvr.filter(m=>m.is_active&&Array.isArray(m.tools)&&m.tools.length).length
<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>
${paramsPanel}<div class="ch-m" id="chm">${ms}</div>
<div class="ch-i"><input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
${S.chatFiles.length?`<div style="display:flex;gap:.4rem;flex-wrap:wrap;padding:.4rem .85rem;background:var(--bg1);border:1px solid var(--bd);border-bottom:none">${S.chatFiles.map((f,i)=>f.type==='image'?`<div style="position:relative;width:48px;height:48px"><img src="${f.preview}" style="width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd)"><span onclick="rmChatFile(${i})" style="position:absolute;top:-4px;right:-4px;background:var(--err);color:#fff;width:16px;height:16px;border-radius:50%;font-size:.6rem;display:flex;align-items:center;justify-content:center;cursor:pointer">&times;</span></div>`:`<div style="position:relative;display:flex;align-items:center;gap:.25rem;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;padding:.25rem .5rem;font-size:.68rem"><span>📄 ${f.name}</span><span onclick="rmChatFile(${i})" style="color:var(--err);cursor:pointer;font-weight:700">&times;</span></div>`).join('')}</div>`:''}
<div class="ch-i"><input type="file" id="chf" multiple accept="image/*,.pdf,.txt,.csv,.json,.log,.xml,.yaml,.yml,.md,.py,.js,.html,.css,.sql" style="display:none" onchange="addChatFiles(this)">
<button class="btn bs" onclick="document.getElementById('chf').click()" title="Anexar arquivo" style="font-size:1rem;padding:.3rem .5rem">📎</button>
<input type="text" id="chi" placeholder="Digite sua mensagem..." onkeydown="if(event.key==='Enter')sChat()">
<button class="btn bp" onclick="sChat()">Enviar →</button></div></div>`}
function toggleModelDrop(){const dd=document.getElementById('mdd');dd.classList.toggle('open');if(dd.classList.contains('open')){const inp=document.getElementById('mds');inp.value='';inp.focus();filterModels('')}}
function pickModel(v){S.chatModel=v;document.getElementById('mdd').classList.remove('open');
@@ -344,32 +349,59 @@ async function savePrompt(){const name=document.getElementById('spname').value;c
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;
function addChatFiles(input){for(const f of input.files){if(S.chatFiles.length>=5){alert('Máximo 5 arquivos');break}
const entry={file:f,name:f.name,type:f.type.startsWith('image/')?'image':'file',preview:null};
if(entry.type==='image'){const r=new FileReader();r.onload=e=>{entry.preview=e.target.result;R()};r.readAsDataURL(f)}
S.chatFiles.push(entry)}input.value='';R()}
function rmChatFile(i){S.chatFiles.splice(i,1);R()}
async function sChat(){const el=document.getElementById('chi');const m=el.value.trim();
if(!m&&!S.chatFiles.length)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();
const hasFiles=S.chatFiles.length>0;
const fileNames=S.chatFiles.map(f=>f.name);
let userDisplay=m||'';
if(hasFiles)userDisplay+=(userDisplay?'\n':'')+'📎 '+fileNames.join(', ');
S.msgs.push({r:'user',c:userDisplay});R();scCh();
// Disable input and show thinking indicator
const btn=document.querySelector('.ch-i .btn');
el.disabled=true;if(btn){btn.disabled=true;btn.dataset.origText=btn.textContent;btn.innerHTML='<span class="spinner" style="width:14px;height:14px;border-width:2px;margin-right:6px"></span>Pensando...'}
const btns=document.querySelectorAll('.ch-i .btn');
el.disabled=true;btns.forEach(b=>{b.disabled=true});
const sendBtn=btns[btns.length-1];if(sendBtn){sendBtn.dataset.origText=sendBtn.textContent;sendBtn.innerHTML='<span class="spinner" style="width:14px;height:14px;border-width:2px;margin-right:6px"></span>Pensando...'}
S.msgs.push({r:'assistant',c:'⏳ _Pensando..._',thinking:true});R();scCh();
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;
body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty}
else if(S.chatModel){
if(!S.chatOci){S.msgs.pop();S.msgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI para usar o modelo direto.'});R();el.disabled=false;if(btn){btn.disabled=false;btn.textContent=btn.dataset.origText}return}
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;
// Remove thinking indicator
try{let d;
if(hasFiles){
const fd=new FormData();
fd.append('message',m||'Analise os arquivos anexados.');
if(S.sid)fd.append('session_id',S.sid);
fd.append('use_tools',S.chatUseTools);
if(S.chatModel.startsWith('cfg:')){fd.append('genai_config_id',S.chatModel.slice(4))}
else if(S.chatModel){
if(!S.chatOci){S.msgs.pop();S.msgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();el.disabled=false;btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText;return}
fd.append('model_id',S.chatModel);fd.append('oci_config_id',S.chatOci);fd.append('genai_region',S.chatRegion);fd.append('compartment_id',S.chatCompartment)}
fd.append('temperature',S.chatParams.temperature);fd.append('max_tokens',S.chatParams.max_tokens);
fd.append('top_p',S.chatParams.top_p);fd.append('top_k',S.chatParams.top_k);
fd.append('frequency_penalty',S.chatParams.frequency_penalty);fd.append('presence_penalty',S.chatParams.presence_penalty);
S.chatFiles.forEach(f=>fd.append('files',f.file));
d=await $api('/chat/upload',{method:'POST',body:fd});S.chatFiles=[]
}else{
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;
body.top_k=S.chatParams.top_k;body.frequency_penalty=S.chatParams.frequency_penalty;body.presence_penalty=S.chatParams.presence_penalty}
else if(S.chatModel){
if(!S.chatOci){S.msgs.pop();S.msgs.push({r:'assistant',c:'⚠️ Selecione uma credencial OCI.'});R();el.disabled=false;btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText;return}
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}
d=await $api('/chat',{method:'POST',body})}
S.sid=d.session_id;
S.msgs=S.msgs.filter(x=>!x.thinking);
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=S.msgs.filter(x=>!x.thinking);S.msgs.push({r:'assistant',c:'❌ Erro: '+e.message});R()}
finally{el.disabled=false;el.focus();if(btn){btn.disabled=false;btn.textContent=btn.dataset.origText||'Enviar →'}}}
finally{el.disabled=false;el.focus();btns.forEach(b=>{b.disabled=false});if(sendBtn)sendBtn.textContent=sendBtn.dataset.origText||'Enviar →'}}
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()}

View File

@@ -19,7 +19,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_connect_timeout 60s;
}
}