"""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())}" )