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