diff --git a/README.md b/README.md index 188e3b5..5063e06 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,15 @@ tg_allowed_users = [YOUR_USER_ID] python frontends/tgapp.py ``` +### Alternative App Frontends + +Besides the default Streamlit web UI, you can also try other frontend styles: + +```bash +python frontends/qtapp.py # Qt-based desktop app +streamlit run frontends/stapp2.py # Alternative Streamlit UI +``` + ## ๐Ÿ“Š Comparison with Similar Tools @@ -338,6 +347,15 @@ dingtalk_client_secret = "your_app_secret" dingtalk_allowed_users = ["your_staff_id"] # ๆˆ– ['*'] ``` +### ๅ…ถไป– App ๅ‰็ซฏ + +้™ค้ป˜่ฎค็š„ Streamlit Web UI ๅค–๏ผŒ่ฟ˜ๅฏไปฅๅฐ่ฏ•ไธๅŒ้ฃŽๆ ผ็š„ๅ‰็ซฏ๏ผš + +```bash +python frontends/qtapp.py # ๅŸบไบŽ Qt ็š„ๆกŒ้ขๅบ”็”จ +streamlit run frontends/stapp2.py # ๅฆไธ€็ง Streamlit ้ฃŽๆ ผ UI +``` + ## ๐Ÿ“Š ไธŽๅŒ็ฑปไบงๅ“ๅฏนๆฏ” diff --git a/frontends/qtapp.py b/frontends/qtapp.py new file mode 100644 index 0000000..1a37567 --- /dev/null +++ b/frontends/qtapp.py @@ -0,0 +1,1746 @@ +""" +ๆกŒ้ขๅ‰็ซฏๅ•ๆ–‡ไปถ็‰ˆ โ€“ PySide6 ่Šๅคฉ้ขๆฟ + ๆ‚ฌๆตฎๆŒ‰้’ฎ thanks to GaoZhiCheng +ไพ่ต–: pip install PySide6 +ๅฏ้€‰: pip install markdown (Markdown ๆธฒๆŸ“) +็”จๆณ•: python frontends/qtapp.py +""" +from __future__ import annotations + +import math, os, sys, json, glob, re, base64, time, threading +import queue as _queue +from datetime import datetime +from typing import Optional + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTextEdit, QStackedWidget, + QListWidget, QListWidgetItem, QSizePolicy, QFileDialog, + QSplitter, QTextBrowser, QApplication, QMessageBox, +) +from PySide6.QtCore import ( + Qt, QTimer, QPoint, QPointF, QByteArray, QSize, + Signal, QMetaObject, Q_ARG, QObject, QDateTime, QEvent, +) +from PySide6.QtGui import ( + QPainter, QColor, QLinearGradient, QRadialGradient, + QPen, QPainterPath, QCursor, QFont, QIcon, QPixmap, QRegion, +) + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from agentmain import GeneraticAgent + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# FloatingButton +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +class FloatingButton(QWidget): + SIZE = 60 # circle diameter + MARGIN = 14 # extra space for glow + TOTAL = SIZE + MARGIN * 2 + + def __init__(self, chat_panel: QWidget): + super().__init__() + self.chat_panel = chat_panel + self._drag_origin_global: QPoint | None = None + self._drag_origin_win: QPoint | None = None + self._dragged = False + self._glow = 0.5 + self._glow_dir = 1 + self._hovering = False + self._hover_clock = 0.0 + self._hover_strength = 0.0 + self._flow_phase = 0.0 + self._running = False + self._last_toggle_ms = 0 # debounce timestamp + + # Window flags: frameless, always on top, no taskbar entry + self.setWindowFlags( + Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint + | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setFixedSize(self.TOTAL, self.TOTAL) + self.setCursor(QCursor(Qt.PointingHandCursor)) + + # Smooth animation (~30 fps) + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._timer.start(33) + + # Default position: bottom-right of the work area + scr = QApplication.primaryScreen().availableGeometry() + self.move(scr.right() - self.TOTAL - 20, scr.bottom() - self.TOTAL - 20) + + # โ”€โ”€ Animation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _tick(self): + # running status: green when model is actively responding + self._running = bool( + getattr(self.chat_panel, "_is_streaming", False) + or getattr(getattr(self.chat_panel, "agent", None), "is_running", False) + ) + + self._glow += self._glow_dir * 0.04 + if self._glow >= 1.0: + self._glow, self._glow_dir = 1.0, -1 + elif self._glow <= 0.0: + self._glow, self._glow_dir = 0.0, 1 + + target = 1.0 if self._hovering else 0.0 + self._hover_strength += (target - self._hover_strength) * 0.20 + self._hover_clock += 0.033 + self._flow_phase += 0.16 + (0.06 if self._running else 0.0) + (0.05 if self._hovering else 0.0) + self.update() + + # โ”€โ”€ Painting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + m = self.MARGIN + r = self.SIZE // 2 + cx = m + r + # Rhythmic spring bounce: one main hop + one lighter rebound per beat. + beat_t = self._hover_clock % 1.18 + spring = 0.0 + if beat_t < 0.70: + spring += max(0.0, math.exp(-5.2 * beat_t) * math.sin(15.5 * beat_t)) + if beat_t > 0.20: + rt = beat_t - 0.20 + spring += 0.52 * max(0.0, math.exp(-7.0 * rt) * math.sin(21.0 * rt)) + idle_sway = 0.20 * math.sin(self._hover_clock * 2.1) + bounce = int(round((spring * 7.2 + idle_sway) * self._hover_strength)) + cy = m + r - bounce + + if self._running: + # running: #2DFFF5 -> #FFF878 + g0 = QColor(45, 255, 245, 195) + g1 = QColor(255, 248, 120, 195) + glow_rgb = (96, 255, 216) + else: + # idle: #103CE7 -> #64E9FF + g0 = QColor(16, 60, 231, 195) + g1 = QColor(100, 233, 255, 195) + glow_rgb = (74, 170, 255) + + # --- Outer glow rings (3 layers) --- + base_alpha = int(45 + 25 * self._glow) + for i, gr in enumerate([r + 10, r + 6, r + 2]): + g = QRadialGradient(QPointF(cx, cy), gr) + g.setColorAt(0.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], max(0, base_alpha - i * 14))) + g.setColorAt(1.0, QColor(glow_rgb[0], glow_rgb[1], glow_rgb[2], 0)) + p.setBrush(g) + p.setPen(Qt.NoPen) + p.drawEllipse(int(cx - gr), int(cy - gr), int(gr * 2), int(gr * 2)) + + # --- Frosted glass disc behind main circle --- + frost = QRadialGradient(QPointF(cx, cy), r) + frost.setColorAt(0.0, QColor(30, 30, 45, 140)) + frost.setColorAt(0.85, QColor(20, 20, 32, 160)) + frost.setColorAt(1.0, QColor(14, 14, 20, 100)) + p.setBrush(frost) + p.setPen(Qt.NoPen) + p.drawEllipse(cx - r, cy - r, r * 2, r * 2) + + # --- Main circle (flowing state gradient) --- + spin = self._flow_phase + dx = math.cos(spin) * r + dy = math.sin(spin) * r + grad = QLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy) + grad.setColorAt(0.0, g0) + grad.setColorAt(1.0, g1) + p.setBrush(grad) + p.setPen(QPen(QColor(255, 255, 255, 50), 1.5)) + p.drawEllipse(cx - r, cy - r, r * 2, r * 2) + + # --- Flowing glass streaks --- + clip = QPainterPath() + clip.addEllipse(float(cx - r), float(cy - r), float(r * 2), float(r * 2)) + p.setClipPath(clip) + + flow_shift = math.sin(self._flow_phase * 0.85) * (r * 0.7) + streak1 = QLinearGradient(cx - r + flow_shift, cy - r, cx + r + flow_shift, cy + r) + streak1.setColorAt(0.00, QColor(255, 255, 255, 0)) + streak1.setColorAt(0.45, QColor(255, 255, 255, 42)) + streak1.setColorAt(0.52, QColor(255, 255, 255, 78)) + streak1.setColorAt(0.60, QColor(255, 255, 255, 24)) + streak1.setColorAt(1.00, QColor(255, 255, 255, 0)) + p.setBrush(streak1) + p.setPen(Qt.NoPen) + p.drawEllipse(cx - r, cy - r, r * 2, r * 2) + + flow_shift_2 = math.cos(self._flow_phase * 1.2) * (r * 0.5) + streak2 = QLinearGradient(cx - r, cy + flow_shift_2, cx + r, cy - flow_shift_2) + streak2.setColorAt(0.00, QColor(255, 255, 255, 0)) + streak2.setColorAt(0.35, QColor(255, 255, 255, 16)) + streak2.setColorAt(0.50, QColor(255, 255, 255, 46)) + streak2.setColorAt(0.65, QColor(255, 255, 255, 16)) + streak2.setColorAt(1.00, QColor(255, 255, 255, 0)) + p.setBrush(streak2) + p.drawEllipse(cx - r, cy - r, r * 2, r * 2) + + # --- Top highlight --- + hl = QLinearGradient(cx, cy - r, cx, cy) + hl.setColorAt(0.0, QColor(255, 255, 255, 72)) + hl.setColorAt(1.0, QColor(255, 255, 255, 0)) + p.setBrush(hl) + p.drawRect(cx - r, cy - r, r * 2, r) + p.setClipping(False) + + # --- Bot icon --- + p.setPen(QPen(QColor(255, 255, 255, 220), 1.8)) + p.setBrush(Qt.NoBrush) + # Head + p.drawRoundedRect(cx - 9, cy - 6, 18, 12, 2, 2) + # Eyes + p.setBrush(QColor(255, 255, 255, 220)) + p.setPen(Qt.NoPen) + p.drawEllipse(cx - 6, cy - 3, 4, 4) + p.drawEllipse(cx + 2, cy - 3, 4, 4) + # Antenna stem + p.setPen(QPen(QColor(255, 255, 255, 220), 1.8)) + p.drawLine(cx, cy - 6, cx, cy - 10) + # Antenna tip + p.setBrush(QColor(255, 255, 255, 190)) + p.setPen(Qt.NoPen) + p.drawEllipse(cx - 2, cy - 13, 4, 4) + + def enterEvent(self, event): + self._hovering = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hovering = False + self.update() + super().leaveEvent(event) + + # โ”€โ”€ Mouse events (drag + click) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self._drag_origin_global = event.globalPosition().toPoint() + self._drag_origin_win = self.pos() + self._dragged = False + + def mouseMoveEvent(self, event): + if event.buttons() == Qt.LeftButton and self._drag_origin_global: + delta = event.globalPosition().toPoint() - self._drag_origin_global + if abs(delta.x()) > 5 or abs(delta.y()) > 5: + self._dragged = True + if self._dragged: + new = self._drag_origin_win + delta + scr = QApplication.primaryScreen().availableGeometry() + new.setX(max(scr.left(), min(new.x(), scr.right() - self.width()))) + new.setY(max(scr.top(), min(new.y(), scr.bottom() - self.height()))) + self.move(new) + + def mouseDoubleClickEvent(self, event): + # Qt sends Pressโ†’Releaseโ†’DoubleClickโ†’Release on double-click. + # The first Release already toggled the panel; swallow the DoubleClick + # so the second Release does NOT trigger a second toggle. + self._dragged = True # mark as "dragged" โ†’ Release will be ignored + event.accept() + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton: + if not self._dragged: + self._toggle() + self._dragged = False + self._drag_origin_global = None + + # โ”€โ”€ Toggle panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _toggle(self): + from PySide6.QtCore import QDateTime + now = QDateTime.currentMSecsSinceEpoch() + if now - self._last_toggle_ms < 500: # 500 ms debounce + return + self._last_toggle_ms = now + + if self.chat_panel.isVisible(): + self.chat_panel.hide() + else: + self._position_panel() + self.chat_panel.show() + self.chat_panel.raise_() + self.chat_panel.activateWindow() + + def _position_panel(self): + scr = QApplication.primaryScreen().availableGeometry() + btn = self.geometry() + pw = self.chat_panel.width() + ph = self.chat_panel.height() + # Prefer left of button, bottom-aligned + x = btn.left() - pw - 12 + y = btn.bottom() - ph + x = max(scr.left() + 10, min(x, scr.right() - pw - 10)) + y = max(scr.top() + 10, min(y, scr.bottom() - ph - 10)) + self.chat_panel.move(x, y) + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# ChatPanel +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +# โ”€โ”€ constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +HISTORY_FILE = "memory/chat_history.json" +TEXT_FILE_EXTS = { + ".txt", ".md", ".py", ".json", ".csv", ".yaml", ".yml", + ".log", ".ini", ".toml", ".xml", ".html", ".js", ".ts", ".sql", +} +MAX_INLINE_CHARS = 6000 + +C = { + "bg": QColor(14, 14, 18), + "panel": QColor(20, 20, 24, 248), + "border": QColor(45, 45, 50), + "accent": "#7c3aed", + "text": "#e4e4e7", + "muted": "#71717a", + "user_g0": QColor(79, 70, 229), + "user_g1": QColor(124, 58, 237), + "asst_bg": QColor(39, 39, 42, 210), + "asst_bdr": QColor(63, 63, 70), + "send_g0": QColor(220, 38, 38), + "send_g1": QColor(239, 68, 68), + "green": "#22c55e", +} + +SCROLLBAR_STYLE = """ +QScrollBar:vertical { width: 5px; background: transparent; border: none; } +QScrollBar::handle:vertical { + background: rgba(255,255,255,0.12); border-radius: 2px; min-height: 20px; +} +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } +""" + +_SVG_COPY = '' +_SVG_REGEN = '' +_SVG_CHAT = '' +_SVG_CLOCK = '' +_SVG_BOOK = '' +_SVG_GEAR = '' +_SVG_CLIP = '' +_SVG_STOP = '' +_SVG_RESET = _SVG_REGEN +_SVG_SAVE = '' +_SVG_TRASH = '' +_SVG_BOLT = '' +_SVG_PLAY = '' +_SVG_FILE = '' +_SVG_USER = '' +_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; } +h1 { color: #f4f4f5; font-size: 20px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 4px; margin-top: 16px; } +h2 { color: #f4f4f5; font-size: 17px; font-weight: 700; border-bottom: 1px solid #3f3f46; padding-bottom: 3px; margin-top: 14px; } +h3 { color: #f4f4f5; font-size: 15px; font-weight: 600; margin-top: 12px; } +h4,h5,h6 { color: #d4d4d8; font-size: 13px; font-weight: 600; margin-top: 10px; } +code { background: rgba(63,63,70,0.6); color: #c4b5fd; padding: 1px 4px; border-radius: 3px; + font-family: Consolas, "Courier New", monospace; font-size: 12px; } +pre { background: rgba(24,24,30,0.95); border: 1px solid #3f3f46; border-radius: 6px; + padding: 10px 12px; margin: 8px 0; } +pre code { background: transparent; padding: 0; color: #d4d4d8; } +a { color: #818cf8; text-decoration: none; } +a:hover { text-decoration: underline; } +blockquote { border-left: 3px solid #7c3aed; margin: 8px 0 8px 0; padding: 4px 0 4px 12px; color: #a1a1aa; } +table { border-collapse: collapse; margin: 8px 0; } +th, td { border: 1px solid #3f3f46; padding: 5px 10px; } +th { background: rgba(63,63,70,0.35); color: #d4d4d8; font-weight: 700; } +hr { border: none; border-top: 1px solid #3f3f46; margin: 12px 0; } +ul, ol { padding-left: 22px; margin: 4px 0; } +li { margin: 2px 0; } +p { margin: 6px 0; } +""" + + +def _md_to_html(text: str) -> str: + try: + import markdown + return markdown.markdown( + text, extensions=["fenced_code", "tables", "nl2br", "sane_lists"] + ) + except ImportError: + pass + html, in_code, in_ul = [], False, False + for raw in text.split("\n"): + if raw.strip().startswith("```"): + if in_code: + html.append("") + else: + html.append("
")
+            in_code = not in_code
+            continue
+        if in_code:
+            html.append(raw.replace("&", "&").replace("<", "<").replace(">", ">"))
+            continue
+        line = raw
+        line = re.sub(r"`([^`]+)`", r"\1", line)
+        line = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
+        line = re.sub(r"\*(.+?)\*", r"\1", line)
+        line = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', line)
+        if re.match(r"^#{1,6}\s", line):
+            lvl = len(line.split()[0])
+            line = f"{line[lvl:].strip()}"
+        elif re.match(r"^-{3,}$|^_{3,}$|^\*{3,}$", line.strip()):
+            line = "
" + elif re.match(r"^\s*[-*+]\s", line): + content = re.sub(r"^\s*[-*+]\s", "", line) + if not in_ul: + html.append("") + in_ul = False + line = f"

