Initial release — APIClient - Agent v2.0.0
AI-first API testing desktop client built with Python + PyQt6. Features: - Multi-tab HTTP request editor with params/headers/body/auth/tests - KeyValueTable with per-row enable/disable checkboxes and 36px rows - Format JSON button, syntax highlighting, pre-request & test scripts - Collections, environments, history, import/export (Postman v2.1, cURL) - OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens) - EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL, Custom REST JSON with all auth types (instant, no AI tokens) - Persistent AI chat sidebar (Claude-powered co-pilot) with streaming, context-aware suggestions, and one-click Apply to request editor - AI collection generator from any docs URL or pasted spec - WebSocket client, Mock server, Collection runner, Code generator - Dark/light theme engine (global QSS, object-name selectors) - SSL error detection with actionable hints - MIT License Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
391
README.md
391
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
|
||||
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/core/__init__.py
Normal file
0
app/core/__init__.py
Normal file
189
app/core/ai_chat.py
Normal file
189
app/core/ai_chat.py
Normal file
@@ -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()
|
||||
219
app/core/ai_client.py
Normal file
219
app/core/ai_client.py
Normal file
@@ -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)[^>]*>.*?</(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:")
|
||||
163
app/core/code_gen.py
Normal file
163
app/core/code_gen.py
Normal file
@@ -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,
|
||||
}
|
||||
613
app/core/ekika_odoo_generator.py
Normal file
613
app/core/ekika_odoo_generator.py
Normal file
@@ -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",
|
||||
}
|
||||
66
app/core/exporter.py
Normal file
66
app/core/exporter.py
Normal file
@@ -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)
|
||||
161
app/core/http_client.py
Normal file
161
app/core/http_client.py
Normal file
@@ -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))
|
||||
103
app/core/importer.py
Normal file
103
app/core/importer.py
Normal file
@@ -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
|
||||
82
app/core/mock_server.py
Normal file
82
app/core/mock_server.py
Normal file
@@ -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
|
||||
236
app/core/openapi_parser.py
Normal file
236
app/core/openapi_parser.py
Normal file
@@ -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 {}
|
||||
436
app/core/storage.py
Normal file
436
app/core/storage.py
Normal file
@@ -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)
|
||||
)
|
||||
86
app/core/test_runner.py
Normal file
86
app/core/test_runner.py
Normal file
@@ -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
|
||||
72
app/models.py
Normal file
72
app/models.py
Normal file
@@ -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 = ""
|
||||
0
app/ui/__init__.py
Normal file
0
app/ui/__init__.py
Normal file
394
app/ui/ai_chat_panel.py
Normal file
394
app/ui/ai_chat_panel.py
Normal file
@@ -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()
|
||||
699
app/ui/ai_panel.py
Normal file
699
app/ui/ai_panel.py
Normal file
@@ -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())}"
|
||||
)
|
||||
76
app/ui/code_gen_dialog.py
Normal file
76
app/ui/code_gen_dialog.py
Normal file
@@ -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())
|
||||
194
app/ui/collection_runner.py
Normal file
194
app/ui/collection_runner.py
Normal file
@@ -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"
|
||||
)
|
||||
238
app/ui/environment_dialog.py
Normal file
238
app/ui/environment_dialog.py
Normal file
@@ -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()
|
||||
32
app/ui/highlighter.py
Normal file
32
app/ui/highlighter.py
Normal file
@@ -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'(?<!:)\s*"([^"\\]|\\.)*"'), fmt("#CE9178")))
|
||||
# Numbers
|
||||
self._rules.append((re.compile(r'\b-?\d+(\.\d+)?([eE][+-]?\d+)?\b'), fmt("#B5CEA8")))
|
||||
# Booleans & null
|
||||
self._rules.append((re.compile(r'\b(true|false|null)\b'), fmt("#569CD6", bold=True)))
|
||||
# Braces/brackets
|
||||
self._rules.append((re.compile(r'[{}\[\]]'), fmt("#FFD700")))
|
||||
|
||||
def highlightBlock(self, text):
|
||||
for pattern, fmt in self._rules:
|
||||
for m in pattern.finditer(text):
|
||||
self.setFormat(m.start(), m.end() - m.start(), fmt)
|
||||
138
app/ui/import_dialog.py
Normal file
138
app/ui/import_dialog.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""APIClient - Agent — Import Dialog."""
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
||||
QTextEdit, QPushButton, QLabel, QFileDialog, QMessageBox
|
||||
)
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from app.ui.theme import Colors
|
||||
from app.core import importer, storage
|
||||
from app.models import HttpRequest
|
||||
|
||||
|
||||
class ImportDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Import")
|
||||
self.setMinimumSize(640, 460)
|
||||
self.imported_req: HttpRequest | None = None
|
||||
|
||||
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("Import")
|
||||
title.setObjectName("panelTitle")
|
||||
hl.addWidget(title)
|
||||
layout.addWidget(header)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.addTab(self._make_postman_tab(), "Postman Collection")
|
||||
self.tabs.addTab(self._make_curl_tab(), "cURL Command")
|
||||
layout.addWidget(self.tabs, 1)
|
||||
|
||||
# ── Tab builders ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_postman_tab(self):
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
hint = QLabel("Paste a Postman Collection v2.1 JSON or load from file:")
|
||||
hint.setObjectName("hintText")
|
||||
layout.addWidget(hint)
|
||||
|
||||
self.postman_editor = QTextEdit()
|
||||
self.postman_editor.setFont(
|
||||
QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)
|
||||
)
|
||||
self.postman_editor.setPlaceholderText(
|
||||
'{\n "info": {"name": "My Collection"},\n "item": [...]\n}'
|
||||
)
|
||||
layout.addWidget(self.postman_editor)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
load_btn = QPushButton("Load File…")
|
||||
load_btn.clicked.connect(self._load_postman_file)
|
||||
import_btn = QPushButton("Import Collection")
|
||||
import_btn.setObjectName("accent")
|
||||
import_btn.clicked.connect(self._import_postman)
|
||||
btn_row.addWidget(load_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(import_btn)
|
||||
layout.addLayout(btn_row)
|
||||
return w
|
||||
|
||||
def _make_curl_tab(self):
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
hint = QLabel("Paste a cURL command — it will open as a new request tab:")
|
||||
hint.setObjectName("hintText")
|
||||
layout.addWidget(hint)
|
||||
|
||||
self.curl_editor = QTextEdit()
|
||||
self.curl_editor.setFont(
|
||||
QFont("JetBrains Mono, Fira Code, Consolas, monospace", 10)
|
||||
)
|
||||
self.curl_editor.setPlaceholderText(
|
||||
"curl -X POST https://api.example.com/data \\\n"
|
||||
" -H 'Content-Type: application/json' \\\n"
|
||||
" -d '{\"key\": \"value\"}'"
|
||||
)
|
||||
layout.addWidget(self.curl_editor)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
import_btn = QPushButton("Import as Request")
|
||||
import_btn.setObjectName("accent")
|
||||
import_btn.clicked.connect(self._import_curl)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(import_btn)
|
||||
layout.addLayout(btn_row)
|
||||
return w
|
||||
|
||||
# ── Import actions ────────────────────────────────────────────────────────
|
||||
|
||||
def _load_postman_file(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Open Collection", "", "JSON Files (*.json);;All Files (*)"
|
||||
)
|
||||
if path:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
self.postman_editor.setPlainText(f.read())
|
||||
|
||||
def _import_postman(self):
|
||||
text = self.postman_editor.toPlainText().strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
col_name, requests = importer.from_postman_collection(text)
|
||||
col_id = storage.add_collection(col_name)
|
||||
for req in requests:
|
||||
storage.save_request(col_id, req)
|
||||
QMessageBox.information(
|
||||
self, "Import Successful",
|
||||
f"Imported collection '{col_name}' with {len(requests)} request(s)."
|
||||
)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Import Error", str(e))
|
||||
|
||||
def _import_curl(self):
|
||||
text = self.curl_editor.toPlainText().strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
self.imported_req = importer.from_curl(text)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Import Error", str(e))
|
||||
466
app/ui/main_window.py
Normal file
466
app/ui/main_window.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""APIClient - Agent — Main Window."""
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QSplitter, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QComboBox, QPushButton, QStatusBar, QTabWidget,
|
||||
QInputDialog, QMessageBox, QFileDialog, QApplication
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||||
from PyQt6.QtGui import QKeySequence, QShortcut
|
||||
|
||||
from app.ui.tabs_manager import TabsManager
|
||||
from app.ui.response_panel import ResponsePanel
|
||||
from app.ui.sidebar import CollectionsSidebar
|
||||
from app.ui.theme import Colors, toggle as toggle_theme, is_dark
|
||||
from app.core import http_client, storage
|
||||
from app.core.test_runner import run_tests
|
||||
from app.models import HttpRequest
|
||||
|
||||
APP_VERSION = "2.0.0"
|
||||
APP_NAME = "APIClient - Agent"
|
||||
|
||||
|
||||
class RequestWorker(QThread):
|
||||
finished = pyqtSignal(object, list)
|
||||
|
||||
def __init__(self, req: HttpRequest, variables: dict):
|
||||
super().__init__()
|
||||
self.req = req
|
||||
self.variables = variables
|
||||
self._cancelled = False
|
||||
|
||||
def run(self):
|
||||
if self.req.pre_request_script.strip():
|
||||
try:
|
||||
exec(self.req.pre_request_script, {"__builtins__": {}}) # noqa: S102
|
||||
except Exception:
|
||||
pass
|
||||
if self._cancelled:
|
||||
return
|
||||
resp = http_client.send_request(self.req, self.variables)
|
||||
tests = run_tests(self.req.test_script, resp)
|
||||
self.finished.emit(resp, tests)
|
||||
|
||||
def cancel(self):
|
||||
self._cancelled = True
|
||||
|
||||
|
||||
class EnvBar(QWidget):
|
||||
"""Top branding + environment selector bar."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("envBar")
|
||||
self.setFixedHeight(46)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 0, 16, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
brand = QLabel("APIClient")
|
||||
brand.setObjectName("brandName")
|
||||
|
||||
sub = QLabel("Agent")
|
||||
sub.setObjectName("brandSub")
|
||||
|
||||
layout.addWidget(brand)
|
||||
layout.addWidget(sub)
|
||||
layout.addStretch()
|
||||
|
||||
env_label = QLabel("ENV")
|
||||
env_label.setObjectName("envChip")
|
||||
layout.addWidget(env_label)
|
||||
|
||||
self.env_combo = QComboBox()
|
||||
self.env_combo.setObjectName("methodCombo")
|
||||
self.env_combo.setMinimumWidth(180)
|
||||
layout.addWidget(self.env_combo)
|
||||
|
||||
self.manage_btn = QPushButton("Manage")
|
||||
self.manage_btn.setObjectName("ghost")
|
||||
self.manage_btn.setToolTip("Manage Environments (Ctrl+E)")
|
||||
layout.addWidget(self.manage_btn)
|
||||
|
||||
self.ai_btn = QPushButton("✦ AI")
|
||||
self.ai_btn.setObjectName("accent")
|
||||
self.ai_btn.setFixedWidth(60)
|
||||
self.ai_btn.setToolTip("Toggle AI Chat (Ctrl+Shift+A)")
|
||||
layout.addWidget(self.ai_btn)
|
||||
|
||||
self.theme_btn = QPushButton("◑")
|
||||
self.theme_btn.setObjectName("ghost")
|
||||
self.theme_btn.setFixedWidth(32)
|
||||
self.theme_btn.setToolTip("Toggle Light / Dark Theme")
|
||||
layout.addWidget(self.theme_btn)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
|
||||
self.setMinimumSize(1280, 800)
|
||||
self._worker: RequestWorker | None = None
|
||||
|
||||
storage.init_db()
|
||||
self._build_ui()
|
||||
self._build_menu()
|
||||
self._build_shortcuts()
|
||||
self._update_env_selector()
|
||||
|
||||
# ── UI Construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QWidget()
|
||||
root_layout = QVBoxLayout(root)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
self.env_bar = EnvBar()
|
||||
self.env_bar.env_combo.currentIndexChanged.connect(self._on_env_changed)
|
||||
self.env_bar.manage_btn.clicked.connect(self._open_env_dialog)
|
||||
self.env_bar.theme_btn.clicked.connect(self._toggle_theme)
|
||||
self.env_bar.ai_btn.clicked.connect(self._toggle_ai_chat)
|
||||
root_layout.addWidget(self.env_bar)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setHandleWidth(1)
|
||||
|
||||
self.sidebar = CollectionsSidebar()
|
||||
self.sidebar.request_selected.connect(self._load_request_in_tab)
|
||||
splitter.addWidget(self.sidebar)
|
||||
|
||||
self.workspace = QTabWidget()
|
||||
self.workspace.setObjectName("workspaceTabs")
|
||||
|
||||
self.workspace.addTab(self._build_http_workspace(), " HTTP ")
|
||||
|
||||
from app.ui.websocket_panel import WebSocketPanel
|
||||
self.ws_panel = WebSocketPanel()
|
||||
self.workspace.addTab(self.ws_panel, " WebSocket ")
|
||||
|
||||
from app.ui.mock_server_panel import MockServerPanel
|
||||
self.mock_panel = MockServerPanel()
|
||||
self.workspace.addTab(self.mock_panel, " Mock Server ")
|
||||
|
||||
splitter.addWidget(self.workspace)
|
||||
|
||||
from app.ui.ai_chat_panel import AIChatPanel
|
||||
self.chat_panel = AIChatPanel()
|
||||
splitter.addWidget(self.chat_panel)
|
||||
splitter.setSizes([260, 940, 360]) # give chat panel real size first
|
||||
self.chat_panel.hide() # THEN hide — splitter remembers 360
|
||||
self._main_splitter = splitter
|
||||
|
||||
# Wire apply signals
|
||||
self.chat_panel.apply_body.connect(lambda c: self._ai_apply("body", c))
|
||||
self.chat_panel.apply_params.connect(lambda c: self._ai_apply("params", c))
|
||||
self.chat_panel.apply_headers.connect(lambda c: self._ai_apply("headers", c))
|
||||
self.chat_panel.apply_test.connect(lambda c: self._ai_apply("test", c))
|
||||
|
||||
root_layout.addWidget(splitter)
|
||||
|
||||
self.setCentralWidget(root)
|
||||
|
||||
self._status_bar = QStatusBar()
|
||||
self._status_bar.setFixedHeight(26)
|
||||
self.setStatusBar(self._status_bar)
|
||||
self._status_bar.showMessage(f"Ready — {APP_NAME} v{APP_VERSION}")
|
||||
|
||||
def _build_http_workspace(self) -> 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"<b>{APP_NAME} v{APP_VERSION}</b><br>"
|
||||
"Enterprise-grade API testing tool with AI co-pilot.<br><br>"
|
||||
"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"
|
||||
222
app/ui/mock_server_panel.py
Normal file
222
app/ui/mock_server_panel.py
Normal file
@@ -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)
|
||||
467
app/ui/request_panel.py
Normal file
467
app/ui/request_panel.py
Normal file
@@ -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
|
||||
290
app/ui/response_panel.py
Normal file
290
app/ui/response_panel.py
Normal file
@@ -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)
|
||||
133
app/ui/search_dialog.py
Normal file
133
app/ui/search_dialog.py
Normal file
@@ -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())
|
||||
268
app/ui/sidebar.py
Normal file
268
app/ui/sidebar.py
Normal file
@@ -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()
|
||||
97
app/ui/tabs_manager.py
Normal file
97
app/ui/tabs_manager.py
Normal file
@@ -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)
|
||||
981
app/ui/theme.py
Normal file
981
app/ui/theme.py
Normal file
@@ -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)
|
||||
216
app/ui/websocket_panel.py
Normal file
216
app/ui/websocket_panel.py
Normal file
@@ -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'<span style="color:{color}">[{ts}] {safe_text}</span>')
|
||||
34
build_installer.sh
Executable file
34
build_installer.sh
Executable file
@@ -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
|
||||
17
main.py
Normal file
17
main.py
Normal file
@@ -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())
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user