diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65ab971 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +*.spec +*.db +*.sqlite +*.sqlite3 +.env +*.log +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a47b40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EKIKA.co + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0d3b28f..e30a098 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,391 @@ -# APIClient-Agent +# APIClient - Agent +> **AI-first API testing desktop client** — built with Python + PyQt6. +> Specialised for the [EKIKA Odoo API Framework](https://apps.odoo.com/apps/modules/19.0/api_framework), but works with any REST, GraphQL, or WebSocket API. + +--- + +## Features + +### Core API Testing +- **Multi-tab request editor** — work on multiple requests simultaneously, drag to reorder +- **All HTTP methods** — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS +- **Smart params & headers table** — per-row enable/disable checkboxes, 36 px comfortable rows, auto-expanding blank row +- **Body editor** — raw JSON/XML/text with syntax highlighting, **Format JSON** button, `application/vnd.api+json` support +- **Auth panel** — Bearer Token, Basic Auth, API Key (header or query) +- **Pre-request scripts** — Python executed before each request; access `pm.environment.get/set` +- **Test scripts** — assertions auto-run after every response; `pm.test(...)` / `expect(...)` DSL +- **Response viewer** — syntax-highlighted body, headers table, test results, search, copy, save +- **WebSocket client** — connect, send, receive, log messages +- **Mock server** — local HTTP mock with configurable routes + +### Collections & Environments +- **Collections sidebar** — import/export Postman Collection v2.1 JSON, cURL +- **Environment variables** — `{{base_url}}`, `{{api_key}}`, etc. resolved at send time; per-environment values +- **Collection runner** — run all requests in a collection, view pass/fail results +- **History** — every sent request automatically saved + +### AI Co-pilot (Claude-powered) +- **Persistent AI chat sidebar** — toggle with the `✦ AI` button or `Ctrl+Shift+A` +- **Full context awareness** — AI sees your current request (method, URL, headers, body, params, test scripts) and the last response (status, body, errors); secrets are automatically redacted +- **Streaming responses** — tokens stream in real time +- **One-click Apply** — AI suggestions come with **Apply Body**, **Apply Params**, **Apply Headers**, **Apply Test Script** buttons that set the values directly in the request editor +- **Multi-turn conversation** — full history maintained per session; Clear to reset +- **Quick actions** — Analyze, Fix Error, Gen Body, Write Tests, Auth Help, Explain Response +- **EKIKA Odoo collection generator** — generate complete collections for JSON-API, REST JSON, GraphQL, and Custom REST JSON without spending AI tokens; supports all auth types + +### EKIKA Odoo API Framework specialisation +- Generates full CRUD + Execute / Export / Report / Fields / Access-Rights endpoints per model +- Auth types: API Key (`x-api-key`), Basic Auth, User Credentials, OAuth2, JWT, Public +- Correct JSON-API body format out of the box: `{"data": {"type": "sale.order", "attributes": {...}}}` +- Automatic environment creation with `base_url`, `api_key`, tokens + +--- + +## Installation + +### Requirements +- Python 3.11+ +- Linux, macOS, or Windows + +```bash +git clone https://git.ekika.co/EKIKA.co/APIClient-Agent.git +cd APIClient-Agent + +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +pip install -r requirements.txt +python main.py +``` + +### requirements.txt +``` +PyQt6>=6.6.0 +httpx>=0.27.0 +websockets>=12.0 +anthropic>=0.25.0 +pyyaml>=6.0 +pyinstaller>=6.0.0 +``` + +--- + +## Quick Start + +### 1 — Send your first request + +1. Launch the app: `python main.py` +2. Type a URL in the bar, e.g. `https://jsonplaceholder.typicode.com/todos/1` +3. Press **Send** (or `Ctrl+Enter`) +4. See the JSON response with syntax highlighting in the bottom panel + +### 2 — Use environment variables + +1. Click **Manage** → **New Environment** → name it `My API` +2. Add variables: + ``` + base_url = https://api.example.com + api_key = your-secret-key + ``` +3. Select the environment in the top bar +4. In the URL bar, type `{{base_url}}/v1/users` +5. In Headers, add `Authorization: Bearer {{api_key}}` +6. Variables are resolved automatically at send time + +### 3 — Import a collection + +**From Postman export:** +1. `File → Import…` +2. Paste Postman Collection v2.1 JSON or drop the file + +**From cURL:** +``` +File → Import… → paste: +curl -X POST https://api.example.com/v1/orders \ + -H "Content-Type: application/json" \ + -d '{"product_id": 42, "qty": 1}' +``` + +**From OpenAPI spec:** +1. `Tools → AI Assistant → Import from Docs` +2. Paste the OpenAPI JSON/YAML URL — parsed instantly, no AI tokens used + +--- + +## EKIKA Odoo API Framework — Complete Example + +### Generate a collection in 30 seconds + +1. Open **Tools → AI Assistant** (or click `✦ AI` → `AI Assistant` in the menu) +2. Select the **EKIKA Odoo API** tab +3. Fill in: + + | Field | Example | + |---|---| + | Instance URL | `https://mycompany.odoo.com` | + | API Endpoint | `/user-jsonapi-apikey` | + | API Kind | `JSON-API` | + | Auth Type | `API Key` | + | API Key | `EwKCljvZoHXsaGlxxvCHt1h4SvWLpuWW` | + | Models | `sale.order, res.partner, account.move` | + | Operations | ✓ List, Get, Create, Update, Delete | + +4. Click **Generate Collection** — preview appears instantly +5. Click **Import Both** — collection + environment are saved + +This generates the following requests for each model with zero AI tokens: + +``` +GET {{base_url}}/user-jsonapi-apikey/sale.order +GET {{base_url}}/user-jsonapi-apikey/sale.order/{{id}} +POST {{base_url}}/user-jsonapi-apikey/sale.order +PATCH {{base_url}}/user-jsonapi-apikey/sale.order/{{id}} +DELETE {{base_url}}/user-jsonapi-apikey/sale.order/{{id}} +``` + +### Sending a JSON-API request + +The generated **Create sale.order** request body: +```json +{ + "data": { + "type": "sale.order", + "attributes": { + "name": "New Order" + } + } +} +``` + +Headers sent automatically: +``` +x-api-key: EwKCljvZoHXsaGlxxvCHt1h4SvWLpuWW +Content-Type: application/vnd.api+json +Accept: application/vnd.api+json +``` + +### Filtering and pagination (List endpoint) + +The **List sale.order** request includes query params: +``` +page[number] = 1 +page[size] = 10 +fields[sale.order] = id,name,display_name,partner_id +sort = -id +``` + +To filter by customer, open the AI chat sidebar and ask: +> *"Add a filter param to get only sale orders for partner ID 7"* + +AI responds with an apply block you can apply in one click. + +### GraphQL example + +Select **API Kind: GraphQL**. The generator creates: + +**List sale.order (GraphQL)** +```json +{ + "query": "query {\n sale_order(\n filter: \"\"\n pageSize: 10\n pageNumber: 1\n ) {\n id\n name\n display_name\n }\n}" +} +``` + +**Create sale.order (GraphQL)** +```json +{ + "query": "mutation {\n create_sale_order(\n attributes: {\n name: \"New Record\"\n }\n ) {\n id\n name\n }\n}" +} +``` + +--- + +## AI Chat Co-pilot — Example Session + +Click **✦ AI** in the top bar to open the sidebar. The AI automatically knows what request you have open and the last response. + +### Debugging a 401 error + +``` +You: I'm getting 401 Unauthorized on all my Odoo API requests. + +AI: A 401 on the EKIKA JSON-API endpoint means the x-api-key header is + missing, empty, or incorrect. + + Checklist: + 1. Confirm your environment has api_key set (Tools → Environments) + 2. Confirm the request Headers tab includes x-api-key: {{api_key}} + 3. In Odoo, verify the API key is still active under your user's + API Framework settings + + Here are the correct headers to apply: + + ┌─ apply:headers ──────────────────────────────────┐ + │ x-api-key: {{api_key}} │ + │ Accept: application/vnd.api+json │ + └──────────────────────────────────────────────────┘ + [ Apply Headers to Request ] +``` + +Click **Apply Headers to Request** — headers are set immediately and the Headers tab opens. + +### Generating a body for a complex model + +``` +You: Generate a body to create an invoice (account.move) for partner ID 14, + with one line for product ID 7, quantity 3, price 150. + +AI: Here is a JSON-API compliant body for creating an account.move: + + ┌─ apply:body ─────────────────────────────────────┐ + │ { │ + │ "data": { │ + │ "type": "account.move", │ + │ "attributes": { │ + │ "move_type": "out_invoice", │ + │ "partner_id": 14, │ + │ "invoice_line_ids": [[0, 0, { │ + │ "product_id": 7, │ + │ "quantity": 3, │ + │ "price_unit": 150.0 │ + │ }]] │ + │ } │ + │ } │ + │ } │ + └──────────────────────────────────────────────────┘ + [ Apply Body to Request ] +``` + +### Writing test scripts + +``` +You: Write tests to verify a successful JSON-API list response. + +AI: ┌─ apply:test ─────────────────────────────────────┐ + │ pm.test('Status 200', lambda: │ + │ pm.response.to_have_status(200)) │ + │ pm.test('Has data array', lambda: │ + │ expect(pm.response.json()).to_have_key('data'))│ + │ pm.test('Data is list', lambda: │ + │ expect(pm.response.json()['data']).to_be_list())│ + │ pm.test('Response time < 2s', lambda: │ + │ expect(pm.response.response_time).to_be_below(2000))│ + └──────────────────────────────────────────────────┘ + [ Apply Test Script to Request ] +``` + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|---|---| +| `Ctrl+Enter` | Send request | +| `Ctrl+T` | New tab | +| `Ctrl+W` | Close tab | +| `Ctrl+S` | Save to collection | +| `Ctrl+F` | Search requests | +| `Ctrl+E` | Manage environments | +| `Ctrl+Shift+A` | Toggle AI chat sidebar | +| `Ctrl+Shift+F` | Format JSON body | +| `Escape` | Cancel in-flight request | +| `Ctrl+Q` | Quit | + +--- + +## Project Structure + +``` +APIClient-Agent/ +├── main.py # Entry point +├── requirements.txt +├── LICENSE +├── app/ +│ ├── models.py # HttpRequest, HttpResponse, Environment +│ ├── core/ +│ │ ├── storage.py # SQLite persistence (collections, environments, history) +│ │ ├── http_client.py # httpx-based request engine, variable resolution +│ │ ├── ai_client.py # Claude API — collection generation from docs +│ │ ├── ai_chat.py # Claude API — multi-turn conversational co-pilot +│ │ ├── openapi_parser.py # OpenAPI 3.x / Swagger 2.0 local parser +│ │ ├── ekika_odoo_generator.py# EKIKA Odoo framework collection generator +│ │ ├── test_runner.py # pm.test / expect assertion engine +│ │ ├── mock_server.py # Local HTTP mock server +│ │ ├── code_gen.py # Code generation (curl, Python, JS, etc.) +│ │ ├── exporter.py # Postman Collection v2.1 export +│ │ └── importer.py # Postman Collection / cURL import +│ └── ui/ +│ ├── main_window.py # Main window, splitter layout, env bar +│ ├── theme.py # Central QSS stylesheet engine (dark/light) +│ ├── request_panel.py # URL bar, params/headers/body/auth/tests editor +│ ├── response_panel.py # Response viewer with status badge +│ ├── tabs_manager.py # Multi-tab request manager +│ ├── sidebar.py # Collections tree sidebar +│ ├── ai_panel.py # AI Assistant dialog (collection generator) +│ ├── ai_chat_panel.py # AI chat sidebar (co-pilot) +│ ├── environment_dialog.py # Environment manager +│ ├── collection_runner.py # Collection runner +│ ├── websocket_panel.py # WebSocket client +│ ├── mock_server_panel.py # Mock server UI +│ ├── import_dialog.py # Import dialog +│ ├── code_gen_dialog.py # Code generation dialog +│ ├── search_dialog.py # Request search +│ └── highlighter.py # JSON syntax highlighter +``` + +--- + +## Configuration + +Settings are stored in an SQLite database at `~/.apiclient_agent/data.db` (created automatically). + +### Anthropic API Key (for AI features) + +1. Get a key at [console.anthropic.com](https://console.anthropic.com) +2. In the app: `Tools → AI Assistant → Settings tab` +3. Paste the key and click **Save API Key** + +The key is stored locally in the SQLite database only — never transmitted except to the Anthropic API. + +--- + +## SSL / TLS Notes + +Some servers (especially demo/development instances) use self-signed certificates or wildcard certificates that don't match the exact hostname. If you see: + +``` +SSL certificate error — could not connect to https://... +Tip: disable SSL verification in the request Settings tab. +``` + +Open the **Settings** tab in the request editor and uncheck **Verify SSL certificate**. + +--- + +## Building a Standalone Executable + +```bash +pyinstaller --onefile --windowed \ + --name "APIClient-Agent" \ + --add-data "app:app" \ + main.py +``` + +The executable is produced in `dist/APIClient-Agent`. + +--- + +## Contributing + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Commit your changes: `git commit -m "Add my feature"` +4. Push and open a pull request + +Please keep UI styling in `theme.py` using `setObjectName()` selectors — never inline `setStyleSheet()` for static colors. + +--- + +## License + +[MIT License](LICENSE) — Copyright (c) 2026 EKIKA.co diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/ai_chat.py b/app/core/ai_chat.py new file mode 100644 index 0000000..4acbdf2 --- /dev/null +++ b/app/core/ai_chat.py @@ -0,0 +1,189 @@ +"""APIClient - Agent — Conversational AI co-pilot core.""" +import json +import re +import httpx +from app.core import storage + +MAX_HISTORY_MSGS = 30 + +_SYSTEM_PROMPT = """\ +You are APIClient - Agent, an expert AI API testing co-pilot embedded in the APIClient - Agent desktop application. + +Your responsibilities: +• Help craft and debug HTTP requests (REST, JSON-API, GraphQL, Odoo APIs) +• Analyze HTTP responses — status codes, headers, body structure, errors +• Specialize in the EKIKA Odoo API Framework: + - JSON-API (Content-Type: application/vnd.api+json) — body format: {"data": {"type": model, "attributes": {...}}} + - REST JSON (Content-Type: application/json) + - GraphQL (POST with {"query": "..."} body) + - Auth: x-api-key header, Basic Auth, OAuth2 Bearer, JWT Bearer + - Odoo models: sale.order, res.partner, account.move, product.template, stock.picking, etc. +• Generate request bodies, params, headers, and test scripts +• Explain SSL/TLS errors, auth failures, and connection issues +• Help with environment variable setup ({{base_url}}, {{api_key}}, etc.) + +When you produce content the user should apply to their request, use EXACTLY these fences: + +```apply:body +{ "json": "here" } +``` + +```apply:params +page=1 +limit=10 +fields=id,name +``` + +```apply:headers +x-api-key: {{api_key}} +Accept: application/vnd.api+json +``` + +```apply:test +pm.test('Status 200', lambda: pm.response.to_have_status(200)) +pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data')) +``` + +Rules: +- Be concise and actionable — explain WHY, not just WHAT +- If you add apply blocks, briefly explain what each block does +- For JSON-API responses: data is in response.data, errors in response.errors +- For SSL cert errors: tell user to uncheck SSL verification in the Settings tab +- For 401/403: check x-api-key header and environment variable values +- For unresolved {{variable}}: tell user to set up environment via Tools → Environments +""" + + +class AIError(Exception): + pass + + +def get_api_key() -> str: + return storage.get_setting("anthropic_api_key", "") + + +def build_context(req=None, resp=None, env_vars: dict = None) -> str: + """Build compact context string for system prompt injection.""" + parts = [] + + if req: + parts.append(f"METHOD: {req.method}") + parts.append(f"URL: {req.url}") + if req.headers: + safe = { + k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "auth", "pass"]) else v) + for k, v in req.headers.items() + } + parts.append(f"HEADERS: {json.dumps(safe)}") + if req.params: + parts.append(f"PARAMS: {json.dumps(req.params)}") + if req.body: + preview = req.body[:1500] + ("…" if len(req.body) > 1500 else "") + parts.append(f"BODY ({req.body_type}):\n{preview}") + if req.content_type: + parts.append(f"CONTENT-TYPE: {req.content_type}") + if req.test_script: + parts.append(f"TEST SCRIPT:\n{req.test_script}") + + if resp: + if resp.error: + parts.append(f"\nRESPONSE ERROR: {resp.error}") + else: + parts.append(f"\nRESPONSE: {resp.status} {resp.reason} ({resp.elapsed_ms:.0f} ms)") + ct = (resp.headers or {}).get("content-type", (resp.headers or {}).get("Content-Type", "")) + if ct: + parts.append(f"RESPONSE CONTENT-TYPE: {ct}") + if resp.body: + preview = resp.body[:4000] + ("…" if len(resp.body) > 4000 else "") + parts.append(f"RESPONSE BODY:\n{preview}") + + if env_vars: + safe_vars = { + k: ("***" if any(s in k.lower() for s in ["key", "token", "secret", "password", "pass"]) else v) + for k, v in env_vars.items() + } + parts.append(f"\nENVIRONMENT VARIABLES: {json.dumps(safe_vars)}") + + return "\n".join(parts) + + +def stream_chat(messages: list[dict], context: str = "", chunk_cb=None) -> str: + """ + Stream a multi-turn conversation to Claude. + messages: list of {"role": "user"|"assistant", "content": str} + chunk_cb(chunk: str): called for each streamed text chunk + Returns full assistant response text. + Raises AIError on failure. + """ + api_key = get_api_key() + if not api_key: + raise AIError( + "No Anthropic API key configured.\n" + "Go to Tools → AI Assistant → Settings to add your key." + ) + + system = _SYSTEM_PROMPT + if context: + system += f"\n\n## Current Request Context\n{context}" + + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + payload = { + "model": "claude-opus-4-6", + "max_tokens": 2048, + "system": system, + "messages": messages[-MAX_HISTORY_MSGS:], + } + + full_text = "" + try: + with httpx.stream( + "POST", + "https://api.anthropic.com/v1/messages", + headers=headers, + json=payload, + timeout=60.0, + ) as resp: + if resp.status_code != 200: + body = resp.read().decode() + raise AIError(f"Claude API error {resp.status_code}: {body[:400]}") + + for line in resp.iter_lines(): + if not line.startswith("data:"): + continue + data_str = line[5:].strip() + if data_str == "[DONE]": + break + try: + event = json.loads(data_str) + delta = event.get("delta", {}) + if delta.get("type") == "text_delta": + chunk = delta.get("text", "") + full_text += chunk + if chunk_cb: + chunk_cb(chunk) + except json.JSONDecodeError: + continue + + except httpx.TimeoutException: + raise AIError("Request timed out. Try a shorter question or check your connection.") + except httpx.RequestError as e: + raise AIError(f"Network error: {e}") + + return full_text + + +def parse_apply_blocks(text: str) -> list[dict]: + """Parse ```apply:TYPE ... ``` blocks from AI response text.""" + blocks = [] + for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL): + blocks.append({"type": m.group(1), "content": m.group(2).strip()}) + return blocks + + +def strip_apply_blocks(text: str) -> str: + """Remove apply fences from display text, leaving the explanation.""" + return re.sub(r"```apply:\w+\n.*?```", "", text, flags=re.DOTALL).strip() diff --git a/app/core/ai_client.py b/app/core/ai_client.py new file mode 100644 index 0000000..3a87a84 --- /dev/null +++ b/app/core/ai_client.py @@ -0,0 +1,219 @@ +"""APIClient - Agent — Claude AI integration.""" +import json +import re +import httpx + +from app.core import storage + +# Max characters to send to Claude (roughly 60k tokens) +_MAX_CONTENT_CHARS = 80_000 + + +def _strip_html(html: str) -> str: + """Strip HTML tags and collapse whitespace for cleaner AI input.""" + # Remove script/style blocks entirely + html = re.sub(r"<(script|style)[^>]*>.*?", " ", html, flags=re.S | re.I) + # Remove HTML tags + html = re.sub(r"<[^>]+>", " ", html) + # Decode common entities + html = (html + .replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", '"').replace("'", "'").replace(" ", " ")) + # Collapse whitespace + html = re.sub(r"\s{3,}", "\n\n", html) + return html.strip() + +_SYSTEM_PROMPT = """\ +You are an expert API documentation analyzer for APIClient - Agent. +Given API documentation (which may be a spec, a web page, framework docs, or raw text), +extract or infer all useful API endpoints and return structured JSON. + +Return ONLY valid JSON — no markdown, no commentary, just the JSON object. + +Schema: +{ + "collection_name": "API Name", + "base_url": "https://api.example.com", + "auth_type": "bearer|basic|apikey|none", + "doc_type": "openapi|rest|framework|graphql|unknown", + "endpoints": [ + { + "name": "Human readable name", + "method": "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS", + "path": "/v1/resource", + "description": "What this endpoint does", + "headers": {"Header-Name": "value or {{variable}}"}, + "params": {"param_name": "example_value"}, + "body": "", + "body_type": "raw|form-urlencoded|form-data", + "content_type": "application/json", + "test_script": "pm.test('Status 200', lambda: pm.response.to_have_status(200))" + } + ], + "environment_variables": { + "base_url": "https://api.example.com", + "token": "" + }, + "notes": "Any important setup notes for the user" +} + +Rules: +- Use {{variable_name}} for ALL dynamic values (tokens, IDs, model names, etc.) +- Always output realistic example values for query params and bodies +- Generate a test_script for every endpoint +- Detect auth pattern and add the correct header to every endpoint +- If the documentation is a FRAMEWORK (e.g. it documents URL patterns like + {domain}/{endpoint}/{model} rather than fixed paths), do the following: + * Set doc_type to "framework" + * Use {{base_url}} as the domain placeholder + * Use {{model}} as a placeholder for the resource/model name + * Generate one endpoint per HTTP method the framework supports (GET list, + GET single, POST create, PATCH update, DELETE delete, plus any special ops) + * Set notes explaining that the user must replace {{model}} with actual model names + e.g. "res.partner", "sale.order", "product.template" etc. +- If it is a GRAPHQL API, generate a POST /graphql endpoint with example query body +- If auth options are shown (API key, OAuth, Basic), include ALL variants as separate + environment variables so the user can choose +- Keep paths clean — strip trailing slashes, normalise to lowercase +""" + + +class AIError(Exception): + pass + + +def get_api_key() -> str: + return storage.get_setting("anthropic_api_key", "") + + +def set_api_key(key: str): + storage.set_setting("anthropic_api_key", key.strip()) + + +def analyze_docs(content: str, progress_cb=None) -> dict: + """ + Send API documentation content to Claude and return parsed collection dict. + progress_cb(message: str) is called with status updates during streaming. + Raises AIError on failure. + """ + api_key = get_api_key() + if not api_key: + raise AIError("No Anthropic API key configured. Go to Tools → AI Assistant → Settings.") + + if progress_cb: + progress_cb("Sending to Claude AI…") + + headers = { + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + payload = { + "model": "claude-opus-4-6", + "max_tokens": 8192, + "system": _SYSTEM_PROMPT, + "messages": [{"role": "user", "content": content}], + } + + full_text = "" + try: + with httpx.stream( + "POST", + "https://api.anthropic.com/v1/messages", + headers=headers, + json=payload, + timeout=120.0, + ) as resp: + if resp.status_code != 200: + body = resp.read().decode() + raise AIError(f"API error {resp.status_code}: {body[:300]}") + + for line in resp.iter_lines(): + if not line.startswith("data:"): + continue + data_str = line[5:].strip() + if data_str == "[DONE]": + break + try: + event = json.loads(data_str) + delta = event.get("delta", {}) + if delta.get("type") == "text_delta": + chunk = delta.get("text", "") + full_text += chunk + if progress_cb and len(full_text) % 500 < len(chunk): + progress_cb(f"Receiving response… ({len(full_text)} chars)") + except json.JSONDecodeError: + continue + + except httpx.TimeoutException: + raise AIError("Request timed out. The documentation may be too large.") + except httpx.RequestError as e: + raise AIError(f"Network error: {e}") + + if progress_cb: + progress_cb("Parsing AI response…") + + return _parse_ai_response(full_text) + + +def _parse_ai_response(text: str) -> dict: + """Extract and validate the JSON from the AI response.""" + text = text.strip() + + # Strip markdown code fences if present + if text.startswith("```"): + lines = text.split("\n") + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + + try: + data = json.loads(text) + except json.JSONDecodeError: + # Try to find JSON object in the text + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + try: + data = json.loads(text[start:end]) + except json.JSONDecodeError: + raise AIError("AI returned invalid JSON. Try again or simplify the documentation.") + else: + raise AIError("AI response did not contain a JSON object.") + + # Validate required keys + if "endpoints" not in data: + raise AIError("AI response missing 'endpoints' key.") + + return data + + +def fetch_url_content(url: str) -> str: + """Fetch content from a URL, strip HTML if needed, and truncate if too large.""" + try: + resp = httpx.get(url, follow_redirects=True, timeout=30.0, headers={ + "User-Agent": "EKIKA-API-Client/2.0 (documentation-fetcher)", + "Accept": "application/json, text/yaml, text/html, */*", + }) + resp.raise_for_status() + except httpx.HTTPStatusError as e: + raise AIError(f"HTTP {e.response.status_code} fetching URL.") + except httpx.RequestError as e: + raise AIError(f"Could not fetch URL: {e}") + + ct = resp.headers.get("content-type", "") + text = resp.text + + # If HTML page — strip tags for cleaner AI input + if "html" in ct and not _looks_like_spec(text): + text = _strip_html(text) + + # Truncate if too large + if len(text) > _MAX_CONTENT_CHARS: + text = text[:_MAX_CONTENT_CHARS] + "\n\n[Content truncated for length]" + + return text + + +def _looks_like_spec(text: str) -> bool: + """Quick check: is this likely a JSON/YAML OpenAPI spec?""" + t = text.lstrip() + return t.startswith("{") or t.startswith("openapi:") or t.startswith("swagger:") diff --git a/app/core/code_gen.py b/app/core/code_gen.py new file mode 100644 index 0000000..4685863 --- /dev/null +++ b/app/core/code_gen.py @@ -0,0 +1,163 @@ +"""APIClient - Agent — Code snippet generators.""" +import json +from urllib.parse import urlencode + +from app.models import HttpRequest + + +def _qs(req: HttpRequest) -> str: + if not req.params: + return req.url + sep = "&" if "?" in req.url else "?" + return req.url + sep + urlencode(req.params) + + +def to_curl(req: HttpRequest) -> str: + parts = [f"curl -X {req.method}"] + parts.append(f' "{_qs(req)}"') + for k, v in req.headers.items(): + parts.append(f" -H '{k}: {v}'") + if req.body: + escaped = req.body.replace("'", r"'\''") + parts.append(f" -d '{escaped}'") + if not req.ssl_verify: + parts.append(" --insecure") + return " \\\n".join(parts) + + +def to_python_requests(req: HttpRequest) -> str: + headers = dict(req.headers) + if req.body and "Content-Type" not in headers: + headers["Content-Type"] = "application/json" + + lines = ["import requests", ""] + lines.append(f'url = "{req.url}"') + if headers: + lines.append(f"headers = {json.dumps(headers, indent=4)}") + if req.params: + lines.append(f"params = {json.dumps(req.params, indent=4)}") + lines.append("") + + call_args = ["url"] + if headers: + call_args.append("headers=headers") + if req.params: + call_args.append("params=params") + if req.body: + lines.append(f"payload = {json.dumps(req.body)}") + call_args.append("data=payload") + if not req.ssl_verify: + call_args.append("verify=False") + + lines.append(f"response = requests.{req.method.lower()}({', '.join(call_args)})") + lines.append("") + lines.append("print(response.status_code)") + lines.append("print(response.json())") + return "\n".join(lines) + + +def to_python_httpx(req: HttpRequest) -> str: + headers = dict(req.headers) + lines = ["import httpx", ""] + lines.append(f'url = "{req.url}"') + if headers: + lines.append(f"headers = {json.dumps(headers, indent=4)}") + if req.params: + lines.append(f"params = {json.dumps(req.params, indent=4)}") + lines.append("") + + call_args = ["url"] + if headers: + call_args.append("headers=headers") + if req.params: + call_args.append("params=params") + if req.body: + lines.append(f"payload = {json.dumps(req.body)}") + call_args.append("content=payload.encode()") + if not req.ssl_verify: + call_args.append("verify=False") + + lines.append("with httpx.Client() as client:") + lines.append(f" response = client.{req.method.lower()}({', '.join(call_args)})") + lines.append(" print(response.status_code)") + lines.append(" print(response.json())") + return "\n".join(lines) + + +def to_javascript_fetch(req: HttpRequest) -> str: + options: dict = {"method": req.method} + if req.headers: + options["headers"] = req.headers + if req.body: + options["body"] = req.body + + lines = [ + f'const response = await fetch("{_qs(req)}", {json.dumps(options, indent=2)});', + "", + "const data = await response.json();", + "console.log(response.status, data);", + ] + return "\n".join(lines) + + +def to_javascript_axios(req: HttpRequest) -> str: + config: dict = {} + if req.headers: + config["headers"] = req.headers + if req.params: + config["params"] = req.params + if req.body: + config["data"] = req.body + + lines = ["const axios = require('axios');", ""] + if config: + lines.append(f"const config = {json.dumps(config, indent=2)};") + lines.append("") + lines.append(f'const response = await axios.{req.method.lower()}("{req.url}", config);') + else: + lines.append(f'const response = await axios.{req.method.lower()}("{req.url}");') + lines.append("console.log(response.status, response.data);") + return "\n".join(lines) + + +def to_ruby(req: HttpRequest) -> str: + lines = [ + "require 'net/http'", + "require 'uri'", + "require 'json'", + "", + f'uri = URI.parse("{_qs(req)}")', + f"http = Net::HTTP.new(uri.host, uri.port)", + "http.use_ssl = uri.scheme == 'https'", + ] + if not req.ssl_verify: + lines.append("http.verify_mode = OpenSSL::SSL::VERIFY_NONE") + lines.append("") + klass_map = { + "GET": "Net::HTTP::Get", "POST": "Net::HTTP::Post", + "PUT": "Net::HTTP::Put", "PATCH": "Net::HTTP::Patch", + "DELETE": "Net::HTTP::Delete", "HEAD": "Net::HTTP::Head", + } + klass = klass_map.get(req.method, "Net::HTTP::Get") + lines.append(f"request = {klass}.new(uri.request_uri)") + for k, v in req.headers.items(): + lines.append(f'request["{k}"] = "{v}"') + if req.body: + lines.append(f"request.body = {json.dumps(req.body)}") + lines += [ + "", + "response = http.request(request)", + "puts response.code", + "puts response.body", + ] + return "\n".join(lines) + + +GENERATORS = { + "curl": to_curl, + "Python (requests)": to_python_requests, + "Python (httpx)": to_python_httpx, + "JavaScript (fetch)": to_javascript_fetch, + "JavaScript (axios)": to_javascript_axios, + "Ruby (Net::HTTP)": to_ruby, +} diff --git a/app/core/ekika_odoo_generator.py b/app/core/ekika_odoo_generator.py new file mode 100644 index 0000000..8bedca5 --- /dev/null +++ b/app/core/ekika_odoo_generator.py @@ -0,0 +1,613 @@ +"""EKIKA Odoo API Framework — Direct collection generator. + +Generates complete Postman-style collections from the EKIKA api_framework module +without requiring any AI API calls. All URL patterns, body formats, auth headers, +and special operations are derived directly from the framework documentation. + +Supported API kinds: JSON-API, REST JSON, GraphQL, Custom REST JSON +Supported auth types: API Key, Basic Auth, User Credentials, OAuth2, JWT, Public +""" +import json +import re + + +# ── Constants ───────────────────────────────────────────────────────────────── + +API_KINDS = ["JSON-API", "REST JSON", "GraphQL", "Custom REST JSON"] + +AUTH_TYPES = [ + "API Key", + "Basic Auth", + "User Credentials", + "OAuth2", + "JWT", + "Public", +] + +OPERATIONS = [ + "List Records", + "Get Single Record", + "Create Record", + "Update Record", + "Delete Record", + "Execute Method", + "Export to Excel", + "Generate Report", + "Get Fields", + "Check Access Rights", +] + +# Content-Type per API kind +_CT = { + "JSON-API": "application/vnd.api+json", + "REST JSON": "application/json", + "GraphQL": "application/json", + "Custom REST JSON": "application/json", +} + + +# ── Auth header builder ─────────────────────────────────────────────────────── + +def _auth_headers(auth_type: str) -> dict: + if auth_type == "API Key": + return {"x-api-key": "{{api_key}}"} + if auth_type == "Basic Auth": + return {"Authorization": "Basic {{base64_user_pass}}"} + if auth_type == "User Credentials": + return {"username": "{{username}}", "password": "{{password}}"} + if auth_type == "OAuth2": + return {"Authorization": "Bearer {{access_token}}"} + if auth_type == "JWT": + return {"Authorization": "Bearer {{jwt_token}}"} + return {} # Public + + +def _env_vars(instance_url: str, auth_type: str, extra: dict = None) -> dict: + base = instance_url.rstrip("/") + env: dict = {"base_url": base} + + if auth_type == "API Key": + env["api_key"] = extra.get("api_key", "") if extra else "" + elif auth_type == "Basic Auth": + env["base64_user_pass"] = extra.get("base64_user_pass", "") if extra else "" + env["username"] = extra.get("username", "") if extra else "" + env["password"] = extra.get("password", "") if extra else "" + elif auth_type == "User Credentials": + env["username"] = extra.get("username", "") if extra else "" + env["password"] = extra.get("password", "") if extra else "" + elif auth_type == "OAuth2": + env["access_token"] = extra.get("access_token", "") if extra else "" + env["refresh_token"] = "" + env["client_id"] = "" + env["client_secret"] = "" + elif auth_type == "JWT": + env["jwt_token"] = extra.get("jwt_token", "") if extra else "" + + return env + + +def _clean_endpoint(endpoint: str) -> str: + """Normalise endpoint slug — ensure leading slash, strip trailing slash.""" + ep = endpoint.strip().strip("/") + return f"/{ep}" if ep else "/api" + + +def _model_gql(model: str) -> str: + """Convert 'sale.order' → 'sale_order' for GraphQL field names.""" + return model.replace(".", "_") + + +# ── Request builders ────────────────────────────────────────────────────────── + +def _jsonapi_test(status: int = 200) -> str: + return ( + f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n" + f"pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data'))" + ) + + +def _rest_test(status: int = 200) -> str: + return ( + f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n" + f"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())" + ) + + +def _build_jsonapi_endpoints(base_ep: str, model: str, headers: dict, + operations: list[str]) -> list[dict]: + ct = _CT["JSON-API"] + ep_path = f"{base_ep}/{model}" + eps = [] + + if "List Records" in operations: + eps.append({ + "name": f"List {model}", + "method": "GET", + "path": ep_path, + "headers": {**headers, "Accept": ct}, + "params": { + "page[number]": "1", + "page[size]": "10", + f"fields[{model}]": "id,name,display_name", + "sort": "id", + }, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _jsonapi_test(200), + "description": f"Fetch paginated list of {model} records with field selection, sorting and filtering.", + }) + + if "Get Single Record" in operations: + eps.append({ + "name": f"Get {model} by ID", + "method": "GET", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers, "Accept": ct}, + "params": {f"fields[{model}]": "id,name,display_name"}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _jsonapi_test(200), + "description": f"Fetch a single {model} record by its database ID.", + }) + + if "Create Record" in operations: + body = json.dumps({ + "data": { + "type": model, + "attributes": {"name": f"New {model.split('.')[-1].replace('_', ' ').title()}"}, + } + }, indent=2) + eps.append({ + "name": f"Create {model}", + "method": "POST", + "path": ep_path, + "headers": {**headers, "Content-Type": ct, "Accept": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _jsonapi_test(201), + "description": f"Create a new {model} record.", + }) + + if "Update Record" in operations: + body = json.dumps({ + "data": { + "type": model, + "id": "{{id}}", + "attributes": {"name": "Updated Name"}, + } + }, indent=2) + eps.append({ + "name": f"Update {model}", + "method": "PATCH", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers, "Content-Type": ct, "Accept": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _jsonapi_test(200), + "description": f"Update an existing {model} record by ID.", + }) + + if "Delete Record" in operations: + eps.append({ + "name": f"Delete {model}", + "method": "DELETE", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers, "Accept": ct}, + "params": {}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _jsonapi_test(200), + "description": f"Delete a {model} record by ID.", + }) + + if "Execute Method" in operations: + body = json.dumps({ + "data": { + "type": model, + "id": "{{id}}", + "attributes": { + "method": "action_confirm", + "args": [], + "kwargs": {}, + }, + } + }, indent=2) + eps.append({ + "name": f"Execute Method on {model}", + "method": "POST", + "path": f"{ep_path}/{{{{id}}}}/execute", + "headers": {**headers, "Content-Type": ct, "Accept": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"Execute an ORM method on a {model} record. Change 'action_confirm' to any valid method name.", + }) + + if "Export to Excel" in operations: + body = json.dumps({ + "data": { + "type": model, + "attributes": { + "fields": ["id", "name", "display_name"], + "ids": [], + }, + } + }, indent=2) + eps.append({ + "name": f"Export {model} to Excel", + "method": "POST", + "path": f"{base_ep}/export", + "headers": {**headers, "Content-Type": ct, "Accept": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": "Returns a Base64-encoded Excel file with the specified fields.", + }) + + if "Generate Report" in operations: + body = json.dumps({ + "data": { + "type": model, + "attributes": { + "report": f"{model.split('.')[0]}.report_{model.split('.')[-1]}", + "ids": ["{{id}}"], + "format": "pdf", + }, + } + }, indent=2) + eps.append({ + "name": f"Generate Report for {model}", + "method": "POST", + "path": f"{base_ep}/report", + "headers": {**headers, "Content-Type": ct, "Accept": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": "Generate a PDF/HTML/TEXT report. Change format to 'html' or 'text' as needed.", + }) + + if "Get Fields" in operations: + eps.append({ + "name": f"Get Fields — {model}", + "method": "GET", + "path": f"{ep_path}/fields_get", + "headers": {**headers, "Accept": ct}, + "params": {"attributes": "string,type,required,help"}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": f"Get all field definitions for {model}.", + }) + + if "Check Access Rights" in operations: + eps.append({ + "name": f"Check Access — {model}", + "method": "GET", + "path": f"{ep_path}/check_access_rights", + "headers": {**headers, "Accept": ct}, + "params": {"operation": "read"}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": "Check if the current user has the specified access right (read/write/create/unlink).", + }) + + return eps + + +def _build_restjson_endpoints(base_ep: str, model: str, headers: dict, + operations: list[str]) -> list[dict]: + ct = _CT["REST JSON"] + ep_path = f"{base_ep}/{model}" + eps = [] + + if "List Records" in operations: + eps.append({ + "name": f"List {model}", + "method": "GET", + "path": ep_path, + "headers": {**headers, "Accept": ct}, + "params": {"page": "1", "limit": "10", "fields": "id,name,display_name", "sort": "id"}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": f"Fetch paginated list of {model} records.", + }) + + if "Get Single Record" in operations: + eps.append({ + "name": f"Get {model} by ID", + "method": "GET", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers, "Accept": ct}, + "params": {"fields": "id,name,display_name"}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": f"Fetch a single {model} record by ID.", + }) + + if "Create Record" in operations: + body = json.dumps({"name": f"New {model.split('.')[-1].title()}"}, indent=2) + eps.append({ + "name": f"Create {model}", + "method": "POST", + "path": ep_path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(201), + "description": f"Create a new {model} record.", + }) + + if "Update Record" in operations: + body = json.dumps({"name": "Updated Name"}, indent=2) + eps.append({ + "name": f"Update {model}", + "method": "PATCH", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"Update an existing {model} record.", + }) + + if "Delete Record" in operations: + eps.append({ + "name": f"Delete {model}", + "method": "DELETE", + "path": f"{ep_path}/{{{{id}}}}", + "headers": {**headers}, + "params": {}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": f"Delete a {model} record by ID.", + }) + + if "Execute Method" in operations: + body = json.dumps({"method": "action_confirm", "args": [], "kwargs": {}}, indent=2) + eps.append({ + "name": f"Execute Method on {model}", + "method": "POST", + "path": f"{ep_path}/{{{{id}}}}/execute", + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": body, + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": "Execute an ORM method on a record.", + }) + + if "Get Fields" in operations: + eps.append({ + "name": f"Get Fields — {model}", + "method": "GET", + "path": f"{ep_path}/fields_get", + "headers": {**headers}, + "params": {}, + "body": "", + "body_type": "raw", + "content_type": "", + "test_script": _rest_test(200), + "description": f"Get field definitions for {model}.", + }) + + return eps + + +def _build_graphql_endpoints(base_ep: str, model: str, headers: dict, + operations: list[str]) -> list[dict]: + ct = _CT["GraphQL"] + gql = _model_gql(model) + path = f"{base_ep}/graphql" + eps = [] + + if "List Records" in operations: + query = ( + f"query {{\n" + f" {gql}(\n" + f" filter: \"\"\n" + f" pageSize: 10\n" + f" pageNumber: 1\n" + f" ) {{\n" + f" id\n" + f" name\n" + f" display_name\n" + f" }}\n" + f"}}" + ) + eps.append({ + "name": f"GraphQL — List {model}", + "method": "POST", + "path": path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": json.dumps({"query": query}, indent=2), + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"GraphQL query to list {model} records.", + }) + + if "Get Single Record" in operations: + query = ( + f"query {{\n" + f" {gql}(id: {{{{id}}}}) {{\n" + f" id\n" + f" name\n" + f" display_name\n" + f" }}\n" + f"}}" + ) + eps.append({ + "name": f"GraphQL — Get {model} by ID", + "method": "POST", + "path": path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": json.dumps({"query": query}, indent=2), + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"GraphQL query to get a single {model} record.", + }) + + if "Create Record" in operations: + mutation = ( + f"mutation {{\n" + f" create_{gql}(\n" + f" attributes: {{\n" + f" name: \"New Record\"\n" + f" }}\n" + f" ) {{\n" + f" id\n" + f" name\n" + f" }}\n" + f"}}" + ) + eps.append({ + "name": f"GraphQL — Create {model}", + "method": "POST", + "path": path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": json.dumps({"query": mutation}, indent=2), + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"GraphQL mutation to create a {model} record.", + }) + + if "Update Record" in operations: + mutation = ( + f"mutation {{\n" + f" update_{gql}(\n" + f" id: {{{{id}}}}\n" + f" attributes: {{\n" + f" name: \"Updated Name\"\n" + f" }}\n" + f" ) {{\n" + f" id\n" + f" name\n" + f" }}\n" + f"}}" + ) + eps.append({ + "name": f"GraphQL — Update {model}", + "method": "POST", + "path": path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": json.dumps({"query": mutation}, indent=2), + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"GraphQL mutation to update a {model} record.", + }) + + if "Delete Record" in operations: + mutation = ( + f"mutation {{\n" + f" delete_{gql}(id: {{{{id}}}}) {{\n" + f" id\n" + f" }}\n" + f"}}" + ) + eps.append({ + "name": f"GraphQL — Delete {model}", + "method": "POST", + "path": path, + "headers": {**headers, "Content-Type": ct}, + "params": {}, + "body": json.dumps({"query": mutation}, indent=2), + "body_type": "raw", + "content_type": ct, + "test_script": _rest_test(200), + "description": f"GraphQL mutation to delete a {model} record.", + }) + + return eps + + +# ── Main entry point ────────────────────────────────────────────────────────── + +def generate_collection( + instance_url: str, + endpoint: str, + api_kind: str, + auth_type: str, + auth_creds: dict, + models: list[str], + operations: list[str], + collection_name: str = "", +) -> dict: + """ + Generate an EKIKA API Framework collection dict ready for storage. + + Returns same structure as ai_client.analyze_docs(). + """ + base_url = instance_url.rstrip("/") + base_ep = _clean_endpoint(endpoint) + headers = _auth_headers(auth_type) + env_vars = _env_vars(instance_url, auth_type, auth_creds) + + all_endpoints = [] + for model in models: + model = model.strip() + if not model: + continue + if api_kind == "JSON-API": + all_endpoints += _build_jsonapi_endpoints(base_ep, model, headers, operations) + elif api_kind == "REST JSON": + all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations) + elif api_kind == "GraphQL": + all_endpoints += _build_graphql_endpoints(base_ep, model, headers, operations) + else: # Custom REST JSON — same as REST JSON + all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations) + + # Build URLs using {{base_url}} variable + for ep in all_endpoints: + if not ep["path"].startswith("http"): + ep["url"] = f"{{{{base_url}}}}{ep['path']}" + + name = collection_name or f"EKIKA Odoo — {api_kind} — {', '.join(models[:3])}" + + return { + "collection_name": name, + "base_url": base_url, + "auth_type": auth_type.lower().replace(" ", "_"), + "doc_type": "ekika_odoo_framework", + "endpoints": all_endpoints, + "environment_variables": env_vars, + "notes": ( + f"API Kind: {api_kind} | Auth: {auth_type}\n" + f"Endpoint: {base_url}{base_ep}/{{model}}\n" + f"Replace {{{{id}}}} with actual record IDs before sending." + ), + "_source": "ekika_odoo", + } diff --git a/app/core/exporter.py b/app/core/exporter.py new file mode 100644 index 0000000..dcff792 --- /dev/null +++ b/app/core/exporter.py @@ -0,0 +1,66 @@ +"""Export collections to Postman Collection v2.1 JSON.""" +import json +from app.models import HttpRequest +from app.core import storage + + +def export_collection(collection_id: int) -> str: + collections = storage.get_collections() + col = next((c for c in collections if c["id"] == collection_id), None) + if not col: + raise ValueError(f"Collection {collection_id} not found") + + items = [] + + def make_request_item(r: dict) -> dict: + header = [{"key": k, "value": v} for k, v in r.get("headers", {}).items()] + url_raw = r.get("url", "") + params = r.get("params", {}) + query = [{"key": k, "value": v} for k, v in params.items()] + + body_obj = None + body = r.get("body", "") + body_type = r.get("body_type", "raw") + if body: + if body_type == "urlencoded": + pairs = [] + for line in body.split("&"): + if "=" in line: + k, _, v = line.partition("=") + pairs.append({"key": k, "value": v}) + body_obj = {"mode": "urlencoded", "urlencoded": pairs} + else: + body_obj = {"mode": "raw", "raw": body} + + item = { + "name": r.get("name") or url_raw, + "request": { + "method": r.get("method", "GET"), + "header": header, + "url": {"raw": url_raw, "query": query}, + } + } + if body_obj: + item["request"]["body"] = body_obj + return item + + # Top-level requests (no folder) + for r in storage.get_requests(collection_id): + items.append(make_request_item(r)) + + # Folders + for folder in storage.get_folders(collection_id): + folder_item = { + "name": folder["name"], + "item": [make_request_item(r) for r in storage.get_requests(collection_id, folder["id"])] + } + items.append(folder_item) + + collection = { + "info": { + "name": col["name"], + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": items + } + return json.dumps(collection, indent=2) diff --git a/app/core/http_client.py b/app/core/http_client.py new file mode 100644 index 0000000..8841f7b --- /dev/null +++ b/app/core/http_client.py @@ -0,0 +1,161 @@ +"""APIClient - Agent — HTTP client engine.""" +import re +import base64 +from copy import deepcopy + +import httpx + +from app.models import HttpRequest, HttpResponse + + +def resolve_variables(text: str, variables: dict) -> str: + """Replace {{variable}} placeholders with environment values.""" + if not text or not variables: + return text + + def replacer(m): + key = m.group(1).strip() + return str(variables.get(key, m.group(0))) + + return re.sub(r"\{\{(.+?)\}\}", replacer, text) + + +def apply_variables(req: HttpRequest, variables: dict) -> HttpRequest: + """Return a deep copy of the request with all variables resolved.""" + r = deepcopy(req) + r.url = resolve_variables(r.url, variables) + r.body = resolve_variables(r.body, variables) + r.headers = {k: resolve_variables(v, variables) for k, v in r.headers.items()} + r.params = {k: resolve_variables(v, variables) for k, v in r.params.items()} + if r.auth_data: + r.auth_data = {k: resolve_variables(str(v), variables) for k, v in r.auth_data.items()} + return r + + +def _build_auth_headers(req: HttpRequest) -> dict: + headers = {} + if req.auth_type == "bearer": + token = req.auth_data.get("token", "") + if token: + headers["Authorization"] = f"Bearer {token}" + elif req.auth_type == "basic": + user = req.auth_data.get("username", "") + pwd = req.auth_data.get("password", "") + encoded = base64.b64encode(f"{user}:{pwd}".encode()).decode() + headers["Authorization"] = f"Basic {encoded}" + elif req.auth_type == "apikey": + key = req.auth_data.get("key", "") + value = req.auth_data.get("value", "") + location = req.auth_data.get("in", "header") + if location == "header" and key: + headers[key] = value + return headers + + +def send_request(req: HttpRequest, variables: dict = None) -> HttpResponse: + r = req # will be overwritten with resolved copy; kept here for exception handlers + try: + r = apply_variables(req, variables or {}) + + # Check for unresolved variables + unresolved = re.findall(r"\{\{(.+?)\}\}", r.url) + if unresolved: + return HttpResponse( + error=f"Unresolved variable(s): {', '.join(unresolved)}. " + "Go to Tools → Environments to define them." + ) + + headers = {**r.headers, **_build_auth_headers(r)} + + # Query params (merge URL params dict + API-key-in-query) + params = r.params.copy() + if r.auth_type == "apikey" and r.auth_data.get("in") == "query": + params[r.auth_data.get("key", "")] = r.auth_data.get("value", "") + + # Build request body + content = None + data = None + files = None + + if r.body_type == "raw" and r.body: + content = r.body.encode() + # Auto Content-Type: honour explicit override, then try to detect JSON + if r.content_type: + headers.setdefault("Content-Type", r.content_type) + elif "Content-Type" not in headers: + stripped = r.body.lstrip() + if stripped.startswith(("{", "[")): + headers["Content-Type"] = "application/json" + else: + headers["Content-Type"] = "text/plain" + + elif r.body_type == "urlencoded" and r.body: + pairs = {} + for line in r.body.splitlines(): + if "=" in line: + k, _, v = line.partition("=") + pairs[k.strip()] = v.strip() + data = pairs + + elif r.body_type == "form-data" and r.body: + # Expect "key=value" lines; values starting with "@" treated as file paths + pairs = {} + for line in r.body.splitlines(): + if "=" in line: + k, _, v = line.partition("=") + pairs[k.strip()] = v.strip() + data = pairs + + timeout = httpx.Timeout( + connect=10.0, + read=float(r.timeout), + write=float(r.timeout), + pool=5.0, + ) + + with httpx.Client( + follow_redirects=True, + timeout=timeout, + verify=r.ssl_verify, + ) as client: + response = client.request( + method=r.method, + url=r.url, + headers=headers, + params=params or None, + content=content, + data=data, + files=files, + ) + body = response.text + size_bytes = len(response.content) + return HttpResponse( + status=response.status_code, + reason=response.reason_phrase, + headers=dict(response.headers), + body=body, + elapsed_ms=response.elapsed.total_seconds() * 1000, + size_bytes=size_bytes, + ) + + except httpx.InvalidURL: + return HttpResponse(error=f"Invalid URL: {r.url}") + except httpx.ConnectError as e: + detail = str(e) + if "CERTIFICATE_VERIFY_FAILED" in detail or "certificate" in detail.lower() or "SSL" in detail: + return HttpResponse(error=( + f"SSL certificate error — could not connect to {r.url}\n\n" + f"The server's certificate is not trusted or doesn't match the hostname.\n" + f"Tip: disable SSL verification in the request Settings tab." + )) + return HttpResponse(error=f"Connection refused — could not reach {r.url}") + except httpx.ConnectTimeout: + return HttpResponse(error=f"Connection timed out after {req.timeout}s") + except httpx.ReadTimeout: + return HttpResponse(error=f"Read timed out — server took too long to respond") + except httpx.SSLError as e: + return HttpResponse(error=f"SSL error: {e}. Disable SSL verification if using a self-signed cert.") + except httpx.TooManyRedirects: + return HttpResponse(error="Too many redirects — possible redirect loop") + except Exception as e: + return HttpResponse(error=str(e)) diff --git a/app/core/importer.py b/app/core/importer.py new file mode 100644 index 0000000..a3de60c --- /dev/null +++ b/app/core/importer.py @@ -0,0 +1,103 @@ +"""Import from Postman Collection v2.1 JSON or curl command.""" +import json +import re +import shlex +from app.models import HttpRequest + + +def from_postman_collection(json_text: str) -> tuple[str, list[HttpRequest]]: + """Returns (collection_name, list of HttpRequest).""" + data = json.loads(json_text) + name = data.get("info", {}).get("name", "Imported Collection") + requests = [] + + def parse_item(item): + if "request" in item: + r = item["request"] + method = r.get("method", "GET") + url_obj = r.get("url", {}) + if isinstance(url_obj, str): + url = url_obj + params = {} + else: + raw = url_obj.get("raw", "") + url = raw.split("?")[0] if "?" in raw else raw + params = {} + for qp in url_obj.get("query", []): + if not qp.get("disabled"): + params[qp.get("key", "")] = qp.get("value", "") + + headers = {} + for h in r.get("header", []): + if not h.get("disabled"): + headers[h.get("key", "")] = h.get("value", "") + + body_obj = r.get("body", {}) + body = "" + body_type = "raw" + if body_obj: + mode = body_obj.get("mode", "raw") + if mode == "raw": + body = body_obj.get("raw", "") + body_type = "raw" + elif mode == "urlencoded": + pairs = body_obj.get("urlencoded", []) + body = "&".join(f"{p['key']}={p.get('value','')}" for p in pairs if not p.get("disabled")) + body_type = "urlencoded" + + requests.append(HttpRequest( + method=method, url=url, headers=headers, + params=params, body=body, body_type=body_type, + name=item.get("name", "") + )) + elif "item" in item: + for sub in item["item"]: + parse_item(sub) + + for item in data.get("item", []): + parse_item(item) + + return name, requests + + +def from_curl(curl_cmd: str) -> HttpRequest: + """Parse a curl command string into an HttpRequest.""" + # Normalize line continuations + cmd = curl_cmd.replace("\\\n", " ").strip() + try: + tokens = shlex.split(cmd) + except ValueError: + tokens = cmd.split() + + req = HttpRequest(method="GET") + i = 1 # skip 'curl' + while i < len(tokens): + token = tokens[i] + if token in ("-X", "--request") and i + 1 < len(tokens): + req.method = tokens[i + 1].upper() + i += 2 + elif token in ("-H", "--header") and i + 1 < len(tokens): + header = tokens[i + 1] + if ":" in header: + k, _, v = header.partition(":") + req.headers[k.strip()] = v.strip() + i += 2 + elif token in ("-d", "--data", "--data-raw", "--data-binary") and i + 1 < len(tokens): + req.body = tokens[i + 1] + if req.method == "GET": + req.method = "POST" + i += 2 + elif token in ("-u", "--user") and i + 1 < len(tokens): + user_pass = tokens[i + 1] + if ":" in user_pass: + u, _, p = user_pass.partition(":") + req.auth_type = "basic" + req.auth_data = {"username": u, "password": p} + i += 2 + elif not token.startswith("-") and not req.url: + req.url = token.strip("'\"") + i += 1 + else: + i += 1 + + return req diff --git a/app/core/mock_server.py b/app/core/mock_server.py new file mode 100644 index 0000000..b648338 --- /dev/null +++ b/app/core/mock_server.py @@ -0,0 +1,82 @@ +"""APIClient - Agent — Lightweight HTTP mock server.""" +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +from app.core import storage + + +_server_instance: HTTPServer | None = None +_server_thread: threading.Thread | None = None +_server_port: int = 8888 + + +class _MockHandler(BaseHTTPRequestHandler): + """Queries the DB on every request so new/edited endpoints are served immediately.""" + + def log_message(self, fmt, *args): + pass # suppress default console logging + + def _handle(self): + endpoints = storage.get_mock_endpoints() + matched = None + for ep in endpoints: + method_match = ep.method == "*" or ep.method == self.command + if method_match and ep.path == self.path: + matched = ep + break + + if matched: + self.send_response(matched.status_code) + for k, v in matched.response_headers.items(): + self.send_header(k, v) + body = matched.response_body.encode("utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + body = b'{"error": "No mock endpoint matched"}' + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + do_GET = _handle + do_POST = _handle + do_PUT = _handle + do_PATCH = _handle + do_DELETE = _handle + do_HEAD = _handle + do_OPTIONS = _handle + + +def start(port: int = 8888) -> str: + global _server_instance, _server_thread, _server_port + if _server_instance: + return f"Already running on port {_server_port}" + _server_port = port + try: + _server_instance = HTTPServer(("localhost", port), _MockHandler) + except OSError as e: + return f"Failed to start: {e}" + _server_thread = threading.Thread(target=_server_instance.serve_forever, daemon=True) + _server_thread.start() + return f"Mock server running on http://localhost:{port}" + + +def stop() -> str: + global _server_instance, _server_thread + if _server_instance: + _server_instance.shutdown() + _server_instance = None + _server_thread = None + return "Mock server stopped" + return "Not running" + + +def is_running() -> bool: + return _server_instance is not None + + +def get_port() -> int: + return _server_port diff --git a/app/core/openapi_parser.py b/app/core/openapi_parser.py new file mode 100644 index 0000000..d1f378b --- /dev/null +++ b/app/core/openapi_parser.py @@ -0,0 +1,236 @@ +"""APIClient - Agent — OpenAPI / Swagger spec parser. + +Parses OpenAPI 3.x and Swagger 2.0 specs (JSON or YAML) directly, +without needing AI tokens. +""" +import json +import re + + +def _try_yaml(text: str) -> dict | None: + try: + import yaml + return yaml.safe_load(text) + except Exception: + return None + + +def _try_json(text: str) -> dict | None: + try: + return json.loads(text) + except Exception: + return None + + +def detect_spec(text: str) -> dict | None: + """Try to parse text as OpenAPI/Swagger JSON or YAML. Returns raw dict or None.""" + data = _try_json(text) or _try_yaml(text) + if not isinstance(data, dict): + return None + if "openapi" in data or "swagger" in data: + return data + return None + + +def parse_spec(data: dict) -> dict: + """ + Parse an OpenAPI 3.x or Swagger 2.0 spec dict into EKIKA's internal format: + { + "collection_name": str, + "base_url": str, + "auth_type": str, + "endpoints": [...], + "environment_variables": {...} + } + """ + version = str(data.get("openapi", data.get("swagger", "2"))) + is_v3 = version.startswith("3") + + # Collection name + info = data.get("info", {}) + collection_name = info.get("title", "Imported API") + + # Base URL + if is_v3: + servers = data.get("servers", []) + base_url = servers[0].get("url", "") if servers else "" + else: + host = data.get("host", "") + schemes = data.get("schemes", ["https"]) + base_p = data.get("basePath", "/") + base_url = f"{schemes[0]}://{host}{base_p}" if host else "" + + # Clean trailing slash + base_url = base_url.rstrip("/") + + # Auth detection + security_schemes = {} + if is_v3: + security_schemes = data.get("components", {}).get("securitySchemes", {}) + else: + security_schemes = data.get("securityDefinitions", {}) + + auth_type = "none" + for scheme in security_schemes.values(): + t = scheme.get("type", "").lower() + if t in ("http", "bearer") or scheme.get("scheme", "").lower() == "bearer": + auth_type = "bearer" + break + if t == "apikey": + auth_type = "apikey" + break + if t in ("basic", "http") and scheme.get("scheme", "").lower() == "basic": + auth_type = "basic" + break + + # Endpoints + endpoints = [] + paths = data.get("paths", {}) + + for path, path_item in paths.items(): + if not isinstance(path_item, dict): + continue + for method in ("get", "post", "put", "patch", "delete", "head", "options"): + op = path_item.get(method) + if not isinstance(op, dict): + continue + + name = op.get("summary") or op.get("operationId") or f"{method.upper()} {path}" + description = op.get("description", "") + + # Headers + headers: dict = {} + + # Query params + params: dict = {} + body_example = "" + content_type = "application/json" + body_type = "raw" + + # Parameters + for param in op.get("parameters", []): + if not isinstance(param, dict): + continue + p_in = param.get("in", "") + p_name = param.get("name", "") + if p_in == "query": + params[p_name] = param.get("example", "") + elif p_in == "header": + headers[p_name] = param.get("example", "") + + # Request body (OpenAPI 3) + if is_v3 and "requestBody" in op: + rb = op["requestBody"] + content = rb.get("content", {}) + if "application/json" in content: + schema = content["application/json"].get("schema", {}) + body_example = _schema_to_example_str(schema) + content_type = "application/json" + elif "application/x-www-form-urlencoded" in content: + body_type = "form-urlencoded" + content_type = "" + elif content: + first_ct = next(iter(content)) + content_type = first_ct + + # Request body (Swagger 2) + if not is_v3: + consumes = op.get("consumes", data.get("consumes", ["application/json"])) + for param in op.get("parameters", []): + if param.get("in") == "body": + schema = param.get("schema", {}) + body_example = _schema_to_example_str(schema) + if consumes: + content_type = consumes[0] + + # Add auth header hint + if auth_type == "bearer": + headers.setdefault("Authorization", "Bearer {{token}}") + elif auth_type == "apikey": + headers.setdefault("X-API-Key", "{{api_key}}") + + # Basic test script + test_script = ( + f"pm.test('Status OK', lambda: pm.response.to_have_status(200))\n" + f"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())" + ) + + endpoints.append({ + "name": name, + "method": method.upper(), + "path": path, + "description": description, + "headers": headers, + "params": params, + "body": body_example, + "body_type": body_type, + "content_type": content_type, + "test_script": test_script, + }) + + # Environment variables + env_vars: dict = {} + if base_url: + env_vars["base_url"] = base_url + if auth_type == "bearer": + env_vars["token"] = "" + elif auth_type == "apikey": + env_vars["api_key"] = "" + elif auth_type == "basic": + env_vars["username"] = "" + env_vars["password"] = "" + + return { + "collection_name": collection_name, + "base_url": base_url, + "auth_type": auth_type, + "endpoints": endpoints, + "environment_variables": env_vars, + } + + +def _schema_to_example_str(schema: dict) -> str: + """Generate a compact JSON example string from an OpenAPI schema.""" + try: + example = _schema_to_example(schema) + return json.dumps(example, indent=2, ensure_ascii=False) + except Exception: + return "" + + +def _schema_to_example(schema: dict, depth: int = 0) -> object: + if depth > 5: + return {} + if not isinstance(schema, dict): + return {} + + # Use provided example first + if "example" in schema: + return schema["example"] + if "default" in schema: + return schema["default"] + + t = schema.get("type", "object") + + if t == "object" or "properties" in schema: + result = {} + for k, v in schema.get("properties", {}).items(): + result[k] = _schema_to_example(v, depth + 1) + return result + + if t == "array": + items = schema.get("items", {}) + return [_schema_to_example(items, depth + 1)] + + if t == "string": + fmt = schema.get("format", "") + if fmt == "date-time": return "2024-01-01T00:00:00Z" + if fmt == "date": return "2024-01-01" + if fmt == "email": return "user@example.com" + if fmt == "uuid": return "00000000-0000-0000-0000-000000000000" + return schema.get("enum", ["string"])[0] + + if t == "integer": return 0 + if t == "number": return 0.0 + if t == "boolean": return True + return {} diff --git a/app/core/storage.py b/app/core/storage.py new file mode 100644 index 0000000..80b06a3 --- /dev/null +++ b/app/core/storage.py @@ -0,0 +1,436 @@ +"""APIClient - Agent — Storage layer (SQLite).""" +import json +import sqlite3 +from pathlib import Path +from app.models import HttpRequest, Environment, MockEndpoint + +DB_PATH = Path.home() / ".ekika-api-client" / "data.db" + + +def _get_conn() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _migrate(conn: sqlite3.Connection): + """Add columns/tables introduced after initial schema.""" + existing = {row[1] for row in conn.execute("PRAGMA table_info(requests)")} + migrations = [ + ("folder_id", "ALTER TABLE requests ADD COLUMN folder_id INTEGER"), + ("params", "ALTER TABLE requests ADD COLUMN params TEXT"), + ("body_type", "ALTER TABLE requests ADD COLUMN body_type TEXT DEFAULT 'raw'"), + ("auth_type", "ALTER TABLE requests ADD COLUMN auth_type TEXT DEFAULT 'none'"), + ("auth_data", "ALTER TABLE requests ADD COLUMN auth_data TEXT"), + ("pre_request_script", "ALTER TABLE requests ADD COLUMN pre_request_script TEXT"), + ("test_script", "ALTER TABLE requests ADD COLUMN test_script TEXT"), + ("timeout", "ALTER TABLE requests ADD COLUMN timeout INTEGER DEFAULT 30"), + ("ssl_verify", "ALTER TABLE requests ADD COLUMN ssl_verify INTEGER DEFAULT 1"), + ("content_type", "ALTER TABLE requests ADD COLUMN content_type TEXT"), + ("created_at", "ALTER TABLE requests ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP"), + ] + for col, sql in migrations: + if col not in existing: + conn.execute(sql) + + hist_cols = {row[1] for row in conn.execute("PRAGMA table_info(history)")} + hist_migrations = [ + ("params", "ALTER TABLE history ADD COLUMN params TEXT"), + ("body_type", "ALTER TABLE history ADD COLUMN body_type TEXT"), + ("auth_type", "ALTER TABLE history ADD COLUMN auth_type TEXT"), + ("auth_data", "ALTER TABLE history ADD COLUMN auth_data TEXT"), + ("timeout", "ALTER TABLE history ADD COLUMN timeout INTEGER DEFAULT 30"), + ("ssl_verify","ALTER TABLE history ADD COLUMN ssl_verify INTEGER DEFAULT 1"), + ] + for col, sql in hist_migrations: + if col not in hist_cols: + conn.execute(sql) + + +def init_db(): + with _get_conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL, + name TEXT NOT NULL, + FOREIGN KEY (collection_id) REFERENCES collections(id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER, + folder_id INTEGER, + name TEXT, + method TEXT, + url TEXT, + headers TEXT, + params TEXT, + body TEXT, + body_type TEXT DEFAULT 'raw', + content_type TEXT, + auth_type TEXT DEFAULT 'none', + auth_data TEXT, + pre_request_script TEXT, + test_script TEXT, + timeout INTEGER DEFAULT 30, + ssl_verify INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (collection_id) REFERENCES collections(id), + FOREIGN KEY (folder_id) REFERENCES folders(id) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + method TEXT, + url TEXT, + headers TEXT, + params TEXT, + body TEXT, + body_type TEXT, + auth_type TEXT, + auth_data TEXT, + timeout INTEGER DEFAULT 30, + ssl_verify INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + variables TEXT DEFAULT '{}', + is_active INTEGER DEFAULT 0 + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS mock_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + method TEXT DEFAULT 'GET', + path TEXT NOT NULL, + status_code INTEGER DEFAULT 200, + response_headers TEXT DEFAULT '{}', + response_body TEXT DEFAULT '' + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + """) + + # Performance indexes + conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_collection ON requests(collection_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_folder ON requests(folder_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at DESC)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_requests_url ON requests(url)") + + _migrate(conn) + + +# ── Collections ────────────────────────────────────────────────────────────── + +def get_collections() -> list[dict]: + with _get_conn() as conn: + rows = conn.execute("SELECT * FROM collections ORDER BY name COLLATE NOCASE").fetchall() + return [dict(r) for r in rows] + + +def add_collection(name: str) -> int: + with _get_conn() as conn: + cur = conn.execute("INSERT INTO collections (name) VALUES (?)", (name,)) + return cur.lastrowid + + +def rename_collection(col_id: int, name: str): + with _get_conn() as conn: + conn.execute("UPDATE collections SET name=? WHERE id=?", (name, col_id)) + + +def delete_collection(col_id: int): + with _get_conn() as conn: + conn.execute("DELETE FROM requests WHERE collection_id=?", (col_id,)) + conn.execute("DELETE FROM folders WHERE collection_id=?", (col_id,)) + conn.execute("DELETE FROM collections WHERE id=?", (col_id,)) + + +# ── Folders ─────────────────────────────────────────────────────────────────── + +def get_folders(collection_id: int) -> list[dict]: + with _get_conn() as conn: + rows = conn.execute( + "SELECT * FROM folders WHERE collection_id=? ORDER BY name COLLATE NOCASE", + (collection_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def add_folder(collection_id: int, name: str) -> int: + with _get_conn() as conn: + cur = conn.execute( + "INSERT INTO folders (collection_id, name) VALUES (?,?)", (collection_id, name) + ) + return cur.lastrowid + + +def rename_folder(folder_id: int, name: str): + with _get_conn() as conn: + conn.execute("UPDATE folders SET name=? WHERE id=?", (name, folder_id)) + + +def delete_folder(folder_id: int): + with _get_conn() as conn: + conn.execute("DELETE FROM requests WHERE folder_id=?", (folder_id,)) + conn.execute("DELETE FROM folders WHERE id=?", (folder_id,)) + + +# ── Requests ────────────────────────────────────────────────────────────────── + +def _deserialize_request(r) -> dict: + d = dict(r) + d["headers"] = json.loads(d.get("headers") or "{}") + d["params"] = json.loads(d.get("params") or "{}") + d["auth_data"]= json.loads(d.get("auth_data")or "{}") + return d + + +def get_requests(collection_id: int, folder_id: int = None) -> list[dict]: + with _get_conn() as conn: + if folder_id is not None: + rows = conn.execute( + "SELECT * FROM requests WHERE collection_id=? AND folder_id=? ORDER BY name COLLATE NOCASE", + (collection_id, folder_id) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM requests WHERE collection_id=? AND folder_id IS NULL ORDER BY name COLLATE NOCASE", + (collection_id,) + ).fetchall() + return [_deserialize_request(r) for r in rows] + + +def get_all_requests(collection_id: int) -> list[dict]: + with _get_conn() as conn: + rows = conn.execute( + "SELECT * FROM requests WHERE collection_id=? ORDER BY name COLLATE NOCASE", + (collection_id,) + ).fetchall() + return [_deserialize_request(r) for r in rows] + + +def save_request(collection_id: int, req: HttpRequest, folder_id: int = None) -> int: + with _get_conn() as conn: + cur = conn.execute( + """INSERT INTO requests + (collection_id, folder_id, name, method, url, headers, params, + body, body_type, content_type, auth_type, auth_data, + pre_request_script, test_script, timeout, ssl_verify) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (collection_id, folder_id, req.name, req.method, req.url, + json.dumps(req.headers), json.dumps(req.params), + req.body, req.body_type, req.content_type, + req.auth_type, json.dumps(req.auth_data), + req.pre_request_script, req.test_script, + req.timeout, int(req.ssl_verify)), + ) + return cur.lastrowid + + +def update_request(req_id: int, req: HttpRequest): + with _get_conn() as conn: + conn.execute( + """UPDATE requests SET + name=?, method=?, url=?, headers=?, params=?, + body=?, body_type=?, content_type=?, auth_type=?, auth_data=?, + pre_request_script=?, test_script=?, timeout=?, ssl_verify=? + WHERE id=?""", + (req.name, req.method, req.url, + json.dumps(req.headers), json.dumps(req.params), + req.body, req.body_type, req.content_type, + req.auth_type, json.dumps(req.auth_data), + req.pre_request_script, req.test_script, + req.timeout, int(req.ssl_verify), req_id), + ) + + +def delete_request(req_id: int): + with _get_conn() as conn: + conn.execute("DELETE FROM requests WHERE id=?", (req_id,)) + + +def search_requests(query: str) -> list[dict]: + with _get_conn() as conn: + like = f"%{query}%" + rows = conn.execute( + """SELECT r.*, c.name as collection_name + FROM requests r + LEFT JOIN collections c ON r.collection_id = c.id + WHERE r.name LIKE ? OR r.url LIKE ? + ORDER BY r.name COLLATE NOCASE""", + (like, like) + ).fetchall() + return [_deserialize_request(r) for r in rows] + + +# ── History ─────────────────────────────────────────────────────────────────── + +def add_to_history(req: HttpRequest): + with _get_conn() as conn: + # Trim history to 200 entries + conn.execute( + """DELETE FROM history WHERE id NOT IN ( + SELECT id FROM history ORDER BY created_at DESC LIMIT 199 + )""" + ) + conn.execute( + """INSERT INTO history + (method, url, headers, params, body, body_type, auth_type, auth_data, timeout, ssl_verify) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (req.method, req.url, + json.dumps(req.headers), json.dumps(req.params), + req.body, req.body_type, req.auth_type, json.dumps(req.auth_data), + req.timeout, int(req.ssl_verify)), + ) + + +def get_history(limit: int = 50) -> list[dict]: + with _get_conn() as conn: + rows = conn.execute( + "SELECT * FROM history ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + return [_deserialize_request(r) for r in rows] + + +def clear_history(): + with _get_conn() as conn: + conn.execute("DELETE FROM history") + + +# ── Environments ────────────────────────────────────────────────────────────── + +def get_environments() -> list[Environment]: + with _get_conn() as conn: + rows = conn.execute("SELECT * FROM environments ORDER BY name COLLATE NOCASE").fetchall() + return [ + Environment( + id=r["id"], name=r["name"], + variables=json.loads(r["variables"] or "{}"), + is_active=bool(r["is_active"]) + ) + for r in rows + ] + + +def get_active_environment() -> Environment | None: + with _get_conn() as conn: + row = conn.execute("SELECT * FROM environments WHERE is_active=1").fetchone() + if row: + return Environment( + id=row["id"], name=row["name"], + variables=json.loads(row["variables"] or "{}"), + is_active=True + ) + return None + + +def save_environment(env: Environment) -> int: + with _get_conn() as conn: + if env.id: + conn.execute( + "UPDATE environments SET name=?, variables=? WHERE id=?", + (env.name, json.dumps(env.variables), env.id) + ) + return env.id + else: + cur = conn.execute( + "INSERT INTO environments (name, variables, is_active) VALUES (?,?,?)", + (env.name, json.dumps(env.variables), int(env.is_active)) + ) + return cur.lastrowid + + +def set_active_environment(env_id: int | None): + with _get_conn() as conn: + conn.execute("UPDATE environments SET is_active=0") + if env_id: + conn.execute("UPDATE environments SET is_active=1 WHERE id=?", (env_id,)) + + +def delete_environment(env_id: int): + with _get_conn() as conn: + conn.execute("DELETE FROM environments WHERE id=?", (env_id,)) + + +# ── Mock Endpoints ──────────────────────────────────────────────────────────── + +def get_mock_endpoints() -> list[MockEndpoint]: + with _get_conn() as conn: + rows = conn.execute("SELECT * FROM mock_endpoints ORDER BY path").fetchall() + return [ + MockEndpoint( + id=r["id"], name=r["name"], method=r["method"], + path=r["path"], status_code=r["status_code"], + response_headers=json.loads(r["response_headers"] or "{}"), + response_body=r["response_body"] or "" + ) + for r in rows + ] + + +def save_mock_endpoint(ep: MockEndpoint) -> int: + with _get_conn() as conn: + if ep.id: + conn.execute( + """UPDATE mock_endpoints + SET name=?, method=?, path=?, status_code=?, response_headers=?, response_body=? + WHERE id=?""", + (ep.name, ep.method, ep.path, ep.status_code, + json.dumps(ep.response_headers), ep.response_body, ep.id) + ) + return ep.id + else: + cur = conn.execute( + """INSERT INTO mock_endpoints + (name, method, path, status_code, response_headers, response_body) + VALUES (?,?,?,?,?,?)""", + (ep.name, ep.method, ep.path, ep.status_code, + json.dumps(ep.response_headers), ep.response_body) + ) + return cur.lastrowid + + +def delete_mock_endpoint(ep_id: int): + with _get_conn() as conn: + conn.execute("DELETE FROM mock_endpoints WHERE id=?", (ep_id,)) + + +# ── Settings ────────────────────────────────────────────────────────────────── + +def get_setting(key: str, default: str = "") -> str: + with _get_conn() as conn: + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(key: str, value: str): + with _get_conn() as conn: + conn.execute( + "INSERT INTO settings (key, value) VALUES (?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, value) + ) diff --git a/app/core/test_runner.py b/app/core/test_runner.py new file mode 100644 index 0000000..d5d376f --- /dev/null +++ b/app/core/test_runner.py @@ -0,0 +1,86 @@ +"""Run test scripts after a response is received.""" +import json +from app.models import HttpResponse, TestResult + + +class TestContext: + """Exposes pm.test(), pm.expect(), pm.response in test scripts.""" + + def __init__(self, response: HttpResponse): + self.results: list[TestResult] = [] + self.response = _ResponseProxy(response) + self.expect = _expect + + def test(self, name: str, fn): + try: + fn() + self.results.append(TestResult(name=name, passed=True)) + except AssertionError as e: + self.results.append(TestResult(name=name, passed=False, message=str(e))) + except Exception as e: + self.results.append(TestResult(name=name, passed=False, message=f"Error: {e}")) + + +class _ResponseProxy: + def __init__(self, resp: HttpResponse): + self._resp = resp + self.status = resp.status + self.responseTime = resp.elapsed_ms + self.text = resp.body + try: + self._json = json.loads(resp.body) + except Exception: + self._json = None + + def json(self): + return self._json + + def to_have_status(self, code: int): + assert self._resp.status == code, f"Expected status {code}, got {self._resp.status}" + + +class _Assertion: + def __init__(self, value): + self._value = value + + def to_equal(self, expected): + assert self._value == expected, f"Expected {expected!r}, got {self._value!r}" + return self + + def to_be_truthy(self): + assert self._value, f"Expected truthy, got {self._value!r}" + return self + + def to_include(self, substr): + assert substr in str(self._value), f"Expected {substr!r} in {self._value!r}" + return self + + def to_be_below(self, n): + assert self._value < n, f"Expected {self._value} < {n}" + return self + + def to_have_property(self, key): + assert hasattr(self._value, key) or (isinstance(self._value, dict) and key in self._value), \ + f"Expected property {key!r}" + return self + + +def _expect(value) -> _Assertion: + return _Assertion(value) + + +def run_tests(script: str, response: HttpResponse) -> list[TestResult]: + if not script or not script.strip(): + return [] + + ctx = TestContext(response) + namespace = { + "pm": ctx, + "response": ctx.response, + "expect": _expect, + } + try: + exec(script, namespace) + except Exception as e: + ctx.results.append(TestResult(name="Script Error", passed=False, message=str(e))) + return ctx.results diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..34fd776 --- /dev/null +++ b/app/models.py @@ -0,0 +1,72 @@ +"""APIClient - Agent — Core data models.""" +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class HttpRequest: + method: str = "GET" + url: str = "" + headers: dict = field(default_factory=dict) + params: dict = field(default_factory=dict) + body: str = "" + body_type: str = "raw" # raw | form-data | urlencoded + content_type: str = "" # explicit Content-Type override + auth_type: str = "none" # none | bearer | basic | apikey + auth_data: dict = field(default_factory=dict) + pre_request_script: str = "" + test_script: str = "" + name: str = "" + collection_id: Optional[int] = None + folder_id: Optional[int] = None + id: Optional[int] = None # set when loaded from DB + timeout: int = 30 # seconds + ssl_verify: bool = True + + +@dataclass +class HttpResponse: + status: int = 0 + reason: str = "" + headers: dict = field(default_factory=dict) + body: str = "" + elapsed_ms: float = 0.0 + size_bytes: int = 0 + error: str = "" + + +@dataclass +class Environment: + id: Optional[int] = None + name: str = "" + variables: dict = field(default_factory=dict) + is_active: bool = False + + +@dataclass +class MockEndpoint: + id: Optional[int] = None + method: str = "GET" + path: str = "/mock" + status_code: int = 200 + response_headers: dict = field(default_factory=dict) + response_body: str = "" + name: str = "" + + +@dataclass +class TestResult: + name: str + passed: bool + message: str = "" + + +@dataclass +class CollectionRunResult: + request_name: str + method: str + url: str + status: int = 0 + elapsed_ms: float = 0.0 + test_results: list = field(default_factory=list) + error: str = "" diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/ai_chat_panel.py b/app/ui/ai_chat_panel.py new file mode 100644 index 0000000..604006c --- /dev/null +++ b/app/ui/ai_chat_panel.py @@ -0,0 +1,394 @@ +"""APIClient - Agent — AI chat sidebar panel (persistent, context-aware).""" +import re + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, + QPushButton, QScrollArea, QFrame, QSizePolicy +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QEvent +from PyQt6.QtGui import QFont + +from app.core import ai_chat + + +# ── Background worker ───────────────────────────────────────────────────────── + +class ChatWorker(QThread): + chunk_received = pyqtSignal(str) + finished = pyqtSignal(str) + error = pyqtSignal(str) + + def __init__(self, messages: list, context: str): + super().__init__() + self.messages = messages + self.context = context + + def run(self): + try: + text = ai_chat.stream_chat( + self.messages, + self.context, + chunk_cb=lambda c: self.chunk_received.emit(c), + ) + self.finished.emit(text) + except ai_chat.AIError as e: + self.error.emit(str(e)) + except Exception as e: + self.error.emit(f"Unexpected error: {e}") + + +# ── Apply block widget ──────────────────────────────────────────────────────── + +class ApplyBlock(QFrame): + apply_clicked = pyqtSignal(str, str) # type, content + + _LABELS = { + "body": "Apply Body", + "params": "Apply Params", + "headers": "Apply Headers", + "test": "Apply Test Script", + } + + def __init__(self, atype: str, content: str, parent=None): + super().__init__(parent) + self.setObjectName("applyBlock") + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(4) + + # Code preview + code = QTextEdit() + code.setObjectName("applyCode") + code.setReadOnly(True) + code.setPlainText(content) + code.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 9)) + code.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + code.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + lines = content.count("\n") + 1 + code.setFixedHeight(min(lines * 18 + 16, 180)) + layout.addWidget(code) + + # Apply button + btn = QPushButton(self._LABELS.get(atype, f"Apply {atype}")) + btn.setObjectName("accent") + btn.setFixedHeight(28) + btn.clicked.connect(lambda: self.apply_clicked.emit(atype, content)) + layout.addWidget(btn) + + +# ── Message bubble ──────────────────────────────────────────────────────────── + +class MessageBubble(QFrame): + apply_requested = pyqtSignal(str, str) + + def __init__(self, role: str, text: str = "", parent=None): + super().__init__(parent) + self.role = role + self._full_text = text + self._finalized = False + self.setObjectName("userBubble" if role == "user" else "aiBubble") + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(12, 8, 12, 8) + self._layout.setSpacing(6) + + # Role label + role_lbl = QLabel("You" if role == "user" else "✦ APIClient - Agent") + role_lbl.setObjectName("chatRoleLabel") + self._layout.addWidget(role_lbl) + + # Message text label + self._text_lbl = QLabel() + self._text_lbl.setObjectName("chatMessageText") + self._text_lbl.setWordWrap(True) + self._text_lbl.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse | + Qt.TextInteractionFlag.TextSelectableByKeyboard + ) + self._text_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self._layout.addWidget(self._text_lbl) + + # Apply blocks area + self._apply_area = QVBoxLayout() + self._apply_area.setSpacing(6) + self._layout.addLayout(self._apply_area) + + if text: + self._render(text) + + def append_chunk(self, chunk: str): + """Called during streaming to add text incrementally.""" + self._full_text += chunk + # Show raw text while streaming (apply blocks appear after finalize) + self._text_lbl.setText(self._full_text) + + def finalize(self): + """Called when streaming ends — strip apply blocks and render them.""" + if self._finalized: + return + self._finalized = True + self._render(self._full_text) + + def _render(self, text: str): + # Strip apply blocks from display text + display = ai_chat.strip_apply_blocks(text) + self._text_lbl.setText(display if display else text) + + # Clear old apply blocks + while self._apply_area.count(): + item = self._apply_area.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + # Add new apply blocks + for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL): + atype = m.group(1) + content = m.group(2).strip() + block = ApplyBlock(atype, content) + block.apply_clicked.connect(self.apply_requested) + self._apply_area.addWidget(block) + + +# ── Quick action definitions ────────────────────────────────────────────────── + +QUICK_ACTIONS = [ + ("Analyze", "Analyze this request and response. What does the response mean? Any issues?"), + ("Fix Error", "This request has an error. What went wrong and how do I fix it?"), + ("Gen Body", "Generate a complete, correct request body for this endpoint with realistic example data."), + ("Write Tests", "Write comprehensive test assertions for this response using pm.test()."), + ("Auth Help", "Explain the authentication setup needed and show me the correct headers."), + ("Explain Resp", "Explain this API response in detail. What does each field mean?"), +] + + +# ── Main chat panel ─────────────────────────────────────────────────────────── + +class AIChatPanel(QWidget): + """Persistent right-sidebar AI chat panel. Context-aware, multi-turn.""" + + # Emitted when AI suggests applying something to the current request + apply_body = pyqtSignal(str) + apply_params = pyqtSignal(str) + apply_headers = pyqtSignal(str) + apply_test = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("aiChatPanel") + self.setMinimumWidth(300) + self.setMaximumWidth(500) + + self._messages: list[dict] = [] + self._context: str = "" + self._worker: ChatWorker|None = None + self._streaming_bubble: MessageBubble|None = None + + self._build_ui() + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Header + header = QWidget() + header.setObjectName("aiChatHeader") + header.setFixedHeight(44) + hl = QHBoxLayout(header) + hl.setContentsMargins(12, 0, 8, 0) + hl.setSpacing(8) + + title = QLabel("✦ APIClient - Agent") + title.setObjectName("aiChatTitle") + hl.addWidget(title) + hl.addStretch() + + clear_btn = QPushButton("Clear") + clear_btn.setObjectName("ghost") + clear_btn.setFixedHeight(26) + clear_btn.setToolTip("Clear conversation history") + clear_btn.clicked.connect(self._clear_chat) + hl.addWidget(clear_btn) + + root.addWidget(header) + + # Chat history scroll area + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setObjectName("chatScroll") + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + self._chat_container = QWidget() + self._chat_container.setObjectName("chatArea") + self._chat_layout = QVBoxLayout(self._chat_container) + self._chat_layout.setContentsMargins(8, 8, 8, 8) + self._chat_layout.setSpacing(8) + self._chat_layout.addStretch() + + self._scroll.setWidget(self._chat_container) + root.addWidget(self._scroll, 1) + + # Quick actions bar + qa_bar = QWidget() + qa_bar.setObjectName("quickActions") + qa_outer = QVBoxLayout(qa_bar) + qa_outer.setContentsMargins(8, 5, 8, 4) + qa_outer.setSpacing(4) + + qa_lbl = QLabel("Quick Actions") + qa_lbl.setObjectName("hintText") + qa_outer.addWidget(qa_lbl) + + for row_start in (0, 3): + row_layout = QHBoxLayout() + row_layout.setSpacing(4) + for label, prompt in QUICK_ACTIONS[row_start:row_start+3]: + btn = QPushButton(label) + btn.setObjectName("qaBtn") + btn.setFixedHeight(24) + btn.clicked.connect(lambda _, p=prompt: self._send(p)) + row_layout.addWidget(btn) + qa_outer.addLayout(row_layout) + + root.addWidget(qa_bar) + + # Input area + input_area = QWidget() + input_area.setObjectName("chatInputArea") + il = QVBoxLayout(input_area) + il.setContentsMargins(8, 6, 8, 8) + il.setSpacing(5) + + self._input = QTextEdit() + self._input.setObjectName("chatInput") + self._input.setPlaceholderText("Ask anything about this request… (Ctrl+Enter to send)") + self._input.setFont(QFont("Segoe UI, SF Pro, sans-serif", 10)) + self._input.setFixedHeight(68) + self._input.installEventFilter(self) + il.addWidget(self._input) + + btn_row = QHBoxLayout() + self._ctx_label = QLabel("No context") + self._ctx_label.setObjectName("hintText") + self._ctx_label.setWordWrap(False) + btn_row.addWidget(self._ctx_label, 1) + + self._send_btn = QPushButton("Send") + self._send_btn.setObjectName("accent") + self._send_btn.setFixedSize(70, 28) + self._send_btn.clicked.connect(lambda: self._send(self._input.toPlainText().strip())) + btn_row.addWidget(self._send_btn) + + il.addLayout(btn_row) + root.addWidget(input_area) + + # ── Public API ──────────────────────────────────────────────────────────── + + def set_context(self, req=None, resp=None, env_vars: dict = None): + """Update context from the main window (called on request sent / response received).""" + self._context = ai_chat.build_context(req, resp, env_vars) + if req and req.url: + name = req.name or req.url + short = name[:35] + "…" if len(name) > 35 else name + self._ctx_label.setText(f"{req.method} {short}") + elif req: + self._ctx_label.setText("Request loaded") + else: + self._ctx_label.setText("No context") + + # ── Event filter: Ctrl+Enter sends ─────────────────────────────────────── + + def eventFilter(self, obj, event): + if obj is self._input and event.type() == QEvent.Type.KeyPress: + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + text = self._input.toPlainText().strip() + if text: + self._send(text) + return True + return super().eventFilter(obj, event) + + # ── Sending ─────────────────────────────────────────────────────────────── + + def _send(self, text: str): + if not text: + return + if self._worker and self._worker.isRunning(): + return # already streaming + + self._input.clear() + self._messages.append({"role": "user", "content": text}) + + # User bubble + user_bubble = MessageBubble("user", text) + self._add_bubble(user_bubble) + + # AI streaming bubble (starts empty) + ai_bubble = MessageBubble("assistant") + ai_bubble.apply_requested.connect(self._on_apply) + self._streaming_bubble = ai_bubble + self._add_bubble(ai_bubble) + + self._send_btn.setEnabled(False) + self._send_btn.setText("…") + + self._worker = ChatWorker(list(self._messages), self._context) + self._worker.chunk_received.connect(self._on_chunk) + self._worker.finished.connect(self._on_done) + self._worker.error.connect(self._on_error) + self._worker.start() + + def _on_chunk(self, chunk: str): + if self._streaming_bubble: + self._streaming_bubble.append_chunk(chunk) + QTimer.singleShot(0, self._scroll_to_bottom) + + def _on_done(self, full_text: str): + self._messages.append({"role": "assistant", "content": full_text}) + if self._streaming_bubble: + self._streaming_bubble.finalize() + self._streaming_bubble = None + self._send_btn.setEnabled(True) + self._send_btn.setText("Send") + QTimer.singleShot(50, self._scroll_to_bottom) + + def _on_error(self, msg: str): + if self._streaming_bubble: + self._streaming_bubble.finalize() + self._streaming_bubble = None + err_bubble = MessageBubble("assistant", f"Error: {msg}") + self._add_bubble(err_bubble) + self._send_btn.setEnabled(True) + self._send_btn.setText("Send") + + def _on_apply(self, atype: str, content: str): + signal_map = { + "body": self.apply_body, + "params": self.apply_params, + "headers": self.apply_headers, + "test": self.apply_test, + } + if atype in signal_map: + signal_map[atype].emit(content) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _add_bubble(self, bubble: MessageBubble): + idx = self._chat_layout.count() - 1 # insert before the stretch + self._chat_layout.insertWidget(idx, bubble) + QTimer.singleShot(50, self._scroll_to_bottom) + + def _scroll_to_bottom(self): + vsb = self._scroll.verticalScrollBar() + vsb.setValue(vsb.maximum()) + + def _clear_chat(self): + self._messages.clear() + while self._chat_layout.count() > 1: + item = self._chat_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() diff --git a/app/ui/ai_panel.py b/app/ui/ai_panel.py new file mode 100644 index 0000000..ccaa006 --- /dev/null +++ b/app/ui/ai_panel.py @@ -0,0 +1,699 @@ +"""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())}" + ) diff --git a/app/ui/code_gen_dialog.py b/app/ui/code_gen_dialog.py new file mode 100644 index 0000000..1a95ffb --- /dev/null +++ b/app/ui/code_gen_dialog.py @@ -0,0 +1,76 @@ +"""APIClient - Agent — Code Generation Dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QComboBox, + QTextEdit, QPushButton, QLabel, QApplication, QWidget +) +from PyQt6.QtGui import QFont +from app.ui.theme import Colors +from app.core.code_gen import GENERATORS +from app.models import HttpRequest + + +class CodeGenDialog(QDialog): + def __init__(self, req: HttpRequest, parent=None): + super().__init__(parent) + self.req = req + self.setWindowTitle("Generate Code Snippet") + self.setMinimumSize(720, 540) + + 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("Generate Code") + title.setObjectName("panelTitle") + hl.addWidget(title) + hl.addStretch() + lang_label = QLabel("Language:") + lang_label.setObjectName("fieldLabel") + self.lang_combo = QComboBox() + self.lang_combo.addItems(list(GENERATORS.keys())) + self.lang_combo.setMinimumWidth(200) + self.lang_combo.currentTextChanged.connect(self._generate) + hl.addWidget(lang_label) + hl.addWidget(self.lang_combo) + layout.addWidget(header) + + # Code view + self.code_view = QTextEdit() + self.code_view.setObjectName("codeEditor") + self.code_view.setReadOnly(True) + self.code_view.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas", 11)) + layout.addWidget(self.code_view, 1) + + # Footer + footer = QWidget() + footer.setObjectName("panelFooter") + footer.setFixedHeight(52) + fl = QHBoxLayout(footer) + fl.setContentsMargins(16, 0, 16, 0) + fl.addStretch() + copy_btn = QPushButton("Copy to Clipboard") + copy_btn.setObjectName("accent") + copy_btn.setFixedWidth(150) + copy_btn.clicked.connect(self._copy) + close_btn = QPushButton("Close") + close_btn.setFixedWidth(80) + close_btn.clicked.connect(self.accept) + fl.addWidget(copy_btn) + fl.addWidget(close_btn) + layout.addWidget(footer) + + self._generate(self.lang_combo.currentText()) + + def _generate(self, lang: str): + gen = GENERATORS.get(lang) + if gen: + self.code_view.setPlainText(gen(self.req)) + + def _copy(self): + QApplication.clipboard().setText(self.code_view.toPlainText()) diff --git a/app/ui/collection_runner.py b/app/ui/collection_runner.py new file mode 100644 index 0000000..2648f12 --- /dev/null +++ b/app/ui/collection_runner.py @@ -0,0 +1,194 @@ +"""APIClient - Agent — Collection Runner dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTreeWidget, QTreeWidgetItem, QComboBox, QHeaderView, QProgressBar, QWidget +) +from PyQt6.QtCore import QThread, pyqtSignal, Qt +from PyQt6.QtGui import QBrush, QColor + +from app.ui.theme import Colors +from app.core import storage, http_client +from app.core.test_runner import run_tests +from app.models import HttpRequest, CollectionRunResult + + +class RunnerWorker(QThread): + result_ready = pyqtSignal(object) + finished = pyqtSignal() + + def __init__(self, requests: list[dict], variables: dict): + super().__init__() + self.requests = requests + self.variables = variables + + def run(self): + for r in self.requests: + req = HttpRequest( + method = r.get("method") or "GET", + url = r.get("url") or "", + headers = r.get("headers") or {}, + params = r.get("params") or {}, + body = r.get("body") or "", + body_type = r.get("body_type") or "raw", + auth_type = r.get("auth_type") or "none", + auth_data = r.get("auth_data") or {}, + test_script = r.get("test_script") or "", + name = r.get("name") or r.get("url", ""), + timeout = r.get("timeout") or 30, + ssl_verify = bool(r.get("ssl_verify", 1)), + ) + resp = http_client.send_request(req, self.variables) + test_results = run_tests(req.test_script, resp) + result = CollectionRunResult( + request_name = req.name or req.url, + method = req.method, + url = req.url, + status = resp.status, + elapsed_ms = resp.elapsed_ms, + test_results = test_results, + error = resp.error, + ) + self.result_ready.emit(result) + self.finished.emit() + + +class CollectionRunnerDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Collection Runner") + self.setMinimumSize(800, 550) + self._worker = 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("Collection Runner") + title.setObjectName("panelTitle") + hl.addWidget(title) + hl.addStretch() + col_label = QLabel("Collection:") + col_label.setObjectName("fieldLabel") + self.col_combo = QComboBox() + self.col_combo.setMinimumWidth(200) + self._collections = storage.get_collections() + for c in self._collections: + self.col_combo.addItem(c["name"], c["id"]) + self.run_btn = QPushButton("Run All") + self.run_btn.setObjectName("accent") + self.run_btn.setFixedWidth(100) + self.run_btn.clicked.connect(self._run) + hl.addWidget(col_label) + hl.addWidget(self.col_combo) + hl.addSpacing(8) + hl.addWidget(self.run_btn) + layout.addWidget(header) + + # ── Body ────────────────────────────────────────────────────────────── + body = QWidget() + body.setObjectName("panelBody") + bl = QVBoxLayout(body) + bl.setContentsMargins(16, 12, 16, 12) + + self.progress = QProgressBar() + self.progress.setValue(0) + bl.addWidget(self.progress) + + self.result_tree = QTreeWidget() + self.result_tree.setHeaderLabels(["Request", "Status", "Time", "Tests"]) + self.result_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.result_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.result_tree.header().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + self.result_tree.header().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + bl.addWidget(self.result_tree) + + self.summary_label = QLabel("") + self.summary_label.setObjectName("fieldLabel") + bl.addWidget(self.summary_label) + + layout.addWidget(body, 1) + + # ── Footer ──────────────────────────────────────────────────────────── + footer = QWidget() + footer.setObjectName("panelFooter") + footer.setFixedHeight(52) + fl = QHBoxLayout(footer) + fl.setContentsMargins(16, 0, 16, 0) + fl.addStretch() + close_btn = QPushButton("Close") + close_btn.setFixedWidth(80) + close_btn.clicked.connect(self.accept) + fl.addWidget(close_btn) + layout.addWidget(footer) + + def _run(self): + col_id = self.col_combo.currentData() + if col_id is None: + return + requests = storage.get_all_requests(col_id) + if not requests: + self.summary_label.setText("No requests in this collection.") + return + + self.result_tree.clear() + self.progress.setMaximum(len(requests)) + self.progress.setValue(0) + self.run_btn.setEnabled(False) + self._done = 0 + self._passed_tests = 0 + self._total_tests = 0 + + env = storage.get_active_environment() + variables = env.variables if env else {} + + self._worker = RunnerWorker(requests, variables) + self._worker.result_ready.connect(self._on_result) + self._worker.finished.connect(self._on_finished) + self._worker.start() + + def _on_result(self, result: CollectionRunResult): + self._done += 1 + self.progress.setValue(self._done) + + passed = sum(1 for t in result.test_results if t.passed) + total = len(result.test_results) + self._passed_tests += passed + self._total_tests += total + + if result.error: + status_str = "Error" + row_color = Colors.ERROR + else: + status_str = str(result.status) + row_color = Colors.SUCCESS if result.status < 400 else Colors.ERROR + + test_str = f"{passed}/{total}" if total > 0 else "—" + item = QTreeWidgetItem([ + f"{result.method} {result.request_name}", + status_str, + f"{result.elapsed_ms:.0f} ms", + test_str, + ]) + item.setForeground(1, QBrush(QColor(row_color))) + self.result_tree.addTopLevelItem(item) + + for tr in result.test_results: + icon = "✓" if tr.passed else "✗" + child = QTreeWidgetItem([f" {icon} {tr.name}", "", "", tr.message]) + child.setForeground(0, QBrush(QColor(Colors.SUCCESS if tr.passed else Colors.ERROR))) + item.addChild(child) + + item.setExpanded(True) + + def _on_finished(self): + self.run_btn.setEnabled(True) + self.summary_label.setText( + f"Completed: {self._done} request(s) — " + f"Tests: {self._passed_tests}/{self._total_tests} passed" + ) diff --git a/app/ui/environment_dialog.py b/app/ui/environment_dialog.py new file mode 100644 index 0000000..cf2d3d9 --- /dev/null +++ b/app/ui/environment_dialog.py @@ -0,0 +1,238 @@ +"""APIClient - Agent — Environment Manager Dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, + QPushButton, QTableWidget, QTableWidgetItem, QHeaderView, + QLabel, QInputDialog, QMessageBox, QSplitter, QWidget +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QBrush, QColor + +from app.ui.theme import Colors +from app.core import storage +from app.models import Environment + + +class EnvironmentDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Manage Environments") + self.setMinimumSize(760, 520) + self._current_env: Environment | None = None + self._dirty = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Title bar ───────────────────────────────────────────────────────── + title_bar = QWidget() + title_bar.setObjectName("panelHeader") + title_bar.setFixedHeight(48) + tl = QHBoxLayout(title_bar) + tl.setContentsMargins(16, 0, 16, 0) + title = QLabel("Manage Environments") + title.setObjectName("panelTitle") + tl.addWidget(title) + layout.addWidget(title_bar) + + # ── Splitter ────────────────────────────────────────────────────────── + splitter = QSplitter(Qt.Orientation.Horizontal) + splitter.setHandleWidth(1) + + # Left: environment list + left = QWidget() + left.setObjectName("sidebarPanel") + left.setFixedWidth(220) + ll = QVBoxLayout(left) + ll.setContentsMargins(0, 0, 0, 0) + ll.setSpacing(0) + + list_header = QWidget() + list_header.setObjectName("sectionHeader") + list_header.setFixedHeight(36) + lh = QHBoxLayout(list_header) + lh.setContentsMargins(12, 0, 8, 0) + env_heading = QLabel("ENVIRONMENTS") + env_heading.setObjectName("sectionLabel") + lh.addWidget(env_heading) + lh.addStretch() + add_env_btn = QPushButton("+") + add_env_btn.setObjectName("ghost") + add_env_btn.setFixedSize(26, 26) + add_env_btn.setToolTip("Add Environment") + add_env_btn.clicked.connect(self._add_env) + lh.addWidget(add_env_btn) + ll.addWidget(list_header) + + self.env_list = QListWidget() + self.env_list.setObjectName("sidebarList") + self.env_list.currentItemChanged.connect(self._on_env_selected) + ll.addWidget(self.env_list) + + btn_row = QHBoxLayout() + btn_row.setContentsMargins(8, 6, 8, 6) + btn_row.setSpacing(6) + self.activate_btn = QPushButton("Set Active") + self.activate_btn.clicked.connect(self._set_active) + self.del_btn = QPushButton("Delete") + self.del_btn.setObjectName("danger") + self.del_btn.clicked.connect(self._delete_env) + btn_row.addWidget(self.activate_btn) + btn_row.addWidget(self.del_btn) + ll.addLayout(btn_row) + splitter.addWidget(left) + + # Right: variable table + right = QWidget() + right.setObjectName("panelBody") + rl = QVBoxLayout(right) + rl.setContentsMargins(0, 0, 0, 0) + rl.setSpacing(0) + + var_header = QWidget() + var_header.setObjectName("panelHeader") + var_header.setFixedHeight(36) + vh = QHBoxLayout(var_header) + vh.setContentsMargins(16, 0, 12, 0) + var_label = QLabel("Variables") + var_label.setObjectName("fieldLabel") + vh.addWidget(var_label) + vh.addStretch() + add_var_btn = QPushButton("+ Add Variable") + add_var_btn.setObjectName("ghost") + add_var_btn.clicked.connect(self._add_var_row) + vh.addWidget(add_var_btn) + rl.addWidget(var_header) + + self.var_table = QTableWidget(0, 2) + self.var_table.setHorizontalHeaderLabels(["Variable", "Value"]) + self.var_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.var_table.verticalHeader().setVisible(False) + self.var_table.setAlternatingRowColors(True) + self.var_table.itemChanged.connect(self._on_var_changed) + rl.addWidget(self.var_table, 1) + splitter.addWidget(right) + splitter.setSizes([220, 520]) + + layout.addWidget(splitter, 1) + + # ── Bottom bar ──────────────────────────────────────────────────────── + bottom = QWidget() + bottom.setObjectName("panelFooter") + bottom.setFixedHeight(52) + bl = QHBoxLayout(bottom) + bl.setContentsMargins(16, 0, 16, 0) + bl.addStretch() + save_btn = QPushButton("Save & Close") + save_btn.setObjectName("accent") + save_btn.setFixedWidth(120) + save_btn.clicked.connect(self._save_and_close) + bl.addWidget(save_btn) + layout.addWidget(bottom) + + self._load_envs() + + # ── Data loading ────────────────────────────────────────────────────────── + + def _load_envs(self): + self.env_list.clear() + for env in storage.get_environments(): + label = f"{'● ' if env.is_active else ' '} {env.name}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, env) + if env.is_active: + item.setForeground(QBrush(QColor(Colors.ACCENT))) + self.env_list.addItem(item) + + def _on_env_selected(self, current, _previous): + if self._current_env and self._dirty: + self._current_env.variables = self._get_vars() + + if not current: + return + self._current_env = current.data(Qt.ItemDataRole.UserRole) + self._dirty = False + self._load_vars(self._current_env.variables) + + def _load_vars(self, variables: dict): + self.var_table.blockSignals(True) + self.var_table.setRowCount(0) + for k, v in variables.items(): + row = self.var_table.rowCount() + self.var_table.insertRow(row) + self.var_table.setItem(row, 0, QTableWidgetItem(k)) + self.var_table.setItem(row, 1, QTableWidgetItem(str(v))) + self.var_table.blockSignals(False) + + def _on_var_changed(self): + if self._current_env: + self._dirty = True + + def _get_vars(self) -> dict: + result = {} + for row in range(self.var_table.rowCount()): + k = self.var_table.item(row, 0) + v = self.var_table.item(row, 1) + if k and k.text().strip(): + result[k.text().strip()] = v.text() if v else "" + return result + + # ── Actions ─────────────────────────────────────────────────────────────── + + def _add_var_row(self): + if not self._current_env: + QMessageBox.information( + self, "No Environment Selected", + "Select or create an environment first." + ) + return + row = self.var_table.rowCount() + self.var_table.insertRow(row) + self.var_table.setItem(row, 0, QTableWidgetItem("")) + self.var_table.setItem(row, 1, QTableWidgetItem("")) + self.var_table.editItem(self.var_table.item(row, 0)) + + def _add_env(self): + name, ok = QInputDialog.getText(self, "New Environment", "Name:") + if not ok or not name.strip(): + return + env = Environment(name=name.strip()) + env_id = storage.save_environment(env) + env.id = env_id + self._load_envs() + for i in range(self.env_list.count()): + item = self.env_list.item(i) + if item.data(Qt.ItemDataRole.UserRole).id == env_id: + self.env_list.setCurrentItem(item) + break + + def _delete_env(self): + item = self.env_list.currentItem() + if not item: + return + env = item.data(Qt.ItemDataRole.UserRole) + reply = QMessageBox.question( + self, "Delete Environment", + f"Delete '{env.name}'? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel + ) + if reply == QMessageBox.StandardButton.Yes: + storage.delete_environment(env.id) + self._current_env = None + self._dirty = False + self.var_table.setRowCount(0) + self._load_envs() + + def _set_active(self): + item = self.env_list.currentItem() + if not item: + return + env = item.data(Qt.ItemDataRole.UserRole) + storage.set_active_environment(env.id) + self._load_envs() + + def _save_and_close(self): + if self._current_env: + self._current_env.variables = self._get_vars() + storage.save_environment(self._current_env) + self.accept() diff --git a/app/ui/highlighter.py b/app/ui/highlighter.py new file mode 100644 index 0000000..e0d8e03 --- /dev/null +++ b/app/ui/highlighter.py @@ -0,0 +1,32 @@ +"""Simple JSON/general syntax highlighter for QTextEdit.""" +import re +from PyQt6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont + + +class JsonHighlighter(QSyntaxHighlighter): + def __init__(self, parent=None): + super().__init__(parent) + self._rules = [] + + def fmt(color, bold=False): + f = QTextCharFormat() + f.setForeground(QColor(color)) + if bold: + f.setFontWeight(QFont.Weight.Bold) + return f + + # Keys + self._rules.append((re.compile(r'"([^"\\]|\\.)*"\s*(?=:)'), fmt("#9CDCFE"))) + # String values + self._rules.append((re.compile(r'(? QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.tabs_manager = TabsManager() + self.tabs_manager.send_requested.connect(self._send) + + self.response_panel = ResponsePanel() + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.setHandleWidth(1) + splitter.addWidget(self.tabs_manager) + splitter.addWidget(self.response_panel) + splitter.setSizes([400, 400]) + layout.addWidget(splitter) + return w + + # ── Menu ────────────────────────────────────────────────────────────────── + + def _build_menu(self): + mb = self.menuBar() + + file_m = mb.addMenu("File") + file_m.addAction("New Tab", self.tabs_manager.new_tab).setShortcut("Ctrl+T") + file_m.addAction("Close Tab", self.tabs_manager.close_current_tab).setShortcut("Ctrl+W") + file_m.addSeparator() + file_m.addAction("Save to Collection", self._save_to_collection).setShortcut("Ctrl+S") + file_m.addSeparator() + file_m.addAction("Import…", self._import) + file_m.addAction("Export Collection…", self._export) + file_m.addSeparator() + file_m.addAction("Quit", self.close).setShortcut("Ctrl+Q") + + view_m = mb.addMenu("View") + view_m.addAction("Search Requests", self._open_search).setShortcut("Ctrl+F") + view_m.addAction("Toggle Theme", self._toggle_theme) + + tools_m = mb.addMenu("Tools") + tools_m.addAction("Environments…", self._open_env_dialog).setShortcut("Ctrl+E") + tools_m.addAction("Collection Runner…", self._open_runner) + tools_m.addAction("Generate Code…", self._open_code_gen) + tools_m.addSeparator() + tools_m.addAction("AI Assistant…", self._open_ai_assistant) + + mb.addMenu("Help").addAction( + f"About {APP_NAME}", + lambda: QMessageBox.about( + self, APP_NAME, + f"{APP_NAME} v{APP_VERSION}
" + "Enterprise-grade API testing tool with AI co-pilot.

