feat: chat history, inline file editor, split terminal, OCI resource actions, and layout fixes

- Add ChatGPT-style chat history for Chat Agent and Terraform Agent (sessions, rename, delete)
- Add inline .tf file editor with Tab support and auto-sync to workspace code
- Add split-panel terminal showing Plan/Apply/Destroy output alongside files/plan/resources
- Add OCI resource actions: start/stop Compute instances and Autonomous DBs from Resources panel
- Add multi-file Terraform generation via // filename: markers
- Fix GenAI empty response handling (GPT-5.2 fallback message)
- Fix compartment select visibility, Terraform code block light theme
- Fix page layout overflow (no more scroll beyond viewport)
- Add DB migration for terraform_workspaces.compartment_id column
- Update README with new features, API endpoints, and version notes
This commit is contained in:
nogueiraguh
2026-03-08 00:15:10 -03:00
parent a05956a726
commit 8bccf9367e
3 changed files with 500 additions and 94 deletions

View File

@@ -14,7 +14,7 @@ from functools import partial
from fastapi import (
FastAPI, HTTPException, Depends, Request, UploadFile, File, Form,
Query, BackgroundTasks
Query, Body, BackgroundTasks
)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
@@ -314,6 +314,14 @@ def init_db():
message TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chat_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
agent_type TEXT NOT NULL DEFAULT 'chat',
title TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS terraform_workspaces (
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
session_id TEXT NOT NULL,
@@ -352,6 +360,11 @@ def init_db():
c.execute(f"ALTER TABLE chat_messages ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
for col in ["compartment_id TEXT"]:
try:
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
except sqlite3.OperationalError:
pass
# Migrate legacy table_name from adb_vector_configs into adb_vector_tables
try:
for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall():
@@ -360,6 +373,19 @@ def init_db():
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
except Exception:
pass
# Backfill chat_sessions from existing chat_messages
try:
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
SELECT cm.session_id, cm.user_id,
CASE WHEN tw.id IS NOT NULL THEN 'terraform' ELSE 'chat' END,
SUBSTR((SELECT content FROM chat_messages cm2 WHERE cm2.session_id=cm.session_id AND cm2.role='user' ORDER BY cm2.created_at ASC LIMIT 1), 1, 80),
MIN(cm.created_at), MAX(cm.created_at)
FROM chat_messages cm
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
GROUP BY cm.session_id""")
except Exception:
pass
# Seed default system prompt if none exists for chat agent
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
@@ -1798,11 +1824,61 @@ def _call_genai(gc: dict, message: str, history: list = None, tools: list = None
})
# Preserve raw tool_calls for building assistant message in next iteration
tool_calls_raw = choice.message.tool_calls
# Extract text
# Extract text from content
if hasattr(choice.message, 'content') and choice.message.content:
contents = choice.message.content
if contents and len(contents) > 0:
text = contents[0].text if hasattr(contents[0], 'text') else ""
if isinstance(contents, str):
text = contents
elif isinstance(contents, list):
text_parts = []
for c in contents:
if isinstance(c, str):
text_parts.append(c)
elif hasattr(c, 'text') and c.text:
text_parts.append(c.text)
elif isinstance(c, dict) and c.get('text'):
text_parts.append(c['text'])
text = "\n".join(text_parts)
else:
text = str(contents)
# Fallback: check reasoning_content (some models like GPT-5.2 use this)
if not text and hasattr(resp, 'choices') and resp.choices:
choice = resp.choices[0]
if hasattr(choice, 'message') and choice.message:
msg_obj = choice.message
if hasattr(msg_obj, 'reasoning_content') and msg_obj.reasoning_content:
rc = msg_obj.reasoning_content
if isinstance(rc, str):
text = rc
elif isinstance(rc, list):
text_parts = []
for c in rc:
if isinstance(c, str):
text_parts.append(c)
elif hasattr(c, 'text') and c.text:
text_parts.append(c.text)
elif isinstance(c, dict) and c.get('text'):
text_parts.append(c['text'])
text = "\n".join(text_parts)
else:
text = str(rc)
if text:
log.info(f"GenAI: extracted text from reasoning_content ({len(text)} chars)")
# Last resort: try choice.text
if not text:
if hasattr(choice, 'text') and choice.text:
text = choice.text
if not text and not tool_calls:
raw_content = getattr(getattr(resp.choices[0], 'message', None), 'content', None) if hasattr(resp, 'choices') and resp.choices else None
# Also dump message attrs for debugging
msg_attrs = []
if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') and resp.choices[0].message:
msg_attrs = [a for a in dir(resp.choices[0].message) if not a.startswith('_')]
reasoning = getattr(resp.choices[0].message, 'reasoning_content', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
refusal = getattr(resp.choices[0].message, 'refusal', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
finish_reason = getattr(resp.choices[0], 'finish_reason', None) if hasattr(resp, 'choices') and resp.choices else None
choice_attrs = [a for a in dir(resp.choices[0]) if not a.startswith('_')] if hasattr(resp, 'choices') and resp.choices else []
log.warning(f"GenAI returned empty response. model={gc.get('model_id')}, finish_reason={finish_reason}, content_repr: {repr(raw_content)[:500]}, reasoning: {repr(reasoning)[:500] if reasoning else None}, refusal: {repr(refusal)[:200] if refusal else None}, choice_attrs: {choice_attrs}")
return (text or "", tool_calls, tool_calls_raw)
# ── RAG Helpers ───────────────────────────────────────────────────────────────
@@ -1859,12 +1935,30 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
### Seu papel
- Gerar código Terraform HCL production-ready usando o provider OCI (oracle/oci).
- Sempre usar a sintaxe mais recente do OCI Terraform provider.
- NÃO incluir bloco provider/required_providers — ele é gerado automaticamente pelo sistema.
### Multi-Região
- Quando o usuário solicitar recursos em MÚLTIPLAS regiões OCI, use **provider aliases**.
- NÃO inclua o bloco `terraform { required_providers }` nem o `provider "oci"` principal — estes são gerados automaticamente pelo sistema.
- Inclua APENAS os providers com alias para regiões adicionais:
```hcl
provider "oci" {
alias = "secondary"
region = var.region_secondary
}
```
- Referencie o provider alias nos recursos: `provider = oci.secondary`
- NUNCA diga que não pode criar recursos em outra região. Use provider aliases para qualquer região OCI.
### Múltiplos Arquivos
- Quando o usuário pedir "um arquivo por recurso" ou "arquivos separados", gere MÚLTIPLOS blocos `hcl` separados.
- Nomeie cada bloco com um comentário na primeira linha: `// filename: vcn_hub.tf`, `// filename: firewall.tf`, etc.
- Cada bloco `hcl` vira um arquivo separado no sistema.
- Se o usuário NÃO especificar, gere tudo em um único bloco.
### Contexto OCI
- Use as informações de tenancy, região e compartment fornecidas no contexto.
- Use `var.compartment_id` como default para compartment_id nos recursos.
- Use `var.region` para região.
- Use `var.region` para região primária.
### Recursos existentes
- Se o contexto incluir uma seção "RECURSOS OCI EXISTENTES NO COMPARTMENT", analise-a ANTES de gerar código.
@@ -1878,7 +1972,7 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
// código aqui
```
2. Após CADA bloco de código, forneça uma seção **Resource Plan** neste formato exato:
2. Após TODOS os blocos de código, forneça uma seção **Resource Plan** neste formato exato:
```plan
+ oci_core_vcn.main (Virtual Cloud Network)
+ oci_core_subnet.public (Subnet Pública)
@@ -1889,6 +1983,15 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
3. Explique o que cada recurso faz e como se conectam.
4. Quando o usuário pedir modificação, mostre o código COMPLETO atualizado (não apenas diffs).
### Validação
- Após gerar o código, faça uma validação automática verificando:
- Referências cruzadas entre recursos estão corretas
- Todos os CIDRs estão consistentes e não se sobrepõem indevidamente
- Security lists, route tables e gateways necessários estão presentes
- Dependências entre recursos estão na ordem correta
- Não há gaps de segurança óbvios (subnets sem security list, rotas faltando, etc.)
- Inclua uma seção "✅ Validação" no final da resposta com os itens verificados.
### Diretrizes
- Use variables para valores configuráveis (CIDRs, display names, shapes, etc.)
- Inclua defaults sensatos nas definições de variáveis.
@@ -1897,7 +2000,6 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
- Se o pedido for ambíguo, faça perguntas de esclarecimento.
- NÃO sugira rodar comandos terraform — o sistema cuida disso.
- Inclua outputs úteis (IPs, OCIDs dos recursos criados).
- Gere tudo em um único bloco de código (variables + resources + outputs juntos).
"""
def _get_adb_connection(cfg: dict):
@@ -2824,10 +2926,17 @@ async def report_dl(rid, fmt: str = Query("json"), u=Depends(current_user)):
def _chat_start(msg: ChatMsg, u, attachments: list = None, agent_type: str = "chat"):
"""Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response.
If genai_cfg is None, returns immediate fallback response in mid field as dict."""
is_new = not msg.session_id
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,status) VALUES (?,?,?,?,?,?,?)",
(str(uuid.uuid4()), sid, u["id"], "user", msg.message, None, "done"))
if is_new:
title = (msg.message or "Nova conversa")[:80].strip()
c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)",
(sid, u["id"], agent_type, title))
else:
c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,))
genai_cfg = None
if msg.genai_config_id:
@@ -2982,6 +3091,10 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
base_prompt = cfg_dict.get("system_prompt", "")
cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
# ── Terraform agent: boost max_tokens for complex infra code ──
if agent_type == "terraform":
cfg_dict["max_tokens"] = max(int(cfg_dict.get("max_tokens", 6000)), 16000)
# ── Inject existing OCI resources for terraform agent ──
if agent_type == "terraform" and active_oci_id:
try:
@@ -3086,6 +3199,11 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results)
resp += tools_info
if not resp or not resp.strip():
model_id = genai_cfg.get("model_id", "unknown")
resp = f"⚠️ O modelo **{model_id}** retornou uma resposta vazia. Isso pode ocorrer com alguns modelos. Tente novamente ou selecione outro modelo (ex: Gemini, Llama)."
log.warning(f"Chat {mid}: empty response from model {model_id}, returning fallback message")
with db() as c:
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid))
log.info(f"Chat {mid} completed successfully")
@@ -3218,9 +3336,43 @@ def _agent_respond(msg, user):
return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n"
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
@app.get("/api/chat/sessions")
async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depends(current_user)):
with db() as c:
rows = c.execute(
"""SELECT cs.id, cs.title, cs.agent_type, cs.created_at, cs.updated_at,
(SELECT COUNT(*) FROM chat_messages cm WHERE cm.session_id=cs.id) as message_count
FROM chat_sessions cs WHERE cs.user_id=? AND cs.agent_type=?
ORDER BY cs.updated_at DESC LIMIT ?""",
(u["id"], agent_type, limit)).fetchall()
return [dict(r) for r in rows]
@app.get("/api/chat/sessions/{sid}/messages")
async def get_session_messages(sid: str, u=Depends(current_user)):
with db() as c:
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
if not session:
raise HTTPException(404, "Session not found")
msgs = c.execute(
"SELECT id, role, content, model_id, status, created_at FROM chat_messages "
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
return {"session": dict(session), "messages": [dict(m) for m in msgs]}
@app.put("/api/chat/sessions/{sid}/title")
async def rename_session(sid: str, req: dict = Body(...), u=Depends(current_user)):
with db() as c:
c.execute("UPDATE chat_sessions SET title=? WHERE id=? AND user_id=?",
(req.get("title", "")[:120], sid, u["id"]))
return {"ok": True}
@app.delete("/api/chat/{sid}")
async def clear_chat(sid, u=Depends(current_user)):
with db() as c: c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
with db() as c:
c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
c.execute("DELETE FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"]))
return {"ok": True}
@@ -3252,13 +3404,13 @@ def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region
try:
compute = oci.core.ComputeClient(config)
insts = compute.list_instances(compartment_id).data
resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state == "RUNNING"]
resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state in ("RUNNING", "STOPPED")]
except Exception as e:
log.warning(f"Failed to fetch compute resources: {e}")
try:
db_client = oci.database.DatabaseClient(config)
adbs = db_client.list_autonomous_databases(compartment_id).data
resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state == "AVAILABLE"]
resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state in ("AVAILABLE", "STOPPED")]
except Exception as e:
log.warning(f"Failed to fetch database resources: {e}")
try:
@@ -3308,6 +3460,53 @@ def _build_resource_context(resources: dict) -> str:
return "\n".join(lines)
@app.post("/api/oci/instances/{instance_id}/action")
async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_user)):
action = req.get("action", "").upper()
oci_config_id = req.get("oci_config_id", "")
region = req.get("region")
if action not in ("START", "STOP"):
raise HTTPException(400, "Ação inválida. Use START ou STOP.")
if not oci_config_id:
raise HTTPException(400, "oci_config_id obrigatório")
try:
import oci
config = _get_oci_config(oci_config_id)
if region:
config["region"] = region
compute = oci.core.ComputeClient(config)
compute.instance_action(instance_id, action)
_audit(u["id"], u["username"], f"instance_{action.lower()}", instance_id)
return {"ok": True, "action": action, "instance_id": instance_id}
except Exception as e:
raise HTTPException(500, str(e)[:500])
@app.post("/api/oci/autonomous-databases/{adb_id}/action")
async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)):
action = req.get("action", "").lower()
oci_config_id = req.get("oci_config_id", "")
region = req.get("region")
if action not in ("start", "stop"):
raise HTTPException(400, "Ação inválida. Use start ou stop.")
if not oci_config_id:
raise HTTPException(400, "oci_config_id obrigatório")
try:
import oci
config = _get_oci_config(oci_config_id)
if region:
config["region"] = region
db_client = oci.database.DatabaseClient(config)
if action == "start":
db_client.start_autonomous_database(adb_id)
else:
db_client.stop_autonomous_database(adb_id)
_audit(u["id"], u["username"], f"adb_{action}", adb_id)
return {"ok": True, "action": action, "adb_id": adb_id}
except Exception as e:
raise HTTPException(500, str(e)[:500])
@app.get("/api/terraform/resources")
async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)):
"""List existing OCI resources in a compartment for terraform context."""
@@ -3430,6 +3629,23 @@ async def tf_download(wid: str, u=Depends(current_user)):
r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
if not r or not r["tf_code"]: raise HTTPException(404, "No code found")
from starlette.responses import Response
import re as _re
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', r["tf_code"], flags=_re.MULTILINE)
if len(parts) >= 3:
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
if parts[0].strip():
zf.writestr("main.tf", parts[0].strip())
for i in range(1, len(parts), 2):
fname = parts[i].strip().replace("/", "_").replace("..", "_")
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
if fname and content:
zf.writestr(fname, content)
buf.seek(0)
ws_name = r["name"] or "terraform"
return Response(content=buf.read(), media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{ws_name}.zip"'})
return Response(content=r["tf_code"], media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="main.tf"'})
@@ -3460,6 +3676,24 @@ async def tf_delete_workspace(wid: str, u=Depends(current_user)):
return {"ok": True}
def _write_tf_files(wdir: Path, tf_code: str):
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files."""
import re as _re
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', tf_code, flags=_re.MULTILINE)
if len(parts) >= 3:
# parts = [preamble, filename1, content1, filename2, content2, ...]
if parts[0].strip():
(wdir / "main.tf").write_text(parts[0].strip())
for i in range(1, len(parts), 2):
fname = parts[i].strip()
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
if fname and content:
safe_name = fname.replace("/", "_").replace("..", "_")
(wdir / safe_name).write_text(content)
else:
(wdir / "main.tf").write_text(tf_code)
async def _terraform_exec(wid: str, action: str, user: dict):
"""Background: run terraform init + plan/apply/destroy in workspace dir."""
log.info(f"Terraform {action} started: wid={wid}")
@@ -3475,8 +3709,8 @@ async def _terraform_exec(wid: str, action: str, user: dict):
wdir = TERRAFORM_DIR / wid
wdir.mkdir(parents=True, exist_ok=True)
# Write main.tf
(wdir / "main.tf").write_text(ws["tf_code"] or "")
# Write .tf files — parse // filename: comments to split into separate files
_write_tf_files(wdir, ws["tf_code"] or "")
# Auto-generate provider.tf from OCI config
oci_cfg = _get_oci_config(ws["oci_config_id"])