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

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>')