395 lines
15 KiB
Python
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()
|