From 8065c0d759ab5cc191462ea3916153f7bc8979e0 Mon Sep 17 00:00:00 2001 From: wellsoren Date: Fri, 24 Apr 2026 01:50:09 +0800 Subject: [PATCH] =?UTF-8?q?style:=20=E9=87=8D=E6=9E=84=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20qtapp=20=E6=A1=8C=E9=9D=A2=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=B8=8E=E4=BA=A4=E4=BA=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontends/qtapp.py | 356 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 316 insertions(+), 40 deletions(-) diff --git a/frontends/qtapp.py b/frontends/qtapp.py index ce69c34..3d5de07 100644 --- a/frontends/qtapp.py +++ b/frontends/qtapp.py @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( QScrollArea, QFrame, QTextEdit, QStackedWidget, QListWidget, QListWidgetItem, QSizePolicy, QFileDialog, QSplitter, QTextBrowser, QApplication, QMessageBox, + QMenu, QLineEdit, ) from PySide6.QtCore import ( Qt, QTimer, QPoint, QPointF, QByteArray, QSize, @@ -251,7 +252,6 @@ class FloatingButton(QWidget): # ── Toggle panel ────────────────────────────────────── def _toggle(self): - from PySide6.QtCore import QDateTime now = QDateTime.currentMSecsSinceEpoch() if now - self._last_toggle_ms < 500: # 500 ms debounce return @@ -319,9 +319,11 @@ _SVG_COPY = '' _SVG_BOT = '' _SVG_SEND = '' -_SVG_PLUS = '' _MD_CSS = """ body { color: #e4e4e7; font-family: "Arial", "Microsoft YaHei", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; } @@ -702,6 +703,48 @@ class _MsgRow(QWidget): self._label.setHtml(_md_to_html(text)) self._adjust_browser_height() + def highlight(self, keyword: str): + """Apply highlight and return keyword's y position in document, or None.""" + if not keyword or not self._text: + return None + kw_lower = keyword.lower() + text_lower = self._text.lower() + if kw_lower not in text_lower: + return None + if self._role == "user": + escaped = self._text.replace("&", "&").replace("<", "<").replace(">", ">") + kw_esc = keyword.replace("&", "&").replace("<", "<").replace(">", ">") + highlighted = escaped.replace(kw_esc, f'{kw_esc}') + self._label.setText(highlighted) + self._label.adjustSize() + return 0 # plain text, keyword at top + else: + from PySide6.QtGui import QTextDocument, QTextCursor, QTextCharFormat + doc = self._label.document() + cursor = QTextCursor(doc) + flags = QTextDocument.FindFlags(0) + fmt = QTextCharFormat() + fmt.setBackground(QColor(251, 191, 36, 90)) + fmt.setForeground(QColor(251, 191, 36)) + keyword_y = None + while True: + cursor = doc.find(keyword, cursor, flags) + if cursor.isNull(): + break + cursor.mergeCharFormat(fmt) + if keyword_y is None: + keyword_y = self._label.cursorRect(cursor).y() + self._adjust_browser_height() + return keyword_y + + def clear_highlight(self): + if self._role == "user": + self._label.setText(self._text) + self._label.adjustSize() + else: + self._label.setHtml(_md_to_html(self._text)) + self._adjust_browser_height() + class _TabButton(QPushButton): _STYLE = """ @@ -787,8 +830,7 @@ class ChatPanel(QWidget): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) path = QPainterPath() - path.addRoundedRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0, - 20.0, 20.0) + path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0) grad = QLinearGradient(0, 0, 0, self.height()) grad.setColorAt(0.0, QColor(20, 20, 28, 228)) grad.setColorAt(1.0, QColor(10, 10, 14, 242)) @@ -798,8 +840,7 @@ class ChatPanel(QWidget): def resizeEvent(self, event): path = QPainterPath() - path.addRoundedRect(0, 0, float(self.width()), float(self.height()), - 20.0, 20.0) + path.addRect(0, 0, float(self.width()), float(self.height())) self.setMask(QRegion(path.toFillPolygon().toPolygon())) super().resizeEvent(event) @@ -821,6 +862,7 @@ class ChatPanel(QWidget): self._stack.addWidget(self._build_sop_page()) # 2 self._stack.addWidget(self._build_settings_page())# 3 root.addWidget(self._stack) + root.addWidget(self._build_statusbar()) # Now that _stack exists, activate the first tab self._switch_tab(0) @@ -836,35 +878,94 @@ class ChatPanel(QWidget): ly.setContentsMargins(16, 0, 10, 0) ly.setSpacing(8) - # Status dot - dot = QLabel("●") - dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") - dot.setFixedWidth(12) - ly.addWidget(dot) + # Search button + search_btn = QPushButton() + search_btn.setIcon(_svg_icon("search", _SVG_SEARCH, "#a1a1aa")) + search_btn.setIconSize(QSize(16, 16)) + search_btn.setFixedSize(26, 26) + search_btn.setCursor(QCursor(Qt.PointingHandCursor)) + search_btn.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 13px; } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """) + search_btn.clicked.connect(self._toggle_search) + self._search_btn = search_btn + ly.addWidget(search_btn) - # Title - t = QLabel("GenericAgent") - t.setStyleSheet("color: #f4f4f5; font-weight: 700; font-size: 14px;") - ly.addWidget(t) + # Search widget (hidden by default) + self._search_widget = QWidget() + self._search_widget.hide() + sw_ly = QHBoxLayout(self._search_widget) + sw_ly.setContentsMargins(0, 0, 0, 0) + sw_ly.setSpacing(6) - # Model badge - self._model_badge = _Badge(self._model_name()) - ly.addWidget(self._model_badge) + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("搜索当前对话和历史...") + self._search_input.setFixedHeight(26) + self._search_input.setStyleSheet(f""" + QLineEdit {{ + background: rgba(32,32,38,0.9); + border: 1px solid {C['border'].name()}; + border-radius: 13px; + color: {C['text']}; + font-size: 13px; + padding: 0 10px; + }} + QLineEdit::placeholder {{ color: {C['muted']}; }} + """) + self._search_input.setFixedWidth(200) + self._search_input.textChanged.connect(self._on_search_changed) + self._search_input.installEventFilter(self) + sw_ly.addWidget(self._search_input) - self._streaming_badge = _StreamingBadge() - ly.addWidget(self._streaming_badge) + close_search = QPushButton("×") + close_search.setFixedSize(26, 26) + close_search.setCursor(QCursor(Qt.PointingHandCursor)) + close_search.setStyleSheet(""" + QPushButton { background: transparent; color: #71717a; border: none; font-size: 16px; } + QPushButton:hover { color: #a1a1aa; } + """) + close_search.clicked.connect(self._hide_search) + sw_ly.addWidget(close_search) + ly.addWidget(self._search_widget) ly.addStretch() + # Minimize button + mini = QPushButton("\uE949") + mini.setFixedSize(26, 26) + mini.setCursor(QCursor(Qt.PointingHandCursor)) + mini.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + mini.clicked.connect(self.hide) + ly.addWidget(mini) + + # Maximize button + maxi = QPushButton("\uE739") + maxi.setFixedSize(26, 26) + maxi.setCursor(QCursor(Qt.PointingHandCursor)) + maxi.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + maxi.clicked.connect(self._toggle_maximize) + self._maxi_btn = maxi + ly.addWidget(maxi) + # Close button - close = QPushButton("×") + close = QPushButton("\uE8BB") close.setFixedSize(26, 26) + close.setCursor(QCursor(Qt.PointingHandCursor)) close.setStyleSheet(""" QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; - border: none; border-radius: 13px; font-size: 15px; font-weight: bold; } + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } QPushButton:hover { background: rgba(220,38,38,0.85); color: white; } """) - close.clicked.connect(self.hide) + close.clicked.connect(lambda: (self.close(), QApplication.instance().quit())) ly.addWidget(close) # Drag @@ -873,6 +974,111 @@ class ChatPanel(QWidget): bar.mouseReleaseEvent = self._tb_release return bar + def _toggle_search(self): + if hasattr(self, "_search_visible") and self._search_visible: + self._hide_search() + else: + self._show_search() + + def _show_search(self): + self._search_visible = True + self._search_btn.setFixedSize(0, 0) + self._search_widget.show() + self._search_input.setFocus() + self._search_input.selectAll() + + def _hide_search(self): + self._search_visible = False + self._search_btn.setFixedSize(26, 26) + self._search_widget.hide() + self._search_input.clear() + self._clear_all_highlights() + if self._stack.currentIndex() == 1: + self._reset_history_items_style() + + def _hide_search_if_no_focus(self): + if not self._search_input.hasFocus(): + self._hide_search() + + def _on_search_changed(self, text): + if not text.strip(): + self._clear_all_highlights() + return + keyword = text.strip() + current_tab = self._stack.currentIndex() + + if current_tab == 0: + self._search_current_chat(keyword) + elif current_tab == 1: + self._search_history(keyword) + + def _clear_all_highlights(self): + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + w.clear_highlight() + + def _search_current_chat(self, keyword: str): + first_found = None + first_keyword_y = None + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + if keyword.lower() in w._text.lower(): + kw_y = w.highlight(keyword) + if first_found is None: + first_found = w + first_keyword_y = kw_y + else: + w.clear_highlight() + # 滚动到第一个匹配项(使用关键词在文档内的实际位置) + if first_found: + self._scroll_to_widget(first_found, first_keyword_y or 0) + + def _scroll_to_widget(self, w, keyword_y=0): + self._user_scrolled_up = True + self._msg_container.layout().activate() + QApplication.processEvents() + + sb = self._scroll.verticalScrollBar() + vp_h = self._scroll.viewport().height() + keyword_screen_y = w.y() + keyword_y + target = keyword_screen_y - vp_h // 3 + target = max(0, min(target, sb.maximum())) + sb.setValue(target) + QApplication.processEvents() + self._scroll.viewport().repaint() + + def _search_history(self, keyword: str): + kw_lower = keyword.lower() + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + session = item.data(Qt.UserRole) + messages = session.get("messages", []) if session else [] + content_text = " ".join([m.get("content", "") for m in messages if isinstance(m.get("content"), str)]) + match = kw_lower in content_text.lower() + item.setHidden(not match) + if match: + item.setBackground(QColor(251, 191, 36, 50)) + item.setForeground(QColor(251, 191, 36)) + else: + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + + def _reset_history_items_style(self): + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + item.setHidden(False) + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + w = self._hist_list.itemWidget(item) + if w: + w.setStyleSheet( + f"background: rgba(35,35,42,0.6); color: {C['text']};" + " border: 1px solid #3f3f46; border-radius: 8px;" + " padding: 8px 12px; margin: 2px 0;" + ) + def _tb_press(self, e): if e.button() == Qt.LeftButton: self._drag_pos = e.globalPosition().toPoint() - self.pos() @@ -884,6 +1090,68 @@ class ChatPanel(QWidget): def _tb_release(self, _e): self._drag_pos = None + def _toggle_maximize(self): + if self.isMaximized(): + self.showNormal() + self._maxi_btn.setText("☐") + else: + self.showMaximized() + self._maxi_btn.setText("❐") + + # ── status bar ───────────────────────────────────────────────────────────── + def _build_statusbar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(24) + bar.setStyleSheet("background: transparent;") + ly = QHBoxLayout(bar) + ly.setContentsMargins(16, 0, 10, 0) + ly.setSpacing(8) + + # Status dot + dot = QLabel("●") + dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") + dot.setFixedWidth(12) + ly.addWidget(dot) + + # Model name (clickable to show model list) + self._model_badge = QLabel(self._model_name()) + self._model_badge.setStyleSheet("color: #a1a1aa; font-size: 11px;") + self._model_badge.setCursor(QCursor(Qt.PointingHandCursor)) + self._model_badge.mousePressEvent = lambda e: self._show_model_menu(e) + ly.addWidget(self._model_badge) + + self._streaming_badge = _StreamingBadge() + ly.addWidget(self._streaming_badge) + + ly.addStretch() + return bar + + def _show_model_menu(self, _e): + menu = QMenu(self._model_badge) + menu.setStyleSheet(f""" + QMenu {{ + background: {C['panel'].name()}; + border: 1px solid {C['border'].name()}; + padding: 4px 0; + }} + QMenu::item {{ + color: {C['text']}; + padding: 6px 20px 6px 12px; + font-size: 12px; + }} + QMenu::item:selected {{ + background: rgba(63,63,70,0.6); + }} + """) + for i, client in enumerate(self.agent.llmclients): + try: + name = client.name or "未知" + except Exception: + name = "未知" + act = menu.addAction(f"{name} #{i + 1}") + act.triggered.connect(lambda _, idx=i: self._do_switch_to(idx)) + menu.exec(QCursor.pos()) + # ── tab bar ─────────────────────────────────────────────────────────────── def _build_tabbar(self) -> QWidget: bar = QWidget() @@ -931,6 +1199,9 @@ class ChatPanel(QWidget): self._stack.setCurrentIndex(idx) for i, btn in enumerate(self._tabs): btn.setChecked(i == idx) + # 切换标签时关闭搜索框 + if hasattr(self, '_search_visible') and self._search_visible: + self._hide_search() if idx == 1: self._refresh_history() if idx == 2: @@ -977,7 +1248,7 @@ class ChatPanel(QWidget): wrap = QWidget() wrap.setStyleSheet("background: transparent;") ly = QVBoxLayout(wrap) - ly.setContentsMargins(20, 6, 20, 14) + ly.setContentsMargins(20, 6, 20, 0) ly.setSpacing(0) self._chips_row = QWidget() @@ -1006,7 +1277,7 @@ class ChatPanel(QWidget): self._input = QTextEdit() self._input.setFixedHeight(64) - self._input.setPlaceholderText("给助手发送消息...") + self._input.setPlaceholderText("给助手发送消息... Enter发送,Shift+Enter换行") self._input.setStyleSheet(f""" QTextEdit {{ background: transparent; color: {C['text']}; @@ -1294,6 +1565,9 @@ class ChatPanel(QWidget): ok = False try: reply = backend.ask("你好", stream=False) + # 兼容生成器函数(NativeClaudeSession.ask是生成器) + if hasattr(reply, '__iter__') and not isinstance(reply, str): + reply = ''.join(str(b) for b in reply if isinstance(b, str)) text = str(reply).strip() if reply else "" ok = len(text) > 0 and not text.startswith("Error") and not text.startswith("[") print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: {'OK' if ok else 'FAIL'} -> {text[:60]}") @@ -1304,14 +1578,20 @@ class ChatPanel(QWidget): backend.raw_msgs = [m for m in backend.raw_msgs if m.get("prompt") != "你好"] self._health_results[idx] = ok - # ── event filter (Enter key in text edit) ───────────────────────────────── + # ── event filter (Enter key in text edit, Escape to close search) ────────── def eventFilter(self, obj, event): - from PySide6.QtCore import QEvent - if obj is self._input and event.type() == QEvent.KeyPress: - if event.key() in (Qt.Key_Return, Qt.Key_Enter): + if event.type() == QEvent.KeyPress: + if obj is self._search_input and event.key() == Qt.Key_Escape: + self._hide_search() + return True + if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter): if not (event.modifiers() & Qt.ShiftModifier): self._handle_send() return True + # 搜索框失焦时关闭搜索 + if event.type() == QEvent.FocusOut and obj is self._search_input: + # 延迟关闭,等待点击事件处理完毕 + QTimer.singleShot(50, self._hide_search_if_no_focus) return super().eventFilter(obj, event) def _on_text_changed(self): @@ -1480,10 +1760,8 @@ class ChatPanel(QWidget): def _scroll_bottom(self): if self._user_scrolled_up: return - QTimer.singleShot(60, lambda: ( - self._scroll.verticalScrollBar().setValue( - self._scroll.verticalScrollBar().maximum() - ) + QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() )) # ── inject (autonomous mode) ─────────────────────────────────────────────── @@ -1513,6 +1791,9 @@ class ChatPanel(QWidget): self._rebuild_messages() self._switch_tab(0) self._update_token_usage() + search_text = self._search_input.text().strip() + if search_text: + QTimer.singleShot(50, lambda: self._search_current_chat(search_text)) def _delete_selected(self): item = self._hist_list.currentItem() @@ -1551,7 +1832,7 @@ class ChatPanel(QWidget): def _refresh_sop(self): self._sop_list.clear() file_icon = _svg_icon("sop_file_item", _SVG_FILE, C["muted"]) - for path in sorted(glob.glob("memory/*.md")): + for path in sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(__file__)), "memory", "*.md"))): name = os.path.basename(path) size = os.path.getsize(path) it = QListWidgetItem(name) @@ -1589,10 +1870,6 @@ class ChatPanel(QWidget): self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl) self._scroll_bottom() - def _do_switch_model(self): - nxt = (self.agent.llm_no + 1) % len(self.agent.llmclients) - self._do_switch_to(nxt) - def _do_stop(self): self.agent.abort() self._poll_timer.stop() @@ -1722,7 +1999,6 @@ def main(): _last_trigger = [0.0] def idle_check(): - import time if not panel.autonomous_enabled: return now = time.time()