diff --git a/README.md b/README.md index 279fc4d..3807846 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Version + Version Python FastAPI 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 | diff --git a/backend/app.py b/backend/app.py index 215ea69..93b4e97 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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"]): diff --git a/backend/mcp_cis_server.py b/backend/mcp_cis_server.py index 2b2d020..347d36a 100644 --- a/backend/mcp_cis_server.py +++ b/backend/mcp_cis_server.py @@ -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 = { diff --git a/frontend/index.html b/frontend/index.html index 07b0ca3..c632915 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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

${curLabel}
${ddItems}
${ociSel}${gearBtn}${ragBadge}
${paramsPanel}
${ms}
-
+${S.chatFiles.length?`
${S.chatFiles.map((f,i)=>f.type==='image'?`
×
`:`
📄 ${f.name}×
`).join('')}
`:''} +
+ +
`} 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,'$1').replace(/`(.*?)`/g,'$1').replace(/\n/g,'
')} -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='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='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()} diff --git a/nginx/default.conf b/nginx/default.conf index 4558e5b..7885c93 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -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; } }