diff --git a/feedback_ui.py b/feedback_ui.py
index c71e951..d0d4ac3 100644
--- a/feedback_ui.py
+++ b/feedback_ui.py
@@ -1,581 +1,938 @@
-# Interactive Feedback MCP UI
-# Developed by Fábio Ferreira (https://x.com/fabiomlferreira)
-# Inspired by/related to dotcursorrules.com (https://dotcursorrules.com/)
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+互動式回饋收集 GUI 介面
+=======================
+
+基於 PySide6 的圖形用戶介面,提供直觀的回饋收集功能。
+支援文字輸入、圖片上傳、命令執行等功能。
+
+作者: Fábio Ferreira
+靈感來源: dotcursorrules.com
+增強功能: 圖片支援和現代化界面設計
+"""
+
import os
import sys
-import json
-import psutil
-import argparse
import subprocess
-import threading
-import hashlib
-from typing import Optional, TypedDict
+import base64
+import uuid
+import time
+from typing import Optional, TypedDict, List, Dict
+from pathlib import Path
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit, QGroupBox
+ QLabel, QLineEdit, QPushButton, QTextEdit, QGroupBox,
+ QScrollArea, QFrame, QGridLayout, QFileDialog, QMessageBox,
+ QTabWidget, QSizePolicy
)
-from PySide6.QtCore import Qt, Signal, QObject, QTimer, QSettings
-from PySide6.QtGui import QTextCursor, QIcon, QKeyEvent, QFont, QFontDatabase, QPalette, QColor
+from PySide6.QtCore import Qt, Signal, QTimer
+from PySide6.QtGui import QFont, QPixmap, QDragEnterEvent, QDropEvent, QKeySequence, QShortcut
+# ===== 型別定義 =====
class FeedbackResult(TypedDict):
+ """回饋結果的型別定義"""
command_logs: str
interactive_feedback: str
+ images: List[dict]
-class FeedbackConfig(TypedDict):
- run_command: str
- execute_automatically: bool
-def set_dark_title_bar(widget: QWidget, dark_title_bar: bool) -> None:
- # Ensure we're on Windows
- if sys.platform != "win32":
- return
-
- from ctypes import windll, c_uint32, byref
-
- # Get Windows build number
- build_number = sys.getwindowsversion().build
- if build_number < 17763: # Windows 10 1809 minimum
- return
-
- # Check if the widget's property already matches the setting
- dark_prop = widget.property("DarkTitleBar")
- if dark_prop is not None and dark_prop == dark_title_bar:
- return
-
- # Set the property (True if dark_title_bar != 0, False otherwise)
- widget.setProperty("DarkTitleBar", dark_title_bar)
-
- # Load dwmapi.dll and call DwmSetWindowAttribute
- dwmapi = windll.dwmapi
- hwnd = widget.winId() # Get the window handle
- attribute = 20 if build_number >= 18985 else 19 # Use newer attribute for newer builds
- c_dark_title_bar = c_uint32(dark_title_bar) # Convert to C-compatible uint32
- dwmapi.DwmSetWindowAttribute(hwnd, attribute, byref(c_dark_title_bar), 4)
-
- # HACK: Create a 1x1 pixel frameless window to force redraw
- temp_widget = QWidget(None, Qt.FramelessWindowHint)
- temp_widget.resize(1, 1)
- temp_widget.move(widget.pos())
- temp_widget.show()
- temp_widget.deleteLater() # Safe deletion in Qt event loop
-
-def get_dark_mode_palette(app: QApplication):
- darkPalette = app.palette()
- darkPalette.setColor(QPalette.Window, QColor(53, 53, 53))
- darkPalette.setColor(QPalette.WindowText, Qt.white)
- darkPalette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(127, 127, 127))
- darkPalette.setColor(QPalette.Base, QColor(42, 42, 42))
- darkPalette.setColor(QPalette.AlternateBase, QColor(66, 66, 66))
- darkPalette.setColor(QPalette.ToolTipBase, QColor(53, 53, 53))
- darkPalette.setColor(QPalette.ToolTipText, Qt.white)
- darkPalette.setColor(QPalette.Text, Qt.white)
- darkPalette.setColor(QPalette.Disabled, QPalette.Text, QColor(127, 127, 127))
- darkPalette.setColor(QPalette.Dark, QColor(35, 35, 35))
- darkPalette.setColor(QPalette.Shadow, QColor(20, 20, 20))
- darkPalette.setColor(QPalette.Button, QColor(53, 53, 53))
- darkPalette.setColor(QPalette.ButtonText, Qt.white)
- darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(127, 127, 127))
- darkPalette.setColor(QPalette.BrightText, Qt.red)
- darkPalette.setColor(QPalette.Link, QColor(42, 130, 218))
- darkPalette.setColor(QPalette.Highlight, QColor(42, 130, 218))
- darkPalette.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80))
- darkPalette.setColor(QPalette.HighlightedText, Qt.white)
- darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, QColor(127, 127, 127))
- darkPalette.setColor(QPalette.PlaceholderText, QColor(127, 127, 127))
- return darkPalette
-
-def kill_tree(process: subprocess.Popen):
- killed: list[psutil.Process] = []
- parent = psutil.Process(process.pid)
- for proc in parent.children(recursive=True):
+# ===== 圖片預覽元件 =====
+class ImagePreviewWidget(QLabel):
+ """圖片預覽元件"""
+ remove_clicked = Signal(str)
+
+ def __init__(self, image_path: str, image_id: str, parent=None):
+ super().__init__(parent)
+ self.image_path = image_path
+ self.image_id = image_id
+ self._setup_widget()
+ self._load_image()
+ self._create_delete_button()
+
+ def _setup_widget(self) -> None:
+ """設置元件基本屬性"""
+ self.setFixedSize(100, 100)
+ self.setFrameStyle(QFrame.Box)
+ self.setStyleSheet("""
+ QLabel {
+ border: 2px solid #464647;
+ border-radius: 8px;
+ background-color: #2d2d30;
+ padding: 2px;
+ }
+ QLabel:hover {
+ border-color: #007acc;
+ background-color: #383838;
+ }
+ """)
+ self.setToolTip(f"圖片: {os.path.basename(self.image_path)}")
+
+ def _load_image(self) -> None:
+ """載入並顯示圖片"""
try:
- proc.kill()
- killed.append(proc)
- except psutil.Error:
- pass
- try:
- parent.kill()
- except psutil.Error:
- pass
- killed.append(parent)
+ pixmap = QPixmap(self.image_path)
+ if not pixmap.isNull():
+ scaled_pixmap = pixmap.scaled(96, 96, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+ self.setPixmap(scaled_pixmap)
+ self.setAlignment(Qt.AlignCenter)
+ else:
+ self.setText("無法載入圖片")
+ self.setAlignment(Qt.AlignCenter)
+ except Exception:
+ self.setText("載入錯誤")
+ self.setAlignment(Qt.AlignCenter)
+
+ def _create_delete_button(self) -> None:
+ """創建刪除按鈕"""
+ self.delete_button = QPushButton("×", self)
+ self.delete_button.setFixedSize(20, 20)
+ self.delete_button.move(78, 2)
+ self.delete_button.setStyleSheet("""
+ QPushButton {
+ background-color: #f44336;
+ color: white;
+ border: none;
+ border-radius: 10px;
+ font-weight: bold;
+ font-size: 12px;
+ }
+ QPushButton:hover { background-color: #d32f2f; }
+ """)
+ self.delete_button.clicked.connect(self._on_delete_clicked)
+ self.delete_button.setToolTip("刪除圖片")
+
+ def _on_delete_clicked(self) -> None:
+ """處理刪除按鈕點擊事件"""
+ reply = QMessageBox.question(
+ self, '確認刪除',
+ f'確定要移除圖片 "{os.path.basename(self.image_path)}" 嗎?',
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply == QMessageBox.Yes:
+ self.remove_clicked.emit(self.image_id)
+
- # Terminate any remaining processes
- for proc in killed:
- try:
- if proc.is_running():
- proc.terminate()
- except psutil.Error:
- pass
-
-def get_user_environment() -> dict[str, str]:
- if sys.platform != "win32":
- return os.environ.copy()
-
- import ctypes
- from ctypes import wintypes
-
- # Load required DLLs
- advapi32 = ctypes.WinDLL("advapi32")
- userenv = ctypes.WinDLL("userenv")
- kernel32 = ctypes.WinDLL("kernel32")
-
- # Constants
- TOKEN_QUERY = 0x0008
-
- # Function prototypes
- OpenProcessToken = advapi32.OpenProcessToken
- OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
- OpenProcessToken.restype = wintypes.BOOL
-
- CreateEnvironmentBlock = userenv.CreateEnvironmentBlock
- CreateEnvironmentBlock.argtypes = [ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.BOOL]
- CreateEnvironmentBlock.restype = wintypes.BOOL
-
- DestroyEnvironmentBlock = userenv.DestroyEnvironmentBlock
- DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID]
- DestroyEnvironmentBlock.restype = wintypes.BOOL
-
- GetCurrentProcess = kernel32.GetCurrentProcess
- GetCurrentProcess.argtypes = []
- GetCurrentProcess.restype = wintypes.HANDLE
-
- CloseHandle = kernel32.CloseHandle
- CloseHandle.argtypes = [wintypes.HANDLE]
- CloseHandle.restype = wintypes.BOOL
-
- # Get process token
- token = wintypes.HANDLE()
- if not OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)):
- raise RuntimeError("Failed to open process token")
-
- try:
- # Create environment block
- environment = ctypes.c_void_p()
- if not CreateEnvironmentBlock(ctypes.byref(environment), token, False):
- raise RuntimeError("Failed to create environment block")
-
- try:
- # Convert environment block to list of strings
- result = {}
- env_ptr = ctypes.cast(environment, ctypes.POINTER(ctypes.c_wchar))
- offset = 0
-
- while True:
- # Get string at current offset
- current_string = ""
- while env_ptr[offset] != "\0":
- current_string += env_ptr[offset]
- offset += 1
-
- # Skip null terminator
- offset += 1
-
- # Break if we hit double null terminator
- if not current_string:
- break
-
- equal_index = current_string.index("=")
- if equal_index == -1:
- continue
-
- key = current_string[:equal_index]
- value = current_string[equal_index + 1:]
- result[key] = value
-
- return result
-
- finally:
- DestroyEnvironmentBlock(environment)
-
- finally:
- CloseHandle(token)
-
-class FeedbackTextEdit(QTextEdit):
+# ===== 圖片上傳元件 =====
+class ImageUploadWidget(QWidget):
+ """圖片上傳元件"""
+ images_changed = Signal()
+
def __init__(self, parent=None):
super().__init__(parent)
-
- def keyPressEvent(self, event: QKeyEvent):
- if event.key() == Qt.Key_Return and event.modifiers() == Qt.ControlModifier:
- # Find the parent FeedbackUI instance and call submit
- parent = self.parent()
- while parent and not isinstance(parent, FeedbackUI):
- parent = parent.parent()
- if parent:
- parent._submit_feedback()
+ self.images: Dict[str, Dict[str, str]] = {}
+ self._setup_ui()
+ self.setAcceptDrops(True)
+ # 啟動時清理舊的臨時文件
+ self._cleanup_old_temp_files()
+
+ def _setup_ui(self) -> None:
+ """設置用戶介面"""
+ layout = QVBoxLayout(self)
+ layout.setSpacing(6)
+ layout.setContentsMargins(12, 8, 12, 8)
+
+ # 標題
+ title = QLabel("🖼️ 圖片附件(可選)")
+ title.setFont(QFont("", 10, QFont.Bold))
+ title.setStyleSheet("color: #007acc; margin: 1px 0;")
+ layout.addWidget(title)
+
+ # 操作按鈕
+ self._create_buttons(layout)
+
+ # 拖拽區域
+ self._create_drop_zone(layout)
+
+ # 圖片預覽區域
+ self._create_preview_area(layout)
+
+ # 狀態標籤
+ self.status_label = QLabel("已選擇 0 張圖片")
+ self.status_label.setStyleSheet("color: #9e9e9e; font-size: 10px;")
+ layout.addWidget(self.status_label)
+
+ def _create_buttons(self, layout: QVBoxLayout) -> None:
+ """創建操作按鈕"""
+ button_layout = QHBoxLayout()
+
+ # 選擇文件按鈕
+ self.file_button = QPushButton("📁 選擇文件")
+ self.file_button.clicked.connect(self.select_files)
+
+ # 剪貼板按鈕
+ self.paste_button = QPushButton("📋 剪貼板")
+ self.paste_button.clicked.connect(self.paste_from_clipboard)
+
+ # 清除按鈕
+ self.clear_button = QPushButton("❌ 清除")
+ self.clear_button.clicked.connect(self.clear_all_images)
+
+ # 設置按鈕樣式
+ button_style = """
+ QPushButton {
+ color: white;
+ border: none;
+ padding: 5px 10px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 11px;
+ }
+ """
+
+ self.file_button.setStyleSheet(button_style + "QPushButton { background-color: #0e639c; }")
+ self.paste_button.setStyleSheet(button_style + "QPushButton { background-color: #4caf50; }")
+ self.clear_button.setStyleSheet(button_style + "QPushButton { background-color: #f44336; }")
+
+ button_layout.addWidget(self.file_button)
+ button_layout.addWidget(self.paste_button)
+ button_layout.addWidget(self.clear_button)
+ button_layout.addStretch()
+
+ layout.addLayout(button_layout)
+
+ def _create_drop_zone(self, layout: QVBoxLayout) -> None:
+ """創建拖拽區域"""
+ self.drop_zone = QLabel("🎯 拖拽圖片到這裡 (PNG、JPG、JPEG、GIF、BMP、WebP)")
+ self.drop_zone.setFixedHeight(50)
+ self.drop_zone.setAlignment(Qt.AlignCenter)
+ self.drop_zone.setStyleSheet("""
+ QLabel {
+ border: 2px dashed #464647;
+ border-radius: 6px;
+ background-color: #2d2d30;
+ color: #9e9e9e;
+ font-size: 11px;
+ }
+ """)
+ layout.addWidget(self.drop_zone)
+
+ def _create_preview_area(self, layout: QVBoxLayout) -> None:
+ """創建圖片預覽區域"""
+ self.preview_scroll = QScrollArea()
+ self.preview_widget = QWidget()
+ self.preview_layout = QGridLayout(self.preview_widget)
+ self.preview_layout.setSpacing(4)
+ self.preview_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
+
+ self.preview_scroll.setWidget(self.preview_widget)
+ self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.preview_scroll.setMinimumHeight(70)
+ self.preview_scroll.setMaximumHeight(180)
+ self.preview_scroll.setWidgetResizable(True)
+ self.preview_scroll.setStyleSheet("""
+ QScrollArea {
+ border: 1px solid #464647;
+ border-radius: 4px;
+ background-color: #1e1e1e;
+ }
+ """)
+
+ layout.addWidget(self.preview_scroll)
+
+ def select_files(self) -> None:
+ """選擇文件對話框"""
+ files, _ = QFileDialog.getOpenFileNames(
+ self,
+ "選擇圖片文件",
+ "",
+ "圖片文件 (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;所有文件 (*)"
+ )
+ if files:
+ self._add_images(files)
+
+ def paste_from_clipboard(self) -> None:
+ """從剪貼板粘貼圖片"""
+ clipboard = QApplication.clipboard()
+
+ if clipboard.mimeData().hasImage():
+ image = clipboard.image()
+ if not image.isNull():
+ # 保存臨時文件
+ temp_dir = Path.home() / ".cache" / "interactive-feedback-mcp"
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ temp_file = temp_dir / f"clipboard_{uuid.uuid4().hex}.png"
+
+ # 檢查圖片尺寸,如果太大則壓縮
+ max_dimension = 1024 # 最大尺寸
+ if image.width() > max_dimension or image.height() > max_dimension:
+ # 計算縮放比例
+ scale = min(max_dimension / image.width(), max_dimension / image.height())
+ new_width = int(image.width() * scale)
+ new_height = int(image.height() * scale)
+
+ # 縮放圖片
+ from PySide6.QtCore import Qt
+ image = image.scaled(new_width, new_height, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+ print(f"[DEBUG] 圖片已縮放至: {new_width}x{new_height}")
+
+ # 使用較低的質量保存以減小文件大小
+ quality = 70 # 降低質量以減小文件大小
+ if image.save(str(temp_file), "PNG", quality):
+ # 檢查保存後的文件大小
+ if temp_file.exists():
+ file_size = temp_file.stat().st_size
+ print(f"[DEBUG] 剪貼板圖片保存成功: {temp_file}, 大小: {file_size} bytes")
+
+ # 檢查文件大小是否超過限制
+ if file_size > 1 * 1024 * 1024: # 1MB 限制
+ temp_file.unlink() # 刪除過大的文件
+ QMessageBox.warning(
+ self, "圖片過大",
+ f"剪貼板圖片壓縮後仍然超過 1MB 限制 ({file_size/1024/1024:.1f}MB)!\n"
+ f"請使用圖片編輯軟體進一步壓縮。"
+ )
+ return
+
+ if file_size > 0:
+ self._add_images([str(temp_file)])
+ else:
+ QMessageBox.warning(self, "錯誤", f"保存的圖片文件為空!位置: {temp_file}")
+ else:
+ QMessageBox.warning(self, "錯誤", "圖片保存失敗!")
+ else:
+ QMessageBox.warning(self, "錯誤", "無法保存剪貼板圖片!")
+ else:
+ QMessageBox.information(self, "提示", "剪貼板中沒有有效的圖片!")
else:
- super().keyPressEvent(event)
+ QMessageBox.information(self, "提示", "剪貼板中沒有圖片內容!")
+
+ def clear_all_images(self) -> None:
+ """清除所有圖片"""
+ if self.images:
+ reply = QMessageBox.question(
+ self, '確認清除',
+ f'確定要清除所有 {len(self.images)} 張圖片嗎?',
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply == QMessageBox.Yes:
+ # 清理臨時文件
+ temp_files_cleaned = 0
+ for image_info in self.images.values():
+ file_path = image_info["path"]
+ if "clipboard_" in os.path.basename(file_path) and ".cache" in file_path:
+ try:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ temp_files_cleaned += 1
+ print(f"[DEBUG] 已刪除臨時文件: {file_path}")
+ except Exception as e:
+ print(f"[DEBUG] 刪除臨時文件失敗: {e}")
+
+ # 清除內存中的圖片數據
+ self.images.clear()
+ self._refresh_preview()
+ self._update_status()
+ self.images_changed.emit()
+ print(f"[DEBUG] 已清除所有圖片,包括 {temp_files_cleaned} 個臨時文件")
+
+ def _add_images(self, file_paths: List[str]) -> None:
+ """添加圖片"""
+ added_count = 0
+ for file_path in file_paths:
+ try:
+ print(f"[DEBUG] 嘗試添加圖片: {file_path}")
+
+ if not os.path.exists(file_path):
+ print(f"[DEBUG] 文件不存在: {file_path}")
+ continue
+
+ if not self._is_image_file(file_path):
+ print(f"[DEBUG] 不是圖片文件: {file_path}")
+ continue
+
+ file_size = os.path.getsize(file_path)
+ print(f"[DEBUG] 文件大小: {file_size} bytes")
+
+ # 更嚴格的大小限制(1MB)
+ if file_size > 1 * 1024 * 1024:
+ QMessageBox.warning(
+ self, "文件過大",
+ f"圖片 {os.path.basename(file_path)} 大小為 {file_size/1024/1024:.1f}MB,"
+ f"超過 1MB 限制!\n建議使用圖片編輯軟體壓縮後再上傳。"
+ )
+ continue
+
+ if file_size == 0:
+ QMessageBox.warning(self, "文件為空", f"圖片 {os.path.basename(file_path)} 是空文件!")
+ continue
+
+ # 讀取圖片原始二進制數據
+ with open(file_path, 'rb') as f:
+ raw_data = f.read()
+ print(f"[DEBUG] 讀取原始數據大小: {len(raw_data)} bytes")
+
+ if len(raw_data) == 0:
+ print(f"[DEBUG] 讀取的數據為空!")
+ continue
+
+ # 再次檢查內存中的數據大小
+ if len(raw_data) > 1 * 1024 * 1024:
+ QMessageBox.warning(
+ self, "數據過大",
+ f"圖片 {os.path.basename(file_path)} 數據大小超過 1MB 限制!"
+ )
+ continue
+
+ image_id = str(uuid.uuid4())
+ self.images[image_id] = {
+ "path": file_path,
+ "data": raw_data, # 直接保存原始二進制數據
+ "name": os.path.basename(file_path),
+ "size": file_size
+ }
+ added_count += 1
+ print(f"[DEBUG] 圖片添加成功: {os.path.basename(file_path)}, 數據大小: {len(raw_data)} bytes")
+
+ except Exception as e:
+ print(f"[DEBUG] 添加圖片失敗: {e}")
+ QMessageBox.warning(self, "錯誤", f"無法載入圖片 {os.path.basename(file_path)}:\n{str(e)}")
+
+ print(f"[DEBUG] 共添加 {added_count} 張圖片")
+ print(f"[DEBUG] 當前總共有 {len(self.images)} 張圖片")
+ if added_count > 0:
+ self._refresh_preview()
+ self._update_status()
+ self.images_changed.emit()
+
+ def _is_image_file(self, file_path: str) -> bool:
+ """檢查是否為支援的圖片格式"""
+ extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
+ return Path(file_path).suffix.lower() in extensions
+
+ def _refresh_preview(self) -> None:
+ """刷新預覽布局"""
+ # 清除現有預覽
+ while self.preview_layout.count():
+ child = self.preview_layout.takeAt(0)
+ if child.widget():
+ child.widget().deleteLater()
+
+ # 重新添加圖片預覽
+ for i, (image_id, image_info) in enumerate(self.images.items()):
+ preview = ImagePreviewWidget(image_info["path"], image_id, self)
+ preview.remove_clicked.connect(self._remove_image)
+
+ row = i // 5
+ col = i % 5
+ self.preview_layout.addWidget(preview, row, col)
+
+ def _remove_image(self, image_id: str) -> None:
+ """移除圖片"""
+ if image_id in self.images:
+ image_info = self.images[image_id]
+
+ # 如果是臨時文件(剪貼板圖片),則物理刪除文件
+ file_path = image_info["path"]
+ if "clipboard_" in os.path.basename(file_path) and ".cache" in file_path:
+ try:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ print(f"[DEBUG] 已刪除臨時文件: {file_path}")
+ except Exception as e:
+ print(f"[DEBUG] 刪除臨時文件失敗: {e}")
+
+ # 從內存中移除圖片數據
+ del self.images[image_id]
+ self._refresh_preview()
+ self._update_status()
+ self.images_changed.emit()
+ print(f"[DEBUG] 已移除圖片: {image_info['name']}")
+
+ def _update_status(self) -> None:
+ """更新狀態標籤"""
+ count = len(self.images)
+ if count == 0:
+ self.status_label.setText("已選擇 0 張圖片")
+ else:
+ total_size = sum(img["size"] for img in self.images.values())
+
+ # 智能單位顯示
+ if total_size < 1024:
+ size_str = f"{total_size} B"
+ elif total_size < 1024 * 1024:
+ size_kb = total_size / 1024
+ size_str = f"{size_kb:.1f} KB"
+ else:
+ size_mb = total_size / (1024 * 1024)
+ size_str = f"{size_mb:.1f} MB"
+
+ self.status_label.setText(f"已選擇 {count} 張圖片 (總計 {size_str})")
+
+ # 詳細調試信息
+ print(f"[DEBUG] === 圖片狀態更新 ===")
+ print(f"[DEBUG] 圖片數量: {count}")
+ print(f"[DEBUG] 總大小: {total_size} bytes ({size_str})")
+ for i, (image_id, img) in enumerate(self.images.items(), 1):
+ data_size = len(img["data"]) if isinstance(img["data"], bytes) else 0
+ # 智能顯示每張圖片的大小
+ if data_size < 1024:
+ data_str = f"{data_size} B"
+ elif data_size < 1024 * 1024:
+ data_str = f"{data_size/1024:.1f} KB"
+ else:
+ data_str = f"{data_size/(1024*1024):.1f} MB"
+ print(f"[DEBUG] 圖片 {i}: {img['name']} - 數據大小: {data_str}")
+ print(f"[DEBUG] ==================")
+
+ def get_images_data(self) -> List[dict]:
+ """獲取圖片數據"""
+ return [
+ {
+ "name": img["name"],
+ "data": img["data"], # 原始二進制數據
+ "size": len(img["data"]) if isinstance(img["data"], bytes) else img["size"] # 使用實際數據大小
+ }
+ for img in self.images.values()
+ ]
-class LogSignals(QObject):
- append_log = Signal(str)
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
+ """拖拽進入事件"""
+ if event.mimeData().hasUrls():
+ for url in event.mimeData().urls():
+ if url.isLocalFile() and self._is_image_file(url.toLocalFile()):
+ event.acceptProposedAction()
+ self.drop_zone.setStyleSheet("""
+ QLabel {
+ border: 2px dashed #007acc;
+ border-radius: 6px;
+ background-color: #383838;
+ color: #007acc;
+ font-size: 11px;
+ }
+ """)
+ return
+ event.ignore()
+
+ def dragLeaveEvent(self, event) -> None:
+ """拖拽離開事件"""
+ self.drop_zone.setStyleSheet("""
+ QLabel {
+ border: 2px dashed #464647;
+ border-radius: 6px;
+ background-color: #2d2d30;
+ color: #9e9e9e;
+ font-size: 11px;
+ }
+ """)
+
+ def dropEvent(self, event: QDropEvent) -> None:
+ """拖拽放下事件"""
+ self.dragLeaveEvent(event)
+
+ files = []
+ for url in event.mimeData().urls():
+ if url.isLocalFile():
+ file_path = url.toLocalFile()
+ if self._is_image_file(file_path):
+ files.append(file_path)
+
+ if files:
+ self._add_images(files)
+ event.acceptProposedAction()
+ else:
+ QMessageBox.warning(self, "格式錯誤", "請拖拽有效的圖片文件!")
+
+ def _cleanup_old_temp_files(self) -> None:
+ """清理舊的臨時文件"""
+ try:
+ temp_dir = Path.home() / ".cache" / "interactive-feedback-mcp"
+ if temp_dir.exists():
+ cleaned_count = 0
+ for temp_file in temp_dir.glob("clipboard_*.png"):
+ try:
+ # 清理超過1小時的臨時文件
+ if temp_file.exists():
+ file_age = time.time() - temp_file.stat().st_mtime
+ if file_age > 3600: # 1小時 = 3600秒
+ temp_file.unlink()
+ cleaned_count += 1
+ except Exception as e:
+ print(f"[DEBUG] 清理舊臨時文件失敗: {e}")
+ if cleaned_count > 0:
+ print(f"[DEBUG] 清理了 {cleaned_count} 個舊的臨時文件")
+ except Exception as e:
+ print(f"[DEBUG] 臨時文件清理過程出錯: {e}")
-class FeedbackUI(QMainWindow):
- def __init__(self, project_directory: str, prompt: str):
+
+# ===== 主要回饋介面 =====
+class FeedbackWindow(QMainWindow):
+ """主要的回饋收集視窗"""
+
+ def __init__(self, project_dir: str, summary: str):
super().__init__()
- self.project_directory = project_directory
- self.prompt = prompt
-
+ self.project_dir = project_dir
+ self.summary = summary
+ self.result: Optional[FeedbackResult] = None
self.process: Optional[subprocess.Popen] = None
- self.log_buffer = []
- self.feedback_result = None
- self.log_signals = LogSignals()
- self.log_signals.append_log.connect(self._append_log)
-
- self.setWindowTitle("Interactive Feedback MCP")
- script_dir = os.path.dirname(os.path.abspath(__file__))
- icon_path = os.path.join(script_dir, "images", "feedback.png")
- self.setWindowIcon(QIcon(icon_path))
- self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint)
+ self.accepted = False
- self.settings = QSettings("InteractiveFeedbackMCP", "InteractiveFeedbackMCP")
+ self._setup_ui()
+ self._apply_dark_style()
+
+ def _setup_ui(self) -> None:
+ """設置用戶介面"""
+ self.setWindowTitle("互動式回饋收集")
+ self.setMinimumSize(800, 600)
- # Load general UI settings for the main window (geometry, state)
- self.settings.beginGroup("MainWindow_General")
- geometry = self.settings.value("geometry")
- if geometry:
- self.restoreGeometry(geometry)
- else:
- self.resize(800, 600)
- screen = QApplication.primaryScreen().geometry()
- x = (screen.width() - 800) // 2
- y = (screen.height() - 600) // 2
- self.move(x, y)
- state = self.settings.value("windowState")
- if state:
- self.restoreState(state)
- self.settings.endGroup() # End "MainWindow_General" group
-
- # Load project-specific settings (command, auto-execute, command section visibility)
- self.project_group_name = get_project_settings_group(self.project_directory)
- self.settings.beginGroup(self.project_group_name)
- loaded_run_command = self.settings.value("run_command", "", type=str)
- loaded_execute_auto = self.settings.value("execute_automatically", False, type=bool)
- command_section_visible = self.settings.value("commandSectionVisible", False, type=bool)
- self.settings.endGroup() # End project-specific group
-
- self.config: FeedbackConfig = {
- "run_command": loaded_run_command,
- "execute_automatically": loaded_execute_auto
- }
-
- self._create_ui() # self.config is used here to set initial values
-
- # Set command section visibility AFTER _create_ui has created relevant widgets
- self.command_group.setVisible(command_section_visible)
- if command_section_visible:
- self.toggle_command_button.setText("Hide Command Section")
- else:
- self.toggle_command_button.setText("Show Command Section")
-
- set_dark_title_bar(self, True)
-
- if self.config.get("execute_automatically", False):
- self._run_command()
-
- def _format_windows_path(self, path: str) -> str:
- if sys.platform == "win32":
- # Convert forward slashes to backslashes
- path = path.replace("/", "\\")
- # Capitalize drive letter if path starts with x:\
- if len(path) >= 2 and path[1] == ":" and path[0].isalpha():
- path = path[0].upper() + path[1:]
- return path
-
- def _create_ui(self):
+ # 主要元件
central_widget = QWidget()
self.setCentralWidget(central_widget)
- layout = QVBoxLayout(central_widget)
-
- # Toggle Command Section Button
- self.toggle_command_button = QPushButton("Show Command Section")
- self.toggle_command_button.clicked.connect(self._toggle_command_section)
- layout.addWidget(self.toggle_command_button)
-
- # Command section
- self.command_group = QGroupBox("Command")
- command_layout = QVBoxLayout(self.command_group)
-
- # Working directory label
- formatted_path = self._format_windows_path(self.project_directory)
- working_dir_label = QLabel(f"Working directory: {formatted_path}")
- command_layout.addWidget(working_dir_label)
-
- # Command input row
- command_input_layout = QHBoxLayout()
- self.command_entry = QLineEdit()
- self.command_entry.setText(self.config["run_command"])
- self.command_entry.returnPressed.connect(self._run_command)
- self.command_entry.textChanged.connect(self._update_config)
- self.run_button = QPushButton("&Run")
+
+ # 主要佈局
+ main_layout = QVBoxLayout(central_widget)
+ main_layout.setSpacing(10)
+ main_layout.setContentsMargins(10, 10, 10, 10)
+
+ # AI 工作摘要(適度參與拉伸)
+ self._create_summary_section(main_layout)
+
+ # 分頁標籤(主要工作區域)
+ self._create_tabs(main_layout)
+
+ # 操作按鈕(固定大小)
+ self._create_action_buttons(main_layout)
+
+ # 設置比例拉伸:摘要區域佔1份,分頁區域佔3份,按鈕不拉伸
+ summary_widget = main_layout.itemAt(0).widget() # 摘要區域
+ main_layout.setStretchFactor(summary_widget, 1) # 適度拉伸
+ main_layout.setStretchFactor(self.tabs, 3) # 主要拉伸區域
+
+ def _create_summary_section(self, layout: QVBoxLayout) -> None:
+ """創建 AI 工作摘要區域"""
+ summary_group = QGroupBox("📋 AI 工作摘要")
+ summary_layout = QVBoxLayout(summary_group)
+
+ self.summary_text = QTextEdit()
+ self.summary_text.setPlainText(self.summary)
+ # 設置合理的高度範圍,允許適度拉伸
+ self.summary_text.setMinimumHeight(80)
+ self.summary_text.setMaximumHeight(250) # 增加最大高度,允許更多拉伸
+ self.summary_text.setReadOnly(True)
+ self.summary_text.setStyleSheet("background-color: #2d2d30; border: 1px solid #464647;")
+
+ # 設置大小策略:允許適度垂直擴展
+ self.summary_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+ # 設置群組框的大小策略:允許適度擴展
+ summary_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+ summary_layout.addWidget(self.summary_text)
+ layout.addWidget(summary_group)
+
+ def _create_tabs(self, layout: QVBoxLayout) -> None:
+ """創建分頁標籤"""
+ self.tabs = QTabWidget()
+
+ # 設置分頁標籤的大小策略,確保能夠獲得主要空間
+ self.tabs.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+ # 回饋分頁
+ self._create_feedback_tab()
+
+ # 命令分頁
+ self._create_command_tab()
+
+ layout.addWidget(self.tabs)
+
+ def _create_feedback_tab(self) -> None:
+ """創建回饋分頁"""
+ feedback_widget = QWidget()
+ feedback_layout = QVBoxLayout(feedback_widget)
+
+ # 文字回饋區域
+ feedback_group = QGroupBox("💬 您的回饋")
+ feedback_group_layout = QVBoxLayout(feedback_group)
+
+ self.feedback_text = QTextEdit()
+ self.feedback_text.setPlaceholderText("請在這裡輸入您的回饋、建議或問題...\n\n💡 小提示:按 Ctrl+Enter 可快速提交回饋")
+ self.feedback_text.setMinimumHeight(150)
+ # 確保文字輸入區域能夠擴展
+ self.feedback_text.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+ # 添加快捷鍵支援
+ submit_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self.feedback_text)
+ submit_shortcut.activated.connect(self._submit_feedback)
+
+ feedback_group_layout.addWidget(self.feedback_text)
+ feedback_layout.addWidget(feedback_group)
+
+ # 圖片上傳區域(允許適度拉伸)
+ self.image_upload = ImageUploadWidget()
+ self.image_upload.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.image_upload.setMinimumHeight(200) # 設置最小高度
+ self.image_upload.setMaximumHeight(400) # 增加最大高度限制
+ feedback_layout.addWidget(self.image_upload)
+
+ # 設置比例拉伸:文字區域佔2份,圖片區域佔1份
+ feedback_layout.setStretchFactor(feedback_group, 2)
+ feedback_layout.setStretchFactor(self.image_upload, 1)
+
+ self.tabs.addTab(feedback_widget, "💬 回饋")
+
+ def _create_command_tab(self) -> None:
+ """創建命令分頁"""
+ command_widget = QWidget()
+ command_layout = QVBoxLayout(command_widget)
+
+ # 命令輸入區域
+ command_group = QGroupBox("⚡ 命令執行")
+ command_group_layout = QVBoxLayout(command_group)
+
+ # 命令輸入
+ cmd_input_layout = QHBoxLayout()
+ self.command_input = QLineEdit()
+ self.command_input.setPlaceholderText("輸入要執行的命令...")
+ self.command_input.returnPressed.connect(self._run_command)
+
+ self.run_button = QPushButton("▶️ 執行")
self.run_button.clicked.connect(self._run_command)
-
- command_input_layout.addWidget(self.command_entry)
- command_input_layout.addWidget(self.run_button)
- command_layout.addLayout(command_input_layout)
-
- # Auto-execute and save config row
- auto_layout = QHBoxLayout()
- self.auto_check = QCheckBox("Execute automatically on next run")
- self.auto_check.setChecked(self.config.get("execute_automatically", False))
- self.auto_check.stateChanged.connect(self._update_config)
-
- save_button = QPushButton("&Save Configuration")
- save_button.clicked.connect(self._save_config)
-
- auto_layout.addWidget(self.auto_check)
- auto_layout.addStretch()
- auto_layout.addWidget(save_button)
- command_layout.addLayout(auto_layout)
-
- # Console section (now part of command_group)
- console_group = QGroupBox("Console")
- console_layout_internal = QVBoxLayout(console_group)
- console_group.setMinimumHeight(200)
-
- # Log text area
- self.log_text = QTextEdit()
- self.log_text.setReadOnly(True)
- font = QFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
- font.setPointSize(9)
- self.log_text.setFont(font)
- console_layout_internal.addWidget(self.log_text)
-
- # Clear button
+
+ cmd_input_layout.addWidget(self.command_input)
+ cmd_input_layout.addWidget(self.run_button)
+ command_group_layout.addLayout(cmd_input_layout)
+
+ # 命令輸出
+ self.command_output = QTextEdit()
+ self.command_output.setReadOnly(True)
+ self.command_output.setMinimumHeight(200)
+ self.command_output.setStyleSheet("background-color: #1e1e1e; color: #ffffff; font-family: 'Consolas', 'Monaco', 'Courier New', monospace;")
+ # 確保命令輸出區域能夠擴展
+ self.command_output.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ command_group_layout.addWidget(self.command_output)
+
+ # 設置群組框的大小策略
+ command_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+
+ command_layout.addWidget(command_group)
+
+ self.tabs.addTab(command_widget, "⚡ 命令")
+
+ def _create_action_buttons(self, layout: QVBoxLayout) -> None:
+ """創建操作按鈕"""
button_layout = QHBoxLayout()
- self.clear_button = QPushButton("&Clear")
- self.clear_button.clicked.connect(self.clear_logs)
+
+ self.submit_button = QPushButton("✅ 提交回饋")
+ self.submit_button.clicked.connect(self._submit_feedback)
+ self.submit_button.setStyleSheet("""
+ QPushButton {
+ background-color: #4caf50;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ font-weight: bold;
+ font-size: 14px;
+ }
+ QPushButton:hover { background-color: #45a049; }
+ """)
+
+ self.cancel_button = QPushButton("❌ 取消")
+ self.cancel_button.clicked.connect(self._cancel_feedback)
+ self.cancel_button.setStyleSheet("""
+ QPushButton {
+ background-color: #f44336;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ font-weight: bold;
+ font-size: 14px;
+ }
+ QPushButton:hover { background-color: #d32f2f; }
+ """)
+
button_layout.addStretch()
- button_layout.addWidget(self.clear_button)
- console_layout_internal.addLayout(button_layout)
+ button_layout.addWidget(self.cancel_button)
+ button_layout.addWidget(self.submit_button)
- command_layout.addWidget(console_group)
+ layout.addLayout(button_layout)
+
+ def _apply_dark_style(self) -> None:
+ """應用深色主題"""
+ self.setStyleSheet("""
+ QMainWindow {
+ background-color: #2b2b2b;
+ color: #ffffff;
+ }
+ QGroupBox {
+ font-weight: bold;
+ border: 2px solid #464647;
+ border-radius: 8px;
+ margin-top: 1ex;
+ padding: 10px;
+ }
+ QGroupBox::title {
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 5px 0 5px;
+ }
+ QTextEdit {
+ background-color: #2d2d30;
+ border: 1px solid #464647;
+ border-radius: 4px;
+ padding: 8px;
+ color: #ffffff;
+ }
+ QLineEdit {
+ background-color: #2d2d30;
+ border: 1px solid #464647;
+ border-radius: 4px;
+ padding: 8px;
+ color: #ffffff;
+ }
+ QTabWidget::pane {
+ border: 1px solid #464647;
+ border-radius: 4px;
+ }
+ QTabBar::tab {
+ background-color: #2d2d30;
+ color: #ffffff;
+ border: 1px solid #464647;
+ padding: 8px 16px;
+ margin-right: 2px;
+ }
+ QTabBar::tab:selected {
+ background-color: #007acc;
+ }
+ """)
- self.command_group.setVisible(False)
- layout.addWidget(self.command_group)
-
- # Feedback section with adjusted height
- self.feedback_group = QGroupBox("Feedback")
- feedback_layout = QVBoxLayout(self.feedback_group)
-
- # Short description label (from self.prompt)
- self.description_label = QLabel(self.prompt)
- self.description_label.setWordWrap(True)
- feedback_layout.addWidget(self.description_label)
-
- self.feedback_text = FeedbackTextEdit()
- font_metrics = self.feedback_text.fontMetrics()
- row_height = font_metrics.height()
- # Calculate height for 5 lines + some padding for margins
- padding = self.feedback_text.contentsMargins().top() + self.feedback_text.contentsMargins().bottom() + 5 # 5 is extra vertical padding
- self.feedback_text.setMinimumHeight(5 * row_height + padding)
-
- self.feedback_text.setPlaceholderText("Enter your feedback here (Ctrl+Enter to submit)")
- submit_button = QPushButton("&Send Feedback (Ctrl+Enter)")
- submit_button.clicked.connect(self._submit_feedback)
-
- feedback_layout.addWidget(self.feedback_text)
- feedback_layout.addWidget(submit_button)
-
- # Set minimum height for feedback_group to accommodate its contents
- # This will be based on the description label and the 5-line feedback_text
- self.feedback_group.setMinimumHeight(self.description_label.sizeHint().height() + self.feedback_text.minimumHeight() + submit_button.sizeHint().height() + feedback_layout.spacing() * 2 + feedback_layout.contentsMargins().top() + feedback_layout.contentsMargins().bottom() + 10) # 10 for extra padding
-
- # Add widgets in a specific order
- layout.addWidget(self.feedback_group)
-
- # Credits/Contact Label
- contact_label = QLabel('Need to improve? Contact Fábio Ferreira on X.com or visit dotcursorrules.com')
- contact_label.setOpenExternalLinks(True)
- contact_label.setAlignment(Qt.AlignCenter)
- # Optionally, make font a bit smaller and less prominent
- # contact_label_font = contact_label.font()
- # contact_label_font.setPointSize(contact_label_font.pointSize() - 1)
- # contact_label.setFont(contact_label_font)
- contact_label.setStyleSheet("font-size: 9pt; color: #cccccc;") # Light gray for dark theme
- layout.addWidget(contact_label)
-
- def _toggle_command_section(self):
- is_visible = self.command_group.isVisible()
- self.command_group.setVisible(not is_visible)
- if not is_visible:
- self.toggle_command_button.setText("Hide Command Section")
- else:
- self.toggle_command_button.setText("Show Command Section")
-
- # Immediately save the visibility state for this project
- self.settings.beginGroup(self.project_group_name)
- self.settings.setValue("commandSectionVisible", self.command_group.isVisible())
- self.settings.endGroup()
-
- # Adjust window height only
- new_height = self.centralWidget().sizeHint().height()
- if self.command_group.isVisible() and self.command_group.layout().sizeHint().height() > 0 :
- # if command group became visible and has content, ensure enough height
- min_content_height = self.command_group.layout().sizeHint().height() + self.feedback_group.minimumHeight() + self.toggle_command_button.height() + layout().spacing() * 2
- new_height = max(new_height, min_content_height)
-
- current_width = self.width()
- self.resize(current_width, new_height)
-
- def _update_config(self):
- self.config["run_command"] = self.command_entry.text()
- self.config["execute_automatically"] = self.auto_check.isChecked()
-
- def _append_log(self, text: str):
- self.log_buffer.append(text)
- self.log_text.append(text.rstrip())
- cursor = self.log_text.textCursor()
- cursor.movePosition(QTextCursor.End)
- self.log_text.setTextCursor(cursor)
-
- def _check_process_status(self):
- if self.process and self.process.poll() is not None:
- # Process has terminated
- exit_code = self.process.poll()
- self._append_log(f"\nProcess exited with code {exit_code}\n")
- self.run_button.setText("&Run")
- self.process = None
- self.activateWindow()
- self.feedback_text.setFocus()
-
- def _run_command(self):
- if self.process:
- kill_tree(self.process)
- self.process = None
- self.run_button.setText("&Run")
- return
-
- # Clear the log buffer but keep UI logs visible
- self.log_buffer = []
-
- command = self.command_entry.text()
+ def _run_command(self) -> None:
+ """執行命令"""
+ command = self.command_input.text().strip()
if not command:
- self._append_log("Please enter a command to run\n")
return
- self._append_log(f"$ {command}\n")
- self.run_button.setText("Sto&p")
-
+ self.command_output.append(f"$ {command}")
+
try:
+ # 在專案目錄中執行命令
self.process = subprocess.Popen(
command,
shell=True,
- cwd=self.project_directory,
+ cwd=self.project_dir,
stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- env=get_user_environment(),
+ stderr=subprocess.STDOUT,
text=True,
bufsize=1,
- encoding="utf-8",
- errors="ignore",
- close_fds=True,
+ universal_newlines=True
)
-
- def read_output(pipe):
- for line in iter(pipe.readline, ""):
- self.log_signals.append_log.emit(line)
-
- threading.Thread(
- target=read_output,
- args=(self.process.stdout,),
- daemon=True
- ).start()
-
- threading.Thread(
- target=read_output,
- args=(self.process.stderr,),
- daemon=True
- ).start()
-
- # Start process status checking
- self.status_timer = QTimer()
- self.status_timer.timeout.connect(self._check_process_status)
- self.status_timer.start(100) # Check every 100ms
-
+
+ # 使用計時器讀取輸出
+ self.timer = QTimer()
+ self.timer.timeout.connect(self._read_command_output)
+ self.timer.start(100)
+
except Exception as e:
- self._append_log(f"Error running command: {str(e)}\n")
- self.run_button.setText("&Run")
-
- def _submit_feedback(self):
- self.feedback_result = FeedbackResult(
- logs="".join(self.log_buffer),
- interactive_feedback=self.feedback_text.toPlainText().strip(),
- )
+ self.command_output.append(f"錯誤: {str(e)}")
+
+ def _read_command_output(self) -> None:
+ """讀取命令輸出"""
+ if self.process and self.process.poll() is None:
+ try:
+ output = self.process.stdout.readline()
+ if output:
+ self.command_output.insertPlainText(output)
+ # 自動滾動到底部
+ cursor = self.command_output.textCursor()
+ cursor.movePosition(cursor.End)
+ self.command_output.setTextCursor(cursor)
+ except:
+ pass
+ else:
+ # 進程結束
+ if hasattr(self, 'timer'):
+ self.timer.stop()
+ if self.process:
+ return_code = self.process.returncode
+ self.command_output.append(f"\n進程結束,返回碼: {return_code}\n")
+
+ def _submit_feedback(self) -> None:
+ """提交回饋"""
+ self.result = {
+ "interactive_feedback": self.feedback_text.toPlainText(),
+ "command_logs": self.command_output.toPlainText(),
+ "images": self.image_upload.get_images_data()
+ }
+ self.accepted = True
+ self.close()
+
+ def _cancel_feedback(self) -> None:
+ """取消回饋"""
+ self.accepted = False
self.close()
- def clear_logs(self):
- self.log_buffer = []
- self.log_text.clear()
-
- def _save_config(self):
- # Save run_command and execute_automatically to QSettings under project group
- self.settings.beginGroup(self.project_group_name)
- self.settings.setValue("run_command", self.config["run_command"])
- self.settings.setValue("execute_automatically", self.config["execute_automatically"])
- self.settings.endGroup()
- self._append_log("Configuration saved for this project.\n")
-
- def closeEvent(self, event):
- # Save general UI settings for the main window (geometry, state)
- self.settings.beginGroup("MainWindow_General")
- self.settings.setValue("geometry", self.saveGeometry())
- self.settings.setValue("windowState", self.saveState())
- self.settings.endGroup()
-
- # Save project-specific command section visibility (this is now slightly redundant due to immediate save in toggle, but harmless)
- self.settings.beginGroup(self.project_group_name)
- self.settings.setValue("commandSectionVisible", self.command_group.isVisible())
- self.settings.endGroup()
-
+ def closeEvent(self, event) -> None:
+ """處理視窗關閉事件"""
+ if hasattr(self, 'timer'):
+ self.timer.stop()
if self.process:
- kill_tree(self.process)
- super().closeEvent(event)
+ try:
+ self.process.terminate()
+ except:
+ pass
+
+ # 清理圖片上傳組件中的臨時文件
+ if hasattr(self, 'image_upload') and self.image_upload:
+ temp_files_cleaned = 0
+ for image_info in self.image_upload.images.values():
+ file_path = image_info["path"]
+ if "clipboard_" in os.path.basename(file_path) and ".cache" in file_path:
+ try:
+ if os.path.exists(file_path):
+ os.remove(file_path)
+ temp_files_cleaned += 1
+ print(f"[DEBUG] 關閉時清理臨時文件: {file_path}")
+ except Exception as e:
+ print(f"[DEBUG] 關閉時清理臨時文件失敗: {e}")
+ if temp_files_cleaned > 0:
+ print(f"[DEBUG] 視窗關閉時清理了 {temp_files_cleaned} 個臨時文件")
+
+ event.accept()
- def run(self) -> FeedbackResult:
- self.show()
- QApplication.instance().exec()
- if self.process:
- kill_tree(self.process)
-
- if not self.feedback_result:
- return FeedbackResult(logs="".join(self.log_buffer), interactive_feedback="")
-
- return self.feedback_result
-
-def get_project_settings_group(project_dir: str) -> str:
- # Create a safe, unique group name from the project directory path
- # Using only the last component + hash of full path to keep it somewhat readable but unique
- basename = os.path.basename(os.path.normpath(project_dir))
- full_hash = hashlib.md5(project_dir.encode('utf-8')).hexdigest()[:8]
- return f"{basename}_{full_hash}"
-
-def feedback_ui(project_directory: str, prompt: str, output_file: Optional[str] = None) -> Optional[FeedbackResult]:
- app = QApplication.instance() or QApplication()
- app.setPalette(get_dark_mode_palette(app))
- app.setStyle("Fusion")
- ui = FeedbackUI(project_directory, prompt)
- result = ui.run()
-
- if output_file and result:
- # Ensure the directory exists
- os.makedirs(os.path.dirname(output_file) if os.path.dirname(output_file) else ".", exist_ok=True)
- # Save the result to the output file
- with open(output_file, "w") as f:
- json.dump(result, f)
+# ===== 主要入口函數 =====
+def feedback_ui(project_directory: str, summary: str) -> Optional[FeedbackResult]:
+ """
+ 啟動回饋收集 GUI 介面
+
+ Args:
+ project_directory: 專案目錄路徑
+ summary: AI 工作摘要
+
+ Returns:
+ Optional[FeedbackResult]: 用戶回饋結果,如果取消則返回 None
+ """
+ app = QApplication.instance()
+ if app is None:
+ app = QApplication(sys.argv)
+
+ # 設置應用程式屬性
+ app.setApplicationName("互動式回饋收集")
+ app.setApplicationVersion("1.0")
+
+ # 創建並顯示視窗
+ window = FeedbackWindow(project_directory, summary)
+ window.show()
+
+ # 使用事件循環等待視窗關閉
+ app.exec()
+
+ # 返回結果
+ if window.accepted:
+ return window.result
+ else:
return None
- return result
if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Run the feedback UI")
- parser.add_argument("--project-directory", default=os.getcwd(), help="The project directory to run the command in")
- parser.add_argument("--prompt", default="I implemented the changes you requested.", help="The prompt to show to the user")
- parser.add_argument("--output-file", help="Path to save the feedback result as JSON")
- args = parser.parse_args()
-
- result = feedback_ui(args.project_directory, args.prompt, args.output_file)
+ # 測試用的主程式
+ result = feedback_ui(".", "測試摘要")
if result:
- print(f"\nLogs collected: \n{result['logs']}")
- print(f"\nFeedback received:\n{result['interactive_feedback']}")
- sys.exit(0)
+ print("收到回饋:", result)
+ else:
+ print("用戶取消了回饋")
\ No newline at end of file
diff --git a/server.py b/server.py
index bccc012..0d2a954 100644
--- a/server.py
+++ b/server.py
@@ -1,176 +1,498 @@
-# Interactive Feedback MCP
-# Developed by Fábio Ferreira (https://x.com/fabiomlferreira)
-# Inspired by/related to dotcursorrules.com (https://dotcursorrules.com/)
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+互動式回饋收集 MCP 服務器
+============================
+
+這是一個基於 Model Context Protocol (MCP) 的服務器,提供互動式用戶回饋收集功能。
+支援文字回饋、圖片上傳,並自動偵測運行環境選擇適當的用戶介面。
+
+作者: Fábio Ferreira
+靈感來源: dotcursorrules.com
+增強功能: 圖片支援和環境偵測
+"""
+
import os
import sys
import json
import tempfile
-import subprocess
+import asyncio
+import base64
+from typing import Annotated, List
-from typing import Annotated, Dict
-
-from fastmcp import FastMCP
+from mcp.server.fastmcp import FastMCP
+from mcp.server.fastmcp.utilities.types import Image as MCPImage
+from mcp.types import TextContent
from pydantic import Field
-# The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81
-mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR")
+# ===== 常數定義 =====
+SERVER_NAME = "互動式回饋收集 MCP"
+SSH_ENV_VARS = ['SSH_CONNECTION', 'SSH_CLIENT', 'SSH_TTY']
+REMOTE_ENV_VARS = ['REMOTE_CONTAINERS', 'CODESPACES']
-def is_ssh_session() -> bool:
- """Check if we're running in an SSH session or remote environment"""
- # Check for SSH environment variables
- ssh_indicators = [
- 'SSH_CONNECTION',
- 'SSH_CLIENT',
- 'SSH_TTY'
- ]
+# 初始化 MCP 服務器
+mcp = FastMCP(SERVER_NAME)
+
+
+# ===== 工具函數 =====
+def debug_log(message: str) -> None:
+ """輸出調試訊息到標準錯誤,避免污染標準輸出"""
+ print(f"[DEBUG] {message}", file=sys.stderr)
+
+
+def is_remote_environment() -> bool:
+ """
+ 檢測是否在遠端環境中運行
- for indicator in ssh_indicators:
- if os.getenv(indicator):
+ Returns:
+ bool: True 表示遠端環境,False 表示本地環境
+ """
+ # 檢查 SSH 連線指標
+ for env_var in SSH_ENV_VARS:
+ if os.getenv(env_var):
+ debug_log(f"偵測到 SSH 環境變數: {env_var}")
return True
- # Check if DISPLAY is not set (common in SSH without X11 forwarding)
- if sys.platform.startswith('linux') and not os.getenv('DISPLAY'):
+ # 檢查遠端開發環境
+ for env_var in REMOTE_ENV_VARS:
+ if os.getenv(env_var):
+ debug_log(f"偵測到遠端開發環境: {env_var}")
+ return True
+
+ # 檢查 Docker 容器
+ if os.path.exists('/.dockerenv'):
+ debug_log("偵測到 Docker 容器環境")
return True
- # Check for other remote indicators
- if os.getenv('TERM_PROGRAM') == 'vscode' and os.getenv('VSCODE_INJECTION') == '1':
- # VSCode remote development
+ # Windows 遠端桌面檢查
+ if sys.platform == 'win32':
+ session_name = os.getenv('SESSIONNAME', '')
+ if session_name and 'RDP' in session_name:
+ debug_log(f"偵測到 Windows 遠端桌面: {session_name}")
+ return True
+
+ # Linux 無顯示環境檢查
+ if sys.platform.startswith('linux') and not os.getenv('DISPLAY'):
+ debug_log("偵測到 Linux 無顯示環境")
return True
return False
+
def can_use_gui() -> bool:
- """Check if GUI can be used in current environment"""
- if is_ssh_session():
+ """
+ 檢測是否可以使用圖形介面
+
+ Returns:
+ bool: True 表示可以使用 GUI,False 表示只能使用 Web UI
+ """
+ if is_remote_environment():
return False
try:
- # Try to import Qt and check if display is available
- if sys.platform == 'win32':
- return True # Windows should generally support GUI
- elif sys.platform == 'darwin':
- return True # macOS should generally support GUI
- else:
- # Linux - check for DISPLAY
- return bool(os.getenv('DISPLAY'))
+ from PySide6.QtWidgets import QApplication
+ debug_log("成功載入 PySide6,可使用 GUI")
+ return True
except ImportError:
+ debug_log("無法載入 PySide6,使用 Web UI")
+ return False
+ except Exception as e:
+ debug_log(f"GUI 初始化失敗: {e}")
return False
-def launch_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
- """Launch appropriate UI based on environment"""
+
+def save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
+ """
+ 將回饋資料儲存到 JSON 文件
- if can_use_gui():
- # Use Qt GUI (original implementation)
- return launch_qt_feedback_ui(project_directory, summary)
- else:
- # Use Web UI
- return launch_web_feedback_ui(project_directory, summary)
-
-def launch_qt_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
- """Original Qt GUI implementation"""
- # Create a temporary file for the feedback result
- with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
- output_file = tmp.name
-
- try:
- # Get the path to feedback_ui.py relative to this script
- script_dir = os.path.dirname(os.path.abspath(__file__))
- feedback_ui_path = os.path.join(script_dir, "feedback_ui.py")
-
- # Run feedback_ui.py as a separate process
- # NOTE: There appears to be a bug in uv, so we need
- # to pass a bunch of special flags to make this work
- args = [
- sys.executable,
- "-u",
- feedback_ui_path,
- "--project-directory", project_directory,
- "--prompt", summary,
- "--output-file", output_file
- ]
- result = subprocess.run(
- args,
- check=False,
- shell=False,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- stdin=subprocess.DEVNULL,
- close_fds=True
- )
- if result.returncode != 0:
- raise Exception(f"Failed to launch feedback UI: {result.returncode}")
-
- # Read the result from the temporary file
- with open(output_file, 'r') as f:
- result = json.load(f)
- os.unlink(output_file)
- return result
- except Exception as e:
- if os.path.exists(output_file):
- os.unlink(output_file)
- raise e
-
-def launch_web_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
- """Launch Web UI implementation"""
- try:
- from web_ui import launch_web_feedback_ui as launch_web
- return launch_web(project_directory, summary)
- except ImportError as e:
- # Fallback to command line if web UI fails
- print(f"Web UI not available: {e}")
- return launch_cli_feedback_ui(project_directory, summary)
-
-def launch_cli_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
- """Simple command line fallback"""
- print(f"\n{'='*60}")
- print("Interactive Feedback MCP")
- print(f"{'='*60}")
- print(f"專案目錄: {project_directory}")
- print(f"任務描述: {summary}")
- print(f"{'='*60}")
+ Args:
+ feedback_data: 回饋資料字典
+ file_path: 儲存路徑,若為 None 則自動產生臨時文件
+
+ Returns:
+ str: 儲存的文件路徑
+ """
+ if file_path is None:
+ temp_fd, file_path = tempfile.mkstemp(suffix='.json', prefix='feedback_')
+ os.close(temp_fd)
- # Ask for command to run
- command = input("要執行的命令 (留空跳過): ").strip()
- command_logs = ""
+ # 確保目錄存在
+ directory = os.path.dirname(file_path)
+ if directory and not os.path.exists(directory):
+ os.makedirs(directory, exist_ok=True)
- if command:
- print(f"執行命令: {command}")
+ # 複製數據以避免修改原始數據
+ json_data = feedback_data.copy()
+
+ # 處理圖片數據:將 bytes 轉換為 base64 字符串以便 JSON 序列化
+ if "images" in json_data and isinstance(json_data["images"], list):
+ processed_images = []
+ for img in json_data["images"]:
+ if isinstance(img, dict) and "data" in img:
+ processed_img = img.copy()
+ # 如果 data 是 bytes,轉換為 base64 字符串
+ if isinstance(img["data"], bytes):
+ processed_img["data"] = base64.b64encode(img["data"]).decode('utf-8')
+ processed_img["data_type"] = "base64"
+ processed_images.append(processed_img)
+ else:
+ processed_images.append(img)
+ json_data["images"] = processed_images
+
+ # 儲存資料
+ with open(file_path, "w", encoding="utf-8") as f:
+ json.dump(json_data, f, ensure_ascii=False, indent=2)
+
+ debug_log(f"回饋資料已儲存至: {file_path}")
+ return file_path
+
+
+def create_feedback_text(feedback_data: dict) -> str:
+ """
+ 建立格式化的回饋文字
+
+ Args:
+ feedback_data: 回饋資料字典
+
+ Returns:
+ str: 格式化後的回饋文字
+ """
+ text_parts = []
+
+ # 基本回饋內容
+ if feedback_data.get("interactive_feedback"):
+ text_parts.append(f"=== 用戶回饋 ===\n{feedback_data['interactive_feedback']}")
+
+ # 命令執行日誌
+ if feedback_data.get("logs"):
+ text_parts.append(f"=== 命令執行日誌 ===\n{feedback_data['logs']}")
+
+ # 圖片附件概要
+ if feedback_data.get("images"):
+ images = feedback_data["images"]
+ text_parts.append(f"=== 圖片附件概要 ===\n用戶提供了 {len(images)} 張圖片:")
+
+ for i, img in enumerate(images, 1):
+ size = img.get("size", 0)
+ name = img.get("name", "unknown")
+
+ # 智能單位顯示
+ if size < 1024:
+ size_str = f"{size} B"
+ elif size < 1024 * 1024:
+ size_kb = size / 1024
+ size_str = f"{size_kb:.1f} KB"
+ else:
+ size_mb = size / (1024 * 1024)
+ size_str = f"{size_mb:.1f} MB"
+
+ text_parts.append(f" {i}. {name} ({size_str})")
+
+ return "\n\n".join(text_parts) if text_parts else "用戶未提供任何回饋內容。"
+
+
+def process_images(images_data: List[dict]) -> List[MCPImage]:
+ """
+ 處理圖片資料,轉換為 MCP 圖片對象
+
+ Args:
+ images_data: 圖片資料列表
+
+ Returns:
+ List[MCPImage]: MCP 圖片對象列表
+ """
+ mcp_images = []
+
+ for i, img in enumerate(images_data, 1):
try:
- result = subprocess.run(
- command,
- shell=True,
- cwd=project_directory,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="ignore"
- )
- command_logs = f"$ {command}\n{result.stdout}{result.stderr}"
- print(command_logs)
+ if not img.get("data"):
+ debug_log(f"圖片 {i} 沒有資料,跳過")
+ continue
+
+ # 檢查數據類型並相應處理
+ if isinstance(img["data"], bytes):
+ # 如果是原始 bytes 數據,直接使用
+ image_bytes = img["data"]
+ debug_log(f"圖片 {i} 使用原始 bytes 數據,大小: {len(image_bytes)} bytes")
+ elif isinstance(img["data"], str):
+ # 如果是 base64 字符串,進行解碼
+ image_bytes = base64.b64decode(img["data"])
+ debug_log(f"圖片 {i} 從 base64 解碼,大小: {len(image_bytes)} bytes")
+ else:
+ debug_log(f"圖片 {i} 數據類型不支援: {type(img['data'])}")
+ continue
+
+ if len(image_bytes) == 0:
+ debug_log(f"圖片 {i} 數據為空,跳過")
+ continue
+
+ # 根據文件名推斷格式
+ file_name = img.get("name", "image.png")
+ if file_name.lower().endswith(('.jpg', '.jpeg')):
+ image_format = 'jpeg'
+ elif file_name.lower().endswith('.gif'):
+ image_format = 'gif'
+ else:
+ image_format = 'png' # 默認使用 PNG
+
+ # 創建 MCPImage 對象
+ mcp_image = MCPImage(data=image_bytes, format=image_format)
+ mcp_images.append(mcp_image)
+
+ debug_log(f"圖片 {i} ({file_name}) 處理成功,格式: {image_format}")
+
except Exception as e:
- command_logs = f"$ {command}\nError: {str(e)}\n"
- print(command_logs)
+ debug_log(f"圖片 {i} 處理失敗: {e}")
+ import traceback
+ debug_log(f"詳細錯誤: {traceback.format_exc()}")
- # Ask for feedback
- print(f"\n{'='*60}")
- print("請提供您的回饋意見:")
- feedback = input().strip()
-
- return {
- "command_logs": command_logs,
- "interactive_feedback": feedback
- }
+ debug_log(f"共處理 {len(mcp_images)} 張圖片")
+ return mcp_images
+
+
+def launch_gui(project_dir: str, summary: str) -> dict:
+ """
+ 啟動 GUI 收集回饋
+
+ Args:
+ project_dir: 專案目錄路徑
+ summary: AI 工作摘要
+
+ Returns:
+ dict: 收集到的回饋資料
+ """
+ debug_log("啟動 Qt GUI 介面")
+
+ from feedback_ui import feedback_ui
+ return feedback_ui(project_dir, summary)
+
+
+# ===== MCP 工具定義 =====
+@mcp.tool()
+async def interactive_feedback(
+ project_directory: Annotated[str, Field(description="專案目錄路徑")] = ".",
+ summary: Annotated[str, Field(description="AI 工作完成的摘要說明")] = "我已完成了您請求的任務。",
+ timeout: Annotated[int, Field(description="等待用戶回饋的超時時間(秒)")] = 600,
+ force_web_ui: Annotated[bool, Field(description="強制使用 Web UI(用於測試或特殊需求)")] = False
+) -> List:
+ """
+ 收集用戶的互動回饋,支援文字和圖片
+
+ 此工具會自動偵測運行環境:
+ - 遠端環境:使用 Web UI
+ - 本地環境:使用 Qt GUI
+ - 可透過 force_web_ui 參數或 FORCE_WEB 環境變數強制使用 Web UI
+
+ 用戶可以:
+ 1. 執行命令來驗證結果
+ 2. 提供文字回饋
+ 3. 上傳圖片作為回饋
+ 4. 查看 AI 的工作摘要
+
+ Args:
+ project_directory: 專案目錄路徑
+ summary: AI 工作完成的摘要說明
+ timeout: 等待用戶回饋的超時時間(秒),預設為 600 秒(10 分鐘)
+ force_web_ui: 強制使用 Web UI,即使在本地環境也使用 Web UI(用於測試)
+
+ Returns:
+ List: 包含 TextContent 和 MCPImage 對象的列表
+ """
+ # 檢查環境變數,如果設定了 FORCE_WEB 就覆蓋 force_web_ui 參數
+ env_force_web = os.getenv("FORCE_WEB", "").lower()
+ if env_force_web in ("true", "1", "yes", "on"):
+ force_web_ui = True
+ debug_log("環境變數 FORCE_WEB 已啟用,強制使用 Web UI")
+ elif env_force_web in ("false", "0", "no", "off"):
+ force_web_ui = False
+ debug_log("環境變數 FORCE_WEB 已停用,使用預設邏輯")
+
+ # 環境偵測
+ is_remote = is_remote_environment()
+ can_gui = can_use_gui()
+ use_web_ui = is_remote or not can_gui or force_web_ui
+
+ debug_log(f"環境偵測結果 - 遠端: {is_remote}, GUI 可用: {can_gui}, 強制 Web UI: {force_web_ui}")
+ debug_log(f"決定使用介面: {'Web UI' if use_web_ui else 'Qt GUI'}")
+
+ try:
+ # 確保專案目錄存在
+ if not os.path.exists(project_directory):
+ project_directory = os.getcwd()
+ project_directory = os.path.abspath(project_directory)
+
+ # 選擇適當的介面
+ if use_web_ui:
+ result = await launch_web_ui_with_timeout(project_directory, summary, timeout)
+ else:
+ result = launch_gui(project_directory, summary)
+
+ # 處理取消情況
+ if not result:
+ return [TextContent(type="text", text="用戶取消了回饋。")]
+
+ # 儲存詳細結果
+ save_feedback_to_file(result)
+
+ # 建立回饋項目列表
+ feedback_items = []
+
+ # 添加文字回饋
+ if result.get("interactive_feedback") or result.get("logs") or result.get("images"):
+ feedback_text = create_feedback_text(result)
+ feedback_items.append(TextContent(type="text", text=feedback_text))
+ debug_log("文字回饋已添加")
+
+ # 添加圖片回饋
+ if result.get("images"):
+ mcp_images = process_images(result["images"])
+ feedback_items.extend(mcp_images)
+ debug_log(f"已添加 {len(mcp_images)} 張圖片")
+
+ # 確保至少有一個回饋項目
+ if not feedback_items:
+ feedback_items.append(TextContent(type="text", text="用戶未提供任何回饋內容。"))
+
+ debug_log(f"回饋收集完成,共 {len(feedback_items)} 個項目")
+ return feedback_items
+
+ except Exception as e:
+ error_msg = f"回饋收集錯誤: {str(e)}"
+ debug_log(f"錯誤: {error_msg}")
+ return [TextContent(type="text", text=error_msg)]
+
+
+async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: int) -> dict:
+ """
+ 啟動 Web UI 收集回饋,支援自訂超時時間
+
+ Args:
+ project_dir: 專案目錄路徑
+ summary: AI 工作摘要
+ timeout: 超時時間(秒)
+
+ Returns:
+ dict: 收集到的回饋資料
+ """
+ debug_log(f"啟動 Web UI 介面,超時時間: {timeout} 秒")
+
+ try:
+ from web_ui import get_web_ui_manager
+
+ # 直接運行 Web UI 會話
+ return await _run_web_ui_session(project_dir, summary, timeout)
+ except ImportError as e:
+ debug_log(f"無法導入 Web UI 模組: {e}")
+ return {
+ "logs": "",
+ "interactive_feedback": f"Web UI 模組導入失敗: {str(e)}",
+ "images": []
+ }
+
+
+async def _run_web_ui_session(project_dir: str, summary: str, timeout: int) -> dict:
+ """
+ 運行 Web UI 會話
+
+ Args:
+ project_dir: 專案目錄路徑
+ summary: AI 工作摘要
+ timeout: 超時時間(秒)
+
+ Returns:
+ dict: 收集到的回饋資料
+ """
+ from web_ui import get_web_ui_manager
+
+ manager = get_web_ui_manager()
+
+ # 創建會話
+ session_id = manager.create_session(project_dir, summary)
+ session_url = f"http://{manager.host}:{manager.port}/session/{session_id}"
+
+ debug_log(f"Web UI 已啟動: {session_url}")
+ try:
+ print(f"Web UI 已啟動: {session_url}")
+ except UnicodeEncodeError:
+ print(f"Web UI launched: {session_url}")
+
+ # 開啟瀏覽器
+ manager.open_browser(session_url)
+
+ try:
+ # 等待用戶回饋
+ session = manager.get_session(session_id)
+ if not session:
+ raise RuntimeError("會話創建失敗")
+
+ result = await session.wait_for_feedback(timeout=timeout)
+ debug_log(f"Web UI 回饋收集成功,超時時間: {timeout} 秒")
+ return result
+
+ except TimeoutError:
+ timeout_msg = f"等待用戶回饋超時({timeout} 秒)"
+ debug_log(f"⏰ {timeout_msg}")
+ try:
+ print(f"等待用戶回饋超時({timeout} 秒)")
+ except UnicodeEncodeError:
+ print(f"Feedback timeout ({timeout} seconds)")
+ return {
+ "logs": "",
+ "interactive_feedback": f"回饋超時({timeout} 秒)",
+ "images": []
+ }
+ except Exception as e:
+ error_msg = f"Web UI 錯誤: {e}"
+ debug_log(f"❌ {error_msg}")
+ try:
+ print(f"Web UI 錯誤: {e}")
+ except UnicodeEncodeError:
+ print(f"Web UI error: {e}")
+ return {
+ "logs": "",
+ "interactive_feedback": f"錯誤: {str(e)}",
+ "images": []
+ }
+ finally:
+ # 清理會話
+ manager.remove_session(session_id)
-def first_line(text: str) -> str:
- return text.split("\n")[0].strip()
@mcp.tool()
-def interactive_feedback(
- project_directory: Annotated[str, Field(description="Full path to the project directory")],
- summary: Annotated[str, Field(description="Short, one-line summary of the changes")],
-) -> Dict[str, str]:
- """Request interactive feedback for a given project directory and summary"""
- return launch_feedback_ui(first_line(project_directory), first_line(summary))
+def get_system_info() -> str:
+ """
+ 獲取系統環境資訊
+
+ Returns:
+ str: JSON 格式的系統資訊
+ """
+ is_remote = is_remote_environment()
+ can_gui = can_use_gui()
+
+ system_info = {
+ "平台": sys.platform,
+ "Python 版本": sys.version.split()[0],
+ "遠端環境": is_remote,
+ "GUI 可用": can_gui,
+ "建議介面": "Web UI" if is_remote or not can_gui else "Qt GUI",
+ "環境變數": {
+ "SSH_CONNECTION": os.getenv("SSH_CONNECTION"),
+ "SSH_CLIENT": os.getenv("SSH_CLIENT"),
+ "DISPLAY": os.getenv("DISPLAY"),
+ "VSCODE_INJECTION": os.getenv("VSCODE_INJECTION"),
+ "SESSIONNAME": os.getenv("SESSIONNAME"),
+ }
+ }
+
+ return json.dumps(system_info, ensure_ascii=False, indent=2)
+
+# ===== 主程式入口 =====
if __name__ == "__main__":
- mcp.run(transport="stdio")
+ debug_log("🚀 啟動互動式回饋收集 MCP 服務器")
+ debug_log(f" 遠端環境: {is_remote_environment()}")
+ debug_log(f" GUI 可用: {can_use_gui()}")
+ debug_log(f" 建議介面: {'Web UI' if is_remote_environment() or not can_use_gui() else 'Qt GUI'}")
+ debug_log(" 等待來自 AI 助手的調用...")
+
+ mcp.run()
diff --git a/test_qt_gui.py b/test_qt_gui.py
new file mode 100644
index 0000000..1541912
--- /dev/null
+++ b/test_qt_gui.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+# Test script for Qt GUI functionality
+import os
+import sys
+from pathlib import Path
+
+# 添加項目路徑到 Python 路徑
+sys.path.insert(0, str(Path(__file__).parent))
+
+def test_qt_gui():
+ """測試 Qt GUI 功能"""
+ try:
+ from feedback_ui import feedback_ui
+
+ # 測試參數
+ project_directory = os.getcwd()
+ prompt = """🎯 圖片預覽和視窗調整測試
+
+這是一個測試會話,用於驗證以下功能:
+
+✅ 功能測試項目:
+1. 圖片上傳和預覽功能
+2. 圖片右上角X刪除按鈕
+3. 視窗自由調整大小
+4. 分割器的靈活調整
+5. 各區域的動態佈局
+
+📋 測試步驟:
+1. 嘗試上傳一些圖片(拖拽、文件選擇、剪貼板)
+2. 檢查圖片預覽是否正常顯示
+3. 點擊圖片右上角的X按鈕刪除圖片
+4. 嘗試調整視窗大小,檢查是否可以自由調整
+5. 拖動分割器調整各區域大小
+6. 提供任何回饋或發現的問題
+
+請測試這些功能並提供回饋!"""
+
+ print("🚀 啟動 Qt GUI 測試...")
+ print("📝 測試項目:")
+ print(" - 圖片預覽功能")
+ print(" - X刪除按鈕")
+ print(" - 視窗大小調整")
+ print(" - 分割器調整")
+ print()
+
+ # 啟動 GUI
+ result = feedback_ui(project_directory, prompt)
+
+ if result:
+ print("\n✅ 測試完成!")
+ print(f"📄 收到回饋: {result.get('interactive_feedback', '無')}")
+ if result.get('images'):
+ print(f"🖼️ 收到圖片: {len(result['images'])} 張")
+ if result.get('logs'):
+ print(f"📋 命令日誌: {len(result['logs'])} 行")
+ else:
+ print("\n❌ 測試取消或無回饋")
+
+ except ImportError as e:
+ print(f"❌ 導入錯誤: {e}")
+ print("請確保已安裝 PySide6: pip install PySide6")
+ return False
+ except Exception as e:
+ print(f"❌ 測試錯誤: {e}")
+ return False
+
+ return True
+
+if __name__ == "__main__":
+ print("🧪 Interactive Feedback MCP - Qt GUI 測試")
+ print("=" * 50)
+
+ # 檢查環境
+ try:
+ from PySide6.QtWidgets import QApplication
+ print("✅ PySide6 已安裝")
+ except ImportError:
+ print("❌ PySide6 未安裝,請執行: pip install PySide6")
+ sys.exit(1)
+
+ # 運行測試
+ success = test_qt_gui()
+
+ if success:
+ print("\n🎉 測試程序運行完成")
+ else:
+ print("\n💥 測試程序運行失敗")
+ sys.exit(1)
\ No newline at end of file