{line}

" if line.strip() else "" + html.append(line) + if in_code: + html.append("
") + if in_ul: + html.append("") + return "\n".join(html) + + +_icon_cache: dict[str, QIcon] = {} + +def _svg_icon(key: str, svg_template: str, color: str = "#a1a1aa", + size: int = 16) -> QIcon: + cache_key = f"{key}_{color}_{size}" + if cache_key not in _icon_cache: + try: + from PySide6.QtSvg import QSvgRenderer + except ImportError: + return QIcon() + data = QByteArray(svg_template.format(c=color).encode("utf-8")) + renderer = QSvgRenderer(data) + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + _icon_cache[cache_key] = QIcon(pixmap) + return _icon_cache[cache_key] + + +# โ”€โ”€ utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +def _make_session_id() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + +def _load_history() -> list: + if os.path.exists(HISTORY_FILE): + try: + with open(HISTORY_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return [] + + +def _save_history(history: list): + os.makedirs(os.path.dirname(HISTORY_FILE), exist_ok=True) + with open(HISTORY_FILE, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=2) + + +def _build_prompt_with_uploads(prompt: str, files: list) -> tuple: + """ + files: list of {'name': str, 'type': str, 'raw': bytes} + returns (full_prompt, display_prompt, display_attachments) + """ + if not files: + return prompt, prompt, [] + + os.makedirs("temp/uploaded", exist_ok=True) + attachment_chunks = ["\n\n[็”จๆˆทไธŠไผ ้™„ไปถ โ€” ๆ–‡ไปถๅทฒไฟๅญ˜ๅˆฐๆœฌๅœฐ็ฃ็›˜๏ผŒๅฏ็”จ file_read ๅทฅๅ…ท่ฏปๅ–]"] + display_attachments = [] + img_count, file_names = 0, [] + + for f in files: + raw, name, mime = f["raw"], f["name"], f.get("type", "") + size = len(raw) + ext = os.path.splitext(name)[1].lower() + safe = re.sub(r"[^A-Za-z0-9._\-]", "_", name) + saved = os.path.join( + "temp", "uploaded", + f"{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{safe}", + ) + try: + with open(saved, "wb") as out: + out.write(raw) + except Exception: + saved = "(ไฟๅญ˜ๅคฑ่ดฅ)" + + if mime.startswith("image/"): + b64 = base64.b64encode(raw).decode() + attachment_chunks.append( + f"\n- [ๅ›พ็‰‡้™„ไปถ] {name} ({size} bytes)\n ็ฃ็›˜่ทฏๅพ„: {saved}" + f"\n data:{mime};base64,{b64}" + ) + display_attachments.append({"type": "image", "name": name}) + img_count += 1 + elif ext in TEXT_FILE_EXTS: + text = raw.decode("utf-8", errors="replace") + attachment_chunks.append( + f"\n--- ๆ–‡ๆœฌๆ–‡ไปถ: {name} ({size} bytes) ---\n็ฃ็›˜่ทฏๅพ„: {saved}\n{text[:MAX_INLINE_CHARS]}" + + ("\n[ๅ†…ๅฎนๅทฒๆˆชๆ–ญ๏ผŒ่ฏท็”จ file_read ่ฏปๅ–ๅฎŒๆ•ดๅ†…ๅฎน]" if len(text) > MAX_INLINE_CHARS else "") + ) + display_attachments.append({"type": "file", "name": name}) + file_names.append(name) + else: + attachment_chunks.append( + f"\n- ๆ–‡ไปถ: {name} ({size} bytes)\n ็ฃ็›˜่ทฏๅพ„: {saved}" + ) + display_attachments.append({"type": "file", "name": name}) + file_names.append(name) + + parts = [] + if img_count: + parts.append(f"{img_count} ๅผ ๅ›พ็‰‡") + if file_names: + parts.append(f"{len(file_names)} ไธชๆ–‡ไปถ๏ผˆ{'ใ€'.join(file_names)}๏ผ‰") + display_prompt = f"{prompt}\n\n๐Ÿ“Ž ๅทฒ้™„ๅธฆ๏ผš{'๏ผŒ'.join(parts)}" if parts else prompt + return prompt + "\n".join(attachment_chunks), display_prompt, display_attachments + + +# โ”€โ”€ small reusable widgets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _Separator(QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedHeight(1) + self.setStyleSheet(f"background: {C['border'].name()};") + + +class _Badge(QLabel): + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setStyleSheet( + "QLabel { background: rgba(63,63,70,0.9); color: #a1a1aa;" + " border: 1px solid #3f3f46; border-radius: 9px;" + " padding: 1px 8px; font-size: 11px; }" + ) + + +class _StreamingBadge(QLabel): + def __init__(self, parent=None): + super().__init__("ๅค„็†ไธญโ€ฆ", parent) + self.setStyleSheet( + "QLabel { background: rgba(124,58,237,0.18); color: #c4b5fd;" + " border: 1px solid rgba(124,58,237,0.35); border-radius: 9px;" + " padding: 1px 8px; font-size: 11px; }" + ) + self.hide() + + +class _MsgRow(QWidget): + """A single message row โ€“ flat layout with avatar, inspired by ChatGPT / Qwen.""" + + _ACTION_BTN = """ + QPushButton { + background: transparent; border: none; border-radius: 4px; padding: 3px; + } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """ + + def __init__(self, text: str, role: str, parent=None, on_resend=None): + super().__init__(parent) + self._text = text + self._role = role + self._on_resend = on_resend + self._action_row = None + self._finished = True + + is_user = role == "user" + self.setStyleSheet( + "background: rgba(255,255,255,0.03);" if is_user else "background: transparent;" + ) + + outer = QHBoxLayout(self) + outer.setContentsMargins(20, 10, 20, 10) + outer.setSpacing(12) + outer.setAlignment(Qt.AlignTop) + + avatar = QLabel() + avatar.setFixedSize(30, 30) + avatar.setAlignment(Qt.AlignCenter) + svg_data = _SVG_USER if is_user else _SVG_BOT + avatar_color = "#c8c8d0" if is_user else "#9eb4d0" + pm = QPixmap(30, 30) + pm.fill(QColor(0, 0, 0, 0)) + from PySide6.QtSvg import QSvgRenderer + renderer = QSvgRenderer(QByteArray(svg_data.replace("{c}", avatar_color).encode())) + p = QPainter(pm) + renderer.render(p) + p.end() + avatar.setPixmap(pm) + avatar.setStyleSheet( + "QLabel { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.10);" + " border-radius: 15px; }" + ) + outer.addWidget(avatar, 0, Qt.AlignTop) + + right = QVBoxLayout() + right.setContentsMargins(0, 0, 0, 0) + right.setSpacing(2) + + role_lbl = QLabel("ไฝ " if is_user else "ๅŠฉๆ‰‹") + role_lbl.setStyleSheet( + "color: #d4d4d8; font-size: 12px; font-weight: 700; background: transparent;" + ) + right.addWidget(role_lbl) + + if is_user: + label = QLabel(text) + label.setWordWrap(True) + label.setTextInteractionFlags(Qt.TextSelectableByMouse) + label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + label.setStyleSheet( + "QLabel { background: transparent; color: #e4e4e7;" + " padding: 2px 0; font-size: 14px; line-height: 1.6; }" + ) + right.addWidget(label) + self._label = label + else: + browser = QTextBrowser() + browser.setReadOnly(True) + browser.setOpenExternalLinks(True) + browser.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + browser.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + browser.document().setDefaultStyleSheet(_MD_CSS) + browser.setStyleSheet( + "QTextBrowser { background: transparent; color: #e4e4e7;" + " border: none; padding: 0; font-size: 14px; }" + ) + browser.setHtml(_md_to_html(text)) + self._label = browser + right.addWidget(browser) + self._adjust_browser_height() + + self._action_row = QWidget() + self._action_row.setStyleSheet("background: transparent;") + alayout = QHBoxLayout(self._action_row) + alayout.setContentsMargins(0, 4, 0, 0) + alayout.setSpacing(4) + + icon_sz = QSize(15, 15) + + copy_btn = QPushButton() + copy_btn.setIcon(_svg_icon("copy", _SVG_COPY)) + copy_btn.setIconSize(icon_sz) + copy_btn.setFixedSize(26, 24) + copy_btn.setStyleSheet(self._ACTION_BTN) + copy_btn.setToolTip("ๅคๅˆถ") + copy_btn.setCursor(QCursor(Qt.PointingHandCursor)) + copy_btn.clicked.connect(self._copy_text) + alayout.addWidget(copy_btn) + + if on_resend: + regen_btn = QPushButton() + regen_btn.setIcon(_svg_icon("regen", _SVG_REGEN)) + regen_btn.setIconSize(icon_sz) + regen_btn.setFixedSize(26, 24) + regen_btn.setStyleSheet(self._ACTION_BTN) + regen_btn.setToolTip("้‡ๆ–ฐ็”Ÿๆˆ") + regen_btn.setCursor(QCursor(Qt.PointingHandCursor)) + regen_btn.clicked.connect(self._do_resend) + alayout.addWidget(regen_btn) + + alayout.addStretch() + self._action_row.hide() + right.addWidget(self._action_row) + + outer.addLayout(right, 1) + + def _copy_text(self): + QApplication.clipboard().setText(self._text) + + def _do_resend(self): + if self._on_resend: + self._on_resend() + + def enterEvent(self, event): + if self._action_row and self._finished: + self._action_row.show() + super().enterEvent(event) + + def leaveEvent(self, event): + if self._action_row: + self._action_row.hide() + super().leaveEvent(event) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._role != "user" and hasattr(self, '_label'): + self._adjust_browser_height() + + def set_finished(self, done: bool): + self._finished = done + if not done and self._action_row: + self._action_row.hide() + + def _adjust_browser_height(self): + doc = self._label.document() + w = self._label.width() + if w < 50: + w = 460 + doc.setTextWidth(w - 6) + self._label.setFixedHeight(int(doc.size().height() + 8)) + + def set_text(self, text: str): + self._text = text + if self._role == "user": + self._label.setText(text) + self._label.adjustSize() + else: + self._label.setHtml(_md_to_html(text)) + self._adjust_browser_height() + + +class _TabButton(QPushButton): + _STYLE = """ + QPushButton {{ + background: transparent; color: {muted}; + border: none; border-radius: 8px; + padding: 0 14px; font-size: 12px; font-weight: 700; + }} + QPushButton:hover {{ + background: rgba(63,63,70,0.6); color: {text}; + }} + QPushButton:checked {{ + background: #7c3aed; color: white; + }} + """.format(muted=C["muted"], text=C["text"]) + + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setCheckable(True) + self.setFixedHeight(30) + self.setStyleSheet(self._STYLE) + + +def _action_btn(label: str, color: str, icon: QIcon | None = None) -> QPushButton: + btn = QPushButton(label) + if icon and not icon.isNull(): + btn.setIcon(icon) + btn.setIconSize(QSize(16, 16)) + btn.setFixedHeight(36) + btn.setStyleSheet(f""" + QPushButton {{ + background: rgba(35,35,40,0.8); color: {C['text']}; + border: 1px solid {C['border'].name()}; + border-left: 3px solid {color}; + border-radius: 8px; padding: 0 14px; + font-size: 13px; font-weight: 700; text-align: left; + }} + QPushButton:hover {{ background: rgba(55,55,62,0.9); }} + QPushButton:checked {{ color: {color}; background: rgba(35,35,40,0.95); }} + """) + return btn + + +# โ”€โ”€ Main panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class ChatPanel(QWidget): + """Frameless always-on-top chat window.""" + + def __init__(self, agent): + super().__init__() + self.agent = agent + + # session state + self._messages: list[dict] = [] + self._session = {"id": _make_session_id(), "title": "ๆ–ฐๅฏน่ฏ", "messages": []} + self._history: list[dict] = _load_history() + self._pending_files: list[dict] = [] # {'name','type','raw'} + self._settings_health_checked = False + + # streaming state + self._display_queue: Optional[_queue.Queue] = None + self._streaming_row: Optional[_MsgRow] = None + self._streaming_text = "" + self._user_scrolled_up = False + self._poll_timer = QTimer(self) + self._poll_timer.timeout.connect(self._poll_queue) + + # autonomous mode + self.autonomous_enabled = False + self.last_reply_time = time.time() + + self.setWindowFlags( + Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground) + self.resize(530, 700) + + # drag state (title bar) + self._drag_pos: Optional[QPoint] = None + + self._build_ui() + + def paintEvent(self, _event): + 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) + 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)) + p.fillPath(path, grad) + p.setPen(QPen(QColor(99, 102, 241, 80), 1.0)) + p.drawPath(path) + + def resizeEvent(self, event): + path = QPainterPath() + path.addRoundedRect(0, 0, float(self.width()), float(self.height()), + 20.0, 20.0) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + super().resizeEvent(event) + + # โ”€โ”€ UI construction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + root.addWidget(self._build_titlebar()) + root.addWidget(_Separator()) + root.addWidget(self._build_tabbar()) + root.addWidget(_Separator()) + + self._stack = QStackedWidget() + self._stack.setStyleSheet("background: transparent;") + self._stack.addWidget(self._build_chat_page()) # 0 + self._stack.addWidget(self._build_history_page()) # 1 + self._stack.addWidget(self._build_sop_page()) # 2 + self._stack.addWidget(self._build_settings_page())# 3 + root.addWidget(self._stack) + + # Now that _stack exists, activate the first tab + self._switch_tab(0) + + # โ”€โ”€ title bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_titlebar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(48) + bar.setStyleSheet("background: transparent;") + bar.setCursor(QCursor(Qt.SizeAllCursor)) + + 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) + + # Title + t = QLabel("GenericAgent") + t.setStyleSheet("color: #f4f4f5; font-weight: 700; font-size: 14px;") + ly.addWidget(t) + + # Model badge + self._model_badge = _Badge(self._model_name()) + ly.addWidget(self._model_badge) + + self._streaming_badge = _StreamingBadge() + ly.addWidget(self._streaming_badge) + + ly.addStretch() + + # Close button + close = QPushButton("ร—") + close.setFixedSize(26, 26) + close.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-size: 15px; font-weight: bold; } + QPushButton:hover { background: rgba(220,38,38,0.85); color: white; } + """) + close.clicked.connect(self.hide) + ly.addWidget(close) + + # Drag + bar.mousePressEvent = self._tb_press + bar.mouseMoveEvent = self._tb_move + bar.mouseReleaseEvent = self._tb_release + return bar + + def _tb_press(self, e): + if e.button() == Qt.LeftButton: + self._drag_pos = e.globalPosition().toPoint() - self.pos() + + def _tb_move(self, e): + if e.buttons() == Qt.LeftButton and self._drag_pos is not None: + self.move(e.globalPosition().toPoint() - self._drag_pos) + + def _tb_release(self, _e): + self._drag_pos = None + + # โ”€โ”€ tab bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_tabbar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(40) + bar.setStyleSheet("background: rgba(10,10,14,0.6);") + + ly = QHBoxLayout(bar) + ly.setContentsMargins(12, 5, 12, 5) + ly.setSpacing(4) + + self._tabs: list[_TabButton] = [] + tab_defs = [ + (_SVG_CHAT, "ๅฏน่ฏ"), + (_SVG_CLOCK, "ๅކๅฒ"), + (_SVG_BOOK, "SOP"), + (_SVG_GEAR, "่ฎพ็ฝฎ"), + ] + for i, (svg, text) in enumerate(tab_defs): + btn = _TabButton(text) + btn.setIcon(_svg_icon(text, svg, "#b0b0b8")) + btn.setIconSize(QSize(14, 14)) + btn.clicked.connect(lambda _checked, idx=i: self._switch_tab(idx)) + ly.addWidget(btn) + self._tabs.append(btn) + + ly.addStretch() + + new_btn = QPushButton("ๆ–ฐๅฏน่ฏ") + new_btn.setIcon(_svg_icon("plus", _SVG_PLUS, "#a78bfa")) + new_btn.setIconSize(QSize(12, 12)) + new_btn.setFixedHeight(27) + new_btn.setStyleSheet(f""" + QPushButton {{ background: rgba(124,58,237,0.18); color: #a78bfa; + border: 1px solid rgba(124,58,237,0.3); border-radius: 7px; + padding: 0 10px; font-size: 12px; font-weight: 700; }} + QPushButton:hover {{ background: rgba(124,58,237,0.35); color: white; }} + """) + new_btn.clicked.connect(self._new_session) + ly.addWidget(new_btn) + + # NOTE: _switch_tab(0) is called in _build_ui() after _stack is created + return bar + + def _switch_tab(self, idx: int): + self._stack.setCurrentIndex(idx) + for i, btn in enumerate(self._tabs): + btn.setChecked(i == idx) + if idx == 1: + self._refresh_history() + if idx == 2: + self._refresh_sop() + if idx == 3: + self._refresh_model_rows_style() + if not self._settings_health_checked: + self._start_health_checks() + self._settings_health_checked = True + + # โ”€โ”€ chat page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_chat_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(0, 0, 0, 0) + ly.setSpacing(0) + + # โ”€โ”€ message scroll area โ”€โ”€ + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }} {SCROLLBAR_STYLE}") + + self._msg_container = QWidget() + self._msg_container.setStyleSheet("background: transparent;") + self._msg_layout = QVBoxLayout(self._msg_container) + self._msg_layout.setContentsMargins(0, 12, 0, 12) + self._msg_layout.setSpacing(4) + self._msg_layout.addStretch() + + self._scroll.setWidget(self._msg_container) + self._scroll.verticalScrollBar().valueChanged.connect(self._on_scroll) + ly.addWidget(self._scroll, 1) + + ly.addWidget(_Separator()) + + # โ”€โ”€ input area โ”€โ”€ + ly.addWidget(self._build_input_area()) + return page + + def _build_input_area(self) -> QWidget: + wrap = QWidget() + wrap.setStyleSheet("background: transparent;") + ly = QVBoxLayout(wrap) + ly.setContentsMargins(20, 6, 20, 14) + ly.setSpacing(0) + + self._chips_row = QWidget() + self._chips_row.setStyleSheet("background: transparent;") + self._chips_ly = QHBoxLayout(self._chips_row) + self._chips_ly.setContentsMargins(0, 0, 0, 6) + self._chips_ly.setSpacing(6) + self._chips_row.hide() + ly.addWidget(self._chips_row) + + card = QWidget() + card.setStyleSheet(f""" + QWidget#inputCard {{ + background: rgba(32,32,38,0.85); + border: 1px solid {C['border'].name()}; + border-radius: 16px; + }} + QWidget#inputCard:focus-within {{ + border-color: rgba(124,58,237,0.55); + }} + """) + card.setObjectName("inputCard") + card_ly = QVBoxLayout(card) + card_ly.setContentsMargins(14, 10, 10, 10) + card_ly.setSpacing(6) + + self._input = QTextEdit() + self._input.setFixedHeight(64) + self._input.setPlaceholderText("็ป™ๅŠฉๆ‰‹ๅ‘้€ๆถˆๆฏ...") + self._input.setStyleSheet(f""" + QTextEdit {{ + background: transparent; color: {C['text']}; + border: none; padding: 0; font-size: 14px; + selection-background-color: rgba(124,58,237,0.4); + }} + """) + self._input.installEventFilter(self) + self._input.textChanged.connect(self._on_text_changed) + card_ly.addWidget(self._input) + + bottom = QHBoxLayout() + bottom.setSpacing(6) + + attach = QPushButton() + attach.setIcon(_svg_icon("clip", _SVG_CLIP, "#a1a1aa")) + attach.setIconSize(QSize(17, 17)) + attach.setFixedSize(30, 30) + attach.setToolTip("ไธŠไผ ้™„ไปถ") + attach.setCursor(QCursor(Qt.PointingHandCursor)) + attach.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 15px; } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """) + attach.clicked.connect(self._attach_files) + bottom.addWidget(attach) + + self._char_lbl = QLabel("0 / 2000") + self._char_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px;") + bottom.addWidget(self._char_lbl) + + self._token_lbl = QLabel("") + self._token_lbl.setStyleSheet(f"color: {C['muted']}; font-size: 11px; margin-left: 10px;") + bottom.addWidget(self._token_lbl) + + bottom.addStretch() + + self._is_streaming = False + self._send_btn = QPushButton() + self._send_btn.setFixedSize(34, 34) + self._send_btn.setCursor(QCursor(Qt.PointingHandCursor)) + self._send_btn.clicked.connect(self._on_send_btn_click) + self._set_send_mode() + bottom.addWidget(self._send_btn) + + card_ly.addLayout(bottom) + ly.addWidget(card) + return wrap + + # โ”€โ”€ history page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_history_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(12, 12, 12, 12) + ly.setSpacing(8) + + header = QHBoxLayout() + lbl = QLabel("ๅކๅฒ่ฎฐๅฝ•") + lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;") + header.addWidget(lbl) + header.addStretch() + + restore_btn = QPushButton("ๆขๅคไผš่ฏ") + restore_btn.setStyleSheet(self._small_btn_style(C["accent"])) + restore_btn.clicked.connect(self._restore_selected) + header.addWidget(restore_btn) + + del_btn = QPushButton("ๅˆ ้™ค") + del_btn.setStyleSheet(self._small_btn_style("#dc2626")) + del_btn.clicked.connect(self._delete_selected) + header.addWidget(del_btn) + ly.addLayout(header) + + self._hist_list = QListWidget() + self._hist_list.setStyleSheet(f""" + QListWidget {{ background: transparent; border: none; outline: none; }} + QListWidget::item {{ + background: rgba(35,35,42,0.6); color: {C['text']}; + border: 1px solid {C['border'].name()}; border-radius: 8px; + padding: 8px 12px; margin: 2px 0; + }} + QListWidget::item:hover {{ background: rgba(55,55,65,0.8); + border-color: rgba(124,58,237,0.4); }} + QListWidget::item:selected {{ background: rgba(124,58,237,0.25); + border-color: rgba(124,58,237,0.6); }} + {SCROLLBAR_STYLE} + """) + self._hist_list.itemDoubleClicked.connect(self._restore_selected) + ly.addWidget(self._hist_list) + return page + + # โ”€โ”€ SOP page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_sop_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(0, 0, 0, 0) + + splitter = QSplitter(Qt.Horizontal) + + self._sop_list = QListWidget() + self._sop_list.setMaximumWidth(175) + self._sop_list.setStyleSheet(f""" + QListWidget {{ background: rgba(10,10,14,0.7); border: none; + border-right: 1px solid {C['border'].name()}; outline: none; }} + QListWidget::item {{ color: {C['muted']}; padding: 7px 10px; + border-radius: 4px; margin: 1px 4px; }} + QListWidget::item:hover {{ background: rgba(55,55,65,0.7); color: {C['text']}; }} + QListWidget::item:selected {{ background: rgba(124,58,237,0.28); color: white; }} + {SCROLLBAR_STYLE} + """) + self._sop_list.currentItemChanged.connect(self._load_sop) + splitter.addWidget(self._sop_list) + + self._sop_viewer = QTextBrowser() + self._sop_viewer.setOpenExternalLinks(True) + self._sop_viewer.document().setDefaultStyleSheet(_MD_CSS) + self._sop_viewer.setStyleSheet(f""" + QTextBrowser {{ background: transparent; color: {C['text']}; + border: none; padding: 10px 14px; + font-family: "Arial", "Microsoft YaHei", sans-serif; + font-size: 13px; }} + {SCROLLBAR_STYLE} + """) + splitter.addWidget(self._sop_viewer) + splitter.setSizes([165, 340]) + ly.addWidget(splitter) + return page + + # โ”€โ”€ settings page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _build_settings_page(self) -> QWidget: + page = QWidget() + page.setStyleSheet("background: transparent;") + ly = QVBoxLayout(page) + ly.setContentsMargins(16, 16, 16, 16) + ly.setSpacing(8) + + lbl = QLabel("ๆŽงๅˆถ้ขๆฟ") + lbl.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 14px;") + ly.addWidget(lbl) + + self._model_info = QLabel(f"ๅฝ“ๅ‰ๆจกๅž‹๏ผš{self._model_name()} (#{self.agent.llm_no})") + self._model_info.setStyleSheet(f"color: {C['muted']}; font-size: 12px;") + ly.addWidget(self._model_info) + ly.addSpacing(4) + + model_hdr = QLabel("ๆจกๅž‹ๅˆ—่กจ") + model_hdr.setStyleSheet("color: #d4d4d8; font-weight: 600; font-size: 13px;") + ly.addWidget(model_hdr) + + self._model_rows_container = QWidget() + self._model_rows_container.setStyleSheet("background: transparent;") + self._model_rows_layout = QVBoxLayout(self._model_rows_container) + self._model_rows_layout.setContentsMargins(0, 0, 0, 0) + self._model_rows_layout.setSpacing(3) + ly.addWidget(self._model_rows_container) + + self._model_row_widgets: list[dict] = [] + self._health_results: dict[int, bool | None] = {} + self._build_model_rows() + + ly.addSpacing(6) + + for (lbl_text, color, handler, svg) in [ + ("้‡็ฝฎๆ็คบ่ฏ", "#059669", self._do_reset_prompt, _SVG_RESET), + ("ไฟๅญ˜ๅฝ“ๅ‰ไผš่ฏ","#0ea5e9", self._do_save, _SVG_SAVE), + ("ๆธ…็ฉบๅฏน่ฏ", "#78716c", self._do_clear, _SVG_TRASH), + ]: + b = _action_btn(lbl_text, color, _svg_icon(lbl_text, svg)) + b.clicked.connect(handler) + ly.addWidget(b) + + ly.addSpacing(10) + sep = QLabel("่‡ชไธป่กŒๅŠจ") + sep.setStyleSheet("color: #f4f4f5; font-weight: 600; font-size: 13px;") + ly.addWidget(sep) + + self._auto_btn = _action_btn("ๅผ€ๅฏ่‡ชไธป่กŒๅŠจ (idle > 30 min ่‡ชๅŠจ่งฆๅ‘)", "#f59e0b", + _svg_icon("bolt", _SVG_BOLT)) + self._auto_btn.setCheckable(True) + self._auto_btn.clicked.connect(self._do_toggle_auto) + ly.addWidget(self._auto_btn) + + trigger_btn = _action_btn("็ซ‹ๅณ่งฆๅ‘ไธ€ๆฌก", "#f59e0b", + _svg_icon("play", _SVG_PLAY)) + trigger_btn.clicked.connect(self._do_trigger_auto) + ly.addWidget(trigger_btn) + + ly.addStretch() + return page + + # โ”€โ”€ model list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _MODEL_ROW_STYLE = ( + "QPushButton { background: rgba(39,39,42,0.7); color: #e4e4e7;" + " border: 1px solid #3f3f46; border-radius: 8px;" + " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }" + " QPushButton:hover { background: rgba(63,63,70,0.8); }" + ) + _MODEL_ROW_ACTIVE = ( + "QPushButton { background: rgba(124,58,237,0.25); color: #c4b5fd;" + " border: 1px solid rgba(124,58,237,0.5); border-radius: 8px;" + " padding: 6px 10px; font-size: 12px; font-weight: 700; text-align: left; }" + " QPushButton:hover { background: rgba(124,58,237,0.35); }" + ) + + def _build_model_rows(self): + while self._model_rows_layout.count(): + w = self._model_rows_layout.takeAt(0).widget() + if w: + w.deleteLater() + self._model_row_widgets.clear() + + for idx, tc in enumerate(self.agent.llmclients): + b = tc.backend + name = f"{type(b).__name__}/{b.default_model}" + is_current = idx == self.agent.llm_no + + row = QWidget() + row.setStyleSheet("background: transparent;") + rlay = QHBoxLayout(row) + rlay.setContentsMargins(0, 0, 0, 0) + rlay.setSpacing(6) + + dot = QLabel("โ—") + dot.setFixedWidth(14) + dot.setAlignment(Qt.AlignCenter) + dot.setStyleSheet("color: #71717a; font-size: 11px;") + rlay.addWidget(dot) + + btn = QPushButton(f" #{idx} {name}") + btn.setCursor(QCursor(Qt.PointingHandCursor)) + btn.setStyleSheet(self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE) + btn.clicked.connect(lambda checked, i=idx: self._do_switch_to(i)) + rlay.addWidget(btn, 1) + + self._model_rows_layout.addWidget(row) + self._model_row_widgets.append({"dot": dot, "btn": btn, "idx": idx}) + + def _refresh_model_rows_style(self): + for entry in self._model_row_widgets: + is_current = entry["idx"] == self.agent.llm_no + entry["btn"].setStyleSheet( + self._MODEL_ROW_ACTIVE if is_current else self._MODEL_ROW_STYLE + ) + status = self._health_results.get(entry["idx"]) + if status is True: + entry["dot"].setStyleSheet("color: #22c55e; font-size: 11px;") + elif status is False: + entry["dot"].setStyleSheet("color: #ef4444; font-size: 11px;") + else: + entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;") + + def _do_switch_to(self, idx: int): + if idx == self.agent.llm_no: + return + self.agent.next_llm(n=idx) + name = self._model_name() + self._model_badge.setText(name) + self._model_info.setText(f"ๅฝ“ๅ‰ๆจกๅž‹๏ผš{name} (#{self.agent.llm_no})") + self._add_system_notice(f"ๅทฒๅˆ‡ๆข่‡ณ {name}๏ผŒๅฏน่ฏไธŠไธ‹ๆ–‡ๅทฒไฟ็•™") + self._refresh_model_rows_style() + + def _start_health_checks(self): + self._health_results.clear() + self._health_pending = 0 + for entry in self._model_row_widgets: + entry["dot"].setStyleSheet("color: #71717a; font-size: 11px;") + entry["dot"].setText("โ—Œ") + for idx, tc in enumerate(self.agent.llmclients): + self._health_pending += 1 + t = threading.Thread(target=self._check_backend, args=(idx, tc.backend), daemon=True) + t.start() + if not hasattr(self, '_health_poll_timer'): + self._health_poll_timer = QTimer(self) + self._health_poll_timer.timeout.connect(self._poll_health_results) + self._health_poll_timer.start(500) + + def _poll_health_results(self): + self._refresh_model_rows_style() + if len(self._health_results) >= self._health_pending: + self._health_poll_timer.stop() + + def _check_backend(self, idx: int, backend): + ok = False + try: + reply = backend.ask("ไฝ ๅฅฝ", stream=False) + 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.default_model}: {'OK' if ok else 'FAIL'} -> {text[:60]}") + except Exception as e: + print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.default_model}: ERROR -> {e}") + ok = False + if hasattr(backend, 'raw_msgs') and backend.raw_msgs: + 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) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 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 not (event.modifiers() & Qt.ShiftModifier): + self._handle_send() + return True + return super().eventFilter(obj, event) + + def _on_text_changed(self): + n = len(self._input.toPlainText()) + self._char_lbl.setText(f"{n} / 2000") + + # โ”€โ”€ file attachment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _attach_files(self): + paths, _ = QFileDialog.getOpenFileNames( + self, "้€‰ๆ‹ฉ้™„ไปถ", "", + "All Files (*);;" + "Images (*.png *.jpg *.jpeg *.gif *.webp *.bmp);;" + "Text (*.txt *.md *.py *.json *.csv *.yaml *.yml *.log *.js *.ts *.sql)", + ) + for path in paths: + name = os.path.basename(path) + if any(f["name"] == name for f in self._pending_files): + continue + ext = os.path.splitext(path)[1].lower() + img_exts = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"} + mime = (f"image/{ext[1:]}" if ext in img_exts else + "text/plain" if ext in TEXT_FILE_EXTS else + "application/octet-stream") + try: + with open(path, "rb") as fh: + raw = fh.read() + self._pending_files.append({"name": name, "type": mime, "raw": raw}) + except Exception as e: + print(f"[Attach] Failed to read {path}: {e}") + self._refresh_chips() + + def _refresh_chips(self): + while self._chips_ly.count(): + item = self._chips_ly.takeAt(0) + if item.widget(): + item.widget().deleteLater() + if not self._pending_files: + self._chips_row.hide() + return + for f in self._pending_files: + chip = QLabel(f['name']) + chip.setStyleSheet(f""" + QLabel {{ background: rgba(55,55,65,0.7); color: {C['text']}; + border: 1px solid {C['border'].name()}; border-radius: 6px; + padding: 3px 8px; font-size: 11px; }} + """) + self._chips_ly.addWidget(chip) + self._chips_ly.addStretch() + self._chips_row.show() + + # โ”€โ”€ send / streaming โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _SEND_BTN_STYLE = """ + QPushButton { background: #e4e4e7; border: none; border-radius: 17px; } + QPushButton:hover { background: #f4f4f5; } + QPushButton:pressed { background: #d4d4d8; } + """ + _STOP_BTN_STYLE = """ + QPushButton { background: rgba(239,68,68,0.85); border: none; border-radius: 17px; } + QPushButton:hover { background: rgba(248,113,113,0.9); } + QPushButton:pressed { background: rgba(220,38,38,0.9); } + """ + + def _set_send_mode(self): + self._is_streaming = False + self._send_btn.setText("") + self._send_btn.setIcon(_svg_icon("send_arrow", _SVG_SEND, "#18181b")) + self._send_btn.setIconSize(QSize(18, 18)) + self._send_btn.setStyleSheet(self._SEND_BTN_STYLE) + + def _set_stop_mode(self): + self._is_streaming = True + self._send_btn.setText("") + self._send_btn.setIcon(_svg_icon("stop_circle", _SVG_STOP, "#ffffff")) + self._send_btn.setIconSize(QSize(16, 16)) + self._send_btn.setStyleSheet(self._STOP_BTN_STYLE) + + def _on_send_btn_click(self): + if self._is_streaming: + self._do_stop() + else: + self._handle_send() + + def _handle_send(self): + text = self._input.toPlainText().strip() + files = self._pending_files.copy() + if not text and not files: + return + + prompt = text or "่ฏทๅˆ†ๆžๆˆ‘ไธŠไผ ็š„้™„ไปถใ€‚" + full_prompt, display_prompt, _ = _build_prompt_with_uploads(prompt, files) + + # Clear input state + self._input.clear() + self._pending_files.clear() + self._refresh_chips() + + # Update session title + if self._session["title"] == "ๆ–ฐๅฏน่ฏ" and prompt: + self._session["title"] = prompt[:20] + ("..." if len(prompt) > 20 else "") + + self._add_msg_row("user", display_prompt) + self._messages.append({"role": "user", "content": display_prompt}) + self._update_token_usage() + + # Start streaming โ€” reset scroll lock so new output auto-scrolls + self._user_scrolled_up = False + self._streaming_text = "" + self._streaming_row = self._add_msg_row("assistant", "โ–Œ") + self._streaming_row.set_finished(False) + self._set_stop_mode() + self._streaming_badge.show() + + self._display_queue = self.agent.put_task(full_prompt, source="user") + self._poll_timer.start(40) + + def _poll_queue(self): + if not self._display_queue: + return + try: + while True: + item = self._display_queue.get_nowait() + if "next" in item: + self._streaming_text = item["next"] + if self._streaming_row: + self._streaming_row.set_text(self._streaming_text + " โ–Œ") + self._update_token_usage() + self._scroll_bottom() + if "done" in item: + final = item["done"] + if self._streaming_row: + self._streaming_row.set_text(final) + self._streaming_row.set_finished(True) + self._messages.append({"role": "assistant", "content": final}) + self._streaming_row = None + self._poll_timer.stop() + self._set_send_mode() + self._streaming_badge.hide() + self.last_reply_time = time.time() + self._update_token_usage() + self._scroll_bottom() + self._auto_save() + break + except _queue.Empty: + pass + + def _add_msg_row(self, role: str, text: str) -> _MsgRow: + row = _MsgRow(text, role, on_resend=self._regenerate_response if role != "user" else None) + self._msg_layout.insertWidget(self._msg_layout.count() - 1, row) + self._scroll_bottom() + return row + + def _regenerate_response(self): + """Resend the last user message to regenerate the assistant response.""" + if self._is_streaming: + return + for msg in reversed(self._messages): + if msg["role"] == "user": + self._input.setPlainText(msg["content"]) + self._handle_send() + break + + def _on_scroll(self, value): + sb = self._scroll.verticalScrollBar() + self._user_scrolled_up = value < sb.maximum() - 30 + + def _scroll_bottom(self): + if self._user_scrolled_up: + return + QTimer.singleShot(60, lambda: ( + self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() + ) + )) + + # โ”€โ”€ inject (autonomous mode) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def inject_message(self, text: str): + """Programmatically send a message (called by idle monitor).""" + self._input.setPlainText(text) + self._handle_send() + + # โ”€โ”€ history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _refresh_history(self): + self._history = _load_history() + self._hist_list.clear() + for s in reversed(self._history[-20:]): + n = len(s.get("messages", [])) + item = QListWidgetItem(f" {s.get('title','ๆœชๅ‘ฝๅ')} ({n} ๆก)") + item.setData(Qt.UserRole, s) + self._hist_list.addItem(item) + + def _restore_selected(self, item=None): + item = item or self._hist_list.currentItem() + if not item: + return + s = item.data(Qt.UserRole) + if s: + self._session = s.copy() + self._messages = s.get("messages", []).copy() + self._rebuild_messages() + self._switch_tab(0) + self._update_token_usage() + + def _delete_selected(self): + item = self._hist_list.currentItem() + if not item: + return + s = item.data(Qt.UserRole) + if s: + self._history = [h for h in self._history if h.get("id") != s.get("id")] + _save_history(self._history) + self._refresh_history() + + def _rebuild_messages(self): + while self._msg_layout.count() > 1: + it = self._msg_layout.takeAt(0) + if it.widget(): + it.widget().deleteLater() + for m in self._messages: + self._add_msg_row(m["role"], m["content"]) + self._update_token_usage() + + def _update_token_usage(self): + in_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "user") + out_chars = sum(len(m.get("content", "")) for m in self._messages if m.get("role") == "assistant") + if getattr(self, "_is_streaming", False) and getattr(self, "_streaming_text", ""): + out_chars += len(self._streaming_text) + + in_tokens = int(in_chars / 2.5) + out_tokens = int(out_chars / 2.5) + + if in_tokens == 0 and out_tokens == 0: + self._token_lbl.setText("") + else: + self._token_lbl.setText(f"| ไผš่ฏไธŠไธ‹ๆ–‡ๆถˆ่€—: ๅ…ฅ {in_tokens} ๅ‡บ {out_tokens} tokens") + + # โ”€โ”€ SOP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + 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")): + name = os.path.basename(path) + size = os.path.getsize(path) + it = QListWidgetItem(name) + it.setIcon(file_icon) + it.setData(Qt.UserRole, path) + it.setToolTip(f"{size:,} ๅญ—่Š‚") + self._sop_list.addItem(it) + + def _load_sop(self, item): + if not item: + return + path = item.data(Qt.UserRole) + try: + with open(path, "r", encoding="utf-8") as f: + self._sop_viewer.setHtml(_md_to_html(f.read())) + except Exception as e: + self._sop_viewer.setPlainText(f"่ฏปๅ–ๅคฑ่ดฅ: {e}") + + # โ”€โ”€ settings actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + def _model_name(self) -> str: + try: + return self.agent.get_llm_name() + except Exception: + return "ๆœช็Ÿฅ" + + def _add_system_notice(self, text: str): + """Insert a small centered notice label (not tracked as a message).""" + lbl = QLabel(text) + lbl.setWordWrap(True) + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet( + "QLabel { background: transparent; color: #71717a;" + " border: none; padding: 6px 20px; font-size: 12px; }" + ) + 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() + self._set_send_mode() + self._streaming_badge.hide() + if self._streaming_row: + self._streaming_row.set_text(self._streaming_text or "๏ผˆๅทฒๅœๆญข๏ผ‰") + self._streaming_row.set_finished(True) + self._streaming_row = None + self._update_token_usage() + + def _do_reset_prompt(self): + try: + self.agent.llmclient.last_tools = "" + except Exception: + pass + + def _auto_save(self): + if not self._messages: + return + if self._session.get("title") == "ๆ–ฐๅฏน่ฏ": + first_user = next( + (m["content"] for m in self._messages if m["role"] == "user"), "" + ) + if first_user: + self._session["title"] = first_user[:30].replace("\n", " ") + self._do_save() + + def _do_save(self): + if not self._messages: + return + self._session["messages"] = self._messages.copy() + self._session["updatedAt"] = datetime.now().isoformat() + self._history = _load_history() + for i, s in enumerate(self._history): + if s.get("id") == self._session["id"]: + self._history[i] = self._session.copy() + break + else: + self._history.append(self._session.copy()) + _save_history(self._history) + + def _do_clear(self): + self._messages.clear() + self._session = {"id": _make_session_id(), "title": "ๆ–ฐๅฏน่ฏ", "messages": []} + self._rebuild_messages() + self._switch_tab(0) + self._update_token_usage() + + def _new_session(self): + if self._messages: + self._do_save() + self._do_clear() + + def _do_toggle_auto(self): + self.autonomous_enabled = not self.autonomous_enabled + self._auto_btn.setChecked(self.autonomous_enabled) + lbl = "ๆš‚ๅœ่‡ชไธป่กŒๅŠจ" if self.autonomous_enabled else "ๅผ€ๅฏ่‡ชไธป่กŒๅŠจ (idle > 30 min ่‡ชๅŠจ่งฆๅ‘)" + self._auto_btn.setText(lbl) + + def _do_trigger_auto(self): + self.inject_message( + "[AUTO]๐Ÿค– ็”จๆˆท่งฆๅ‘ไบ†่‡ชไธป่กŒๅŠจ๏ผŒ่ฏท้˜…่ฏป่‡ชๅŠจๅŒ–sop๏ผŒ้€‰ๆ‹ฉๅนถๆ‰ง่กŒไธ€้กนๆœ‰ไปทๅ€ผ็š„ไปปๅŠกใ€‚" + ) + + # โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + @staticmethod + def _small_btn_style(color: str) -> str: + return ( + f"QPushButton {{ background: {color}; color: white; border: none;" + f" border-radius: 7px; padding: 4px 12px; font-size: 12px; font-weight: 600; }}" + f"QPushButton:hover {{ opacity: 0.85; }}" + ) + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# Entry Point +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +def main(): + # High-DPI support + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + app.setApplicationName("GenericAgent") + + # Font + font = QFont() + # Keep English glyphs in Arial; Chinese falls back to Microsoft YaHei. + try: + font.setFamilies(["Arial", "Microsoft YaHei"]) + except Exception: + font.setFamily("Microsoft YaHei") + font.setPointSize(10) + app.setFont(font) + + # โ”€โ”€ Agent initialisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + agent = GeneraticAgent() + if agent.llmclient is None: + QMessageBox.critical( + None, + "ๆœช้…็ฝฎ LLM", + "ๆœชๅœจ mykey.py ไธญๅ‘็Žฐไปปไฝ•ๅฏ็”จ็š„ LLM ๆŽฅๅฃ้…็ฝฎ๏ผŒ\n็จ‹ๅบๅฐ†ๅœจๆ—  LLM ๆจกๅผไธ‹่ฟ่กŒใ€‚", + ) + else: + threading.Thread(target=agent.run, daemon=True).start() + + # โ”€โ”€ Windows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + panel = ChatPanel(agent) + button = FloatingButton(panel) + button.show() + + # Position panel next to button and show it on first launch + button._position_panel() + panel.show() + + scr = QApplication.primaryScreen().availableGeometry() + print(f"[GenericAgent] ๅฏๅŠจๆˆๅŠŸ") + print(f" ๅฑๅน•ๅˆ†่พจ็އ: {scr.width()}x{scr.height()}") + print(f" ๆ‚ฌๆตฎๆŒ‰้’ฎ: ({button.x()}, {button.y()})") + print(f" ่Šๅคฉ้ขๆฟ: ({panel.x()}, {panel.y()})") + print(f" ๅ…ณ้—ญ้ขๆฟๅŽๅฏ็‚นๅ‡ปๅณไธ‹่ง’ๅ‘ๅ…‰ๆŒ‰้’ฎ้‡ๆ–ฐๆ‰“ๅผ€") + + # โ”€โ”€ Idle monitor (autonomous mode) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _last_trigger = [0.0] + + def idle_check(): + import time + if not panel.autonomous_enabled: + return + now = time.time() + if now - _last_trigger[0] < 120: + return + idle = now - panel.last_reply_time + if idle > 1800: + _last_trigger[0] = now + panel.inject_message( + "[AUTO]๐Ÿค– ็”จๆˆทๅทฒ็ป็ฆปๅผ€่ถ…่ฟ‡30ๅˆ†้’Ÿ๏ผŒไฝœไธบ่‡ชไธปๆ™บ่ƒฝไฝ“๏ผŒ่ฏท้˜…่ฏป่‡ชๅŠจๅŒ–sop๏ผŒๆ‰ง่กŒ่‡ชๅŠจไปปๅŠกใ€‚" + ) + + idle_timer = QTimer() + idle_timer.timeout.connect(idle_check) + idle_timer.start(5000) # check every 5 s + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/frontends/stapp2.py b/frontends/stapp2.py new file mode 100644 index 0000000..e54150d --- /dev/null +++ b/frontends/stapp2.py @@ -0,0 +1,1045 @@ +import os, sys +import html +if sys.stdout is None: sys.stdout = open(os.devnull, "w") +if sys.stderr is None: sys.stderr = open(os.devnull, "w") +try: sys.stdout.reconfigure(errors='replace') +except: pass +try: sys.stderr.reconfigure(errors='replace') +except: pass +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import streamlit as st +import streamlit.components.v1 as components +import time, json, re, threading, queue +from datetime import datetime +from agentmain import GeneraticAgent + +st.set_page_config(page_title="Cowork", layout="wide") + +# โ”€โ”€โ”€ Anthropic Light Theme CSS โ”€โ”€โ”€ +ANTHROPIC_CSS = """ + +""" + +ANTHROPIC_SELECTBOX_SCRIPT = """ +
+ +""" + +@st.cache_resource +def init(): + agent = GeneraticAgent() + if agent.llmclient is None: + st.error("โš ๏ธ ๆœช้…็ฝฎไปปไฝ•ๅฏ็”จ็š„ LLM ๆŽฅๅฃ๏ผŒ่ฏทๅœจ mykey.py ไธญๆทปๅŠ  sider_cookie ๆˆ– oai_apikey+oai_apibase ็ญ‰ไฟกๆฏๅŽ้‡ๅฏใ€‚") + st.stop() + else: + threading.Thread(target=agent.run, daemon=True).start() + return agent + + +def build_dynamic_font_css(scale_percent: float) -> str: + root_percent = max(100.0, min(200.0, float(scale_percent))) + rem_scale = root_percent / 100.0 + return f""" + +""" + + +def build_dynamic_font_update_script(scale_percent: float) -> str: + css = json.dumps(build_dynamic_font_css(scale_percent)) + return f""" + +""" + + +def build_header_agent_badge_script() -> str: + return """ + +""" + +agent = init() + +def init_session_state(): + for key, value in { + 'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None, + 'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no, + 'autonomous_enabled': False, 'messages': [], + }.items(): st.session_state.setdefault(key, value) + +init_session_state() + +# Inject Anthropic theme +st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True) +st.markdown(build_dynamic_font_css(110.0), unsafe_allow_html=True) +components.html(ANTHROPIC_SELECTBOX_SCRIPT, height=0, width=0) +components.html(build_header_agent_badge_script(), height=0, width=0) + +st.session_state.agent_name = 'Generic Agent' +with st.chat_message("assistant"): + st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) + st.write("ๆฌข่ฟŽไฝฟ็”จGenericAgent~") + + +@st.fragment +def render_sidebar(): + llm_options, current_idx = agent.list_llms(), agent.llm_no + st.session_state.selected_llm_idx = current_idx + llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + st.caption(f"ๅฝ“ๅ‰ไฝฟ็”จ็š„LLMไธบ๏ผš{current_idx}: {agent.get_llm_name()}", help="ๅฏๅœจไธ‹ๆ–น้€‰ๆ‹ฉ้“พ่ทฏ") + st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) + selected_idx = st.selectbox("้€‰ๆ‹ฉ้“พ่ทฏ๏ผš", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") + if selected_idx != current_idx: + agent.next_llm(selected_idx) + st.session_state.selected_llm_idx = selected_idx + st.toast(f"ๅทฒๅˆ‡ๆขๅˆฐๅค‡็”จ้“พ่ทฏ๏ผš{llm_labels[selected_idx]}") + st.rerun() + st.divider() + if st.button("้‡ๆ–ฐๆณจๅ…ฅSystem Prompt"): + agent.llmclient.last_tools = '' + st.toast("ไธ‹ๆฌกๅฐ†้‡ๆ–ฐๆณจๅ…ฅSystem Prompt") + +with st.sidebar: render_sidebar() + + +def start_agent_task(prompt): + st.session_state.display_queue = agent.put_task(prompt, source="user") + st.session_state.streaming, st.session_state.stopping, st.session_state.partial_response = True, False, '' + st.session_state.reply_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + st.session_state.current_prompt = prompt + + +def poll_agent_output(max_items=20): + q = st.session_state.display_queue + if q is None: + st.session_state.streaming = False + return False + done = False + for _ in range(max_items): + try: + item = q.get_nowait() + except queue.Empty: + break + if 'next' in item: st.session_state.partial_response = item['next'] + if 'done' in item: + st.session_state.partial_response = item['done'] + done = True + break + if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None + return done + + +def _get_response_segments(text): + return [p for p in re.split(r'(?=\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)', text) if p.strip()] or [text] + +def render_message(role, content, ts='', unsafe_allow_html=True): + with st.chat_message(role): + if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) + st.markdown(content, unsafe_allow_html=unsafe_allow_html) + +def finish_streaming_message(): + reply_ts = st.session_state.reply_ts + st.session_state.messages.extend({"role": "assistant", "content": seg, "time": reply_ts} for seg in _get_response_segments(st.session_state.partial_response)) + st.session_state.last_reply_time = int(time.time()) + st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = '' + +def render_streaming_area(): + if not st.session_state.streaming: return + with st.container(): + st.markdown('', unsafe_allow_html=True) + if st.button("โน๏ธ ๅœๆญข็”Ÿๆˆ", type="primary"): + agent.abort(); st.session_state.stopping = True; st.toast("ๅทฒๅ‘้€ๅœๆญขไฟกๅท"); st.rerun() + reply_ts = st.session_state.reply_ts + with st.empty().container(): + segments = _get_response_segments(st.session_state.partial_response) + for i, seg in enumerate(segments): render_message("assistant", seg + ("" if i < len(segments) - 1 else "โ–Œ"), ts=reply_ts, unsafe_allow_html=False) + if poll_agent_output(): finish_streaming_message() + else: time.sleep(0.2) + st.rerun() + +for msg in st.session_state.messages: render_message(msg["role"], msg["content"], ts=msg.get("time", ""), unsafe_allow_html=True) +if st.session_state.streaming: render_streaming_area() +if prompt := st.chat_input("่ฏท่พ“ๅ…ฅๆŒ‡ไปค", disabled=st.session_state.streaming): + st.session_state.messages.append({"role": "user", "content": prompt, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) + start_agent_task(prompt) + st.rerun() + diff --git a/hub.pyw b/hub.pyw index 97ead53..8a88c47 100644 --- a/hub.pyw +++ b/hub.pyw @@ -12,12 +12,8 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) def acquire_singleton(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: - s.bind(('127.0.0.1', LOCK_PORT)) - s.listen(1) - return s - except OSError: - return None - + s.bind(('127.0.0.1', LOCK_PORT)); s.listen(1);return s + except OSError: return None def discover_services(): services = [] @@ -32,12 +28,9 @@ def discover_services(): frontends_dir = os.path.join(BASE_DIR, 'frontends') if os.path.isdir(frontends_dir): for f in sorted(os.listdir(frontends_dir)): - if f.endswith('app.py') and f != 'chatapp_common.py': - if f == 'stapp.py': - cmd = [sys.executable, '-m', 'streamlit', 'run', - 'frontends/' + f, '--server.headless=true'] - else: - cmd = [sys.executable, 'frontends/' + f] + if 'app' in f and f.endswith('.py') and f != 'chatapp_common.py': + if 'stapp' in f: cmd = [sys.executable, '-m', 'streamlit', 'run', 'frontends/' + f, '--server.headless=true'] + else: cmd = [sys.executable, 'frontends/' + f] services.append({'name': 'frontends/' + f, 'cmd': cmd}) return services @@ -213,11 +206,12 @@ class LauncherApp: if not self.selected: return lines = self.mgr.get_output(self.selected) + at_bottom = self.output_text.yview()[1] >= 0.99 self.output_text.configure(state='normal') self.output_text.delete('1.0', 'end') self.output_text.insert('end', ''.join(lines[-200:])) self.output_text.configure(state='disabled') - self.output_text.see('end') + if at_bottom: self.output_text.see('end') def _poll(self): for svc in self.services: