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:
394
app/ui/ai_chat_panel.py
Normal file
394
app/ui/ai_chat_panel.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""APIClient - Agent — AI chat sidebar panel (persistent, context-aware)."""
|
||||
import re
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
|
||||
QPushButton, QScrollArea, QFrame, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QEvent
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from app.core import ai_chat
|
||||
|
||||
|
||||
# ── Background worker ─────────────────────────────────────────────────────────
|
||||
|
||||
class ChatWorker(QThread):
|
||||
chunk_received = pyqtSignal(str)
|
||||
finished = pyqtSignal(str)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, messages: list, context: str):
|
||||
super().__init__()
|
||||
self.messages = messages
|
||||
self.context = context
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
text = ai_chat.stream_chat(
|
||||
self.messages,
|
||||
self.context,
|
||||
chunk_cb=lambda c: self.chunk_received.emit(c),
|
||||
)
|
||||
self.finished.emit(text)
|
||||
except ai_chat.AIError as e:
|
||||
self.error.emit(str(e))
|
||||
except Exception as e:
|
||||
self.error.emit(f"Unexpected error: {e}")
|
||||
|
||||
|
||||
# ── Apply block widget ────────────────────────────────────────────────────────
|
||||
|
||||
class ApplyBlock(QFrame):
|
||||
apply_clicked = pyqtSignal(str, str) # type, content
|
||||
|
||||
_LABELS = {
|
||||
"body": "Apply Body",
|
||||
"params": "Apply Params",
|
||||
"headers": "Apply Headers",
|
||||
"test": "Apply Test Script",
|
||||
}
|
||||
|
||||
def __init__(self, atype: str, content: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("applyBlock")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# Code preview
|
||||
code = QTextEdit()
|
||||
code.setObjectName("applyCode")
|
||||
code.setReadOnly(True)
|
||||
code.setPlainText(content)
|
||||
code.setFont(QFont("JetBrains Mono, Fira Code, Consolas", 9))
|
||||
code.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
code.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
lines = content.count("\n") + 1
|
||||
code.setFixedHeight(min(lines * 18 + 16, 180))
|
||||
layout.addWidget(code)
|
||||
|
||||
# Apply button
|
||||
btn = QPushButton(self._LABELS.get(atype, f"Apply {atype}"))
|
||||
btn.setObjectName("accent")
|
||||
btn.setFixedHeight(28)
|
||||
btn.clicked.connect(lambda: self.apply_clicked.emit(atype, content))
|
||||
layout.addWidget(btn)
|
||||
|
||||
|
||||
# ── Message bubble ────────────────────────────────────────────────────────────
|
||||
|
||||
class MessageBubble(QFrame):
|
||||
apply_requested = pyqtSignal(str, str)
|
||||
|
||||
def __init__(self, role: str, text: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self.role = role
|
||||
self._full_text = text
|
||||
self._finalized = False
|
||||
self.setObjectName("userBubble" if role == "user" else "aiBubble")
|
||||
|
||||
self._layout = QVBoxLayout(self)
|
||||
self._layout.setContentsMargins(12, 8, 12, 8)
|
||||
self._layout.setSpacing(6)
|
||||
|
||||
# Role label
|
||||
role_lbl = QLabel("You" if role == "user" else "✦ APIClient - Agent")
|
||||
role_lbl.setObjectName("chatRoleLabel")
|
||||
self._layout.addWidget(role_lbl)
|
||||
|
||||
# Message text label
|
||||
self._text_lbl = QLabel()
|
||||
self._text_lbl.setObjectName("chatMessageText")
|
||||
self._text_lbl.setWordWrap(True)
|
||||
self._text_lbl.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse |
|
||||
Qt.TextInteractionFlag.TextSelectableByKeyboard
|
||||
)
|
||||
self._text_lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self._layout.addWidget(self._text_lbl)
|
||||
|
||||
# Apply blocks area
|
||||
self._apply_area = QVBoxLayout()
|
||||
self._apply_area.setSpacing(6)
|
||||
self._layout.addLayout(self._apply_area)
|
||||
|
||||
if text:
|
||||
self._render(text)
|
||||
|
||||
def append_chunk(self, chunk: str):
|
||||
"""Called during streaming to add text incrementally."""
|
||||
self._full_text += chunk
|
||||
# Show raw text while streaming (apply blocks appear after finalize)
|
||||
self._text_lbl.setText(self._full_text)
|
||||
|
||||
def finalize(self):
|
||||
"""Called when streaming ends — strip apply blocks and render them."""
|
||||
if self._finalized:
|
||||
return
|
||||
self._finalized = True
|
||||
self._render(self._full_text)
|
||||
|
||||
def _render(self, text: str):
|
||||
# Strip apply blocks from display text
|
||||
display = ai_chat.strip_apply_blocks(text)
|
||||
self._text_lbl.setText(display if display else text)
|
||||
|
||||
# Clear old apply blocks
|
||||
while self._apply_area.count():
|
||||
item = self._apply_area.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
# Add new apply blocks
|
||||
for m in re.finditer(r"```apply:(\w+)\n(.*?)```", text, re.DOTALL):
|
||||
atype = m.group(1)
|
||||
content = m.group(2).strip()
|
||||
block = ApplyBlock(atype, content)
|
||||
block.apply_clicked.connect(self.apply_requested)
|
||||
self._apply_area.addWidget(block)
|
||||
|
||||
|
||||
# ── Quick action definitions ──────────────────────────────────────────────────
|
||||
|
||||
QUICK_ACTIONS = [
|
||||
("Analyze", "Analyze this request and response. What does the response mean? Any issues?"),
|
||||
("Fix Error", "This request has an error. What went wrong and how do I fix it?"),
|
||||
("Gen Body", "Generate a complete, correct request body for this endpoint with realistic example data."),
|
||||
("Write Tests", "Write comprehensive test assertions for this response using pm.test()."),
|
||||
("Auth Help", "Explain the authentication setup needed and show me the correct headers."),
|
||||
("Explain Resp", "Explain this API response in detail. What does each field mean?"),
|
||||
]
|
||||
|
||||
|
||||
# ── Main chat panel ───────────────────────────────────────────────────────────
|
||||
|
||||
class AIChatPanel(QWidget):
|
||||
"""Persistent right-sidebar AI chat panel. Context-aware, multi-turn."""
|
||||
|
||||
# Emitted when AI suggests applying something to the current request
|
||||
apply_body = pyqtSignal(str)
|
||||
apply_params = pyqtSignal(str)
|
||||
apply_headers = pyqtSignal(str)
|
||||
apply_test = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("aiChatPanel")
|
||||
self.setMinimumWidth(300)
|
||||
self.setMaximumWidth(500)
|
||||
|
||||
self._messages: list[dict] = []
|
||||
self._context: str = ""
|
||||
self._worker: ChatWorker|None = None
|
||||
self._streaming_bubble: MessageBubble|None = None
|
||||
|
||||
self._build_ui()
|
||||
|
||||
# ── UI construction ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
# Header
|
||||
header = QWidget()
|
||||
header.setObjectName("aiChatHeader")
|
||||
header.setFixedHeight(44)
|
||||
hl = QHBoxLayout(header)
|
||||
hl.setContentsMargins(12, 0, 8, 0)
|
||||
hl.setSpacing(8)
|
||||
|
||||
title = QLabel("✦ APIClient - Agent")
|
||||
title.setObjectName("aiChatTitle")
|
||||
hl.addWidget(title)
|
||||
hl.addStretch()
|
||||
|
||||
clear_btn = QPushButton("Clear")
|
||||
clear_btn.setObjectName("ghost")
|
||||
clear_btn.setFixedHeight(26)
|
||||
clear_btn.setToolTip("Clear conversation history")
|
||||
clear_btn.clicked.connect(self._clear_chat)
|
||||
hl.addWidget(clear_btn)
|
||||
|
||||
root.addWidget(header)
|
||||
|
||||
# Chat history scroll area
|
||||
self._scroll = QScrollArea()
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self._scroll.setObjectName("chatScroll")
|
||||
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
self._chat_container = QWidget()
|
||||
self._chat_container.setObjectName("chatArea")
|
||||
self._chat_layout = QVBoxLayout(self._chat_container)
|
||||
self._chat_layout.setContentsMargins(8, 8, 8, 8)
|
||||
self._chat_layout.setSpacing(8)
|
||||
self._chat_layout.addStretch()
|
||||
|
||||
self._scroll.setWidget(self._chat_container)
|
||||
root.addWidget(self._scroll, 1)
|
||||
|
||||
# Quick actions bar
|
||||
qa_bar = QWidget()
|
||||
qa_bar.setObjectName("quickActions")
|
||||
qa_outer = QVBoxLayout(qa_bar)
|
||||
qa_outer.setContentsMargins(8, 5, 8, 4)
|
||||
qa_outer.setSpacing(4)
|
||||
|
||||
qa_lbl = QLabel("Quick Actions")
|
||||
qa_lbl.setObjectName("hintText")
|
||||
qa_outer.addWidget(qa_lbl)
|
||||
|
||||
for row_start in (0, 3):
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.setSpacing(4)
|
||||
for label, prompt in QUICK_ACTIONS[row_start:row_start+3]:
|
||||
btn = QPushButton(label)
|
||||
btn.setObjectName("qaBtn")
|
||||
btn.setFixedHeight(24)
|
||||
btn.clicked.connect(lambda _, p=prompt: self._send(p))
|
||||
row_layout.addWidget(btn)
|
||||
qa_outer.addLayout(row_layout)
|
||||
|
||||
root.addWidget(qa_bar)
|
||||
|
||||
# Input area
|
||||
input_area = QWidget()
|
||||
input_area.setObjectName("chatInputArea")
|
||||
il = QVBoxLayout(input_area)
|
||||
il.setContentsMargins(8, 6, 8, 8)
|
||||
il.setSpacing(5)
|
||||
|
||||
self._input = QTextEdit()
|
||||
self._input.setObjectName("chatInput")
|
||||
self._input.setPlaceholderText("Ask anything about this request… (Ctrl+Enter to send)")
|
||||
self._input.setFont(QFont("Segoe UI, SF Pro, sans-serif", 10))
|
||||
self._input.setFixedHeight(68)
|
||||
self._input.installEventFilter(self)
|
||||
il.addWidget(self._input)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
self._ctx_label = QLabel("No context")
|
||||
self._ctx_label.setObjectName("hintText")
|
||||
self._ctx_label.setWordWrap(False)
|
||||
btn_row.addWidget(self._ctx_label, 1)
|
||||
|
||||
self._send_btn = QPushButton("Send")
|
||||
self._send_btn.setObjectName("accent")
|
||||
self._send_btn.setFixedSize(70, 28)
|
||||
self._send_btn.clicked.connect(lambda: self._send(self._input.toPlainText().strip()))
|
||||
btn_row.addWidget(self._send_btn)
|
||||
|
||||
il.addLayout(btn_row)
|
||||
root.addWidget(input_area)
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_context(self, req=None, resp=None, env_vars: dict = None):
|
||||
"""Update context from the main window (called on request sent / response received)."""
|
||||
self._context = ai_chat.build_context(req, resp, env_vars)
|
||||
if req and req.url:
|
||||
name = req.name or req.url
|
||||
short = name[:35] + "…" if len(name) > 35 else name
|
||||
self._ctx_label.setText(f"{req.method} {short}")
|
||||
elif req:
|
||||
self._ctx_label.setText("Request loaded")
|
||||
else:
|
||||
self._ctx_label.setText("No context")
|
||||
|
||||
# ── Event filter: Ctrl+Enter sends ───────────────────────────────────────
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if obj is self._input and event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
text = self._input.toPlainText().strip()
|
||||
if text:
|
||||
self._send(text)
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# ── Sending ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _send(self, text: str):
|
||||
if not text:
|
||||
return
|
||||
if self._worker and self._worker.isRunning():
|
||||
return # already streaming
|
||||
|
||||
self._input.clear()
|
||||
self._messages.append({"role": "user", "content": text})
|
||||
|
||||
# User bubble
|
||||
user_bubble = MessageBubble("user", text)
|
||||
self._add_bubble(user_bubble)
|
||||
|
||||
# AI streaming bubble (starts empty)
|
||||
ai_bubble = MessageBubble("assistant")
|
||||
ai_bubble.apply_requested.connect(self._on_apply)
|
||||
self._streaming_bubble = ai_bubble
|
||||
self._add_bubble(ai_bubble)
|
||||
|
||||
self._send_btn.setEnabled(False)
|
||||
self._send_btn.setText("…")
|
||||
|
||||
self._worker = ChatWorker(list(self._messages), self._context)
|
||||
self._worker.chunk_received.connect(self._on_chunk)
|
||||
self._worker.finished.connect(self._on_done)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_chunk(self, chunk: str):
|
||||
if self._streaming_bubble:
|
||||
self._streaming_bubble.append_chunk(chunk)
|
||||
QTimer.singleShot(0, self._scroll_to_bottom)
|
||||
|
||||
def _on_done(self, full_text: str):
|
||||
self._messages.append({"role": "assistant", "content": full_text})
|
||||
if self._streaming_bubble:
|
||||
self._streaming_bubble.finalize()
|
||||
self._streaming_bubble = None
|
||||
self._send_btn.setEnabled(True)
|
||||
self._send_btn.setText("Send")
|
||||
QTimer.singleShot(50, self._scroll_to_bottom)
|
||||
|
||||
def _on_error(self, msg: str):
|
||||
if self._streaming_bubble:
|
||||
self._streaming_bubble.finalize()
|
||||
self._streaming_bubble = None
|
||||
err_bubble = MessageBubble("assistant", f"Error: {msg}")
|
||||
self._add_bubble(err_bubble)
|
||||
self._send_btn.setEnabled(True)
|
||||
self._send_btn.setText("Send")
|
||||
|
||||
def _on_apply(self, atype: str, content: str):
|
||||
signal_map = {
|
||||
"body": self.apply_body,
|
||||
"params": self.apply_params,
|
||||
"headers": self.apply_headers,
|
||||
"test": self.apply_test,
|
||||
}
|
||||
if atype in signal_map:
|
||||
signal_map[atype].emit(content)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _add_bubble(self, bubble: MessageBubble):
|
||||
idx = self._chat_layout.count() - 1 # insert before the stretch
|
||||
self._chat_layout.insertWidget(idx, bubble)
|
||||
QTimer.singleShot(50, self._scroll_to_bottom)
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
vsb = self._scroll.verticalScrollBar()
|
||||
vsb.setValue(vsb.maximum())
|
||||
|
||||
def _clear_chat(self):
|
||||
self._messages.clear()
|
||||
while self._chat_layout.count() > 1:
|
||||
item = self._chat_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
Reference in New Issue
Block a user