Files
APIClient-Agent/app/ui/ai_chat_panel.py
2026-03-28 17:42:37 +05:30

395 lines
15 KiB
Python

"""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()