mcp-feedback-enhanced/feedback_ui.py

938 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
互動式回饋收集 GUI 介面
=======================
基於 PySide6 的圖形用戶介面,提供直觀的回饋收集功能。
支援文字輸入、圖片上傳、命令執行等功能。
作者: Fábio Ferreira
靈感來源: dotcursorrules.com
增強功能: 圖片支援和現代化界面設計
"""
import os
import sys
import subprocess
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, QTextEdit, QGroupBox,
QScrollArea, QFrame, QGridLayout, QFileDialog, QMessageBox,
QTabWidget, QSizePolicy
)
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 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:
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)
# ===== 圖片上傳元件 =====
class ImageUploadWidget(QWidget):
"""圖片上傳元件"""
images_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
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:
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()
]
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 FeedbackWindow(QMainWindow):
"""主要的回饋收集視窗"""
def __init__(self, project_dir: str, summary: str):
super().__init__()
self.project_dir = project_dir
self.summary = summary
self.result: Optional[FeedbackResult] = None
self.process: Optional[subprocess.Popen] = None
self.accepted = False
self._setup_ui()
self._apply_dark_style()
def _setup_ui(self) -> None:
"""設置用戶介面"""
self.setWindowTitle("互動式回饋收集")
self.setMinimumSize(800, 600)
# 主要元件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主要佈局
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)
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.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.cancel_button)
button_layout.addWidget(self.submit_button)
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;
}
""")
def _run_command(self) -> None:
"""執行命令"""
command = self.command_input.text().strip()
if not command:
return
self.command_output.append(f"$ {command}")
try:
# 在專案目錄中執行命令
self.process = subprocess.Popen(
command,
shell=True,
cwd=self.project_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# 使用計時器讀取輸出
self.timer = QTimer()
self.timer.timeout.connect(self._read_command_output)
self.timer.start(100)
except Exception as e:
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 closeEvent(self, event) -> None:
"""處理視窗關閉事件"""
if hasattr(self, 'timer'):
self.timer.stop()
if self.process:
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 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
if __name__ == "__main__":
# 測試用的主程式
result = feedback_ui(".", "測試摘要")
if result:
print("收到回饋:", result)
else:
print("用戶取消了回饋")