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:
2026-03-28 17:34:18 +05:30
parent 1dbbb4320b
commit 01662f7e0e
37 changed files with 7822 additions and 1 deletions

17
.gitignore vendored Normal file
View 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
View 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
View File

@@ -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
View File

0
app/core/__init__.py Normal file
View File

189
app/core/ai_chat.py Normal file
View 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
View 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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
.replace("&quot;", '"').replace("&#39;", "'").replace("&nbsp;", " "))
# 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

394
app/ui/ai_chat_panel.py Normal file
View 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
View 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
View 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
View 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"
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
self.log.append(f'<span style="color:{color}">[{ts}] {safe_text}</span>')

34
build_installer.sh Executable file
View 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
View 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
View 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