AI-first API testing desktop client built with Python + PyQt6. Features: - Multi-tab HTTP request editor with params/headers/body/auth/tests - KeyValueTable with per-row enable/disable checkboxes and 36px rows - Format JSON button, syntax highlighting, pre-request & test scripts - Collections, environments, history, import/export (Postman v2.1, cURL) - OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens) - EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL, Custom REST JSON with all auth types (instant, no AI tokens) - Persistent AI chat sidebar (Claude-powered co-pilot) with streaming, context-aware suggestions, and one-click Apply to request editor - AI collection generator from any docs URL or pasted spec - WebSocket client, Mock server, Collection runner, Code generator - Dark/light theme engine (global QSS, object-name selectors) - SSL error detection with actionable hints - MIT License Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
700 lines
30 KiB
Python
700 lines
30 KiB
Python
"""APIClient - Agent — AI Assistant Dialog."""
|
|
import json
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
|
QPushButton, QTextEdit, QWidget, QTabWidget, QMessageBox,
|
|
QProgressBar, QCheckBox, QFormLayout, QComboBox, QScrollArea,
|
|
QGroupBox, QGridLayout, QSizePolicy
|
|
)
|
|
from PyQt6.QtCore import QThread, pyqtSignal, Qt
|
|
from PyQt6.QtGui import QFont
|
|
|
|
from app.core import storage, ai_client, openapi_parser
|
|
from app.core.ekika_odoo_generator import (
|
|
generate_collection, API_KINDS, AUTH_TYPES, OPERATIONS
|
|
)
|
|
from app.models import HttpRequest, Environment
|
|
|
|
|
|
# ── Generic analysis worker ───────────────────────────────────────────────────
|
|
|
|
class AnalysisWorker(QThread):
|
|
progress = pyqtSignal(str)
|
|
finished = pyqtSignal(dict)
|
|
error = pyqtSignal(str)
|
|
|
|
def __init__(self, url: str = "", raw_text: str = "",
|
|
base_url: str = "", models: list = None):
|
|
super().__init__()
|
|
self.url = url
|
|
self.raw_text = raw_text
|
|
self.base_url = base_url
|
|
self.models = models or []
|
|
|
|
def run(self):
|
|
try:
|
|
content = self.raw_text
|
|
|
|
if self.url and not content:
|
|
self.progress.emit("Fetching documentation…")
|
|
content = ai_client.fetch_url_content(self.url)
|
|
|
|
self.progress.emit("Checking for OpenAPI/Swagger spec…")
|
|
spec = openapi_parser.detect_spec(content)
|
|
if spec:
|
|
self.progress.emit("OpenAPI spec detected — parsing directly…")
|
|
result = openapi_parser.parse_spec(spec)
|
|
if self.base_url:
|
|
result["base_url"] = self.base_url
|
|
result.setdefault("environment_variables", {})["base_url"] = self.base_url
|
|
result["_source"] = "openapi"
|
|
self.finished.emit(result)
|
|
return
|
|
|
|
prompt = self._build_prompt(content)
|
|
result = ai_client.analyze_docs(prompt, progress_cb=self.progress.emit)
|
|
result["_source"] = "ai"
|
|
if self.base_url and not result.get("base_url"):
|
|
result["base_url"] = self.base_url
|
|
if self.base_url:
|
|
result.setdefault("environment_variables", {})["base_url"] = self.base_url
|
|
self.finished.emit(result)
|
|
|
|
except ai_client.AIError as e:
|
|
self.error.emit(str(e))
|
|
except Exception as e:
|
|
self.error.emit(f"Unexpected error: {e}")
|
|
|
|
def _build_prompt(self, content: str) -> str:
|
|
parts = ["Analyze the following API documentation and generate the JSON collection.\n"]
|
|
if self.base_url:
|
|
parts.append(f"The user's API instance base URL is: {self.base_url}")
|
|
if self.models:
|
|
parts.append(
|
|
f"The user wants endpoints for these specific models/resources: "
|
|
f"{', '.join(self.models)}\n"
|
|
f"Generate a full CRUD set for each model."
|
|
)
|
|
parts.append("\n--- DOCUMENTATION ---\n")
|
|
parts.append(content)
|
|
return "\n".join(parts)
|
|
|
|
|
|
# ── Main Dialog ───────────────────────────────────────────────────────────────
|
|
|
|
class AIAssistantDialog(QDialog):
|
|
collection_imported = pyqtSignal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("AI Assistant")
|
|
self.setMinimumSize(900, 680)
|
|
self._worker: AnalysisWorker | None = None
|
|
self._result: dict | None = None
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
# ── Header ────────────────────────────────────────────────────────────
|
|
header = QWidget()
|
|
header.setObjectName("panelHeader")
|
|
header.setFixedHeight(52)
|
|
hl = QHBoxLayout(header)
|
|
hl.setContentsMargins(16, 0, 16, 0)
|
|
title = QLabel("AI Assistant")
|
|
title.setObjectName("panelTitle")
|
|
hl.addWidget(title)
|
|
hl.addStretch()
|
|
sub = QLabel("EKIKA Odoo API Framework · OpenAPI · Any REST API")
|
|
sub.setObjectName("hintText")
|
|
hl.addWidget(sub)
|
|
layout.addWidget(header)
|
|
|
|
# ── Tabs ──────────────────────────────────────────────────────────────
|
|
self.tabs = QTabWidget()
|
|
self.tabs.addTab(self._build_ekika_tab(), " EKIKA Odoo API ")
|
|
self.tabs.addTab(self._build_generic_tab(), " Import from Docs ")
|
|
self.tabs.addTab(self._build_settings_tab(), " Settings ")
|
|
layout.addWidget(self.tabs, 1)
|
|
|
|
# ── Footer ────────────────────────────────────────────────────────────
|
|
footer = QWidget()
|
|
footer.setObjectName("panelFooter")
|
|
footer.setFixedHeight(52)
|
|
fl = QHBoxLayout(footer)
|
|
fl.setContentsMargins(16, 0, 16, 0)
|
|
self.status_label = QLabel("Ready")
|
|
self.status_label.setObjectName("aiStatusLabel")
|
|
fl.addWidget(self.status_label)
|
|
fl.addStretch()
|
|
close_btn = QPushButton("Close")
|
|
close_btn.setFixedWidth(80)
|
|
close_btn.clicked.connect(self.accept)
|
|
fl.addWidget(close_btn)
|
|
layout.addWidget(footer)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# Tab 1 — EKIKA Odoo API Framework (dedicated, no AI tokens needed)
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _build_ekika_tab(self) -> QWidget:
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
scroll.setFrameShape(scroll.Shape.NoFrame)
|
|
|
|
w = QWidget()
|
|
w.setObjectName("panelBody")
|
|
layout = QVBoxLayout(w)
|
|
layout.setContentsMargins(20, 16, 20, 16)
|
|
layout.setSpacing(14)
|
|
|
|
# ── Connection ────────────────────────────────────────────────────────
|
|
conn_group = QGroupBox("Connection")
|
|
cg = QFormLayout(conn_group)
|
|
cg.setSpacing(8)
|
|
|
|
self.ek_instance_url = QLineEdit()
|
|
self.ek_instance_url.setObjectName("urlBar")
|
|
self.ek_instance_url.setPlaceholderText("https://mycompany.odoo.com")
|
|
self.ek_instance_url.setText("https://api_framework-18.demo.odoo-apps.ekika.co")
|
|
|
|
self.ek_endpoint = QLineEdit()
|
|
self.ek_endpoint.setPlaceholderText("/user-jsonapi-apikey")
|
|
self.ek_endpoint.setText("/user-jsonapi-apikey")
|
|
|
|
self.ek_api_kind = QComboBox()
|
|
self.ek_api_kind.addItems(API_KINDS)
|
|
|
|
cg.addRow("Instance URL:", self.ek_instance_url)
|
|
cg.addRow("API Endpoint:", self.ek_endpoint)
|
|
cg.addRow("API Kind:", self.ek_api_kind)
|
|
layout.addWidget(conn_group)
|
|
|
|
# ── Authentication ────────────────────────────────────────────────────
|
|
auth_group = QGroupBox("Authentication")
|
|
ag = QVBoxLayout(auth_group)
|
|
|
|
auth_top = QHBoxLayout()
|
|
auth_top.addWidget(QLabel("Auth Type:"))
|
|
self.ek_auth_type = QComboBox()
|
|
self.ek_auth_type.addItems(AUTH_TYPES)
|
|
self.ek_auth_type.currentTextChanged.connect(self._on_ek_auth_changed)
|
|
auth_top.addWidget(self.ek_auth_type)
|
|
auth_top.addStretch()
|
|
ag.addLayout(auth_top)
|
|
|
|
# Auth fields stack (we show/hide rows as needed)
|
|
self.ek_auth_form = QFormLayout()
|
|
self.ek_auth_form.setSpacing(8)
|
|
|
|
self.ek_api_key_input = QLineEdit()
|
|
self.ek_api_key_input.setPlaceholderText("Your API key value")
|
|
self.ek_api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
show_ak = QCheckBox("Show")
|
|
show_ak.toggled.connect(lambda on: self.ek_api_key_input.setEchoMode(
|
|
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
|
ak_row = QHBoxLayout()
|
|
ak_row.addWidget(self.ek_api_key_input)
|
|
ak_row.addWidget(show_ak)
|
|
self._ek_ak_label = QLabel("API Key:")
|
|
self.ek_auth_form.addRow(self._ek_ak_label, ak_row)
|
|
|
|
self.ek_username = QLineEdit()
|
|
self.ek_username.setPlaceholderText("admin")
|
|
self._ek_user_label = QLabel("Username:")
|
|
self.ek_auth_form.addRow(self._ek_user_label, self.ek_username)
|
|
|
|
self.ek_password = QLineEdit()
|
|
self.ek_password.setEchoMode(QLineEdit.EchoMode.Password)
|
|
self.ek_password.setPlaceholderText("password")
|
|
show_pw = QCheckBox("Show")
|
|
show_pw.toggled.connect(lambda on: self.ek_password.setEchoMode(
|
|
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
|
pw_row = QHBoxLayout()
|
|
pw_row.addWidget(self.ek_password)
|
|
pw_row.addWidget(show_pw)
|
|
self._ek_pw_label = QLabel("Password:")
|
|
self.ek_auth_form.addRow(self._ek_pw_label, pw_row)
|
|
|
|
ag.addLayout(self.ek_auth_form)
|
|
layout.addWidget(auth_group)
|
|
self._on_ek_auth_changed("API Key") # set initial visibility
|
|
|
|
# ── Models ────────────────────────────────────────────────────────────
|
|
models_group = QGroupBox("Models")
|
|
mg = QVBoxLayout(models_group)
|
|
models_hint = QLabel(
|
|
"Enter Odoo model technical names (comma-separated).\n"
|
|
"Examples: sale.order, res.partner, account.move, product.template"
|
|
)
|
|
models_hint.setObjectName("hintText")
|
|
models_hint.setWordWrap(True)
|
|
mg.addWidget(models_hint)
|
|
self.ek_models = QLineEdit()
|
|
self.ek_models.setPlaceholderText("sale.order, res.partner, product.template")
|
|
self.ek_models.setText("sale.order")
|
|
mg.addWidget(self.ek_models)
|
|
layout.addWidget(models_group)
|
|
|
|
# ── Operations ────────────────────────────────────────────────────────
|
|
ops_group = QGroupBox("Operations to Generate")
|
|
og = QGridLayout(ops_group)
|
|
og.setSpacing(6)
|
|
self.ek_op_checks: dict[str, QCheckBox] = {}
|
|
default_ops = {"List Records", "Get Single Record", "Create Record",
|
|
"Update Record", "Delete Record"}
|
|
cols = 3
|
|
for i, op in enumerate(OPERATIONS):
|
|
cb = QCheckBox(op)
|
|
cb.setChecked(op in default_ops)
|
|
self.ek_op_checks[op] = cb
|
|
og.addWidget(cb, i // cols, i % cols)
|
|
layout.addWidget(ops_group)
|
|
|
|
# ── Collection name ───────────────────────────────────────────────────
|
|
name_row = QHBoxLayout()
|
|
name_label = QLabel("Collection Name:")
|
|
name_label.setObjectName("fieldLabel")
|
|
self.ek_col_name = QLineEdit()
|
|
self.ek_col_name.setPlaceholderText("Leave blank for auto-name")
|
|
name_row.addWidget(name_label)
|
|
name_row.addWidget(self.ek_col_name, 1)
|
|
layout.addLayout(name_row)
|
|
|
|
# ── Generate button ───────────────────────────────────────────────────
|
|
gen_row = QHBoxLayout()
|
|
self.ek_generate_btn = QPushButton("Generate Collection")
|
|
self.ek_generate_btn.setObjectName("accent")
|
|
self.ek_generate_btn.setFixedHeight(36)
|
|
self.ek_generate_btn.clicked.connect(self._ekika_generate)
|
|
gen_row.addWidget(self.ek_generate_btn)
|
|
gen_row.addStretch()
|
|
|
|
self.ek_import_btn = QPushButton("Import Collection")
|
|
self.ek_import_btn.setFixedHeight(36)
|
|
self.ek_import_btn.setEnabled(False)
|
|
self.ek_import_btn.clicked.connect(self._ekika_import)
|
|
gen_row.addWidget(self.ek_import_btn)
|
|
|
|
self.ek_env_btn = QPushButton("Create Environment")
|
|
self.ek_env_btn.setFixedHeight(36)
|
|
self.ek_env_btn.setEnabled(False)
|
|
self.ek_env_btn.clicked.connect(self._ekika_create_env)
|
|
gen_row.addWidget(self.ek_env_btn)
|
|
|
|
self.ek_both_btn = QPushButton("Import Both")
|
|
self.ek_both_btn.setObjectName("accent")
|
|
self.ek_both_btn.setFixedHeight(36)
|
|
self.ek_both_btn.setEnabled(False)
|
|
self.ek_both_btn.clicked.connect(self._ekika_import_both)
|
|
gen_row.addWidget(self.ek_both_btn)
|
|
layout.addLayout(gen_row)
|
|
|
|
# ── Preview ───────────────────────────────────────────────────────────
|
|
preview_label = QLabel("Preview:")
|
|
preview_label.setObjectName("fieldLabel")
|
|
layout.addWidget(preview_label)
|
|
|
|
self.ek_preview = QTextEdit()
|
|
self.ek_preview.setObjectName("aiOutput")
|
|
self.ek_preview.setReadOnly(True)
|
|
self.ek_preview.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
|
|
self.ek_preview.setPlaceholderText(
|
|
"Fill in the form above and click Generate Collection to preview.\n\n"
|
|
"No API key required — collection is generated instantly from the\n"
|
|
"EKIKA Odoo API Framework documentation."
|
|
)
|
|
self.ek_preview.setMaximumHeight(180)
|
|
layout.addWidget(self.ek_preview)
|
|
|
|
scroll.setWidget(w)
|
|
return scroll
|
|
|
|
def _on_ek_auth_changed(self, auth_type: str):
|
|
show_key = auth_type == "API Key"
|
|
show_user = auth_type in ("Basic Auth", "User Credentials")
|
|
show_pw = auth_type in ("Basic Auth", "User Credentials")
|
|
|
|
self._ek_ak_label.setVisible(show_key)
|
|
self.ek_api_key_input.setVisible(show_key)
|
|
# find the show checkbox (parent widget)
|
|
self._ek_user_label.setVisible(show_user)
|
|
self.ek_username.setVisible(show_user)
|
|
self._ek_pw_label.setVisible(show_pw)
|
|
self.ek_password.setVisible(show_pw)
|
|
|
|
def _ekika_generate(self):
|
|
instance_url = self.ek_instance_url.text().strip()
|
|
endpoint = self.ek_endpoint.text().strip()
|
|
api_kind = self.ek_api_kind.currentText()
|
|
auth_type = self.ek_auth_type.currentText()
|
|
models_raw = self.ek_models.text().strip()
|
|
models = [m.strip() for m in models_raw.split(",") if m.strip()]
|
|
operations = [op for op, cb in self.ek_op_checks.items() if cb.isChecked()]
|
|
col_name = self.ek_col_name.text().strip()
|
|
|
|
if not instance_url:
|
|
QMessageBox.warning(self, "Missing", "Enter the Odoo Instance URL.")
|
|
return
|
|
if not models:
|
|
QMessageBox.warning(self, "Missing", "Enter at least one model name.")
|
|
return
|
|
if not operations:
|
|
QMessageBox.warning(self, "Missing", "Select at least one operation.")
|
|
return
|
|
|
|
auth_creds = {
|
|
"api_key": self.ek_api_key_input.text().strip(),
|
|
"username": self.ek_username.text().strip(),
|
|
"password": self.ek_password.text().strip(),
|
|
}
|
|
|
|
self._result = generate_collection(
|
|
instance_url = instance_url,
|
|
endpoint = endpoint,
|
|
api_kind = api_kind,
|
|
auth_type = auth_type,
|
|
auth_creds = auth_creds,
|
|
models = models,
|
|
operations = operations,
|
|
collection_name = col_name,
|
|
)
|
|
|
|
eps = self._result["endpoints"]
|
|
envs = self._result["environment_variables"]
|
|
lines = [
|
|
f"✓ Collection: {self._result['collection_name']}",
|
|
f"✓ API Kind: {api_kind}",
|
|
f"✓ Auth: {auth_type}",
|
|
f"✓ Endpoints: {len(eps)} generated",
|
|
f"✓ Env vars: {list(envs.keys())}",
|
|
"",
|
|
"── Endpoints ─────────────────────────────────────",
|
|
]
|
|
for ep in eps:
|
|
lines.append(f" {ep['method']:<8} {ep['path']}")
|
|
|
|
self.ek_preview.setPlainText("\n".join(lines))
|
|
self.ek_import_btn.setEnabled(True)
|
|
self.ek_env_btn.setEnabled(True)
|
|
self.ek_both_btn.setEnabled(True)
|
|
self.status_label.setText(f"✓ {len(eps)} endpoint(s) ready — click Import to save")
|
|
|
|
def _ekika_import(self):
|
|
if not self._result:
|
|
return
|
|
self._do_import(self._result)
|
|
|
|
def _ekika_create_env(self):
|
|
if not self._result:
|
|
return
|
|
self._do_create_env(self._result)
|
|
|
|
def _ekika_import_both(self):
|
|
if not self._result:
|
|
return
|
|
self._do_import(self._result)
|
|
self._do_create_env(self._result)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# Tab 2 — Generic AI analysis (OpenAPI / any docs URL)
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _build_generic_tab(self) -> QWidget:
|
|
w = QWidget()
|
|
w.setObjectName("panelBody")
|
|
layout = QVBoxLayout(w)
|
|
layout.setContentsMargins(16, 12, 16, 12)
|
|
layout.setSpacing(10)
|
|
|
|
url_row = QHBoxLayout()
|
|
url_label = QLabel("Docs URL:")
|
|
url_label.setObjectName("fieldLabel")
|
|
url_label.setFixedWidth(80)
|
|
self.url_input = QLineEdit()
|
|
self.url_input.setObjectName("urlBar")
|
|
self.url_input.setPlaceholderText(
|
|
"https://api.example.com/openapi.json or https://docs.example.com"
|
|
)
|
|
self.url_input.returnPressed.connect(self._analyze)
|
|
self.analyze_btn = QPushButton("Analyze")
|
|
self.analyze_btn.setObjectName("accent")
|
|
self.analyze_btn.setFixedWidth(100)
|
|
self.analyze_btn.clicked.connect(self._analyze)
|
|
url_row.addWidget(url_label)
|
|
url_row.addWidget(self.url_input, 1)
|
|
url_row.addWidget(self.analyze_btn)
|
|
layout.addLayout(url_row)
|
|
|
|
ctx_row = QHBoxLayout()
|
|
base_label = QLabel("Base URL:")
|
|
base_label.setObjectName("fieldLabel")
|
|
base_label.setFixedWidth(80)
|
|
self.base_url_input = QLineEdit()
|
|
self.base_url_input.setPlaceholderText("https://myapi.example.com (optional)")
|
|
models_label = QLabel("Models:")
|
|
models_label.setObjectName("fieldLabel")
|
|
models_label.setFixedWidth(55)
|
|
self.models_input = QLineEdit()
|
|
self.models_input.setPlaceholderText("res.partner, sale.order (optional)")
|
|
ctx_row.addWidget(base_label)
|
|
ctx_row.addWidget(self.base_url_input, 2)
|
|
ctx_row.addSpacing(8)
|
|
ctx_row.addWidget(models_label)
|
|
ctx_row.addWidget(self.models_input, 3)
|
|
layout.addLayout(ctx_row)
|
|
|
|
paste_hint = QLabel("Or paste raw documentation / OpenAPI JSON / YAML:")
|
|
paste_hint.setObjectName("hintText")
|
|
layout.addWidget(paste_hint)
|
|
|
|
self.paste_editor = QTextEdit()
|
|
self.paste_editor.setObjectName("codeEditor")
|
|
self.paste_editor.setPlaceholderText("Paste OpenAPI JSON, Swagger YAML, or raw API docs…")
|
|
self.paste_editor.setMaximumHeight(110)
|
|
self.paste_editor.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
|
|
layout.addWidget(self.paste_editor)
|
|
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setRange(0, 0)
|
|
self.progress_bar.setFixedHeight(4)
|
|
self.progress_bar.setVisible(False)
|
|
layout.addWidget(self.progress_bar)
|
|
|
|
results_label = QLabel("Analysis Result:")
|
|
results_label.setObjectName("fieldLabel")
|
|
layout.addWidget(results_label)
|
|
|
|
self.result_view = QTextEdit()
|
|
self.result_view.setObjectName("aiOutput")
|
|
self.result_view.setReadOnly(True)
|
|
self.result_view.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 10))
|
|
self.result_view.setPlaceholderText(
|
|
"Results will appear here.\n\n"
|
|
"• OpenAPI/Swagger specs are parsed instantly (no API key needed)\n"
|
|
"• Other documentation is analyzed by Claude AI"
|
|
)
|
|
layout.addWidget(self.result_view, 1)
|
|
|
|
action_row = QHBoxLayout()
|
|
self.import_btn = QPushButton("Import Collection")
|
|
self.import_btn.setObjectName("accent")
|
|
self.import_btn.setFixedWidth(160)
|
|
self.import_btn.setEnabled(False)
|
|
self.import_btn.clicked.connect(self._generic_import)
|
|
|
|
self.env_btn = QPushButton("Create Environment")
|
|
self.env_btn.setFixedWidth(160)
|
|
self.env_btn.setEnabled(False)
|
|
self.env_btn.clicked.connect(self._generic_create_env)
|
|
|
|
self.both_btn = QPushButton("Import Both")
|
|
self.both_btn.setObjectName("accent")
|
|
self.both_btn.setFixedWidth(120)
|
|
self.both_btn.setEnabled(False)
|
|
self.both_btn.clicked.connect(self._generic_import_both)
|
|
|
|
action_row.addWidget(self.import_btn)
|
|
action_row.addWidget(self.env_btn)
|
|
action_row.addWidget(self.both_btn)
|
|
action_row.addStretch()
|
|
layout.addLayout(action_row)
|
|
|
|
return w
|
|
|
|
def _analyze(self):
|
|
url = self.url_input.text().strip()
|
|
raw_text = self.paste_editor.toPlainText().strip()
|
|
base_url = self.base_url_input.text().strip()
|
|
models_raw = self.models_input.text().strip()
|
|
models = [m.strip() for m in models_raw.split(",") if m.strip()]
|
|
|
|
if not url and not raw_text:
|
|
QMessageBox.warning(self, "Input Required", "Enter a URL or paste documentation text.")
|
|
return
|
|
|
|
self._generic_result = None
|
|
self.analyze_btn.setEnabled(False)
|
|
self.progress_bar.setVisible(True)
|
|
self.result_view.clear()
|
|
self._set_generic_action_buttons(False)
|
|
|
|
self._worker = AnalysisWorker(url=url, raw_text=raw_text,
|
|
base_url=base_url, models=models)
|
|
self._worker.progress.connect(lambda m: self.status_label.setText(m))
|
|
self._worker.finished.connect(self._on_generic_finished)
|
|
self._worker.error.connect(self._on_generic_error)
|
|
self._worker.start()
|
|
|
|
def _on_generic_finished(self, result: dict):
|
|
self._generic_result = result
|
|
self.analyze_btn.setEnabled(True)
|
|
self.progress_bar.setVisible(False)
|
|
self._set_generic_action_buttons(True)
|
|
|
|
source = result.pop("_source", "ai")
|
|
src_label = {"openapi": "OpenAPI spec (local)", "ai": "Claude AI"}.get(source, source)
|
|
endpoints = result.get("endpoints", [])
|
|
env_vars = result.get("environment_variables", {})
|
|
notes = result.get("notes", "")
|
|
|
|
lines = [
|
|
f"✓ Parsed via: {src_label}",
|
|
f"✓ Collection: {result.get('collection_name', 'Unnamed')}",
|
|
f"✓ Base URL: {result.get('base_url', '—')}",
|
|
f"✓ Auth type: {result.get('auth_type', 'none')}",
|
|
f"✓ Endpoints: {len(endpoints)} found",
|
|
f"✓ Env vars: {list(env_vars.keys()) or '—'}",
|
|
]
|
|
if notes:
|
|
lines += ["", "── Notes ─────────────────", notes]
|
|
lines += ["", "── Endpoints ─────────────────────────────────────"]
|
|
for ep in endpoints:
|
|
lines.append(f" {ep['method']:<8} {ep['path']} ({ep.get('name','')})")
|
|
if env_vars:
|
|
lines += ["", "── Environment Variables ──────────────────────────"]
|
|
for k, v in env_vars.items():
|
|
lines.append(f" {k} = {v!r}")
|
|
|
|
self.result_view.setPlainText("\n".join(lines))
|
|
self.status_label.setText(f"✓ Found {len(endpoints)} endpoint(s)")
|
|
|
|
def _on_generic_error(self, msg: str):
|
|
self.analyze_btn.setEnabled(True)
|
|
self.progress_bar.setVisible(False)
|
|
self.result_view.setPlainText(f"✗ Error:\n\n{msg}")
|
|
self.status_label.setText("Error — see results panel")
|
|
|
|
def _set_generic_action_buttons(self, enabled: bool):
|
|
self.import_btn.setEnabled(enabled)
|
|
self.env_btn.setEnabled(enabled)
|
|
self.both_btn.setEnabled(enabled)
|
|
|
|
def _generic_import(self):
|
|
if hasattr(self, "_generic_result") and self._generic_result:
|
|
self._do_import(self._generic_result)
|
|
|
|
def _generic_create_env(self):
|
|
if hasattr(self, "_generic_result") and self._generic_result:
|
|
self._do_create_env(self._generic_result)
|
|
|
|
def _generic_import_both(self):
|
|
if hasattr(self, "_generic_result") and self._generic_result:
|
|
self._do_import(self._generic_result)
|
|
self._do_create_env(self._generic_result)
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# Tab 3 — Settings
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _build_settings_tab(self) -> QWidget:
|
|
w = QWidget()
|
|
w.setObjectName("panelBody")
|
|
outer = QVBoxLayout(w)
|
|
outer.setContentsMargins(24, 20, 24, 20)
|
|
outer.setSpacing(16)
|
|
|
|
hint = QLabel(
|
|
"EKIKA AI Assistant uses Claude by Anthropic to analyze plain-text API documentation.\n"
|
|
"OpenAPI/Swagger specs and EKIKA Odoo Framework collections are generated locally — "
|
|
"no API key required for those."
|
|
)
|
|
hint.setObjectName("hintText")
|
|
hint.setWordWrap(True)
|
|
outer.addWidget(hint)
|
|
|
|
form = QFormLayout()
|
|
form.setSpacing(10)
|
|
|
|
self.api_key_input = QLineEdit()
|
|
self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
|
|
self.api_key_input.setPlaceholderText("sk-ant-…")
|
|
self.api_key_input.setText(ai_client.get_api_key())
|
|
show_key = QCheckBox("Show")
|
|
show_key.toggled.connect(lambda on: self.api_key_input.setEchoMode(
|
|
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
|
key_row = QHBoxLayout()
|
|
key_row.addWidget(self.api_key_input)
|
|
key_row.addWidget(show_key)
|
|
form.addRow("Anthropic API Key:", key_row)
|
|
outer.addLayout(form)
|
|
|
|
save_key_btn = QPushButton("Save API Key")
|
|
save_key_btn.setObjectName("accent")
|
|
save_key_btn.setFixedWidth(130)
|
|
save_key_btn.clicked.connect(self._save_api_key)
|
|
outer.addWidget(save_key_btn)
|
|
outer.addStretch()
|
|
|
|
info = QLabel(
|
|
"Get your key at console.anthropic.com\n"
|
|
"Keys are stored locally in the EKIKA database only."
|
|
)
|
|
info.setObjectName("hintText")
|
|
info.setWordWrap(True)
|
|
outer.addWidget(info)
|
|
|
|
return w
|
|
|
|
def _save_api_key(self):
|
|
ai_client.set_api_key(self.api_key_input.text().strip())
|
|
QMessageBox.information(self, "Saved", "Anthropic API key saved.")
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
# Shared import helpers
|
|
# ══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _do_import(self, result: dict):
|
|
col_name = result.get("collection_name", "AI Import")
|
|
endpoints = result.get("endpoints", [])
|
|
base_url = result.get("base_url", "")
|
|
|
|
col_id = storage.add_collection(col_name)
|
|
for ep in endpoints:
|
|
# Build full URL with {{base_url}} variable
|
|
path = ep.get("path", "")
|
|
if not path.startswith("http") and base_url:
|
|
url = f"{{{{base_url}}}}{path}"
|
|
else:
|
|
url = ep.get("url", path)
|
|
|
|
req = HttpRequest(
|
|
name = ep.get("name", ""),
|
|
method = ep.get("method", "GET"),
|
|
url = url,
|
|
headers = ep.get("headers", {}),
|
|
params = ep.get("params", {}),
|
|
body = ep.get("body", ""),
|
|
body_type = ep.get("body_type", "raw"),
|
|
content_type = ep.get("content_type", ""),
|
|
test_script = ep.get("test_script", ""),
|
|
)
|
|
storage.save_request(col_id, req)
|
|
|
|
self.collection_imported.emit()
|
|
QMessageBox.information(
|
|
self, "Collection Imported",
|
|
f"✓ Imported '{col_name}'\n"
|
|
f" {len(endpoints)} request(s) added to the sidebar."
|
|
)
|
|
|
|
def _do_create_env(self, result: dict):
|
|
env_vars = result.get("environment_variables", {})
|
|
col_name = result.get("collection_name", "AI Import")
|
|
env_name = f"{col_name} — Environment"
|
|
|
|
if not env_vars:
|
|
QMessageBox.information(self, "No Variables", "No environment variables detected.")
|
|
return
|
|
|
|
env = Environment(name=env_name, variables=env_vars)
|
|
env_id = storage.save_environment(env)
|
|
|
|
QMessageBox.information(
|
|
self, "Environment Created",
|
|
f"✓ Created environment '{env_name}'\n"
|
|
f" Variables: {', '.join(env_vars.keys())}"
|
|
)
|