" + "Built with Python + PyQt6" + ) + ) + + def _build_shortcuts(self): + QShortcut(QKeySequence("Ctrl+Return"), self, self._send_current) + QShortcut(QKeySequence("Ctrl+T"), self, self.tabs_manager.new_tab) + QShortcut(QKeySequence("Ctrl+W"), self, self.tabs_manager.close_current_tab) + QShortcut(QKeySequence("Ctrl+S"), self, self._save_to_collection) + QShortcut(QKeySequence("Ctrl+F"), self, self._open_search) + QShortcut(QKeySequence("Ctrl+E"), self, self._open_env_dialog) + QShortcut(QKeySequence("Ctrl+Shift+A"), self, self._toggle_ai_chat) + QShortcut(QKeySequence("Escape"), self, self._cancel_request) + QShortcut(QKeySequence("Ctrl+Q"), self, self.close) + + # ── Environment ─────────────────────────────────────────────────────────── + + def _update_env_selector(self): + combo = self.env_bar.env_combo + combo.blockSignals(True) + combo.clear() + combo.addItem("No Environment", None) + active = storage.get_active_environment() + for e in storage.get_environments(): + combo.addItem(e.name, e.id) + if active and e.id == active.id: + combo.setCurrentIndex(combo.count() - 1) + combo.blockSignals(False) + + def _on_env_changed(self, _): + env_id = self.env_bar.env_combo.currentData() + storage.set_active_environment(env_id) + self._set_status(f"Environment: {self.env_bar.env_combo.currentText()}") + + def _get_active_variables(self) -> dict: + env = storage.get_active_environment() + return env.variables if env else {} + + # ── Sending ─────────────────────────────────────────────────────────────── + + def _send_current(self): + tab = self.tabs_manager.current_tab() + if tab: + self._send(tab.get_request()) + + def _send(self, req: HttpRequest): + if not req.url.strip(): + self._set_status("Enter a URL first", error=True) + return + if self._worker and self._worker.isRunning(): + self._worker.cancel() + self._worker.wait(500) + + tab = self.tabs_manager.current_tab() + if tab: + tab.request_panel.send_btn.setEnabled(False) + tab.request_panel.send_btn.setText("Sending…") + + self.response_panel.set_loading(True) + storage.add_to_history(req) + self.sidebar.refresh() + + self._worker = RequestWorker(req, self._get_active_variables()) + self._worker.finished.connect(self._on_response) + self._worker.start() + self._set_status(f"⟳ {req.method} {req.url}") + if self.chat_panel.isVisible(): + self.chat_panel.set_context(req=req, env_vars=self._get_active_variables()) + + def _cancel_request(self): + if self._worker and self._worker.isRunning(): + self._worker.cancel() + self._worker.wait(500) + self.response_panel.set_loading(False) + self._restore_send_btn() + self._set_status("Request cancelled") + + def _restore_send_btn(self): + tab = self.tabs_manager.current_tab() + if tab: + tab.request_panel.send_btn.setEnabled(True) + tab.request_panel.send_btn.setText("Send") + + def _on_response(self, resp, tests): + self.response_panel.set_loading(False) + self.response_panel.display(resp, tests) + self._restore_send_btn() + tab = self.tabs_manager.current_tab() + req = tab.get_request() if tab else None + if self.chat_panel.isVisible(): + self.chat_panel.set_context(req=req, resp=resp, env_vars=self._get_active_variables()) + if resp.error: + self._set_status(f"✗ {resp.error}", error=True) + else: + size = resp.size_bytes or len(resp.body.encode()) + self._set_status( + f"✓ {resp.status} {resp.reason} {resp.elapsed_ms:.0f} ms {_fmt_size(size)}" + ) + + # ── Request loading ─────────────────────────────────────────────────────── + + def _load_request_in_tab(self, req: HttpRequest): + self.tabs_manager.load_request_in_new_tab(req) + if self.chat_panel.isVisible(): + self.chat_panel.set_context(req=req, env_vars=self._get_active_variables()) + + # ── Save ────────────────────────────────────────────────────────────────── + + def _save_to_collection(self): + cols = storage.get_collections() + if not cols: + QMessageBox.information(self, "No Collections", + "Create a collection first using the + button in the sidebar.") + return + col_name, ok = QInputDialog.getItem( + self, "Save Request", "Collection:", [c["name"] for c in cols], 0, False) + if not ok: + return + col_id = next(c["id"] for c in cols if c["name"] == col_name) + tab = self.tabs_manager.current_tab() + if not tab: + return + req = tab.get_request() + req_name, ok2 = QInputDialog.getText( + self, "Request Name", "Name:", text=req.name or req.url or "New Request") + if not ok2 or not req_name.strip(): + return + req.name = req_name.strip() + storage.save_request(col_id, req) + self.tabs_manager.rename_current_tab(req.name) + self.sidebar.refresh() + self._set_status(f"✓ Saved '{req.name}' → '{col_name}'") + + # ── Dialogs ─────────────────────────────────────────────────────────────── + + def _open_env_dialog(self): + from app.ui.environment_dialog import EnvironmentDialog + EnvironmentDialog(self).exec() + self._update_env_selector() + + def _open_runner(self): + from app.ui.collection_runner import CollectionRunnerDialog + CollectionRunnerDialog(self).exec() + + def _open_code_gen(self): + tab = self.tabs_manager.current_tab() + if not tab: + return + from app.ui.code_gen_dialog import CodeGenDialog + CodeGenDialog(tab.get_request(), self).exec() + + def _import(self): + from app.ui.import_dialog import ImportDialog + dlg = ImportDialog(self) + dlg.exec() + if dlg.imported_req: + self.tabs_manager.load_request_in_new_tab(dlg.imported_req) + self.sidebar.refresh() + + def _export(self): + cols = storage.get_collections() + if not cols: + QMessageBox.information(self, "Nothing to Export", "No collections found.") + return + name, ok = QInputDialog.getItem( + self, "Export Collection", "Collection:", [c["name"] for c in cols], 0, False) + if not ok: + return + col_id = next(c["id"] for c in cols if c["name"] == name) + path, _ = QFileDialog.getSaveFileName( + self, "Save", f"{name}.json", "JSON (*.json)") + if not path: + return + from app.core.exporter import export_collection + with open(path, "w", encoding="utf-8") as f: + f.write(export_collection(col_id)) + self._set_status(f"✓ Exported to {path}") + + def _open_search(self): + from app.ui.search_dialog import SearchDialog + dlg = SearchDialog(self) + dlg.request_selected.connect(self._load_request_in_tab) + dlg.exec() + + def _open_ai_assistant(self): + from app.ui.ai_panel import AIAssistantDialog + dlg = AIAssistantDialog(self) + dlg.collection_imported.connect(self.sidebar.refresh) + dlg.exec() + self._update_env_selector() + self.sidebar.refresh() + + def _toggle_ai_chat(self): + if self.chat_panel.isVisible(): + self.chat_panel.hide() + else: + # Ensure the splitter gives the panel a visible width + sizes = self._main_splitter.sizes() + if sizes[2] < 50: + total = self._main_splitter.width() - 2 # 2 for handles + chat_w = 360 + side_w = sizes[0] + work_w = max(total - side_w - chat_w, 400) + self._main_splitter.setSizes([side_w, work_w, chat_w]) + self.chat_panel.show() + tab = self.tabs_manager.current_tab() + if tab: + self.chat_panel.set_context( + req=tab.get_request(), + env_vars=self._get_active_variables() + ) + + def _ai_apply(self, atype: str, content: str): + tab = self.tabs_manager.current_tab() + if not tab: + return + rp = tab.request_panel + if atype == "body": + rp.apply_body(content) + elif atype == "params": + rp.apply_params(content) + elif atype == "headers": + rp.apply_headers(content) + elif atype == "test": + rp.apply_test_script(content) + + # ── Theme ───────────────────────────────────────────────────────────────── + + def _toggle_theme(self): + toggle_theme(QApplication.instance()) + self.env_bar.theme_btn.setText("◑" if is_dark() else "◐") + + # ── Status Bar ──────────────────────────────────────────────────────────── + + def _set_status(self, msg: str, error: bool = False): + color = Colors.ERROR if error else Colors.TEXT_SECONDARY + self._status_bar.setStyleSheet( + f"QStatusBar {{ color: {color}; background: {Colors.BG_DARKEST}; " + f"border-top: 1px solid {Colors.BORDER}; font-size: 11px; }}" + ) + self._status_bar.showMessage(msg, 8000) + + +def _fmt_size(n: int) -> str: + if n < 1024: return f"{n} B" + if n < 1024 * 1024: return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.2f} MB" diff --git a/app/ui/mock_server_panel.py b/app/ui/mock_server_panel.py new file mode 100644 index 0000000..759e0c7 --- /dev/null +++ b/app/ui/mock_server_panel.py @@ -0,0 +1,222 @@ +"""APIClient - Agent — Mock Server Panel.""" +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, + QTableWidget, QTableWidgetItem, QHeaderView, QDialog, + QFormLayout, QLineEdit, QComboBox, QTextEdit, QSpinBox, + QDialogButtonBox, QMessageBox +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QBrush, QColor + +from app.ui.theme import Colors, restyle +from app.core import mock_server, storage +from app.models import MockEndpoint + + +class EndpointDialog(QDialog): + def __init__(self, ep: MockEndpoint = None, parent=None): + super().__init__(parent) + self.setWindowTitle("Mock Endpoint") + self.setMinimumWidth(480) + self.ep = MockEndpoint() if ep is None else MockEndpoint( + id=ep.id, name=ep.name, method=ep.method, path=ep.path, + status_code=ep.status_code, response_headers=ep.response_headers, + response_body=ep.response_body + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + header = QWidget() + header.setObjectName("panelHeader") + header.setFixedHeight(44) + hl = QHBoxLayout(header) + hl.setContentsMargins(16, 0, 16, 0) + title = QLabel("Configure Mock Endpoint") + title.setObjectName("panelTitle") + hl.addWidget(title) + layout.addWidget(header) + + body = QWidget() + bl = QVBoxLayout(body) + form = QFormLayout() + form.setContentsMargins(16, 12, 16, 12) + form.setSpacing(10) + + self.name_input = QLineEdit(self.ep.name or "") + self.name_input.setPlaceholderText("Optional display name") + + self.method_combo = QComboBox() + self.method_combo.addItems(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "*"]) + self.method_combo.setCurrentText(self.ep.method or "GET") + + self.path_input = QLineEdit(self.ep.path or "/") + self.path_input.setPlaceholderText("/api/v1/resource") + + self.status_spin = QSpinBox() + self.status_spin.setRange(100, 599) + self.status_spin.setValue(self.ep.status_code or 200) + + self.body_editor = QTextEdit() + self.body_editor.setPlaceholderText('{"message": "ok"}') + self.body_editor.setPlainText(self.ep.response_body or "") + self.body_editor.setMaximumHeight(140) + + form.addRow("Name:", self.name_input) + form.addRow("Method:", self.method_combo) + form.addRow("Path:", self.path_input) + form.addRow("Status Code:", self.status_spin) + form.addRow("Response Body:", self.body_editor) + bl.addLayout(form) + layout.addWidget(body, 1) + + footer = QWidget() + footer.setObjectName("panelFooter") + fl = QVBoxLayout(footer) + fl.setContentsMargins(12, 8, 12, 8) + btns = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + btns.accepted.connect(self._save) + btns.rejected.connect(self.reject) + fl.addWidget(btns) + layout.addWidget(footer) + + def _save(self): + path = self.path_input.text().strip() + if not path: + QMessageBox.warning(self, "Validation", "Path is required.") + return + if not path.startswith("/"): + path = "/" + path + self.ep.name = self.name_input.text().strip() + self.ep.method = self.method_combo.currentText() + self.ep.path = path + self.ep.status_code = self.status_spin.value() + self.ep.response_body = self.body_editor.toPlainText() + self.accept() + + +class MockServerPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # ── Server controls ─────────────────────────────────────────────────── + top = QHBoxLayout() + port_label = QLabel("Port:") + port_label.setObjectName("fieldLabel") + self.port_input = QLineEdit("8888") + self.port_input.setFixedWidth(70) + self.port_input.setToolTip("Listening port for the mock server") + + self.toggle_btn = QPushButton("Start Server") + self.toggle_btn.setObjectName("accent") + self.toggle_btn.setFixedWidth(120) + self.toggle_btn.clicked.connect(self._toggle_server) + + self.status_label = QLabel("● Stopped") + self.status_label.setObjectName("statusErr") + + top.addWidget(port_label) + top.addWidget(self.port_input) + top.addWidget(self.toggle_btn) + top.addStretch() + top.addWidget(self.status_label) + layout.addLayout(top) + + # ── Endpoint table ──────────────────────────────────────────────────── + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Name", "Method", "Path", "Status"]) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + self.table.verticalHeader().setVisible(False) + self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.table.doubleClicked.connect(self._edit_endpoint) + layout.addWidget(self.table) + + # ── Action buttons ──────────────────────────────────────────────────── + btn_row = QHBoxLayout() + add_btn = QPushButton("+ Add Endpoint") + add_btn.clicked.connect(self._add_endpoint) + del_btn = QPushButton("Delete") + del_btn.setObjectName("danger") + del_btn.clicked.connect(self._delete_endpoint) + btn_row.addWidget(add_btn) + btn_row.addWidget(del_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + self._load_endpoints() + + # ── Data loading ────────────────────────────────────────────────────────── + + def _load_endpoints(self): + self.table.setRowCount(0) + for ep in storage.get_mock_endpoints(): + row = self.table.rowCount() + self.table.insertRow(row) + self.table.setItem(row, 0, QTableWidgetItem(ep.name or "")) + method_item = QTableWidgetItem(ep.method) + method_item.setForeground(QBrush(QColor(Colors.INFO))) + self.table.setItem(row, 1, method_item) + self.table.setItem(row, 2, QTableWidgetItem(ep.path)) + self.table.setItem(row, 3, QTableWidgetItem(str(ep.status_code))) + self.table.item(row, 0).setData(Qt.ItemDataRole.UserRole, ep) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def _add_endpoint(self): + dlg = EndpointDialog(parent=self) + if dlg.exec(): + storage.save_mock_endpoint(dlg.ep) + self._load_endpoints() + + def _edit_endpoint(self): + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 0) + if not item: + return + ep = item.data(Qt.ItemDataRole.UserRole) + dlg = EndpointDialog(ep=ep, parent=self) + if dlg.exec(): + storage.save_mock_endpoint(dlg.ep) + self._load_endpoints() + + def _delete_endpoint(self): + row = self.table.currentRow() + if row < 0: + return + item = self.table.item(row, 0) + if not item: + return + ep = item.data(Qt.ItemDataRole.UserRole) + if ep and ep.id: + storage.delete_mock_endpoint(ep.id) + self._load_endpoints() + + def _toggle_server(self): + if mock_server.is_running(): + mock_server.stop() + self.toggle_btn.setText("Start Server") + restyle(self.status_label, "statusErr") + self.status_label.setText("● Stopped") + else: + try: + port = int(self.port_input.text()) + except ValueError: + port = 8888 + msg = mock_server.start(port) + if mock_server.is_running(): + self.toggle_btn.setText("Stop Server") + restyle(self.status_label, "statusOk") + self.status_label.setText(f"● Running on :{port}") + else: + QMessageBox.warning(self, "Mock Server", msg) diff --git a/app/ui/request_panel.py b/app/ui/request_panel.py new file mode 100644 index 0000000..43a2a77 --- /dev/null +++ b/app/ui/request_panel.py @@ -0,0 +1,467 @@ +"""APIClient - Agent — Request Panel.""" +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLineEdit, + QPushButton, QTabWidget, QTableWidget, QTableWidgetItem, + QTextEdit, QHeaderView, QLabel, QFormLayout, QStackedWidget, + QCheckBox, QSpinBox +) +from PyQt6.QtCore import pyqtSignal, Qt +from PyQt6.QtGui import QFont + +from app.ui.theme import Colors, method_color +from app.ui.highlighter import JsonHighlighter +from app.models import HttpRequest + +HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + + +class KeyValueTable(QTableWidget): + """Editable key-value table with enable/disable checkboxes per row.""" + + def __init__(self, key_hint: str = "Key", val_hint: str = "Value", parent=None): + super().__init__(0, 3, parent) + self.setHorizontalHeaderLabels(["", key_hint, val_hint]) + hh = self.horizontalHeader() + hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + hh.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self.setColumnWidth(0, 32) + self.verticalHeader().setVisible(False) + self.verticalHeader().setDefaultSectionSize(36) + self.setAlternatingRowColors(True) + self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._add_empty_row() + self.itemChanged.connect(self._on_item_changed) + + def _make_checkbox_item(self, checked: bool = True) -> QTableWidgetItem: + item = QTableWidgetItem() + item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) + item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked) + return item + + def _add_empty_row(self): + row = self.rowCount() + self.blockSignals(True) + self.insertRow(row) + self.setItem(row, 0, self._make_checkbox_item(True)) + self.setItem(row, 1, QTableWidgetItem("")) + self.setItem(row, 2, QTableWidgetItem("")) + self.blockSignals(False) + + def _on_item_changed(self, item): + if item.column() in (1, 2) and item.row() == self.rowCount() - 1 and item.text().strip(): + self._add_empty_row() + + def get_pairs(self) -> dict: + result = {} + for row in range(self.rowCount()): + chk = self.item(row, 0) + if chk and chk.checkState() == Qt.CheckState.Unchecked: + continue + k = self.item(row, 1) + v = self.item(row, 2) + key = k.text().strip() if k else "" + val = v.text().strip() if v else "" + if key: + result[key] = val + return result + + def set_pairs(self, pairs: dict): + self.blockSignals(True) + self.setRowCount(0) + for k, v in pairs.items(): + row = self.rowCount() + self.insertRow(row) + self.setItem(row, 0, self._make_checkbox_item(True)) + self.setItem(row, 1, QTableWidgetItem(str(k))) + self.setItem(row, 2, QTableWidgetItem(str(v))) + self.blockSignals(False) + self._add_empty_row() + + +class AuthWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 12, 16, 12) + layout.setSpacing(10) + + row = QHBoxLayout() + row.addWidget(QLabel("Auth Type:")) + self.type_combo = QComboBox() + self.type_combo.addItems(["None", "Bearer Token", "Basic Auth", "API Key"]) + self.type_combo.setMaximumWidth(200) + self.type_combo.currentIndexChanged.connect(self._on_type) + row.addWidget(self.type_combo) + row.addStretch() + layout.addLayout(row) + + self.stack = QStackedWidget() + + # None page + none_lbl = QLabel("No authentication configured.") + none_lbl.setObjectName("authNone") + self.stack.addWidget(none_lbl) + + # Bearer page + bearer_w = QWidget() + fl = QFormLayout(bearer_w) + fl.setContentsMargins(0, 8, 0, 0) + self.bearer_token = QLineEdit() + self.bearer_token.setPlaceholderText("{{token}}") + self.bearer_token.setEchoMode(QLineEdit.EchoMode.Password) + show_bearer = QCheckBox("Show") + show_bearer.toggled.connect( + lambda on: self.bearer_token.setEchoMode( + QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password + ) + ) + tok_row = QHBoxLayout() + tok_row.addWidget(self.bearer_token) + tok_row.addWidget(show_bearer) + fl.addRow("Token:", tok_row) + self.stack.addWidget(bearer_w) + + # Basic page + basic_w = QWidget() + fl2 = QFormLayout(basic_w) + fl2.setContentsMargins(0, 8, 0, 0) + self.basic_user = QLineEdit() + self.basic_user.setPlaceholderText("username") + self.basic_pass = QLineEdit() + self.basic_pass.setPlaceholderText("password") + self.basic_pass.setEchoMode(QLineEdit.EchoMode.Password) + show_pass = QCheckBox("Show") + show_pass.toggled.connect( + lambda on: self.basic_pass.setEchoMode( + QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password + ) + ) + pass_row = QHBoxLayout() + pass_row.addWidget(self.basic_pass) + pass_row.addWidget(show_pass) + fl2.addRow("Username:", self.basic_user) + fl2.addRow("Password:", pass_row) + self.stack.addWidget(basic_w) + + # API Key page + apikey_w = QWidget() + fl3 = QFormLayout(apikey_w) + fl3.setContentsMargins(0, 8, 0, 0) + self.apikey_key = QLineEdit() + self.apikey_key.setPlaceholderText("X-API-Key") + self.apikey_value = QLineEdit() + self.apikey_value.setPlaceholderText("{{api_key}}") + self.apikey_in = QComboBox() + self.apikey_in.addItems(["header", "query"]) + fl3.addRow("Key:", self.apikey_key) + fl3.addRow("Value:", self.apikey_value) + fl3.addRow("Add to:", self.apikey_in) + self.stack.addWidget(apikey_w) + + layout.addWidget(self.stack) + layout.addStretch() + + def _on_type(self, idx: int): + self.stack.setCurrentIndex(idx) + + def get_auth(self) -> tuple[str, dict]: + idx = self.type_combo.currentIndex() + if idx == 1: + return "bearer", {"token": self.bearer_token.text()} + if idx == 2: + return "basic", {"username": self.basic_user.text(), "password": self.basic_pass.text()} + if idx == 3: + return "apikey", { + "key": self.apikey_key.text(), + "value": self.apikey_value.text(), + "in": self.apikey_in.currentText(), + } + return "none", {} + + def set_auth(self, auth_type: str, auth_data: dict): + idx_map = {"none": 0, "bearer": 1, "basic": 2, "apikey": 3} + self.type_combo.setCurrentIndex(idx_map.get(auth_type, 0)) + if auth_type == "bearer": + self.bearer_token.setText(auth_data.get("token", "")) + elif auth_type == "basic": + self.basic_user.setText(auth_data.get("username", "")) + self.basic_pass.setText(auth_data.get("password", "")) + elif auth_type == "apikey": + self.apikey_key.setText(auth_data.get("key", "")) + self.apikey_value.setText(auth_data.get("value", "")) + self.apikey_in.setCurrentText(auth_data.get("in", "header")) + + +def _mono_editor(placeholder: str = "") -> QTextEdit: + e = QTextEdit() + e.setObjectName("codeEditor") + e.setPlaceholderText(placeholder) + e.setAcceptRichText(False) + e.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11)) + return e + + +class RequestPanel(QWidget): + send_requested = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── URL bar ────────────────────────────────────────────────────────── + url_bar = QWidget() + url_bar.setObjectName("urlBarStrip") + url_bar.setFixedHeight(56) + url_layout = QHBoxLayout(url_bar) + url_layout.setContentsMargins(12, 0, 12, 0) + url_layout.setSpacing(8) + + self.method_combo = QComboBox() + self.method_combo.setObjectName("methodCombo") + self.method_combo.addItems(HTTP_METHODS) + self.method_combo.setFixedWidth(105) + self.method_combo.currentTextChanged.connect(self._on_method_changed) + + self.url_input = QLineEdit() + self.url_input.setObjectName("urlBar") + self.url_input.setPlaceholderText("Enter URL — e.g. https://api.example.com/v1/users") + self.url_input.returnPressed.connect(self._send) + + self.send_btn = QPushButton("Send") + self.send_btn.setObjectName("sendBtn") + self.send_btn.setFixedWidth(90) + self.send_btn.setToolTip("Send request (Ctrl+Enter)") + self.send_btn.clicked.connect(self._send) + + url_layout.addWidget(self.method_combo) + url_layout.addWidget(self.url_input, 1) + url_layout.addWidget(self.send_btn) + layout.addWidget(url_bar) + + # ── Request tabs ───────────────────────────────────────────────────── + self.tabs = QTabWidget() + self.tabs.setObjectName("innerTabs") + + # Params / Headers + self.params_table = KeyValueTable("Parameter", "Value") + self.headers_table = KeyValueTable("Header", "Value") + + # Auth + self.auth_widget = AuthWidget() + + # Body tab + body_w = QWidget() + bl = QVBoxLayout(body_w) + bl.setContentsMargins(12, 8, 12, 8) + bl.setSpacing(6) + + type_row = QHBoxLayout() + type_row.addWidget(QLabel("Format:")) + self.body_type_combo = QComboBox() + self.body_type_combo.addItems(["raw", "form-urlencoded", "form-data"]) + self.body_type_combo.setMaximumWidth(160) + self.body_type_combo.currentTextChanged.connect(self._on_body_type_changed) + type_row.addWidget(self.body_type_combo) + type_row.addSpacing(12) + + self.ct_label = QLabel("Content-Type:") + self.ct_label.setObjectName("fieldLabel") + self.ct_combo = QComboBox() + self.ct_combo.addItems([ + "application/vnd.api+json", + "application/json", + "application/xml", + "text/plain", + "text/html", + "application/x-www-form-urlencoded", + ]) + self.ct_combo.setMaximumWidth(230) + type_row.addWidget(self.ct_label) + type_row.addWidget(self.ct_combo) + type_row.addStretch() + + fmt_btn = QPushButton("{ } Format") + fmt_btn.setObjectName("ghost") + fmt_btn.setFixedHeight(28) + fmt_btn.setToolTip("Pretty-print / format JSON body (Ctrl+Shift+F)") + fmt_btn.clicked.connect(self._format_body) + type_row.addWidget(fmt_btn) + bl.addLayout(type_row) + + self.body_editor = _mono_editor('{\n "key": "value"\n}') + self._body_hl = JsonHighlighter(self.body_editor.document()) + bl.addWidget(self.body_editor) + + # Pre-request scripts + pre_w = QWidget() + pl = QVBoxLayout(pre_w) + pl.setContentsMargins(12, 8, 12, 8) + hint = QLabel("Python executed before the request. Use pm.environment.get('key') to read variables.") + hint.setObjectName("hintText") + hint.setWordWrap(True) + pl.addWidget(hint) + self.pre_script_editor = _mono_editor("# Example:\n# pm.environment.set('token', 'my-value')") + pl.addWidget(self.pre_script_editor) + + # Test scripts + test_w = QWidget() + tl = QVBoxLayout(test_w) + tl.setContentsMargins(12, 8, 12, 8) + hint2 = QLabel("Assertions run automatically after each response is received.") + hint2.setObjectName("hintText") + hint2.setWordWrap(True) + tl.addWidget(hint2) + self.test_editor = _mono_editor( + "pm.test('Status is 200', lambda: pm.response.to_have_status(200))\n" + "pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())" + ) + tl.addWidget(self.test_editor) + + # Settings tab (timeout, SSL) + settings_filler = QWidget() + sl_outer = QVBoxLayout(settings_filler) + sl_outer.setContentsMargins(0, 0, 0, 0) + + sl = QFormLayout() + sl.setContentsMargins(16, 12, 16, 12) + sl.setSpacing(10) + + self.timeout_spin = QSpinBox() + self.timeout_spin.setRange(1, 300) + self.timeout_spin.setValue(30) + self.timeout_spin.setSuffix(" s") + self.timeout_spin.setToolTip("Request timeout in seconds") + sl.addRow("Timeout:", self.timeout_spin) + + self.ssl_check = QCheckBox("Verify SSL certificate") + self.ssl_check.setChecked(True) + self.ssl_check.setToolTip("Uncheck to allow self-signed or invalid certificates") + sl.addRow("SSL:", self.ssl_check) + + sl_outer.addLayout(sl) + sl_outer.addStretch() + + self.tabs.addTab(self.params_table, "Params") + self.tabs.addTab(self.headers_table, "Headers") + self.tabs.addTab(self.auth_widget, "Auth") + self.tabs.addTab(body_w, "Body") + self.tabs.addTab(pre_w, "Pre-request") + self.tabs.addTab(test_w, "Tests") + self.tabs.addTab(settings_filler, "Settings") + layout.addWidget(self.tabs, 1) + + # Apply initial method color after all widgets are built + self._on_method_changed(self.method_combo.currentText()) + + # ── Slots ──────────────────────────────────────────────────────────────── + + def _on_method_changed(self, method: str): + # Inline style is intentional here — color is dynamic per method value + color = method_color(method) + self.method_combo.setStyleSheet(f"QComboBox#methodCombo {{ color: {color}; }}") + + def _on_body_type_changed(self, body_type: str): + raw = body_type == "raw" + self.ct_label.setVisible(raw) + self.ct_combo.setVisible(raw) + + def _format_body(self): + import json + text = self.body_editor.toPlainText().strip() + if not text: + return + try: + parsed = json.loads(text) + self.body_editor.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False)) + except json.JSONDecodeError: + pass # not valid JSON — leave as-is + + def _send(self): + self.send_requested.emit(self._build_request()) + + # ── Public API ──────────────────────────────────────────────────────────── + + def get_request(self) -> HttpRequest: + return self._build_request() + + def _build_request(self) -> HttpRequest: + auth_type, auth_data = self.auth_widget.get_auth() + body_type = self.body_type_combo.currentText() + content_type = self.ct_combo.currentText() if body_type == "raw" else "" + return HttpRequest( + method = self.method_combo.currentText(), + url = self.url_input.text().strip(), + headers = self.headers_table.get_pairs(), + params = self.params_table.get_pairs(), + body = self.body_editor.toPlainText(), + body_type = body_type, + content_type = content_type, + auth_type = auth_type, + auth_data = auth_data, + pre_request_script = self.pre_script_editor.toPlainText(), + test_script = self.test_editor.toPlainText(), + timeout = self.timeout_spin.value(), + ssl_verify = self.ssl_check.isChecked(), + ) + + def load_request(self, req: HttpRequest): + idx = self.method_combo.findText(req.method) + if idx >= 0: + self.method_combo.setCurrentIndex(idx) + self.url_input.setText(req.url) + self.headers_table.set_pairs(req.headers or {}) + self.params_table.set_pairs(req.params or {}) + self.body_editor.setPlainText(req.body or "") + self.body_type_combo.setCurrentText(req.body_type or "raw") + if req.content_type: + idx_ct = self.ct_combo.findText(req.content_type) + if idx_ct >= 0: + self.ct_combo.setCurrentIndex(idx_ct) + self.auth_widget.set_auth(req.auth_type or "none", req.auth_data or {}) + self.pre_script_editor.setPlainText(req.pre_request_script or "") + self.test_editor.setPlainText(req.test_script or "") + self.timeout_spin.setValue(req.timeout or 30) + self.ssl_check.setChecked(req.ssl_verify if req.ssl_verify is not None else True) + + def apply_body(self, content: str): + """Set body from AI suggestion and switch to Body tab.""" + self.body_editor.setPlainText(content) + self.tabs.setCurrentIndex(3) # Body tab + + def apply_params(self, content: str): + """Parse key=value lines and merge into params table.""" + pairs = {} + for line in content.splitlines(): + line = line.strip() + if "=" in line and not line.startswith("#"): + k, _, v = line.partition("=") + if k.strip(): + pairs[k.strip()] = v.strip() + if pairs: + existing = self.params_table.get_pairs() + existing.update(pairs) + self.params_table.set_pairs(existing) + self.tabs.setCurrentIndex(0) # Params tab + + def apply_headers(self, content: str): + """Parse Header: value lines and merge into headers table.""" + pairs = {} + for line in content.splitlines(): + line = line.strip() + if ":" in line and not line.startswith("#"): + k, _, v = line.partition(":") + if k.strip(): + pairs[k.strip()] = v.strip() + if pairs: + existing = self.headers_table.get_pairs() + existing.update(pairs) + self.headers_table.set_pairs(existing) + self.tabs.setCurrentIndex(1) # Headers tab + + def apply_test_script(self, content: str): + """Set test script from AI suggestion and switch to Tests tab.""" + self.test_editor.setPlainText(content) + self.tabs.setCurrentIndex(5) # Tests tab diff --git a/app/ui/response_panel.py b/app/ui/response_panel.py new file mode 100644 index 0000000..45b9a00 --- /dev/null +++ b/app/ui/response_panel.py @@ -0,0 +1,290 @@ +"""APIClient - Agent — Response Panel.""" +import json + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget, + QTextEdit, QTableWidget, QTableWidgetItem, QHeaderView, + QPushButton, QLineEdit, QApplication, QTreeWidget, + QTreeWidgetItem, QFrame, QFileDialog, QStackedWidget +) +from PyQt6.QtGui import QFont, QTextDocument, QTextCursor, QBrush, QColor +from PyQt6.QtCore import Qt + +from app.ui.theme import Colors, status_color +from app.ui.highlighter import JsonHighlighter +from app.models import HttpResponse, TestResult + + +def _fmt_size(n: int) -> str: + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.2f} MB" + + +class StatusBadge(QLabel): + def __init__(self, parent=None): + super().__init__("—", parent) + self.setFixedHeight(26) + self._apply_style(Colors.TEXT_MUTED) + self.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold)) + + def _apply_style(self, color: str): + # Inline style intentional — badge color is dynamic per status code + self.setStyleSheet(f""" + QLabel {{ + color: {color}; + background: {color}18; + border: 1px solid {color}50; + border-radius: 4px; + padding: 2px 10px; + font-weight: 700; + }} + """) + + def set_status(self, code: int, reason: str): + color = status_color(code) + self.setText(f" {code} {reason} ") + self._apply_style(color) + + def set_error(self): + self.setText(" ERROR ") + self._apply_style(Colors.ERROR) + + def clear(self): + self.setText("—") + self._apply_style(Colors.TEXT_MUTED) + + +class ResponsePanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Top bar ────────────────────────────────────────────────────────── + top_bar = QWidget() + top_bar.setObjectName("responseBar") + top_bar.setFixedHeight(42) + top_layout = QHBoxLayout(top_bar) + top_layout.setContentsMargins(12, 0, 12, 0) + top_layout.setSpacing(8) + + resp_label = QLabel("RESPONSE") + resp_label.setObjectName("responseTitle") + top_layout.addWidget(resp_label) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.VLine) + top_layout.addWidget(sep) + + self.status_badge = StatusBadge() + self.time_label = QLabel("") + self.time_label.setObjectName("metaLabel") + self.size_label = QLabel("") + self.size_label.setObjectName("metaLabel") + top_layout.addWidget(self.status_badge) + top_layout.addWidget(self.time_label) + top_layout.addWidget(self.size_label) + top_layout.addStretch() + + # Search bar + self.search_input = QLineEdit() + self.search_input.setObjectName("searchBar") + self.search_input.setPlaceholderText("Search response…") + self.search_input.setFixedWidth(200) + self.search_input.textChanged.connect(self._on_search) + + prev_btn = QPushButton("↑") + prev_btn.setObjectName("ghost") + prev_btn.setFixedSize(26, 26) + prev_btn.setToolTip("Previous match") + prev_btn.clicked.connect(lambda: self._nav(backward=True)) + + next_btn = QPushButton("↓") + next_btn.setObjectName("ghost") + next_btn.setFixedSize(26, 26) + next_btn.setToolTip("Next match") + next_btn.clicked.connect(lambda: self._nav(backward=False)) + + self.match_label = QLabel("") + self.match_label.setObjectName("metaLabel") + + self.copy_btn = QPushButton("Copy") + self.copy_btn.setObjectName("ghost") + self.copy_btn.setFixedWidth(55) + self.copy_btn.setToolTip("Copy response body") + self.copy_btn.clicked.connect(self._copy) + + self.save_btn = QPushButton("Save") + self.save_btn.setObjectName("ghost") + self.save_btn.setFixedWidth(55) + self.save_btn.setToolTip("Save response body to file") + self.save_btn.clicked.connect(self._save) + + top_layout.addWidget(self.search_input) + top_layout.addWidget(prev_btn) + top_layout.addWidget(next_btn) + top_layout.addWidget(self.match_label) + top_layout.addWidget(self.copy_btn) + top_layout.addWidget(self.save_btn) + layout.addWidget(top_bar) + + # ── Stacked: content tabs + loading overlay ─────────────────────── + self._stack = QStackedWidget() + + # ── Content tabs ──────────────────────────────────────────────────── + self.tabs = QTabWidget() + self.tabs.setObjectName("innerTabs") + + # Body view + self.body_view = QTextEdit() + self.body_view.setObjectName("codeEditor") + self.body_view.setReadOnly(True) + self.body_view.setFont( + QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11) + ) + self._hl = JsonHighlighter(self.body_view.document()) + self.tabs.addTab(self.body_view, "Body") + + # Headers table + self.headers_table = QTableWidget(0, 2) + self.headers_table.setHorizontalHeaderLabels(["Header", "Value"]) + self.headers_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self.headers_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.headers_table.verticalHeader().setVisible(False) + self.tabs.addTab(self.headers_table, "Headers") + + # Test results tree + self.test_tree = QTreeWidget() + self.test_tree.setHeaderLabels(["Test", "Result"]) + self.test_tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.test_tree.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.tabs.addTab(self.test_tree, "Tests") + + # ── Loading overlay ────────────────────────────────────────────────── + loading_widget = QWidget() + loading_widget.setObjectName("loadingOverlay") + ll = QVBoxLayout(loading_widget) + ll.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._loading_label = QLabel("Sending request…") + self._loading_label.setObjectName("loadingLabel") + self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + ll.addWidget(self._loading_label) + + self._stack.addWidget(self.tabs) # index 0 — normal view + self._stack.addWidget(loading_widget) # index 1 — loading + + layout.addWidget(self._stack, 1) + + self._match_positions: list = [] + + # ── Public API ──────────────────────────────────────────────────────────── + + def set_loading(self, loading: bool): + self._stack.setCurrentIndex(1 if loading else 0) + + def display(self, resp: HttpResponse, test_results: list = None): + self.set_loading(False) + + if resp.error: + self.status_badge.set_error() + self.time_label.setText("") + self.size_label.setText("") + self.body_view.setPlainText(resp.error) + self.tabs.setCurrentIndex(0) + return + + # Status / timing / size + self.status_badge.set_status(resp.status, resp.reason) + self.time_label.setText(f"{resp.elapsed_ms:.0f} ms") + size = resp.size_bytes or len(resp.body.encode()) + self.size_label.setText(_fmt_size(size)) + + # Body — pretty-print JSON if possible + try: + parsed = json.loads(resp.body) + self.body_view.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False)) + except (json.JSONDecodeError, ValueError): + self.body_view.setPlainText(resp.body) + + # Response headers + self.headers_table.setRowCount(0) + for k, v in sorted(resp.headers.items()): + row = self.headers_table.rowCount() + self.headers_table.insertRow(row) + ki = QTableWidgetItem(k) + ki.setForeground(QBrush(QColor(Colors.INFO))) + self.headers_table.setItem(row, 0, ki) + self.headers_table.setItem(row, 1, QTableWidgetItem(v)) + + # Test results + self.test_tree.clear() + if test_results: + passed = sum(1 for t in test_results if t.passed) + total = len(test_results) + color = Colors.SUCCESS if passed == total else Colors.WARNING + summary = QTreeWidgetItem([f"Results: {passed}/{total} passed", ""]) + summary.setForeground(0, QBrush(QColor(color))) + self.test_tree.addTopLevelItem(summary) + for tr in test_results: + icon = "✓" if tr.passed else "✗" + child = QTreeWidgetItem([f" {icon} {tr.name}", tr.message]) + child.setForeground(0, QBrush(QColor(Colors.SUCCESS if tr.passed else Colors.ERROR))) + summary.addChild(child) + summary.setExpanded(True) + self.tabs.setTabText(2, f"Tests ({passed}/{total})") + self.tabs.setCurrentIndex(2 if test_results else 0) + else: + self.tabs.setTabText(2, "Tests") + self.tabs.setCurrentIndex(0) + + def clear(self): + self.status_badge.clear() + self.time_label.setText("") + self.size_label.setText("") + self.body_view.clear() + self.headers_table.setRowCount(0) + self.test_tree.clear() + self.tabs.setTabText(2, "Tests") + self.match_label.setText("") + + # ── Search ──────────────────────────────────────────────────────────────── + + def _on_search(self, text: str): + if not text: + self.match_label.setText("") + return + self._nav(backward=False) + + def _nav(self, backward: bool): + text = self.search_input.text() + if not text: + return + flag = QTextDocument.FindFlag.FindBackward if backward else QTextDocument.FindFlag(0) + found = self.body_view.find(text, flag) + if not found: + cursor = self.body_view.textCursor() + cursor.movePosition( + QTextCursor.MoveOperation.End if backward else QTextCursor.MoveOperation.Start + ) + self.body_view.setTextCursor(cursor) + self.body_view.find(text, flag) + + def _copy(self): + text = self.body_view.toPlainText() + if text: + QApplication.clipboard().setText(text) + + def _save(self): + text = self.body_view.toPlainText() + if not text: + return + path, _ = QFileDialog.getSaveFileName( + self, "Save Response", "response.json", "JSON (*.json);;Text (*.txt);;All Files (*)" + ) + if path: + with open(path, "w", encoding="utf-8") as f: + f.write(text) diff --git a/app/ui/search_dialog.py b/app/ui/search_dialog.py new file mode 100644 index 0000000..d124f01 --- /dev/null +++ b/app/ui/search_dialog.py @@ -0,0 +1,133 @@ +"""APIClient - Agent — Request Search Dialog.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QLabel, QWidget +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QBrush, QColor + +from app.ui.theme import Colors, method_color +from app.core import storage +from app.models import HttpRequest + + +class SearchDialog(QDialog): + request_selected = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Search Requests") + self.setMinimumSize(580, 460) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Header ──────────────────────────────────────────────────────────── + header = QWidget() + header.setObjectName("panelHeader") + header.setFixedHeight(48) + hl = QHBoxLayout(header) + hl.setContentsMargins(16, 0, 16, 0) + title = QLabel("Search Requests") + title.setObjectName("panelTitle") + hl.addWidget(title) + layout.addWidget(header) + + # ── Search bar ──────────────────────────────────────────────────────── + search_bar = QWidget() + search_bar.setObjectName("urlBarStrip") + sl = QHBoxLayout(search_bar) + sl.setContentsMargins(16, 10, 16, 10) + self.search_input = QLineEdit() + self.search_input.setObjectName("urlBar") + self.search_input.setPlaceholderText("Search by name or URL…") + self.search_input.textChanged.connect(self._search) + sl.addWidget(self.search_input) + layout.addWidget(search_bar) + + # ── Results ─────────────────────────────────────────────────────────── + body = QWidget() + body.setObjectName("panelBody") + bl = QVBoxLayout(body) + bl.setContentsMargins(16, 8, 16, 8) + bl.setSpacing(6) + + self.count_label = QLabel("") + self.count_label.setObjectName("hintText") + bl.addWidget(self.count_label) + + self.results_list = QListWidget() + self.results_list.itemDoubleClicked.connect(self._on_selected) + bl.addWidget(self.results_list) + layout.addWidget(body, 1) + + # ── Footer ──────────────────────────────────────────────────────────── + footer = QWidget() + footer.setObjectName("panelFooter") + footer.setFixedHeight(52) + fl = QHBoxLayout(footer) + fl.setContentsMargins(16, 0, 16, 0) + open_btn = QPushButton("Open in Tab") + open_btn.setObjectName("accent") + open_btn.setFixedWidth(120) + open_btn.clicked.connect(self._on_selected_btn) + close_btn = QPushButton("Close") + close_btn.setFixedWidth(80) + close_btn.clicked.connect(self.reject) + fl.addWidget(open_btn) + fl.addStretch() + fl.addWidget(close_btn) + layout.addWidget(footer) + + self.search_input.setFocus() + self._search("") + + # ── Logic ───────────────────────────────────────────────────────────────── + + def _search(self, query: str): + self.results_list.clear() + results = storage.search_requests(query) if query.strip() else [] + count = len(results) + self.count_label.setText( + f"{count} result{'s' if count != 1 else ''}" + if query.strip() else "Type to search…" + ) + for r in results: + method = r.get("method", "GET") + name = r.get("name") or r.get("url", "") + col_name = r.get("collection_name", "") + label = f"[{col_name}] {method} {name}" if col_name else f"{method} {name}" + item = QListWidgetItem(label) + item.setForeground(QBrush(QColor(method_color(method)))) + item.setData(Qt.ItemDataRole.UserRole, r) + self.results_list.addItem(item) + + def _build_request(self, r: dict) -> HttpRequest: + return HttpRequest( + method = r.get("method", "GET"), + url = r.get("url", ""), + headers = r.get("headers") or {}, + params = r.get("params") or {}, + body = r.get("body") or "", + body_type = r.get("body_type") or "raw", + content_type = r.get("content_type") or "", + auth_type = r.get("auth_type") or "none", + auth_data = r.get("auth_data") or {}, + name = r.get("name") or "", + id = r.get("id"), + timeout = r.get("timeout") or 30, + ssl_verify = bool(r.get("ssl_verify", 1)), + ) + + def _on_selected(self, item: QListWidgetItem = None): + if item is None: + item = self.results_list.currentItem() + if not item: + return + r = item.data(Qt.ItemDataRole.UserRole) + self.request_selected.emit(self._build_request(r)) + self.accept() + + def _on_selected_btn(self): + self._on_selected(self.results_list.currentItem()) diff --git a/app/ui/sidebar.py b/app/ui/sidebar.py new file mode 100644 index 0000000..f682329 --- /dev/null +++ b/app/ui/sidebar.py @@ -0,0 +1,268 @@ +"""APIClient - Agent — Collections Sidebar.""" +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, + QPushButton, QInputDialog, QMenu, QLineEdit, QLabel, QMessageBox +) +from PyQt6.QtCore import pyqtSignal, Qt +from PyQt6.QtGui import QColor, QFont, QBrush + +from app.ui.theme import Colors, method_color +from app.core import storage +from app.models import HttpRequest + + +class CollectionsSidebar(QWidget): + request_selected = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("sidebar") + self.setMinimumWidth(240) + self.setMaximumWidth(380) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # ── Header ──────────────────────────────────────────────────────────── + header = QWidget() + header.setObjectName("sidebarHeader") + header.setFixedHeight(44) + h_layout = QHBoxLayout(header) + h_layout.setContentsMargins(12, 0, 8, 0) + + title = QLabel("COLLECTIONS") + title.setObjectName("sectionLabel") + self.add_col_btn = QPushButton("+") + self.add_col_btn.setObjectName("ghost") + self.add_col_btn.setFixedSize(28, 28) + self.add_col_btn.setToolTip("New Collection (Ctrl+Shift+N)") + self.add_col_btn.clicked.connect(self._add_collection) + + h_layout.addWidget(title) + h_layout.addStretch() + h_layout.addWidget(self.add_col_btn) + layout.addWidget(header) + + # ── Filter ──────────────────────────────────────────────────────────── + search_wrap = QWidget() + search_wrap.setObjectName("sidebarSearch") + sw = QHBoxLayout(search_wrap) + sw.setContentsMargins(10, 6, 10, 6) + self.search_input = QLineEdit() + self.search_input.setObjectName("filterInput") + self.search_input.setPlaceholderText("Filter collections…") + self.search_input.textChanged.connect(self._filter) + sw.addWidget(self.search_input) + layout.addWidget(search_wrap) + + # ── Tree ───────────────────────────────────────────────────────────── + self.tree = QTreeWidget() + self.tree.setHeaderHidden(True) + self.tree.setIndentation(16) + self.tree.setAnimated(True) + self.tree.itemDoubleClicked.connect(self._on_double_click) + self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self._context_menu) + layout.addWidget(self.tree) + + # History root (always last in tree) + self._history_root = QTreeWidgetItem([" History"]) + self._history_root.setForeground(0, QBrush(QColor(Colors.TEXT_MUTED))) + self._history_root.setFont(0, self._section_font()) + self.tree.addTopLevelItem(self._history_root) + + self._load_collections() + self._load_history() + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _section_font(self) -> QFont: + f = QFont() + f.setPointSize(9) + f.setWeight(QFont.Weight.Bold) + return f + + def _make_req_item(self, req: dict) -> QTreeWidgetItem: + method = req.get("method", "GET") + name = req.get("name") or req.get("url", "Untitled") + item = QTreeWidgetItem() + item.setText(0, f" {method} {name}") + item.setForeground(0, QBrush(QColor(method_color(method)))) + item.setData(0, Qt.ItemDataRole.UserRole, {"type": "request", "req": req}) + return item + + def _make_collection_item(self, col: dict) -> QTreeWidgetItem: + item = QTreeWidgetItem([f" {col['name']}"]) + item.setForeground(0, QBrush(QColor(Colors.TEXT_PRIMARY))) + item.setFont(0, self._section_font()) + item.setData(0, Qt.ItemDataRole.UserRole, {"type": "collection", "id": col["id"]}) + return item + + def _make_folder_item(self, folder: dict) -> QTreeWidgetItem: + item = QTreeWidgetItem([f" ▸ {folder['name']}"]) + item.setForeground(0, QBrush(QColor(Colors.TEXT_SECONDARY))) + item.setData( + 0, Qt.ItemDataRole.UserRole, + {"type": "folder", "id": folder["id"], "col_id": folder["collection_id"]} + ) + return item + + def _dict_to_request(self, r: dict) -> HttpRequest: + return HttpRequest( + method = r.get("method") or "GET", + url = r.get("url") or "", + headers = r.get("headers") or {}, + params = r.get("params") or {}, + body = r.get("body") or "", + body_type = r.get("body_type") or "raw", + content_type = r.get("content_type") or "", + auth_type = r.get("auth_type") or "none", + auth_data = r.get("auth_data") or {}, + pre_request_script = r.get("pre_request_script") or "", + test_script = r.get("test_script") or "", + name = r.get("name") or "", + id = r.get("id"), + timeout = r.get("timeout") or 30, + ssl_verify = bool(r.get("ssl_verify", 1)), + ) + + # ── Data loading ────────────────────────────────────────────────────────── + + def _load_collections(self, filter_text: str = ""): + for i in range(self.tree.topLevelItemCount() - 1, -1, -1): + if self.tree.topLevelItem(i) is not self._history_root: + self.tree.takeTopLevelItem(i) + + for col in storage.get_collections(): + if filter_text and filter_text.lower() not in col["name"].lower(): + continue + col_item = self._make_collection_item(col) + + for folder in storage.get_folders(col["id"]): + folder_item = self._make_folder_item(folder) + for req in storage.get_requests(col["id"], folder["id"]): + folder_item.addChild(self._make_req_item(req)) + col_item.addChild(folder_item) + + for req in storage.get_requests(col["id"]): + col_item.addChild(self._make_req_item(req)) + + self.tree.insertTopLevelItem(0, col_item) + col_item.setExpanded(True) + + def _load_history(self): + self._history_root.takeChildren() + for h in storage.get_history(30): + item = QTreeWidgetItem() + method = h.get("method", "GET") + url = h.get("url", "") + item.setText(0, f" {method} {url}") + item.setForeground(0, QBrush(QColor(method_color(method)))) + item.setData(0, Qt.ItemDataRole.UserRole, {"type": "history", "req": h}) + self._history_root.addChild(item) + + def _filter(self, text: str): + self._load_collections(filter_text=text) + + def refresh(self): + self._load_collections(self.search_input.text()) + self._load_history() + + # ── Interaction ─────────────────────────────────────────────────────────── + + def _on_double_click(self, item, _column): + data = item.data(0, Qt.ItemDataRole.UserRole) + if not data or data["type"] not in ("request", "history"): + return + self.request_selected.emit(self._dict_to_request(data["req"])) + + def _context_menu(self, pos): + item = self.tree.itemAt(pos) + if not item: + return + data = item.data(0, Qt.ItemDataRole.UserRole) + if not data: + return + menu = QMenu(self) + t = data["type"] + if t == "collection": + menu.addAction("Add Folder", lambda: self._add_folder(data["id"])) + menu.addAction("Rename", lambda: self._rename_collection(item, data["id"])) + menu.addSeparator() + menu.addAction("Delete", lambda: self._delete_collection(item, data["id"])) + elif t == "folder": + menu.addAction("Rename", lambda: self._rename_folder(item, data["id"])) + menu.addSeparator() + menu.addAction("Delete", lambda: self._delete_folder(data["id"])) + elif t == "request": + menu.addAction("Open in Tab", lambda: self.request_selected.emit( + self._dict_to_request(data["req"]) + )) + menu.addSeparator() + menu.addAction("Delete", lambda: self._delete_request(data["req"].get("id"))) + elif t == "history": + menu.addAction("Open in Tab", lambda: self.request_selected.emit( + self._dict_to_request(data["req"]) + )) + menu.addSeparator() + menu.addAction("Clear All History", self._clear_history) + menu.exec(self.tree.mapToGlobal(pos)) + + # ── CRUD actions ────────────────────────────────────────────────────────── + + def _add_collection(self): + name, ok = QInputDialog.getText(self, "New Collection", "Collection name:") + if ok and name.strip(): + storage.add_collection(name.strip()) + self._load_collections() + + def _add_folder(self, col_id: int): + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if ok and name.strip(): + storage.add_folder(col_id, name.strip()) + self._load_collections() + + def _rename_collection(self, item, col_id: int): + current = item.text(0).strip() + name, ok = QInputDialog.getText(self, "Rename Collection", "Name:", text=current) + if ok and name.strip(): + storage.rename_collection(col_id, name.strip()) + self._load_collections() + + def _rename_folder(self, item, folder_id: int): + current = item.text(0).strip().lstrip("▸ ").strip() + name, ok = QInputDialog.getText(self, "Rename Folder", "Name:", text=current) + if ok and name.strip(): + storage.rename_folder(folder_id, name.strip()) + self._load_collections() + + def _delete_collection(self, item, col_id: int): + name = item.text(0).strip() + reply = QMessageBox.question( + self, "Delete Collection", + f"Delete '{name}' and all its requests? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel + ) + if reply == QMessageBox.StandardButton.Yes: + storage.delete_collection(col_id) + self._load_collections() + + def _delete_folder(self, folder_id: int): + reply = QMessageBox.question( + self, "Delete Folder", + "Delete this folder and all its requests?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel + ) + if reply == QMessageBox.StandardButton.Yes: + storage.delete_folder(folder_id) + self._load_collections() + + def _delete_request(self, req_id): + if req_id: + storage.delete_request(req_id) + self._load_collections() + + def _clear_history(self): + storage.clear_history() + self._load_history() diff --git a/app/ui/tabs_manager.py b/app/ui/tabs_manager.py new file mode 100644 index 0000000..396e684 --- /dev/null +++ b/app/ui/tabs_manager.py @@ -0,0 +1,97 @@ +"""APIClient - Agent — Multi-tab request manager.""" +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QTabWidget, QPushButton, QTabBar +from PyQt6.QtCore import pyqtSignal, Qt + +from app.ui.request_panel import RequestPanel +from app.models import HttpRequest + + +class RequestTab(QWidget): + send_requested = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.request_panel = RequestPanel() + self.request_panel.send_requested.connect(self.send_requested) + layout.addWidget(self.request_panel) + + def load_request(self, req: HttpRequest): + self.request_panel.load_request(req) + + def get_request(self) -> HttpRequest: + return self.request_panel.get_request() + + +class TabsManager(QWidget): + send_requested = pyqtSignal(object) + current_tab_changed = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.tab_widget = QTabWidget() + self.tab_widget.setTabsClosable(False) # we manage close buttons ourselves + self.tab_widget.setMovable(True) + self.tab_widget.currentChanged.connect(lambda _: self.current_tab_changed.emit()) + + # "+" new tab button in the corner + new_btn = QPushButton("+") + new_btn.setObjectName("ghost") + new_btn.setFixedSize(28, 28) + new_btn.setToolTip("New Tab (Ctrl+T)") + new_btn.clicked.connect(lambda: self.new_tab()) + self.tab_widget.setCornerWidget(new_btn, Qt.Corner.TopRightCorner) + + layout.addWidget(self.tab_widget) + self._tab_counter = 0 + self.new_tab() + + # ── Public API ──────────────────────────────────────────────────────────── + + def new_tab(self, req: HttpRequest = None) -> RequestTab: + tab = RequestTab() + tab.send_requested.connect(self.send_requested) + if req: + tab.load_request(req) + self._tab_counter += 1 + label = req.name if (req and req.name) else f"Request {self._tab_counter}" + idx = self.tab_widget.addTab(tab, label) + self.tab_widget.setCurrentIndex(idx) + self._add_close_button(idx, tab) + return tab + + def _add_close_button(self, idx: int, tab: RequestTab): + btn = QPushButton("×") + btn.setObjectName("tabCloseBtn") + btn.setFixedSize(18, 18) + btn.setToolTip("Close Tab") + btn.clicked.connect(lambda: self._close_tab(self.tab_widget.indexOf(tab))) + self.tab_widget.tabBar().setTabButton(idx, QTabBar.ButtonPosition.RightSide, btn) + + def close_current_tab(self): + self._close_tab(self.tab_widget.currentIndex()) + + def _close_tab(self, index: int): + if self.tab_widget.count() > 1: + self.tab_widget.removeTab(index) + + def current_tab(self) -> RequestTab | None: + w = self.tab_widget.currentWidget() + return w if isinstance(w, RequestTab) else None + + def load_request_in_new_tab(self, req: HttpRequest): + self.new_tab(req) + + def load_request_in_current_tab(self, req: HttpRequest): + tab = self.current_tab() + if tab: + tab.load_request(req) + + def rename_current_tab(self, name: str): + self.tab_widget.setTabText(self.tab_widget.currentIndex(), name) diff --git a/app/ui/theme.py b/app/ui/theme.py new file mode 100644 index 0000000..45ddbcb --- /dev/null +++ b/app/ui/theme.py @@ -0,0 +1,981 @@ +""" +APIClient - Agent — Central Theme Engine +All styling lives here in the global QSS. +UI widgets use setObjectName() selectors — never inline setStyleSheet() for static colors. +Only truly dynamic values (per-request method color, status badge) stay inline. +""" +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication + + +# ── Color Palettes ──────────────────────────────────────────────────────────── + +class DarkColors: + BG_DARKEST = "#0D0D0D" + BG_SIDEBAR = "#111111" + BG_MAIN = "#181818" + BG_PANEL = "#1E1E1E" + BG_ELEVATED = "#242424" + BG_INPUT = "#2A2A2A" + BG_HOVER = "#303030" + BG_SELECTED = "#383838" + + BORDER = "#2C2C2C" + BORDER_FOCUS = "#505050" + + TEXT_PRIMARY = "#E4E4E4" + TEXT_SECONDARY = "#8A8A8A" + TEXT_MUTED = "#505050" + TEXT_DISABLED = "#3A3A3A" + + ACCENT = "#E05C2C" + ACCENT_HOVER = "#F06030" + ACCENT_PRESSED = "#C04C20" + ACCENT_SUBTLE = "#2A1208" + + SUCCESS = "#3FB950" + WARNING = "#D29922" + ERROR = "#F85149" + INFO = "#58A6FF" + + METHOD_GET = "#61AFFE" + METHOD_POST = "#49CC90" + METHOD_PUT = "#FCA130" + METHOD_PATCH = "#50E3C2" + METHOD_DELETE = "#F93E3E" + METHOD_HEAD = "#9012FE" + METHOD_OPTIONS = "#0D5AA7" + + STATUS_1XX = "#8C8C8C" + STATUS_2XX = "#3FB950" + STATUS_3XX = "#D29922" + STATUS_4XX = "#F85149" + STATUS_5XX = "#FF4444" + + +class LightColors: + BG_DARKEST = "#E2E2E2" + BG_SIDEBAR = "#ECECEC" + BG_MAIN = "#F2F2F2" + BG_PANEL = "#FFFFFF" + BG_ELEVATED = "#E8E8E8" + BG_INPUT = "#FFFFFF" + BG_HOVER = "#DCDCDC" + BG_SELECTED = "#D0D0D0" + + BORDER = "#D0D0D0" + BORDER_FOCUS = "#A0A0A0" + + TEXT_PRIMARY = "#1A1A1A" + TEXT_SECONDARY = "#555555" + TEXT_MUTED = "#999999" + TEXT_DISABLED = "#BBBBBB" + + ACCENT = "#C94A14" + ACCENT_HOVER = "#E05520" + ACCENT_PRESSED = "#A83C0E" + ACCENT_SUBTLE = "#FDEEE6" + + SUCCESS = "#1A7F37" + WARNING = "#7A5800" + ERROR = "#C01020" + INFO = "#0550AE" + + METHOD_GET = "#0550AE" + METHOD_POST = "#1A7F37" + METHOD_PUT = "#7A3800" + METHOD_PATCH = "#116329" + METHOD_DELETE = "#C01020" + METHOD_HEAD = "#6639BA" + METHOD_OPTIONS = "#0550AE" + + STATUS_1XX = "#777777" + STATUS_2XX = "#1A7F37" + STATUS_3XX = "#7A5800" + STATUS_4XX = "#C01020" + STATUS_5XX = "#C01020" + + +# ── Active palette (module-level singleton) ─────────────────────────────────── +Colors = DarkColors +_is_dark = True + + +def method_color(method: str) -> str: + return { + "GET": Colors.METHOD_GET, + "POST": Colors.METHOD_POST, + "PUT": Colors.METHOD_PUT, + "PATCH": Colors.METHOD_PATCH, + "DELETE": Colors.METHOD_DELETE, + "HEAD": Colors.METHOD_HEAD, + "OPTIONS": Colors.METHOD_OPTIONS, + }.get(method.upper(), Colors.TEXT_SECONDARY) + + +def status_color(code: int) -> str: + if code < 200: return Colors.STATUS_1XX + if code < 300: return Colors.STATUS_2XX + if code < 400: return Colors.STATUS_3XX + if code < 500: return Colors.STATUS_4XX + return Colors.STATUS_5XX + + +# ── Global Stylesheet ───────────────────────────────────────────────────────── +# Everything static lives here. Object names are the API between theme and UI. + +def _build_stylesheet(C) -> str: + return f""" + +/* ════════════════════════════════════════════════════════ + BASE +════════════════════════════════════════════════════════ */ +QWidget {{ + background-color: {C.BG_MAIN}; + color: {C.TEXT_PRIMARY}; + font-family: "Segoe UI", "SF Pro Text", "Inter", "Helvetica Neue", sans-serif; + font-size: 13px; + border: none; + outline: none; +}} +QMainWindow, QDialog {{ + background-color: {C.BG_PANEL}; +}} + +/* ════════════════════════════════════════════════════════ + SCROLLBARS +════════════════════════════════════════════════════════ */ +QScrollBar:vertical {{ + background: transparent; width: 8px; margin: 0; +}} +QScrollBar::handle:vertical {{ + background: {C.BORDER_FOCUS}; border-radius: 4px; min-height: 28px; +}} +QScrollBar::handle:vertical:hover {{ background: {C.TEXT_MUTED}; }} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} +QScrollBar:horizontal {{ + background: transparent; height: 8px; margin: 0; +}} +QScrollBar::handle:horizontal {{ + background: {C.BORDER_FOCUS}; border-radius: 4px; min-width: 28px; +}} +QScrollBar::handle:horizontal:hover {{ background: {C.TEXT_MUTED}; }} +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} + +/* ════════════════════════════════════════════════════════ + SPLITTER +════════════════════════════════════════════════════════ */ +QSplitter::handle {{ background: {C.BORDER}; }} +QSplitter::handle:horizontal {{ width: 1px; }} +QSplitter::handle:vertical {{ height: 1px; }} + +/* ════════════════════════════════════════════════════════ + MENU +════════════════════════════════════════════════════════ */ +QMenuBar {{ + background-color: {C.BG_DARKEST}; + color: {C.TEXT_SECONDARY}; + border-bottom: 1px solid {C.BORDER}; + padding: 2px 4px; + spacing: 4px; +}} +QMenuBar::item {{ + background: transparent; padding: 4px 10px; border-radius: 4px; +}} +QMenuBar::item:selected, QMenuBar::item:pressed {{ + background-color: {C.BG_ELEVATED}; color: {C.TEXT_PRIMARY}; +}} +QMenu {{ + background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 6px; + padding: 4px; +}} +QMenu::item {{ padding: 7px 28px 7px 12px; border-radius: 4px; }} +QMenu::item:selected {{ background-color: {C.BG_HOVER}; }} +QMenu::item:disabled {{ color: {C.TEXT_MUTED}; }} +QMenu::separator {{ height: 1px; background: {C.BORDER}; margin: 4px 8px; }} + +/* ════════════════════════════════════════════════════════ + INPUTS +════════════════════════════════════════════════════════ */ +QLineEdit {{ + background-color: {C.BG_INPUT}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 6px 10px; + selection-background-color: {C.ACCENT}; +}} +QLineEdit:focus {{ + border: 1px solid {C.BORDER_FOCUS}; + background-color: {C.BG_ELEVATED}; +}} +QLineEdit:disabled {{ + color: {C.TEXT_DISABLED}; + background-color: {C.BG_MAIN}; +}} +QLineEdit::placeholder {{ color: {C.TEXT_MUTED}; }} + +QTextEdit, QPlainTextEdit {{ + background-color: {C.BG_INPUT}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 6px; + selection-background-color: {C.ACCENT}; +}} +QTextEdit:focus, QPlainTextEdit:focus {{ + border: 1px solid {C.BORDER_FOCUS}; +}} + +QSpinBox {{ + background-color: {C.BG_INPUT}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 5px 8px; +}} +QSpinBox:focus {{ border-color: {C.BORDER_FOCUS}; }} +QSpinBox::up-button, QSpinBox::down-button {{ + background: {C.BG_ELEVATED}; + border: none; + width: 18px; +}} +QSpinBox::up-button:hover, QSpinBox::down-button:hover {{ + background: {C.BG_HOVER}; +}} + +/* ════════════════════════════════════════════════════════ + COMBOBOX +════════════════════════════════════════════════════════ */ +QComboBox {{ + background-color: {C.BG_INPUT}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 5px 10px; + min-width: 80px; +}} +QComboBox:hover {{ border-color: {C.BORDER_FOCUS}; }} +QComboBox:focus {{ border-color: {C.BORDER_FOCUS}; }} +QComboBox::drop-down {{ border: none; width: 20px; subcontrol-origin: padding; }} +QComboBox::down-arrow {{ width: 10px; height: 10px; }} +QComboBox QAbstractItemView {{ + background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + selection-background-color: {C.BG_SELECTED}; + outline: none; + padding: 2px; +}} + +/* ════════════════════════════════════════════════════════ + CHECKBOX +════════════════════════════════════════════════════════ */ +QCheckBox {{ + color: {C.TEXT_SECONDARY}; + spacing: 6px; + background: transparent; +}} +QCheckBox::indicator {{ + width: 14px; height: 14px; + border: 1px solid {C.BORDER_FOCUS}; + border-radius: 3px; + background: {C.BG_INPUT}; +}} +QCheckBox::indicator:checked {{ + background: {C.ACCENT}; border-color: {C.ACCENT}; +}} + +/* ════════════════════════════════════════════════════════ + BUTTONS +════════════════════════════════════════════════════════ */ +QPushButton {{ + background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 6px 14px; + font-weight: 500; +}} +QPushButton:hover {{ + background-color: {C.BG_HOVER}; + border-color: {C.BORDER_FOCUS}; +}} +QPushButton:pressed {{ background-color: {C.BG_SELECTED}; }} +QPushButton:disabled {{ color: {C.TEXT_MUTED}; border-color: {C.BORDER}; }} + +QPushButton#accent {{ + background-color: {C.ACCENT}; + color: #FFFFFF; + border: none; + font-weight: 600; +}} +QPushButton#accent:hover {{ background-color: {C.ACCENT_HOVER}; }} +QPushButton#accent:pressed {{ background-color: {C.ACCENT_PRESSED}; }} +QPushButton#accent:disabled {{ + background-color: {C.ACCENT_SUBTLE}; + color: {C.TEXT_MUTED}; +}} + +QPushButton#ghost {{ + background: transparent; + border: none; + color: {C.TEXT_SECONDARY}; + padding: 4px 8px; + border-radius: 4px; +}} +QPushButton#ghost:hover {{ + color: {C.TEXT_PRIMARY}; + background-color: {C.BG_HOVER}; +}} +QPushButton#ghost:pressed {{ background-color: {C.BG_SELECTED}; }} + +QPushButton#danger {{ + background-color: transparent; + color: {C.ERROR}; + border: 1px solid {C.ERROR}; +}} +QPushButton#danger:hover {{ background-color: {C.ACCENT_SUBTLE}; }} + +QPushButton#sendBtn {{ + background-color: {C.ACCENT}; + color: white; + border: none; + border-radius: 6px; + padding: 8px 22px; + font-weight: 700; + font-size: 13px; + letter-spacing: 0.3px; +}} +QPushButton#sendBtn:hover {{ background-color: {C.ACCENT_HOVER}; }} +QPushButton#sendBtn:pressed {{ background-color: {C.ACCENT_PRESSED}; }} +QPushButton#sendBtn:disabled {{ + background-color: {C.ACCENT_SUBTLE}; + color: {C.TEXT_MUTED}; +}} + +/* ════════════════════════════════════════════════════════ + TABS +════════════════════════════════════════════════════════ */ +QTabWidget::pane {{ + border: none; + background-color: {C.BG_PANEL}; +}} +QTabBar {{ background: transparent; }} +QTabBar::tab {{ + background: transparent; + color: {C.TEXT_SECONDARY}; + border: none; + border-bottom: 2px solid transparent; + padding: 8px 16px; + font-size: 12px; + font-weight: 500; +}} +QTabBar::tab:selected {{ + color: {C.TEXT_PRIMARY}; + border-bottom: 2px solid {C.ACCENT}; +}} +QTabBar::tab:hover:!selected {{ + color: {C.TEXT_PRIMARY}; + background-color: {C.BG_HOVER}; + border-radius: 4px 4px 0 0; +}} +QTabBar::close-button {{ + subcontrol-position: right; + border-radius: 3px; + margin: 3px 2px; + padding: 0; + width: 14px; + height: 14px; +}} + +/* Request/Response inner tab bars sit on BG_MAIN strip */ +QTabWidget#innerTabs QTabBar {{ + background: {C.BG_MAIN}; + border-bottom: 1px solid {C.BORDER}; +}} + +/* Top workspace tab bar (HTTP / WebSocket / Mock Server) */ +QTabWidget#workspaceTabs QTabBar::tab {{ + background: {C.BG_DARKEST}; + color: {C.TEXT_SECONDARY}; + border: none; + border-right: 1px solid {C.BORDER}; + padding: 10px 20px; + font-size: 12px; + font-weight: 600; + border-bottom: none; + border-top: 2px solid transparent; +}} +QTabWidget#workspaceTabs QTabBar::tab:selected {{ + background: {C.BG_MAIN}; + color: {C.TEXT_PRIMARY}; + border-top: 2px solid {C.ACCENT}; +}} +QTabWidget#workspaceTabs QTabBar::tab:hover:!selected {{ + background: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; +}} + +/* ════════════════════════════════════════════════════════ + TABLES +════════════════════════════════════════════════════════ */ +QTableWidget {{ + background-color: {C.BG_PANEL}; + alternate-background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: none; + gridline-color: {C.BORDER}; + selection-background-color: {C.BG_SELECTED}; + selection-color: {C.TEXT_PRIMARY}; +}} +QTableWidget::item {{ + padding: 5px 8px; + border-bottom: 1px solid {C.BORDER}; +}} +QTableWidget::item:selected {{ + background-color: {C.BG_SELECTED}; + color: {C.TEXT_PRIMARY}; +}} +QHeaderView::section {{ + background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: none; + border-bottom: 1px solid {C.BORDER}; + border-right: 1px solid {C.BORDER}; + padding: 6px 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +}} +QHeaderView::section:last {{ border-right: none; }} + +/* ════════════════════════════════════════════════════════ + TREE +════════════════════════════════════════════════════════ */ +QTreeWidget {{ + background-color: {C.BG_SIDEBAR}; + color: {C.TEXT_PRIMARY}; + border: none; + outline: none; + show-decoration-selected: 1; +}} +QTreeWidget::item {{ + padding: 4px 4px; + border-radius: 3px; +}} +QTreeWidget::item:selected {{ + background-color: {C.BG_SELECTED}; + color: {C.TEXT_PRIMARY}; +}} +QTreeWidget::item:hover:!selected {{ background-color: {C.BG_HOVER}; }} +QTreeWidget::branch {{ background: {C.BG_SIDEBAR}; }} + +/* ════════════════════════════════════════════════════════ + LIST +════════════════════════════════════════════════════════ */ +QListWidget {{ + background-color: {C.BG_PANEL}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + outline: none; +}} +QListWidget::item {{ padding: 8px 10px; border-radius: 3px; }} +QListWidget::item:selected {{ + background-color: {C.BG_SELECTED}; color: {C.TEXT_PRIMARY}; +}} +QListWidget::item:hover:!selected {{ background-color: {C.BG_HOVER}; }} + +/* ════════════════════════════════════════════════════════ + SIDEBAR LIST (no border, flush) +════════════════════════════════════════════════════════ */ +QListWidget#sidebarList {{ + background: {C.BG_SIDEBAR}; + border: none; + border-radius: 0; +}} +QListWidget#sidebarList::item {{ + padding: 10px 14px; + border-bottom: 1px solid {C.BORDER}; + border-radius: 0; + font-size: 13px; +}} +QListWidget#sidebarList::item:selected {{ + background: {C.BG_SELECTED}; color: {C.TEXT_PRIMARY}; +}} +QListWidget#sidebarList::item:hover:!selected {{ background: {C.BG_HOVER}; }} + +/* ════════════════════════════════════════════════════════ + STATUS BAR +════════════════════════════════════════════════════════ */ +QStatusBar {{ + background-color: {C.BG_DARKEST}; + color: {C.TEXT_SECONDARY}; + border-top: 1px solid {C.BORDER}; + font-size: 11px; + padding: 0 8px; +}} +QStatusBar::item {{ border: none; }} + +/* ════════════════════════════════════════════════════════ + PROGRESS BAR +════════════════════════════════════════════════════════ */ +QProgressBar {{ + background-color: {C.BG_ELEVATED}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + height: 6px; + text-align: center; + color: transparent; +}} +QProgressBar::chunk {{ + background-color: {C.ACCENT}; border-radius: 4px; +}} + +/* ════════════════════════════════════════════════════════ + GROUP BOX +════════════════════════════════════════════════════════ */ +QGroupBox {{ + color: {C.TEXT_SECONDARY}; + border: 1px solid {C.BORDER}; + border-radius: 6px; + margin-top: 8px; + padding: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +}} +QGroupBox::title {{ + subcontrol-origin: margin; left: 10px; padding: 0 4px; +}} + +/* ════════════════════════════════════════════════════════ + TOOLTIP +════════════════════════════════════════════════════════ */ +QToolTip {{ + background-color: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + padding: 5px 8px; + font-size: 12px; +}} + +/* ════════════════════════════════════════════════════════ + DIALOG BUTTON BOX +════════════════════════════════════════════════════════ */ +QDialogButtonBox QPushButton {{ min-width: 80px; }} + +/* ════════════════════════════════════════════════════════ + FRAME SEPARATORS +════════════════════════════════════════════════════════ */ +QFrame[frameShape="4"], QFrame[frameShape="5"] {{ + background-color: {C.BORDER}; + border: none; + max-height: 1px; + max-width: 1px; +}} + +/* ════════════════════════════════════════════════════════ + ── NAMED WIDGET RULES (setObjectName API) ── +════════════════════════════════════════════════════════ */ + +/* Top brand / env bar */ +QWidget#envBar {{ + background-color: {C.BG_DARKEST}; + border-bottom: 1px solid {C.BORDER}; +}} +QLabel#brandName {{ + color: {C.ACCENT}; + font-size: 15px; + font-weight: 800; + letter-spacing: 2px; + background: transparent; +}} +QLabel#brandSub {{ + color: {C.TEXT_MUTED}; + font-size: 11px; + font-weight: 500; + background: transparent; +}} +QLabel#envChip {{ + color: {C.TEXT_MUTED}; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + background: transparent; +}} + +/* Sidebar */ +QWidget#sidebar {{ + background-color: {C.BG_SIDEBAR}; + border-right: 1px solid {C.BORDER}; +}} +QWidget#sidebarHeader {{ + background-color: {C.BG_SIDEBAR}; + border-bottom: 1px solid {C.BORDER}; +}} +QWidget#sidebarSearch {{ + background-color: {C.BG_SIDEBAR}; +}} + +/* URL bar strip */ +QWidget#urlBarStrip {{ + background-color: {C.BG_MAIN}; + border-bottom: 1px solid {C.BORDER}; +}} +QLineEdit#urlBar {{ + background-color: {C.BG_INPUT}; + border: 1.5px solid {C.BORDER}; + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace; + color: {C.TEXT_PRIMARY}; +}} +QLineEdit#urlBar:focus {{ + border-color: {C.ACCENT}; + background-color: {C.BG_ELEVATED}; +}} + +/* Method combo (color set inline per method, only layout here) */ +QComboBox#methodCombo {{ + font-weight: 800; + font-size: 12px; + border-radius: 6px; + padding: 8px 10px; + min-width: 100px; + border: 1px solid {C.BORDER}; + background-color: {C.BG_INPUT}; +}} +QComboBox#methodCombo:hover {{ border-color: {C.BORDER_FOCUS}; }} +QComboBox#methodCombo QAbstractItemView {{ + background: {C.BG_ELEVATED}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + selection-background-color: {C.BG_SELECTED}; +}} + +/* Inner request/response tab strip */ +QWidget#tabStrip {{ + background-color: {C.BG_MAIN}; + border-bottom: 1px solid {C.BORDER}; +}} + +/* Response top bar */ +QWidget#responseBar {{ + background-color: {C.BG_MAIN}; + border-top: 1px solid {C.BORDER}; + border-bottom: 1px solid {C.BORDER}; +}} +QLabel#responseTitle {{ + color: {C.TEXT_MUTED}; + font-size: 10px; + font-weight: 700; + letter-spacing: 1.2px; + background: transparent; +}} +QLabel#metaLabel {{ + color: {C.TEXT_MUTED}; + font-size: 11px; + background: transparent; + padding: 0 6px; +}} + +/* Section/panel headers used in dialogs */ +QWidget#panelHeader {{ + background-color: {C.BG_ELEVATED}; + border-bottom: 1px solid {C.BORDER}; +}} +QWidget#panelFooter {{ + background-color: {C.BG_ELEVATED}; + border-top: 1px solid {C.BORDER}; +}} +QWidget#sectionHeader {{ + background-color: {C.BG_SIDEBAR}; + border-bottom: 1px solid {C.BORDER}; +}} +QWidget#panelBody {{ + background-color: {C.BG_PANEL}; +}} + +/* Labels inside panels */ +QLabel#panelTitle {{ + font-size: 14px; + font-weight: 700; + color: {C.TEXT_PRIMARY}; + background: transparent; +}} +QLabel#sectionLabel {{ + color: {C.TEXT_MUTED}; + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + background: transparent; +}} +QLabel#hintText {{ + color: {C.TEXT_MUTED}; + font-size: 11px; + background: transparent; +}} +QLabel#fieldLabel {{ + color: {C.TEXT_SECONDARY}; + font-size: 12px; + background: transparent; +}} + +/* Body/code editors */ +QTextEdit#codeEditor {{ + background-color: {C.BG_PANEL}; + color: {C.TEXT_PRIMARY}; + border: none; + padding: 8px; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace; + font-size: 11px; +}} + +/* Loading overlay */ +QWidget#loadingOverlay {{ + background-color: {C.BG_PANEL}; +}} +QLabel#loadingLabel {{ + color: {C.TEXT_MUTED}; + font-size: 13px; + background: transparent; +}} + +/* Search in response bar */ +QLineEdit#searchBar {{ + background: {C.BG_INPUT}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + color: {C.TEXT_PRIMARY}; +}} +QLineEdit#searchBar:focus {{ border-color: {C.BORDER_FOCUS}; }} + +/* Sidebar filter input */ +QLineEdit#filterInput {{ + background: {C.BG_ELEVATED}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + padding: 5px 8px; + font-size: 12px; + color: {C.TEXT_PRIMARY}; +}} +QLineEdit#filterInput:focus {{ border-color: {C.BORDER_FOCUS}; }} + +/* WebSocket / Mock status indicator labels */ +QLabel#statusOk {{ + color: {C.SUCCESS}; + font-size: 12px; + font-weight: 600; + background: transparent; +}} +QLabel#statusWarn {{ + color: {C.WARNING}; + font-size: 12px; + font-weight: 600; + background: transparent; +}} +QLabel#statusErr {{ + color: {C.ERROR}; + font-size: 12px; + font-weight: 600; + background: transparent; +}} + +/* Auth "none" hint */ +QLabel#authNone {{ + color: {C.TEXT_MUTED}; + padding: 12px; + background: transparent; +}} + +/* Sidebar panel (environment dialog left pane, etc.) */ +QWidget#sidebarPanel {{ + background-color: {C.BG_SIDEBAR}; +}} + +/* Custom tab close button */ +QPushButton#tabCloseBtn {{ + background: transparent; + border: none; + border-radius: 3px; + color: {C.TEXT_MUTED}; + font-size: 14px; + font-weight: 700; + padding: 0; +}} +QPushButton#tabCloseBtn:hover {{ + background-color: {C.ERROR}; + color: #FFFFFF; +}} + +/* AI Assistant panel */ +QWidget#aiPanel {{ + background-color: {C.BG_PANEL}; +}} +QTextEdit#aiOutput {{ + background-color: {C.BG_INPUT}; + color: {C.TEXT_PRIMARY}; + border: 1px solid {C.BORDER}; + border-radius: 5px; + padding: 8px; + font-size: 12px; +}} +QLabel#aiStatusLabel {{ + color: {C.TEXT_MUTED}; + font-size: 11px; + background: transparent; +}} + +/* ── AI Chat Panel ─────────────────────────────────────── */ +QWidget#aiChatPanel {{ + background-color: {C.BG_SIDEBAR}; + border-left: 1px solid {C.BORDER}; +}} +QWidget#aiChatHeader {{ + background-color: {C.BG_ELEVATED}; + border-bottom: 1px solid {C.BORDER}; +}} +QLabel#aiChatTitle {{ + color: {C.ACCENT}; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.5px; + background: transparent; +}} +QWidget#chatArea {{ + background-color: {C.BG_SIDEBAR}; +}} +QFrame#userBubble {{ + background-color: {C.ACCENT_SUBTLE}; + border: 1px solid {C.BORDER}; + border-left: 3px solid {C.ACCENT}; + border-radius: 6px; + margin: 0px; +}} +QFrame#aiBubble {{ + background-color: {C.BG_ELEVATED}; + border: 1px solid {C.BORDER}; + border-radius: 6px; + margin: 0px; +}} +QLabel#chatRoleLabel {{ + font-size: 10px; + font-weight: 700; + color: {C.TEXT_MUTED}; + background: transparent; + letter-spacing: 0.5px; + text-transform: uppercase; +}} +QLabel#chatMessageText {{ + color: {C.TEXT_PRIMARY}; + background: transparent; + font-size: 12px; + line-height: 1.6; +}} +QWidget#chatInputArea {{ + background-color: {C.BG_ELEVATED}; + border-top: 1px solid {C.BORDER}; +}} +QTextEdit#chatInput {{ + background-color: {C.BG_INPUT}; + border: 1px solid {C.BORDER}; + border-radius: 6px; + padding: 6px 10px; + font-size: 12px; + color: {C.TEXT_PRIMARY}; +}} +QTextEdit#chatInput:focus {{ + border-color: {C.ACCENT}; +}} +QWidget#quickActions {{ + background-color: {C.BG_MAIN}; + border-top: 1px solid {C.BORDER}; + border-bottom: 1px solid {C.BORDER}; +}} +QPushButton#qaBtn {{ + background-color: {C.BG_INPUT}; + border: 1px solid {C.BORDER}; + border-radius: 10px; + padding: 2px 8px; + font-size: 11px; + color: {C.TEXT_SECONDARY}; + font-weight: 500; +}} +QPushButton#qaBtn:hover {{ + background-color: {C.BG_HOVER}; + border-color: {C.ACCENT}; + color: {C.TEXT_PRIMARY}; +}} +QFrame#applyBlock {{ + background-color: {C.BG_PANEL}; + border: 1px solid {C.BORDER}; + border-left: 3px solid {C.ACCENT}; + border-radius: 4px; +}} +QTextEdit#applyCode {{ + background-color: transparent; + border: none; + padding: 4px; + font-size: 10px; + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Consolas", monospace; + color: {C.TEXT_SECONDARY}; +}} + +""" + + +def _apply_palette(app: QApplication, C): + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(C.BG_MAIN)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(C.TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.Base, QColor(C.BG_INPUT)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(C.BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.Text, QColor(C.TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(C.TEXT_MUTED)) + palette.setColor(QPalette.ColorRole.Button, QColor(C.BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(C.TEXT_PRIMARY)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(C.ACCENT)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#FFFFFF")) + palette.setColor(QPalette.ColorRole.Link, QColor(C.INFO)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(C.BG_ELEVATED)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(C.TEXT_PRIMARY)) + app.setPalette(palette) + + +def apply(app: QApplication, dark: bool = True): + global Colors, _is_dark + _is_dark = dark + Colors = DarkColors if dark else LightColors + app.setStyle("Fusion") + _apply_palette(app, Colors) + app.setStyleSheet(_build_stylesheet(Colors)) + + +def toggle(app: QApplication) -> bool: + """Toggle dark/light theme. Returns True if now dark.""" + global _is_dark + apply(app, dark=not _is_dark) + return _is_dark + + +def is_dark() -> bool: + return _is_dark + + +def restyle(widget, obj_name: str) -> None: + """Change a widget's objectName and force Qt to re-evaluate CSS rules.""" + widget.setObjectName(obj_name) + widget.style().unpolish(widget) + widget.style().polish(widget) diff --git a/app/ui/websocket_panel.py b/app/ui/websocket_panel.py new file mode 100644 index 0000000..5dd4dd1 --- /dev/null +++ b/app/ui/websocket_panel.py @@ -0,0 +1,216 @@ +"""APIClient - Agent — WebSocket client panel.""" +import asyncio +import queue +import time + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, + QPushButton, QTextEdit, QLabel, QSplitter +) +from PyQt6.QtCore import QThread, pyqtSignal, Qt +from PyQt6.QtGui import QFont + +from app.ui.theme import Colors, restyle + + +class WsWorker(QThread): + """Runs the asyncio WebSocket loop in a background thread. + + Messages to send are passed via a thread-safe queue. + Received messages and connection events are emitted as Qt signals. + """ + + message_received = pyqtSignal(str) + connected = pyqtSignal() + disconnected = pyqtSignal(str) + + def __init__(self, url: str): + super().__init__() + self.url = url + self._send_q: queue.Queue = queue.Queue() + self._running: bool = False + self._loop: asyncio.AbstractEventLoop | None = None + + # ── Called from UI thread ───────────────────────────────────────────────── + + def send(self, message: str): + """Thread-safe: enqueue a message to be sent.""" + self._send_q.put(message) + + def stop(self): + self._running = False + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + + # ── Worker thread ───────────────────────────────────────────────────────── + + def run(self): + try: + import websockets + except ImportError: + self.disconnected.emit("WebSocket support not installed.\nRun: pip install websockets") + return + + self._loop = asyncio.new_event_loop() + self._running = True + try: + self._loop.run_until_complete(self._connect(websockets)) + except Exception as e: + self.disconnected.emit(str(e)) + finally: + self._loop.close() + self._loop = None + + async def _connect(self, websockets): + try: + async with websockets.connect(self.url) as ws: + self.connected.emit() + while self._running: + # Drain outbound queue + while not self._send_q.empty(): + try: + msg = self._send_q.get_nowait() + await ws.send(msg) + except queue.Empty: + break + + # Non-blocking receive with timeout + try: + msg = await asyncio.wait_for(ws.recv(), timeout=0.1) + self.message_received.emit(f"← {msg}") + except asyncio.TimeoutError: + continue + except Exception: + break + + except Exception as e: + self.disconnected.emit(str(e)) + return + + self.disconnected.emit("Connection closed") + + +class WebSocketPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + # ── Connection bar ──────────────────────────────────────────────────── + conn_row = QHBoxLayout() + url_label = QLabel("URL:") + url_label.setObjectName("fieldLabel") + self.url_input = QLineEdit() + self.url_input.setObjectName("urlBar") + self.url_input.setPlaceholderText("ws://localhost:8080/socket") + self.connect_btn = QPushButton("Connect") + self.connect_btn.setObjectName("accent") + self.connect_btn.setFixedWidth(100) + self.connect_btn.clicked.connect(self._toggle_connection) + self.status_label = QLabel("● Disconnected") + self.status_label.setObjectName("statusErr") + conn_row.addWidget(url_label) + conn_row.addWidget(self.url_input, 1) + conn_row.addWidget(self.connect_btn) + conn_row.addWidget(self.status_label) + layout.addLayout(conn_row) + + splitter = QSplitter(Qt.Orientation.Vertical) + + # Message log + self.log = QTextEdit() + self.log.setReadOnly(True) + self.log.setFont(QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)) + splitter.addWidget(self.log) + + # Send area + send_w = QWidget() + send_layout = QVBoxLayout(send_w) + send_layout.setContentsMargins(0, 4, 0, 0) + send_layout.setSpacing(6) + + self.send_input = QTextEdit() + self.send_input.setPlaceholderText("Type message to send…") + self.send_input.setMaximumHeight(80) + self.send_input.setFont(QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)) + + send_btn_row = QHBoxLayout() + self.send_btn = QPushButton("Send") + self.send_btn.setObjectName("sendBtn") + self.send_btn.setEnabled(False) + self.send_btn.setFixedWidth(90) + self.send_btn.clicked.connect(self._send_message) + + clear_btn = QPushButton("Clear Log") + clear_btn.setObjectName("ghost") + clear_btn.clicked.connect(self.log.clear) + + send_btn_row.addWidget(self.send_btn) + send_btn_row.addWidget(clear_btn) + send_btn_row.addStretch() + + send_layout.addWidget(self.send_input) + send_layout.addLayout(send_btn_row) + splitter.addWidget(send_w) + + splitter.setSizes([350, 150]) + layout.addWidget(splitter) + + self._worker: WsWorker | None = None + + # ── Slots ───────────────────────────────────────────────────────────────── + + def _toggle_connection(self): + if self._worker and self._worker.isRunning(): + self._worker.stop() + self._worker.wait(1000) + self._worker = None + self._set_disconnected("Disconnected by user") + else: + url = self.url_input.text().strip() + if not url: + return + self._worker = WsWorker(url) + self._worker.connected.connect(self._on_connected) + self._worker.disconnected.connect(self._on_disconnected) + self._worker.message_received.connect(self._on_message) + self._worker.start() + self.connect_btn.setText("Connecting…") + self.connect_btn.setEnabled(False) + restyle(self.status_label, "statusWarn") + self.status_label.setText("● Connecting…") + + def _on_connected(self): + restyle(self.status_label, "statusOk") + self.status_label.setText("● Connected") + self.connect_btn.setText("Disconnect") + self.connect_btn.setEnabled(True) + self.send_btn.setEnabled(True) + self._log("── Connected ──", Colors.SUCCESS) + + def _on_disconnected(self, reason: str): + self._set_disconnected(reason) + self._log(f"── {reason} ──", Colors.ERROR) + + def _set_disconnected(self, reason: str = "Disconnected"): + restyle(self.status_label, "statusErr") + self.status_label.setText(f"● {reason}") + self.connect_btn.setText("Connect") + self.connect_btn.setEnabled(True) + self.send_btn.setEnabled(False) + + def _on_message(self, msg: str): + self._log(msg, Colors.INFO) + + def _send_message(self): + msg = self.send_input.toPlainText().strip() + if msg and self._worker: + self._worker.send(msg) + self._log(f"→ {msg}", Colors.WARNING) + self.send_input.clear() + + def _log(self, text: str, color: str = "#D4D4D4"): + ts = time.strftime("%H:%M:%S") + safe_text = text.replace("&", "&").replace("<", "<").replace(">", ">") + self.log.append(f'[{ts}] {safe_text}') diff --git a/build_installer.sh b/build_installer.sh new file mode 100755 index 0000000..020edae --- /dev/null +++ b/build_installer.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +echo "=== Building API Client installer ===" + +# Install deps +pip install -r requirements.txt + +# Build with PyInstaller +pyinstaller \ + --onedir \ + --windowed \ + --name "APIClient" \ + --add-data "app:app" \ + main.py + +echo "" +echo "=== Build complete ===" +echo "Executable: dist/APIClient/APIClient" +echo "" + +# Optional: create .deb (requires fpm: gem install fpm) +if command -v fpm &> /dev/null; then + echo "Creating .deb package..." + fpm -s dir -t deb \ + -n api-client \ + -v 1.0.0 \ + --description "Postman-like API client" \ + dist/APIClient/=/opt/api-client \ + --after-install /dev/null + echo "Package: api-client_1.0.0_amd64.deb" +else + echo "Tip: install fpm (gem install fpm) to also generate a .deb package" +fi diff --git a/main.py b/main.py new file mode 100644 index 0000000..42896ed --- /dev/null +++ b/main.py @@ -0,0 +1,17 @@ +import sys +from PyQt6.QtWidgets import QApplication +from app.ui.theme import apply +from app.ui.main_window import MainWindow + +APP_NAME = "APIClient - Agent" +APP_VERSION = "2.0.0" + +if __name__ == "__main__": + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setApplicationVersion(APP_VERSION) + app.setOrganizationName("EKIKA") + apply(app, dark=True) + window = MainWindow() + window.show() + sys.exit(app.exec()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f1618d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt6>=6.6.0 +httpx>=0.27.0 +websockets>=12.0 +anthropic>=0.25.0 +pyyaml>=6.0 +pyinstaller>=6.0.0