first commit

This commit is contained in:
huangzhenpc
2025-06-07 15:35:13 +08:00
parent 4e14f40dbf
commit 669705dc9a
115 changed files with 19831 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Feedback TingQuan Enhanced
===============================
互動式用戶回饋 MCP 伺服器,提供 AI 輔助開發中的回饋收集功能。
作者: maticarmy
原始作者: Fábio Ferreira
增強功能: Web UI 支援、圖片上傳、現代化界面設計
特色:
- 雙介面支援Qt GUI 和 Web UI
- 智慧環境檢測
- 命令執行功能
- 圖片上傳支援
- 現代化深色主題
- 重構的模組化架構
"""
__version__ = "0.0.1"
__author__ = "maticarmy"
__email__ = "maticarmy@example.com"
import os
from .server import main as run_server
# 導入新的 Web UI 模組
from .web import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
# 條件性導入 GUI 模組(只有在不強制使用 Web 時才導入)
feedback_ui = None
if not os.getenv('FORCE_WEB', '').lower() in ('true', '1', 'yes'):
try:
from .gui import feedback_ui
except ImportError:
# 如果 GUI 依賴不可用,設為 None
feedback_ui = None
# 主要導出介面
__all__ = [
"run_server",
"feedback_ui",
"WebUIManager",
"launch_web_feedback_ui",
"get_web_ui_manager",
"stop_web_ui",
"__version__",
"__author__",
]
def main():
"""主要入口點,用於 uvx 執行"""
from .__main__ import main as cli_main
return cli_main()

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Interactive Feedback Enhanced - 主程式入口
==============================================
此檔案允許套件透過 `python -m mcp_feedback_enhanced` 執行。
使用方法:
python -m mcp_feedback_enhanced # 啟動 MCP 伺服器
python -m mcp_feedback_enhanced test # 執行測試
"""
import sys
import argparse
import os
def main():
"""主程式入口點"""
parser = argparse.ArgumentParser(
description="MCP Feedback TingQuan Enhanced - 互動式回饋收集 MCP 伺服器"
)
subparsers = parser.add_subparsers(dest='command', help='可用命令')
# 伺服器命令(預設)
server_parser = subparsers.add_parser('server', help='啟動 MCP 伺服器(預設)')
# 測試命令
test_parser = subparsers.add_parser('test', help='執行測試')
test_parser.add_argument('--web', action='store_true', help='測試 Web UI (自動持續運行)')
test_parser.add_argument('--gui', action='store_true', help='測試 Qt GUI (快速測試)')
test_parser.add_argument('--enhanced', action='store_true', help='執行增強 MCP 測試 (推薦)')
test_parser.add_argument('--scenario', help='運行特定的測試場景')
test_parser.add_argument('--tags', help='根據標籤運行測試場景 (逗號分隔)')
test_parser.add_argument('--list-scenarios', action='store_true', help='列出所有可用的測試場景')
test_parser.add_argument('--report-format', choices=['html', 'json', 'markdown'], help='報告格式')
test_parser.add_argument('--timeout', type=int, help='測試超時時間 (秒)')
# 版本命令
version_parser = subparsers.add_parser('version', help='顯示版本資訊')
args = parser.parse_args()
if args.command == 'test':
run_tests(args)
elif args.command == 'version':
show_version()
elif args.command == 'server':
run_server()
elif args.command is None:
run_server()
else:
# 不應該到達這裡
parser.print_help()
sys.exit(1)
def run_server():
"""啟動 MCP 伺服器"""
from .server import main as server_main
return server_main()
def run_tests(args):
"""執行測試"""
# 啟用調試模式以顯示測試過程
os.environ["MCP_DEBUG"] = "true"
if args.enhanced or args.scenario or args.tags or args.list_scenarios:
# 使用新的增強測試系統
print("🚀 執行增強 MCP 測試系統...")
import asyncio
from .test_mcp_enhanced import MCPTestRunner, TestConfig
# 創建配置
config = TestConfig.from_env()
if args.timeout:
config.test_timeout = args.timeout
if args.report_format:
config.report_format = args.report_format
runner = MCPTestRunner(config)
async def run_enhanced_tests():
try:
if args.list_scenarios:
# 列出測試場景
tags = args.tags.split(',') if args.tags else None
runner.list_scenarios(tags)
return True
success = False
if args.scenario:
# 運行特定場景
success = await runner.run_single_scenario(args.scenario)
elif args.tags:
# 根據標籤運行
tags = [tag.strip() for tag in args.tags.split(',')]
success = await runner.run_scenarios_by_tags(tags)
else:
# 運行所有場景
success = await runner.run_all_scenarios()
return success
except Exception as e:
print(f"❌ 增強測試執行失敗: {e}")
return False
success = asyncio.run(run_enhanced_tests())
if not success:
sys.exit(1)
elif args.web:
print("🧪 執行 Web UI 測試...")
from .test_web_ui import test_web_ui, interactive_demo
success, session_info = test_web_ui()
if not success:
sys.exit(1)
# Web UI 測試自動啟用持續模式
if session_info:
print("📝 Web UI 測試完成,進入持續模式...")
print("💡 提示:服務器將持續運行,可在瀏覽器中測試互動功能")
print("💡 按 Ctrl+C 停止服務器")
interactive_demo(session_info)
elif args.gui:
print("🧪 執行 Qt GUI 測試...")
from .test_qt_gui import test_qt_gui
if not test_qt_gui():
sys.exit(1)
else:
# 默認執行增強測試系統的快速測試
print("🧪 執行快速測試套件 (使用增強測試系統)...")
print("💡 提示:使用 --enhanced 參數可執行完整測試")
import asyncio
from .test_mcp_enhanced import MCPTestRunner, TestConfig
config = TestConfig.from_env()
config.test_timeout = 60 # 快速測試使用較短超時
runner = MCPTestRunner(config)
async def run_quick_tests():
try:
# 運行快速測試標籤
success = await runner.run_scenarios_by_tags(["quick"])
return success
except Exception as e:
print(f"❌ 快速測試執行失敗: {e}")
return False
success = asyncio.run(run_quick_tests())
if not success:
sys.exit(1)
print("🎉 快速測試通過!")
print("💡 使用 'test --enhanced' 執行完整測試套件")
def show_version():
"""顯示版本資訊"""
from . import __version__, __author__
print(f"MCP Feedback TingQuan Enhanced v{__version__}")
print(f"作者: {__author__}")
print(f"GitHub: https://github.com/maticarmy/mcp-tingquan")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
統一調試日誌模組
================
提供統一的調試日誌功能,確保調試輸出不會干擾 MCP 通信。
所有調試輸出都會發送到 stderr並且只在調試模式啟用時才輸出。
使用方法:
```python
from .debug import debug_log
debug_log("這是一條調試信息")
```
環境變數控制:
- MCP_DEBUG=true/1/yes/on: 啟用調試模式
- MCP_DEBUG=false/0/no/off: 關閉調試模式(默認)
作者: Minidoracat
"""
import os
import sys
from typing import Any
def debug_log(message: Any, prefix: str = "DEBUG") -> None:
"""
輸出調試訊息到標準錯誤,避免污染標準輸出
Args:
message: 要輸出的調試信息
prefix: 調試信息的前綴標識,默認為 "DEBUG"
"""
# 只在啟用調試模式時才輸出,避免干擾 MCP 通信
if not os.getenv("MCP_DEBUG", "").lower() in ("true", "1", "yes", "on"):
return
try:
# 確保消息是字符串類型
if not isinstance(message, str):
message = str(message)
# 安全地輸出到 stderr處理編碼問題
try:
print(f"[{prefix}] {message}", file=sys.stderr, flush=True)
except UnicodeEncodeError:
# 如果遇到編碼問題,使用 ASCII 安全模式
safe_message = message.encode('ascii', errors='replace').decode('ascii')
print(f"[{prefix}] {safe_message}", file=sys.stderr, flush=True)
except Exception:
# 最後的備用方案:靜默失敗,不影響主程序
pass
def gui_debug_log(message: Any) -> None:
"""GUI 模組專用的調試日誌"""
debug_log(message, "GUI")
def i18n_debug_log(message: Any) -> None:
"""國際化模組專用的調試日誌"""
debug_log(message, "I18N")
def server_debug_log(message: Any) -> None:
"""伺服器模組專用的調試日誌"""
debug_log(message, "SERVER")
def web_debug_log(message: Any) -> None:
"""Web UI 模組專用的調試日誌"""
debug_log(message, "WEB")
def is_debug_enabled() -> bool:
"""檢查是否啟用了調試模式"""
return os.getenv("MCP_DEBUG", "").lower() in ("true", "1", "yes", "on")
def set_debug_mode(enabled: bool) -> None:
"""設置調試模式(用於測試)"""
os.environ["MCP_DEBUG"] = "true" if enabled else "false"

View File

@@ -0,0 +1,25 @@
"""
互動式回饋收集 GUI 模組
=======================
基於 PySide6 的圖形用戶介面模組,提供直觀的回饋收集功能。
支援文字輸入、圖片上傳、命令執行等功能。
模組結構:
- main.py: 主要介面入口點
- window/: 窗口類別
- widgets/: 自定義元件
- styles/: 樣式定義
- utils/: 工具函數
- models/: 資料模型
作者: Fábio Ferreira
靈感來源: dotcursorrules.com
增強功能: 圖片支援和現代化界面設計
多語系支援: Minidoracat
重構: 模塊化設計
"""
from .main import feedback_ui, feedback_ui_with_timeout
__all__ = ['feedback_ui', 'feedback_ui_with_timeout']

View File

@@ -0,0 +1,172 @@
# 多語系檔案結構說明
## 📁 檔案結構
```
locales/
├── README.md # 此說明檔案
├── zh-TW/ # 繁體中文
│ └── translations.json
├── en/ # 英文
│ └── translations.json
└── zh-CN/ # 簡體中文
└── translations.json
```
## 🌐 翻譯檔案格式
每個語言的 `translations.json` 檔案都包含以下結構:
### 1. 元資料區塊 (meta)
```json
{
"meta": {
"language": "zh-TW",
"displayName": "繁體中文",
"author": "作者名稱",
"version": "1.0.0",
"lastUpdate": "2025-01-31"
}
}
```
### 2. 應用程式區塊 (app)
```json
{
"app": {
"title": "應用程式標題",
"projectDirectory": "專案目錄",
"language": "語言",
"settings": "設定"
}
}
```
### 3. 分頁區塊 (tabs)
```json
{
"tabs": {
"feedback": "💬 回饋",
"command": "⚡ 命令",
"images": "🖼️ 圖片"
}
}
```
### 4. 其他功能區塊
- `feedback`: 回饋相關文字
- `command`: 命令執行相關文字
- `images`: 圖片上傳相關文字
- `buttons`: 按鈕文字
- `status`: 狀態訊息
- `aiSummary`: AI 摘要標題
- `languageSelector`: 語言選擇器標題
- `languageNames`: 語言顯示名稱
## 🔧 新增新語言步驟
### 1. 建立語言目錄
```bash
mkdir src/mcp_feedback_enhanced/locales/[語言代碼]
```
### 2. 複製翻譯檔案
```bash
cp src/mcp_feedback_enhanced/locales/en/translations.json \
src/mcp_feedback_enhanced/locales/[語言代碼]/translations.json
```
### 3. 修改元資料
```json
{
"meta": {
"language": "[語言代碼]",
"displayName": "[語言顯示名稱]",
"author": "[翻譯者姓名]",
"version": "1.0.0",
"lastUpdate": "[日期]"
}
}
```
### 4. 翻譯內容
逐一翻譯各個區塊的內容,保持 JSON 結構不變。
### 5. 註冊新語言
`i18n.py` 中將新語言代碼加入支援列表:
```python
self._supported_languages = ['zh-TW', 'en', 'zh-CN', '[新語言代碼]']
```
`i18n.js` 中也要加入:
```javascript
this.supportedLanguages = ['zh-TW', 'en', 'zh-CN', '[新語言代碼]'];
```
## 🎯 使用方式
### Python 後端
```python
from .i18n import t
# 新格式(建議)
title = t('app.title')
button_text = t('buttons.submitFeedback')
# 舊格式(兼容)
title = t('app_title')
button_text = t('btn_submit_feedback')
```
### JavaScript 前端
```javascript
// 新格式(建議)
const title = t('app.title');
const buttonText = t('buttons.submitFeedback');
// 舊格式(兼容)
const title = t('app_title');
const buttonText = t('btn_submit_feedback');
```
## 📋 翻譯檢查清單
建議在新增或修改翻譯時檢查:
- [ ] JSON 格式正確,沒有語法錯誤
- [ ] 所有必要的鍵值都存在
- [ ] 佔位符 `{param}` 格式正確
- [ ] 特殊字符和 Emoji 顯示正常
- [ ] 文字長度適合 UI 顯示
- [ ] 語言顯示名稱在 `languageNames` 中正確設定
## 🔄 向後兼容
新的多語系系統完全向後兼容舊的鍵值格式:
| 舊格式 | 新格式 |
|--------|--------|
| `app_title` | `app.title` |
| `btn_submit_feedback` | `buttons.submitFeedback` |
| `images_status` | `images.status` |
| `command_output` | `command.output` |
## 🚀 優勢特色
1. **結構化組織**:按功能區域分組,易於維護
2. **元資料支援**:包含版本、作者等資訊
3. **巢狀鍵值**:更清晰的命名空間
4. **動態載入**:前端支援從 API 載入翻譯
5. **向後兼容**:舊程式碼無需修改
6. **易於擴充**:新增語言非常簡單
## 📝 貢獻指南
歡迎貢獻新的語言翻譯:
1. Fork 專案
2. 按照上述步驟新增語言
3. 測試翻譯是否正確顯示
4. 提交 Pull Request
需要幫助可以參考現有的翻譯檔案作為範本。

View File

@@ -0,0 +1,207 @@
{
"meta": {
"language": "en",
"displayName": "English",
"author": "Minidoracat",
"version": "1.0.0",
"lastUpdate": "2025-01-31"
},
"app": {
"title": "Interactive Feedback Collection",
"projectDirectory": "Project Directory",
"language": "Language",
"settings": "Settings",
"confirmCancel": "Confirm Cancel",
"confirmCancelMessage": "Are you sure you want to cancel feedback? All input content will be lost.",
"layoutChangeTitle": "Interface Layout Change",
"layoutChangeMessage": "Layout mode has been changed and requires reloading the interface to take effect.\nReload now?"
},
"tabs": {
"summary": "📋 AI Summary",
"feedback": "💬 Feedback",
"command": "⚡ Command",
"language": "⚙️ Settings",
"images": "🖼️ Images",
"about": " About"
},
"about": {
"appInfo": "Application Information",
"version": "Version",
"description": "A powerful MCP server that provides human-in-the-loop interactive feedback functionality for AI-assisted development tools. Supports dual interfaces (Qt GUI and Web UI) with rich features including image upload, command execution, and multi-language support.",
"projectLinks": "Project Links",
"githubProject": "GitHub Project",
"visitGithub": "Visit GitHub",
"contact": "Contact & Support",
"discordSupport": "Discord Support",
"joinDiscord": "Join Discord",
"contactDescription": "For technical support, issue reports, or feature suggestions, feel free to contact us through Discord community or GitHub Issues.",
"thanks": "Thanks & Contributions",
"thanksText": "Special thanks to the original author Fábio Ferreira (@fabiomlferreira) for creating the original interactive-feedback-mcp project.\n\nThis enhanced version is developed and maintained by Minidoracat, who has significantly expanded the project with GUI interface, image support, multi-language capabilities, and many other improvements.\n\nAlso thanks to sanshao85's mcp-feedback-collector project for UI design inspiration.\n\nOpen source collaboration makes technology better!"
},
"feedback": {
"title": "Your Feedback",
"description": "Please describe your thoughts, suggestions, or modifications needed for the AI's work.",
"placeholder": "Please enter your feedback, suggestions, or questions here...\n\n💡 Tips:\n• Press Ctrl+Enter (numpad supported) to submit quickly\n• Press Ctrl+V to paste images from clipboard",
"emptyTitle": "Feedback Content Empty",
"emptyMessage": "Please enter feedback content before submitting. You can describe your thoughts, suggestions, or areas that need modification.",
"outputPlaceholder": "Command output will appear here..."
},
"summary": {
"title": "AI Work Summary",
"description": "Below is the work content that AI has just completed for you. Please review and provide feedback.",
"testDescription": "Below is the message content replied by AI. Please review and provide feedback."
},
"command": {
"title": "Command Execution",
"description": "You can execute commands to verify results or gather more information.",
"placeholder": "Enter command to execute...",
"output": "Command Output",
"outputPlaceholder": "Command output will appear here...",
"run": "▶️ Run",
"terminate": "⏹️ Stop"
},
"images": {
"title": "🖼️ Image Attachments (Optional)",
"select": "Select Files",
"paste": "Clipboard",
"clear": "Clear",
"status": "{count} images selected",
"statusWithSize": "{count} images selected (Total {size})",
"dragHint": "🎯 Drag images here or press Ctrl+V/Cmd+V to paste from clipboard (PNG, JPG, JPEG, GIF, BMP, WebP)",
"deleteConfirm": "Are you sure you want to remove image \"{filename}\"?",
"deleteTitle": "Confirm Delete",
"sizeWarning": "Image file size cannot exceed 1MB",
"formatError": "Unsupported image format",
"paste_images": "📋 Paste from Clipboard",
"paste_failed": "Paste failed, no image in clipboard",
"paste_no_image": "No image in clipboard to paste",
"paste_image_from_textarea": "Image intelligently pasted from text area to image area",
"images_clear": "Clear all images",
"settings": {
"title": "Image Settings",
"sizeLimit": "Image Size Limit",
"sizeLimitOptions": {
"unlimited": "Unlimited",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 Compatibility Mode",
"base64DetailHelp": "When enabled, includes complete Base64 image data in text to improve compatibility with AI models ",
"base64Warning": "⚠️ Increases transmission size",
"compatibilityHint": "💡 Images not recognized correctly?",
"enableBase64Hint": "Try enabling Base64 compatibility mode"
},
"sizeLimitExceeded": "Image {filename} size is {size}, exceeds {limit} limit!",
"sizeLimitExceededAdvice": "Please compress the image using image editing software, or adjust the image size limit setting."
},
"language": {
"settings": "Language Settings",
"selector": "🌐 Language Selection",
"description": "Choose your preferred interface language. Language changes take effect immediately."
},
"settings": {
"title": "Application Settings",
"language": {
"title": "Language Settings",
"selector": "🌐 Language Selection"
},
"layout": {
"title": "Interface Layout",
"separateMode": "Separate Mode",
"separateModeDescription": "AI summary and feedback are in separate tabs",
"combinedVertical": "Combined Mode (Vertical Layout)",
"combinedVerticalDescription": "AI summary on top, feedback input below, both on the same page",
"combinedHorizontal": "Combined Mode (Horizontal Layout)",
"combinedHorizontalDescription": "AI summary on left, feedback input on right, expanding summary viewing area"
},
"window": {
"title": "Window Positioning",
"alwaysCenter": "Always show window at primary screen center"
},
"reset": {
"title": "Reset Settings",
"button": "Reset Settings",
"confirmTitle": "Confirm Reset Settings",
"confirmMessage": "Are you sure you want to reset all settings? This will clear all saved preferences and restore to default state.",
"successTitle": "Reset Successful",
"successMessage": "All settings have been successfully reset to default values.",
"errorTitle": "Reset Failed",
"errorMessage": "Error occurred while resetting settings: {error}"
}
},
"timeout": {
"enable": "Auto Close",
"enableTooltip": "When enabled, the interface will automatically close after the specified time",
"duration": {
"label": "Timeout Duration",
"description": "Set the auto-close time (30 seconds - 2 hours)"
},
"seconds": "seconds",
"remaining": "Time Remaining",
"expired": "Time Expired",
"autoCloseMessage": "Interface will automatically close in {seconds} seconds",
"settings": {
"title": "Timeout Settings",
"description": "When enabled, the interface will automatically close after the specified time. The countdown timer will be displayed in the header area."
}
},
"buttons": {
"submit": "Submit Feedback",
"cancel": "Cancel",
"close": "Close",
"clear": "Clear",
"submitFeedback": "✅ Submit Feedback",
"selectFiles": "📁 Select Files",
"pasteClipboard": "📋 Clipboard",
"clearAll": "✕ Clear",
"runCommand": "▶️ Run"
},
"status": {
"feedbackSubmitted": "Feedback submitted successfully!",
"feedbackCancelled": "Feedback cancelled.",
"timeoutMessage": "Feedback timeout",
"errorOccurred": "Error occurred",
"loading": "Loading...",
"connecting": "Connecting...",
"connected": "Connected",
"disconnected": "Disconnected",
"uploading": "Uploading...",
"uploadSuccess": "Upload successful",
"uploadFailed": "Upload failed",
"commandRunning": "Command running...",
"commandFinished": "Command finished",
"pasteSuccess": "Image pasted from clipboard",
"pasteFailed": "Failed to get image from clipboard",
"invalidFileType": "Unsupported file type",
"fileTooLarge": "File too large (max 1MB)"
},
"errors": {
"title": "Error",
"warning": "Warning",
"info": "Information",
"interfaceReloadError": "Error occurred while reloading interface: {error}",
"imageSaveEmpty": "Saved image file is empty! Location: {path}",
"imageSaveFailed": "Image save failed!",
"clipboardSaveFailed": "Failed to save clipboard image!",
"noValidImage": "No valid image in clipboard!",
"noImageContent": "No image content in clipboard!",
"emptyFile": "Image {filename} is an empty file!",
"loadImageFailed": "Failed to load image {filename}:\n{error}",
"dragInvalidFiles": "Please drag valid image files!",
"confirmClearAll": "Are you sure you want to clear all {count} images?",
"confirmClearTitle": "Confirm Clear",
"fileSizeExceeded": "Image {filename} size is {size}MB, exceeding 1MB limit!\nRecommend using image editing software to compress before uploading.",
"dataSizeExceeded": "Image {filename} data size exceeds 1MB limit!"
},
"languageSelector": "🌐 Language",
"languageNames": {
"zhTw": "繁體中文",
"en": "English",
"zhCn": "简体中文"
},
"test": {
"qtGuiSummary": "🎯 Image Preview and Window Adjustment Test\n\nThis is a test session to verify the following features:\n\n✅ Test Items:\n1. Image upload and preview functionality\n2. Image X delete button in top-right corner\n3. Free window resizing\n4. Flexible splitter adjustment\n5. Dynamic layout of all areas\n6. Smart Ctrl+V image paste functionality\n\n📋 Test Steps:\n1. Try uploading some images (drag & drop, file selection, clipboard)\n2. Check if image preview displays correctly\n3. Click the X button in the top-right corner of images to delete them\n4. Try resizing the window, check if it can be freely adjusted\n5. Drag the splitter to adjust area sizes\n6. Press Ctrl+V in the text box to test smart paste functionality\n7. Provide any feedback or issues found\n\nPlease test these features and provide feedback!",
"webUiSummary": "Test Web UI Functionality\n\n🎯 **Test Items:**\n- Web UI server startup and operation\n- WebSocket real-time communication\n- Feedback submission functionality\n- Image upload and preview\n- Command execution functionality\n- Smart Ctrl+V image paste\n- Multi-language interface switching\n\n📋 **Test Steps:**\n1. Test image upload (drag & drop, file selection, clipboard)\n2. Press Ctrl+V in text box to test smart paste\n3. Try switching languages (Traditional Chinese/Simplified Chinese/English)\n4. Test command execution functionality\n5. Submit feedback and images\n\nPlease test these features and provide feedback!"
}
}

View File

@@ -0,0 +1,202 @@
{
"meta": {
"language": "zh-CN",
"displayName": "简体中文",
"author": "Minidoracat",
"version": "1.0.0",
"lastUpdate": "2025-01-31"
},
"app": {
"title": "交互式反馈收集",
"projectDirectory": "项目目录",
"language": "语言",
"settings": "设置",
"confirmCancel": "确认取消",
"confirmCancelMessage": "确定要取消反馈吗?所有输入的内容将会丢失。",
"layoutChangeTitle": "界面布局变更",
"layoutChangeMessage": "布局模式已变更,需要重新加载界面才能生效。\n是否现在重新加载"
},
"tabs": {
"summary": "📋 AI 摘要",
"feedback": "💬 反馈",
"command": "⚡ 命令",
"language": "⚙️ 设置",
"images": "🖼️ 图片",
"about": " 关于"
},
"feedback": {
"title": "您的反馈",
"description": "请描述您对 AI 工作结果的想法、建议或需要修改的地方。",
"placeholder": "请在这里输入您的反馈、建议或问题...\n\n💡 小提示:\n• 按 Ctrl+Enter支持数字键盘可快速提交反馈\n• 按 Ctrl+V 可直接粘贴剪贴板图片",
"emptyTitle": "反馈内容为空",
"emptyMessage": "请先输入反馈内容再提交。您可以描述想法、建议或需要修改的地方。"
},
"summary": {
"title": "AI 工作摘要",
"description": "以下是 AI 刚才为您完成的工作内容,请检视并提供反馈。",
"testDescription": "以下是 AI 回复的消息内容,请检视并提供反馈。"
},
"command": {
"title": "命令执行",
"description": "您可以执行命令来验证结果或收集更多信息。",
"placeholder": "输入要执行的命令...",
"output": "命令输出",
"outputPlaceholder": "命令输出将在这里显示...",
"run": "▶️ 执行",
"terminate": "⏹️ 停止"
},
"images": {
"title": "🖼️ 图片附件(可选)",
"select": "选择文件",
"paste": "剪贴板",
"clear": "清除",
"status": "已选择 {count} 张图片",
"statusWithSize": "已选择 {count} 张图片 (总计 {size})",
"dragHint": "🎯 拖拽图片到这里 或 按 Ctrl+V/Cmd+V 粘贴剪贴板图片 (PNG、JPG、JPEG、GIF、BMP、WebP)",
"deleteConfirm": "确定要移除图片 \"{filename}\" 吗?",
"deleteTitle": "确认删除",
"sizeWarning": "图片文件大小不能超过 1MB",
"formatError": "不支持的图片格式",
"paste_images": "📋 从剪贴板粘贴",
"paste_failed": "粘贴失败,剪贴板中没有图片",
"paste_no_image": "剪贴板中没有图片可粘贴",
"paste_image_from_textarea": "已将图片从文本框智能贴到图片区域",
"images_clear": "清除所有图片",
"settings": {
"title": "图片设置",
"sizeLimit": "图片大小限制",
"sizeLimitOptions": {
"unlimited": "无限制",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 兼容模式",
"base64DetailHelp": "启用后会在文本中包含完整的 Base64 图片数据,提升部分 AI 模型的兼容性",
"base64Warning": "⚠️ 会增加传输量",
"compatibilityHint": "💡 图片无法正确识别?",
"enableBase64Hint": "尝试启用 Base64 兼容模式"
},
"sizeLimitExceeded": "图片 {filename} 大小为 {size},超过 {limit} 限制!",
"sizeLimitExceededAdvice": "建议使用图片编辑软件压缩后再上传,或调整图片大小限制设置。"
},
"settings": {
"title": "应用设置",
"language": {
"title": "语言设置",
"selector": "🌐 语言选择"
},
"layout": {
"title": "界面布局",
"separateMode": "分离模式",
"separateModeDescription": "AI 摘要和反馈分别在不同页签",
"combinedVertical": "合并模式(垂直布局)",
"combinedVerticalDescription": "AI 摘要在上,反馈输入在下,摘要和反馈在同一页面",
"combinedHorizontal": "合并模式(水平布局)",
"combinedHorizontalDescription": "AI 摘要在左,反馈输入在右,增大摘要可视区域"
},
"window": {
"title": "窗口定位",
"alwaysCenter": "总是在主屏幕中心显示窗口"
},
"reset": {
"title": "重置设置",
"button": "重置设置",
"confirmTitle": "确认重置设置",
"confirmMessage": "确定要重置所有设置吗?这将清除所有已保存的偏好设置并恢复到默认状态。",
"successTitle": "重置成功",
"successMessage": "所有设置已成功重置为默认值。",
"errorTitle": "重置失败",
"errorMessage": "重置设置时发生错误:{error}"
}
},
"timeout": {
"enable": "自动关闭",
"enableTooltip": "启用后将在指定时间后自动关闭界面",
"duration": {
"label": "超时时间",
"description": "设置自动关闭的时间30秒 - 2小时"
},
"seconds": "秒",
"remaining": "剩余时间",
"expired": "时间已到",
"autoCloseMessage": "界面将在 {seconds} 秒后自动关闭",
"settings": {
"title": "超时设置",
"description": "启用后,界面将在指定时间后自动关闭。倒数计时器会显示在顶部区域。"
}
},
"buttons": {
"submit": "提交反馈",
"cancel": "取消",
"close": "关闭",
"clear": "清除",
"submitFeedback": "✅ 提交反馈",
"selectFiles": "📁 选择文件",
"pasteClipboard": "📋 剪贴板",
"clearAll": "✕ 清除",
"runCommand": "▶️ 执行"
},
"status": {
"feedbackSubmitted": "反馈已成功提交!",
"feedbackCancelled": "已取消反馈。",
"timeoutMessage": "等待反馈超时",
"errorOccurred": "发生错误",
"loading": "加载中...",
"connecting": "连接中...",
"connected": "已连接",
"disconnected": "连接中断",
"uploading": "上传中...",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败",
"commandRunning": "命令执行中...",
"commandFinished": "命令执行完成",
"pasteSuccess": "已从剪贴板粘贴图片",
"pasteFailed": "无法从剪贴板获取图片",
"invalidFileType": "不支持的文件类型",
"fileTooLarge": "文件过大(最大 1MB"
},
"errors": {
"title": "错误",
"warning": "警告",
"info": "提示",
"interfaceReloadError": "重新加载界面时发生错误: {error}",
"imageSaveEmpty": "保存的图片文件为空!位置: {path}",
"imageSaveFailed": "图片保存失败!",
"clipboardSaveFailed": "无法保存剪贴板图片!",
"noValidImage": "剪贴板中没有有效的图片!",
"noImageContent": "剪贴板中没有图片内容!",
"emptyFile": "图片 {filename} 是空文件!",
"loadImageFailed": "无法加载图片 {filename}:\n{error}",
"dragInvalidFiles": "请拖拽有效的图片文件!",
"confirmClearAll": "确定要清除所有 {count} 张图片吗?",
"confirmClearTitle": "确认清除",
"fileSizeExceeded": "图片 {filename} 大小为 {size}MB超过 1MB 限制!\n建议使用图片编辑软件压缩后再上传。",
"dataSizeExceeded": "图片 {filename} 数据大小超过 1MB 限制!"
},
"aiSummary": "AI 工作摘要",
"languageSelector": "🌐 语言选择",
"languageNames": {
"zhTw": "繁體中文",
"en": "English",
"zhCn": "简体中文"
},
"test": {
"qtGuiSummary": "🎯 图片预览和窗口调整测试\n\n这是一个测试会话用于验证以下功能\n\n✅ 功能测试项目:\n1. 图片上传和预览功能\n2. 图片右上角X删除按钮\n3. 窗口自由调整大小\n4. 分割器的灵活调整\n5. 各区域的动态布局\n6. 智能 Ctrl+V 图片粘贴功能\n\n📋 测试步骤:\n1. 尝试上传一些图片(拖拽、文件选择、剪贴板)\n2. 检查图片预览是否正常显示\n3. 点击图片右上角的X按钮删除图片\n4. 尝试调整窗口大小,检查是否可以自由调整\n5. 拖动分割器调整各区域大小\n6. 在文本框内按 Ctrl+V 测试智能粘贴功能\n7. 提供任何回馈或发现的问题\n\n请测试这些功能并提供回馈",
"webUiSummary": "测试 Web UI 功能\n\n🎯 **功能测试项目:**\n- Web UI 服务器启动和运行\n- WebSocket 即时通讯\n- 回馈提交功能\n- 图片上传和预览\n- 命令执行功能\n- 智能 Ctrl+V 图片粘贴\n- 多语言界面切换\n\n📋 **测试步骤:**\n1. 测试图片上传(拖拽、选择文件、剪贴板)\n2. 在文本框内按 Ctrl+V 测试智能粘贴\n3. 尝试切换语言(繁中/简中/英文)\n4. 测试命令执行功能\n5. 提交回馈和图片\n\n请测试这些功能并提供回馈"
},
"about": {
"appInfo": "应用程序信息",
"version": "版本",
"description": "一个强大的 MCP 服务器,为 AI 辅助开发工具提供人在回路的交互反馈功能。支持 Qt GUI 和 Web UI 双界面,并具备图片上传、命令执行、多语言等丰富功能。",
"projectLinks": "项目链接",
"githubProject": "GitHub 项目",
"visitGithub": "访问 GitHub",
"contact": "联系与支持",
"discordSupport": "Discord 支持",
"joinDiscord": "加入 Discord",
"contactDescription": "如需技术支持、问题反馈或功能建议,欢迎通过 Discord 社群或 GitHub Issues 与我们联系。",
"thanks": "致谢与贡献",
"thanksText": "感谢原作者 Fábio Ferreira (@fabiomlferreira) 创建了原始的 interactive-feedback-mcp 项目。\n\n本增强版本由 Minidoracat 开发和维护,大幅扩展了项目功能,新增了 GUI 界面、图片支持、多语言能力以及许多其他改进功能。\n\n同时感谢 sanshao85 的 mcp-feedback-collector 项目提供的 UI 设计灵感。\n\n开源协作让技术变得更美好"
}
}

View File

@@ -0,0 +1,202 @@
{
"meta": {
"language": "zh-TW",
"displayName": "繁體中文",
"author": "Minidoracat",
"version": "1.0.0",
"lastUpdate": "2025-01-31"
},
"app": {
"title": "互動式回饋收集",
"projectDirectory": "專案目錄",
"language": "語言",
"settings": "設定",
"confirmCancel": "確認取消",
"confirmCancelMessage": "確定要取消回饋嗎?所有輸入的內容將會遺失。",
"layoutChangeTitle": "界面佈局變更",
"layoutChangeMessage": "佈局模式已變更,需要重新載入界面才能生效。\n是否現在重新載入"
},
"tabs": {
"summary": "📋 AI 摘要",
"feedback": "💬 回饋",
"command": "⚡ 命令",
"language": "⚙️ 設置",
"images": "🖼️ 圖片",
"about": " 關於"
},
"about": {
"appInfo": "應用程式資訊",
"version": "版本",
"description": "一個強大的 MCP 伺服器,為 AI 輔助開發工具提供人在回路的互動回饋功能。支援 Qt GUI 和 Web UI 雙介面,並具備圖片上傳、命令執行、多語言等豐富功能。",
"projectLinks": "專案連結",
"githubProject": "GitHub 專案",
"visitGithub": "訪問 GitHub",
"contact": "聯繫與支援",
"discordSupport": "Discord 支援",
"joinDiscord": "加入 Discord",
"contactDescription": "如需技術支援、問題回報或功能建議,歡迎透過 Discord 社群或 GitHub Issues 與我們聯繫。",
"thanks": "致謝與貢獻",
"thanksText": "感謝原作者 Fábio Ferreira (@fabiomlferreira) 創建了原始的 interactive-feedback-mcp 專案。\n\n本增強版本由 Minidoracat 開發和維護,大幅擴展了專案功能,新增了 GUI 介面、圖片支援、多語言能力以及許多其他改進功能。\n\n同時感謝 sanshao85 的 mcp-feedback-collector 專案提供的 UI 設計靈感。\n\n開源協作讓技術變得更美好"
},
"feedback": {
"title": "您的回饋",
"description": "請描述您對 AI 工作結果的想法、建議或需要修改的地方。",
"placeholder": "請在這裡輸入您的回饋、建議或問題...\n\n💡 小提示:\n• 按 Ctrl+Enter支援數字鍵盤可快速提交回饋\n• 按 Ctrl+V 可直接貼上剪貼簿圖片",
"emptyTitle": "回饋內容為空",
"emptyMessage": "請先輸入回饋內容再提交。您可以描述想法、建議或需要修改的地方。",
"input": "您的回饋"
},
"summary": {
"title": "AI 工作摘要",
"description": "以下是 AI 剛才為您完成的工作內容,請檢視並提供回饋。",
"testDescription": "以下是 AI 回復的訊息內容,請檢視並提供回饋。"
},
"command": {
"title": "命令執行",
"description": "您可以執行命令來驗證結果或收集更多資訊。",
"input": "命令",
"placeholder": "輸入要執行的命令...",
"output": "命令輸出",
"outputPlaceholder": "命令輸出將顯示在這裡...",
"run": "▶️ 執行",
"terminate": "⏹️ 終止"
},
"images": {
"title": "🖼️ 圖片附件(可選)",
"select": "選擇文件",
"paste": "剪貼板",
"clear": "清除",
"status": "已選擇 {count} 張圖片",
"statusWithSize": "已選擇 {count} 張圖片 (總計 {size})",
"dragHint": "🎯 拖拽圖片到這裡 或 按 Ctrl+V/Cmd+V 貼上剪貼簿圖片 (PNG、JPG、JPEG、GIF、BMP、WebP)",
"deleteConfirm": "確定要移除圖片 \"{filename}\" 嗎?",
"deleteTitle": "確認刪除",
"sizeWarning": "圖片文件大小不能超過 1MB",
"formatError": "不支援的圖片格式",
"paste_images": "📋 從剪貼簿貼上",
"paste_failed": "貼上失敗,剪貼簿中沒有圖片",
"paste_no_image": "剪貼簿中沒有圖片可貼上",
"paste_image_from_textarea": "已將圖片從文字框智能貼到圖片區域",
"images_clear": "清除所有圖片",
"settings": {
"title": "圖片設定",
"sizeLimit": "圖片大小限制",
"sizeLimitOptions": {
"unlimited": "無限制",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 相容模式",
"base64DetailHelp": "啟用後會在文字中包含完整的 Base64 圖片資料,提升部分 AI 模型的相容性",
"base64Warning": "⚠️ 會增加傳輸量",
"compatibilityHint": "💡 圖片無法正確識別?",
"enableBase64Hint": "嘗試啟用 Base64 相容模式"
},
"sizeLimitExceeded": "圖片 {filename} 大小為 {size},超過 {limit} 限制!",
"sizeLimitExceededAdvice": "建議使用圖片編輯軟體壓縮後再上傳,或調整圖片大小限制設定。"
},
"timeout": {
"enable": "自動關閉",
"enableTooltip": "啟用後將在指定時間後自動關閉介面",
"duration": {
"label": "超時時間",
"description": "設置自動關閉的時間30秒 - 2小時"
},
"seconds": "秒",
"remaining": "剩餘時間",
"expired": "時間已到",
"autoCloseMessage": "介面將在 {seconds} 秒後自動關閉",
"settings": {
"title": "超時設置",
"description": "啟用後,介面將在指定時間後自動關閉。倒數計時器會顯示在頂部區域。"
}
},
"settings": {
"title": "應用設置",
"language": {
"title": "語言設置",
"selector": "🌐 語言選擇"
},
"layout": {
"title": "界面佈局",
"separateMode": "分離模式",
"separateModeDescription": "AI 摘要和回饋分別在不同頁籤",
"combinedVertical": "合併模式(垂直布局)",
"combinedVerticalDescription": "AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面",
"combinedHorizontal": "合併模式(水平布局)",
"combinedHorizontalDescription": "AI 摘要在左,回饋輸入在右,增大摘要可視區域"
},
"window": {
"title": "視窗定位",
"alwaysCenter": "總是在主螢幕中心顯示視窗"
},
"reset": {
"title": "重置設定",
"button": "重置設定",
"confirmTitle": "確認重置設定",
"confirmMessage": "確定要重置所有設定嗎?這將清除所有已保存的偏好設定並恢復到預設狀態。",
"successTitle": "重置成功",
"successMessage": "所有設定已成功重置為預設值。",
"errorTitle": "重置失敗",
"errorMessage": "重置設定時發生錯誤:{error}"
}
},
"buttons": {
"submit": "提交回饋",
"cancel": "取消",
"close": "關閉",
"clear": "清除",
"submitFeedback": "✅ 提交回饋",
"selectFiles": "📁 選擇文件",
"pasteClipboard": "📋 剪貼板",
"clearAll": "✕ 清除",
"runCommand": "▶️ 執行"
},
"status": {
"feedbackSubmitted": "回饋已成功提交!",
"feedbackCancelled": "已取消回饋。",
"timeoutMessage": "等待回饋超時",
"errorOccurred": "發生錯誤",
"loading": "載入中...",
"connecting": "連接中...",
"connected": "已連接",
"disconnected": "連接中斷",
"uploading": "上傳中...",
"uploadSuccess": "上傳成功",
"uploadFailed": "上傳失敗",
"commandRunning": "命令執行中...",
"commandFinished": "命令執行完成",
"pasteSuccess": "已從剪貼板貼上圖片",
"pasteFailed": "無法從剪貼板獲取圖片",
"invalidFileType": "不支援的文件類型",
"fileTooLarge": "文件過大(最大 1MB"
},
"errors": {
"title": "錯誤",
"warning": "警告",
"info": "提示",
"interfaceReloadError": "重新載入界面時發生錯誤: {error}",
"imageSaveEmpty": "保存的圖片文件為空!位置: {path}",
"imageSaveFailed": "圖片保存失敗!",
"clipboardSaveFailed": "無法保存剪貼板圖片!",
"noValidImage": "剪貼板中沒有有效的圖片!",
"noImageContent": "剪貼板中沒有圖片內容!",
"emptyFile": "圖片 {filename} 是空文件!",
"loadImageFailed": "無法載入圖片 {filename}:\n{error}",
"dragInvalidFiles": "請拖拽有效的圖片文件!",
"confirmClearAll": "確定要清除所有 {count} 張圖片嗎?",
"confirmClearTitle": "確認清除",
"fileSizeExceeded": "圖片 {filename} 大小為 {size}MB超過 1MB 限制!\n建議使用圖片編輯軟體壓縮後再上傳。",
"dataSizeExceeded": "圖片 {filename} 數據大小超過 1MB 限制!"
},
"languageNames": {
"zhTw": "繁體中文",
"en": "English",
"zhCn": "简体中文"
},
"test": {
"qtGuiSummary": "🎯 圖片預覽和視窗調整測試\n\n這是一個測試會話用於驗證以下功能\n\n✅ 功能測試項目:\n1. 圖片上傳和預覽功能\n2. 圖片右上角X刪除按鈕\n3. 視窗自由調整大小\n4. 分割器的靈活調整\n5. 各區域的動態佈局\n6. 智能 Ctrl+V 圖片貼上功能\n\n📋 測試步驟:\n1. 嘗試上傳一些圖片(拖拽、文件選擇、剪貼板)\n2. 檢查圖片預覽是否正常顯示\n3. 點擊圖片右上角的X按鈕刪除圖片\n4. 嘗試調整視窗大小,檢查是否可以自由調整\n5. 拖動分割器調整各區域大小\n6. 在文字框內按 Ctrl+V 測試智能貼上功能\n7. 提供任何回饋或發現的問題\n\n請測試這些功能並提供回饋",
"webUiSummary": "測試 Web UI 功能\n\n🎯 **功能測試項目:**\n- Web UI 服務器啟動和運行\n- WebSocket 即時通訊\n- 回饋提交功能\n- 圖片上傳和預覽\n- 命令執行功能\n- 智能 Ctrl+V 圖片貼上\n- 多語言介面切換\n\n📋 **測試步驟:**\n1. 測試圖片上傳(拖拽、選擇檔案、剪貼簿)\n2. 在文字框內按 Ctrl+V 測試智能貼上\n3. 嘗試切換語言(繁中/簡中/英文)\n4. 測試命令執行功能\n5. 提交回饋和圖片\n\n請測試這些功能並提供回饋"
}
}

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI 主要入口點
==============
提供 GUI 回饋介面的主要入口點函數。
"""
import threading
import time
from typing import Optional
from PySide6.QtWidgets import QApplication, QMainWindow
from PySide6.QtGui import QFont
from PySide6.QtCore import QTimer
import sys
from .models import FeedbackResult
from .window import FeedbackWindow
def feedback_ui(project_directory: str, summary: str) -> Optional[FeedbackResult]:
"""
啟動回饋收集 GUI 介面
Args:
project_directory: 專案目錄路徑
summary: AI 工作摘要
Returns:
Optional[FeedbackResult]: 回饋結果,如果用戶取消則返回 None
"""
# 檢查是否已有 QApplication 實例
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
# 設定全域微軟正黑體字體
font = QFont("Microsoft JhengHei", 11) # 微軟正黑體11pt
app.setFont(font)
# 設定字體回退順序,確保中文字體正確顯示
app.setStyleSheet("""
* {
font-family: "Microsoft JhengHei", "微軟正黑體", "Microsoft YaHei", "微软雅黑", "SimHei", "黑体", sans-serif;
}
""")
# 創建主窗口
window = FeedbackWindow(project_directory, summary)
window.show()
# 運行事件循環直到窗口關閉
app.exec()
# 返回結果
return window.result
def feedback_ui_with_timeout(project_directory: str, summary: str, timeout: int) -> Optional[FeedbackResult]:
"""
啟動帶超時的回饋收集 GUI 介面
Args:
project_directory: 專案目錄路徑
summary: AI 工作摘要
timeout: 超時時間(秒)- MCP 傳入的超時時間,作為最大限制
Returns:
Optional[FeedbackResult]: 回饋結果,如果用戶取消或超時則返回 None
Raises:
TimeoutError: 當超時時拋出
"""
# 檢查是否已有 QApplication 實例
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
# 設定全域微軟正黑體字體
font = QFont("Microsoft JhengHei", 11) # 微軟正黑體11pt
app.setFont(font)
# 設定字體回退順序,確保中文字體正確顯示
app.setStyleSheet("""
* {
font-family: "Microsoft JhengHei", "微軟正黑體", "Microsoft YaHei", "微软雅黑", "SimHei", "黑体", sans-serif;
}
""")
# 創建主窗口,傳入 MCP 超時時間
window = FeedbackWindow(project_directory, summary, timeout)
# 連接超時信號
timeout_occurred = False
def on_timeout():
nonlocal timeout_occurred
timeout_occurred = True
window.timeout_occurred.connect(on_timeout)
window.show()
# 開始用戶設置的超時倒數(如果啟用)
window.start_timeout_if_enabled()
# 創建 MCP 超時計時器作為後備
mcp_timeout_timer = QTimer()
mcp_timeout_timer.setSingleShot(True)
mcp_timeout_timer.timeout.connect(lambda: _handle_mcp_timeout(window, app))
mcp_timeout_timer.start(timeout * 1000) # 轉換為毫秒
# 運行事件循環直到窗口關閉
app.exec()
# 停止計時器(如果還在運行)
mcp_timeout_timer.stop()
window.stop_timeout()
# 檢查是否超時
if timeout_occurred:
raise TimeoutError(f"回饋收集超時GUI 介面已自動關閉")
elif hasattr(window, '_timeout_occurred'):
raise TimeoutError(f"回饋收集超時({timeout}GUI 介面已自動關閉")
# 返回結果
return window.result
def _handle_timeout(window: FeedbackWindow, app: QApplication) -> None:
"""處理超時事件(舊版本,保留向後兼容)"""
# 標記超時發生
window._timeout_occurred = True
# 強制關閉視窗
window.force_close()
# 退出應用程式
app.quit()
def _handle_mcp_timeout(window: FeedbackWindow, app: QApplication) -> None:
"""處理 MCP 超時事件(後備機制)"""
# 標記超時發生
window._timeout_occurred = True
# 強制關閉視窗
window.force_close()
# 退出應用程式
app.quit()

View File

@@ -0,0 +1,10 @@
"""
GUI 資料模型模組
===============
定義 GUI 相關的資料結構和型別。
"""
from .feedback import FeedbackResult
__all__ = ['FeedbackResult']

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回饋結果資料模型
===============
定義回饋收集的資料結構。
"""
from typing import TypedDict, List
class FeedbackResult(TypedDict):
"""回饋結果的型別定義"""
command_logs: str
interactive_feedback: str
images: List[dict]

View File

@@ -0,0 +1,17 @@
"""
GUI 樣式模組
============
集中管理 GUI 的樣式定義。
"""
from .themes import *
__all__ = [
'BUTTON_BASE_STYLE',
'PRIMARY_BUTTON_STYLE',
'SUCCESS_BUTTON_STYLE',
'DANGER_BUTTON_STYLE',
'SECONDARY_BUTTON_STYLE',
'DARK_STYLE'
]

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI 主題樣式定義
===============
集中定義所有 GUI 元件的樣式。
"""
# 統一按鈕樣式常量
BUTTON_BASE_STYLE = """
QPushButton {
color: white;
border: none;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
opacity: 0.8;
}
"""
PRIMARY_BUTTON_STYLE = BUTTON_BASE_STYLE + """
QPushButton {
background-color: #0e639c;
}
QPushButton:hover {
background-color: #005a9e;
}
"""
SUCCESS_BUTTON_STYLE = BUTTON_BASE_STYLE + """
QPushButton {
background-color: #4caf50;
}
QPushButton:hover {
background-color: #45a049;
}
"""
DANGER_BUTTON_STYLE = BUTTON_BASE_STYLE + """
QPushButton {
background-color: #f44336;
color: #ffffff;
}
QPushButton:hover {
background-color: #d32f2f;
color: #ffffff;
}
"""
SECONDARY_BUTTON_STYLE = BUTTON_BASE_STYLE + """
QPushButton {
background-color: #666666;
}
QPushButton:hover {
background-color: #555555;
}
"""
# Dark 主題樣式
DARK_STYLE = """
QMainWindow {
background-color: #1e1e1e;
color: #d4d4d4;
}
QWidget {
background-color: #1e1e1e;
color: #d4d4d4;
}
QLabel {
color: #d4d4d4;
}
QLineEdit {
background-color: #333333;
border: 1px solid #464647;
padding: 8px;
border-radius: 6px;
color: #d4d4d4;
font-size: 14px;
}
QLineEdit:focus {
border-color: #007acc;
background-color: #383838;
}
QTextEdit {
background-color: #333333;
border: 1px solid #464647;
padding: 8px;
border-radius: 6px;
color: #d4d4d4;
font-size: 14px;
line-height: 1.5;
}
QTextEdit:focus {
border-color: #007acc;
background-color: #383838;
}
QGroupBox {
font-weight: bold;
border: 2px solid #464647;
border-radius: 6px;
margin-top: 6px;
padding-top: 10px;
background-color: #2d2d30;
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top center;
padding: 0 8px;
background-color: #2d2d30;
color: #007acc;
}
QTabWidget::pane {
border: 1px solid #464647;
background-color: #2d2d30;
border-radius: 6px;
}
QTabBar::tab {
background-color: #3c3c3c;
color: #d4d4d4;
padding: 8px 12px;
margin-right: 2px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
QTabBar::tab:selected {
background-color: #007acc;
color: white;
}
QTabBar::tab:hover {
background-color: #4a4a4a;
}
QComboBox {
background-color: #333333;
border: 1px solid #464647;
padding: 6px 8px;
border-radius: 6px;
color: #d4d4d4;
font-size: 14px;
min-width: 120px;
}
QComboBox:focus {
border-color: #007acc;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox::down-arrow {
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #d4d4d4;
margin-top: 2px;
}
QComboBox QAbstractItemView {
background-color: #333333;
border: 1px solid #464647;
color: #d4d4d4;
selection-background-color: #007acc;
}
QScrollBar:vertical {
background-color: #333333;
width: 12px;
border: none;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555555;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #777777;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
border: none;
background: none;
}
QScrollBar:horizontal {
background-color: #333333;
height: 12px;
border: none;
border-radius: 6px;
}
QScrollBar::handle:horizontal {
background-color: #555555;
border-radius: 6px;
min-width: 20px;
}
QScrollBar::handle:horizontal:hover {
background-color: #777777;
}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
border: none;
background: none;
}
QMenuBar {
background-color: #2d2d30;
color: #d4d4d4;
border-bottom: 1px solid #464647;
}
QMenuBar::item {
background-color: transparent;
padding: 4px 8px;
}
QMenuBar::item:selected {
background-color: #007acc;
}
QMenu {
background-color: #2d2d30;
color: #d4d4d4;
border: 1px solid #464647;
}
QMenu::item {
padding: 6px 12px;
}
QMenu::item:selected {
background-color: #007acc;
}
QSplitter::handle {
background-color: #464647;
}
QSplitter::handle:horizontal {
width: 3px;
}
QSplitter::handle:vertical {
height: 3px;
}
/* 訊息框樣式 */
QMessageBox {
background-color: #2d2d30;
color: #d4d4d4;
}
QMessageBox QPushButton {
min-width: 60px;
padding: 6px 12px;
}
"""

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分頁組件
========
包含各種專用分頁組件的實現。
"""
from .feedback_tab import FeedbackTab
from .summary_tab import SummaryTab
from .command_tab import CommandTab
from .settings_tab import SettingsTab
from .about_tab import AboutTab
__all__ = [
'FeedbackTab',
'SummaryTab',
'CommandTab',
'SettingsTab',
'AboutTab'
]

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
關於分頁組件
============
顯示應用程式資訊和聯繫方式的分頁組件。
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QGroupBox, QPushButton, QTextEdit, QScrollArea
)
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QFont, QDesktopServices
from ...i18n import t
from ... import __version__
class AboutTab(QWidget):
"""關於分頁組件"""
def __init__(self, parent=None):
super().__init__(parent)
self._setup_ui()
def _setup_ui(self) -> None:
"""設置用戶介面"""
# 主布局
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
# 創建滾動區域
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("""
QScrollArea {
border: none;
background: transparent;
}
QScrollBar:vertical {
background-color: #2d2d30;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #464647;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #555555;
}
""")
# 創建內容容器
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setSpacing(16)
content_layout.setContentsMargins(16, 16, 16, 16)
# === 主要資訊區域(合併應用程式資訊、專案連結、聯繫與支援) ===
self.main_info_group = QGroupBox(t('about.appInfo'))
self.main_info_group.setObjectName('main_info_group')
main_info_layout = QVBoxLayout(self.main_info_group)
main_info_layout.setSpacing(16)
main_info_layout.setContentsMargins(20, 20, 20, 20)
# 應用程式標題和版本
title_layout = QHBoxLayout()
self.app_title_label = QLabel("MCP Feedback Enhanced")
self.app_title_label.setStyleSheet("font-size: 20px; font-weight: bold; color: #e0e0e0;")
title_layout.addWidget(self.app_title_label)
title_layout.addStretch()
self.version_label = QLabel(f"v{__version__}")
self.version_label.setStyleSheet("font-size: 16px; color: #007acc; font-weight: bold;")
title_layout.addWidget(self.version_label)
main_info_layout.addLayout(title_layout)
# 應用程式描述
self.app_description = QLabel(t('about.description'))
self.app_description.setStyleSheet("color: #9e9e9e; font-size: 13px; line-height: 1.4; margin-bottom: 16px;")
self.app_description.setWordWrap(True)
main_info_layout.addWidget(self.app_description)
# 分隔線
separator1 = QLabel()
separator1.setFixedHeight(1)
separator1.setStyleSheet("background-color: #464647; margin: 8px 0;")
main_info_layout.addWidget(separator1)
# GitHub 專案區域
github_layout = QHBoxLayout()
self.github_label = QLabel("📂 " + t('about.githubProject'))
self.github_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 14px;")
github_layout.addWidget(self.github_label)
github_layout.addStretch()
self.github_button = QPushButton(t('about.visitGithub'))
self.github_button.setFixedSize(120, 32)
self.github_button.setStyleSheet("""
QPushButton {
background-color: #0078d4;
border: none;
border-radius: 4px;
color: white;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #106ebe;
}
QPushButton:pressed {
background-color: #005a9e;
}
""")
self.github_button.clicked.connect(self._open_github)
github_layout.addWidget(self.github_button)
main_info_layout.addLayout(github_layout)
# GitHub URL
self.github_url_label = QLabel("https://github.com/Minidoracat/mcp-feedback-enhanced")
self.github_url_label.setStyleSheet("color: #9e9e9e; font-size: 11px; margin-left: 24px; margin-bottom: 12px;")
main_info_layout.addWidget(self.github_url_label)
# 分隔線
separator2 = QLabel()
separator2.setFixedHeight(1)
separator2.setStyleSheet("background-color: #464647; margin: 8px 0;")
main_info_layout.addWidget(separator2)
# Discord 支援區域
discord_layout = QHBoxLayout()
self.discord_label = QLabel("💬 " + t('about.discordSupport'))
self.discord_label.setStyleSheet("font-weight: bold; color: #e0e0e0; font-size: 14px;")
discord_layout.addWidget(self.discord_label)
discord_layout.addStretch()
self.discord_button = QPushButton(t('about.joinDiscord'))
self.discord_button.setFixedSize(120, 32)
self.discord_button.setStyleSheet("""
QPushButton {
background-color: #5865F2;
border: none;
border-radius: 4px;
color: white;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #4752C4;
}
QPushButton:pressed {
background-color: #3C45A5;
}
""")
self.discord_button.clicked.connect(self._open_discord)
discord_layout.addWidget(self.discord_button)
main_info_layout.addLayout(discord_layout)
# Discord URL 和說明
self.discord_url_label = QLabel("https://discord.gg/ACjf9Q58")
self.discord_url_label.setStyleSheet("color: #9e9e9e; font-size: 11px; margin-left: 24px;")
main_info_layout.addWidget(self.discord_url_label)
self.contact_description = QLabel(t('about.contactDescription'))
self.contact_description.setStyleSheet("color: #9e9e9e; font-size: 12px; margin-left: 24px; margin-top: 8px;")
self.contact_description.setWordWrap(True)
main_info_layout.addWidget(self.contact_description)
content_layout.addWidget(self.main_info_group)
# === 致謝區域 ===
self.thanks_group = QGroupBox(t('about.thanks'))
self.thanks_group.setObjectName('thanks_group')
thanks_layout = QVBoxLayout(self.thanks_group)
thanks_layout.setSpacing(12)
thanks_layout.setContentsMargins(20, 20, 20, 20)
# 致謝文字
self.thanks_text = QTextEdit()
self.thanks_text.setReadOnly(True)
self.thanks_text.setMinimumHeight(160)
self.thanks_text.setMaximumHeight(220)
self.thanks_text.setStyleSheet("""
QTextEdit {
background-color: #2d2d30;
border: 1px solid #464647;
border-radius: 4px;
padding: 12px;
color: #e0e0e0;
font-size: 12px;
line-height: 1.4;
}
QScrollBar:vertical {
background-color: #2d2d30;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #464647;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #555555;
}
""")
self.thanks_text.setPlainText(t('about.thanksText'))
thanks_layout.addWidget(self.thanks_text)
content_layout.addWidget(self.thanks_group)
# 添加彈性空間
content_layout.addStretch()
# 設置滾動區域的內容
scroll_area.setWidget(content_widget)
main_layout.addWidget(scroll_area)
def _open_github(self) -> None:
"""開啟 GitHub 專案連結"""
QDesktopServices.openUrl(QUrl("https://github.com/Minidoracat/mcp-feedback-enhanced"))
def _open_discord(self) -> None:
"""開啟 Discord 邀請連結"""
QDesktopServices.openUrl(QUrl("https://discord.gg/ACjf9Q58"))
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
# 更新GroupBox標題
self.main_info_group.setTitle(t('about.appInfo'))
self.thanks_group.setTitle(t('about.thanks'))
# 更新版本資訊
self.version_label.setText(f"v{__version__}")
# 更新描述文字
self.app_description.setText(t('about.description'))
self.contact_description.setText(t('about.contactDescription'))
# 更新標籤文字
self.github_label.setText("📂 " + t('about.githubProject'))
self.discord_label.setText("💬 " + t('about.discordSupport'))
# 更新按鈕文字
self.github_button.setText(t('about.visitGithub'))
self.discord_button.setText(t('about.joinDiscord'))
# 更新致謝文字
self.thanks_text.setPlainText(t('about.thanksText'))

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
命令分頁組件
============
專門處理命令執行的分頁組件。
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QTextEdit, QLineEdit, QPushButton
)
from PySide6.QtCore import Signal
from PySide6.QtGui import QFont
from ..utils import apply_widget_styles
from ..window.command_executor import CommandExecutor
from ...i18n import t
class CommandTab(QWidget):
"""命令分頁組件"""
def __init__(self, project_dir: str, parent=None):
super().__init__(parent)
self.project_dir = project_dir
self.command_executor = CommandExecutor(project_dir, self)
self._setup_ui()
self._connect_signals()
def _setup_ui(self) -> None:
"""設置用戶介面"""
command_layout = QVBoxLayout(self)
command_layout.setSpacing(0) # 緊湊佈局
command_layout.setContentsMargins(0, 0, 0, 0)
# 命令說明區域(頂部,只保留說明文字)
header_widget = QWidget()
header_layout = QVBoxLayout(header_widget)
header_layout.setSpacing(6)
header_layout.setContentsMargins(12, 8, 12, 8)
self.command_description_label = QLabel(t('command.description'))
self.command_description_label.setStyleSheet("color: #9e9e9e; font-size: 11px; margin-bottom: 6px;")
self.command_description_label.setWordWrap(True)
header_layout.addWidget(self.command_description_label)
command_layout.addWidget(header_widget)
# 命令輸出區域(中間,佔大部分空間)
output_widget = QWidget()
output_layout = QVBoxLayout(output_widget)
output_layout.setSpacing(6)
output_layout.setContentsMargins(12, 4, 12, 8)
self.command_output = QTextEdit()
self.command_output.setReadOnly(True)
self.command_output.setFont(QFont("Consolas", 11))
self.command_output.setPlaceholderText(t('command.outputPlaceholder'))
# 終端機風格樣式
self.command_output.setStyleSheet("""
QTextEdit {
background-color: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
color: #00ff00;
line-height: 1.4;
}
QScrollBar:vertical {
background-color: #2a2a2a;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555;
border-radius: 6px;
min-height: 20px;
}
QScrollBar::handle:vertical:hover {
background-color: #666;
}
""")
output_layout.addWidget(self.command_output, 1) # 佔據剩餘空間
command_layout.addWidget(output_widget, 1) # 輸出區域佔大部分空間
# 命令輸入區域(底部,固定高度)
input_widget = QWidget()
input_widget.setFixedHeight(70) # 固定高度
input_layout = QVBoxLayout(input_widget)
input_layout.setSpacing(6)
input_layout.setContentsMargins(12, 8, 12, 12)
# 命令輸入和執行按鈕(水平布局)
input_row_layout = QHBoxLayout()
input_row_layout.setSpacing(8)
# 提示符號標籤
prompt_label = QLabel("$")
prompt_label.setStyleSheet("color: #00ff00; font-family: 'Consolas', 'Monaco', monospace; font-size: 14px; font-weight: bold;")
prompt_label.setFixedWidth(20)
input_row_layout.addWidget(prompt_label)
self.command_input = QLineEdit()
self.command_input.setPlaceholderText(t('command.placeholder'))
self.command_input.setMinimumHeight(36)
# 終端機風格輸入框
self.command_input.setStyleSheet("""
QLineEdit {
background-color: #1a1a1a;
border: 2px solid #333;
border-radius: 4px;
padding: 8px 12px;
color: #00ff00;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
}
QLineEdit:focus {
border-color: #007acc;
background-color: #1e1e1e;
}
""")
self.command_input.returnPressed.connect(self._run_command)
input_row_layout.addWidget(self.command_input, 1) # 佔據大部分空間
self.command_run_button = QPushButton(t('command.run'))
self.command_run_button.clicked.connect(self._run_command)
self.command_run_button.setFixedSize(80, 36)
apply_widget_styles(self.command_run_button, "primary_button")
input_row_layout.addWidget(self.command_run_button)
self.command_terminate_button = QPushButton(t('command.terminate'))
self.command_terminate_button.clicked.connect(self._terminate_command)
self.command_terminate_button.setFixedSize(80, 36)
apply_widget_styles(self.command_terminate_button, "danger_button")
input_row_layout.addWidget(self.command_terminate_button)
input_layout.addLayout(input_row_layout)
command_layout.addWidget(input_widget) # 輸入區域在底部
def _connect_signals(self) -> None:
"""連接信號"""
self.command_executor.output_received.connect(self._append_command_output)
def _run_command(self) -> None:
"""執行命令"""
command = self.command_input.text().strip()
if command:
self.command_executor.run_command(command)
self.command_input.clear()
def _terminate_command(self) -> None:
"""終止命令"""
self.command_executor.terminate_command()
def _append_command_output(self, text: str) -> None:
"""添加命令輸出並自動滾動到底部"""
# 移動光標到最後
cursor = self.command_output.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
self.command_output.setTextCursor(cursor)
# 插入文本
self.command_output.insertPlainText(text)
# 確保滾動到最底部
scrollbar = self.command_output.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
# 刷新界面
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
def get_command_logs(self) -> str:
"""獲取命令日誌"""
return self.command_output.toPlainText().strip()
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
self.command_description_label.setText(t('command.description'))
self.command_input.setPlaceholderText(t('command.placeholder'))
self.command_output.setPlaceholderText(t('command.outputPlaceholder'))
self.command_run_button.setText(t('command.run'))
self.command_terminate_button.setText(t('command.terminate'))
def cleanup(self) -> None:
"""清理資源"""
if self.command_executor:
self.command_executor.cleanup()

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回饋分頁組件
============
專門處理用戶回饋輸入的分頁組件。
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QSplitter, QSizePolicy
from PySide6.QtCore import Qt, Signal
from ..widgets import SmartTextEdit, ImageUploadWidget
from ...i18n import t
from ..window.config_manager import ConfigManager
class FeedbackTab(QWidget):
"""回饋分頁組件"""
def __init__(self, parent=None):
super().__init__(parent)
self.config_manager = ConfigManager()
self._setup_ui()
def _setup_ui(self) -> None:
"""設置用戶介面"""
# 主布局
tab_layout = QVBoxLayout(self)
tab_layout.setSpacing(12)
tab_layout.setContentsMargins(0, 0, 0, 0) # 設置邊距為0與合併分頁保持一致
# 說明文字容器
description_wrapper = QWidget()
description_layout = QVBoxLayout(description_wrapper)
description_layout.setContentsMargins(16, 16, 16, 10) # 只對說明文字設置邊距
description_layout.setSpacing(0)
# 說明文字
self.feedback_description = QLabel(t('feedback.description'))
self.feedback_description.setStyleSheet("color: #9e9e9e; font-size: 12px;")
self.feedback_description.setWordWrap(True)
description_layout.addWidget(self.feedback_description)
tab_layout.addWidget(description_wrapper)
# 使用分割器來管理回饋輸入和圖片區域
splitter_wrapper = QWidget() # 創建包裝容器
splitter_wrapper_layout = QVBoxLayout(splitter_wrapper)
splitter_wrapper_layout.setContentsMargins(16, 0, 16, 16) # 設置左右邊距
splitter_wrapper_layout.setSpacing(0)
feedback_splitter = QSplitter(Qt.Vertical)
feedback_splitter.setChildrenCollapsible(False)
feedback_splitter.setHandleWidth(6)
feedback_splitter.setContentsMargins(0, 0, 0, 0) # 設置分割器邊距為0
feedback_splitter.setStyleSheet("""
QSplitter {
border: none;
background: transparent;
}
QSplitter::handle:vertical {
height: 8px;
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 4px;
margin-left: 0px;
margin-right: 0px;
margin-top: 2px;
margin-bottom: 2px;
}
QSplitter::handle:vertical:hover {
background-color: #606060;
border-color: #808080;
}
QSplitter::handle:vertical:pressed {
background-color: #007acc;
border-color: #005a9e;
}
""")
# 創建圖片上傳區域(需要先創建以便連接信號)
image_upload_widget = QWidget()
image_upload_widget.setMinimumHeight(200) # 進一步增加最小高度
image_upload_widget.setMaximumHeight(320) # 增加最大高度
image_upload_layout = QVBoxLayout(image_upload_widget)
image_upload_layout.setSpacing(8)
image_upload_layout.setContentsMargins(0, 8, 0, 0) # 與回饋輸入區域保持一致的邊距
self.image_upload = ImageUploadWidget(config_manager=self.config_manager)
image_upload_layout.addWidget(self.image_upload, 1)
# 回饋輸入區域
self.feedback_input = SmartTextEdit()
placeholder_text = t('feedback.placeholder').replace("Ctrl+Enter", "Ctrl+Enter/Cmd+Enter").replace("Ctrl+V", "Ctrl+V/Cmd+V")
self.feedback_input.setPlaceholderText(placeholder_text)
self.feedback_input.setMinimumHeight(120)
self.feedback_input.setStyleSheet("""
QTextEdit {
background-color: #2d2d30;
border: 1px solid #464647;
border-radius: 4px;
padding: 10px;
color: #ffffff;
font-size: 12px;
line-height: 1.4;
}
""")
# 直接連接文字輸入框的圖片貼上信號到圖片上傳組件
self.feedback_input.image_paste_requested.connect(self.image_upload.paste_from_clipboard)
# 添加到分割器
feedback_splitter.addWidget(self.feedback_input)
feedback_splitter.addWidget(image_upload_widget)
# 調整分割器比例和設置(確保圖片區域始終可見)
feedback_splitter.setStretchFactor(0, 2) # 回饋輸入區域
feedback_splitter.setStretchFactor(1, 1) # 圖片上傳區域
# 從配置載入分割器位置,如果沒有則使用預設值
saved_sizes = self.config_manager.get_splitter_sizes('feedback_splitter')
if saved_sizes and len(saved_sizes) == 2:
feedback_splitter.setSizes(saved_sizes)
else:
feedback_splitter.setSizes([220, 200]) # 預設大小
# 連接分割器位置變化信號,自動保存位置
feedback_splitter.splitterMoved.connect(
lambda pos, index: self._save_feedback_splitter_position(feedback_splitter)
)
# 設置分割器的最小尺寸和處理策略
feedback_splitter.setMinimumHeight(200) # 降低分割器最小高度,支持小窗口
feedback_splitter.setMaximumHeight(2000) # 允許更大的高度以觸發滾動
# 確保子控件的最小尺寸(防止過度壓縮)
self.feedback_input.setMinimumHeight(80) # 降低文字輸入最小高度
image_upload_widget.setMinimumHeight(100) # 降低圖片區域最小高度
splitter_wrapper_layout.addWidget(feedback_splitter)
tab_layout.addWidget(splitter_wrapper, 1)
# 設置分頁的大小策略,確保能夠觸發父容器的滾動條
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
self.setMinimumHeight(200) # 降低回饋分頁最小高度 # 設置最小高度
def get_feedback_text(self) -> str:
"""獲取回饋文字"""
return self.feedback_input.toPlainText().strip()
def get_images_data(self) -> list:
"""獲取圖片數據"""
return self.image_upload.get_images_data()
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
self.feedback_description.setText(t('feedback.description'))
placeholder_text = t('feedback.placeholder').replace("Ctrl+Enter", "Ctrl+Enter/Cmd+Enter").replace("Ctrl+V", "Ctrl+V/Cmd+V")
self.feedback_input.setPlaceholderText(placeholder_text)
if hasattr(self, 'image_upload'):
self.image_upload.update_texts()
def _save_feedback_splitter_position(self, splitter: QSplitter) -> None:
"""保存分割器的位置"""
sizes = splitter.sizes()
self.config_manager.set_splitter_sizes('feedback_splitter', sizes)

View File

@@ -0,0 +1,623 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
設置分頁組件
============
專門處理應用設置的分頁組件。
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QRadioButton, QButtonGroup, QMessageBox,
QCheckBox, QPushButton, QFrame, QSpinBox
)
from ..widgets import SwitchWithLabel
from ..widgets.styled_spinbox import StyledSpinBox
from PySide6.QtCore import Signal, Qt
from PySide6.QtGui import QFont
from ...i18n import t, get_i18n_manager
from ...debug import gui_debug_log as debug_log
class SettingsTab(QWidget):
"""設置分頁組件"""
language_changed = Signal()
layout_change_requested = Signal(bool, str) # 佈局變更請求信號 (combined_mode, orientation)
reset_requested = Signal() # 重置設定請求信號
timeout_settings_changed = Signal(bool, int) # 超時設置變更信號 (enabled, duration)
def __init__(self, combined_mode: bool, config_manager, parent=None):
super().__init__(parent)
self.combined_mode = combined_mode
self.config_manager = config_manager
self.layout_orientation = self.config_manager.get_layout_orientation()
self.i18n = get_i18n_manager()
# 保存需要更新的UI元素引用
self.ui_elements = {}
# 設置全域字體為微軟正黑體
self._setup_font()
self._setup_ui()
# 在UI設置完成後確保正確設置初始狀態
self._set_initial_layout_state()
def _setup_font(self) -> None:
"""設置全域字體"""
font = QFont("Microsoft JhengHei", 9) # 微軟正黑體,調整為 9pt
self.setFont(font)
# 設置整個控件的樣式表,確保中文字體正確
self.setStyleSheet("""
QWidget {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
}
""")
def _setup_ui(self) -> None:
"""設置用戶介面"""
# 主容器
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# 左側內容區域
content_widget = QWidget()
content_widget.setMaximumWidth(600)
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(20, 20, 20, 20)
content_layout.setSpacing(16)
# === 語言設置 ===
self._create_language_section(content_layout)
# 添加分隔線
self._add_separator(content_layout)
# === 界面佈局 ===
self._create_layout_section(content_layout)
# 添加分隔線
self._add_separator(content_layout)
# === 視窗設置 ===
self._create_window_section(content_layout)
# 添加分隔線
self._add_separator(content_layout)
# === 超時設置 ===
self._create_timeout_section(content_layout)
# 添加分隔線
self._add_separator(content_layout)
# === 重置設定 ===
self._create_reset_section(content_layout)
# 添加彈性空間
content_layout.addStretch()
# 添加到主布局
main_layout.addWidget(content_widget)
main_layout.addStretch() # 右側彈性空間
# 設定初始狀態
self._set_initial_layout_state()
def _add_separator(self, layout: QVBoxLayout) -> None:
"""添加分隔線"""
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet("""
QFrame {
color: #444444;
background-color: #444444;
border: none;
height: 1px;
margin: 6px 0px;
}
""")
layout.addWidget(separator)
def _create_section_header(self, title: str, emoji: str = "") -> QLabel:
"""創建區塊標題"""
text = f"{emoji} {title}" if emoji else title
label = QLabel(text)
label.setStyleSheet("""
QLabel {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
font-size: 16px;
font-weight: bold;
color: #ffffff;
margin-bottom: 6px;
margin-top: 2px;
}
""")
return label
def _create_description(self, text: str) -> QLabel:
"""創建說明文字"""
label = QLabel(text)
label.setStyleSheet("""
QLabel {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
color: #aaaaaa;
font-size: 12px;
margin-bottom: 12px;
line-height: 1.3;
}
""")
label.setWordWrap(True)
return label
def _create_language_section(self, layout: QVBoxLayout) -> None:
"""創建語言設置區域"""
header = self._create_section_header(t('settings.language.title'), "🌐")
layout.addWidget(header)
# 保存引用以便更新
self.ui_elements['language_header'] = header
# 語言選擇器容器
lang_container = QHBoxLayout()
lang_container.setContentsMargins(0, 0, 0, 0)
self.language_selector = QComboBox()
self.language_selector.setMinimumHeight(28)
self.language_selector.setMaximumWidth(140)
self.language_selector.setStyleSheet("""
QComboBox {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
background-color: #3a3a3a;
border: 1px solid #555555;
border-radius: 4px;
padding: 4px 8px;
color: #ffffff;
font-size: 12px;
}
QComboBox:hover {
border-color: #0078d4;
}
QComboBox::drop-down {
border: none;
width: 20px;
}
QComboBox::down-arrow {
image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iMTIiIHZpZXdCb3g9IjAgMCAxMiAxMiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTMgNEw2IDdMOSA0IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEuNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPg==);
width: 12px;
height: 12px;
}
QComboBox QAbstractItemView {
selection-background-color: #0078d4;
color: #ffffff;
font-size: 12px;
min-width: 120px;
}
""")
# 填充語言選項
self._populate_language_selector()
self.language_selector.currentIndexChanged.connect(self._on_language_changed)
lang_container.addWidget(self.language_selector)
lang_container.addStretch()
layout.addLayout(lang_container)
def _create_layout_section(self, layout: QVBoxLayout) -> None:
"""創建界面佈局區域"""
header = self._create_section_header(t('settings.layout.title'), "📐")
layout.addWidget(header)
# 保存引用以便更新
self.ui_elements['layout_header'] = header
# 選項容器
options_layout = QVBoxLayout()
options_layout.setSpacing(2)
# 創建按鈕組
self.layout_button_group = QButtonGroup()
# 分離模式
self.separate_mode_radio = QRadioButton(t('settings.layout.separateMode'))
self.separate_mode_radio.setStyleSheet("""
QRadioButton {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
font-size: 13px;
color: #ffffff;
spacing: 8px;
padding: 2px 0px;
}
QRadioButton::indicator {
width: 16px;
height: 16px;
}
QRadioButton::indicator:unchecked {
border: 2px solid #666666;
border-radius: 9px;
background-color: transparent;
}
QRadioButton::indicator:checked {
border: 2px solid #0078d4;
border-radius: 9px;
background-color: #0078d4;
image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI4IiB2aWV3Qm94PSIwIDAgOCA4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8Y2lyY2xlIGN4PSI0IiBjeT0iNCIgcj0iMiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+);
}
QRadioButton::indicator:hover {
border-color: #0078d4;
}
""")
self.layout_button_group.addButton(self.separate_mode_radio, 0)
options_layout.addWidget(self.separate_mode_radio)
separate_hint = QLabel(f" {t('settings.layout.separateModeDescription')}")
separate_hint.setStyleSheet("""
QLabel {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
color: #888888;
font-size: 11px;
margin-left: 20px;
margin-bottom: 4px;
}
""")
options_layout.addWidget(separate_hint)
# 保存引用以便更新
self.ui_elements['separate_hint'] = separate_hint
# 合併模式(垂直)
self.combined_vertical_radio = QRadioButton(t('settings.layout.combinedVertical'))
self.combined_vertical_radio.setStyleSheet(self.separate_mode_radio.styleSheet())
self.layout_button_group.addButton(self.combined_vertical_radio, 1)
options_layout.addWidget(self.combined_vertical_radio)
vertical_hint = QLabel(f" {t('settings.layout.combinedVerticalDescription')}")
vertical_hint.setStyleSheet(separate_hint.styleSheet())
options_layout.addWidget(vertical_hint)
# 保存引用以便更新
self.ui_elements['vertical_hint'] = vertical_hint
# 合併模式(水平)
self.combined_horizontal_radio = QRadioButton(t('settings.layout.combinedHorizontal'))
self.combined_horizontal_radio.setStyleSheet(self.separate_mode_radio.styleSheet())
self.layout_button_group.addButton(self.combined_horizontal_radio, 2)
options_layout.addWidget(self.combined_horizontal_radio)
horizontal_hint = QLabel(f" {t('settings.layout.combinedHorizontalDescription')}")
horizontal_hint.setStyleSheet(separate_hint.styleSheet())
options_layout.addWidget(horizontal_hint)
# 保存引用以便更新
self.ui_elements['horizontal_hint'] = horizontal_hint
layout.addLayout(options_layout)
# 連接佈局變更信號
self.layout_button_group.buttonToggled.connect(self._on_layout_changed)
def _create_window_section(self, layout: QVBoxLayout) -> None:
"""創建視窗設置區域"""
header = self._create_section_header(t('settings.window.title'), "🖥️")
layout.addWidget(header)
# 保存引用以便更新
self.ui_elements['window_header'] = header
# 選項容器
options_layout = QVBoxLayout()
options_layout.setSpacing(8)
# 使用現代化的 Switch 組件
self.always_center_switch = SwitchWithLabel(t('settings.window.alwaysCenter'))
self.always_center_switch.setChecked(self.config_manager.get_always_center_window())
self.always_center_switch.toggled.connect(self._on_always_center_changed)
options_layout.addWidget(self.always_center_switch)
layout.addLayout(options_layout)
def _create_timeout_section(self, layout: QVBoxLayout) -> None:
"""創建超時設置區域"""
header = self._create_section_header(t('timeout.settings.title'), "")
layout.addWidget(header)
# 保存引用以便更新
self.ui_elements['timeout_header'] = header
# 選項容器
options_layout = QVBoxLayout()
options_layout.setSpacing(12)
# 啟用超時自動關閉開關
self.timeout_enabled_switch = SwitchWithLabel(t('timeout.enable'))
self.timeout_enabled_switch.setChecked(self.config_manager.get_timeout_enabled())
self.timeout_enabled_switch.toggled.connect(self._on_timeout_enabled_changed)
options_layout.addWidget(self.timeout_enabled_switch)
# 超時時間設置
timeout_duration_layout = QHBoxLayout()
timeout_duration_layout.setContentsMargins(0, 8, 0, 0)
# 標籤
timeout_duration_label = QLabel(t('timeout.duration.label'))
timeout_duration_label.setStyleSheet("""
QLabel {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
color: #ffffff;
font-size: 13px;
}
""")
timeout_duration_layout.addWidget(timeout_duration_label)
# 保存引用以便更新
self.ui_elements['timeout_duration_label'] = timeout_duration_label
# 彈性空間
timeout_duration_layout.addStretch()
# 時間輸入框
self.timeout_duration_spinbox = StyledSpinBox()
self.timeout_duration_spinbox.setRange(30, 7200) # 30秒到2小時
self.timeout_duration_spinbox.setValue(self.config_manager.get_timeout_duration())
self.timeout_duration_spinbox.setSuffix(" " + t('timeout.seconds'))
# StyledSpinBox 已經有內建樣式,不需要額外設置
self.timeout_duration_spinbox.valueChanged.connect(self._on_timeout_duration_changed)
timeout_duration_layout.addWidget(self.timeout_duration_spinbox)
options_layout.addLayout(timeout_duration_layout)
# 說明文字
description = self._create_description(t('timeout.settings.description'))
options_layout.addWidget(description)
# 保存引用以便更新
self.ui_elements['timeout_description'] = description
layout.addLayout(options_layout)
def _create_reset_section(self, layout: QVBoxLayout) -> None:
"""創建重置設定區域"""
header = self._create_section_header(t('settings.reset.title'), "🔄")
layout.addWidget(header)
# 保存引用以便更新
self.ui_elements['reset_header'] = header
reset_container = QHBoxLayout()
reset_container.setContentsMargins(0, 0, 0, 0)
self.reset_button = QPushButton(t('settings.reset.button'))
self.reset_button.setMinimumHeight(32)
self.reset_button.setMaximumWidth(110)
self.reset_button.setStyleSheet("""
QPushButton {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
padding: 6px 12px;
}
QPushButton:hover {
background-color: #e55565;
}
QPushButton:pressed {
background-color: #c82333;
}
""")
self.reset_button.clicked.connect(self._on_reset_settings)
reset_container.addWidget(self.reset_button)
reset_container.addStretch()
layout.addLayout(reset_container)
def _populate_language_selector(self) -> None:
"""填充語言選擇器"""
languages = [
('zh-TW', '繁體中文'),
('zh-CN', '简体中文'),
('en', 'English')
]
current_language = self.i18n.get_current_language()
# 暫時斷開信號連接以避免觸發變更事件
self.language_selector.blockSignals(True)
# 先清空現有選項
self.language_selector.clear()
for i, (code, name) in enumerate(languages):
self.language_selector.addItem(name, code)
if code == current_language:
self.language_selector.setCurrentIndex(i)
# 重新連接信號
self.language_selector.blockSignals(False)
def _on_language_changed(self, index: int) -> None:
"""語言變更事件處理"""
if index < 0:
return
language_code = self.language_selector.itemData(index)
if language_code and language_code != self.i18n.get_current_language():
# 先保存語言設定
self.config_manager.set_language(language_code)
# 再設定語言
self.i18n.set_language(language_code)
# 發出信號
self.language_changed.emit()
def _on_layout_changed(self, button, checked: bool) -> None:
"""佈局變更事件處理"""
if not checked:
return
button_id = self.layout_button_group.id(button)
if button_id == 0: # 分離模式
new_combined_mode = False
new_orientation = 'vertical'
elif button_id == 1: # 合併模式(垂直)
new_combined_mode = True
new_orientation = 'vertical'
elif button_id == 2: # 合併模式(水平)
new_combined_mode = True
new_orientation = 'horizontal'
else:
return
# 檢查是否真的有變更
if new_combined_mode != self.combined_mode or new_orientation != self.layout_orientation:
# 批量保存配置(避免多次寫入文件)
self.config_manager.update_partial_config({
'combined_mode': new_combined_mode,
'layout_orientation': new_orientation
})
# 更新內部狀態
self.combined_mode = new_combined_mode
self.layout_orientation = new_orientation
# 發出佈局變更請求信號
self.layout_change_requested.emit(new_combined_mode, new_orientation)
def _on_always_center_changed(self, checked: bool) -> None:
"""視窗定位選項變更事件處理"""
# 立即保存設定
self.config_manager.set_always_center_window(checked)
debug_log(f"視窗定位設置已保存: {checked}") # 調試輸出
def _on_timeout_enabled_changed(self, enabled: bool) -> None:
"""超時啟用狀態變更事件處理"""
# 立即保存設定
self.config_manager.set_timeout_enabled(enabled)
debug_log(f"超時啟用設置已保存: {enabled}")
# 發出信號通知主窗口
duration = self.timeout_duration_spinbox.value()
self.timeout_settings_changed.emit(enabled, duration)
def _on_timeout_duration_changed(self, duration: int) -> None:
"""超時時間變更事件處理"""
# 立即保存設定
self.config_manager.set_timeout_duration(duration)
debug_log(f"超時時間設置已保存: {duration}")
# 發出信號通知主窗口
enabled = self.timeout_enabled_switch.isChecked()
self.timeout_settings_changed.emit(enabled, duration)
def _on_reset_settings(self) -> None:
"""重置設定事件處理"""
reply = QMessageBox.question(
self,
t('settings.reset.confirmTitle'),
t('settings.reset.confirmMessage'),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.reset_requested.emit()
def update_texts(self) -> None:
"""更新界面文字(不重新創建界面)"""
# 更新區塊標題
if 'language_header' in self.ui_elements:
self.ui_elements['language_header'].setText(f"🌐 {t('settings.language.title')}")
if 'layout_header' in self.ui_elements:
self.ui_elements['layout_header'].setText(f"📐 {t('settings.layout.title')}")
if 'window_header' in self.ui_elements:
self.ui_elements['window_header'].setText(f"🖥️ {t('settings.window.title')}")
if 'reset_header' in self.ui_elements:
self.ui_elements['reset_header'].setText(f"🔄 {t('settings.reset.title')}")
if 'timeout_header' in self.ui_elements:
self.ui_elements['timeout_header'].setText(f"{t('timeout.settings.title')}")
# 更新提示文字
if 'separate_hint' in self.ui_elements:
self.ui_elements['separate_hint'].setText(f" {t('settings.layout.separateModeDescription')}")
if 'vertical_hint' in self.ui_elements:
self.ui_elements['vertical_hint'].setText(f" {t('settings.layout.combinedVerticalDescription')}")
if 'horizontal_hint' in self.ui_elements:
self.ui_elements['horizontal_hint'].setText(f" {t('settings.layout.combinedHorizontalDescription')}")
if 'timeout_description' in self.ui_elements:
self.ui_elements['timeout_description'].setText(t('timeout.settings.description'))
# 更新按鈕文字
if hasattr(self, 'reset_button'):
self.reset_button.setText(t('settings.reset.button'))
# 更新切換開關文字
if hasattr(self, 'always_center_switch'):
self.always_center_switch.setText(t('settings.window.alwaysCenter'))
if hasattr(self, 'timeout_enabled_switch'):
self.timeout_enabled_switch.setText(t('timeout.enable'))
# 更新超時相關標籤和控件
if 'timeout_duration_label' in self.ui_elements:
self.ui_elements['timeout_duration_label'].setText(t('timeout.duration.label'))
if hasattr(self, 'timeout_duration_spinbox'):
self.timeout_duration_spinbox.setSuffix(" " + t('timeout.seconds'))
# 更新單選按鈕文字
if hasattr(self, 'separate_mode_radio'):
self.separate_mode_radio.setText(t('settings.layout.separateMode'))
if hasattr(self, 'combined_vertical_radio'):
self.combined_vertical_radio.setText(t('settings.layout.combinedVertical'))
if hasattr(self, 'combined_horizontal_radio'):
self.combined_horizontal_radio.setText(t('settings.layout.combinedHorizontal'))
# 注意:不要重新填充語言選擇器,避免重複選項問題
def reload_settings_from_config(self) -> None:
"""從配置重新載入設定狀態"""
# 重新載入語言設定
if hasattr(self, 'language_selector'):
self._populate_language_selector()
# 重新載入佈局設定
self.combined_mode = self.config_manager.get_layout_mode()
self.layout_orientation = self.config_manager.get_layout_orientation()
self._set_initial_layout_state()
# 重新載入視窗設定
if hasattr(self, 'always_center_switch'):
always_center = self.config_manager.get_always_center_window()
self.always_center_switch.setChecked(always_center)
debug_log(f"重新載入視窗定位設置: {always_center}") # 調試輸出
# 重新載入超時設定
if hasattr(self, 'timeout_enabled_switch'):
timeout_enabled = self.config_manager.get_timeout_enabled()
self.timeout_enabled_switch.setChecked(timeout_enabled)
debug_log(f"重新載入超時啟用設置: {timeout_enabled}")
if hasattr(self, 'timeout_duration_spinbox'):
timeout_duration = self.config_manager.get_timeout_duration()
self.timeout_duration_spinbox.setValue(timeout_duration)
debug_log(f"重新載入超時時間設置: {timeout_duration}") # 調試輸出
def set_layout_mode(self, combined_mode: bool) -> None:
"""設置佈局模式"""
self.combined_mode = combined_mode
self._set_initial_layout_state()
def set_layout_orientation(self, orientation: str) -> None:
"""設置佈局方向"""
self.layout_orientation = orientation
self._set_initial_layout_state()
def _set_initial_layout_state(self) -> None:
"""設置初始佈局狀態"""
if hasattr(self, 'separate_mode_radio'):
# 暫時斷開信號連接以避免觸發變更事件
self.layout_button_group.blockSignals(True)
if not self.combined_mode:
self.separate_mode_radio.setChecked(True)
elif self.layout_orientation == 'vertical':
self.combined_vertical_radio.setChecked(True)
else:
self.combined_horizontal_radio.setChecked(True)
# 重新連接信號
self.layout_button_group.blockSignals(False)

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
摘要分頁組件
============
專門顯示AI工作摘要的分頁組件。
"""
import json
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit
from ...i18n import t
class SummaryTab(QWidget):
"""摘要分頁組件"""
def __init__(self, summary: str, parent=None):
super().__init__(parent)
self.summary = self._process_summary(summary)
self._setup_ui()
def _process_summary(self, summary: str) -> str:
"""處理摘要內容如果是JSON格式則提取實際內容"""
try:
# 嘗試解析JSON
if summary.strip().startswith('{') and summary.strip().endswith('}'):
json_data = json.loads(summary)
# 如果是JSON格式提取summary字段的內容
if isinstance(json_data, dict) and 'summary' in json_data:
return json_data['summary']
# 如果JSON中沒有summary字段返回原始內容
return summary
else:
return summary
except (json.JSONDecodeError, TypeError):
# 如果不是有效的JSON返回原始內容
return summary
def _setup_ui(self) -> None:
"""設置用戶介面"""
layout = QVBoxLayout(self)
layout.setSpacing(12)
layout.setContentsMargins(0, 16, 0, 0) # 只保留上邊距,移除左右和底部邊距
# 說明文字容器
description_wrapper = QWidget()
description_layout = QVBoxLayout(description_wrapper)
description_layout.setContentsMargins(16, 0, 16, 0) # 只對說明文字設置左右邊距
description_layout.setSpacing(0)
# 說明文字
if self._is_test_summary():
self.summary_description_label = QLabel(t('summary.testDescription'))
else:
self.summary_description_label = QLabel(t('summary.description'))
self.summary_description_label.setStyleSheet("color: #9e9e9e; font-size: 12px; margin-bottom: 10px;")
self.summary_description_label.setWordWrap(True)
description_layout.addWidget(self.summary_description_label)
layout.addWidget(description_wrapper)
# 摘要顯示區域容器
summary_wrapper = QWidget()
summary_layout = QVBoxLayout(summary_wrapper)
summary_layout.setContentsMargins(16, 0, 16, 0) # 只對摘要區域設置左右邊距
summary_layout.setSpacing(0)
# 摘要顯示區域
self.summary_display = QTextEdit()
# 檢查是否為測試摘要,如果是則使用翻譯的內容
if self._is_test_summary():
self.summary_display.setPlainText(t('test.qtGuiSummary'))
else:
self.summary_display.setPlainText(self.summary)
self.summary_display.setReadOnly(True)
self.summary_display.setStyleSheet("""
QTextEdit {
background-color: #2d2d30;
border: 1px solid #464647;
border-radius: 4px;
padding: 10px;
color: #ffffff;
font-size: 12px;
line-height: 1.4;
}
""")
summary_layout.addWidget(self.summary_display, 1)
layout.addWidget(summary_wrapper, 1)
def _is_test_summary(self) -> bool:
"""檢查是否為測試摘要"""
# 更精確的測試摘要檢測 - 必須包含特定的測試指標組合
test_patterns = [
# Qt GUI 測試特徵組合 - 必須同時包含多個特徵
("圖片預覽和視窗調整測試", "功能測試項目", "🎯"),
("圖片預覽和窗口調整測試", "功能測試項目", "🎯"),
("图片预览和窗口调整测试", "功能测试项目", "🎯"),
("Image Preview and Window Adjustment Test", "Test Items", "🎯"),
# Web UI 測試特徵組合
("測試 Web UI 功能", "🎯 **功能測試項目", "WebSocket 即時通訊"),
("测试 Web UI 功能", "🎯 **功能测试项目", "WebSocket 即时通讯"),
("Test Web UI Functionality", "🎯 **Test Items", "WebSocket real-time communication"),
# 具體的測試步驟特徵
("智能 Ctrl+V 圖片貼上功能", "📋 測試步驟", "請測試這些功能並提供回饋"),
("智能 Ctrl+V 图片粘贴功能", "📋 测试步骤", "请测试这些功能并提供回馈"),
("Smart Ctrl+V image paste", "📋 Test Steps", "Please test these features"),
]
# 檢查是否匹配任何一個測試模式(必須同時包含模式中的所有關鍵詞)
for pattern in test_patterns:
if all(keyword in self.summary for keyword in pattern):
return True
return False
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
if self._is_test_summary():
self.summary_description_label.setText(t('summary.testDescription'))
# 更新測試摘要的內容
self.summary_display.setPlainText(t('test.qtGuiSummary'))
else:
self.summary_description_label.setText(t('summary.description'))

View File

@@ -0,0 +1,14 @@
"""
GUI 工具函數模組
===============
包含各種輔助工具函數。
"""
from .shortcuts import setup_shortcuts
from .utils import apply_widget_styles
__all__ = [
'setup_shortcuts',
'apply_widget_styles'
]

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
快捷鍵設置工具
==============
管理 GUI 快捷鍵設置的工具函數。
"""
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtCore import Qt
def setup_shortcuts(window):
"""
設置窗口的快捷鍵
Args:
window: 主窗口實例
"""
# Ctrl+Enter 提交回饋
submit_shortcut = QShortcut(QKeySequence("Ctrl+Return"), window)
submit_shortcut.activated.connect(window._submit_feedback)
# Escape 取消回饋
cancel_shortcut = QShortcut(QKeySequence(Qt.Key_Escape), window)
cancel_shortcut.activated.connect(window._cancel_feedback)
# Ctrl+R 執行命令
run_shortcut = QShortcut(QKeySequence("Ctrl+R"), window)
run_shortcut.activated.connect(window._run_command)
# Ctrl+Shift+C 終止命令
terminate_shortcut = QShortcut(QKeySequence("Ctrl+Shift+C"), window)
terminate_shortcut.activated.connect(window._terminate_command)

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通用工具函數
============
提供 GUI 相關的通用工具函數。
"""
from ..styles import *
def apply_widget_styles(widget, style_type="default"):
"""
應用樣式到元件
Args:
widget: 要應用樣式的元件
style_type: 樣式類型
"""
if style_type == "primary_button":
widget.setStyleSheet(PRIMARY_BUTTON_STYLE)
elif style_type == "success_button":
widget.setStyleSheet(SUCCESS_BUTTON_STYLE)
elif style_type == "danger_button":
widget.setStyleSheet(DANGER_BUTTON_STYLE)
elif style_type == "secondary_button":
widget.setStyleSheet(SECONDARY_BUTTON_STYLE)
elif style_type == "dark_theme":
widget.setStyleSheet(DARK_STYLE)
def format_file_size(size_bytes):
"""
格式化文件大小顯示
Args:
size_bytes: 文件大小(字節)
Returns:
str: 格式化後的文件大小字符串
"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
size_kb = size_bytes / 1024
return f"{size_kb:.1f} KB"
else:
size_mb = size_bytes / (1024 * 1024)
return f"{size_mb:.1f} MB"

View File

@@ -0,0 +1,19 @@
"""
GUI 自定義元件模組
==================
包含所有自定義的 GUI 元件。
"""
from .text_edit import SmartTextEdit
from .image_preview import ImagePreviewWidget
from .image_upload import ImageUploadWidget
from .switch import SwitchWidget, SwitchWithLabel
__all__ = [
'SmartTextEdit',
'ImagePreviewWidget',
'ImageUploadWidget',
'SwitchWidget',
'SwitchWithLabel'
]

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
圖片預覽元件
============
提供圖片預覽和刪除功能的自定義元件。
"""
import os
from PySide6.QtWidgets import QLabel, QPushButton, QFrame, QMessageBox
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QPixmap
# 導入多語系支援
from ...i18n import t
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: #ffffff;
border: none;
border-radius: 10px;
font-weight: bold;
font-size: 14px;
}
QPushButton:hover {
background-color: #d32f2f;
color: #ffffff;
}
""")
self.delete_button.clicked.connect(self._on_delete_clicked)
self.delete_button.setToolTip(t('images.clear'))
def _on_delete_clicked(self) -> None:
"""處理刪除按鈕點擊事件"""
reply = QMessageBox.question(
self, t('images.deleteTitle'),
t('images.deleteConfirm', filename=os.path.basename(self.image_path)),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.remove_clicked.emit(self.image_id)

View File

@@ -0,0 +1,740 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
圖片上傳元件
============
支援文件選擇、剪貼板貼上、拖拽上傳等多種方式的圖片上傳元件。
"""
import os
import uuid
import time
from typing import Dict, List
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QGridLayout, QFileDialog, QMessageBox, QApplication,
QComboBox, QCheckBox, QGroupBox, QFrame
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QDragEnterEvent, QDropEvent
from PySide6.QtWidgets import QSizePolicy
# 導入多語系支援
from ...i18n import t
from ...debug import gui_debug_log as debug_log
from .image_preview import ImagePreviewWidget
class ImageUploadWidget(QWidget):
"""圖片上傳元件"""
images_changed = Signal()
def __init__(self, parent=None, config_manager=None):
super().__init__(parent)
self.images: Dict[str, Dict[str, str]] = {}
self.config_manager = config_manager
self._last_paste_time = 0 # 添加最後貼上時間記錄
self._setup_ui()
self.setAcceptDrops(True)
# 啟動時清理舊的臨時文件
self._cleanup_old_temp_files()
def _setup_ui(self) -> None:
"""設置用戶介面"""
layout = QVBoxLayout(self)
layout.setSpacing(6)
layout.setContentsMargins(0, 8, 0, 8) # 調整邊距使其與其他區域一致
# 標題
self.title = QLabel(t('images.title'))
self.title.setFont(QFont("", 10, QFont.Bold))
self.title.setStyleSheet("color: #007acc; margin: 1px 0;")
layout.addWidget(self.title)
# 圖片設定區域
self._create_settings_area(layout)
# 狀態標籤
self.status_label = QLabel(t('images.status', count=0))
self.status_label.setStyleSheet("color: #9e9e9e; font-size: 10px; margin: 5px 0;")
layout.addWidget(self.status_label)
# 統一的圖片區域(整合按鈕、拖拽、預覽)
self._create_unified_image_area(layout)
def _create_settings_area(self, layout: QVBoxLayout) -> None:
"""創建圖片設定區域"""
if not self.config_manager:
return # 如果沒有 config_manager跳過設定區域
# 設定群組框
settings_group = QGroupBox(t('images.settings.title'))
settings_group.setStyleSheet("""
QGroupBox {
font-weight: bold;
font-size: 9px;
color: #9e9e9e;
border: 1px solid #464647;
border-radius: 4px;
margin-top: 6px;
padding-top: 4px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 8px;
padding: 0 4px 0 4px;
}
""")
settings_layout = QHBoxLayout(settings_group)
settings_layout.setSpacing(12)
settings_layout.setContentsMargins(8, 8, 8, 8)
# 圖片大小限制設定
self.size_label = QLabel(t('images.settings.sizeLimit') + ":")
self.size_label.setStyleSheet("color: #cccccc; font-size: 11px;")
self.size_limit_combo = QComboBox()
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.unlimited'), 0)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.1mb'), 1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.3mb'), 3*1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.5mb'), 5*1024*1024)
# 載入當前設定
current_limit = self.config_manager.get_image_size_limit()
for i in range(self.size_limit_combo.count()):
if self.size_limit_combo.itemData(i) == current_limit:
self.size_limit_combo.setCurrentIndex(i)
break
self.size_limit_combo.currentIndexChanged.connect(self._on_size_limit_changed)
# Base64 詳細模式設定
self.base64_checkbox = QCheckBox(t('images.settings.base64Detail'))
self.base64_checkbox.setChecked(self.config_manager.get_enable_base64_detail())
self.base64_checkbox.stateChanged.connect(self._on_base64_detail_changed)
self.base64_checkbox.setToolTip(t('images.settings.base64DetailHelp'))
# Base64 警告標籤
self.base64_warning = QLabel(t('images.settings.base64Warning'))
self.base64_warning.setStyleSheet("color: #ff9800; font-size: 10px;")
# 添加到佈局
settings_layout.addWidget(self.size_label)
settings_layout.addWidget(self.size_limit_combo)
settings_layout.addWidget(self.base64_checkbox)
settings_layout.addWidget(self.base64_warning)
settings_layout.addStretch()
layout.addWidget(settings_group)
def _on_size_limit_changed(self, index: int) -> None:
"""圖片大小限制變更處理"""
if self.config_manager and index >= 0:
size_bytes = self.size_limit_combo.itemData(index)
# 處理 None 值
if size_bytes is not None:
self.config_manager.set_image_size_limit(size_bytes)
debug_log(f"圖片大小限制已更新: {size_bytes} bytes")
def _on_base64_detail_changed(self, state: int) -> None:
"""Base64 詳細模式變更處理"""
if self.config_manager:
enabled = state == Qt.Checked
self.config_manager.set_enable_base64_detail(enabled)
debug_log(f"Base64 詳細模式已更新: {enabled}")
def _create_unified_image_area(self, layout: QVBoxLayout) -> None:
"""創建統一的圖片區域"""
# 創建滾動區域
self.preview_scroll = QScrollArea()
self.preview_widget = QWidget()
self.preview_widget.setMinimumHeight(140) # 設置預覽部件的最小高度
# 設置尺寸策略,允許垂直擴展
self.preview_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
self.preview_layout = QVBoxLayout(self.preview_widget)
self.preview_layout.setSpacing(6)
self.preview_layout.setContentsMargins(8, 8, 8, 8)
# 創建操作按鈕區域
self._create_buttons_in_area()
# 創建拖拽提示標籤(初始顯示)
self.drop_hint_label = QLabel(t('images.dragHint'))
self.drop_hint_label.setAlignment(Qt.AlignCenter)
self.drop_hint_label.setMinimumHeight(80) # 增加最小高度
self.drop_hint_label.setMaximumHeight(120) # 設置最大高度
self.drop_hint_label.setStyleSheet("""
QLabel {
border: 2px dashed #464647;
border-radius: 6px;
background-color: #2d2d30;
color: #9e9e9e;
font-size: 11px;
margin: 4px 0;
padding: 16px 8px;
}
""")
# 創建圖片網格容器
self.images_grid_widget = QWidget()
self.images_grid_layout = QGridLayout(self.images_grid_widget)
self.images_grid_layout.setSpacing(4)
self.images_grid_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
# 將部分添加到主布局
self.preview_layout.addWidget(self.button_widget) # 按鈕始終顯示
self.preview_layout.addWidget(self.drop_hint_label)
self.preview_layout.addWidget(self.images_grid_widget)
# 初始時隱藏圖片網格
self.images_grid_widget.hide()
# 設置滾動區域
self.preview_scroll.setWidget(self.preview_widget)
self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # 改回按需顯示滾動條
self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.preview_scroll.setMinimumHeight(160) # 增加最小高度,確保有足夠空間
self.preview_scroll.setMaximumHeight(300) # 增加最大高度
self.preview_scroll.setWidgetResizable(True)
# 增強的滾動區域樣式,改善 macOS 兼容性
self.preview_scroll.setStyleSheet("""
QScrollArea {
border: 1px solid #464647;
border-radius: 4px;
background-color: #1e1e1e;
}
QScrollArea QScrollBar:vertical {
background-color: #2a2a2a;
width: 14px;
border-radius: 7px;
margin: 0;
}
QScrollArea QScrollBar::handle:vertical {
background-color: #555;
border-radius: 7px;
min-height: 30px;
margin: 2px;
}
QScrollArea QScrollBar::handle:vertical:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:vertical,
QScrollArea QScrollBar::sub-line:vertical {
border: none;
background: none;
height: 0px;
}
QScrollArea QScrollBar:horizontal {
background-color: #2a2a2a;
height: 14px;
border-radius: 7px;
margin: 0;
}
QScrollArea QScrollBar::handle:horizontal {
background-color: #555;
border-radius: 7px;
min-width: 30px;
margin: 2px;
}
QScrollArea QScrollBar::handle:horizontal:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:horizontal,
QScrollArea QScrollBar::sub-line:horizontal {
border: none;
background: none;
width: 0px;
}
""")
layout.addWidget(self.preview_scroll)
def _create_buttons_in_area(self) -> None:
"""在統一區域內創建操作按鈕"""
self.button_widget = QWidget()
button_layout = QHBoxLayout(self.button_widget)
button_layout.setContentsMargins(0, 0, 0, 4)
button_layout.setSpacing(6)
# 選擇文件按鈕
self.file_button = QPushButton(t('buttons.selectFiles'))
self.file_button.clicked.connect(self.select_files)
# 剪貼板按鈕
self.paste_button = QPushButton(t('buttons.pasteClipboard'))
self.paste_button.clicked.connect(self.paste_from_clipboard)
# 清除按鈕
self.clear_button = QPushButton(t('buttons.clearAll'))
self.clear_button.clicked.connect(self.clear_all_images)
# 設置按鈕樣式(更緊湊)
button_style = """
QPushButton {
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
font-weight: bold;
font-size: 10px;
min-height: 24px;
}
QPushButton:hover {
opacity: 0.8;
}
"""
self.file_button.setStyleSheet(button_style + """
QPushButton {
background-color: #0e639c;
}
QPushButton:hover {
background-color: #005a9e;
}
""")
self.paste_button.setStyleSheet(button_style + """
QPushButton {
background-color: #4caf50;
}
QPushButton:hover {
background-color: #45a049;
}
""")
self.clear_button.setStyleSheet(button_style + """
QPushButton {
background-color: #f44336;
color: #ffffff;
}
QPushButton:hover {
background-color: #d32f2f;
color: #ffffff;
}
""")
button_layout.addWidget(self.file_button)
button_layout.addWidget(self.paste_button)
button_layout.addWidget(self.clear_button)
button_layout.addStretch() # 左對齊按鈕
def select_files(self) -> None:
"""選擇文件對話框"""
files, _ = QFileDialog.getOpenFileNames(
self,
t('images.select'),
"",
"Image files (*.png *.jpg *.jpeg *.gif *.bmp *.webp);;All files (*)"
)
if files:
self._add_images(files)
def paste_from_clipboard(self) -> None:
"""從剪貼板粘貼圖片"""
# 防重複保護:檢查是否在短時間內重複調用
current_time = time.time() * 1000 # 轉換為毫秒
if current_time - self._last_paste_time < 100: # 100毫秒內的重複調用忽略
debug_log(f"忽略重複的剪貼板粘貼請求(間隔: {current_time - self._last_paste_time:.1f}ms")
return
self._last_paste_time = current_time
clipboard = QApplication.clipboard()
mimeData = clipboard.mimeData()
if mimeData.hasImage():
image = clipboard.image()
if not image.isNull():
# 創建一個唯一的臨時文件名
temp_dir = Path.home() / ".cache" / "mcp-feedback-enhanced"
temp_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time() * 1000)
temp_file = temp_dir / f"clipboard_{timestamp}_{uuid.uuid4().hex[:8]}.png"
# 保存剪貼板圖片
if image.save(str(temp_file), "PNG"):
if os.path.getsize(temp_file) > 0:
self._add_images([str(temp_file)])
debug_log(f"從剪貼板成功粘貼圖片: {temp_file}")
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveEmpty', path=str(temp_file)))
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveFailed'))
else:
QMessageBox.warning(self, t('errors.warning'), t('errors.clipboardSaveFailed'))
elif mimeData.hasText():
# 檢查是否為圖片數據
text = mimeData.text()
if text.startswith('data:image/') or any(ext in text.lower() for ext in ['.png', '.jpg', '.jpeg', '.gif']):
QMessageBox.information(self, t('errors.info'), t('errors.noValidImage'))
else:
QMessageBox.information(self, t('errors.info'), t('errors.noImageContent'))
def clear_all_images(self) -> None:
"""清除所有圖片"""
if self.images:
reply = QMessageBox.question(
self, t('errors.confirmClearTitle'),
t('errors.confirmClearAll', count=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
debug_log(f"已刪除臨時文件: {file_path}")
except Exception as e:
debug_log(f"刪除臨時文件失敗: {e}")
# 清除內存中的圖片數據
self.images.clear()
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"已清除所有圖片,包括 {temp_files_cleaned} 個臨時文件")
def _add_images(self, file_paths: List[str]) -> None:
"""添加圖片"""
added_count = 0
for file_path in file_paths:
try:
debug_log(f"嘗試添加圖片: {file_path}")
if not os.path.exists(file_path):
debug_log(f"文件不存在: {file_path}")
continue
if not self._is_image_file(file_path):
debug_log(f"不是圖片文件: {file_path}")
continue
file_size = os.path.getsize(file_path)
debug_log(f"文件大小: {file_size} bytes")
# 動態圖片大小限制檢查
size_limit = self.config_manager.get_image_size_limit() if self.config_manager else 1024*1024
if size_limit > 0 and file_size > size_limit:
# 格式化限制大小顯示
if size_limit >= 1024*1024:
limit_str = f"{size_limit/(1024*1024):.0f}MB"
else:
limit_str = f"{size_limit/1024:.0f}KB"
# 格式化文件大小顯示
if file_size >= 1024*1024:
size_str = f"{file_size/(1024*1024):.1f}MB"
else:
size_str = f"{file_size/1024:.1f}KB"
QMessageBox.warning(
self, t('errors.warning'),
t('images.sizeLimitExceeded', filename=os.path.basename(file_path), size=size_str, limit=limit_str) +
"\n\n" + t('images.sizeLimitExceededAdvice')
)
continue
if file_size == 0:
QMessageBox.warning(self, t('errors.warning'), t('errors.emptyFile', filename=os.path.basename(file_path)))
continue
# 讀取圖片原始二進制數據
with open(file_path, 'rb') as f:
raw_data = f.read()
debug_log(f"讀取原始數據大小: {len(raw_data)} bytes")
if len(raw_data) == 0:
debug_log(f"讀取的數據為空!")
continue
# 再次檢查內存中的數據大小(使用配置的限制)
size_limit = self.config_manager.get_image_size_limit() if self.config_manager else 1024*1024
if size_limit > 0 and len(raw_data) > size_limit:
# 格式化限制大小顯示
if size_limit >= 1024*1024:
limit_str = f"{size_limit/(1024*1024):.0f}MB"
else:
limit_str = f"{size_limit/1024:.0f}KB"
# 格式化文件大小顯示
if len(raw_data) >= 1024*1024:
size_str = f"{len(raw_data)/(1024*1024):.1f}MB"
else:
size_str = f"{len(raw_data)/1024:.1f}KB"
QMessageBox.warning(
self, t('errors.warning'),
t('images.sizeLimitExceeded', filename=os.path.basename(file_path), size=size_str, limit=limit_str) +
"\n\n" + t('images.sizeLimitExceededAdvice')
)
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
debug_log(f"圖片添加成功: {os.path.basename(file_path)}")
except Exception as e:
debug_log(f"添加圖片失敗: {e}")
QMessageBox.warning(self, t('errors.title'), t('errors.loadImageFailed', filename=os.path.basename(file_path), error=str(e)))
if added_count > 0:
debug_log(f"共添加 {added_count} 張圖片,當前總數: {len(self.images)}")
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.images_grid_layout.count():
child = self.images_grid_layout.takeAt(0)
if child.widget():
child.widget().deleteLater()
# 根據圖片數量決定顯示內容
if len(self.images) == 0:
# 沒有圖片時,顯示拖拽提示
self.drop_hint_label.show()
self.images_grid_widget.hide()
else:
# 有圖片時,隱藏拖拽提示,顯示圖片網格
self.drop_hint_label.hide()
self.images_grid_widget.show()
# 重新添加圖片預覽
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.images_grid_layout.addWidget(preview, row, col)
# 強制更新佈局和滾動區域
self.preview_widget.updateGeometry()
self.preview_scroll.updateGeometry()
# 確保滾動區域能正確計算內容大小
from PySide6.QtWidgets import QApplication
QApplication.processEvents()
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)
debug_log(f"已刪除臨時文件: {file_path}")
except Exception as e:
debug_log(f"刪除臨時文件失敗: {e}")
# 從內存中移除圖片數據
del self.images[image_id]
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"已移除圖片: {image_info['name']}")
def _update_status(self) -> None:
"""更新狀態標籤"""
count = len(self.images)
if count == 0:
self.status_label.setText(t('images.status', count=0))
else:
total_size = sum(img["size"] for img in self.images.values())
# 格式化文件大小
if total_size > 1024 * 1024: # MB
size_mb = total_size / (1024 * 1024)
size_str = f"{size_mb:.1f} MB"
else: # KB
size_kb = total_size / 1024
size_str = f"{size_kb:.1f} KB"
self.status_label.setText(t('images.statusWithSize', count=count, size=size_str))
# 基本調試信息
debug_log(f"圖片狀態: {count} 張圖片,總大小: {size_str}")
def get_images_data(self) -> List[dict]:
"""獲取所有圖片的數據列表"""
images_data = []
for image_info in self.images.values():
images_data.append(image_info)
return images_data
def add_image_data(self, image_data: dict) -> None:
"""添加圖片數據(用於恢復界面時的圖片)"""
try:
# 檢查必要的字段
if not all(key in image_data for key in ['filename', 'data', 'size']):
debug_log("圖片數據格式不正確,缺少必要字段")
return
# 生成新的圖片ID
image_id = str(uuid.uuid4())
# 復制圖片數據
self.images[image_id] = image_data.copy()
# 刷新預覽
self._refresh_preview()
self._update_status()
self.images_changed.emit()
debug_log(f"成功恢復圖片: {image_data['filename']}")
except Exception as e:
debug_log(f"添加圖片數據失敗: {e}")
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_hint_label.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_hint_label.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, t('errors.warning'), t('errors.dragInvalidFiles'))
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:
debug_log(f"清理舊臨時文件失敗: {e}")
if cleaned_count > 0:
debug_log(f"清理了 {cleaned_count} 個舊的臨時文件")
except Exception as e:
debug_log(f"臨時文件清理過程出錯: {e}")
def update_texts(self) -> None:
"""更新界面文字(用於語言切換)"""
# 更新標題
if hasattr(self, 'title'):
self.title.setText(t('images.title'))
# 更新設定區域標籤
if hasattr(self, 'size_label'):
self.size_label.setText(t('images.settings.sizeLimit') + ":")
if hasattr(self, 'base64_warning'):
self.base64_warning.setText(t('images.settings.base64Warning'))
# 更新設定區域文字
if hasattr(self, 'size_limit_combo'):
# 保存當前選擇
current_data = self.size_limit_combo.currentData()
# 暫時斷開信號連接以避免觸發變更事件
self.size_limit_combo.blockSignals(True)
# 清除並重新添加選項
self.size_limit_combo.clear()
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.unlimited'), 0)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.1mb'), 1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.3mb'), 3*1024*1024)
self.size_limit_combo.addItem(t('images.settings.sizeLimitOptions.5mb'), 5*1024*1024)
# 恢復選擇
for i in range(self.size_limit_combo.count()):
if self.size_limit_combo.itemData(i) == current_data:
self.size_limit_combo.setCurrentIndex(i)
break
# 重新連接信號
self.size_limit_combo.blockSignals(False)
if hasattr(self, 'base64_checkbox'):
self.base64_checkbox.setText(t('images.settings.base64Detail'))
self.base64_checkbox.setToolTip(t('images.settings.base64DetailHelp'))
# 更新按鈕文字
if hasattr(self, 'file_button'):
self.file_button.setText(t('buttons.selectFiles'))
if hasattr(self, 'paste_button'):
self.paste_button.setText(t('buttons.pasteClipboard'))
if hasattr(self, 'clear_button'):
self.clear_button.setText(t('buttons.clearAll'))
# 更新拖拽區域文字
if hasattr(self, 'drop_hint_label'):
self.drop_hint_label.setText(t('images.dragHint'))
# 更新狀態文字
self._update_status()

View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
自定義樣式的 QSpinBox
==================
提供美觀的深色主題 QSpinBox帶有自定義箭頭按鈕。
"""
from PySide6.QtWidgets import QSpinBox, QStyleOptionSpinBox, QStyle
from PySide6.QtCore import QRect, Qt
from PySide6.QtGui import QPainter, QPen, QBrush, QColor
class StyledSpinBox(QSpinBox):
"""自定義樣式的 QSpinBox"""
def __init__(self, parent=None):
super().__init__(parent)
self._setup_style()
def _setup_style(self):
"""設置基本樣式"""
self.setStyleSheet("""
QSpinBox {
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 6px;
padding: 4px 8px;
color: #ffffff;
font-size: 12px;
min-width: 100px;
min-height: 24px;
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
}
QSpinBox:focus {
border-color: #007acc;
background-color: #404040;
}
QSpinBox:hover {
background-color: #404040;
border-color: #666666;
}
QSpinBox::up-button {
subcontrol-origin: border;
subcontrol-position: top right;
width: 20px;
border-left: 1px solid #555555;
border-bottom: 1px solid #555555;
border-top-right-radius: 5px;
background-color: #4a4a4a;
}
QSpinBox::up-button:hover {
background-color: #5a5a5a;
}
QSpinBox::up-button:pressed {
background-color: #007acc;
}
QSpinBox::down-button {
subcontrol-origin: border;
subcontrol-position: bottom right;
width: 20px;
border-left: 1px solid #555555;
border-top: 1px solid #555555;
border-bottom-right-radius: 5px;
background-color: #4a4a4a;
}
QSpinBox::down-button:hover {
background-color: #5a5a5a;
}
QSpinBox::down-button:pressed {
background-color: #007acc;
}
QSpinBox::up-arrow {
width: 0px;
height: 0px;
}
QSpinBox::down-arrow {
width: 0px;
height: 0px;
}
""")
def paintEvent(self, event):
"""重寫繪製事件以添加自定義箭頭"""
# 先調用父類的繪製方法
super().paintEvent(event)
# 創建畫筆
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# 獲取按鈕區域
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
# 計算按鈕位置
button_width = 20
widget_rect = self.rect()
# 上箭頭按鈕區域
up_rect = QRect(
widget_rect.width() - button_width,
1,
button_width - 1,
widget_rect.height() // 2 - 1
)
# 下箭頭按鈕區域
down_rect = QRect(
widget_rect.width() - button_width,
widget_rect.height() // 2,
button_width - 1,
widget_rect.height() // 2 - 1
)
# 繪製上箭頭
self._draw_arrow(painter, up_rect, True)
# 繪製下箭頭
self._draw_arrow(painter, down_rect, False)
def _draw_arrow(self, painter: QPainter, rect: QRect, is_up: bool):
"""繪製箭頭"""
# 設置畫筆
pen = QPen(QColor("#cccccc"), 1)
painter.setPen(pen)
painter.setBrush(QBrush(QColor("#cccccc")))
# 計算箭頭位置
center_x = rect.center().x()
center_y = rect.center().y()
arrow_size = 4
if is_up:
# 上箭頭:▲
points = [
(center_x, center_y - arrow_size // 2), # 頂點
(center_x - arrow_size, center_y + arrow_size // 2), # 左下
(center_x + arrow_size, center_y + arrow_size // 2) # 右下
]
else:
# 下箭頭:▼
points = [
(center_x, center_y + arrow_size // 2), # 底點
(center_x - arrow_size, center_y - arrow_size // 2), # 左上
(center_x + arrow_size, center_y - arrow_size // 2) # 右上
]
# 繪製三角形
from PySide6.QtCore import QPoint
triangle = [QPoint(x, y) for x, y in points]
painter.drawPolygon(triangle)

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
現代化切換開關組件
==================
提供類似 web 的現代化 switch 切換開關。
"""
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QToolTip
from PySide6.QtCore import Signal, QPropertyAnimation, QRect, QEasingCurve, Property, Qt, QTimer
from PySide6.QtGui import QPainter, QColor, QPainterPath, QFont
class SwitchWidget(QWidget):
"""現代化切換開關組件"""
toggled = Signal(bool) # 狀態變更信號
def __init__(self, parent=None):
super().__init__(parent)
# 狀態變數
self._checked = False
self._enabled = True
self._animating = False
# 尺寸設定
self._width = 50
self._height = 24
self._thumb_radius = 10
self._track_radius = 12
# 顏色設定
self._track_color_off = QColor(102, 102, 102) # #666666
self._track_color_on = QColor(0, 120, 212) # #0078d4
self._thumb_color = QColor(255, 255, 255) # white
self._track_color_disabled = QColor(68, 68, 68) # #444444
# 動畫屬性
self._thumb_position = 2.0
# 設定基本屬性
self.setFixedSize(self._width, self._height)
self.setCursor(Qt.PointingHandCursor)
# 創建動畫
self._animation = QPropertyAnimation(self, b"thumbPosition")
self._animation.setDuration(200) # 200ms 動畫時間
self._animation.setEasingCurve(QEasingCurve.OutCubic)
# 設置工具提示延遲
self._tooltip_timer = QTimer()
self._tooltip_timer.setSingleShot(True)
self._tooltip_timer.timeout.connect(self._show_delayed_tooltip)
@Property(float)
def thumbPosition(self):
return self._thumb_position
@thumbPosition.setter
def thumbPosition(self, position):
self._thumb_position = position
self.update()
def isChecked(self) -> bool:
"""獲取選中狀態"""
return self._checked
def setChecked(self, checked: bool) -> None:
"""設置選中狀態"""
if self._checked != checked:
self._checked = checked
self._animate_to_position()
self.toggled.emit(checked)
def setEnabled(self, enabled: bool) -> None:
"""設置啟用狀態"""
super().setEnabled(enabled)
self._enabled = enabled
self.setCursor(Qt.PointingHandCursor if enabled else Qt.ArrowCursor)
self.update()
def _animate_to_position(self) -> None:
"""動畫到目標位置"""
if self._animating:
return
self._animating = True
target_position = self._width - self._thumb_radius * 2 - 2 if self._checked else 2
self._animation.setStartValue(self._thumb_position)
self._animation.setEndValue(target_position)
self._animation.finished.connect(self._on_animation_finished)
self._animation.start()
def _on_animation_finished(self) -> None:
"""動畫完成處理"""
self._animating = False
self._animation.finished.disconnect()
def mousePressEvent(self, event):
"""滑鼠按下事件"""
if event.button() == Qt.LeftButton and self._enabled:
self.setChecked(not self._checked)
super().mousePressEvent(event)
def enterEvent(self, event):
"""滑鼠進入事件"""
if self._enabled:
# 延遲顯示工具提示
self._tooltip_timer.start(500) # 500ms 延遲
super().enterEvent(event)
def leaveEvent(self, event):
"""滑鼠離開事件"""
self._tooltip_timer.stop()
super().leaveEvent(event)
def _show_delayed_tooltip(self):
"""顯示延遲的工具提示"""
if self.toolTip():
QToolTip.showText(self.mapToGlobal(self.rect().center()), self.toolTip(), self)
def paintEvent(self, event):
"""繪製事件"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# 計算軌道矩形
track_rect = QRect(0, (self._height - self._track_radius * 2) // 2,
self._width, self._track_radius * 2)
# 繪製軌道
track_path = QPainterPath()
track_path.addRoundedRect(track_rect, self._track_radius, self._track_radius)
if not self._enabled:
track_color = self._track_color_disabled
elif self._checked:
track_color = self._track_color_on
else:
track_color = self._track_color_off
painter.fillPath(track_path, track_color)
# 繪製滑塊
thumb_x = self._thumb_position
thumb_y = (self._height - self._thumb_radius * 2) // 2
thumb_rect = QRect(int(thumb_x), thumb_y, self._thumb_radius * 2, self._thumb_radius * 2)
thumb_path = QPainterPath()
thumb_path.addEllipse(thumb_rect)
# 滑塊顏色(可以根據狀態調整透明度)
thumb_color = self._thumb_color
if not self._enabled:
thumb_color.setAlpha(180) # 半透明效果
painter.fillPath(thumb_path, thumb_color)
# 添加微妙的陰影效果
if self._enabled:
shadow_color = QColor(0, 0, 0, 30)
shadow_rect = thumb_rect.translated(0, 1)
shadow_path = QPainterPath()
shadow_path.addEllipse(shadow_rect)
painter.fillPath(shadow_path, shadow_color)
class SwitchWithLabel(QWidget):
"""帶標籤的切換開關組件"""
toggled = Signal(bool)
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
# 創建布局
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
# 創建標籤
self.label = QLabel(text)
self.label.setStyleSheet("""
QLabel {
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
font-size: 13px;
color: #ffffff;
}
""")
# 創建切換開關
self.switch = SwitchWidget()
self.switch.toggled.connect(self.toggled.emit)
# 添加到布局
layout.addWidget(self.label)
layout.addStretch() # 彈性空間,將開關推到右側
layout.addWidget(self.switch)
# 設置點擊標籤也能切換開關
self.label.mousePressEvent = self._on_label_clicked
def _on_label_clicked(self, event):
"""標籤點擊事件"""
if event.button() == Qt.LeftButton:
self.switch.setChecked(not self.switch.isChecked())
def setText(self, text: str) -> None:
"""設置標籤文字"""
self.label.setText(text)
def text(self) -> str:
"""獲取標籤文字"""
return self.label.text()
def isChecked(self) -> bool:
"""獲取選中狀態"""
return self.switch.isChecked()
def setChecked(self, checked: bool) -> None:
"""設置選中狀態"""
self.switch.setChecked(checked)
def setEnabled(self, enabled: bool) -> None:
"""設置啟用狀態"""
super().setEnabled(enabled)
self.switch.setEnabled(enabled)
self.label.setStyleSheet(f"""
QLabel {{
font-family: "Microsoft JhengHei", "微軟正黑體", sans-serif;
font-size: 13px;
color: {"#ffffff" if enabled else "#888888"};
}}
""")

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
智能文字編輯器
==============
支援智能 Ctrl+V 的文字輸入框,能自動處理圖片貼上。
"""
from PySide6.QtWidgets import QTextEdit, QApplication
from PySide6.QtCore import Qt, Signal
class SmartTextEdit(QTextEdit):
"""支援智能 Ctrl+V 的文字輸入框"""
image_paste_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent)
def keyPressEvent(self, event):
"""處理按鍵事件,實現智能 Ctrl+V"""
if event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier:
# 檢查剪貼簿是否有圖片
clipboard = QApplication.clipboard()
if clipboard.mimeData().hasImage():
# 如果有圖片,發送信號通知主窗口處理圖片貼上
self.image_paste_requested.emit()
# 不執行預設的文字貼上行為
return
else:
# 如果沒有圖片,執行正常的文字貼上
super().keyPressEvent(event)
else:
# 其他按鍵正常處理
super().keyPressEvent(event)

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
超時控制組件
============
提供超時設置和倒數計時器顯示功能。
"""
from PySide6.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QLabel,
QSpinBox, QPushButton, QFrame
)
from PySide6.QtCore import Signal, QTimer, Qt
from PySide6.QtGui import QFont
from .switch import SwitchWidget
from ...i18n import t
from ...debug import gui_debug_log as debug_log
class TimeoutWidget(QWidget):
"""超時控制組件"""
# 信號
timeout_occurred = Signal() # 超時發生
settings_changed = Signal(bool, int) # 設置變更 (enabled, timeout_seconds)
def __init__(self, parent=None):
super().__init__(parent)
self.timeout_enabled = False
self.timeout_seconds = 600 # 預設 10 分鐘
self.remaining_seconds = 0
# 計時器
self.countdown_timer = QTimer()
self.countdown_timer.timeout.connect(self._update_countdown)
self._setup_ui()
self._connect_signals()
debug_log("超時控制組件初始化完成")
def _setup_ui(self):
"""設置用戶介面"""
# 主布局
main_layout = QHBoxLayout(self)
main_layout.setContentsMargins(8, 4, 8, 4)
main_layout.setSpacing(12)
# 超時開關區域
switch_layout = QHBoxLayout()
switch_layout.setSpacing(8)
self.timeout_label = QLabel(t('timeout.enable'))
self.timeout_label.setStyleSheet("color: #cccccc; font-size: 12px;")
switch_layout.addWidget(self.timeout_label)
self.timeout_switch = SwitchWidget()
self.timeout_switch.setToolTip(t('timeout.enableTooltip'))
switch_layout.addWidget(self.timeout_switch)
main_layout.addLayout(switch_layout)
# 分隔線
separator = QFrame()
separator.setFrameShape(QFrame.VLine)
separator.setFrameShadow(QFrame.Sunken)
separator.setStyleSheet("color: #464647;")
main_layout.addWidget(separator)
# 超時時間設置區域
time_layout = QHBoxLayout()
time_layout.setSpacing(8)
self.time_label = QLabel(t('timeout.duration.label'))
self.time_label.setStyleSheet("color: #cccccc; font-size: 12px;")
time_layout.addWidget(self.time_label)
self.time_spinbox = QSpinBox()
self.time_spinbox.setRange(30, 7200) # 30秒到2小時
self.time_spinbox.setValue(600) # 預設10分鐘
self.time_spinbox.setSuffix(" " + t('timeout.seconds'))
# 應用自定義樣式
style = self._get_spinbox_style(False)
self.time_spinbox.setStyleSheet(style)
debug_log("QSpinBox 樣式已應用")
time_layout.addWidget(self.time_spinbox)
main_layout.addLayout(time_layout)
# 分隔線
separator2 = QFrame()
separator2.setFrameShape(QFrame.VLine)
separator2.setFrameShadow(QFrame.Sunken)
separator2.setStyleSheet("color: #464647;")
main_layout.addWidget(separator2)
# 倒數計時器顯示區域
countdown_layout = QHBoxLayout()
countdown_layout.setSpacing(8)
self.countdown_label = QLabel(t('timeout.remaining'))
self.countdown_label.setStyleSheet("color: #cccccc; font-size: 12px;")
countdown_layout.addWidget(self.countdown_label)
self.countdown_display = QLabel("--:--")
self.countdown_display.setStyleSheet("""
color: #ffa500;
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
""")
countdown_layout.addWidget(self.countdown_display)
main_layout.addLayout(countdown_layout)
# 彈性空間
main_layout.addStretch()
# 初始狀態:隱藏倒數計時器
self._update_visibility()
def _connect_signals(self):
"""連接信號"""
self.timeout_switch.toggled.connect(self._on_timeout_enabled_changed)
self.time_spinbox.valueChanged.connect(self._on_timeout_duration_changed)
def _on_timeout_enabled_changed(self, enabled: bool):
"""超時啟用狀態變更"""
self.timeout_enabled = enabled
self._update_visibility()
if enabled:
self.start_countdown()
else:
self.stop_countdown()
self.settings_changed.emit(enabled, self.timeout_seconds)
debug_log(f"超時功能已{'啟用' if enabled else '停用'}")
def _on_timeout_duration_changed(self, seconds: int):
"""超時時間變更"""
self.timeout_seconds = seconds
# 如果正在倒數,重新開始
if self.timeout_enabled and self.countdown_timer.isActive():
self.start_countdown()
self.settings_changed.emit(self.timeout_enabled, seconds)
debug_log(f"超時時間設置為 {seconds}")
def _update_visibility(self):
"""更新組件可見性"""
# 倒數計時器只在啟用超時時顯示
self.countdown_label.setVisible(self.timeout_enabled)
self.countdown_display.setVisible(self.timeout_enabled)
# 時間設置在啟用時更明顯
style = self._get_spinbox_style(self.timeout_enabled)
self.time_spinbox.setStyleSheet(style)
debug_log(f"QSpinBox 樣式已更新 (啟用: {self.timeout_enabled})")
def start_countdown(self):
"""開始倒數計時"""
if not self.timeout_enabled:
return
self.remaining_seconds = self.timeout_seconds
self.countdown_timer.start(1000) # 每秒更新
self._update_countdown_display()
debug_log(f"開始倒數計時:{self.timeout_seconds}")
def stop_countdown(self):
"""停止倒數計時"""
self.countdown_timer.stop()
self.countdown_display.setText("--:--")
debug_log("倒數計時已停止")
def _update_countdown(self):
"""更新倒數計時"""
self.remaining_seconds -= 1
self._update_countdown_display()
if self.remaining_seconds <= 0:
self.countdown_timer.stop()
self.timeout_occurred.emit()
debug_log("倒數計時結束,觸發超時事件")
def _update_countdown_display(self):
"""更新倒數顯示"""
if self.remaining_seconds <= 0:
self.countdown_display.setText("00:00")
self.countdown_display.setStyleSheet("""
color: #ff4444;
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
""")
else:
minutes = self.remaining_seconds // 60
seconds = self.remaining_seconds % 60
time_text = f"{minutes:02d}:{seconds:02d}"
self.countdown_display.setText(time_text)
# 根據剩餘時間調整顏色
if self.remaining_seconds <= 60: # 最後1分鐘
color = "#ff4444" # 紅色
elif self.remaining_seconds <= 300: # 最後5分鐘
color = "#ffaa00" # 橙色
else:
color = "#ffa500" # 黃色
self.countdown_display.setStyleSheet(f"""
color: {color};
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
""")
def set_timeout_settings(self, enabled: bool, seconds: int):
"""設置超時參數"""
self.timeout_switch.setChecked(enabled)
self.time_spinbox.setValue(seconds)
self.timeout_enabled = enabled
self.timeout_seconds = seconds
self._update_visibility()
def get_timeout_settings(self) -> tuple[bool, int]:
"""獲取超時設置"""
return self.timeout_enabled, self.timeout_seconds
def update_texts(self):
"""更新界面文字(用於語言切換)"""
self.timeout_label.setText(t('timeout.enable'))
self.time_label.setText(t('timeout.duration.label'))
self.countdown_label.setText(t('timeout.remaining'))
self.timeout_switch.setToolTip(t('timeout.enableTooltip'))
self.time_spinbox.setSuffix(" " + t('timeout.seconds'))
def _get_spinbox_style(self, enabled: bool) -> str:
"""獲取 QSpinBox 的樣式字符串"""
border_color = "#007acc" if enabled else "#555555"
focus_color = "#0099ff" if enabled else "#007acc"
return f"""
QSpinBox {{
background-color: #3c3c3c;
border: 1px solid {border_color};
border-radius: 6px;
padding: 4px 8px;
color: #ffffff;
font-size: 12px;
min-width: 90px;
min-height: 24px;
}}
QSpinBox:focus {{
border-color: {focus_color};
background-color: #404040;
}}
QSpinBox:hover {{
background-color: #404040;
border-color: #666666;
}}
QSpinBox::up-button {{
subcontrol-origin: border;
subcontrol-position: top right;
width: 18px;
border-left: 1px solid #555555;
border-bottom: 1px solid #555555;
border-top-right-radius: 5px;
background-color: #4a4a4a;
}}
QSpinBox::up-button:hover {{
background-color: #5a5a5a;
}}
QSpinBox::up-button:pressed {{
background-color: #007acc;
}}
QSpinBox::down-button {{
subcontrol-origin: border;
subcontrol-position: bottom right;
width: 18px;
border-left: 1px solid #555555;
border-top: 1px solid #555555;
border-bottom-right-radius: 5px;
background-color: #4a4a4a;
}}
QSpinBox::down-button:hover {{
background-color: #5a5a5a;
}}
QSpinBox::down-button:pressed {{
background-color: #007acc;
}}
QSpinBox::up-arrow {{
width: 0px;
height: 0px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 6px solid #cccccc;
}}
QSpinBox::down-arrow {{
width: 0px;
height: 0px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #cccccc;
}}
"""

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI 窗口模組
============
包含各種窗口類別。
"""
from .feedback_window import FeedbackWindow
from .config_manager import ConfigManager
from .command_executor import CommandExecutor
from .tab_manager import TabManager
__all__ = [
'FeedbackWindow',
'ConfigManager',
'CommandExecutor',
'TabManager'
]

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
命令執行管理器
===============
負責處理命令執行、輸出讀取和進程管理。
"""
import os
import subprocess
import threading
import time
import queue
import select
import sys
from typing import Optional, Callable
from PySide6.QtCore import QObject, QTimer, Signal
from ...debug import gui_debug_log as debug_log
class CommandExecutor(QObject):
"""命令執行管理器"""
output_received = Signal(str) # 輸出接收信號
def __init__(self, project_dir: str, parent=None):
super().__init__(parent)
self.project_dir = project_dir
self.command_process: Optional[subprocess.Popen] = None
self.timer: Optional[QTimer] = None
self._output_queue: Optional[queue.Queue] = None
self._reader_thread: Optional[threading.Thread] = None
self._command_start_time: Optional[float] = None
def run_command(self, command: str) -> None:
"""執行命令"""
if not command.strip():
return
# 如果已經有命令在執行,先停止
if self.timer and self.timer.isActive():
self.terminate_command()
self.output_received.emit(f"$ {command}\n")
# 保存當前命令用於輸出過濾
self._last_command = command
try:
# 準備環境變數以避免不必要的輸出
env = os.environ.copy()
env['NO_UPDATE_NOTIFIER'] = '1'
env['NPM_CONFIG_UPDATE_NOTIFIER'] = 'false'
env['NPM_CONFIG_FUND'] = 'false'
env['NPM_CONFIG_AUDIT'] = 'false'
env['PYTHONUNBUFFERED'] = '1'
# 啟動進程
self.command_process = subprocess.Popen(
command,
shell=True,
cwd=self.project_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=0,
env=env,
universal_newlines=True
)
# 設置計時器來定期讀取輸出
if not self.timer:
self.timer = QTimer()
self.timer.timeout.connect(self._read_command_output)
self.timer.start(100) # 每100ms檢查一次
self._command_start_time = time.time()
debug_log(f"命令已啟動: {command}")
except Exception as e:
self.output_received.emit(f"錯誤: 無法執行命令 - {str(e)}\n")
debug_log(f"命令執行錯誤: {e}")
def terminate_command(self) -> None:
"""終止正在運行的命令"""
if self.command_process and self.command_process.poll() is None:
try:
self.command_process.terminate()
self.output_received.emit("命令已被用戶終止。\n")
debug_log("用戶終止了正在運行的命令")
# 停止計時器
if self.timer:
self.timer.stop()
except Exception as e:
debug_log(f"終止命令失敗: {e}")
self.output_received.emit(f"終止命令失敗: {e}\n")
else:
self.output_received.emit("沒有正在運行的命令可以終止。\n")
def _read_command_output(self) -> None:
"""讀取命令輸出(非阻塞方式)"""
if not self.command_process:
if self.timer:
self.timer.stop()
return
# 檢查進程是否還在運行
if self.command_process.poll() is None:
try:
if sys.platform == "win32":
# Windows 下使用隊列方式
try:
if not self._output_queue:
self._output_queue = queue.Queue()
self._reader_thread = threading.Thread(
target=self._read_process_output_thread,
daemon=True
)
self._reader_thread.start()
# 從隊列中獲取輸出(非阻塞)
try:
while True:
output = self._output_queue.get_nowait()
if output is None: # 進程結束信號
break
self.output_received.emit(output)
except queue.Empty:
pass # 沒有新輸出,繼續等待
except ImportError:
output = self.command_process.stdout.readline()
if output:
filtered_output = self._filter_command_output(output)
if filtered_output:
self.output_received.emit(filtered_output)
else:
# Unix/Linux/macOS 下使用 select
ready, _, _ = select.select([self.command_process.stdout], [], [], 0.1)
if ready:
output = self.command_process.stdout.readline()
if output:
filtered_output = self._filter_command_output(output)
if filtered_output:
self.output_received.emit(filtered_output)
# 檢查命令執行超時30秒
if self._command_start_time and time.time() - self._command_start_time > 30:
self.output_received.emit(f"\n⚠️ 命令執行超過30秒自動終止...")
self.terminate_command()
except Exception as e:
debug_log(f"讀取命令輸出錯誤: {e}")
else:
# 進程結束,停止計時器並讀取剩餘輸出
if self.timer:
self.timer.stop()
# 清理資源
self._cleanup_resources()
try:
# 讀取剩餘的輸出
remaining_output, _ = self.command_process.communicate(timeout=2)
if remaining_output and remaining_output.strip():
filtered_output = self._filter_command_output(remaining_output)
if filtered_output:
self.output_received.emit(filtered_output)
except subprocess.TimeoutExpired:
debug_log("讀取剩餘輸出超時")
except Exception as e:
debug_log(f"讀取剩餘輸出錯誤: {e}")
return_code = self.command_process.returncode
self.output_received.emit(f"\n進程結束,返回碼: {return_code}\n")
def _read_process_output_thread(self) -> None:
"""在背景線程中讀取進程輸出"""
try:
while self.command_process and self.command_process.poll() is None:
output = self.command_process.stdout.readline()
if output:
self._output_queue.put(output)
else:
break
# 進程結束信號
if self._output_queue:
self._output_queue.put(None)
except Exception as e:
debug_log(f"背景讀取線程錯誤: {e}")
def _filter_command_output(self, output: str) -> str:
"""過濾命令輸出,移除不必要的行"""
if not output:
return ""
# 要過濾的字串(避免干擾的輸出)
filter_patterns = [
"npm notice",
"npm WARN deprecated",
"npm fund",
"npm audit",
"found 0 vulnerabilities",
"Run `npm audit` for details",
"[##", # 進度條
"⸩ ░░░░░░░░░░░░░░░░" # 其他進度指示器
]
# 檢查是否需要過濾
for pattern in filter_patterns:
if pattern in output:
return ""
return output
def _cleanup_resources(self) -> None:
"""清理資源"""
if hasattr(self, '_output_queue') and self._output_queue:
self._output_queue = None
if hasattr(self, '_reader_thread') and self._reader_thread:
self._reader_thread = None
if hasattr(self, '_command_start_time') and self._command_start_time:
self._command_start_time = None
def cleanup(self) -> None:
"""清理所有資源"""
if self.command_process and self.command_process.poll() is None:
try:
self.command_process.terminate()
debug_log("已終止正在運行的命令")
except Exception as e:
debug_log(f"終止命令失敗: {e}")
if self.timer:
self.timer.stop()
self._cleanup_resources()

View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
配置管理器
===========
負責處理用戶配置的載入、保存和管理。
"""
import json
from pathlib import Path
from typing import Dict, Any
from ...debug import gui_debug_log as debug_log
class ConfigManager:
"""配置管理器"""
def __init__(self):
self._config_file = self._get_config_file_path()
self._config_cache = {}
self._load_config()
def _get_config_file_path(self) -> Path:
"""獲取配置文件路徑"""
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir / "ui_settings.json"
def _load_config(self) -> None:
"""載入配置"""
try:
if self._config_file.exists():
with open(self._config_file, 'r', encoding='utf-8') as f:
self._config_cache = json.load(f)
debug_log("配置文件載入成功")
else:
self._config_cache = {}
debug_log("配置文件不存在,使用預設配置")
except Exception as e:
debug_log(f"載入配置失敗: {e}")
self._config_cache = {}
def _save_config(self) -> None:
"""保存配置"""
try:
with open(self._config_file, 'w', encoding='utf-8') as f:
json.dump(self._config_cache, f, ensure_ascii=False, indent=2)
debug_log("配置文件保存成功")
except Exception as e:
debug_log(f"保存配置失敗: {e}")
def get(self, key: str, default: Any = None) -> Any:
"""獲取配置值"""
return self._config_cache.get(key, default)
def set(self, key: str, value: Any) -> None:
"""設置配置值"""
self._config_cache[key] = value
self._save_config()
def update_partial_config(self, updates: Dict[str, Any]) -> None:
"""批量更新配置項目,只保存指定的設定而不影響其他參數"""
try:
# 重新載入當前磁碟上的配置,確保我們有最新的數據
current_config = {}
if self._config_file.exists():
with open(self._config_file, 'r', encoding='utf-8') as f:
current_config = json.load(f)
# 只更新指定的項目
for key, value in updates.items():
current_config[key] = value
# 同時更新內存緩存
self._config_cache[key] = value
# 保存到文件
with open(self._config_file, 'w', encoding='utf-8') as f:
json.dump(current_config, f, ensure_ascii=False, indent=2)
debug_log(f"部分配置已更新: {list(updates.keys())}")
except Exception as e:
debug_log(f"更新部分配置失敗: {e}")
def get_layout_mode(self) -> bool:
"""獲取佈局模式False=分離模式True=合併模式)"""
return self.get('combined_mode', False)
def set_layout_mode(self, combined_mode: bool) -> None:
"""設置佈局模式"""
self.update_partial_config({'combined_mode': combined_mode})
debug_log(f"佈局模式設置: {'合併模式' if combined_mode else '分離模式'}")
def get_layout_orientation(self) -> str:
"""獲取佈局方向vertical=垂直上下horizontal=水平(左右))"""
return self.get('layout_orientation', 'vertical')
def set_layout_orientation(self, orientation: str) -> None:
"""設置佈局方向"""
if orientation not in ['vertical', 'horizontal']:
orientation = 'vertical'
self.update_partial_config({'layout_orientation': orientation})
debug_log(f"佈局方向設置: {'垂直(上下)' if orientation == 'vertical' else '水平(左右)'}")
def get_language(self) -> str:
"""獲取語言設置"""
return self.get('language', 'zh-TW')
def set_language(self, language: str) -> None:
"""設置語言"""
self.update_partial_config({'language': language})
debug_log(f"語言設置: {language}")
def get_splitter_sizes(self, splitter_name: str) -> list:
"""獲取分割器尺寸"""
sizes = self.get(f'splitter_sizes.{splitter_name}', [])
if sizes:
debug_log(f"載入分割器 {splitter_name} 尺寸: {sizes}")
return sizes
def set_splitter_sizes(self, splitter_name: str, sizes: list) -> None:
"""設置分割器尺寸"""
self.update_partial_config({f'splitter_sizes.{splitter_name}': sizes})
debug_log(f"保存分割器 {splitter_name} 尺寸: {sizes}")
def get_window_geometry(self) -> dict:
"""獲取窗口幾何信息"""
geometry = self.get('window_geometry', {})
if geometry:
debug_log(f"載入窗口幾何信息: {geometry}")
return geometry
def set_window_geometry(self, geometry: dict) -> None:
"""設置窗口幾何信息(使用部分更新避免覆蓋其他設定)"""
self.update_partial_config({'window_geometry': geometry})
debug_log(f"保存窗口幾何信息: {geometry}")
def get_always_center_window(self) -> bool:
"""獲取總是在主螢幕中心顯示視窗的設置"""
return self.get('always_center_window', False)
def set_always_center_window(self, always_center: bool) -> None:
"""設置總是在主螢幕中心顯示視窗"""
self.update_partial_config({'always_center_window': always_center})
debug_log(f"視窗定位設置: {'總是中心顯示' if always_center else '智能定位'}")
def get_image_size_limit(self) -> int:
"""獲取圖片大小限制bytes0 表示無限制"""
return self.get('image_size_limit', 0)
def set_image_size_limit(self, size_bytes: int) -> None:
"""設置圖片大小限制bytes0 表示無限制"""
# 處理 None 值
if size_bytes is None:
size_bytes = 0
self.update_partial_config({'image_size_limit': size_bytes})
size_mb = size_bytes / (1024 * 1024) if size_bytes > 0 else 0
debug_log(f"圖片大小限制設置: {'無限制' if size_bytes == 0 else f'{size_mb:.1f}MB'}")
def get_enable_base64_detail(self) -> bool:
"""獲取是否啟用 Base64 詳細模式"""
return self.get('enable_base64_detail', False)
def set_enable_base64_detail(self, enabled: bool) -> None:
"""設置是否啟用 Base64 詳細模式"""
self.update_partial_config({'enable_base64_detail': enabled})
debug_log(f"Base64 詳細模式設置: {'啟用' if enabled else '停用'}")
def get_timeout_enabled(self) -> bool:
"""獲取是否啟用超時自動關閉"""
return self.get('timeout_enabled', False)
def set_timeout_enabled(self, enabled: bool) -> None:
"""設置是否啟用超時自動關閉"""
self.update_partial_config({'timeout_enabled': enabled})
debug_log(f"超時自動關閉設置: {'啟用' if enabled else '停用'}")
def get_timeout_duration(self) -> int:
"""獲取超時時間(秒)"""
return self.get('timeout_duration', 600) # 預設10分鐘
def set_timeout_duration(self, seconds: int) -> None:
"""設置超時時間(秒)"""
self.update_partial_config({'timeout_duration': seconds})
debug_log(f"超時時間設置: {seconds}")
def get_timeout_settings(self) -> tuple[bool, int]:
"""獲取超時設置(啟用狀態, 超時時間)"""
return self.get_timeout_enabled(), self.get_timeout_duration()
def set_timeout_settings(self, enabled: bool, seconds: int) -> None:
"""設置超時設置"""
self.update_partial_config({
'timeout_enabled': enabled,
'timeout_duration': seconds
})
debug_log(f"超時設置: {'啟用' if enabled else '停用'}, {seconds}")
def reset_settings(self) -> None:
"""重置所有設定到預設值"""
try:
# 清空配置緩存
self._config_cache = {}
# 刪除配置文件
if self._config_file.exists():
self._config_file.unlink()
debug_log("配置文件已刪除")
debug_log("所有設定已重置到預設值")
except Exception as e:
debug_log(f"重置設定失敗: {e}")
raise

View File

@@ -0,0 +1,791 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回饋收集主窗口(重構版)
========================
簡化的主窗口,專注於主要職責:窗口管理和協調各組件。
"""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QTabWidget, QPushButton, QMessageBox, QScrollArea, QSizePolicy
)
from PySide6.QtCore import Signal, Qt, QTimer
from PySide6.QtGui import QKeySequence, QShortcut
from .config_manager import ConfigManager
from .tab_manager import TabManager
from ..utils import apply_widget_styles
from ...i18n import t, get_i18n_manager
from ...debug import gui_debug_log as debug_log
class FeedbackWindow(QMainWindow):
"""回饋收集主窗口(重構版)"""
language_changed = Signal()
timeout_occurred = Signal() # 超時發生信號
def __init__(self, project_dir: str, summary: str, timeout_seconds: int = None):
super().__init__()
self.project_dir = project_dir
self.summary = summary
self.result = None
self.i18n = get_i18n_manager()
self.mcp_timeout_seconds = timeout_seconds # MCP 傳入的超時時間
# 初始化組件
self.config_manager = ConfigManager()
# 載入保存的語言設定
saved_language = self.config_manager.get_language()
if saved_language:
self.i18n.set_language(saved_language)
self.combined_mode = self.config_manager.get_layout_mode()
self.layout_orientation = self.config_manager.get_layout_orientation()
# 設置窗口狀態保存的防抖計時器
self._save_timer = QTimer()
self._save_timer.setSingleShot(True)
self._save_timer.timeout.connect(self._delayed_save_window_position)
self._save_delay = 500 # 500ms 延遲,避免過於頻繁的保存
# 設置UI
self._setup_ui()
self._setup_shortcuts()
self._connect_signals()
debug_log("主窗口初始化完成")
# 如果啟用了超時,自動開始倒數計時
self.start_timeout_if_enabled()
def _setup_ui(self) -> None:
"""設置用戶介面"""
self.setWindowTitle(t('app.title'))
self.setMinimumSize(400, 300) # 大幅降低最小窗口大小限制,允許用戶自由調整
self.resize(1200, 900)
# 智能視窗定位
self._apply_window_positioning()
# 中央元件
central_widget = QWidget()
self.setCentralWidget(central_widget)
# 主布局
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(8)
main_layout.setContentsMargins(16, 8, 16, 12)
# 頂部專案目錄信息
self._create_project_header(main_layout)
# 分頁區域
self._create_tab_area(main_layout)
# 操作按鈕
self._create_action_buttons(main_layout)
# 應用深色主題
self._apply_dark_style()
def _create_project_header(self, layout: QVBoxLayout) -> None:
"""創建專案目錄頭部信息"""
# 創建水平布局來放置專案目錄和倒數計時器
header_layout = QHBoxLayout()
self.project_label = QLabel(f"{t('app.projectDirectory')}: {self.project_dir}")
self.project_label.setStyleSheet("color: #9e9e9e; font-size: 12px; padding: 4px 0;")
header_layout.addWidget(self.project_label)
# 添加彈性空間
header_layout.addStretch()
# 添加倒數計時器顯示(僅顯示部分)
self._create_countdown_display(header_layout)
# 將水平布局添加到主布局
header_widget = QWidget()
header_widget.setLayout(header_layout)
layout.addWidget(header_widget)
def _create_countdown_display(self, layout: QHBoxLayout) -> None:
"""創建倒數計時器顯示組件(僅顯示)"""
# 倒數計時器標籤
self.countdown_label = QLabel(t('timeout.remaining'))
self.countdown_label.setStyleSheet("color: #cccccc; font-size: 12px;")
self.countdown_label.setVisible(False) # 預設隱藏
layout.addWidget(self.countdown_label)
# 倒數計時器顯示
self.countdown_display = QLabel("--:--")
self.countdown_display.setStyleSheet("""
color: #ffa500;
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
margin-left: 8px;
""")
self.countdown_display.setVisible(False) # 預設隱藏
layout.addWidget(self.countdown_display)
# 初始化超時控制邏輯
self._init_timeout_logic()
def _init_timeout_logic(self) -> None:
"""初始化超時控制邏輯"""
# 載入保存的超時設置
timeout_enabled, timeout_duration = self.config_manager.get_timeout_settings()
# 如果有 MCP 超時參數,且用戶設置的時間大於 MCP 時間,則使用 MCP 時間
if self.mcp_timeout_seconds is not None:
if timeout_duration > self.mcp_timeout_seconds:
timeout_duration = self.mcp_timeout_seconds
debug_log(f"用戶設置的超時時間 ({timeout_duration}s) 大於 MCP 超時時間 ({self.mcp_timeout_seconds}s),使用 MCP 時間")
# 保存超時設置
self.timeout_enabled = timeout_enabled
self.timeout_duration = timeout_duration
self.remaining_seconds = 0
# 創建計時器
self.countdown_timer = QTimer()
self.countdown_timer.timeout.connect(self._update_countdown)
# 更新顯示狀態
self._update_countdown_visibility()
def _create_tab_area(self, layout: QVBoxLayout) -> None:
"""創建分頁區域"""
# 創建滾動區域來包裝整個分頁組件
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setMinimumHeight(150) # 降低滾動區域最小高度,支持小窗口
scroll_area.setStyleSheet("""
QScrollArea {
border: 1px solid #464647;
border-radius: 4px;
background-color: #2b2b2b;
}
QScrollArea > QWidget > QWidget {
background-color: #2b2b2b;
}
QScrollArea QScrollBar:vertical {
background-color: #2a2a2a;
width: 8px;
border-radius: 4px;
margin: 0;
}
QScrollArea QScrollBar::handle:vertical {
background-color: #555;
border-radius: 4px;
min-height: 20px;
margin: 1px;
}
QScrollArea QScrollBar::handle:vertical:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:vertical,
QScrollArea QScrollBar::sub-line:vertical {
border: none;
background: none;
height: 0px;
}
QScrollArea QScrollBar:horizontal {
background-color: #2a2a2a;
height: 8px;
border-radius: 4px;
margin: 0;
}
QScrollArea QScrollBar::handle:horizontal {
background-color: #555;
border-radius: 4px;
min-width: 20px;
margin: 1px;
}
QScrollArea QScrollBar::handle:horizontal:hover {
background-color: #777;
}
QScrollArea QScrollBar::add-line:horizontal,
QScrollArea QScrollBar::sub-line:horizontal {
border: none;
background: none;
width: 0px;
}
""")
self.tab_widget = QTabWidget()
self.tab_widget.setMinimumHeight(150) # 降低分頁組件最小高度
# 設置分頁組件的大小策略,確保能觸發滾動
self.tab_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# 初始化分頁管理器
self.tab_manager = TabManager(
self.tab_widget,
self.project_dir,
self.summary,
self.combined_mode,
self.layout_orientation
)
# 創建分頁
self.tab_manager.create_tabs()
# 連接分頁信號
self.tab_manager.connect_signals(self)
# 將分頁組件放入滾動區域
scroll_area.setWidget(self.tab_widget)
layout.addWidget(scroll_area, 1)
def _create_action_buttons(self, layout: QVBoxLayout) -> None:
"""創建操作按鈕"""
button_layout = QHBoxLayout()
button_layout.addStretch()
# 取消按鈕
self.cancel_button = QPushButton(t('buttons.cancel'))
self.cancel_button.clicked.connect(self._cancel_feedback)
self.cancel_button.setFixedSize(130, 40)
apply_widget_styles(self.cancel_button, "secondary_button")
button_layout.addWidget(self.cancel_button)
# 提交按鈕
self.submit_button = QPushButton(t('buttons.submit'))
self.submit_button.clicked.connect(self._submit_feedback)
self.submit_button.setFixedSize(160, 40)
self.submit_button.setDefault(True)
apply_widget_styles(self.submit_button, "success_button")
button_layout.addWidget(self.submit_button)
layout.addLayout(button_layout)
def _setup_shortcuts(self) -> None:
"""設置快捷鍵"""
# Ctrl+Enter (主鍵盤) 提交回饋
submit_shortcut_main = QShortcut(QKeySequence("Ctrl+Return"), self)
submit_shortcut_main.activated.connect(self._submit_feedback)
# Ctrl+Enter (數字鍵盤) 提交回饋
submit_shortcut_keypad = QShortcut(QKeySequence(Qt.Modifier.CTRL | Qt.Key.Key_Enter), self)
submit_shortcut_keypad.activated.connect(self._submit_feedback)
# macOS 支援 Cmd+Return (主鍵盤)
submit_shortcut_mac_main = QShortcut(QKeySequence("Meta+Return"), self)
submit_shortcut_mac_main.activated.connect(self._submit_feedback)
# macOS 支援 Cmd+Enter (數字鍵盤)
submit_shortcut_mac_keypad = QShortcut(QKeySequence(Qt.Modifier.META | Qt.Key.Key_Enter), self)
submit_shortcut_mac_keypad.activated.connect(self._submit_feedback)
# Escape 取消回饋
cancel_shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self)
cancel_shortcut.activated.connect(self._cancel_feedback)
def _connect_signals(self) -> None:
"""連接信號"""
# 連接語言變更信號
self.language_changed.connect(self._refresh_ui_texts)
# 連接分頁管理器的信號
self.tab_manager.connect_signals(self)
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;
background-color: #2b2b2b;
}
QTabBar::tab {
background-color: #2d2d30;
color: #ffffff;
border: 1px solid #464647;
padding: 8px 16px;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: #007acc;
}
QSplitter {
background-color: #2b2b2b;
}
QSplitter::handle {
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 3px;
margin: 0px;
}
QSplitter::handle:horizontal {
width: 6px;
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 3px;
margin: 0px;
}
QSplitter::handle:vertical {
height: 6px;
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 3px;
margin: 0px;
}
QSplitter::handle:hover {
background-color: #606060;
border-color: #808080;
}
QSplitter::handle:pressed {
background-color: #007acc;
border-color: #005a9e;
}
""")
def _on_layout_change_requested(self, combined_mode: bool, orientation: str) -> None:
"""處理佈局變更請求(模式和方向同時變更)"""
try:
# 保存當前內容
current_data = self.tab_manager.get_feedback_data()
# 記住當前分頁索引
current_tab_index = self.tab_widget.currentIndex()
# 保存新設置
self.combined_mode = combined_mode
self.layout_orientation = orientation
self.config_manager.set_layout_mode(combined_mode)
self.config_manager.set_layout_orientation(orientation)
# 重新創建分頁
self.tab_manager.set_layout_mode(combined_mode)
self.tab_manager.set_layout_orientation(orientation)
self.tab_manager.create_tabs()
# 恢復內容
self.tab_manager.restore_content(
current_data["interactive_feedback"],
current_data["command_logs"],
current_data["images"]
)
# 重新連接信號
self.tab_manager.connect_signals(self)
# 刷新UI文字
self._refresh_ui_texts()
# 恢復到設定頁面(通常是倒數第二個分頁)
if self.combined_mode:
# 合併模式:回饋、命令、設置、關於
settings_tab_index = 2
else:
# 分離模式:回饋、摘要、命令、設置、關於
settings_tab_index = 3
# 確保索引在有效範圍內
if settings_tab_index < self.tab_widget.count():
self.tab_widget.setCurrentIndex(settings_tab_index)
mode_text = "合併模式" if combined_mode else "分離模式"
orientation_text = "(水平布局)" if orientation == "horizontal" else "(垂直布局)"
if combined_mode:
mode_text += orientation_text
debug_log(f"佈局已切換到: {mode_text}")
except Exception as e:
debug_log(f"佈局變更失敗: {e}")
QMessageBox.warning(self, t('errors.title'), t('errors.interfaceReloadError', error=str(e)))
def _on_reset_settings_requested(self) -> None:
"""處理重置設定請求"""
try:
# 重置配置管理器的所有設定
self.config_manager.reset_settings()
# 重置應用程式狀態
self.combined_mode = False # 重置為分離模式
self.layout_orientation = 'vertical' # 重置為垂直布局
# 重新設置語言為預設
self.i18n.set_language('zh-TW')
# 保存當前內容
current_data = self.tab_manager.get_feedback_data()
# 重新創建分頁
self.tab_manager.set_layout_mode(self.combined_mode)
self.tab_manager.set_layout_orientation(self.layout_orientation)
self.tab_manager.create_tabs()
# 恢復內容
self.tab_manager.restore_content(
current_data["interactive_feedback"],
current_data["command_logs"],
current_data["images"]
)
# 重新連接信號
self.tab_manager.connect_signals(self)
# 重新載入設定分頁的狀態
if self.tab_manager.settings_tab:
self.tab_manager.settings_tab.reload_settings_from_config()
# 刷新UI文字
self._refresh_ui_texts()
# 重新應用視窗定位(使用重置後的設定)
self._apply_window_positioning()
# 切換到設定分頁顯示重置結果
settings_tab_index = 3 # 分離模式下設定分頁是第4個索引3
if settings_tab_index < self.tab_widget.count():
self.tab_widget.setCurrentIndex(settings_tab_index)
# 顯示成功訊息
QMessageBox.information(
self,
t('settings.reset.successTitle'),
t('settings.reset.successMessage'),
QMessageBox.Ok
)
debug_log("設定重置成功")
except Exception as e:
debug_log(f"重置設定失敗: {e}")
QMessageBox.critical(
self,
t('errors.title'),
t('settings.reset.error', error=str(e)),
QMessageBox.Ok
)
def _submit_feedback(self) -> None:
"""提交回饋"""
# 獲取所有回饋數據
data = self.tab_manager.get_feedback_data()
self.result = data
debug_log(f"回饋提交: 文字長度={len(data['interactive_feedback'])}, "
f"命令日誌長度={len(data['command_logs'])}, "
f"圖片數量={len(data['images'])}")
# 關閉窗口
self.close()
def _cancel_feedback(self) -> None:
"""取消回饋收集"""
debug_log("取消回饋收集")
self.result = ""
self.close()
def force_close(self) -> None:
"""強制關閉視窗(用於超時處理)"""
debug_log("強制關閉視窗(超時)")
self.result = ""
self.close()
def _on_timeout_occurred(self) -> None:
"""處理超時事件"""
debug_log("用戶設置的超時時間已到,自動關閉視窗")
self._timeout_occurred = True
self.timeout_occurred.emit()
self.force_close()
def start_timeout_if_enabled(self) -> None:
"""如果啟用了超時,自動開始倒數計時"""
if hasattr(self, 'tab_manager') and self.tab_manager:
timeout_widget = self.tab_manager.get_timeout_widget()
if timeout_widget:
enabled, _ = timeout_widget.get_timeout_settings()
if enabled:
timeout_widget.start_countdown()
debug_log("窗口顯示時自動開始倒數計時")
def _on_timeout_settings_changed(self, enabled: bool, seconds: int) -> None:
"""處理超時設置變更(從設置頁籤觸發)"""
# 檢查是否超過 MCP 超時限制
if self.mcp_timeout_seconds is not None and seconds > self.mcp_timeout_seconds:
debug_log(f"用戶設置的超時時間 ({seconds}s) 超過 MCP 限制 ({self.mcp_timeout_seconds}s),調整為 MCP 時間")
seconds = self.mcp_timeout_seconds
# 更新內部狀態
self.timeout_enabled = enabled
self.timeout_duration = seconds
# 保存設置
self.config_manager.set_timeout_settings(enabled, seconds)
debug_log(f"超時設置已更新: {'啟用' if enabled else '停用'}, {seconds}")
# 更新倒數計時器顯示
self._update_countdown_visibility()
# 重新開始倒數計時
if enabled:
self.start_countdown()
else:
self.stop_countdown()
def start_timeout_if_enabled(self) -> None:
"""如果啟用了超時,開始倒數計時"""
if self.timeout_enabled:
self.start_countdown()
debug_log("超時倒數計時已開始")
def stop_timeout(self) -> None:
"""停止超時倒數計時"""
self.stop_countdown()
debug_log("超時倒數計時已停止")
def start_countdown(self) -> None:
"""開始倒數計時"""
if not self.timeout_enabled:
return
self.remaining_seconds = self.timeout_duration
self.countdown_timer.start(1000) # 每秒更新
self._update_countdown_display()
debug_log(f"開始倒數計時:{self.timeout_duration}")
def stop_countdown(self) -> None:
"""停止倒數計時"""
self.countdown_timer.stop()
self.countdown_display.setText("--:--")
debug_log("倒數計時已停止")
def _update_countdown(self) -> None:
"""更新倒數計時"""
self.remaining_seconds -= 1
self._update_countdown_display()
if self.remaining_seconds <= 0:
self.countdown_timer.stop()
self._on_timeout_occurred()
debug_log("倒數計時結束,觸發超時事件")
def _update_countdown_display(self) -> None:
"""更新倒數顯示"""
if self.remaining_seconds <= 0:
self.countdown_display.setText("00:00")
self.countdown_display.setStyleSheet("""
color: #ff4444;
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
margin-left: 8px;
""")
else:
minutes = self.remaining_seconds // 60
seconds = self.remaining_seconds % 60
time_text = f"{minutes:02d}:{seconds:02d}"
self.countdown_display.setText(time_text)
# 根據剩餘時間調整顏色
if self.remaining_seconds <= 60: # 最後1分鐘
color = "#ff4444" # 紅色
elif self.remaining_seconds <= 300: # 最後5分鐘
color = "#ffaa00" # 橙色
else:
color = "#ffa500" # 黃色
self.countdown_display.setStyleSheet(f"""
color: {color};
font-size: 14px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 50px;
margin-left: 8px;
""")
def _update_countdown_visibility(self) -> None:
"""更新倒數計時器可見性"""
# 倒數計時器只在啟用超時時顯示
self.countdown_label.setVisible(self.timeout_enabled)
self.countdown_display.setVisible(self.timeout_enabled)
def _refresh_ui_texts(self) -> None:
"""刷新界面文字"""
self.setWindowTitle(t('app.title'))
self.project_label.setText(f"{t('app.projectDirectory')}: {self.project_dir}")
# 更新按鈕文字
self.submit_button.setText(t('buttons.submit'))
self.cancel_button.setText(t('buttons.cancel'))
# 更新倒數計時器文字
if hasattr(self, 'countdown_label'):
self.countdown_label.setText(t('timeout.remaining'))
# 更新分頁文字
self.tab_manager.update_tab_texts()
def _apply_window_positioning(self) -> None:
"""根據用戶設置應用視窗定位策略"""
always_center = self.config_manager.get_always_center_window()
if always_center:
# 總是中心顯示模式:使用保存的大小(如果有的話),然後置中
self._restore_window_size_only()
self._move_to_primary_screen_center()
else:
# 智能定位模式:先嘗試恢復上次完整的位置和大小
if self._restore_last_position():
# 檢查恢復的位置是否可見
if not self._is_window_visible():
self._move_to_primary_screen_center()
else:
# 沒有保存的位置,移到中心
self._move_to_primary_screen_center()
def _is_window_visible(self) -> bool:
"""檢查視窗是否在任何螢幕的可見範圍內"""
from PySide6.QtWidgets import QApplication
window_rect = self.frameGeometry()
for screen in QApplication.screens():
if screen.availableGeometry().intersects(window_rect):
return True
return False
def _move_to_primary_screen_center(self) -> None:
"""將視窗移到主螢幕中心"""
from PySide6.QtWidgets import QApplication
screen = QApplication.primaryScreen()
if screen:
screen_geometry = screen.availableGeometry()
window_geometry = self.frameGeometry()
center_point = screen_geometry.center()
window_geometry.moveCenter(center_point)
self.move(window_geometry.topLeft())
debug_log("視窗已移到主螢幕中心")
def _restore_window_size_only(self) -> bool:
"""只恢復視窗大小(不恢復位置)"""
try:
geometry = self.config_manager.get_window_geometry()
if geometry and 'width' in geometry and 'height' in geometry:
self.resize(geometry['width'], geometry['height'])
debug_log(f"已恢復視窗大小: {geometry['width']}x{geometry['height']}")
return True
except Exception as e:
debug_log(f"恢復視窗大小失敗: {e}")
return False
def _restore_last_position(self) -> bool:
"""嘗試恢復上次保存的視窗位置和大小"""
try:
geometry = self.config_manager.get_window_geometry()
if geometry and 'x' in geometry and 'y' in geometry and 'width' in geometry and 'height' in geometry:
self.move(geometry['x'], geometry['y'])
self.resize(geometry['width'], geometry['height'])
debug_log(f"已恢復視窗位置: ({geometry['x']}, {geometry['y']}) 大小: {geometry['width']}x{geometry['height']}")
return True
except Exception as e:
debug_log(f"恢復視窗位置失敗: {e}")
return False
def _save_window_position(self) -> None:
"""保存當前視窗位置和大小"""
try:
always_center = self.config_manager.get_always_center_window()
# 獲取當前幾何信息
current_geometry = {
'width': self.width(),
'height': self.height()
}
if not always_center:
# 智能定位模式:同時保存位置
current_geometry['x'] = self.x()
current_geometry['y'] = self.y()
debug_log(f"已保存視窗位置: ({current_geometry['x']}, {current_geometry['y']}) 大小: {current_geometry['width']}x{current_geometry['height']}")
else:
# 總是中心顯示模式:只保存大小,不保存位置
debug_log(f"已保存視窗大小: {current_geometry['width']}x{current_geometry['height']} (總是中心顯示模式)")
# 獲取現有配置,只更新需要的部分
saved_geometry = self.config_manager.get_window_geometry() or {}
saved_geometry.update(current_geometry)
self.config_manager.set_window_geometry(saved_geometry)
except Exception as e:
debug_log(f"保存視窗狀態失敗: {e}")
def resizeEvent(self, event) -> None:
"""窗口大小變化事件"""
super().resizeEvent(event)
# 窗口大小變化時始終保存(無論是否設置為中心顯示)
if hasattr(self, 'config_manager'):
self._schedule_save_window_position()
def moveEvent(self, event) -> None:
"""窗口位置變化事件"""
super().moveEvent(event)
# 窗口位置變化只在智能定位模式下保存
if hasattr(self, 'config_manager') and not self.config_manager.get_always_center_window():
self._schedule_save_window_position()
def _schedule_save_window_position(self) -> None:
"""調度窗口位置保存(防抖機制)"""
if hasattr(self, '_save_timer'):
self._save_timer.start(self._save_delay)
def _delayed_save_window_position(self) -> None:
"""延遲保存窗口位置(防抖機制的實際執行)"""
self._save_window_position()
def closeEvent(self, event) -> None:
"""窗口關閉事件"""
# 最終保存視窗狀態(大小始終保存,位置根據設置決定)
self._save_window_position()
# 清理分頁管理器
self.tab_manager.cleanup()
event.accept()
debug_log("主窗口已關閉")

View File

@@ -0,0 +1,358 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
分頁管理器
==========
負責管理和創建各種分頁組件。
"""
from typing import Dict, Any
from PySide6.QtWidgets import QTabWidget, QSplitter, QWidget, QVBoxLayout, QScrollArea, QSizePolicy
from PySide6.QtCore import Signal, Qt
from ..tabs import FeedbackTab, SummaryTab, CommandTab, SettingsTab, AboutTab
from ..widgets import SmartTextEdit, ImageUploadWidget
from ...i18n import t
from ...debug import gui_debug_log as debug_log
from .config_manager import ConfigManager
class TabManager:
"""分頁管理器"""
def __init__(self, tab_widget: QTabWidget, project_dir: str, summary: str, combined_mode: bool, layout_orientation: str = 'vertical'):
self.tab_widget = tab_widget
self.project_dir = project_dir
self.summary = summary
self.combined_mode = combined_mode
self.layout_orientation = layout_orientation
# 配置管理器
self.config_manager = ConfigManager()
# 分頁組件實例
self.feedback_tab = None
self.summary_tab = None
self.command_tab = None
self.settings_tab = None
self.about_tab = None
self.combined_feedback_tab = None
def create_tabs(self) -> None:
"""創建所有分頁"""
# 清除現有分頁
self.tab_widget.clear()
if self.combined_mode:
# 合併模式回饋頁包含AI摘要
self._create_combined_feedback_tab()
self.tab_widget.addTab(self.combined_feedback_tab, t('tabs.feedback'))
else:
# 分離模式:分別的回饋和摘要頁
self.feedback_tab = FeedbackTab()
self.tab_widget.addTab(self.feedback_tab, t('tabs.feedback'))
self.summary_tab = SummaryTab(self.summary)
self.tab_widget.addTab(self.summary_tab, t('tabs.summary'))
# 命令分頁
self.command_tab = CommandTab(self.project_dir)
self.tab_widget.addTab(self.command_tab, t('tabs.command'))
# 設置分頁
self.settings_tab = SettingsTab(self.combined_mode, self.config_manager)
self.settings_tab.set_layout_orientation(self.layout_orientation)
self.tab_widget.addTab(self.settings_tab, t('tabs.language'))
# 關於分頁
self.about_tab = AboutTab()
self.tab_widget.addTab(self.about_tab, t('tabs.about'))
debug_log(f"分頁創建完成,模式: {'合併' if self.combined_mode else '分離'},方向: {self.layout_orientation}")
def _create_combined_feedback_tab(self) -> None:
"""創建合併模式的回饋分頁包含AI摘要"""
self.combined_feedback_tab = QWidget()
# 主布局
tab_layout = QVBoxLayout(self.combined_feedback_tab)
tab_layout.setSpacing(12)
tab_layout.setContentsMargins(0, 0, 0, 0) # 設置邊距為0
# 創建分割器包裝容器
splitter_wrapper = QWidget()
splitter_wrapper_layout = QVBoxLayout(splitter_wrapper)
splitter_wrapper_layout.setContentsMargins(16, 16, 16, 0) # 恢復左右邊距設置
splitter_wrapper_layout.setSpacing(0)
# 根據布局方向創建分割器
orientation = Qt.Horizontal if self.layout_orientation == 'horizontal' else Qt.Vertical
main_splitter = QSplitter(orientation)
main_splitter.setChildrenCollapsible(False)
main_splitter.setHandleWidth(6)
main_splitter.setContentsMargins(0, 0, 0, 0) # 設置分割器邊距為0
# 設置分割器wrapper樣式確保分割器延伸到邊緣
splitter_wrapper.setStyleSheet("""
QWidget {
margin: 0px;
padding: 0px;
}
""")
# 根據方向設置不同的分割器樣式
if self.layout_orientation == 'horizontal':
# 水平布局(左右)
main_splitter.setStyleSheet("""
QSplitter {
border: none;
background: transparent;
}
QSplitter::handle:horizontal {
width: 8px;
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 4px;
margin-top: 16px;
margin-bottom: 16px;
margin-left: 2px;
margin-right: 2px;
}
QSplitter::handle:horizontal:hover {
background-color: #606060;
border-color: #808080;
}
QSplitter::handle:horizontal:pressed {
background-color: #007acc;
border-color: #005a9e;
}
""")
else:
# 垂直布局(上下)
main_splitter.setStyleSheet("""
QSplitter {
border: none;
background: transparent;
}
QSplitter::handle:vertical {
height: 8px;
background-color: #3c3c3c;
border: 1px solid #555555;
border-radius: 4px;
margin-left: 16px;
margin-right: 16px;
margin-top: 2px;
margin-bottom: 2px;
}
QSplitter::handle:vertical:hover {
background-color: #606060;
border-color: #808080;
}
QSplitter::handle:vertical:pressed {
background-color: #007acc;
border-color: #005a9e;
}
""")
# 創建AI摘要組件
self.summary_tab = SummaryTab(self.summary)
# 創建回饋輸入組件
self.feedback_tab = FeedbackTab()
if self.layout_orientation == 'horizontal':
# 水平布局設置
self.summary_tab.setMinimumWidth(150) # 降低最小寬度
self.summary_tab.setMaximumWidth(800)
self.feedback_tab.setMinimumWidth(200) # 降低最小寬度
self.feedback_tab.setMaximumWidth(1200)
# 添加到主分割器
main_splitter.addWidget(self.summary_tab)
main_splitter.addWidget(self.feedback_tab)
# 調整分割器比例(水平布局)
main_splitter.setStretchFactor(0, 1) # AI摘要區域
main_splitter.setStretchFactor(1, 2) # 回饋輸入區域
# 從配置載入分割器位置
saved_sizes = self.config_manager.get_splitter_sizes('main_splitter_horizontal')
if saved_sizes and len(saved_sizes) == 2:
main_splitter.setSizes(saved_sizes)
else:
main_splitter.setSizes([400, 600]) # 預設大小(水平)
# 連接分割器位置變化信號
main_splitter.splitterMoved.connect(
lambda pos, index: self._save_splitter_position(main_splitter, 'main_splitter_horizontal')
)
# 設置最小高度
main_splitter.setMinimumHeight(200) # 降低水平布局最小高度
main_splitter.setMaximumHeight(2000)
else:
# 垂直布局設置
self.summary_tab.setMinimumHeight(80) # 降低摘要最小高度
self.summary_tab.setMaximumHeight(1000)
self.feedback_tab.setMinimumHeight(120) # 降低回饋最小高度
self.feedback_tab.setMaximumHeight(2000)
# 添加到主分割器
main_splitter.addWidget(self.summary_tab)
main_splitter.addWidget(self.feedback_tab)
# 調整分割器比例(垂直布局)
main_splitter.setStretchFactor(0, 1) # AI摘要區域
main_splitter.setStretchFactor(1, 2) # 回饋輸入區域
# 從配置載入分割器位置
saved_sizes = self.config_manager.get_splitter_sizes('main_splitter_vertical')
if saved_sizes and len(saved_sizes) == 2:
main_splitter.setSizes(saved_sizes)
else:
main_splitter.setSizes([160, 480]) # 預設大小(垂直)
# 連接分割器位置變化信號
main_splitter.splitterMoved.connect(
lambda pos, index: self._save_splitter_position(main_splitter, 'main_splitter_vertical')
)
# 設置最小高度
main_splitter.setMinimumHeight(200) # 降低垂直布局最小高度
main_splitter.setMaximumHeight(3000)
splitter_wrapper_layout.addWidget(main_splitter)
# 添加底部空間以保持完整的邊距
bottom_spacer = QWidget()
bottom_spacer.setFixedHeight(16)
tab_layout.addWidget(splitter_wrapper, 1)
tab_layout.addWidget(bottom_spacer)
# 設置合併分頁的大小策略,確保能夠觸發父容器的滾動條
self.combined_feedback_tab.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
if self.layout_orientation == 'vertical':
self.combined_feedback_tab.setMinimumHeight(200) # 降低垂直布局最小高度
else:
self.combined_feedback_tab.setMinimumWidth(400) # 降低水平布局最小寬度
def update_tab_texts(self) -> None:
"""更新分頁標籤文字"""
if self.combined_mode:
# 合併模式:回饋、命令、設置、關於
self.tab_widget.setTabText(0, t('tabs.feedback'))
self.tab_widget.setTabText(1, t('tabs.command'))
self.tab_widget.setTabText(2, t('tabs.language'))
self.tab_widget.setTabText(3, t('tabs.about'))
else:
# 分離模式:回饋、摘要、命令、設置、關於
self.tab_widget.setTabText(0, t('tabs.feedback'))
self.tab_widget.setTabText(1, t('tabs.summary'))
self.tab_widget.setTabText(2, t('tabs.command'))
self.tab_widget.setTabText(3, t('tabs.language'))
self.tab_widget.setTabText(4, t('tabs.about'))
# 更新各分頁的內部文字
if self.feedback_tab:
self.feedback_tab.update_texts()
if self.summary_tab:
self.summary_tab.update_texts()
if self.command_tab:
self.command_tab.update_texts()
if self.settings_tab:
self.settings_tab.update_texts()
if self.about_tab:
self.about_tab.update_texts()
def get_feedback_data(self) -> Dict[str, Any]:
"""獲取回饋數據"""
result = {
"interactive_feedback": "",
"command_logs": "",
"images": [],
"settings": {}
}
# 獲取回饋文字和圖片
if self.feedback_tab:
result["interactive_feedback"] = self.feedback_tab.get_feedback_text()
result["images"] = self.feedback_tab.get_images_data()
# 獲取命令日誌
if self.command_tab:
result["command_logs"] = self.command_tab.get_command_logs()
# 獲取圖片設定
if self.config_manager:
result["settings"] = {
"image_size_limit": self.config_manager.get_image_size_limit(),
"enable_base64_detail": self.config_manager.get_enable_base64_detail()
}
return result
def restore_content(self, feedback_text: str, command_logs: str, images_data: list) -> None:
"""恢復內容(用於界面重新創建時)"""
try:
if self.feedback_tab and feedback_text:
if hasattr(self.feedback_tab, 'feedback_input'):
self.feedback_tab.feedback_input.setPlainText(feedback_text)
if self.command_tab and command_logs:
if hasattr(self.command_tab, 'command_output'):
self.command_tab.command_output.setPlainText(command_logs)
if self.feedback_tab and images_data:
if hasattr(self.feedback_tab, 'image_upload'):
for img_data in images_data:
try:
self.feedback_tab.image_upload.add_image_data(img_data)
except:
pass # 如果無法恢復圖片,忽略錯誤
debug_log("內容恢復完成")
except Exception as e:
debug_log(f"恢復內容失敗: {e}")
def connect_signals(self, parent) -> None:
"""連接信號"""
# 連接設置分頁的信號
if self.settings_tab:
# 語言變更信號直接連接到父窗口的刷新方法
if hasattr(parent, '_refresh_ui_texts'):
self.settings_tab.language_changed.connect(parent._refresh_ui_texts)
if hasattr(parent, '_on_layout_change_requested'):
self.settings_tab.layout_change_requested.connect(parent._on_layout_change_requested)
if hasattr(parent, '_on_reset_settings_requested'):
self.settings_tab.reset_requested.connect(parent._on_reset_settings_requested)
if hasattr(parent, '_on_timeout_settings_changed'):
self.settings_tab.timeout_settings_changed.connect(parent._on_timeout_settings_changed)
# 圖片貼上信號已在 FeedbackTab 內部直接處理,不需要外部連接
def cleanup(self) -> None:
"""清理資源"""
if self.command_tab:
self.command_tab.cleanup()
debug_log("分頁管理器清理完成")
def set_layout_mode(self, combined_mode: bool) -> None:
"""設置佈局模式"""
self.combined_mode = combined_mode
if self.settings_tab:
self.settings_tab.set_layout_mode(combined_mode)
def set_layout_orientation(self, orientation: str) -> None:
"""設置佈局方向"""
self.layout_orientation = orientation
if self.settings_tab:
self.settings_tab.set_layout_orientation(orientation)
def _save_splitter_position(self, splitter: QSplitter, config_key: str) -> None:
"""保存分割器位置"""
sizes = splitter.sizes()
self.config_manager.set_splitter_sizes(config_key, sizes)
debug_log(f"分割器位置保存成功,大小: {sizes}")

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
國際化支援模組
===============
提供統一的多語系支援功能,支援繁體中文、英文等語言。
自動偵測系統語言,並提供語言切換功能。
新架構:
- 使用分離的 JSON 翻譯檔案
- 支援巢狀翻譯鍵值
- 元資料支援
- 易於擴充新語言
作者: Minidoracat
"""
import os
import sys
import locale
import json
from typing import Dict, Any, Optional, Union
from pathlib import Path
from .debug import i18n_debug_log as debug_log
class I18nManager:
"""國際化管理器 - 新架構版本"""
def __init__(self):
self._current_language = None
self._translations = {}
self._supported_languages = ['zh-TW', 'en', 'zh-CN']
self._fallback_language = 'en'
self._config_file = self._get_config_file_path()
self._locales_dir = Path(__file__).parent / "gui" / "locales"
# 載入翻譯
self._load_all_translations()
# 設定語言
self._current_language = self._detect_language()
def _get_config_file_path(self) -> Path:
"""獲取配置文件路徑"""
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir / "language.json"
def _load_all_translations(self) -> None:
"""載入所有語言的翻譯檔案"""
self._translations = {}
for lang_code in self._supported_languages:
lang_dir = self._locales_dir / lang_code
translation_file = lang_dir / "translations.json"
if translation_file.exists():
try:
with open(translation_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._translations[lang_code] = data
debug_log(f"成功載入語言 {lang_code}: {data.get('meta', {}).get('displayName', lang_code)}")
except Exception as e:
debug_log(f"載入語言檔案失敗 {lang_code}: {e}")
# 如果載入失敗,使用空的翻譯
self._translations[lang_code] = {}
else:
debug_log(f"找不到語言檔案: {translation_file}")
self._translations[lang_code] = {}
def _detect_language(self) -> str:
"""自動偵測語言"""
# 1. 優先使用用戶保存的語言設定
saved_lang = self._load_saved_language()
if saved_lang and saved_lang in self._supported_languages:
return saved_lang
# 2. 檢查環境變數
env_lang = os.getenv('MCP_LANGUAGE', '').strip()
if env_lang and env_lang in self._supported_languages:
return env_lang
# 3. 自動偵測系統語言
try:
# 獲取系統語言
system_locale = locale.getdefaultlocale()[0]
if system_locale:
if system_locale.startswith('zh_TW') or system_locale.startswith('zh_Hant'):
return 'zh-TW'
elif system_locale.startswith('zh_CN') or system_locale.startswith('zh_Hans'):
return 'zh-CN'
elif system_locale.startswith('en'):
return 'en'
except Exception:
pass
# 4. 回退到默認語言
return self._fallback_language
def _load_saved_language(self) -> Optional[str]:
"""載入保存的語言設定"""
try:
if self._config_file.exists():
with open(self._config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get('language')
except Exception:
pass
return None
def save_language(self, language: str) -> None:
"""保存語言設定"""
try:
config = {'language': language}
with open(self._config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception:
pass
def get_current_language(self) -> str:
"""獲取當前語言"""
return self._current_language
def set_language(self, language: str) -> bool:
"""設定語言"""
if language in self._supported_languages:
self._current_language = language
self.save_language(language)
return True
return False
def get_supported_languages(self) -> list:
"""獲取支援的語言列表"""
return self._supported_languages.copy()
def get_language_info(self, language_code: str) -> Dict[str, Any]:
"""獲取語言的元資料信息"""
if language_code in self._translations:
return self._translations[language_code].get('meta', {})
return {}
def _get_nested_value(self, data: Dict[str, Any], key_path: str) -> Optional[str]:
"""從巢狀字典中獲取值,支援點分隔的鍵路徑"""
keys = key_path.split('.')
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current if isinstance(current, str) else None
def t(self, key: str, **kwargs) -> str:
"""
翻譯函數 - 支援新舊兩種鍵值格式
新格式: 'buttons.submit' -> data['buttons']['submit']
舊格式: 'btn_submit_feedback' -> 兼容舊的鍵值
"""
# 獲取當前語言的翻譯
current_translations = self._translations.get(self._current_language, {})
# 嘗試新格式(巢狀鍵)
text = self._get_nested_value(current_translations, key)
# 如果沒有找到,嘗試舊格式的兼容映射
if text is None:
text = self._get_legacy_translation(current_translations, key)
# 如果還是沒有找到,嘗試使用回退語言
if text is None:
fallback_translations = self._translations.get(self._fallback_language, {})
text = self._get_nested_value(fallback_translations, key)
if text is None:
text = self._get_legacy_translation(fallback_translations, key)
# 最後回退到鍵本身
if text is None:
text = key
# 處理格式化參數
if kwargs:
try:
text = text.format(**kwargs)
except (KeyError, ValueError):
pass
return text
def _get_legacy_translation(self, translations: Dict[str, Any], key: str) -> Optional[str]:
"""獲取舊格式翻譯的兼容方法"""
# 舊鍵到新鍵的映射
legacy_mapping = {
# 應用程式
'app_title': 'app.title',
'project_directory': 'app.projectDirectory',
'language': 'app.language',
'settings': 'app.settings',
# 分頁
'feedback_tab': 'tabs.feedback',
'command_tab': 'tabs.command',
'images_tab': 'tabs.images',
# 回饋
'feedback_title': 'feedback.title',
'feedback_description': 'feedback.description',
'feedback_placeholder': 'feedback.placeholder',
# 命令
'command_title': 'command.title',
'command_description': 'command.description',
'command_placeholder': 'command.placeholder',
'command_output': 'command.output',
# 圖片
'images_title': 'images.title',
'images_select': 'images.select',
'images_paste': 'images.paste',
'images_clear': 'images.clear',
'images_status': 'images.status',
'images_status_with_size': 'images.statusWithSize',
'images_drag_hint': 'images.dragHint',
'images_delete_confirm': 'images.deleteConfirm',
'images_delete_title': 'images.deleteTitle',
'images_size_warning': 'images.sizeWarning',
'images_format_error': 'images.formatError',
# 按鈕
'submit': 'buttons.submit',
'cancel': 'buttons.cancel',
'close': 'buttons.close',
'clear': 'buttons.clear',
'btn_submit_feedback': 'buttons.submitFeedback',
'btn_cancel': 'buttons.cancel',
'btn_select_files': 'buttons.selectFiles',
'btn_paste_clipboard': 'buttons.pasteClipboard',
'btn_clear_all': 'buttons.clearAll',
'btn_run_command': 'buttons.runCommand',
# 狀態
'feedback_submitted': 'status.feedbackSubmitted',
'feedback_cancelled': 'status.feedbackCancelled',
'timeout_message': 'status.timeoutMessage',
'error_occurred': 'status.errorOccurred',
'loading': 'status.loading',
'connecting': 'status.connecting',
'connected': 'status.connected',
'disconnected': 'status.disconnected',
'uploading': 'status.uploading',
'upload_success': 'status.uploadSuccess',
'upload_failed': 'status.uploadFailed',
'command_running': 'status.commandRunning',
'command_finished': 'status.commandFinished',
'paste_success': 'status.pasteSuccess',
'paste_failed': 'status.pasteFailed',
'invalid_file_type': 'status.invalidFileType',
'file_too_large': 'status.fileTooLarge',
# 其他
'ai_summary': 'aiSummary',
'language_selector': 'languageSelector',
'language_zh_tw': 'languageNames.zhTw',
'language_en': 'languageNames.en',
'language_zh_cn': 'languageNames.zhCn',
# 測試
'test_qt_gui_summary': 'test.qtGuiSummary',
'test_web_ui_summary': 'test.webUiSummary',
}
# 檢查是否有對應的新鍵
new_key = legacy_mapping.get(key)
if new_key:
return self._get_nested_value(translations, new_key)
return None
def get_language_display_name(self, language_code: str) -> str:
"""獲取語言的顯示名稱"""
# 直接從當前語言的翻譯中獲取,避免遞歸
current_translations = self._translations.get(self._current_language, {})
# 根據語言代碼構建鍵值
lang_key = None
if language_code == 'zh-TW':
lang_key = 'languageNames.zhTw'
elif language_code == 'zh-CN':
lang_key = 'languageNames.zhCn'
elif language_code == 'en':
lang_key = 'languageNames.en'
else:
# 通用格式
lang_key = f"languageNames.{language_code.replace('-', '').lower()}"
# 直接獲取翻譯,避免調用 self.t() 產生遞歸
if lang_key:
display_name = self._get_nested_value(current_translations, lang_key)
if display_name:
return display_name
# 回退到元資料中的顯示名稱
meta = self.get_language_info(language_code)
return meta.get('displayName', language_code)
def reload_translations(self) -> None:
"""重新載入所有翻譯檔案(開發時使用)"""
self._load_all_translations()
def add_language(self, language_code: str, translation_file_path: str) -> bool:
"""動態添加新語言支援"""
try:
translation_file = Path(translation_file_path)
if not translation_file.exists():
return False
with open(translation_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._translations[language_code] = data
if language_code not in self._supported_languages:
self._supported_languages.append(language_code)
debug_log(f"成功添加語言 {language_code}: {data.get('meta', {}).get('displayName', language_code)}")
return True
except Exception as e:
debug_log(f"添加語言失敗 {language_code}: {e}")
return False
# 全域的國際化管理器實例
_i18n_manager = None
def get_i18n_manager() -> I18nManager:
"""獲取全域的國際化管理器實例"""
global _i18n_manager
if _i18n_manager is None:
_i18n_manager = I18nManager()
return _i18n_manager
def t(key: str, **kwargs) -> str:
"""便捷的翻譯函數"""
return get_i18n_manager().t(key, **kwargs)
def set_language(language: str) -> bool:
"""設定語言"""
return get_i18n_manager().set_language(language)
def get_current_language() -> str:
"""獲取當前語言"""
return get_i18n_manager().get_current_language()
def reload_translations() -> None:
"""重新載入翻譯(開發用)"""
get_i18n_manager().reload_translations()

View File

@@ -0,0 +1,675 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 伺服器主程式
================
MCP Feedback Enhanced 的核心伺服器程式,提供用戶互動回饋功能。
支援智能環境檢測,自動選擇 Qt GUI 或 Web UI 介面。
主要功能:
- 環境檢測(本地/遠端)
- 介面選擇GUI/Web UI
- 圖片處理和 MCP 整合
- 回饋結果標準化
作者: Fábio Ferreira (原作者)
增強: Minidoracat (Web UI, 圖片支援, 環境檢測)
重構: 模塊化設計
"""
import os
import sys
import json
import tempfile
import asyncio
import base64
from typing import Annotated, List
import io
from fastmcp import FastMCP, Image as MCPImage
from mcp.types import TextContent
from pydantic import Field
# 導入多語系支援
from .i18n import get_i18n_manager
# 導入統一的調試功能
from .debug import server_debug_log as debug_log
# ===== 編碼初始化 =====
def init_encoding():
"""初始化編碼設置,確保正確處理中文字符"""
try:
# Windows 特殊處理
if sys.platform == 'win32':
import msvcrt
# 設置為二進制模式
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
# 重新包裝為 UTF-8 文本流,並禁用緩衝
sys.stdin = io.TextIOWrapper(
sys.stdin.detach(),
encoding='utf-8',
errors='replace',
newline=None
)
sys.stdout = io.TextIOWrapper(
sys.stdout.detach(),
encoding='utf-8',
errors='replace',
newline='',
write_through=True # 關鍵:禁用寫入緩衝
)
else:
# 非 Windows 系統的標準設置
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdin, 'reconfigure'):
sys.stdin.reconfigure(encoding='utf-8', errors='replace')
# 設置 stderr 編碼(用於調試訊息)
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
return True
except Exception as e:
# 如果編碼設置失敗,嘗試基本設置
try:
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdin, 'reconfigure'):
sys.stdin.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
except:
pass
return False
# 初始化編碼(在導入時就執行)
_encoding_initialized = init_encoding()
# ===== 常數定義 =====
SERVER_NAME = "互動式回饋收集 MCP"
SSH_ENV_VARS = ['SSH_CONNECTION', 'SSH_CLIENT', 'SSH_TTY']
REMOTE_ENV_VARS = ['REMOTE_CONTAINERS', 'CODESPACES']
# 初始化 MCP 服務器
from . import __version__
# 確保 log_level 設定為正確的大寫格式
fastmcp_settings = {}
# 檢查環境變數並設定正確的 log_level
env_log_level = os.getenv("FASTMCP_LOG_LEVEL", "").upper()
if env_log_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
fastmcp_settings["log_level"] = env_log_level
else:
# 預設使用 INFO 等級
fastmcp_settings["log_level"] = "INFO"
mcp = FastMCP(SERVER_NAME, version=__version__, **fastmcp_settings)
# ===== 工具函數 =====
def is_wsl_environment() -> bool:
"""
檢測是否在 WSL (Windows Subsystem for Linux) 環境中運行
Returns:
bool: True 表示 WSL 環境False 表示其他環境
"""
try:
# 檢查 /proc/version 文件是否包含 WSL 標識
if os.path.exists('/proc/version'):
with open('/proc/version', 'r') as f:
version_info = f.read().lower()
if 'microsoft' in version_info or 'wsl' in version_info:
debug_log("偵測到 WSL 環境(通過 /proc/version")
return True
# 檢查 WSL 相關環境變數
wsl_env_vars = ['WSL_DISTRO_NAME', 'WSL_INTEROP', 'WSLENV']
for env_var in wsl_env_vars:
if os.getenv(env_var):
debug_log(f"偵測到 WSL 環境變數: {env_var}")
return True
# 檢查是否存在 WSL 特有的路徑
wsl_paths = ['/mnt/c', '/mnt/d', '/proc/sys/fs/binfmt_misc/WSLInterop']
for path in wsl_paths:
if os.path.exists(path):
debug_log(f"偵測到 WSL 特有路徑: {path}")
return True
except Exception as e:
debug_log(f"WSL 檢測過程中發生錯誤: {e}")
return False
def is_remote_environment() -> bool:
"""
檢測是否在遠端環境中運行
Returns:
bool: True 表示遠端環境False 表示本地環境
"""
# WSL 不應被視為遠端環境,因為它可以訪問 Windows 瀏覽器
if is_wsl_environment():
debug_log("WSL 環境不被視為遠端環境")
return False
# 檢查 SSH 連線指標
for env_var in SSH_ENV_VARS:
if os.getenv(env_var):
debug_log(f"偵測到 SSH 環境變數: {env_var}")
return True
# 檢查遠端開發環境
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
# 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 無顯示環境檢查(但排除 WSL
if sys.platform.startswith('linux') and not os.getenv('DISPLAY') and not is_wsl_environment():
debug_log("偵測到 Linux 無顯示環境")
return True
return False
def can_use_gui() -> bool:
"""
檢測是否可以使用圖形介面
Returns:
bool: True 表示可以使用 GUIFalse 表示只能使用 Web UI
"""
if is_remote_environment():
return False
try:
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 save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
"""
將回饋資料儲存到 JSON 文件
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)
# 確保目錄存在
directory = os.path.dirname(file_path)
if directory and not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
# 複製數據以避免修改原始數據
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("command_logs"):
text_parts.append(f"=== 命令執行日誌 ===\n{feedback_data['command_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"
img_info = f" {i}. {name} ({size_str})"
# 為提高兼容性,添加 base64 預覽信息
if img.get("data"):
try:
if isinstance(img["data"], bytes):
img_base64 = base64.b64encode(img["data"]).decode('utf-8')
elif isinstance(img["data"], str):
img_base64 = img["data"]
else:
img_base64 = None
if img_base64:
# 只顯示前50個字符的預覽
preview = img_base64[:50] + "..." if len(img_base64) > 50 else img_base64
img_info += f"\n Base64 預覽: {preview}"
img_info += f"\n 完整 Base64 長度: {len(img_base64)} 字符"
# 如果 AI 助手不支援 MCP 圖片,可以提供完整 base64
debug_log(f"圖片 {i} Base64 已準備,長度: {len(img_base64)}")
# 檢查是否啟用 Base64 詳細模式(從 UI 設定中獲取)
include_full_base64 = feedback_data.get("settings", {}).get("enable_base64_detail", False)
if include_full_base64:
# 根據檔案名推斷 MIME 類型
file_name = img.get("name", "image.png")
if file_name.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg'
elif file_name.lower().endswith('.gif'):
mime_type = 'image/gif'
elif file_name.lower().endswith('.webp'):
mime_type = 'image/webp'
else:
mime_type = 'image/png'
img_info += f"\n 完整 Base64: data:{mime_type};base64,{img_base64}"
except Exception as e:
debug_log(f"圖片 {i} Base64 處理失敗: {e}")
text_parts.append(img_info)
# 添加兼容性說明
text_parts.append("\n💡 注意:如果 AI 助手無法顯示圖片,圖片數據已包含在上述 Base64 信息中。")
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:
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:
debug_log(f"圖片 {i} 處理失敗: {e}")
import traceback
debug_log(f"詳細錯誤: {traceback.format_exc()}")
debug_log(f"共處理 {len(mcp_images)} 張圖片")
return mcp_images
async def launch_gui_with_timeout(project_dir: str, summary: str, timeout: int) -> dict:
"""
啟動 GUI 模式並處理超時
"""
debug_log(f"啟動 GUI 模式(超時:{timeout}秒)")
try:
from .gui import feedback_ui_with_timeout
# 直接調用帶超時的 GUI 函數
result = feedback_ui_with_timeout(project_dir, summary, timeout)
if result:
return {
"logs": f"GUI 模式回饋收集完成",
"interactive_feedback": result.get("interactive_feedback", ""),
"images": result.get("images", [])
}
else:
return {
"logs": "用戶取消了回饋收集",
"interactive_feedback": "",
"images": []
}
except TimeoutError as e:
# 超時異常 - 這是預期的行為
raise e
except Exception as e:
debug_log(f"GUI 啟動失败: {e}")
raise Exception(f"GUI 啟動失败: {e}")
# ===== 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
) -> List:
"""
收集用戶的互動回饋,支援文字和圖片
此工具會自動偵測運行環境:
- 遠端環境:使用 Web UI
- 本地環境:使用 Qt GUI
- 可透過 FORCE_WEB 環境變數強制使用 Web UI
用戶可以:
1. 執行命令來驗證結果
2. 提供文字回饋
3. 上傳圖片作為回饋
4. 查看 AI 的工作摘要
介面控制(按優先級排序):
1. **FORCE_WEB 環境變數**:在 mcp.json 中設置 "FORCE_WEB": "true"
2. 自動檢測:根據運行環境自動選擇
調試模式:
- 設置環境變數 MCP_DEBUG=true 可啟用詳細調試輸出
- 生產環境建議關閉調試模式以避免輸出干擾
Args:
project_directory: 專案目錄路徑
summary: AI 工作完成的摘要說明
timeout: 等待用戶回饋的超時時間(秒),預設為 600 秒10 分鐘)
Returns:
List: 包含 TextContent 和 MCPImage 對象的列表
"""
# 檢查環境變數 FORCE_WEB
force_web = False
env_force_web = os.getenv("FORCE_WEB", "").lower()
if env_force_web in ("true", "1", "yes", "on"):
force_web = True
debug_log("環境變數 FORCE_WEB 已啟用,強制使用 Web UI")
elif env_force_web in ("false", "0", "no", "off"):
force_web = 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
debug_log(f"環境偵測結果 - 遠端: {is_remote}, GUI 可用: {can_gui}, 強制 Web UI: {force_web}")
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 = await launch_gui_with_timeout(project_directory, summary, timeout)
# 處理取消情況
if not result:
return [TextContent(type="text", text="用戶取消了回饋。")]
# 儲存詳細結果
save_feedback_to_file(result)
# 建立回饋項目列表
feedback_items = []
# 添加文字回饋
if result.get("interactive_feedback") or result.get("command_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:
# 使用新的 web 模組
from .web import launch_web_feedback_ui, stop_web_ui
# 傳遞 timeout 參數給 Web UI
return await launch_web_feedback_ui(project_dir, summary, timeout)
except ImportError as e:
debug_log(f"無法導入 Web UI 模組: {e}")
return {
"command_logs": "",
"interactive_feedback": f"Web UI 模組導入失敗: {str(e)}",
"images": []
}
except TimeoutError as e:
debug_log(f"Web UI 超時: {e}")
# 超時時確保停止 Web 服務器
try:
from .web import stop_web_ui
stop_web_ui()
debug_log("Web UI 服務器已因超時而停止")
except Exception as stop_error:
debug_log(f"停止 Web UI 服務器時發生錯誤: {stop_error}")
return {
"command_logs": "",
"interactive_feedback": f"回饋收集超時({timeout}秒),介面已自動關閉。",
"images": []
}
except Exception as e:
error_msg = f"Web UI 錯誤: {e}"
debug_log(f"{error_msg}")
# 發生錯誤時也要停止 Web 服務器
try:
from .web import stop_web_ui
stop_web_ui()
debug_log("Web UI 服務器已因錯誤而停止")
except Exception as stop_error:
debug_log(f"停止 Web UI 服務器時發生錯誤: {stop_error}")
return {
"command_logs": "",
"interactive_feedback": f"錯誤: {str(e)}",
"images": []
}
@mcp.tool()
def get_system_info() -> str:
"""
獲取系統環境資訊
Returns:
str: JSON 格式的系統資訊
"""
is_remote = is_remote_environment()
is_wsl = is_wsl_environment()
can_gui = can_use_gui()
system_info = {
"平台": sys.platform,
"Python 版本": sys.version.split()[0],
"WSL 環境": is_wsl,
"遠端環境": 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"),
"WSL_DISTRO_NAME": os.getenv("WSL_DISTRO_NAME"),
"WSL_INTEROP": os.getenv("WSL_INTEROP"),
"WSLENV": os.getenv("WSLENV"),
}
}
return json.dumps(system_info, ensure_ascii=False, indent=2)
# ===== 主程式入口 =====
def main():
"""主要入口點,用於套件執行"""
# 檢查是否啟用調試模式
debug_enabled = os.getenv("MCP_DEBUG", "").lower() in ("true", "1", "yes", "on")
if debug_enabled:
debug_log("🚀 啟動互動式回饋收集 MCP 服務器")
debug_log(f" 服務器名稱: {SERVER_NAME}")
debug_log(f" 版本: {__version__}")
debug_log(f" 平台: {sys.platform}")
debug_log(f" 編碼初始化: {'成功' if _encoding_initialized else '失敗'}")
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 助手的調用...")
debug_log("準備啟動 MCP 伺服器...")
debug_log("調用 mcp.run()...")
try:
# 使用正確的 FastMCP API
mcp.run()
except KeyboardInterrupt:
if debug_enabled:
debug_log("收到中斷信號,正常退出")
sys.exit(0)
except Exception as e:
if debug_enabled:
debug_log(f"MCP 服務器啟動失敗: {e}")
import traceback
debug_log(f"詳細錯誤: {traceback.format_exc()}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 增強測試系統
================
完整的 MCP 測試框架,模擬真實的 Cursor IDE 調用場景。
主要功能:
- 真實 MCP 調用模擬
- 完整的回饋循環測試
- 多場景測試覆蓋
- 詳細的測試報告
使用方法:
python -m mcp_feedback_enhanced.test_mcp_enhanced
python -m mcp_feedback_enhanced.test_mcp_enhanced --scenario basic_workflow
python -m mcp_feedback_enhanced.test_mcp_enhanced --tags quick
"""
import asyncio
import argparse
import sys
import os
from typing import List, Optional
from pathlib import Path
# 添加專案根目錄到 Python 路徑
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from .testing import TestScenarios, TestReporter, TestConfig, DEFAULT_CONFIG
from .debug import debug_log
class MCPTestRunner:
"""MCP 測試運行器"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
self.scenarios = TestScenarios(self.config)
self.reporter = TestReporter(self.config)
async def run_single_scenario(self, scenario_name: str) -> bool:
"""運行單個測試場景"""
debug_log(f"🎯 運行單個測試場景: {scenario_name}")
result = await self.scenarios.run_scenario(scenario_name)
# 生成報告
test_results = {
"success": result.get("success", False),
"total_scenarios": 1,
"passed_scenarios": 1 if result.get("success", False) else 0,
"failed_scenarios": 0 if result.get("success", False) else 1,
"results": [result]
}
report = self.reporter.generate_report(test_results)
self.reporter.print_summary(report)
# 保存報告
if self.config.report_output_dir:
report_path = self.reporter.save_report(report)
debug_log(f"📄 詳細報告已保存: {report_path}")
return result.get("success", False)
async def run_scenarios_by_tags(self, tags: List[str]) -> bool:
"""根據標籤運行測試場景"""
debug_log(f"🏷️ 運行標籤測試: {', '.join(tags)}")
results = await self.scenarios.run_all_scenarios(tags)
# 生成報告
report = self.reporter.generate_report(results)
self.reporter.print_summary(report)
# 保存報告
if self.config.report_output_dir:
report_path = self.reporter.save_report(report)
debug_log(f"📄 詳細報告已保存: {report_path}")
return results.get("success", False)
async def run_all_scenarios(self) -> bool:
"""運行所有測試場景"""
debug_log("🚀 運行所有測試場景")
results = await self.scenarios.run_all_scenarios()
# 生成報告
report = self.reporter.generate_report(results)
self.reporter.print_summary(report)
# 保存報告
if self.config.report_output_dir:
report_path = self.reporter.save_report(report)
debug_log(f"📄 詳細報告已保存: {report_path}")
return results.get("success", False)
def list_scenarios(self, tags: Optional[List[str]] = None):
"""列出可用的測試場景"""
scenarios = self.scenarios.list_scenarios(tags)
print("\n📋 可用的測試場景:")
print("=" * 50)
for scenario in scenarios:
tags_str = f" [{', '.join(scenario.tags)}]" if scenario.tags else ""
print(f"🧪 {scenario.name}{tags_str}")
print(f" {scenario.description}")
print(f" 超時: {scenario.timeout}s")
print()
print(f"總計: {len(scenarios)} 個測試場景")
def create_config_from_args(args) -> TestConfig:
"""從命令行參數創建配置"""
config = TestConfig.from_env()
# 覆蓋命令行參數
if args.timeout:
config.test_timeout = args.timeout
if args.verbose is not None:
config.test_verbose = args.verbose
if args.debug:
config.test_debug = True
os.environ["MCP_DEBUG"] = "true"
if args.report_format:
config.report_format = args.report_format
if args.report_dir:
config.report_output_dir = args.report_dir
if args.project_dir:
config.test_project_dir = args.project_dir
return config
async def main():
"""主函數"""
parser = argparse.ArgumentParser(
description="MCP 增強測試系統",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例用法:
%(prog)s # 運行所有測試
%(prog)s --scenario basic_workflow # 運行特定場景
%(prog)s --tags quick # 運行快速測試
%(prog)s --tags basic,integration # 運行多個標籤
%(prog)s --list # 列出所有場景
%(prog)s --debug --verbose # 調試模式
"""
)
# 測試選項
parser.add_argument(
'--scenario',
help='運行特定的測試場景'
)
parser.add_argument(
'--tags',
help='根據標籤運行測試場景 (逗號分隔)'
)
parser.add_argument(
'--list',
action='store_true',
help='列出所有可用的測試場景'
)
# 配置選項
parser.add_argument(
'--timeout',
type=int,
help='測試超時時間 (秒)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='詳細輸出'
)
parser.add_argument(
'--debug',
action='store_true',
help='調試模式'
)
parser.add_argument(
'--project-dir',
help='測試項目目錄'
)
# 報告選項
parser.add_argument(
'--report-format',
choices=['html', 'json', 'markdown'],
help='報告格式'
)
parser.add_argument(
'--report-dir',
help='報告輸出目錄'
)
args = parser.parse_args()
# 創建配置
config = create_config_from_args(args)
# 創建測試運行器
runner = MCPTestRunner(config)
try:
if args.list:
# 列出測試場景
tags = args.tags.split(',') if args.tags else None
runner.list_scenarios(tags)
return
success = False
if args.scenario:
# 運行特定場景
success = await runner.run_single_scenario(args.scenario)
elif args.tags:
# 根據標籤運行
tags = [tag.strip() for tag in args.tags.split(',')]
success = await runner.run_scenarios_by_tags(tags)
else:
# 運行所有場景
success = await runner.run_all_scenarios()
if success:
debug_log("🎉 所有測試通過!")
sys.exit(0)
else:
debug_log("❌ 部分測試失敗")
sys.exit(1)
except KeyboardInterrupt:
debug_log("\n⚠️ 測試被用戶中斷")
sys.exit(130)
except Exception as e:
debug_log(f"❌ 測試執行失敗: {e}")
if config.test_debug:
import traceback
debug_log(f"詳細錯誤: {traceback.format_exc()}")
sys.exit(1)
def run_quick_test():
"""快速測試入口"""
os.environ["MCP_DEBUG"] = "true"
# 設置快速測試配置
config = TestConfig.from_env()
config.test_timeout = 60
config.report_format = "markdown"
async def quick_test():
runner = MCPTestRunner(config)
return await runner.run_scenarios_by_tags(["quick"])
return asyncio.run(quick_test())
def run_basic_workflow_test():
"""基礎工作流程測試入口"""
os.environ["MCP_DEBUG"] = "true"
config = TestConfig.from_env()
config.test_timeout = 180
async def workflow_test():
runner = MCPTestRunner(config)
return await runner.run_single_scenario("basic_workflow")
return asyncio.run(workflow_test())
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Qt GUI 測試模組
===============
用於測試 MCP Feedback Enhanced 的 Qt GUI 功能。
包含完整的 GUI 功能測試。
功能測試:
- Qt GUI 界面啟動
- 多語言支援
- 圖片上傳功能
- 回饋提交功能
- 快捷鍵功能
使用方法:
python -m mcp_feedback_enhanced.test_qt_gui
作者: Minidoracat
"""
import sys
import os
from typing import Optional, Dict, Any
# 添加專案根目錄到 Python 路徑
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from .debug import debug_log
from .i18n import t
# 嘗試導入 Qt GUI 模組
try:
from .gui import feedback_ui
QT_GUI_AVAILABLE = True
except ImportError as e:
debug_log(f"⚠️ 無法導入 Qt GUI 模組: {e}")
QT_GUI_AVAILABLE = False
def test_qt_gui():
"""測試 Qt GUI 功能"""
try:
# 測試參數
project_directory = os.getcwd()
# 使用國際化系統獲取測試摘要
prompt = t('test.qtGuiSummary')
debug_log("🚀 啟動 Qt GUI 測試...")
debug_log("📝 測試項目:")
debug_log(" - 圖片預覽功能")
debug_log(" - X刪除按鈕")
debug_log(" - 視窗大小調整")
debug_log(" - 分割器調整")
debug_log(" - 智能 Ctrl+V 功能")
debug_log("")
# 啟動 GUI
result = feedback_ui(project_directory, prompt)
if result:
debug_log("\n✅ 測試完成!")
debug_log(f"📄 收到回饋: {result.get('interactive_feedback', '')}")
if result.get('images'):
debug_log(f"🖼️ 收到圖片: {len(result['images'])}")
if result.get('logs'):
debug_log(f"📋 命令日誌: {len(result['logs'])}")
else:
debug_log("\n❌ 測試取消或無回饋")
except ImportError as e:
debug_log(f"❌ 導入錯誤: {e}")
debug_log("請確保已安裝 PySide6: pip install PySide6")
return False
except Exception as e:
debug_log(f"❌ 測試錯誤: {e}")
return False
return True
if __name__ == "__main__":
debug_log("🧪 MCP Feedback Enhanced - Qt GUI 測試")
debug_log("=" * 50)
# 檢查環境
try:
from PySide6.QtWidgets import QApplication
debug_log("✅ PySide6 已安裝")
except ImportError:
debug_log("❌ PySide6 未安裝,請執行: pip install PySide6")
sys.exit(1)
# 運行測試
success = test_qt_gui()
if success:
debug_log("\n🎉 測試程序運行完成")
else:
debug_log("\n💥 測試程序運行失敗")
sys.exit(1)

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Feedback Enhanced - Web UI 測試模組
========================================
用於測試 MCP Feedback Enhanced 的 Web UI 功能。
包含完整的 Web UI 功能測試。
功能測試:
- Web UI 服務器啟動
- 會話管理功能
- WebSocket 通訊
- 多語言支援
- 命令執行功能
使用方法:
python -m mcp_feedback_enhanced.test_web_ui
作者: Minidoracat
"""
import asyncio
import webbrowser
import time
import sys
import os
import socket
import threading
from pathlib import Path
from typing import Dict, Any, Optional
# 添加專案根目錄到 Python 路徑
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from .debug import debug_log
from .i18n import t
# 嘗試導入 Web UI 模組
try:
# 使用新的 web 模組
from .web import WebUIManager, launch_web_feedback_ui, get_web_ui_manager
from .web.utils.browser import smart_browser_open, is_wsl_environment
WEB_UI_AVAILABLE = True
debug_log("✅ 使用新的 web 模組")
except ImportError as e:
debug_log(f"⚠️ 無法導入 Web UI 模組: {e}")
WEB_UI_AVAILABLE = False
def get_test_summary():
"""獲取測試摘要,使用國際化系統"""
return t('test.webUiSummary')
def find_free_port():
"""Find a free port to use for testing"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
s.listen(1)
port = s.getsockname()[1]
return port
def test_web_ui(keep_running=False):
"""Test the Web UI functionality"""
debug_log("🧪 測試 MCP Feedback Enhanced Web UI")
debug_log("=" * 50)
# Test import
try:
# 使用新的 web 模組
from .web import WebUIManager, launch_web_feedback_ui
debug_log("✅ Web UI 模組匯入成功")
except ImportError as e:
debug_log(f"❌ Web UI 模組匯入失敗: {e}")
return False, None
# Find free port
try:
free_port = find_free_port()
debug_log(f"🔍 找到可用端口: {free_port}")
except Exception as e:
debug_log(f"❌ 尋找可用端口失敗: {e}")
return False, None
# Test manager creation
try:
manager = WebUIManager(port=free_port)
debug_log("✅ WebUIManager 創建成功")
except Exception as e:
debug_log(f"❌ WebUIManager 創建失敗: {e}")
return False, None
# Test server start (with timeout)
server_started = False
try:
debug_log("🚀 啟動 Web 服務器...")
def start_server():
try:
manager.start_server()
return True
except Exception as e:
debug_log(f"服務器啟動錯誤: {e}")
return False
# Start server in thread
server_thread = threading.Thread(target=start_server)
server_thread.daemon = True
server_thread.start()
# Wait a moment and test if server is responsive
time.sleep(3)
# Test if port is listening
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
result = s.connect_ex((manager.host, manager.port))
if result == 0:
server_started = True
debug_log("✅ Web 服務器啟動成功")
debug_log(f"🌐 服務器運行在: http://{manager.host}:{manager.port}")
else:
debug_log(f"❌ 無法連接到服務器端口 {manager.port}")
except Exception as e:
debug_log(f"❌ Web 服務器啟動失敗: {e}")
return False, None
if not server_started:
debug_log("❌ 服務器未能正常啟動")
return False, None
# Test session creation
session_info = None
try:
project_dir = str(Path.cwd())
# 使用國際化系統獲取測試摘要
summary = t('test.webUiSummary')
session_id = manager.create_session(project_dir, summary)
session_info = {
'manager': manager,
'session_id': session_id,
'url': f"http://{manager.host}:{manager.port}" # 使用根路徑
}
debug_log(f"✅ 測試會話創建成功 (ID: {session_id[:8]}...)")
debug_log(f"🔗 測試 URL: {session_info['url']}")
# 測試瀏覽器啟動功能
try:
debug_log("🌐 測試瀏覽器啟動功能...")
if is_wsl_environment():
debug_log("✅ 檢測到 WSL 環境,使用 WSL 專用瀏覽器啟動")
else:
debug_log(" 非 WSL 環境,使用標準瀏覽器啟動")
smart_browser_open(session_info['url'])
debug_log(f"✅ 瀏覽器啟動成功: {session_info['url']}")
except Exception as browser_error:
debug_log(f"⚠️ 瀏覽器啟動失敗: {browser_error}")
debug_log("💡 這可能是正常的,請手動在瀏覽器中開啟上述 URL")
except Exception as e:
debug_log(f"❌ 會話創建失敗: {e}")
return False, None
debug_log("\n" + "=" * 50)
debug_log("🎉 所有測試通過Web UI 準備就緒")
debug_log("📝 注意事項:")
debug_log(" - Web UI 會在 SSH remote 環境下自動啟用")
debug_log(" - 本地環境會繼續使用 Qt GUI")
debug_log(" - 支援即時命令執行和 WebSocket 通訊")
debug_log(" - 提供現代化的深色主題界面")
debug_log(" - 支援智能 Ctrl+V 圖片貼上功能")
return True, session_info
def test_environment_detection():
"""Test environment detection logic"""
debug_log("🔍 測試環境檢測功能")
debug_log("-" * 30)
try:
from .server import is_remote_environment, is_wsl_environment, can_use_gui
wsl_detected = is_wsl_environment()
remote_detected = is_remote_environment()
gui_available = can_use_gui()
debug_log(f"WSL 環境檢測: {'' if wsl_detected else ''}")
debug_log(f"遠端環境檢測: {'' if remote_detected else ''}")
debug_log(f"GUI 可用性: {'' if gui_available else ''}")
if wsl_detected:
debug_log("✅ 檢測到 WSL 環境,將使用 Web UI 並支援 Windows 瀏覽器啟動")
elif remote_detected:
debug_log("✅ 將使用 Web UI (適合遠端開發環境)")
else:
debug_log("✅ 將使用 Qt GUI (本地環境)")
return True
except Exception as e:
debug_log(f"❌ 環境檢測失敗: {e}")
return False
def test_mcp_integration():
"""Test MCP server integration"""
debug_log("\n🔧 測試 MCP 整合功能")
debug_log("-" * 30)
try:
from .server import interactive_feedback
debug_log("✅ MCP 工具函數可用")
# Test timeout parameter
debug_log("✅ 支援 timeout 參數")
# Test environment-based Web UI selection
debug_log("✅ 支援基於環境變數的 Web UI 選擇")
# Test would require actual MCP call, so just verify import
debug_log("✅ 準備接受來自 AI 助手的調用")
return True
except Exception as e:
debug_log(f"❌ MCP 整合測試失敗: {e}")
return False
def test_new_parameters():
"""Test timeout parameter and environment variable support"""
debug_log("\n🆕 測試參數功能")
debug_log("-" * 30)
try:
from .server import interactive_feedback
# 測試參數是否存在
import inspect
sig = inspect.signature(interactive_feedback)
# 檢查 timeout 參數
if 'timeout' in sig.parameters:
timeout_param = sig.parameters['timeout']
debug_log(f"✅ timeout 參數存在,預設值: {timeout_param.default}")
else:
debug_log("❌ timeout 參數不存在")
return False
# 檢查環境變數支援
import os
current_force_web = os.getenv("FORCE_WEB")
if current_force_web:
debug_log(f"✅ 檢測到 FORCE_WEB 環境變數: {current_force_web}")
else:
debug_log(" FORCE_WEB 環境變數未設定(將使用預設邏輯)")
debug_log("✅ 參數功能正常")
return True
except Exception as e:
debug_log(f"❌ 參數測試失敗: {e}")
return False
def test_environment_web_ui_mode():
"""Test environment-based Web UI mode"""
debug_log("\n🌐 測試環境變數控制 Web UI 模式")
debug_log("-" * 30)
try:
from .server import interactive_feedback, is_remote_environment, is_wsl_environment, can_use_gui
import os
# 顯示當前環境狀態
is_wsl = is_wsl_environment()
is_remote = is_remote_environment()
gui_available = can_use_gui()
force_web_env = os.getenv("FORCE_WEB", "").lower()
debug_log(f"當前環境 - WSL: {is_wsl}, 遠端: {is_remote}, GUI 可用: {gui_available}")
debug_log(f"FORCE_WEB 環境變數: {force_web_env or '未設定'}")
if force_web_env in ("true", "1", "yes", "on"):
debug_log("✅ FORCE_WEB 已啟用,將強制使用 Web UI")
elif is_wsl:
debug_log("✅ WSL 環境,將使用 Web UI 並支援 Windows 瀏覽器啟動")
elif not is_remote and gui_available:
debug_log(" 本地 GUI 環境,將使用 Qt GUI")
debug_log("💡 可設定 FORCE_WEB=true 強制使用 Web UI 進行測試")
else:
debug_log(" 將自動使用 Web UI遠端環境或 GUI 不可用)")
return True
except Exception as e:
debug_log(f"❌ 環境變數測試失敗: {e}")
return False
def interactive_demo(session_info):
"""Run interactive demo with the Web UI"""
debug_log(f"\n🌐 Web UI 互動測試模式")
debug_log("=" * 50)
debug_log(f"服務器地址: {session_info['url']}") # 簡化輸出,只顯示服務器地址
debug_log("\n📖 操作指南:")
debug_log(" 1. 在瀏覽器中開啟上面的服務器地址")
debug_log(" 2. 嘗試以下功能:")
debug_log(" - 點擊 '顯示命令區塊' 按鈕")
debug_log(" - 輸入命令如 'echo Hello World' 並執行")
debug_log(" - 在回饋區域輸入文字")
debug_log(" - 使用 Ctrl+Enter 提交回饋")
debug_log(" 3. 測試 WebSocket 即時通訊功能")
debug_log(" 4. 測試頁面持久性(提交反饋後頁面不關閉)")
debug_log("\n⌨️ 控制選項:")
debug_log(" - 按 Enter 繼續運行")
debug_log(" - 輸入 'q''quit' 停止服務器")
while True:
try:
user_input = input("\n>>> ").strip().lower()
if user_input in ['q', 'quit', 'exit']:
debug_log("🛑 停止服務器...")
break
elif user_input == '':
debug_log(f"🔄 服務器持續運行在: {session_info['url']}")
debug_log(" 瀏覽器應該仍可正常訪問")
else:
debug_log("❓ 未知命令。按 Enter 繼續運行,或輸入 'q' 退出")
except KeyboardInterrupt:
debug_log("\n🛑 收到中斷信號,停止服務器...")
break
debug_log("✅ Web UI 測試完成")
if __name__ == "__main__":
debug_log("MCP Feedback Enhanced - Web UI 測試")
debug_log("=" * 60)
# Test environment detection
env_test = test_environment_detection()
# Test new parameters
params_test = test_new_parameters()
# Test environment-based Web UI mode
env_web_test = test_environment_web_ui_mode()
# Test MCP integration
mcp_test = test_mcp_integration()
# Test Web UI
web_test, session_info = test_web_ui()
debug_log("\n" + "=" * 60)
if env_test and params_test and env_web_test and mcp_test and web_test:
debug_log("🎊 所有測試完成!準備使用 MCP Feedback Enhanced")
debug_log("\n📖 使用方法:")
debug_log(" 1. 在 Cursor/Cline 中配置此 MCP 服務器")
debug_log(" 2. AI 助手會自動調用 interactive_feedback 工具")
debug_log(" 3. 根據環境自動選擇 GUI 或 Web UI")
debug_log(" 4. 提供回饋後繼續工作流程")
debug_log("\n✨ Web UI 新功能:")
debug_log(" - 支援 SSH remote 開發環境")
debug_log(" - 現代化深色主題界面")
debug_log(" - WebSocket 即時通訊")
debug_log(" - 自動瀏覽器啟動")
debug_log(" - 命令執行和即時輸出")
debug_log("\n✅ 測試完成 - 系統已準備就緒!")
if session_info:
debug_log(f"💡 您可以現在就在瀏覽器中測試: {session_info['url']}")
debug_log(" (服務器會繼續運行一小段時間)")
time.sleep(10) # Keep running for a short time for immediate testing
else:
debug_log("❌ 部分測試失敗,請檢查錯誤信息")
sys.exit(1)

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 測試框架
============
完整的 MCP 測試系統,模擬真實的 Cursor IDE 調用場景。
主要功能:
- MCP 客戶端模擬器
- 完整的回饋循環測試
- 多場景測試覆蓋
- 詳細的測試報告
作者: Augment Agent
創建時間: 2025-01-05
"""
from .mcp_client import MCPTestClient
from .scenarios import TestScenarios
from .validators import TestValidators
from .reporter import TestReporter
from .utils import TestUtils
from .config import TestConfig, DEFAULT_CONFIG
__all__ = [
'MCPTestClient',
'TestScenarios',
'TestValidators',
'TestReporter',
'TestUtils',
'TestConfig',
'DEFAULT_CONFIG'
]
__version__ = "1.0.0"
__author__ = "Augment Agent"

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試配置管理
============
管理 MCP 測試框架的配置參數和設定。
"""
import os
from dataclasses import dataclass
from typing import Dict, Any, Optional
from pathlib import Path
@dataclass
class TestConfig:
"""測試配置類"""
# 服務器配置
server_host: str = "127.0.0.1"
server_port: int = 8765
server_timeout: int = 30
# MCP 客戶端配置
mcp_timeout: int = 60
mcp_retry_count: int = 3
mcp_retry_delay: float = 1.0
# WebSocket 配置
websocket_timeout: int = 10
websocket_ping_interval: int = 5
websocket_ping_timeout: int = 3
# 測試配置
test_timeout: int = 120
test_parallel: bool = False
test_verbose: bool = True
test_debug: bool = False
# 報告配置
report_format: str = "html" # html, json, markdown
report_output_dir: str = "test_reports"
report_include_logs: bool = True
report_include_performance: bool = True
# 測試數據配置
test_project_dir: Optional[str] = None
test_summary: str = "MCP 測試框架 - 模擬 Cursor IDE 調用"
test_feedback_text: str = "這是一個測試回饋,用於驗證 MCP 系統功能。"
@classmethod
def from_env(cls) -> 'TestConfig':
"""從環境變數創建配置"""
config = cls()
# 從環境變數讀取配置
config.server_host = os.getenv('MCP_TEST_HOST', config.server_host)
config.server_port = int(os.getenv('MCP_TEST_PORT', str(config.server_port)))
config.server_timeout = int(os.getenv('MCP_TEST_SERVER_TIMEOUT', str(config.server_timeout)))
config.mcp_timeout = int(os.getenv('MCP_TEST_TIMEOUT', str(config.mcp_timeout)))
config.mcp_retry_count = int(os.getenv('MCP_TEST_RETRY_COUNT', str(config.mcp_retry_count)))
config.test_timeout = int(os.getenv('MCP_TEST_CASE_TIMEOUT', str(config.test_timeout)))
config.test_parallel = os.getenv('MCP_TEST_PARALLEL', '').lower() in ('true', '1', 'yes')
config.test_verbose = os.getenv('MCP_TEST_VERBOSE', '').lower() not in ('false', '0', 'no')
config.test_debug = os.getenv('MCP_DEBUG', '').lower() in ('true', '1', 'yes')
config.report_format = os.getenv('MCP_TEST_REPORT_FORMAT', config.report_format)
config.report_output_dir = os.getenv('MCP_TEST_REPORT_DIR', config.report_output_dir)
config.test_project_dir = os.getenv('MCP_TEST_PROJECT_DIR', config.test_project_dir)
return config
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'TestConfig':
"""從字典創建配置"""
config = cls()
for key, value in data.items():
if hasattr(config, key):
setattr(config, key, value)
return config
def to_dict(self) -> Dict[str, Any]:
"""轉換為字典"""
return {
'server_host': self.server_host,
'server_port': self.server_port,
'server_timeout': self.server_timeout,
'mcp_timeout': self.mcp_timeout,
'mcp_retry_count': self.mcp_retry_count,
'mcp_retry_delay': self.mcp_retry_delay,
'websocket_timeout': self.websocket_timeout,
'websocket_ping_interval': self.websocket_ping_interval,
'websocket_ping_timeout': self.websocket_ping_timeout,
'test_timeout': self.test_timeout,
'test_parallel': self.test_parallel,
'test_verbose': self.test_verbose,
'test_debug': self.test_debug,
'report_format': self.report_format,
'report_output_dir': self.report_output_dir,
'report_include_logs': self.report_include_logs,
'report_include_performance': self.report_include_performance,
'test_project_dir': self.test_project_dir,
'test_summary': self.test_summary,
'test_feedback_text': self.test_feedback_text
}
def get_server_url(self) -> str:
"""獲取服務器 URL"""
return f"http://{self.server_host}:{self.server_port}"
def get_websocket_url(self) -> str:
"""獲取 WebSocket URL"""
return f"ws://{self.server_host}:{self.server_port}/ws"
def get_report_output_path(self) -> Path:
"""獲取報告輸出路徑"""
return Path(self.report_output_dir)
def ensure_report_dir(self) -> Path:
"""確保報告目錄存在"""
report_dir = self.get_report_output_path()
report_dir.mkdir(parents=True, exist_ok=True)
return report_dir
# 默認配置實例
DEFAULT_CONFIG = TestConfig.from_env()

View File

@@ -0,0 +1,527 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 客戶端模擬器
================
模擬 Cursor IDE 作為 MCP 客戶端的完整調用流程,實現標準的 JSON-RPC 2.0 通信協議。
主要功能:
- MCP 協議握手和初始化
- 工具發現和能力協商
- 工具調用和結果處理
- 錯誤處理和重連機制
"""
import asyncio
import json
import uuid
import time
import subprocess
import signal
import os
from typing import Dict, Any, Optional, List, Callable, Awaitable
from pathlib import Path
from dataclasses import dataclass, field
from .config import TestConfig, DEFAULT_CONFIG
from .utils import TestUtils, PerformanceMonitor, AsyncEventWaiter
from ..debug import debug_log
@dataclass
class MCPMessage:
"""MCP 消息類"""
jsonrpc: str = "2.0"
id: Optional[str] = None
method: Optional[str] = None
params: Optional[Dict[str, Any]] = None
result: Optional[Any] = None
error: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
"""轉換為字典"""
data = {"jsonrpc": self.jsonrpc}
if self.id is not None:
data["id"] = self.id
if self.method is not None:
data["method"] = self.method
if self.params is not None:
data["params"] = self.params
if self.result is not None:
data["result"] = self.result
if self.error is not None:
data["error"] = self.error
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'MCPMessage':
"""從字典創建"""
return cls(
jsonrpc=data.get("jsonrpc", "2.0"),
id=data.get("id"),
method=data.get("method"),
params=data.get("params"),
result=data.get("result"),
error=data.get("error")
)
def is_request(self) -> bool:
"""是否為請求消息"""
return self.method is not None
def is_response(self) -> bool:
"""是否為響應消息"""
return self.result is not None or self.error is not None
def is_notification(self) -> bool:
"""是否為通知消息"""
return self.method is not None and self.id is None
@dataclass
class MCPClientState:
"""MCP 客戶端狀態"""
connected: bool = False
initialized: bool = False
tools_discovered: bool = False
available_tools: List[Dict[str, Any]] = field(default_factory=list)
server_capabilities: Dict[str, Any] = field(default_factory=dict)
client_info: Dict[str, Any] = field(default_factory=dict)
server_info: Dict[str, Any] = field(default_factory=dict)
class MCPTestClient:
"""MCP 測試客戶端"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
self.state = MCPClientState()
self.process: Optional[subprocess.Popen] = None
self.event_waiter = AsyncEventWaiter()
self.performance_monitor = PerformanceMonitor()
self.message_id_counter = 0
self.pending_requests: Dict[str, asyncio.Future] = {}
self.message_handlers: Dict[str, Callable] = {}
# 設置默認消息處理器
self._setup_default_handlers()
def _setup_default_handlers(self):
"""設置默認消息處理器"""
self.message_handlers.update({
'initialize': self._handle_initialize_response,
'tools/list': self._handle_tools_list_response,
'tools/call': self._handle_tools_call_response,
})
def _generate_message_id(self) -> str:
"""生成消息 ID"""
self.message_id_counter += 1
return f"msg_{self.message_id_counter}_{uuid.uuid4().hex[:8]}"
async def start_server(self) -> bool:
"""啟動 MCP 服務器"""
try:
debug_log("🚀 啟動 MCP 服務器...")
self.performance_monitor.start()
# 構建啟動命令
cmd = [
"python", "-m", "src.mcp_feedback_enhanced", "server"
]
# 設置環境變數
env = os.environ.copy()
env.update({
"MCP_DEBUG": "true" if self.config.test_debug else "false",
"PYTHONPATH": str(Path(__file__).parent.parent.parent.parent)
})
# 啟動進程
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
bufsize=0
)
debug_log(f"✅ MCP 服務器進程已啟動 (PID: {self.process.pid})")
# 等待服務器初始化
await asyncio.sleep(2)
# 檢查進程是否仍在運行
if self.process.poll() is not None:
stderr_output = self.process.stderr.read() if self.process.stderr else ""
raise RuntimeError(f"MCP 服務器啟動失敗: {stderr_output}")
self.state.connected = True
self.performance_monitor.checkpoint("server_started")
return True
except Exception as e:
debug_log(f"❌ 啟動 MCP 服務器失敗: {e}")
await self.cleanup()
return False
async def stop_server(self):
"""停止 MCP 服務器"""
if self.process:
try:
debug_log("🛑 停止 MCP 服務器...")
# 嘗試優雅關閉
self.process.terminate()
try:
await asyncio.wait_for(
asyncio.create_task(self._wait_for_process()),
timeout=5.0
)
except asyncio.TimeoutError:
debug_log("⚠️ 優雅關閉超時,強制終止進程")
self.process.kill()
await self._wait_for_process()
debug_log("✅ MCP 服務器已停止")
except Exception as e:
debug_log(f"⚠️ 停止 MCP 服務器時發生錯誤: {e}")
finally:
self.process = None
self.state.connected = False
async def _wait_for_process(self):
"""等待進程結束"""
if self.process:
while self.process.poll() is None:
await asyncio.sleep(0.1)
async def send_message(self, message: MCPMessage) -> Optional[MCPMessage]:
"""發送 MCP 消息"""
if not self.process or not self.state.connected:
raise RuntimeError("MCP 服務器未連接")
try:
# 序列化消息
message_data = json.dumps(message.to_dict()) + "\n"
debug_log(f"📤 發送 MCP 消息: {message.method or 'response'}")
if self.config.test_debug:
debug_log(f" 內容: {message_data.strip()}")
# 發送消息
self.process.stdin.write(message_data)
self.process.stdin.flush()
# 如果是請求,等待響應
if message.is_request() and message.id:
future = asyncio.Future()
self.pending_requests[message.id] = future
try:
response = await asyncio.wait_for(
future,
timeout=self.config.mcp_timeout
)
return response
except asyncio.TimeoutError:
self.pending_requests.pop(message.id, None)
raise TimeoutError(f"MCP 請求超時: {message.method}")
return None
except Exception as e:
debug_log(f"❌ 發送 MCP 消息失敗: {e}")
raise
async def read_messages(self):
"""讀取 MCP 消息"""
if not self.process:
return
try:
while self.process and self.process.poll() is None:
# 讀取一行
line = await asyncio.create_task(self._read_line())
if not line:
continue
try:
# 解析 JSON
data = json.loads(line.strip())
message = MCPMessage.from_dict(data)
debug_log(f"📨 收到 MCP 消息: {message.method or 'response'}")
if self.config.test_debug:
debug_log(f" 內容: {line.strip()}")
# 處理消息
await self._handle_message(message)
except json.JSONDecodeError as e:
debug_log(f"⚠️ JSON 解析失敗: {e}, 原始數據: {line}")
except Exception as e:
debug_log(f"❌ 處理消息失敗: {e}")
except Exception as e:
debug_log(f"❌ 讀取 MCP 消息失敗: {e}")
async def _read_line(self) -> str:
"""異步讀取一行"""
if not self.process or not self.process.stdout:
return ""
# 使用線程池執行阻塞的讀取操作
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.process.stdout.readline)
async def _handle_message(self, message: MCPMessage):
"""處理收到的消息"""
if message.is_response() and message.id:
# 處理響應
future = self.pending_requests.pop(message.id, None)
if future and not future.done():
future.set_result(message)
elif message.is_request():
# 處理請求(通常是服務器發起的)
debug_log(f"收到服務器請求: {message.method}")
# 調用特定的消息處理器
if message.method in self.message_handlers:
await self.message_handlers[message.method](message)
async def _handle_initialize_response(self, message: MCPMessage):
"""處理初始化響應"""
if message.result:
self.state.server_info = message.result.get('serverInfo', {})
self.state.server_capabilities = message.result.get('capabilities', {})
self.state.initialized = True
debug_log("✅ MCP 初始化完成")
async def _handle_tools_list_response(self, message: MCPMessage):
"""處理工具列表響應"""
if message.result and 'tools' in message.result:
self.state.available_tools = message.result['tools']
self.state.tools_discovered = True
debug_log(f"✅ 發現 {len(self.state.available_tools)} 個工具")
async def _handle_tools_call_response(self, message: MCPMessage):
"""處理工具調用響應"""
if message.result:
debug_log("✅ 工具調用完成")
elif message.error:
debug_log(f"❌ 工具調用失敗: {message.error}")
async def initialize(self) -> bool:
"""初始化 MCP 連接"""
try:
debug_log("🔄 初始化 MCP 連接...")
message = MCPMessage(
id=self._generate_message_id(),
method="initialize",
params={
"protocolVersion": "2024-11-05",
"clientInfo": {
"name": "mcp-test-client",
"version": "1.0.0"
},
"capabilities": {
"roots": {
"listChanged": True
},
"sampling": {}
}
}
)
response = await self.send_message(message)
if response and response.result:
self.performance_monitor.checkpoint("initialized")
return True
else:
debug_log(f"❌ 初始化失敗: {response.error if response else '無響應'}")
return False
except Exception as e:
debug_log(f"❌ 初始化異常: {e}")
return False
async def list_tools(self) -> List[Dict[str, Any]]:
"""獲取可用工具列表"""
try:
debug_log("🔍 獲取工具列表...")
message = MCPMessage(
id=self._generate_message_id(),
method="tools/list",
params={}
)
response = await self.send_message(message)
if response and response.result and 'tools' in response.result:
tools = response.result['tools']
debug_log(f"✅ 獲取到 {len(tools)} 個工具")
self.performance_monitor.checkpoint("tools_listed", {"tools_count": len(tools)})
return tools
else:
debug_log(f"❌ 獲取工具列表失敗: {response.error if response else '無響應'}")
return []
except Exception as e:
debug_log(f"❌ 獲取工具列表異常: {e}")
return []
async def call_interactive_feedback(self, project_directory: str, summary: str,
timeout: int = 60) -> Dict[str, Any]:
"""調用互動回饋工具"""
try:
debug_log("🎯 調用互動回饋工具...")
message = MCPMessage(
id=self._generate_message_id(),
method="tools/call",
params={
"name": "interactive_feedback",
"arguments": {
"project_directory": project_directory,
"summary": summary,
"timeout": timeout
}
}
)
# 設置較長的超時時間,因為需要等待用戶互動
old_timeout = self.config.mcp_timeout
self.config.mcp_timeout = timeout + 30 # 額外 30 秒緩衝
try:
response = await self.send_message(message)
if response and response.result:
result = response.result
debug_log("✅ 互動回饋工具調用成功")
self.performance_monitor.checkpoint("interactive_feedback_completed")
return result
else:
error_msg = response.error if response else "無響應"
debug_log(f"❌ 互動回饋工具調用失敗: {error_msg}")
return {"error": str(error_msg)}
finally:
self.config.mcp_timeout = old_timeout
except Exception as e:
debug_log(f"❌ 互動回饋工具調用異常: {e}")
return {"error": str(e)}
async def full_workflow_test(self, project_directory: Optional[str] = None,
summary: Optional[str] = None) -> Dict[str, Any]:
"""執行完整的工作流程測試"""
try:
debug_log("🚀 開始完整工作流程測試...")
self.performance_monitor.start()
# 使用配置中的默認值
project_dir = project_directory or self.config.test_project_dir or str(Path.cwd())
test_summary = summary or self.config.test_summary
results = {
"success": False,
"steps": {},
"performance": {},
"errors": []
}
# 步驟 1: 啟動服務器
if not await self.start_server():
results["errors"].append("服務器啟動失敗")
return results
results["steps"]["server_started"] = True
# 啟動消息讀取任務
read_task = asyncio.create_task(self.read_messages())
try:
# 步驟 2: 初始化連接
if not await self.initialize():
results["errors"].append("MCP 初始化失敗")
return results
results["steps"]["initialized"] = True
# 步驟 3: 獲取工具列表
tools = await self.list_tools()
if not tools:
results["errors"].append("獲取工具列表失敗")
return results
results["steps"]["tools_discovered"] = True
results["tools_count"] = len(tools)
# 檢查是否有 interactive_feedback 工具
has_interactive_tool = any(
tool.get("name") == "interactive_feedback"
for tool in tools
)
if not has_interactive_tool:
results["errors"].append("未找到 interactive_feedback 工具")
return results
# 步驟 4: 調用互動回饋工具
feedback_result = await self.call_interactive_feedback(
project_dir, test_summary, self.config.test_timeout
)
if "error" in feedback_result:
results["errors"].append(f"互動回饋調用失敗: {feedback_result['error']}")
return results
results["steps"]["interactive_feedback_called"] = True
results["feedback_result"] = feedback_result
results["success"] = True
debug_log("🎉 完整工作流程測試成功完成")
finally:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
return results
except Exception as e:
debug_log(f"❌ 完整工作流程測試異常: {e}")
results["errors"].append(f"測試異常: {str(e)}")
return results
finally:
# 獲取性能數據
self.performance_monitor.stop()
results["performance"] = self.performance_monitor.get_summary()
# 清理資源
await self.cleanup()
async def cleanup(self):
"""清理資源"""
await self.stop_server()
# 取消所有待處理的請求
for future in self.pending_requests.values():
if not future.done():
future.cancel()
self.pending_requests.clear()
self.performance_monitor.stop()
debug_log("🧹 MCP 客戶端資源已清理")

View File

@@ -0,0 +1,447 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試報告生成器
==============
生成詳細的 MCP 測試報告,支持多種格式輸出。
"""
import json
import time
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from pathlib import Path
from dataclasses import dataclass, asdict
from .config import TestConfig, DEFAULT_CONFIG
from .utils import TestUtils
from .validators import TestValidators, ValidationResult
from ..debug import debug_log
@dataclass
class TestReport:
"""測試報告數據結構"""
timestamp: str
duration: float
total_scenarios: int
passed_scenarios: int
failed_scenarios: int
success_rate: float
scenarios: List[Dict[str, Any]]
validation_summary: Dict[str, Any]
performance_summary: Dict[str, Any]
system_info: Dict[str, Any]
config: Dict[str, Any]
errors: List[str]
warnings: List[str]
class TestReporter:
"""測試報告生成器"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
self.validators = TestValidators(config)
def generate_report(self, test_results: Dict[str, Any]) -> TestReport:
"""生成測試報告"""
start_time = time.time()
# 提取基本信息
scenarios = test_results.get("results", [])
total_scenarios = test_results.get("total_scenarios", len(scenarios))
passed_scenarios = test_results.get("passed_scenarios", 0)
failed_scenarios = test_results.get("failed_scenarios", 0)
# 計算成功率
success_rate = passed_scenarios / total_scenarios if total_scenarios > 0 else 0
# 驗證測試結果
validation_results = {}
for i, scenario in enumerate(scenarios):
validation_results[f"scenario_{i}"] = self.validators.result_validator.validate_test_result(scenario)
validation_summary = self.validators.get_validation_summary(validation_results)
# 生成性能摘要
performance_summary = self._generate_performance_summary(scenarios)
# 收集錯誤和警告
all_errors = []
all_warnings = []
for scenario in scenarios:
all_errors.extend(scenario.get("errors", []))
# 計算總持續時間
total_duration = 0
for scenario in scenarios:
perf = scenario.get("performance", {})
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
total_duration += duration
# 創建報告
report = TestReport(
timestamp=datetime.now().isoformat(),
duration=total_duration,
total_scenarios=total_scenarios,
passed_scenarios=passed_scenarios,
failed_scenarios=failed_scenarios,
success_rate=success_rate,
scenarios=scenarios,
validation_summary=validation_summary,
performance_summary=performance_summary,
system_info=TestUtils.get_system_info(),
config=self.config.to_dict(),
errors=all_errors,
warnings=all_warnings
)
debug_log(f"📊 測試報告生成完成 (耗時: {time.time() - start_time:.2f}s)")
return report
def _generate_performance_summary(self, scenarios: List[Dict[str, Any]]) -> Dict[str, Any]:
"""生成性能摘要"""
total_duration = 0
min_duration = float('inf')
max_duration = 0
durations = []
memory_usage = []
for scenario in scenarios:
perf = scenario.get("performance", {})
# 處理持續時間
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
if duration > 0:
total_duration += duration
min_duration = min(min_duration, duration)
max_duration = max(max_duration, duration)
durations.append(duration)
# 處理內存使用
memory_diff = perf.get("memory_diff", {})
if memory_diff:
memory_usage.append(memory_diff)
# 計算平均值
avg_duration = total_duration / len(durations) if durations else 0
# 計算中位數
if durations:
sorted_durations = sorted(durations)
n = len(sorted_durations)
median_duration = (
sorted_durations[n // 2] if n % 2 == 1
else (sorted_durations[n // 2 - 1] + sorted_durations[n // 2]) / 2
)
else:
median_duration = 0
return {
"total_duration": total_duration,
"total_duration_formatted": TestUtils.format_duration(total_duration),
"avg_duration": avg_duration,
"avg_duration_formatted": TestUtils.format_duration(avg_duration),
"median_duration": median_duration,
"median_duration_formatted": TestUtils.format_duration(median_duration),
"min_duration": min_duration if min_duration != float('inf') else 0,
"min_duration_formatted": TestUtils.format_duration(min_duration if min_duration != float('inf') else 0),
"max_duration": max_duration,
"max_duration_formatted": TestUtils.format_duration(max_duration),
"scenarios_with_performance": len(durations),
"memory_usage_samples": len(memory_usage)
}
def save_report(self, report: TestReport, output_path: Optional[Path] = None) -> Path:
"""保存測試報告"""
if output_path is None:
output_dir = self.config.ensure_report_dir()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"mcp_test_report_{timestamp}.{self.config.report_format}"
output_path = output_dir / filename
output_path.parent.mkdir(parents=True, exist_ok=True)
if self.config.report_format.lower() == "json":
self._save_json_report(report, output_path)
elif self.config.report_format.lower() == "html":
self._save_html_report(report, output_path)
elif self.config.report_format.lower() == "markdown":
self._save_markdown_report(report, output_path)
else:
raise ValueError(f"不支持的報告格式: {self.config.report_format}")
debug_log(f"📄 測試報告已保存: {output_path}")
return output_path
def _save_json_report(self, report: TestReport, output_path: Path):
"""保存 JSON 格式報告"""
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(asdict(report), f, indent=2, ensure_ascii=False, default=str)
def _save_html_report(self, report: TestReport, output_path: Path):
"""保存 HTML 格式報告"""
html_content = self._generate_html_report(report)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(html_content)
def _save_markdown_report(self, report: TestReport, output_path: Path):
"""保存 Markdown 格式報告"""
markdown_content = self._generate_markdown_report(report)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
def _generate_html_report(self, report: TestReport) -> str:
"""生成 HTML 報告"""
# 狀態圖標
status_icon = "" if report.success_rate == 1.0 else "" if report.success_rate == 0 else "⚠️"
# 性能圖表數據(簡化版)
scenario_names = [s.get("scenario_name", f"Scenario {i}") for i, s in enumerate(report.scenarios)]
scenario_durations = []
for s in report.scenarios:
perf = s.get("performance", {})
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
scenario_durations.append(duration)
html = f"""
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP 測試報告</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
.header {{ text-align: center; margin-bottom: 30px; }}
.status {{ font-size: 24px; margin: 10px 0; }}
.success {{ color: #28a745; }}
.warning {{ color: #ffc107; }}
.error {{ color: #dc3545; }}
.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
.card {{ background: #f8f9fa; padding: 15px; border-radius: 6px; border-left: 4px solid #007bff; }}
.card h3 {{ margin: 0 0 10px 0; color: #333; }}
.card .value {{ font-size: 24px; font-weight: bold; color: #007bff; }}
.scenarios {{ margin: 20px 0; }}
.scenario {{ background: #f8f9fa; margin: 10px 0; padding: 15px; border-radius: 6px; border-left: 4px solid #28a745; }}
.scenario.failed {{ border-left-color: #dc3545; }}
.scenario h4 {{ margin: 0 0 10px 0; }}
.scenario-details {{ display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 14px; }}
.errors {{ background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin: 10px 0; }}
.performance {{ margin: 20px 0; }}
.footer {{ text-align: center; margin-top: 30px; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 MCP 測試報告</h1>
<div class="status {'success' if report.success_rate == 1.0 else 'warning' if report.success_rate > 0 else 'error'}">
{status_icon} 測試完成
</div>
<p>生成時間: {report.timestamp}</p>
</div>
<div class="summary">
<div class="card">
<h3>總測試數</h3>
<div class="value">{report.total_scenarios}</div>
</div>
<div class="card">
<h3>通過測試</h3>
<div class="value" style="color: #28a745;">{report.passed_scenarios}</div>
</div>
<div class="card">
<h3>失敗測試</h3>
<div class="value" style="color: #dc3545;">{report.failed_scenarios}</div>
</div>
<div class="card">
<h3>成功率</h3>
<div class="value">{report.success_rate:.1%}</div>
</div>
<div class="card">
<h3>總耗時</h3>
<div class="value">{report.performance_summary.get('total_duration_formatted', 'N/A')}</div>
</div>
<div class="card">
<h3>平均耗時</h3>
<div class="value">{report.performance_summary.get('avg_duration_formatted', 'N/A')}</div>
</div>
</div>
<div class="scenarios">
<h2>📋 測試場景詳情</h2>
"""
for i, scenario in enumerate(report.scenarios):
success = scenario.get("success", False)
scenario_name = scenario.get("scenario_name", f"Scenario {i+1}")
scenario_desc = scenario.get("scenario_description", "無描述")
perf = scenario.get("performance", {})
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
duration_str = TestUtils.format_duration(duration) if duration > 0 else "N/A"
steps = scenario.get("steps", {})
completed_steps = sum(1 for v in steps.values() if v)
total_steps = len(steps)
errors = scenario.get("errors", [])
html += f"""
<div class="scenario {'failed' if not success else ''}">
<h4>{'' if success else ''} {scenario_name}</h4>
<p>{scenario_desc}</p>
<div class="scenario-details">
<div><strong>狀態:</strong> {'通過' if success else '失敗'}</div>
<div><strong>耗時:</strong> {duration_str}</div>
<div><strong>完成步驟:</strong> {completed_steps}/{total_steps}</div>
<div><strong>錯誤數:</strong> {len(errors)}</div>
</div>
"""
if errors:
html += '<div class="errors"><strong>錯誤信息:</strong><ul>'
for error in errors:
html += f'<li>{error}</li>'
html += '</ul></div>'
html += '</div>'
html += f"""
</div>
<div class="performance">
<h2>📊 性能統計</h2>
<div class="summary">
<div class="card">
<h3>最快測試</h3>
<div class="value">{report.performance_summary.get('min_duration_formatted', 'N/A')}</div>
</div>
<div class="card">
<h3>最慢測試</h3>
<div class="value">{report.performance_summary.get('max_duration_formatted', 'N/A')}</div>
</div>
<div class="card">
<h3>中位數</h3>
<div class="value">{report.performance_summary.get('median_duration_formatted', 'N/A')}</div>
</div>
</div>
</div>
<div class="footer">
<p>MCP Feedback Enhanced 測試框架 | 生成時間: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
</div>
</body>
</html>
"""
return html
def _generate_markdown_report(self, report: TestReport) -> str:
"""生成 Markdown 報告"""
status_icon = "" if report.success_rate == 1.0 else "" if report.success_rate == 0 else "⚠️"
md = f"""# 🧪 MCP 測試報告
{status_icon} **測試狀態**: {'全部通過' if report.success_rate == 1.0 else '部分失敗' if report.success_rate > 0 else '全部失敗'}
**生成時間**: {report.timestamp}
## 📊 測試摘要
| 指標 | 數值 |
|------|------|
| 總測試數 | {report.total_scenarios} |
| 通過測試 | {report.passed_scenarios} |
| 失敗測試 | {report.failed_scenarios} |
| 成功率 | {report.success_rate:.1%} |
| 總耗時 | {report.performance_summary.get('total_duration_formatted', 'N/A')} |
| 平均耗時 | {report.performance_summary.get('avg_duration_formatted', 'N/A')} |
## 📋 測試場景詳情
"""
for i, scenario in enumerate(report.scenarios):
success = scenario.get("success", False)
scenario_name = scenario.get("scenario_name", f"Scenario {i+1}")
scenario_desc = scenario.get("scenario_description", "無描述")
perf = scenario.get("performance", {})
duration = perf.get("total_duration", 0) or perf.get("total_time", 0)
duration_str = TestUtils.format_duration(duration) if duration > 0 else "N/A"
steps = scenario.get("steps", {})
completed_steps = sum(1 for v in steps.values() if v)
total_steps = len(steps)
errors = scenario.get("errors", [])
md += f"""### {'' if success else ''} {scenario_name}
**描述**: {scenario_desc}
- **狀態**: {'通過' if success else '失敗'}
- **耗時**: {duration_str}
- **完成步驟**: {completed_steps}/{total_steps}
- **錯誤數**: {len(errors)}
"""
if errors:
md += "**錯誤信息**:\n"
for error in errors:
md += f"- {error}\n"
md += "\n"
md += f"""## 📊 性能統計
| 指標 | 數值 |
|------|------|
| 最快測試 | {report.performance_summary.get('min_duration_formatted', 'N/A')} |
| 最慢測試 | {report.performance_summary.get('max_duration_formatted', 'N/A')} |
| 中位數 | {report.performance_summary.get('median_duration_formatted', 'N/A')} |
## 🔧 系統信息
| 項目 | 值 |
|------|---|
| CPU 核心數 | {report.system_info.get('cpu_count', 'N/A')} |
| 總內存 | {report.system_info.get('memory_total', 'N/A')} |
| 可用內存 | {report.system_info.get('memory_available', 'N/A')} |
---
*報告由 MCP Feedback Enhanced 測試框架生成 | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
"""
return md
def print_summary(self, report: TestReport):
"""打印測試摘要到控制台"""
status_icon = "" if report.success_rate == 1.0 else "" if report.success_rate == 0 else "⚠️"
print("\n" + "="*60)
print(f"🧪 MCP 測試報告摘要 {status_icon}")
print("="*60)
print(f"📊 總測試數: {report.total_scenarios}")
print(f"✅ 通過測試: {report.passed_scenarios}")
print(f"❌ 失敗測試: {report.failed_scenarios}")
print(f"📈 成功率: {report.success_rate:.1%}")
print(f"⏱️ 總耗時: {report.performance_summary.get('total_duration_formatted', 'N/A')}")
print(f"⚡ 平均耗時: {report.performance_summary.get('avg_duration_formatted', 'N/A')}")
if report.errors:
print(f"\n❌ 發現 {len(report.errors)} 個錯誤:")
for error in report.errors[:5]: # 只顯示前5個錯誤
print(f"{error}")
if len(report.errors) > 5:
print(f" ... 還有 {len(report.errors) - 5} 個錯誤")
print("="*60)

View File

@@ -0,0 +1,469 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試場景定義
============
定義各種 MCP 測試場景,包括正常流程、錯誤處理、性能測試等。
"""
import asyncio
import time
import random
from typing import Dict, Any, List, Optional, Callable, Awaitable
from dataclasses import dataclass, field
from pathlib import Path
from .mcp_client import MCPTestClient
from .config import TestConfig, DEFAULT_CONFIG
from .utils import TestUtils, PerformanceMonitor, performance_context
from ..debug import debug_log
@dataclass
class TestScenario:
"""測試場景類"""
name: str
description: str
timeout: int = 120
retry_count: int = 1
parallel: bool = False
tags: List[str] = field(default_factory=list)
setup: Optional[Callable] = None
teardown: Optional[Callable] = None
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行測試場景"""
raise NotImplementedError
class BasicWorkflowScenario(TestScenario):
"""基礎工作流程測試場景"""
def __init__(self):
super().__init__(
name="basic_workflow",
description="測試基本的 MCP 工作流程:初始化 -> 工具發現 -> 工具調用",
timeout=180,
tags=["basic", "workflow", "integration"]
)
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行基礎工作流程測試"""
async with performance_context("basic_workflow") as monitor:
result = await client.full_workflow_test()
# 添加額外的驗證
if result["success"]:
# 檢查必要的步驟是否完成
required_steps = ["server_started", "initialized", "tools_discovered", "interactive_feedback_called"]
missing_steps = [step for step in required_steps if not result["steps"].get(step, False)]
if missing_steps:
result["success"] = False
result["errors"].append(f"缺少必要步驟: {missing_steps}")
return result
class QuickConnectionScenario(TestScenario):
"""快速連接測試場景"""
def __init__(self):
super().__init__(
name="quick_connection",
description="測試 MCP 服務器的快速啟動和連接",
timeout=30,
tags=["quick", "connection", "startup"]
)
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行快速連接測試"""
result = {
"success": False,
"steps": {},
"performance": {},
"errors": []
}
try:
start_time = time.time()
# 啟動服務器
if not await client.start_server():
result["errors"].append("服務器啟動失敗")
return result
result["steps"]["server_started"] = True
# 啟動消息讀取
read_task = asyncio.create_task(client.read_messages())
try:
# 初始化連接
if not await client.initialize():
result["errors"].append("初始化失敗")
return result
result["steps"]["initialized"] = True
# 獲取工具列表
tools = await client.list_tools()
if not tools:
result["errors"].append("工具列表為空")
return result
result["steps"]["tools_discovered"] = True
end_time = time.time()
result["performance"]["total_time"] = end_time - start_time
result["performance"]["tools_count"] = len(tools)
result["success"] = True
finally:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
except Exception as e:
result["errors"].append(f"測試異常: {str(e)}")
finally:
await client.cleanup()
return result
class TimeoutHandlingScenario(TestScenario):
"""超時處理測試場景"""
def __init__(self):
super().__init__(
name="timeout_handling",
description="測試超時情況下的處理機制",
timeout=60,
tags=["timeout", "error_handling", "resilience"]
)
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行超時處理測試"""
result = {
"success": False,
"steps": {},
"performance": {},
"errors": []
}
try:
# 設置很短的超時時間來觸發超時
original_timeout = client.config.mcp_timeout
client.config.mcp_timeout = 5 # 5 秒超時
# 啟動服務器
if not await client.start_server():
result["errors"].append("服務器啟動失敗")
return result
result["steps"]["server_started"] = True
# 啟動消息讀取
read_task = asyncio.create_task(client.read_messages())
try:
# 初始化連接
if not await client.initialize():
result["errors"].append("初始化失敗")
return result
result["steps"]["initialized"] = True
# 嘗試調用互動回饋工具(應該超時)
feedback_result = await client.call_interactive_feedback(
str(Path.cwd()),
"超時測試 - 這個調用應該會超時",
timeout=10 # 10 秒超時,但 MCP 客戶端設置為 5 秒
)
# 檢查是否正確處理了超時
if "error" in feedback_result:
result["steps"]["timeout_handled"] = True
result["success"] = True
debug_log("✅ 超時處理測試成功")
else:
result["errors"].append("未正確處理超時情況")
finally:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
# 恢復原始超時設置
client.config.mcp_timeout = original_timeout
except Exception as e:
result["errors"].append(f"測試異常: {str(e)}")
finally:
await client.cleanup()
return result
class ConcurrentCallsScenario(TestScenario):
"""並發調用測試場景"""
def __init__(self):
super().__init__(
name="concurrent_calls",
description="測試並發 MCP 調用的處理能力",
timeout=300,
parallel=True,
tags=["concurrent", "performance", "stress"]
)
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行並發調用測試"""
result = {
"success": False,
"steps": {},
"performance": {},
"errors": []
}
try:
# 啟動服務器
if not await client.start_server():
result["errors"].append("服務器啟動失敗")
return result
result["steps"]["server_started"] = True
# 啟動消息讀取
read_task = asyncio.create_task(client.read_messages())
try:
# 初始化連接
if not await client.initialize():
result["errors"].append("初始化失敗")
return result
result["steps"]["initialized"] = True
# 並發獲取工具列表
concurrent_count = 5
tasks = []
for i in range(concurrent_count):
task = asyncio.create_task(client.list_tools())
tasks.append(task)
start_time = time.time()
results = await asyncio.gather(*tasks, return_exceptions=True)
end_time = time.time()
# 分析結果
successful_calls = 0
failed_calls = 0
for i, res in enumerate(results):
if isinstance(res, Exception):
failed_calls += 1
debug_log(f"並發調用 {i+1} 失敗: {res}")
elif isinstance(res, list) and len(res) > 0:
successful_calls += 1
else:
failed_calls += 1
result["performance"]["concurrent_count"] = concurrent_count
result["performance"]["successful_calls"] = successful_calls
result["performance"]["failed_calls"] = failed_calls
result["performance"]["total_time"] = end_time - start_time
result["performance"]["avg_time_per_call"] = (end_time - start_time) / concurrent_count
# 判斷成功條件:至少 80% 的調用成功
success_rate = successful_calls / concurrent_count
if success_rate >= 0.8:
result["success"] = True
result["steps"]["concurrent_calls_handled"] = True
debug_log(f"✅ 並發調用測試成功 (成功率: {success_rate:.1%})")
else:
result["errors"].append(f"並發調用成功率過低: {success_rate:.1%}")
finally:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
except Exception as e:
result["errors"].append(f"測試異常: {str(e)}")
finally:
await client.cleanup()
return result
class MockTestScenario(TestScenario):
"""模擬測試場景(用於演示)"""
def __init__(self):
super().__init__(
name="mock_test",
description="模擬測試場景,用於演示測試框架功能",
timeout=10,
tags=["mock", "demo", "quick"]
)
async def run(self, client: MCPTestClient) -> Dict[str, Any]:
"""運行模擬測試"""
result = {
"success": True,
"steps": {
"mock_step_1": True,
"mock_step_2": True,
"mock_step_3": True
},
"performance": {
"total_duration": 0.5,
"total_time": 0.5
},
"errors": []
}
# 模擬一些處理時間
await asyncio.sleep(0.5)
debug_log("✅ 模擬測試完成")
return result
class TestScenarios:
"""測試場景管理器"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
self.scenarios: Dict[str, TestScenario] = {}
self._register_default_scenarios()
def _register_default_scenarios(self):
"""註冊默認測試場景"""
scenarios = [
MockTestScenario(), # 添加模擬測試場景
BasicWorkflowScenario(),
QuickConnectionScenario(),
TimeoutHandlingScenario(),
ConcurrentCallsScenario(),
]
for scenario in scenarios:
self.scenarios[scenario.name] = scenario
def register_scenario(self, scenario: TestScenario):
"""註冊自定義測試場景"""
self.scenarios[scenario.name] = scenario
def get_scenario(self, name: str) -> Optional[TestScenario]:
"""獲取測試場景"""
return self.scenarios.get(name)
def list_scenarios(self, tags: Optional[List[str]] = None) -> List[TestScenario]:
"""列出測試場景"""
scenarios = list(self.scenarios.values())
if tags:
scenarios = [
scenario for scenario in scenarios
if any(tag in scenario.tags for tag in tags)
]
return scenarios
async def run_scenario(self, scenario_name: str) -> Dict[str, Any]:
"""運行單個測試場景"""
scenario = self.get_scenario(scenario_name)
if not scenario:
return {
"success": False,
"errors": [f"未找到測試場景: {scenario_name}"]
}
debug_log(f"🧪 運行測試場景: {scenario.name}")
debug_log(f" 描述: {scenario.description}")
client = MCPTestClient(self.config)
try:
# 執行設置
if scenario.setup:
await scenario.setup()
# 運行測試
result = await TestUtils.timeout_wrapper(
scenario.run(client),
scenario.timeout,
f"測試場景 '{scenario.name}' 超時"
)
result["scenario_name"] = scenario.name
result["scenario_description"] = scenario.description
return result
except Exception as e:
debug_log(f"❌ 測試場景 '{scenario.name}' 執行失敗: {e}")
return {
"success": False,
"scenario_name": scenario.name,
"scenario_description": scenario.description,
"errors": [f"執行異常: {str(e)}"]
}
finally:
# 執行清理
if scenario.teardown:
try:
await scenario.teardown()
except Exception as e:
debug_log(f"⚠️ 測試場景 '{scenario.name}' 清理失敗: {e}")
async def run_all_scenarios(self, tags: Optional[List[str]] = None) -> Dict[str, Any]:
"""運行所有測試場景"""
scenarios = self.list_scenarios(tags)
if not scenarios:
return {
"success": False,
"total_scenarios": 0,
"passed_scenarios": 0,
"failed_scenarios": 0,
"results": [],
"errors": ["沒有找到匹配的測試場景"]
}
debug_log(f"🚀 開始運行 {len(scenarios)} 個測試場景...")
results = []
passed_count = 0
failed_count = 0
for scenario in scenarios:
result = await self.run_scenario(scenario.name)
results.append(result)
if result.get("success", False):
passed_count += 1
debug_log(f"{scenario.name}: 通過")
else:
failed_count += 1
debug_log(f"{scenario.name}: 失敗")
overall_success = failed_count == 0
debug_log(f"📊 測試完成: {passed_count}/{len(scenarios)} 通過")
return {
"success": overall_success,
"total_scenarios": len(scenarios),
"passed_scenarios": passed_count,
"failed_scenarios": failed_count,
"results": results
}

View File

@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試工具函數
============
提供 MCP 測試框架使用的通用工具函數。
"""
import asyncio
import time
import json
import uuid
import socket
import psutil
import threading
from typing import Dict, Any, Optional, List, Callable, Awaitable
from pathlib import Path
from datetime import datetime, timedelta
from contextlib import asynccontextmanager
from ..debug import debug_log
class TestUtils:
"""測試工具類"""
@staticmethod
def generate_test_id() -> str:
"""生成測試 ID"""
return f"test_{uuid.uuid4().hex[:8]}"
@staticmethod
def generate_session_id() -> str:
"""生成會話 ID"""
return str(uuid.uuid4())
@staticmethod
def get_timestamp() -> str:
"""獲取當前時間戳"""
return datetime.now().isoformat()
@staticmethod
def format_duration(seconds: float) -> str:
"""格式化持續時間"""
if seconds < 1:
return f"{seconds*1000:.1f}ms"
elif seconds < 60:
return f"{seconds:.2f}s"
else:
minutes = int(seconds // 60)
remaining_seconds = seconds % 60
return f"{minutes}m {remaining_seconds:.1f}s"
@staticmethod
def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
"""尋找可用端口"""
for port in range(start_port, start_port + max_attempts):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', port))
return port
except OSError:
continue
raise RuntimeError(f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})")
@staticmethod
def is_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
"""檢查端口是否開放"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
result = s.connect_ex((host, port))
return result == 0
except Exception:
return False
@staticmethod
async def wait_for_port(host: str, port: int, timeout: float = 30.0, interval: float = 0.5) -> bool:
"""等待端口開放"""
start_time = time.time()
while time.time() - start_time < timeout:
if TestUtils.is_port_open(host, port):
return True
await asyncio.sleep(interval)
return False
@staticmethod
def get_system_info() -> Dict[str, Any]:
"""獲取系統信息"""
try:
return {
'cpu_count': psutil.cpu_count(),
'memory_total': psutil.virtual_memory().total,
'memory_available': psutil.virtual_memory().available,
'disk_usage': psutil.disk_usage('/').percent if hasattr(psutil, 'disk_usage') else None,
'platform': psutil.WINDOWS if hasattr(psutil, 'WINDOWS') else 'unknown'
}
except Exception as e:
debug_log(f"獲取系統信息失敗: {e}")
return {}
@staticmethod
def measure_memory_usage() -> Dict[str, float]:
"""測量內存使用情況"""
try:
process = psutil.Process()
memory_info = process.memory_info()
return {
'rss': memory_info.rss / 1024 / 1024, # MB
'vms': memory_info.vms / 1024 / 1024, # MB
'percent': process.memory_percent()
}
except Exception as e:
debug_log(f"測量內存使用失敗: {e}")
return {}
@staticmethod
async def timeout_wrapper(coro: Awaitable, timeout: float, error_message: str = "操作超時"):
"""為協程添加超時包裝"""
try:
return await asyncio.wait_for(coro, timeout=timeout)
except asyncio.TimeoutError:
raise TimeoutError(f"{error_message} (超時: {timeout}s)")
@staticmethod
def safe_json_loads(data: str) -> Optional[Dict[str, Any]]:
"""安全的 JSON 解析"""
try:
return json.loads(data)
except (json.JSONDecodeError, TypeError) as e:
debug_log(f"JSON 解析失敗: {e}")
return None
@staticmethod
def safe_json_dumps(data: Any, indent: int = 2) -> str:
"""安全的 JSON 序列化"""
try:
return json.dumps(data, indent=indent, ensure_ascii=False, default=str)
except (TypeError, ValueError) as e:
debug_log(f"JSON 序列化失敗: {e}")
return str(data)
@staticmethod
def create_test_directory(base_dir: str = "test_temp") -> Path:
"""創建測試目錄"""
test_dir = Path(base_dir) / f"test_{uuid.uuid4().hex[:8]}"
test_dir.mkdir(parents=True, exist_ok=True)
return test_dir
@staticmethod
def cleanup_test_directory(test_dir: Path):
"""清理測試目錄"""
try:
if test_dir.exists() and test_dir.is_dir():
import shutil
shutil.rmtree(test_dir)
except Exception as e:
debug_log(f"清理測試目錄失敗: {e}")
class PerformanceMonitor:
"""性能監控器"""
def __init__(self):
self.start_time: Optional[float] = None
self.end_time: Optional[float] = None
self.memory_start: Optional[Dict[str, float]] = None
self.memory_end: Optional[Dict[str, float]] = None
self.checkpoints: List[Dict[str, Any]] = []
def start(self):
"""開始監控"""
self.start_time = time.time()
self.memory_start = TestUtils.measure_memory_usage()
self.checkpoints = []
def checkpoint(self, name: str, data: Optional[Dict[str, Any]] = None):
"""添加檢查點"""
if self.start_time is None:
return
checkpoint = {
'name': name,
'timestamp': time.time(),
'elapsed': time.time() - self.start_time,
'memory': TestUtils.measure_memory_usage(),
'data': data or {}
}
self.checkpoints.append(checkpoint)
def stop(self):
"""停止監控"""
self.end_time = time.time()
self.memory_end = TestUtils.measure_memory_usage()
def get_summary(self) -> Dict[str, Any]:
"""獲取監控摘要"""
if self.start_time is None or self.end_time is None:
return {}
total_duration = self.end_time - self.start_time
memory_diff = {}
if self.memory_start and self.memory_end:
for key in self.memory_start:
if key in self.memory_end:
memory_diff[f"memory_{key}_diff"] = self.memory_end[key] - self.memory_start[key]
return {
'total_duration': total_duration,
'total_duration_formatted': TestUtils.format_duration(total_duration),
'memory_start': self.memory_start,
'memory_end': self.memory_end,
'memory_diff': memory_diff,
'checkpoints_count': len(self.checkpoints),
'checkpoints': self.checkpoints
}
@asynccontextmanager
async def performance_context(name: str = "test"):
"""性能監控上下文管理器"""
monitor = PerformanceMonitor()
monitor.start()
try:
yield monitor
finally:
monitor.stop()
summary = monitor.get_summary()
debug_log(f"性能監控 [{name}]: {TestUtils.format_duration(summary.get('total_duration', 0))}")
class AsyncEventWaiter:
"""異步事件等待器"""
def __init__(self):
self.events: Dict[str, asyncio.Event] = {}
self.results: Dict[str, Any] = {}
def create_event(self, event_name: str):
"""創建事件"""
self.events[event_name] = asyncio.Event()
def set_event(self, event_name: str, result: Any = None):
"""設置事件"""
if event_name in self.events:
self.results[event_name] = result
self.events[event_name].set()
async def wait_for_event(self, event_name: str, timeout: float = 30.0) -> Any:
"""等待事件"""
if event_name not in self.events:
self.create_event(event_name)
try:
await asyncio.wait_for(self.events[event_name].wait(), timeout=timeout)
return self.results.get(event_name)
except asyncio.TimeoutError:
raise TimeoutError(f"等待事件 '{event_name}' 超時 ({timeout}s)")
def clear_event(self, event_name: str):
"""清除事件"""
if event_name in self.events:
self.events[event_name].clear()
self.results.pop(event_name, None)

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試結果驗證器
==============
驗證 MCP 測試結果是否符合規範和預期。
"""
import json
import re
from typing import Dict, Any, List, Optional, Union
from dataclasses import dataclass
from pathlib import Path
from .config import TestConfig, DEFAULT_CONFIG
from .utils import TestUtils
from ..debug import debug_log
@dataclass
class ValidationResult:
"""驗證結果"""
valid: bool
errors: List[str]
warnings: List[str]
details: Dict[str, Any]
def add_error(self, message: str):
"""添加錯誤"""
self.errors.append(message)
self.valid = False
def add_warning(self, message: str):
"""添加警告"""
self.warnings.append(message)
def add_detail(self, key: str, value: Any):
"""添加詳細信息"""
self.details[key] = value
class MCPMessageValidator:
"""MCP 消息驗證器"""
@staticmethod
def validate_json_rpc(message: Dict[str, Any]) -> ValidationResult:
"""驗證 JSON-RPC 2.0 格式"""
result = ValidationResult(True, [], [], {})
# 檢查必需字段
if "jsonrpc" not in message:
result.add_error("缺少 'jsonrpc' 字段")
elif message["jsonrpc"] != "2.0":
result.add_error(f"無效的 jsonrpc 版本: {message['jsonrpc']}")
# 檢查消息類型
is_request = "method" in message
is_response = "result" in message or "error" in message
is_notification = is_request and "id" not in message
if not (is_request or is_response):
result.add_error("消息既不是請求也不是響應")
if is_request and is_response:
result.add_error("消息不能同時是請求和響應")
# 驗證請求格式
if is_request:
if not isinstance(message.get("method"), str):
result.add_error("method 字段必須是字符串")
if not is_notification and "id" not in message:
result.add_error("非通知請求必須包含 id 字段")
# 驗證響應格式
if is_response:
if "id" not in message:
result.add_error("響應必須包含 id 字段")
if "result" in message and "error" in message:
result.add_error("響應不能同時包含 result 和 error")
if "result" not in message and "error" not in message:
result.add_error("響應必須包含 result 或 error")
# 驗證錯誤格式
if "error" in message:
error = message["error"]
if not isinstance(error, dict):
result.add_error("error 字段必須是對象")
else:
if "code" not in error:
result.add_error("error 對象必須包含 code 字段")
elif not isinstance(error["code"], int):
result.add_error("error.code 必須是整數")
if "message" not in error:
result.add_error("error 對象必須包含 message 字段")
elif not isinstance(error["message"], str):
result.add_error("error.message 必須是字符串")
result.add_detail("message_type", "request" if is_request else "response")
result.add_detail("is_notification", is_notification)
return result
@staticmethod
def validate_mcp_initialize(message: Dict[str, Any]) -> ValidationResult:
"""驗證 MCP 初始化消息"""
result = ValidationResult(True, [], [], {})
# 先驗證 JSON-RPC 格式
json_rpc_result = MCPMessageValidator.validate_json_rpc(message)
result.errors.extend(json_rpc_result.errors)
result.warnings.extend(json_rpc_result.warnings)
if not json_rpc_result.valid:
result.valid = False
return result
# 驗證初始化特定字段
if message.get("method") == "initialize":
params = message.get("params", {})
if "protocolVersion" not in params:
result.add_error("初始化請求必須包含 protocolVersion")
if "clientInfo" not in params:
result.add_error("初始化請求必須包含 clientInfo")
else:
client_info = params["clientInfo"]
if not isinstance(client_info, dict):
result.add_error("clientInfo 必須是對象")
else:
if "name" not in client_info:
result.add_error("clientInfo 必須包含 name")
if "version" not in client_info:
result.add_error("clientInfo 必須包含 version")
elif "result" in message:
# 驗證初始化響應
result_data = message.get("result", {})
if "serverInfo" not in result_data:
result.add_warning("初始化響應建議包含 serverInfo")
if "capabilities" not in result_data:
result.add_warning("初始化響應建議包含 capabilities")
return result
@staticmethod
def validate_tools_list(message: Dict[str, Any]) -> ValidationResult:
"""驗證工具列表消息"""
result = ValidationResult(True, [], [], {})
# 先驗證 JSON-RPC 格式
json_rpc_result = MCPMessageValidator.validate_json_rpc(message)
result.errors.extend(json_rpc_result.errors)
result.warnings.extend(json_rpc_result.warnings)
if not json_rpc_result.valid:
result.valid = False
return result
# 驗證工具列表響應
if "result" in message:
result_data = message.get("result", {})
if "tools" not in result_data:
result.add_error("工具列表響應必須包含 tools 字段")
else:
tools = result_data["tools"]
if not isinstance(tools, list):
result.add_error("tools 字段必須是數組")
else:
for i, tool in enumerate(tools):
if not isinstance(tool, dict):
result.add_error(f"工具 {i} 必須是對象")
continue
if "name" not in tool:
result.add_error(f"工具 {i} 必須包含 name 字段")
if "description" not in tool:
result.add_warning(f"工具 {i} 建議包含 description 字段")
if "inputSchema" not in tool:
result.add_warning(f"工具 {i} 建議包含 inputSchema 字段")
result.add_detail("tools_count", len(tools))
return result
class TestResultValidator:
"""測試結果驗證器"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
def validate_test_result(self, test_result: Dict[str, Any]) -> ValidationResult:
"""驗證測試結果"""
result = ValidationResult(True, [], [], {})
# 檢查必需字段
required_fields = ["success", "steps", "performance", "errors"]
for field in required_fields:
if field not in test_result:
result.add_error(f"測試結果缺少必需字段: {field}")
# 驗證成功標誌
if "success" in test_result:
if not isinstance(test_result["success"], bool):
result.add_error("success 字段必須是布爾值")
# 驗證步驟信息
if "steps" in test_result:
steps = test_result["steps"]
if not isinstance(steps, dict):
result.add_error("steps 字段必須是對象")
else:
result.add_detail("completed_steps", list(steps.keys()))
result.add_detail("steps_count", len(steps))
# 驗證錯誤信息
if "errors" in test_result:
errors = test_result["errors"]
if not isinstance(errors, list):
result.add_error("errors 字段必須是數組")
else:
result.add_detail("error_count", len(errors))
if len(errors) > 0 and test_result.get("success", False):
result.add_warning("測試標記為成功但包含錯誤信息")
# 驗證性能數據
if "performance" in test_result:
performance = test_result["performance"]
if not isinstance(performance, dict):
result.add_error("performance 字段必須是對象")
else:
self._validate_performance_data(performance, result)
return result
def _validate_performance_data(self, performance: Dict[str, Any], result: ValidationResult):
"""驗證性能數據"""
# 檢查時間相關字段
time_fields = ["total_duration", "total_time"]
for field in time_fields:
if field in performance:
value = performance[field]
if not isinstance(value, (int, float)):
result.add_error(f"性能字段 {field} 必須是數字")
elif value < 0:
result.add_error(f"性能字段 {field} 不能為負數")
elif value > self.config.test_timeout:
result.add_warning(f"性能字段 {field} 超過測試超時時間")
# 檢查內存相關字段
memory_fields = ["memory_start", "memory_end", "memory_diff"]
for field in memory_fields:
if field in performance:
value = performance[field]
if not isinstance(value, dict):
result.add_warning(f"性能字段 {field} 應該是對象")
# 檢查檢查點數據
if "checkpoints" in performance:
checkpoints = performance["checkpoints"]
if not isinstance(checkpoints, list):
result.add_error("checkpoints 字段必須是數組")
else:
result.add_detail("checkpoints_count", len(checkpoints))
def validate_interactive_feedback_result(self, feedback_result: Dict[str, Any]) -> ValidationResult:
"""驗證互動回饋結果"""
result = ValidationResult(True, [], [], {})
# 檢查是否有錯誤
if "error" in feedback_result:
result.add_detail("has_error", True)
result.add_detail("error_message", feedback_result["error"])
return result
# 檢查預期字段
expected_fields = ["command_logs", "interactive_feedback", "images"]
for field in expected_fields:
if field not in feedback_result:
result.add_warning(f"互動回饋結果建議包含 {field} 字段")
# 驗證命令日誌
if "command_logs" in feedback_result:
logs = feedback_result["command_logs"]
if not isinstance(logs, str):
result.add_error("command_logs 字段必須是字符串")
# 驗證互動回饋
if "interactive_feedback" in feedback_result:
feedback = feedback_result["interactive_feedback"]
if not isinstance(feedback, str):
result.add_error("interactive_feedback 字段必須是字符串")
elif len(feedback.strip()) == 0:
result.add_warning("interactive_feedback 為空")
# 驗證圖片數據
if "images" in feedback_result:
images = feedback_result["images"]
if not isinstance(images, list):
result.add_error("images 字段必須是數組")
else:
result.add_detail("images_count", len(images))
for i, image in enumerate(images):
if not isinstance(image, dict):
result.add_error(f"圖片 {i} 必須是對象")
continue
if "data" not in image:
result.add_error(f"圖片 {i} 必須包含 data 字段")
if "media_type" not in image:
result.add_error(f"圖片 {i} 必須包含 media_type 字段")
return result
class TestValidators:
"""測試驗證器集合"""
def __init__(self, config: Optional[TestConfig] = None):
self.config = config or DEFAULT_CONFIG
self.message_validator = MCPMessageValidator()
self.result_validator = TestResultValidator(config)
def validate_all(self, test_data: Dict[str, Any]) -> Dict[str, ValidationResult]:
"""驗證所有測試數據"""
results = {}
# 驗證測試結果
if "test_result" in test_data:
results["test_result"] = self.result_validator.validate_test_result(
test_data["test_result"]
)
# 驗證 MCP 消息
if "mcp_messages" in test_data:
message_results = []
for i, message in enumerate(test_data["mcp_messages"]):
msg_result = self.message_validator.validate_json_rpc(message)
msg_result.add_detail("message_index", i)
message_results.append(msg_result)
results["mcp_messages"] = message_results
# 驗證互動回饋結果
if "feedback_result" in test_data:
results["feedback_result"] = self.result_validator.validate_interactive_feedback_result(
test_data["feedback_result"]
)
return results
def get_validation_summary(self, validation_results: Dict[str, ValidationResult]) -> Dict[str, Any]:
"""獲取驗證摘要"""
total_errors = 0
total_warnings = 0
valid_count = 0
total_count = 0
for key, result in validation_results.items():
if isinstance(result, list):
# 處理消息列表
for msg_result in result:
total_errors += len(msg_result.errors)
total_warnings += len(msg_result.warnings)
if msg_result.valid:
valid_count += 1
total_count += 1
else:
# 處理單個結果
total_errors += len(result.errors)
total_warnings += len(result.warnings)
if result.valid:
valid_count += 1
total_count += 1
return {
"total_validations": total_count,
"valid_count": valid_count,
"invalid_count": total_count - valid_count,
"total_errors": total_errors,
"total_warnings": total_warnings,
"success_rate": valid_count / total_count if total_count > 0 else 0
}

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 模組
===========
提供基於 FastAPI 的 Web 用戶介面,專為 SSH 遠端開發環境設計。
支援文字輸入、圖片上傳、命令執行等功能,並參考 GUI 的設計模式。
"""
from .main import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
__all__ = [
'WebUIManager',
'launch_web_feedback_ui',
'get_web_ui_manager',
'stop_web_ui'
]

View File

@@ -0,0 +1,214 @@
{
"app": {
"title": "MCP Interactive Feedback System",
"subtitle": "AI Assistant Interactive Feedback Platform",
"projectDirectory": "Project Directory"
},
"tabs": {
"feedback": "💬 Feedback",
"summary": "📋 AI Summary",
"commands": "⚡ Commands",
"command": "⚡ Commands",
"settings": "⚙️ Settings",
"combined": "📝 Combined Mode",
"about": " About"
},
"feedback": {
"title": "💬 Provide Feedback",
"description": "Please provide your feedback on the AI assistant's work. You can enter text feedback and upload related images.",
"textLabel": "Text Feedback",
"placeholder": "Please enter your feedback here...",
"detailedPlaceholder": "Please enter your feedback here...\n\n💡 Tips:\n• Press Ctrl+Enter/Cmd+Enter (numpad supported) for quick submit\n• Press Ctrl+V/Cmd+V to paste clipboard images directly",
"imageLabel": "Image Attachments (Optional)",
"imageUploadText": "📎 Click to select images or drag and drop images here\nSupports PNG, JPG, JPEG, GIF, BMP, WebP formats",
"submit": "✅ Submit Feedback",
"uploading": "Uploading...",
"dragdrop": "Drag and drop images here or click to upload",
"selectfiles": "Select Files",
"processing": "Processing...",
"success": "Feedback submitted successfully!",
"error": "Error submitting feedback",
"shortcuts": {
"submit": "Ctrl+Enter to submit (Cmd+Enter on Mac, numpad supported)",
"clear": "Ctrl+Delete to clear (Cmd+Delete on Mac)",
"paste": "Ctrl+V to paste images (Cmd+V on Mac)"
}
},
"summary": {
"title": "📋 AI Work Summary",
"description": "Below is the work summary completed by the AI assistant. Please review carefully and provide your feedback.",
"placeholder": "AI work summary will be displayed here...",
"empty": "No summary content available",
"lastupdate": "Last updated",
"refresh": "Refresh"
},
"commands": {
"title": "⚡ Command Execution",
"description": "Execute commands here to verify results or collect more information. Commands will be executed in the project directory.",
"inputLabel": "Command Input",
"placeholder": "Enter command to execute...",
"execute": "▶️ Execute",
"runButton": "▶️ Execute",
"clear": "Clear",
"output": "Command Output",
"outputLabel": "Command Output",
"running": "Running...",
"completed": "Completed",
"error": "Execution Error",
"history": "Command History"
},
"command": {
"title": "⚡ Command Execution",
"description": "Execute commands here to verify results or collect more information. Commands will be executed in the project directory.",
"inputLabel": "Command Input",
"placeholder": "Enter command to execute...",
"execute": "▶️ Execute",
"runButton": "▶️ Execute",
"clear": "Clear",
"output": "Command Output",
"outputLabel": "Command Output",
"running": "Running...",
"completed": "Completed",
"error": "Execution Error",
"history": "Command History"
},
"combined": {
"description": "Combined mode: AI summary and feedback input are on the same page for easy comparison.",
"summaryTitle": "📋 AI Work Summary",
"feedbackTitle": "💬 Provide Feedback"
},
"settings": {
"title": "⚙️ Settings",
"description": "Adjust interface settings and preference options.",
"language": "Language",
"currentLanguage": "Current Language",
"languageDesc": "Select interface display language",
"interface": "Interface Settings",
"layoutMode": "Interface Layout Mode",
"layoutModeDesc": "Select how AI summary and feedback input are displayed",
"separateMode": "Separate Mode",
"separateModeDesc": "AI summary and feedback are in separate tabs",
"combinedVertical": "Combined Mode (Vertical Layout)",
"combinedVerticalDesc": "AI summary on top, feedback input below, both on the same page",
"combinedHorizontal": "Combined Mode (Horizontal Layout)",
"combinedHorizontalDesc": "AI summary on left, feedback input on right, expanding summary viewing area",
"autoClose": "Auto Close Page",
"autoCloseDesc": "Automatically close page after submitting feedback",
"theme": "Theme",
"notifications": "Notifications",
"advanced": "Advanced Settings",
"save": "Save Settings",
"reset": "Reset Settings",
"resetDesc": "Clear all saved settings and restore to default state",
"resetConfirm": "Are you sure you want to reset all settings? This will clear all saved preferences.",
"resetSuccess": "Settings have been reset to default values",
"resetError": "Error occurred while resetting settings",
"timeout": "Connection Timeout (seconds)",
"autorefresh": "Auto Refresh",
"debug": "Debug Mode"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "Dark",
"light": "Light",
"auto": "Auto"
},
"status": {
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected",
"reconnecting": "Reconnecting...",
"error": "Connection Error",
"waiting": {
"title": "Waiting for Feedback",
"message": "Please provide your feedback"
},
"processing": {
"title": "Processing",
"message": "Submitting your feedback..."
},
"submitted": {
"title": "Feedback Submitted",
"message": "Waiting for next MCP call"
}
},
"notifications": {
"feedback_sent": "Feedback sent",
"command_executed": "Command executed",
"settings_saved": "Settings saved",
"connection_lost": "Connection lost",
"connection_restored": "Connection restored"
},
"connection": {
"waiting": "Connected - Waiting for feedback",
"submitted": "Connected - Feedback submitted",
"processing": "Connected - Processing"
},
"errors": {
"connection_failed": "Connection failed",
"upload_failed": "Upload failed",
"command_failed": "Command execution failed",
"invalid_input": "Invalid input",
"timeout": "Request timeout"
},
"buttons": {
"ok": "OK",
"cancel": "❌ Cancel",
"submit": "✅ Submit Feedback",
"processing": "Processing...",
"submitted": "Submitted",
"retry": "Retry",
"close": "Close",
"upload": "Upload",
"download": "Download"
},
"session": {
"timeout": "⏰ Session has timed out, interface will close automatically",
"timeoutWarning": "Session is about to timeout",
"timeoutDescription": "Due to prolonged inactivity, the session has timed out. The interface will automatically close in 3 seconds.",
"closing": "Closing..."
},
"dynamic": {
"aiSummary": "Test Web UI Functionality\n\n🎯 **Test Items:**\n- Web UI server startup and operation\n- WebSocket real-time communication\n- Feedback submission functionality\n- Image upload and preview\n- Command execution functionality\n- Smart Ctrl+V image pasting\n- Multi-language interface functionality\n\n📋 **Test Steps:**\n1. Test image upload (drag-drop, file selection, clipboard)\n2. Press Ctrl+V in text box to test smart pasting\n3. Try switching languages (Traditional Chinese/Simplified Chinese/English)\n4. Test command execution functionality\n5. Submit feedback and images\n\nPlease test these features and provide feedback!",
"terminalWelcome": "Welcome to Interactive Feedback Terminal\n========================================\nProject Directory: {sessionId}\nEnter commands and press Enter or click Execute button\nSupported commands: ls, dir, pwd, cat, type, etc.\n\n$ "
},
"about": {
"title": " About",
"description": "A powerful MCP server that provides human-in-the-loop interactive feedback functionality for AI-assisted development tools. Supports both Qt GUI and Web UI interfaces, with rich features including image upload, command execution, multi-language support, and more.",
"appInfo": "Application Information",
"version": "Version",
"projectLinks": "Project Links",
"githubProject": "GitHub Project",
"visitGithub": "Visit GitHub",
"contact": "Contact & Support",
"discordSupport": "Discord Support",
"joinDiscord": "Join Discord",
"contactDescription": "For technical support, issue reports, or feature suggestions, please contact us through Discord community or GitHub Issues.",
"thanks": "Thanks & Contributions",
"thanksText": "Thanks to the original author Fábio Ferreira (@fabiomlferreira) for creating the original interactive-feedback-mcp project.\n\nThis enhanced version is developed and maintained by Minidoracat, greatly expanding the project functionality with GUI interface, image support, multi-language capabilities, and many other improvements.\n\nSpecial thanks to sanshao85's mcp-feedback-collector project for UI design inspiration.\n\nOpen source collaboration makes technology better!"
},
"images": {
"settings": {
"title": "Image Settings",
"sizeLimit": "Image Size Limit",
"sizeLimitOptions": {
"unlimited": "Unlimited",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 Compatibility Mode",
"base64DetailHelp": "When enabled, includes full Base64 image data in text, improving compatibility with certain AI models",
"base64Warning": "⚠️ Increases transmission size",
"compatibilityHint": "💡 Images not recognized correctly?",
"enableBase64Hint": "Try enabling Base64 compatibility mode"
},
"sizeLimitExceeded": "Image {filename} size is {size}, exceeds {limit} limit!",
"sizeLimitExceededAdvice": "Consider compressing the image with editing software before uploading, or adjust the image size limit settings."
}
}

View File

@@ -0,0 +1,214 @@
{
"app": {
"title": "MCP 交互反馈系统",
"subtitle": "AI 助手交互反馈平台",
"projectDirectory": "项目目录"
},
"tabs": {
"feedback": "💬 反馈",
"summary": "📋 AI 总结",
"commands": "⚡ 命令",
"command": "⚡ 命令",
"settings": "⚙️ 设置",
"combined": "📝 合并模式",
"about": " 关于"
},
"feedback": {
"title": "💬 提供反馈",
"description": "请提供您对 AI 工作成果的反馈意见。您可以输入文字反馈并上传相关图片。",
"textLabel": "文字反馈",
"placeholder": "请在这里输入您的反馈...",
"detailedPlaceholder": "请在这里输入您的反馈...\n\n💡 小提示:\n• 按 Ctrl+Enter/Cmd+Enter (支持数字键盘) 可快速提交\n• 按 Ctrl+V/Cmd+V 可直接粘贴剪贴板图片",
"imageLabel": "图片附件(可选)",
"imageUploadText": "📎 点击选择图片或拖放图片到此处\n支持 PNG、JPG、JPEG、GIF、BMP、WebP 等格式",
"submit": "✅ 提交反馈",
"uploading": "上传中...",
"dragdrop": "拖放图片到这里或点击上传",
"selectfiles": "选择文件",
"processing": "处理中...",
"success": "反馈已成功提交!",
"error": "提交反馈时发生错误",
"shortcuts": {
"submit": "Ctrl+Enter 提交 (Mac 用 Cmd+Enter支持数字键盘)",
"clear": "Ctrl+Delete 清除 (Mac 用 Cmd+Delete)",
"paste": "Ctrl+V 粘贴图片 (Mac 用 Cmd+V)"
}
},
"summary": {
"title": "📋 AI 工作摘要",
"description": "以下是 AI 助手完成的工作摘要,请仔细查看并提供您的反馈意见。",
"placeholder": "AI 工作摘要将在这里显示...",
"empty": "目前没有摘要内容",
"lastupdate": "最后更新",
"refresh": "刷新"
},
"commands": {
"title": "⚡ 命令执行",
"description": "在此执行命令来验证结果或收集更多信息。命令将在项目目录中执行。",
"inputLabel": "命令输入",
"placeholder": "输入要执行的命令...",
"execute": "▶️ 执行",
"runButton": "▶️ 执行",
"clear": "清除",
"output": "命令输出",
"outputLabel": "命令输出",
"running": "执行中...",
"completed": "执行完成",
"error": "执行错误",
"history": "命令历史"
},
"command": {
"title": "⚡ 命令执行",
"description": "在此执行命令来验证结果或收集更多信息。命令将在项目目录中执行。",
"inputLabel": "命令输入",
"placeholder": "输入要执行的命令...",
"execute": "▶️ 执行",
"runButton": "▶️ 执行",
"clear": "清除",
"output": "命令输出",
"outputLabel": "命令输出",
"running": "执行中...",
"completed": "执行完成",
"error": "执行错误",
"history": "命令历史"
},
"combined": {
"description": "合并模式AI 摘要和反馈输入在同一页面中,方便对照查看。",
"summaryTitle": "📋 AI 工作摘要",
"feedbackTitle": "💬 提供反馈"
},
"settings": {
"title": "⚙️ 设定",
"description": "调整界面设定和偏好选项。",
"language": "语言",
"currentLanguage": "当前语言",
"languageDesc": "选择界面显示语言",
"interface": "界面设定",
"layoutMode": "界面布局模式",
"layoutModeDesc": "选择 AI 摘要和反馈输入的显示方式",
"separateMode": "分离模式",
"separateModeDesc": "AI 摘要和反馈分别在不同页签",
"combinedVertical": "合并模式(垂直布局)",
"combinedVerticalDesc": "AI 摘要在上,反馈输入在下,摘要和反馈在同一页面",
"combinedHorizontal": "合并模式(水平布局)",
"combinedHorizontalDesc": "AI 摘要在左,反馈输入在右,增大摘要可视区域",
"autoClose": "自动关闭页面",
"autoCloseDesc": "提交回馈后自动关闭页面",
"theme": "主题",
"notifications": "通知",
"advanced": "进阶设定",
"save": "储存设定",
"reset": "重置设定",
"resetDesc": "清除所有已保存的设定,恢复到预设状态",
"resetConfirm": "确定要重置所有设定吗?这将清除所有已保存的偏好设定。",
"resetSuccess": "设定已重置为预设值",
"resetError": "重置设定时发生错误",
"timeout": "连线逾时 (秒)",
"autorefresh": "自动重新整理",
"debug": "除错模式"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "深色",
"light": "浅色",
"auto": "自动"
},
"status": {
"connected": "已连接",
"connecting": "连接中...",
"disconnected": "已断开连接",
"reconnecting": "重新连接中...",
"error": "连接错误",
"waiting": {
"title": "等待反馈",
"message": "请提供您的反馈意见"
},
"processing": {
"title": "处理中",
"message": "正在提交您的反馈..."
},
"submitted": {
"title": "反馈已提交",
"message": "等待下次 MCP 调用"
}
},
"notifications": {
"feedback_sent": "反馈已发送",
"command_executed": "命令已执行",
"settings_saved": "设置已保存",
"connection_lost": "连接中断",
"connection_restored": "连接已恢复"
},
"connection": {
"waiting": "已连接 - 等待反馈",
"submitted": "已连接 - 反馈已提交",
"processing": "已连接 - 处理中"
},
"errors": {
"connection_failed": "连接失败",
"upload_failed": "上传失败",
"command_failed": "命令执行失败",
"invalid_input": "输入内容无效",
"timeout": "请求超时"
},
"buttons": {
"ok": "确定",
"cancel": "❌ 取消",
"submit": "✅ 提交反馈",
"processing": "处理中...",
"submitted": "已提交",
"retry": "重试",
"close": "关闭",
"upload": "上传",
"download": "下载"
},
"session": {
"timeout": "⏰ 会话已超时,界面将自动关闭",
"timeoutWarning": "会话即将超时",
"timeoutDescription": "由于长时间无响应,会话已超时。界面将在 3 秒后自动关闭。",
"closing": "正在关闭..."
},
"dynamic": {
"aiSummary": "测试 Web UI 功能\n\n🎯 **功能测试项目:**\n- Web UI 服务器启动和运行\n- WebSocket 实时通讯\n- 反馈提交功能\n- 图片上传和预览\n- 命令执行功能\n- 智能 Ctrl+V 图片粘贴\n- 多语言界面功能\n\n📋 **测试步骤:**\n1. 测试图片上传(拖拽、选择文件、剪贴板)\n2. 在文本框内按 Ctrl+V 测试智能粘贴\n3. 尝试切换语言(繁中/简中/英文)\n4. 测试命令执行功能\n5. 提交反馈和图片\n\n请测试这些功能并提供反馈",
"terminalWelcome": "欢迎使用交互反馈终端\n========================================\n项目目录: {sessionId}\n输入命令后按 Enter 或点击执行按钮\n支持的命令: ls, dir, pwd, cat, type 等\n\n$ "
},
"about": {
"title": " 关于",
"description": "一个强大的 MCP 服务器,为 AI 辅助开发工具提供人在回路的交互反馈功能。支持 Qt GUI 和 Web UI 双界面,并具备图片上传、命令执行、多语言等丰富功能。",
"appInfo": "应用程序信息",
"version": "版本",
"projectLinks": "项目链接",
"githubProject": "GitHub 项目",
"visitGithub": "访问 GitHub",
"contact": "联系与支持",
"discordSupport": "Discord 支持",
"joinDiscord": "加入 Discord",
"contactDescription": "如需技术支持、问题反馈或功能建议,欢迎通过 Discord 社群或 GitHub Issues 与我们联系。",
"thanks": "致谢与贡献",
"thanksText": "感谢原作者 Fábio Ferreira (@fabiomlferreira) 创建了原始的 interactive-feedback-mcp 项目。\n\n本增强版本由 Minidoracat 开发和维护,大幅扩展了项目功能,新增了 GUI 界面、图片支持、多语言能力以及许多其他改进功能。\n\n同时感谢 sanshao85 的 mcp-feedback-collector 项目提供的 UI 设计灵感。\n\n开源协作让技术变得更美好"
},
"images": {
"settings": {
"title": "图片设置",
"sizeLimit": "图片大小限制",
"sizeLimitOptions": {
"unlimited": "无限制",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 兼容模式",
"base64DetailHelp": "启用后会在文本中包含完整的 Base64 图片数据,提升与某些 AI 模型的兼容性",
"base64Warning": "⚠️ 会增加传输量",
"compatibilityHint": "💡 图片无法正确识别?",
"enableBase64Hint": "尝试启用 Base64 兼容模式"
},
"sizeLimitExceeded": "图片 {filename} 大小为 {size},超过 {limit} 限制!",
"sizeLimitExceededAdvice": "建议使用图片编辑软件压缩后再上传,或调整图片大小限制设置。"
}
}

View File

@@ -0,0 +1,214 @@
{
"app": {
"title": "MCP 互動回饋系統",
"subtitle": "AI 助手互動回饋平台",
"projectDirectory": "專案目錄"
},
"tabs": {
"feedback": "💬 回饋",
"summary": "📋 AI 摘要",
"commands": "⚡ 命令",
"command": "⚡ 命令",
"settings": "⚙️ 設定",
"combined": "📝 合併模式",
"about": " 關於"
},
"feedback": {
"title": "💬 提供回饋",
"description": "請提供您對 AI 工作成果的回饋意見。您可以輸入文字回饋並上傳相關圖片。",
"textLabel": "文字回饋",
"placeholder": "請在這裡輸入您的回饋...",
"detailedPlaceholder": "請在這裡輸入您的回饋...\n\n💡 小提示:\n• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交\n• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片",
"imageLabel": "圖片附件(可選)",
"imageUploadText": "📎 點擊選擇圖片或拖放圖片到此處\n支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式",
"submit": "✅ 提交回饋",
"uploading": "上傳中...",
"dragdrop": "拖放圖片到這裡或點擊上傳",
"selectfiles": "選擇檔案",
"processing": "處理中...",
"success": "回饋已成功提交!",
"error": "提交回饋時發生錯誤",
"shortcuts": {
"submit": "Ctrl+Enter 提交 (Mac 用 Cmd+Enter支援數字鍵盤)",
"clear": "Ctrl+Delete 清除 (Mac 用 Cmd+Delete)",
"paste": "Ctrl+V 貼上圖片 (Mac 用 Cmd+V)"
}
},
"summary": {
"title": "📋 AI 工作摘要",
"description": "以下是 AI 助手完成的工作摘要,請仔細查看並提供您的回饋意見。",
"placeholder": "AI 工作摘要將在這裡顯示...",
"empty": "目前沒有摘要內容",
"lastupdate": "最後更新",
"refresh": "重新整理"
},
"commands": {
"title": "⚡ 命令執行",
"description": "在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。",
"inputLabel": "命令輸入",
"placeholder": "輸入要執行的命令...",
"execute": "▶️ 執行",
"runButton": "▶️ 執行",
"clear": "清除",
"output": "命令輸出",
"outputLabel": "命令輸出",
"running": "執行中...",
"completed": "執行完成",
"error": "執行錯誤",
"history": "命令歷史"
},
"command": {
"title": "⚡ 命令執行",
"description": "在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。",
"inputLabel": "命令輸入",
"placeholder": "輸入要執行的命令...",
"execute": "▶️ 執行",
"runButton": "▶️ 執行",
"clear": "清除",
"output": "命令輸出",
"outputLabel": "命令輸出",
"running": "執行中...",
"completed": "執行完成",
"error": "執行錯誤",
"history": "命令歷史"
},
"combined": {
"description": "合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。",
"summaryTitle": "📋 AI 工作摘要",
"feedbackTitle": "💬 提供回饋"
},
"settings": {
"title": "⚙️ 設定",
"description": "調整介面設定和偏好選項。",
"language": "語言",
"currentLanguage": "當前語言",
"languageDesc": "選擇界面顯示語言",
"interface": "介面設定",
"layoutMode": "界面佈局模式",
"layoutModeDesc": "選擇 AI 摘要和回饋輸入的顯示方式",
"separateMode": "分離模式",
"separateModeDesc": "AI 摘要和回饋分別在不同頁籤",
"combinedVertical": "合併模式(垂直布局)",
"combinedVerticalDesc": "AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面",
"combinedHorizontal": "合併模式(水平布局)",
"combinedHorizontalDesc": "AI 摘要在左,回饋輸入在右,增大摘要可視區域",
"autoClose": "自動關閉頁面",
"autoCloseDesc": "提交回饋後自動關閉頁面",
"theme": "主題",
"notifications": "通知",
"advanced": "進階設定",
"save": "儲存設定",
"reset": "重置設定",
"resetDesc": "清除所有已保存的設定,恢復到預設狀態",
"resetConfirm": "確定要重置所有設定嗎?這將清除所有已保存的偏好設定。",
"resetSuccess": "設定已重置為預設值",
"resetError": "重置設定時發生錯誤",
"timeout": "連線逾時 (秒)",
"autorefresh": "自動重新整理",
"debug": "除錯模式"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "深色",
"light": "淺色",
"auto": "自動"
},
"status": {
"connected": "已連線",
"connecting": "連線中...",
"disconnected": "已中斷連線",
"reconnecting": "重新連線中...",
"error": "連線錯誤",
"waiting": {
"title": "等待回饋",
"message": "請提供您的回饋意見"
},
"processing": {
"title": "處理中",
"message": "正在提交您的回饋..."
},
"submitted": {
"title": "回饋已提交",
"message": "等待下次 MCP 調用"
}
},
"notifications": {
"feedback_sent": "回饋已發送",
"command_executed": "指令已執行",
"settings_saved": "設定已儲存",
"connection_lost": "連線中斷",
"connection_restored": "連線已恢復"
},
"connection": {
"waiting": "已連線 - 等待回饋",
"submitted": "已連線 - 反饋已提交",
"processing": "已連線 - 處理中"
},
"errors": {
"connection_failed": "連線失敗",
"upload_failed": "上傳失敗",
"command_failed": "指令執行失敗",
"invalid_input": "輸入內容無效",
"timeout": "請求逾時"
},
"buttons": {
"ok": "確定",
"cancel": "❌ 取消",
"submit": "✅ 提交回饋",
"processing": "處理中...",
"submitted": "已提交",
"retry": "重試",
"close": "關閉",
"upload": "上傳",
"download": "下載"
},
"session": {
"timeout": "⏰ 會話已超時,介面將自動關閉",
"timeoutWarning": "會話即將超時",
"timeoutDescription": "由於長時間無回應,會話已超時。介面將在 3 秒後自動關閉。",
"closing": "正在關閉..."
},
"dynamic": {
"aiSummary": "測試 Web UI 功能\n\n🎯 **功能測試項目:**\n- Web UI 服務器啟動和運行\n- WebSocket 即時通訊\n- 回饋提交功能\n- 圖片上傳和預覽\n- 命令執行功能\n- 智能 Ctrl+V 圖片貼上\n- 多語言介面功能\n\n📋 **測試步驟:**\n1. 測試圖片上傳(拖拽、選擇檔案、剪貼簿)\n2. 在文字框內按 Ctrl+V 測試智能貼上\n3. 嘗試切換語言(繁中/簡中/英文)\n4. 測試命令執行功能\n5. 提交回饋和圖片\n\n請測試這些功能並提供回饋",
"terminalWelcome": "歡迎使用互動回饋終端\n========================================\n專案目錄: {sessionId}\n輸入命令後按 Enter 或點擊執行按鈕\n支援的命令: ls, dir, pwd, cat, type 等\n\n$ "
},
"about": {
"title": " 關於",
"description": "一個強大的 MCP 伺服器,為 AI 輔助開發工具提供人在回路的互動回饋功能。支援 Qt GUI 和 Web UI 雙介面,並具備圖片上傳、命令執行、多語言等豐富功能。",
"appInfo": "應用程式資訊",
"version": "版本",
"projectLinks": "專案連結",
"githubProject": "GitHub 專案",
"visitGithub": "訪問 GitHub",
"contact": "聯繫與支援",
"discordSupport": "Discord 支援",
"joinDiscord": "加入 Discord",
"contactDescription": "如需技術支援、問題回報或功能建議,歡迎透過 Discord 社群或 GitHub Issues 與我們聯繫。",
"thanks": "致謝與貢獻",
"thanksText": "感謝原作者 Fábio Ferreira (@fabiomlferreira) 創建了原始的 interactive-feedback-mcp 專案。\n\n本增強版本由 Minidoracat 開發和維護,大幅擴展了專案功能,新增了 GUI 介面、圖片支援、多語言能力以及許多其他改進功能。\n\n同時感謝 sanshao85 的 mcp-feedback-collector 專案提供的 UI 設計靈感。\n\n開源協作讓技術變得更美好"
},
"images": {
"settings": {
"title": "圖片設定",
"sizeLimit": "圖片大小限制",
"sizeLimitOptions": {
"unlimited": "無限制",
"1mb": "1MB",
"3mb": "3MB",
"5mb": "5MB"
},
"base64Detail": "Base64 相容模式",
"base64DetailHelp": "啟用後會在文字中包含完整的 Base64 圖片資料,提升與某些 AI 模型的相容性",
"base64Warning": "⚠️ 會增加傳輸量",
"compatibilityHint": "💡 圖片無法正確識別?",
"enableBase64Hint": "嘗試啟用 Base64 相容模式"
},
"sizeLimitExceeded": "圖片 {filename} 大小為 {size},超過 {limit} 限制!",
"sizeLimitExceededAdvice": "建議使用圖片編輯軟體壓縮後再上傳,或調整圖片大小限制設定。"
}
}

View File

@@ -0,0 +1,500 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 主要管理器
================
基於 FastAPI 的 Web 用戶介面主要管理類,參考 GUI 的設計模式重構。
專為 SSH 遠端開發環境設計,支援現代化界面和多語言。
"""
import asyncio
import json
import logging
import os
import socket
import threading
import time
import webbrowser
from pathlib import Path
from typing import Dict, Optional
import uuid
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import uvicorn
from .models import WebFeedbackSession, FeedbackResult
from .routes import setup_routes
from .utils import find_free_port, get_browser_opener
from ..debug import web_debug_log as debug_log
from ..i18n import get_i18n_manager
class WebUIManager:
"""Web UI 管理器 - 重構為單一活躍會話模式"""
def __init__(self, host: str = "127.0.0.1", port: int = None):
self.host = host
# 優先使用固定端口 8765確保 localStorage 的一致性
self.port = port or find_free_port(preferred_port=8765)
self.app = FastAPI(title="MCP Feedback Enhanced")
# 重構:使用單一活躍會話而非會話字典
self.current_session: Optional[WebFeedbackSession] = None
self.sessions: Dict[str, WebFeedbackSession] = {} # 保留用於向後兼容
# 全局標籤頁狀態管理 - 跨會話保持
self.global_active_tabs: Dict[str, dict] = {}
# 會話更新通知標記
self._pending_session_update = False
self.server_thread = None
self.server_process = None
self.i18n = get_i18n_manager()
# 設置靜態文件和模板
self._setup_static_files()
self._setup_templates()
# 設置路由
setup_routes(self)
debug_log(f"WebUIManager 初始化完成,將在 {self.host}:{self.port} 啟動")
def _setup_static_files(self):
"""設置靜態文件服務"""
# Web UI 靜態文件
web_static_path = Path(__file__).parent / "static"
if web_static_path.exists():
self.app.mount("/static", StaticFiles(directory=str(web_static_path)), name="static")
else:
raise RuntimeError(f"Static files directory not found: {web_static_path}")
def _setup_templates(self):
"""設置模板引擎"""
# Web UI 模板
web_templates_path = Path(__file__).parent / "templates"
if web_templates_path.exists():
self.templates = Jinja2Templates(directory=str(web_templates_path))
else:
raise RuntimeError(f"Templates directory not found: {web_templates_path}")
def create_session(self, project_directory: str, summary: str) -> str:
"""創建新的回饋會話 - 重構為單一活躍會話模式,保留標籤頁狀態"""
# 保存舊會話的 WebSocket 連接以便發送更新通知
old_websocket = None
if self.current_session and self.current_session.websocket:
old_websocket = self.current_session.websocket
debug_log("保存舊會話的 WebSocket 連接以發送更新通知")
# 如果已有活躍會話,先保存其標籤頁狀態到全局狀態
if self.current_session:
debug_log("保存現有會話的標籤頁狀態並清理會話")
# 保存標籤頁狀態到全局
if hasattr(self.current_session, 'active_tabs'):
self._merge_tabs_to_global(self.current_session.active_tabs)
# 同步清理會話資源(但保留 WebSocket 連接)
self.current_session._cleanup_sync()
session_id = str(uuid.uuid4())
session = WebFeedbackSession(session_id, project_directory, summary)
# 將全局標籤頁狀態繼承到新會話
session.active_tabs = self.global_active_tabs.copy()
# 設置為當前活躍會話
self.current_session = session
# 同時保存到字典中以保持向後兼容
self.sessions[session_id] = session
debug_log(f"創建新的活躍會話: {session_id}")
debug_log(f"繼承 {len(session.active_tabs)} 個活躍標籤頁")
# 如果有舊的 WebSocket 連接,立即發送會話更新通知
if old_websocket:
self._old_websocket_for_update = old_websocket
self._new_session_for_update = session
debug_log("已保存舊 WebSocket 連接,準備發送會話更新通知")
else:
# 標記需要發送會話更新通知(當新 WebSocket 連接建立時)
self._pending_session_update = True
return session_id
def get_session(self, session_id: str) -> Optional[WebFeedbackSession]:
"""獲取回饋會話 - 保持向後兼容"""
return self.sessions.get(session_id)
def get_current_session(self) -> Optional[WebFeedbackSession]:
"""獲取當前活躍會話"""
return self.current_session
def remove_session(self, session_id: str):
"""移除回饋會話"""
if session_id in self.sessions:
session = self.sessions[session_id]
session.cleanup()
del self.sessions[session_id]
# 如果移除的是當前活躍會話,清空當前會話
if self.current_session and self.current_session.session_id == session_id:
self.current_session = None
debug_log("清空當前活躍會話")
debug_log(f"移除回饋會話: {session_id}")
def clear_current_session(self):
"""清空當前活躍會話"""
if self.current_session:
session_id = self.current_session.session_id
self.current_session.cleanup()
self.current_session = None
# 同時從字典中移除
if session_id in self.sessions:
del self.sessions[session_id]
debug_log("已清空當前活躍會話")
def _merge_tabs_to_global(self, session_tabs: dict):
"""將會話的標籤頁狀態合併到全局狀態"""
current_time = time.time()
expired_threshold = 60 # 60秒過期閾值
# 清理過期的全局標籤頁
self.global_active_tabs = {
tab_id: tab_info
for tab_id, tab_info in self.global_active_tabs.items()
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
}
# 合併會話標籤頁到全局
for tab_id, tab_info in session_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
self.global_active_tabs[tab_id] = tab_info
debug_log(f"合併標籤頁狀態,全局活躍標籤頁數量: {len(self.global_active_tabs)}")
def get_global_active_tabs_count(self) -> int:
"""獲取全局活躍標籤頁數量"""
current_time = time.time()
expired_threshold = 60
# 清理過期標籤頁並返回數量
valid_tabs = {
tab_id: tab_info
for tab_id, tab_info in self.global_active_tabs.items()
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
}
self.global_active_tabs = valid_tabs
return len(valid_tabs)
async def broadcast_to_active_tabs(self, message: dict):
"""向所有活躍標籤頁廣播消息"""
if not self.current_session or not self.current_session.websocket:
debug_log("沒有活躍的 WebSocket 連接,無法廣播消息")
return
try:
await self.current_session.websocket.send_json(message)
debug_log(f"已廣播消息到活躍標籤頁: {message.get('type', 'unknown')}")
except Exception as e:
debug_log(f"廣播消息失敗: {e}")
def start_server(self):
"""啟動 Web 伺服器"""
def run_server_with_retry():
max_retries = 5
retry_count = 0
while retry_count < max_retries:
try:
debug_log(f"嘗試啟動伺服器在 {self.host}:{self.port} (嘗試 {retry_count + 1}/{max_retries})")
config = uvicorn.Config(
app=self.app,
host=self.host,
port=self.port,
log_level="warning",
access_log=False
)
server = uvicorn.Server(config)
asyncio.run(server.serve())
break
except OSError as e:
if e.errno == 10048: # Windows: 位址已在使用中
retry_count += 1
if retry_count < max_retries:
debug_log(f"端口 {self.port} 被占用,嘗試下一個端口")
self.port = find_free_port(self.port + 1)
else:
debug_log("已達到最大重試次數,無法啟動伺服器")
break
else:
debug_log(f"伺服器啟動錯誤: {e}")
break
except Exception as e:
debug_log(f"伺服器運行錯誤: {e}")
break
# 在新線程中啟動伺服器
self.server_thread = threading.Thread(target=run_server_with_retry, daemon=True)
self.server_thread.start()
# 等待伺服器啟動
time.sleep(2)
def open_browser(self, url: str):
"""開啟瀏覽器"""
try:
browser_opener = get_browser_opener()
browser_opener(url)
debug_log(f"已開啟瀏覽器:{url}")
except Exception as e:
debug_log(f"無法開啟瀏覽器: {e}")
async def smart_open_browser(self, url: str) -> bool:
"""智能開啟瀏覽器 - 檢測是否已有活躍標籤頁
Returns:
bool: True 表示檢測到活躍標籤頁False 表示開啟了新視窗
"""
import asyncio
import aiohttp
try:
# 檢查是否有活躍標籤頁
has_active_tabs = await self._check_active_tabs()
if has_active_tabs:
debug_log("檢測到活躍標籤頁,不開啟新瀏覽器視窗")
debug_log(f"用戶可以在現有標籤頁中查看更新:{url}")
return True
# 沒有活躍標籤頁,開啟新瀏覽器視窗
debug_log("沒有檢測到活躍標籤頁,開啟新瀏覽器視窗")
self.open_browser(url)
return False
except Exception as e:
debug_log(f"智能瀏覽器開啟失敗,回退到普通開啟:{e}")
self.open_browser(url)
return False
async def notify_session_update(self, session):
"""向活躍標籤頁發送會話更新通知"""
try:
# 向所有活躍的 WebSocket 連接發送會話更新通知
await self.broadcast_to_active_tabs({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id
}
})
debug_log("會話更新通知已發送到所有活躍標籤頁")
except Exception as e:
debug_log(f"發送會話更新通知失敗: {e}")
async def _send_immediate_session_update(self):
"""立即發送會話更新通知(使用舊的 WebSocket 連接)"""
try:
# 檢查是否有保存的舊 WebSocket 連接
if hasattr(self, '_old_websocket_for_update') and hasattr(self, '_new_session_for_update'):
old_websocket = self._old_websocket_for_update
new_session = self._new_session_for_update
# 發送會話更新通知
await old_websocket.send_json({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": new_session.project_directory,
"summary": new_session.summary,
"session_id": new_session.session_id
}
})
debug_log("已通過舊 WebSocket 連接發送會話更新通知")
# 清理臨時變數
delattr(self, '_old_websocket_for_update')
delattr(self, '_new_session_for_update')
# 延遲一小段時間讓前端處理消息,然後關閉舊連接
await asyncio.sleep(0.1)
try:
await old_websocket.close()
debug_log("已關閉舊 WebSocket 連接")
except Exception as e:
debug_log(f"關閉舊 WebSocket 連接失敗: {e}")
else:
# 沒有舊連接,設置待更新標記
self._pending_session_update = True
debug_log("沒有舊 WebSocket 連接,設置待更新標記")
except Exception as e:
debug_log(f"立即發送會話更新通知失敗: {e}")
# 回退到待更新標記
self._pending_session_update = True
async def _check_active_tabs(self) -> bool:
"""檢查是否有活躍標籤頁 - 優先檢查全局狀態,回退到 API"""
try:
# 首先檢查全局標籤頁狀態
global_count = self.get_global_active_tabs_count()
if global_count > 0:
debug_log(f"檢測到 {global_count} 個全局活躍標籤頁")
return True
# 如果全局狀態沒有活躍標籤頁,嘗試通過 API 檢查
# 等待一小段時間讓服務器完全啟動
await asyncio.sleep(0.5)
# 調用活躍標籤頁 API
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.get_server_url()}/api/active-tabs", timeout=2) as response:
if response.status == 200:
data = await response.json()
tab_count = data.get("count", 0)
debug_log(f"API 檢測到 {tab_count} 個活躍標籤頁")
return tab_count > 0
else:
debug_log(f"檢查活躍標籤頁失敗,狀態碼:{response.status}")
return False
except asyncio.TimeoutError:
debug_log("檢查活躍標籤頁超時")
return False
except Exception as e:
debug_log(f"檢查活躍標籤頁時發生錯誤:{e}")
return False
def get_server_url(self) -> str:
"""獲取伺服器 URL"""
return f"http://{self.host}:{self.port}"
def stop(self):
"""停止 Web UI 服務"""
# 清理所有會話
for session in list(self.sessions.values()):
session.cleanup()
self.sessions.clear()
# 停止伺服器注意uvicorn 的 graceful shutdown 需要額外處理)
if self.server_thread and self.server_thread.is_alive():
debug_log("正在停止 Web UI 服務")
# 全域實例
_web_ui_manager: Optional[WebUIManager] = None
def get_web_ui_manager() -> WebUIManager:
"""獲取 Web UI 管理器實例"""
global _web_ui_manager
if _web_ui_manager is None:
_web_ui_manager = WebUIManager()
return _web_ui_manager
async def launch_web_feedback_ui(project_directory: str, summary: str, timeout: int = 600) -> dict:
"""
啟動 Web 回饋介面並等待用戶回饋 - 重構為使用根路徑
Args:
project_directory: 專案目錄路徑
summary: AI 工作摘要
timeout: 超時時間(秒)
Returns:
dict: 回饋結果,包含 logs、interactive_feedback 和 images
"""
manager = get_web_ui_manager()
# 創建或更新當前活躍會話
session_id = manager.create_session(project_directory, summary)
session = manager.get_current_session()
if not session:
raise RuntimeError("無法創建回饋會話")
# 啟動伺服器(如果尚未啟動)
if not manager.server_thread or not manager.server_thread.is_alive():
manager.start_server()
# 使用根路徑 URL 並智能開啟瀏覽器
feedback_url = manager.get_server_url() # 直接使用根路徑
has_active_tabs = await manager.smart_open_browser(feedback_url)
debug_log(f"[DEBUG] 服務器地址: {feedback_url}")
# 如果檢測到活躍標籤頁但沒有開啟新視窗,立即發送會話更新通知
if has_active_tabs:
await manager._send_immediate_session_update()
debug_log("已向活躍標籤頁發送會話更新通知")
try:
# 等待用戶回饋,傳遞 timeout 參數
result = await session.wait_for_feedback(timeout)
debug_log(f"收到用戶回饋")
return result
except TimeoutError:
debug_log(f"會話超時")
# 資源已在 wait_for_feedback 中清理,這裡只需要記錄和重新拋出
raise
except Exception as e:
debug_log(f"會話發生錯誤: {e}")
raise
finally:
# 注意:不再自動清理會話和停止服務器,保持持久性
# 會話將保持活躍狀態,等待下次 MCP 調用
debug_log("會話保持活躍狀態,等待下次 MCP 調用")
def stop_web_ui():
"""停止 Web UI 服務"""
global _web_ui_manager
if _web_ui_manager:
_web_ui_manager.stop()
_web_ui_manager = None
debug_log("Web UI 服務已停止")
# 測試用主函數
if __name__ == "__main__":
async def main():
try:
project_dir = os.getcwd()
summary = "這是一個測試摘要,用於驗證 Web UI 功能。"
from ..debug import debug_log
debug_log(f"啟動 Web UI 測試...")
debug_log(f"專案目錄: {project_dir}")
debug_log("等待用戶回饋...")
result = await launch_web_feedback_ui(project_dir, summary)
debug_log("收到回饋結果:")
debug_log(f"命令日誌: {result.get('logs', '')}")
debug_log(f"互動回饋: {result.get('interactive_feedback', '')}")
debug_log(f"圖片數量: {len(result.get('images', []))}")
except KeyboardInterrupt:
debug_log("\n用戶取消操作")
except Exception as e:
debug_log(f"錯誤: {e}")
finally:
stop_web_ui()
asyncio.run(main())

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 資料模型模組
==================
定義 Web UI 相關的資料結構和型別。
"""
from .feedback_session import WebFeedbackSession
from .feedback_result import FeedbackResult
__all__ = [
'WebFeedbackSession',
'FeedbackResult'
]

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 回饋結果資料模型
======================
定義回饋收集的資料結構,與 GUI 版本保持一致。
"""
from typing import TypedDict, List
class FeedbackResult(TypedDict):
"""回饋結果的型別定義"""
command_logs: str
interactive_feedback: str
images: List[dict]

View File

@@ -0,0 +1,404 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web 回饋會話模型
===============
管理 Web 回饋會話的資料和邏輯。
"""
import asyncio
import base64
import subprocess
import threading
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import WebSocket
from ...debug import web_debug_log as debug_log
class SessionStatus(Enum):
"""會話狀態枚舉"""
WAITING = "waiting" # 等待中
ACTIVE = "active" # 活躍中
FEEDBACK_SUBMITTED = "feedback_submitted" # 已提交反饋
COMPLETED = "completed" # 已完成
TIMEOUT = "timeout" # 超時
ERROR = "error" # 錯誤
# 常數定義
MAX_IMAGE_SIZE = 1 * 1024 * 1024 # 1MB 圖片大小限制
SUPPORTED_IMAGE_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/bmp', 'image/webp'}
TEMP_DIR = Path.home() / ".cache" / "interactive-feedback-mcp-web"
class WebFeedbackSession:
"""Web 回饋會話管理"""
def __init__(self, session_id: str, project_directory: str, summary: str):
self.session_id = session_id
self.project_directory = project_directory
self.summary = summary
self.websocket: Optional[WebSocket] = None
self.feedback_result: Optional[str] = None
self.images: List[dict] = []
self.settings: dict = {} # 圖片設定
self.feedback_completed = threading.Event()
self.process: Optional[subprocess.Popen] = None
self.command_logs = []
self._cleanup_done = False # 防止重複清理
# 新增:會話狀態管理
self.status = SessionStatus.WAITING
self.status_message = "等待用戶回饋"
self.created_at = asyncio.get_event_loop().time()
self.last_activity = self.created_at
# 確保臨時目錄存在
TEMP_DIR.mkdir(parents=True, exist_ok=True)
def update_status(self, status: SessionStatus, message: str = None):
"""更新會話狀態"""
self.status = status
if message:
self.status_message = message
self.last_activity = asyncio.get_event_loop().time()
debug_log(f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}")
def get_status_info(self) -> dict:
"""獲取會話狀態信息"""
return {
"status": self.status.value,
"message": self.status_message,
"feedback_completed": self.feedback_completed.is_set(),
"has_websocket": self.websocket is not None,
"created_at": self.created_at,
"last_activity": self.last_activity,
"project_directory": self.project_directory,
"summary": self.summary,
"session_id": self.session_id
}
def is_active(self) -> bool:
"""檢查會話是否活躍"""
return self.status in [SessionStatus.WAITING, SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]
async def wait_for_feedback(self, timeout: int = 600) -> dict:
"""
等待用戶回饋,包含圖片,支援超時自動清理
Args:
timeout: 超時時間(秒)
Returns:
dict: 回饋結果
"""
try:
# 使用比 MCP 超時稍短的時間(提前處理,避免邊界競爭)
# 對於短超時(<30秒提前1秒對於長超時提前5秒
if timeout <= 30:
actual_timeout = max(timeout - 1, 5) # 短超時提前1秒最少5秒
else:
actual_timeout = timeout - 5 # 長超時提前5秒
debug_log(f"會話 {self.session_id} 開始等待回饋,超時時間: {actual_timeout} 秒(原始: {timeout} 秒)")
loop = asyncio.get_event_loop()
def wait_in_thread():
return self.feedback_completed.wait(actual_timeout)
completed = await loop.run_in_executor(None, wait_in_thread)
if completed:
debug_log(f"會話 {self.session_id} 收到用戶回饋")
return {
"logs": "\n".join(self.command_logs),
"interactive_feedback": self.feedback_result or "",
"images": self.images,
"settings": self.settings
}
else:
# 超時了,立即清理資源
debug_log(f"會話 {self.session_id}{actual_timeout} 秒後超時,開始清理資源...")
await self._cleanup_resources_on_timeout()
raise TimeoutError(f"等待用戶回饋超時({actual_timeout}秒),介面已自動關閉")
except Exception as e:
# 任何異常都要確保清理資源
debug_log(f"會話 {self.session_id} 發生異常: {e}")
await self._cleanup_resources_on_timeout()
raise
async def submit_feedback(self, feedback: str, images: List[dict], settings: dict = None):
"""
提交回饋和圖片
Args:
feedback: 文字回饋
images: 圖片列表
settings: 圖片設定(可選)
"""
self.feedback_result = feedback
# 先設置設定,再處理圖片(因為處理圖片時需要用到設定)
self.settings = settings or {}
self.images = self._process_images(images)
# 更新狀態為已提交反饋
self.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已送出反饋,等待下次 MCP 調用")
self.feedback_completed.set()
# 發送反饋已收到的消息給前端
if self.websocket:
try:
await self.websocket.send_json({
"type": "feedback_received",
"message": "反饋已成功提交",
"status": self.status.value
})
except Exception as e:
debug_log(f"發送反饋確認失敗: {e}")
# 重構:不再自動關閉 WebSocket保持連接以支援頁面持久性
def _process_images(self, images: List[dict]) -> List[dict]:
"""
處理圖片數據,轉換為統一格式
Args:
images: 原始圖片數據列表
Returns:
List[dict]: 處理後的圖片數據
"""
processed_images = []
# 從設定中獲取圖片大小限制,如果沒有設定則使用預設值
size_limit = self.settings.get('image_size_limit', MAX_IMAGE_SIZE)
for img in images:
try:
if not all(key in img for key in ["name", "data", "size"]):
continue
# 檢查文件大小只有當限制大於0時才檢查
if size_limit > 0 and img["size"] > size_limit:
debug_log(f"圖片 {img['name']} 超過大小限制 ({size_limit} bytes),跳過")
continue
# 解碼 base64 數據
if isinstance(img["data"], str):
try:
image_bytes = base64.b64decode(img["data"])
except Exception as e:
debug_log(f"圖片 {img['name']} base64 解碼失敗: {e}")
continue
else:
image_bytes = img["data"]
if len(image_bytes) == 0:
debug_log(f"圖片 {img['name']} 數據為空,跳過")
continue
processed_images.append({
"name": img["name"],
"data": image_bytes, # 保存原始 bytes 數據
"size": len(image_bytes)
})
debug_log(f"圖片 {img['name']} 處理成功,大小: {len(image_bytes)} bytes")
except Exception as e:
debug_log(f"圖片處理錯誤: {e}")
continue
return processed_images
def add_log(self, log_entry: str):
"""添加命令日誌"""
self.command_logs.append(log_entry)
async def run_command(self, command: str):
"""執行命令並透過 WebSocket 發送輸出"""
if self.process:
# 終止現有進程
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None
try:
debug_log(f"執行命令: {command}")
self.process = subprocess.Popen(
command,
shell=True,
cwd=self.project_directory,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# 在背景線程中讀取輸出
async def read_output():
loop = asyncio.get_event_loop()
try:
# 使用線程池執行器來處理阻塞的讀取操作
def read_line():
if self.process and self.process.stdout:
return self.process.stdout.readline()
return ''
while True:
line = await loop.run_in_executor(None, read_line)
if not line:
break
self.add_log(line.rstrip())
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_output",
"output": line
})
except Exception as e:
debug_log(f"WebSocket 發送失敗: {e}")
break
except Exception as e:
debug_log(f"讀取命令輸出錯誤: {e}")
finally:
# 等待進程完成
if self.process:
exit_code = self.process.wait()
# 發送命令完成信號
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_complete",
"exit_code": exit_code
})
except Exception as e:
debug_log(f"發送完成信號失敗: {e}")
# 啟動異步任務讀取輸出
asyncio.create_task(read_output())
except Exception as e:
debug_log(f"執行命令錯誤: {e}")
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_error",
"error": str(e)
})
except:
pass
async def _cleanup_resources_on_timeout(self):
"""超時時清理所有資源"""
if self._cleanup_done:
return # 避免重複清理
self._cleanup_done = True
debug_log(f"開始清理會話 {self.session_id} 的資源...")
try:
# 1. 關閉 WebSocket 連接
if self.websocket:
try:
# 先通知前端超時
await self.websocket.send_json({
"type": "session_timeout",
"message": "會話已超時,介面將自動關閉"
})
await asyncio.sleep(0.1) # 給前端一點時間處理消息
await self.websocket.close()
debug_log(f"會話 {self.session_id} WebSocket 已關閉")
except Exception as e:
debug_log(f"關閉 WebSocket 時發生錯誤: {e}")
finally:
self.websocket = None
# 2. 終止正在運行的命令進程
if self.process:
try:
self.process.terminate()
try:
self.process.wait(timeout=3)
debug_log(f"會話 {self.session_id} 命令進程已正常終止")
except subprocess.TimeoutExpired:
self.process.kill()
debug_log(f"會話 {self.session_id} 命令進程已強制終止")
except Exception as e:
debug_log(f"終止命令進程時發生錯誤: {e}")
finally:
self.process = None
# 3. 設置完成事件(防止其他地方還在等待)
self.feedback_completed.set()
# 4. 清理臨時數據
self.command_logs.clear()
self.images.clear()
debug_log(f"會話 {self.session_id} 資源清理完成")
except Exception as e:
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤: {e}")
def _cleanup_sync(self):
"""同步清理會話資源(但保留 WebSocket 連接)"""
if self._cleanup_done:
return
debug_log(f"同步清理會話 {self.session_id} 資源(保留 WebSocket...")
# 只清理進程,不清理 WebSocket 連接
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None
# 清理臨時數據
self.command_logs.clear()
# 注意:不設置 _cleanup_done = True因為還需要清理 WebSocket
def cleanup(self):
"""同步清理會話資源(保持向後兼容)"""
if self._cleanup_done:
return
self._cleanup_done = True
debug_log(f"同步清理會話 {self.session_id} 資源...")
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None
# 設置完成事件
self.feedback_completed.set()

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 路由模組
==============
提供 Web UI 的路由設置和處理。
"""
from .main_routes import setup_routes
__all__ = ['setup_routes']

View File

@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
主要路由處理
============
設置 Web UI 的主要路由和處理邏輯。
"""
import json
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from ...debug import web_debug_log as debug_log
from ... import __version__
if TYPE_CHECKING:
from ..main import WebUIManager
def setup_routes(manager: 'WebUIManager'):
"""設置路由"""
@manager.app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""統一回饋頁面 - 重構後的主頁面"""
# 獲取當前活躍會話
current_session = manager.get_current_session()
if not current_session:
# 沒有活躍會話時顯示等待頁面
return manager.templates.TemplateResponse("index.html", {
"request": request,
"title": "MCP Feedback Enhanced",
"has_session": False,
"version": __version__
})
# 有活躍會話時顯示回饋頁面
return manager.templates.TemplateResponse("feedback.html", {
"request": request,
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"title": "Interactive Feedback - 回饋收集",
"version": __version__,
"has_session": True
})
@manager.app.get("/api/translations")
async def get_translations():
"""獲取翻譯數據 - 從 Web 專用翻譯檔案載入"""
translations = {}
# 獲取 Web 翻譯檔案目錄
web_locales_dir = Path(__file__).parent.parent / "locales"
supported_languages = ["zh-TW", "zh-CN", "en"]
for lang_code in supported_languages:
lang_dir = web_locales_dir / lang_code
translation_file = lang_dir / "translation.json"
try:
if translation_file.exists():
with open(translation_file, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
translations[lang_code] = lang_data
debug_log(f"成功載入 Web 翻譯: {lang_code}")
else:
debug_log(f"Web 翻譯檔案不存在: {translation_file}")
translations[lang_code] = {}
except Exception as e:
debug_log(f"載入 Web 翻譯檔案失敗 {lang_code}: {e}")
translations[lang_code] = {}
debug_log(f"Web 翻譯 API 返回 {len(translations)} 種語言的數據")
return JSONResponse(content=translations)
@manager.app.get("/api/session-status")
async def get_session_status():
"""獲取當前會話狀態"""
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(content={
"has_session": False,
"status": "no_session",
"message": "沒有活躍會話"
})
return JSONResponse(content={
"has_session": True,
"status": "active",
"session_info": {
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set()
}
})
@manager.app.get("/api/current-session")
async def get_current_session():
"""獲取當前會話詳細信息"""
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(
status_code=404,
content={"error": "沒有活躍會話"}
)
return JSONResponse(content={
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set(),
"command_logs": current_session.command_logs,
"images_count": len(current_session.images)
})
@manager.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket 端點 - 重構後移除 session_id 依賴"""
# 獲取當前活躍會話
session = manager.get_current_session()
if not session:
await websocket.close(code=4004, reason="沒有活躍會話")
return
await websocket.accept()
session.websocket = websocket
debug_log(f"WebSocket 連接建立: 當前活躍會話")
# 發送連接成功消息
try:
await websocket.send_json({
"type": "connection_established",
"message": "WebSocket 連接已建立"
})
# 檢查是否有待發送的會話更新
if getattr(manager, '_pending_session_update', False):
await websocket.send_json({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id
}
})
manager._pending_session_update = False
debug_log("已發送會話更新通知到前端")
except Exception as e:
debug_log(f"發送連接確認失敗: {e}")
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
await handle_websocket_message(manager, session, message)
except WebSocketDisconnect:
debug_log(f"WebSocket 連接斷開")
except Exception as e:
debug_log(f"WebSocket 錯誤: {e}")
finally:
session.websocket = None
@manager.app.post("/api/save-settings")
async def save_settings(request: Request):
"""保存設定到檔案"""
try:
data = await request.json()
# 使用與 GUI 版本相同的設定檔案路徑
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
config_dir.mkdir(parents=True, exist_ok=True)
settings_file = config_dir / "ui_settings.json"
# 保存設定到檔案
with open(settings_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
debug_log(f"設定已保存到: {settings_file}")
return JSONResponse(content={"status": "success", "message": "設定已保存"})
except Exception as e:
debug_log(f"保存設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"保存失敗: {str(e)}"}
)
@manager.app.get("/api/load-settings")
async def load_settings():
"""從檔案載入設定"""
try:
# 使用與 GUI 版本相同的設定檔案路徑
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
settings_file = config_dir / "ui_settings.json"
if settings_file.exists():
with open(settings_file, 'r', encoding='utf-8') as f:
settings = json.load(f)
debug_log(f"設定已從檔案載入: {settings_file}")
return JSONResponse(content=settings)
else:
debug_log("設定檔案不存在,返回空設定")
return JSONResponse(content={})
except Exception as e:
debug_log(f"載入設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"載入失敗: {str(e)}"}
)
@manager.app.post("/api/clear-settings")
async def clear_settings():
"""清除設定檔案"""
try:
# 使用與 GUI 版本相同的設定檔案路徑
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
settings_file = config_dir / "ui_settings.json"
if settings_file.exists():
settings_file.unlink()
debug_log(f"設定檔案已刪除: {settings_file}")
else:
debug_log("設定檔案不存在,無需刪除")
return JSONResponse(content={"status": "success", "message": "設定已清除"})
except Exception as e:
debug_log(f"清除設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"清除失敗: {str(e)}"}
)
@manager.app.get("/api/active-tabs")
async def get_active_tabs():
"""獲取活躍標籤頁信息 - 優先使用全局狀態"""
current_time = time.time()
expired_threshold = 60
# 清理過期的全局標籤頁
valid_global_tabs = {}
for tab_id, tab_info in manager.global_active_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
valid_global_tabs[tab_id] = tab_info
manager.global_active_tabs = valid_global_tabs
# 如果有當前會話,也更新會話的標籤頁狀態
current_session = manager.get_current_session()
if current_session:
# 合併會話標籤頁到全局(如果有的話)
session_tabs = getattr(current_session, 'active_tabs', {})
for tab_id, tab_info in session_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
valid_global_tabs[tab_id] = tab_info
# 更新會話的活躍標籤頁
current_session.active_tabs = valid_global_tabs.copy()
manager.global_active_tabs = valid_global_tabs
return JSONResponse(content={
"has_session": current_session is not None,
"active_tabs": valid_global_tabs,
"count": len(valid_global_tabs)
})
@manager.app.post("/api/register-tab")
async def register_tab(request: Request):
"""註冊新標籤頁"""
try:
data = await request.json()
tab_id = data.get("tabId")
if not tab_id:
return JSONResponse(
status_code=400,
content={"error": "缺少 tabId"}
)
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(
status_code=404,
content={"error": "沒有活躍會話"}
)
# 註冊標籤頁
tab_info = {
'timestamp': time.time() * 1000, # 毫秒時間戳
'last_seen': time.time(),
'registered_at': time.time()
}
if not hasattr(current_session, 'active_tabs'):
current_session.active_tabs = {}
current_session.active_tabs[tab_id] = tab_info
# 同時更新全局標籤頁狀態
manager.global_active_tabs[tab_id] = tab_info
debug_log(f"標籤頁已註冊: {tab_id}")
return JSONResponse(content={
"status": "success",
"tabId": tab_id,
"registered": True
})
except Exception as e:
debug_log(f"註冊標籤頁失敗: {e}")
return JSONResponse(
status_code=500,
content={"error": f"註冊失敗: {str(e)}"}
)
async def handle_websocket_message(manager: 'WebUIManager', session, data: dict):
"""處理 WebSocket 消息"""
message_type = data.get("type")
if message_type == "submit_feedback":
# 提交回饋
feedback = data.get("feedback", "")
images = data.get("images", [])
settings = data.get("settings", {})
await session.submit_feedback(feedback, images, settings)
elif message_type == "run_command":
# 執行命令
command = data.get("command", "")
if command.strip():
await session.run_command(command)
elif message_type == "get_status":
# 獲取會話狀態
if session.websocket:
try:
await session.websocket.send_json({
"type": "status_update",
"status_info": session.get_status_info()
})
except Exception as e:
debug_log(f"發送狀態更新失敗: {e}")
elif message_type == "heartbeat":
# WebSocket 心跳處理
tab_id = data.get("tabId", "unknown")
timestamp = data.get("timestamp", 0)
tab_info = {
'timestamp': timestamp,
'last_seen': time.time()
}
# 更新會話的標籤頁信息
if hasattr(session, 'active_tabs'):
session.active_tabs[tab_id] = tab_info
else:
session.active_tabs = {tab_id: tab_info}
# 同時更新全局標籤頁狀態
manager.global_active_tabs[tab_id] = tab_info
# 發送心跳回應
if session.websocket:
try:
await session.websocket.send_json({
"type": "heartbeat_response",
"tabId": tab_id,
"timestamp": timestamp
})
except Exception as e:
debug_log(f"發送心跳回應失敗: {e}")
elif message_type == "user_timeout":
# 用戶設置的超時已到
debug_log(f"收到用戶超時通知: {session.session_id}")
# 清理會話資源
await session._cleanup_resources_on_timeout()
# 重構:不再自動停止服務器,保持服務器運行以支援持久性
else:
debug_log(f"未知的消息類型: {message_type}")
async def _delayed_server_stop(manager: 'WebUIManager'):
"""延遲停止服務器"""
import asyncio
await asyncio.sleep(5) # 等待 5 秒讓前端有時間關閉
from ..main import stop_web_ui
stop_web_ui()
debug_log("Web UI 服務器已因用戶超時而停止")

View File

@@ -0,0 +1,937 @@
/**
* Web UI 樣式
* ===========
*
* 補充樣式和動畫效果
*/
/* 連接狀態指示器 */
.connection-indicator {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
.connection-indicator.connected {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
border: 1px solid #4caf50;
}
.connection-indicator.disconnected {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
border: 1px solid #f44336;
}
/* 載入動畫 */
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #464647;
border-radius: 50%;
border-top-color: #007acc;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 淡入動畫 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 滑入動畫 */
.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 脈衝動畫 */
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 工具提示 */
.tooltip {
position: relative;
cursor: help;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--border-color);
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 1000;
}
.tooltip:hover::after {
opacity: 1;
}
/* 滾動條美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #606060;
}
/* 選擇文字顏色 */
::selection {
background: rgba(0, 122, 204, 0.3);
color: var(--text-primary);
}
/* 無障礙改進 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 焦點可見性 */
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
/* 禁用狀態 */
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 響應式圖片 */
img {
max-width: 100%;
height: auto;
}
/* 表格樣式 */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--bg-tertiary);
font-weight: 600;
color: var(--text-primary);
}
tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* 代碼區塊 */
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
pre {
background: var(--bg-tertiary);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
border: 1px solid var(--border-color);
}
pre code {
background: none;
padding: 0;
}
/* 警告和提示框 */
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
border-left: 4px solid;
}
.alert-info {
background: rgba(33, 150, 243, 0.1);
border-left-color: var(--info-color);
color: #bbdefb;
}
.alert-success {
background: rgba(76, 175, 80, 0.1);
border-left-color: var(--success-color);
color: #c8e6c9;
}
.alert-warning {
background: rgba(255, 152, 0, 0.1);
border-left-color: var(--warning-color);
color: #ffe0b2;
}
.alert-error {
background: rgba(244, 67, 54, 0.1);
border-left-color: var(--error-color);
color: #ffcdd2;
}
/* 進度條 */
.progress {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent-color);
transition: width 0.3s ease;
}
/* 分隔線 */
.divider {
height: 1px;
background: var(--border-color);
margin: 20px 0;
}
/* 徽章 */
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 12px;
background: var(--accent-color);
color: white;
}
.badge-secondary {
background: var(--text-secondary);
}
.badge-success {
background: var(--success-color);
}
.badge-warning {
background: var(--warning-color);
}
.badge-error {
background: var(--error-color);
}
/* 卡片 */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card-header {
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.card-body {
color: var(--text-secondary);
}
/* 統計數字 */
.stat {
text-align: center;
padding: 20px;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--accent-color);
display: block;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9em;
margin-top: 4px;
}
/* ===== Feedback.html 專用樣式 ===== */
/* CSS 變數定義 */
:root {
/* 深色主題顏色變數 */
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d30;
--bg-tertiary: #252526;
--surface-color: #333333;
--text-primary: #cccccc;
--text-secondary: #9e9e9e;
--accent-color: #007acc;
--accent-hover: #005a9e;
--border-color: #464647;
--success-color: #4caf50;
--warning-color: #ff9800;
--error-color: #f44336;
--info-color: #2196f3;
}
/* 基礎重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 主體樣式 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 容器樣式 */
.container {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 20px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 頭部樣式 */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 15px 0;
margin-bottom: 20px;
border-radius: 8px 8px 0 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.title {
font-size: 24px;
font-weight: bold;
color: var(--accent-color);
margin: 0;
}
.project-info {
color: var(--text-secondary);
font-size: 14px;
}
/* 倒數計時器樣式 */
.countdown-display {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 4px 8px;
}
.countdown-label {
color: var(--text-secondary);
font-size: 11px;
}
.countdown-timer {
color: var(--warning-color);
font-size: 13px;
font-weight: bold;
font-family: 'Consolas', 'Monaco', monospace;
min-width: 45px;
text-align: center;
}
.countdown-timer.warning {
color: var(--warning-color);
}
.countdown-timer.danger {
color: var(--error-color);
}
/* 語言選擇器 */
.language-selector {
display: flex;
align-items: center;
gap: 10px;
}
.language-selector select {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
}
/* 設定項目樣式 */
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info {
flex: 1;
}
.setting-label {
font-weight: 500;
color: var(--text-primary);
font-size: 16px;
margin-bottom: 4px;
}
.setting-description {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.4;
}
/* 設定卡片樣式 */
.settings-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.settings-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.settings-card-header {
background: var(--bg-secondary);
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.settings-card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.settings-card-body {
padding: 20px;
}
.settings-card .setting-item {
padding: 16px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.settings-card .setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.settings-card .setting-label {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.settings-card .setting-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
/* 佈局模式選擇器 */
.layout-mode-selector {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.layout-option {
display: flex;
align-items: flex-start;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
transition: all 0.3s ease;
cursor: pointer;
}
.layout-option:hover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.1);
}
.layout-option input[type="radio"] {
margin: 0;
margin-right: 12px;
margin-top: 2px;
accent-color: var(--accent-color);
transform: scale(1.2);
}
.layout-option label {
flex: 1;
cursor: pointer;
}
.layout-option-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.layout-option-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.layout-option input[type="radio"]:checked + label {
color: var(--accent-color);
}
.layout-option input[type="radio"]:checked + label .layout-option-title {
color: var(--accent-color);
}
.layout-option:has(input[type="radio"]:checked) {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.15);
}
/* 語言選擇器現代化樣式 */
.language-selector-modern {
display: flex;
align-items: center;
gap: 10px;
}
.language-options {
display: flex;
gap: 12px;
}
.language-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 16px;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
}
.language-option:hover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.1);
}
.language-option.active {
border-color: var(--accent-color);
background: var(--accent-color);
color: white;
}
.language-flag {
font-size: 24px;
margin-bottom: 4px;
}
.language-name {
font-size: 12px;
font-weight: 500;
text-align: center;
}
.language-option.active .language-name {
color: white;
}
/* 表單元素樣式 */
.input-group {
margin-bottom: 20px;
width: 100%;
}
.input-label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-primary);
}
.text-input,
.command-input {
width: 100%;
max-width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
resize: vertical;
min-height: 220px;
font-family: inherit;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.text-input:focus,
.command-input:focus {
outline: none;
border-color: var(--accent-color);
}
.command-input {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
min-height: 80px;
}
/* 新增:單行命令輸入框樣式 */
.command-input-line {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
border: 1px solid var(--border-color);
transition: border-color 0.3s ease;
}
.command-input-line:focus {
outline: none;
border-color: var(--accent-color);
}
/* 命令輸出區域 */
.command-output {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
color: var(--text-primary);
white-space: pre-wrap;
overflow-y: auto;
height: 320px;
width: 100%;
box-sizing: border-box;
/* 添加 terminal 風格 */
background: #0f0f0f;
border: 2px solid var(--border-color);
color: #00ff00;
text-shadow: 0 0 5px #00ff00;
/* 確保尺寸固定 */
flex-shrink: 0;
resize: none;
}
/* Terminal 提示符樣式 */
.terminal-prompt {
color: var(--accent-color);
font-weight: bold;
}
/* 按鈕樣式 */
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--surface-color);
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #45a049;
}
/* 底部操作按鈕 */
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 0 0 8px 8px;
margin-top: auto;
}
/* 主內容區域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
/* 分頁樣式 */
.tabs {
border-bottom: 2px solid var(--border-color);
margin-bottom: 20px;
}
.tab-buttons {
display: flex;
gap: 2px;
}
.tab-button {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 12px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
border-radius: 4px 4px 0 0;
}
.tab-button.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
background: var(--bg-tertiary);
}
.tab-button:hover:not(.active) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.tab-button.hidden {
display: none;
}
/* 分頁內容 */
.tab-content {
display: none;
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
overflow-y: auto;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: 600px;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* 分割器樣式(用於合併模式) */
.splitter-container {
display: flex;
flex-direction: column;
flex: 1;
gap: 8px;
}
.splitter-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 16px;
transition: all 0.3s ease;
}
.splitter-handle {
height: 8px;
background: var(--border-color);
border-radius: 4px;
cursor: row-resize;
transition: background 0.3s ease;
margin: 4px 0;
}
.splitter-handle:hover {
background: var(--accent-color);
}
/* 響應式設計 */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header-content {
flex-direction: column;
gap: 10px;
}
.tab-buttons {
flex-wrap: wrap;
}
/* 小屏幕下調整命令輸出區域高度 */
.command-output {
height: 250px;
}
}
/* 更小屏幕的調整 */
@media (max-width: 480px) {
.command-output {
height: 200px;
font-size: 12px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,229 @@
/**
* 國際化i18n模組
* =================
*
* 處理多語言支援和界面文字翻譯
* 從後端 /api/translations 載入翻譯數據
*/
class I18nManager {
constructor() {
this.currentLanguage = 'zh-TW';
this.translations = {};
this.loadingPromise = null;
}
async init() {
// 從 localStorage 載入語言偏好
const savedLanguage = localStorage.getItem('language');
if (savedLanguage) {
this.currentLanguage = savedLanguage;
}
// 載入翻譯數據
await this.loadTranslations();
// 應用翻譯
this.applyTranslations();
// 設置語言選擇器
this.setupLanguageSelectors();
// 延遲一點再更新動態內容,確保應用程式已初始化
setTimeout(() => {
this.updateDynamicContent();
}, 100);
}
async loadTranslations() {
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadingPromise = fetch('/api/translations')
.then(response => response.json())
.then(data => {
this.translations = data;
console.log('翻譯數據載入完成:', Object.keys(this.translations));
// 檢查當前語言是否有翻譯數據
if (!this.translations[this.currentLanguage] || Object.keys(this.translations[this.currentLanguage]).length === 0) {
console.warn(`當前語言 ${this.currentLanguage} 沒有翻譯數據,回退到 zh-TW`);
this.currentLanguage = 'zh-TW';
}
})
.catch(error => {
console.error('載入翻譯數據失敗:', error);
// 使用最小的回退翻譯
this.translations = this.getMinimalFallbackTranslations();
});
return this.loadingPromise;
}
getMinimalFallbackTranslations() {
// 最小的回退翻譯,只包含關鍵項目
return {
'zh-TW': {
'app': {
'title': 'MCP Feedback Enhanced',
'projectDirectory': '專案目錄'
},
'tabs': {
'feedback': '💬 回饋',
'summary': '📋 AI 摘要',
'command': '⚡ 命令',
'settings': '⚙️ 設定'
},
'buttons': {
'cancel': '❌ 取消',
'submit': '✅ 提交回饋'
},
'settings': {
'language': '語言'
}
}
};
}
// 支援巢狀鍵值的翻譯函數,支援參數替換
t(key, params = {}) {
const langData = this.translations[this.currentLanguage] || {};
let translation = this.getNestedValue(langData, key);
// 如果沒有找到翻譯,返回預設值或鍵名
if (!translation) {
return typeof params === 'string' ? params : key;
}
// 如果 params 是字串,當作預設值處理(向後相容)
if (typeof params === 'string') {
return translation;
}
// 參數替換:將 {key} 替換為對應的值
if (typeof params === 'object' && params !== null) {
Object.keys(params).forEach(paramKey => {
const placeholder = `{${paramKey}}`;
translation = translation.replace(new RegExp(placeholder, 'g'), params[paramKey]);
});
}
return translation;
}
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null;
}, obj);
}
setLanguage(language) {
if (this.translations[language]) {
this.currentLanguage = language;
localStorage.setItem('language', language);
this.applyTranslations();
// 更新語言選擇器(只更新設定頁面的)
const selector = document.getElementById('settingsLanguageSelect');
if (selector) {
selector.value = language;
}
// 更新 HTML lang 屬性
document.documentElement.lang = language;
console.log('語言已切換到:', language);
} else {
console.warn('不支援的語言:', language);
}
}
applyTranslations() {
// 翻譯所有有 data-i18n 屬性的元素
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
});
// 翻譯有 data-i18n-placeholder 屬性的元素
const placeholderElements = document.querySelectorAll('[data-i18n-placeholder]');
placeholderElements.forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
const translation = this.t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
});
// 更新動態內容
this.updateDynamicContent();
console.log('翻譯已應用:', this.currentLanguage);
}
updateDynamicContent() {
// 只更新終端歡迎信息,不要覆蓋 AI 摘要
this.updateTerminalWelcome();
// 更新應用程式中的動態狀態文字
if (window.feedbackApp) {
window.feedbackApp.updateUIState();
window.feedbackApp.updateStatusIndicator();
}
}
updateTerminalWelcome() {
const commandOutput = document.getElementById('commandOutput');
if (commandOutput && window.feedbackApp) {
const welcomeTemplate = this.t('dynamic.terminalWelcome');
if (welcomeTemplate && welcomeTemplate !== 'dynamic.terminalWelcome') {
const welcomeMessage = welcomeTemplate.replace('{sessionId}', window.feedbackApp.sessionId || 'unknown');
commandOutput.textContent = welcomeMessage;
}
}
}
setupLanguageSelectors() {
// 舊版下拉選擇器(兼容性保留)
const selector = document.getElementById('settingsLanguageSelect');
if (selector) {
// 設置當前值
selector.value = this.currentLanguage;
// 添加事件監聽器
selector.addEventListener('change', (e) => {
this.setLanguage(e.target.value);
});
}
// 新版現代化語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
if (languageOptions.length > 0) {
// 設置當前語言的活躍狀態
languageOptions.forEach(option => {
const lang = option.getAttribute('data-lang');
if (lang === this.currentLanguage) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
}
getCurrentLanguage() {
return this.currentLanguage;
}
getAvailableLanguages() {
return Object.keys(this.translations);
}
}
// 創建全域實例
window.i18nManager = new I18nManager();

View File

@@ -0,0 +1,70 @@
{#
圖片上傳組件
============
參數:
- id_prefix: ID 前綴,用於區分不同實例 (例如: "feedback", "combined")
- label_text: 標籤文字 (預設: "圖片附件(可選)")
- upload_text: 上傳提示文字
- min_height: 最小高度 (預設: "120px")
使用方式:
{% include 'components/image-upload.html' with context %}
{% include 'components/image-upload.html' with id_prefix="feedback" %}
#}
{% set id_prefix = id_prefix or "default" %}
{% set label_text = label_text or "圖片附件(可選)" %}
{% set min_height = min_height or "120px" %}
{% set upload_text = upload_text or "📎 點擊選擇圖片或拖放圖片到此處<br><small>支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式</small>" %}
<!-- 圖片設定區域 -->
<div class="input-group" style="margin-bottom: 12px;">
<details class="image-settings-details">
<summary class="image-settings-summary" data-i18n="images.settings.title">⚙️ 圖片設定</summary>
<div class="image-settings-content">
<div class="image-setting-row">
<label class="image-setting-label" data-i18n="images.settings.sizeLimit">圖片大小限制:</label>
<select id="{{ id_prefix }}ImageSizeLimit" class="image-setting-select">
<option value="0" data-i18n="images.settings.sizeLimitOptions.unlimited">無限制</option>
<option value="1048576" data-i18n="images.settings.sizeLimitOptions.1mb">1MB</option>
<option value="3145728" data-i18n="images.settings.sizeLimitOptions.3mb">3MB</option>
<option value="5242880" data-i18n="images.settings.sizeLimitOptions.5mb">5MB</option>
</select>
</div>
<div class="image-setting-row">
<label class="image-setting-checkbox-container">
<input type="checkbox" id="{{ id_prefix }}EnableBase64Detail" class="image-setting-checkbox">
<span class="image-setting-checkmark"></span>
<span class="image-setting-label" data-i18n="images.settings.base64Detail">Base64 相容模式</span>
</label>
<small class="image-setting-help" data-i18n="images.settings.base64Warning">⚠️ 會增加傳輸量</small>
</div>
<div class="image-setting-help-text" data-i18n="images.settings.base64DetailHelp">
啟用後會在文字中包含完整的 Base64 圖片資料,提升部分 AI 模型的相容性
</div>
</div>
</details>
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">{{ label_text }}</label>
<!-- 相容性提示區域 -->
<div id="{{ id_prefix }}CompatibilityHint" class="compatibility-hint" style="display: none;">
<span data-i18n="images.settings.compatibilityHint">💡 圖片無法正確識別?</span>
<button type="button" id="{{ id_prefix }}EnableBase64Hint" class="compatibility-hint-btn" data-i18n="images.settings.enableBase64Hint">
嘗試啟用 Base64 相容模式
</button>
</div>
<!-- 圖片上傳區域 -->
<div id="{{ id_prefix }}ImageUploadArea" class="image-upload-area" style="min-height: {{ min_height }};">
<div id="{{ id_prefix }}ImageUploadText" data-i18n="feedback.imageUploadText">
{{ upload_text|safe }}
</div>
<div id="{{ id_prefix }}ImagePreviewContainer" class="image-preview-container"></div>
<input type="file" id="{{ id_prefix }}ImageInput" multiple accept="image/*" style="display: none;">
</div>
</div>

View File

@@ -0,0 +1,58 @@
{#
設定卡片組件
============
參數:
- title: 卡片標題
- icon: 標題圖標 (可選)
- card_id: 卡片 ID (可選)
- content: 卡片內容 (使用 caller() 傳入)
使用方式:
{% call settings_card(title="介面設定", icon="🎨") %}
<!-- 卡片內容 -->
{% endcall %}
#}
{% macro settings_card(title, icon="", card_id="") %}
<div class="settings-card"{% if card_id %} id="{{ card_id }}"{% endif %}>
<div class="settings-card-header">
<h3 class="settings-card-title">
{% if icon %}{{ icon }} {% endif %}{{ title }}
</h3>
</div>
<div class="settings-card-body">
{{ caller() }}
</div>
</div>
{% endmacro %}
{#
設定項目組件
============
參數:
- label: 設定項目標籤
- description: 設定項目描述
- is_last: 是否為最後一個項目 (影響邊框顯示)
- control: 控制元件內容 (使用 caller() 傳入)
使用方式:
{% call setting_item(label="自動關閉頁面", description="提交回饋後自動關閉頁面") %}
<div id="autoCloseToggle" class="toggle-switch active">
<div class="toggle-knob"></div>
</div>
{% endcall %}
#}
{% macro setting_item(label, description="", is_last=false) %}
<div class="setting-item"{% if is_last %} style="border-bottom: none;"{% endif %}>
<div class="setting-info">
<div class="setting-label">{{ label }}</div>
{% if description %}
<div class="setting-description">{{ description }}</div>
{% endif %}
</div>
{{ caller() }}
</div>
{% endmacro %}

View File

@@ -0,0 +1,32 @@
{#
狀態指示器組件
==============
參數:
- id: 指示器 ID
- status: 狀態類型 ("waiting", "processing", "submitted")
- icon: 狀態圖標
- title: 狀態標題
- message: 狀態訊息
- visible: 是否顯示 (預設: false)
使用方式:
{% include 'components/status-indicator.html' with
id="feedbackStatusIndicator",
status="waiting",
icon="⏳",
title="等待您的回饋",
message="請提供您對 AI 工作成果的意見和建議" %}
#}
{% set visible = visible or false %}
{% set status = status or "waiting" %}
{% set icon = icon or "⏳" %}
<div id="{{ id }}" class="feedback-status-indicator status-{{ status }}"{% if not visible %} style="display: none;"{% endif %}>
<div class="status-icon">{{ icon }}</div>
<div class="status-text">
<strong data-i18n="feedback.status.{{ status }}.title">{{ title }}</strong>
<span data-i18n="feedback.status.{{ status }}.message">{{ message }}</span>
</div>
</div>

View File

@@ -0,0 +1,19 @@
{#
切換開關組件
============
參數:
- id: 開關 ID
- active: 是否預設啟用 (預設: false)
- class: 額外的 CSS 類別 (可選)
使用方式:
{% include 'components/toggle-switch.html' with id="autoCloseToggle", active=true %}
#}
{% set active = active or false %}
{% set class = class or "" %}
<div id="{{ id }}" class="toggle-switch{% if active %} active{% endif %}{% if class %} {{ class }}{% endif %}">
<div class="toggle-knob"></div>
</div>

View File

@@ -0,0 +1,726 @@
<!DOCTYPE html>
<html lang="zh-TW" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/css/styles.css">
<style>
/* 僅保留必要的頁面特定樣式和響應式調整 */
/* 響應式調整 */
@media (max-width: 768px) {
.timeout-controls {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.timeout-separator {
display: none;
}
}
/* 頁面特定的佈局模式樣式 */
/* 佈局模式樣式 */
/* 預設分離模式 - 顯示回饋和AI摘要頁籤隱藏合併模式頁籤 */
body.layout-separate .tab-button[data-tab="combined"] {
display: none;
}
body.layout-separate .tab-button[data-tab="feedback"],
body.layout-separate .tab-button[data-tab="summary"] {
display: inline-block;
}
/* 合併模式 - 顯示合併模式頁籤隱藏回饋和AI摘要頁籤 */
body.layout-combined-vertical .tab-button[data-tab="combined"],
body.layout-combined-horizontal .tab-button[data-tab="combined"] {
display: inline-block;
}
body.layout-combined-vertical .tab-button[data-tab="feedback"],
body.layout-combined-vertical .tab-button[data-tab="summary"],
body.layout-combined-horizontal .tab-button[data-tab="feedback"],
body.layout-combined-horizontal .tab-button[data-tab="summary"] {
display: none;
}
/* 響應式設計 */
@media (max-width: 768px) {
.timeout-controls {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.timeout-separator {
display: none;
}
}
/* 合併模式分頁的水平佈局樣式 */
#tab-combined.active.combined-horizontal .combined-content {
display: flex !important;
flex-direction: row !important;
gap: 16px;
height: calc(100% - 60px); /* 減去描述區塊的高度 */
}
#tab-combined.active.combined-horizontal .combined-section:first-child {
flex: 1 !important;
min-width: 300px;
max-width: 50%;
overflow: hidden; /* 確保容器不超出範圍 */
}
#tab-combined.active.combined-horizontal .combined-section:last-child {
flex: 1 !important;
min-width: 400px;
}
#tab-combined.active.combined-horizontal .combined-summary {
height: calc(100vh - 200px);
max-height: 600px;
overflow: hidden; /* 確保摘要容器不超出範圍 */
}
#tab-combined.active.combined-horizontal #combinedSummaryContent {
height: 100%;
min-height: 400px;
overflow-y: auto; /* 添加垂直滾動條 */
overflow-x: hidden; /* 隱藏水平滾動條 */
}
#tab-combined.active.combined-horizontal .text-input {
min-height: 200px;
}
/* 合併模式分頁的垂直佈局樣式 */
#tab-combined.active.combined-vertical .combined-content {
display: flex !important;
flex-direction: column !important;
gap: 16px;
height: calc(100% - 60px); /* 減去描述區塊的高度 */
}
#tab-combined.active.combined-vertical .combined-section:first-child {
flex: 1 !important;
min-height: 200px;
max-height: 400px;
overflow: hidden; /* 確保容器不超出範圍 */
}
#tab-combined.active.combined-vertical .combined-section:last-child {
flex: 2 !important;
min-height: 300px;
}
#tab-combined.active.combined-vertical .combined-summary {
height: 300px;
max-height: 400px;
overflow: hidden; /* 確保摘要容器不超出範圍 */
}
#tab-combined.active.combined-vertical #combinedSummaryContent {
height: 100%;
min-height: 200px;
overflow-y: auto; /* 添加垂直滾動條 */
overflow-x: hidden; /* 隱藏水平滾動條 */
}
#tab-combined.active.combined-vertical .text-input {
min-height: 200px;
}
/* 預設的合併內容布局 */
.combined-content {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
/* 圖片設定樣式 */
.image-settings-details {
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-tertiary);
margin-bottom: 8px;
}
.image-settings-summary {
padding: 8px 12px;
cursor: pointer;
font-weight: 500;
color: var(--text-secondary);
font-size: 13px;
user-select: none;
transition: color 0.3s ease;
}
.image-settings-summary:hover {
color: var(--text-primary);
}
.image-settings-content {
padding: 12px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.image-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 12px;
}
.image-setting-row:last-of-type {
margin-bottom: 8px;
}
.image-setting-label {
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
}
.image-setting-select {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
min-width: 80px;
}
.image-setting-checkbox-container {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
.image-setting-checkbox {
width: 16px;
height: 16px;
accent-color: var(--accent-color);
}
.image-setting-help {
color: var(--warning-color);
font-size: 11px;
margin-left: auto;
}
.image-setting-help-text {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.4;
margin-top: 4px;
padding: 8px;
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border-color);
}
/* 相容性提示樣式 */
.compatibility-hint {
background: rgba(33, 150, 243, 0.1);
border: 1px solid var(--info-color);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--info-color);
}
.compatibility-hint-btn {
background: var(--info-color);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
transition: background 0.3s ease;
}
.compatibility-hint-btn:hover {
background: #1976d2;
}
/* 回饋狀態指示器樣式 */
.feedback-status-indicator {
display: flex;
align-items: center;
padding: 12px 16px;
margin: 16px 0;
border-radius: 8px;
border: 1px solid;
background: var(--card-bg);
transition: all 0.3s ease;
}
.feedback-status-indicator .status-icon {
font-size: 24px;
margin-right: 12px;
min-width: 32px;
text-align: center;
}
.feedback-status-indicator .status-text {
flex: 1;
}
.feedback-status-indicator .status-text strong {
display: block;
font-size: 16px;
margin-bottom: 4px;
}
.feedback-status-indicator .status-text span {
font-size: 14px;
opacity: 0.8;
}
.feedback-status-indicator.status-waiting {
border-color: var(--accent-color);
background: rgba(74, 144, 226, 0.1);
}
.feedback-status-indicator.status-processing {
border-color: #ffa500;
background: rgba(255, 165, 0, 0.1);
animation: pulse 2s infinite;
}
.feedback-status-indicator.status-submitted {
border-color: var(--success-color);
background: rgba(40, 167, 69, 0.1);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
/* 禁用狀態的樣式 */
.image-upload-area.disabled {
opacity: 0.5;
pointer-events: none;
cursor: not-allowed;
}
.text-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
</head>
<body>
<div class="container">
<!-- ===== 頁面頭部區域 ===== -->
<header class="header">
<div class="header-content">
<div class="header-left">
<h1 class="title" data-i18n="app.title">MCP Feedback Enhanced</h1>
<!-- 倒數計時器顯示 -->
<div id="countdownDisplay" class="countdown-display" style="display: none;">
<span class="countdown-label" data-i18n="timeout.remaining">剩餘時間</span>
<span id="countdownTimer" class="countdown-timer">--:--</span>
</div>
</div>
<div class="project-info">
<span data-i18n="app.projectDirectory">專案目錄</span>: {{ project_directory }}
</div>
</div>
</header>
<!-- ===== 主內容區域 ===== -->
<main class="main-content">
<!-- 分頁導航 -->
<div class="tabs">
<div class="tab-buttons">
<!-- 合併模式分頁 - 移到最左邊第一個 -->
<button class="tab-button hidden" data-tab="combined" data-i18n="tabs.combined">
📝 合併模式
</button>
<button class="tab-button active" data-tab="feedback" data-i18n="tabs.feedback">
💬 回饋
</button>
<button class="tab-button" data-tab="summary" data-i18n="tabs.summary">
📋 AI 摘要
</button>
<button class="tab-button" data-tab="command" data-i18n="tabs.command">
⚡ 命令
</button>
<button class="tab-button" data-tab="settings" data-i18n="tabs.settings">
⚙️ 設定
</button>
<button class="tab-button" data-tab="about" data-i18n="tabs.about">
關於
</button>
</div>
</div>
<!-- ===== 回饋分頁 ===== -->
<div id="tab-feedback" class="tab-content active">
<div class="section-description" data-i18n="feedback.description">
請提供您對 AI 工作成果的回饋意見。您可以輸入文字回饋並上傳相關圖片。
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="feedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋...
💡 小提示:
• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交
• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片"
></textarea>
</div>
<!-- 圖片上傳組件 -->
{% set id_prefix = "feedback" %}
{% include 'components/image-upload.html' %}
</div>
<!-- ===== AI 摘要分頁 ===== -->
<div id="tab-summary" class="tab-content">
<div class="section-description" data-i18n="summary.description">
以下是 AI 助手完成的工作摘要,請仔細查看並提供您的回饋意見。
</div>
<div class="input-group">
<div id="summaryContent" class="text-input" style="min-height: 300px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
</div>
</div>
<!-- 命令分頁 -->
<div id="tab-command" class="tab-content">
<div class="section-description" data-i18n="command.description">
在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。
</div>
<!-- 命令輸出區域 - 放在上面 -->
<div class="input-group">
<label class="input-label" data-i18n="command.outputLabel">命令輸出</label>
<div id="commandOutput" class="command-output"></div>
</div>
<!-- 命令輸入區域 - 放在下面 -->
<div class="input-group" style="margin-bottom: 0;">
<label class="input-label" data-i18n="command.inputLabel">命令輸入</label>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
<span style="color: var(--accent-color); font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-weight: bold;">$</span>
<input
type="text"
id="commandInput"
class="command-input-line"
data-i18n-placeholder="command.placeholder"
placeholder="輸入要執行的命令..."
style="flex: 1; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px 12px; color: var(--text-primary); font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px;"
/>
</div>
<button id="runCommandBtn" class="btn btn-primary" data-i18n="command.runButton" style="white-space: nowrap;">
▶️ 執行
</button>
</div>
</div>
</div>
<!-- 合併模式分頁 - 移動到此位置 -->
<div id="tab-combined" class="tab-content">
<div class="section-description" style="margin-bottom: 12px; padding: 8px 12px; font-size: 13px;" data-i18n="combined.description">
合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。
</div>
<div class="combined-content">
<!-- AI 摘要區域 -->
<div class="combined-section">
<h3 class="combined-section-title" data-i18n="combined.summaryTitle">📋 AI 工作摘要</h3>
<div class="combined-summary">
<div id="combinedSummaryContent" class="text-input" style="min-height: 200px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
</div>
</div>
<!-- 回饋輸入區域 -->
<div class="combined-section">
<h3 class="combined-section-title" data-i18n="combined.feedbackTitle">💬 提供回饋</h3>
<!-- 等待回饋狀態指示器 -->
{% set id = "combinedFeedbackStatusIndicator" %}
{% set status = "waiting" %}
{% set icon = "⏳" %}
{% set title = "等待您的回饋" %}
{% set message = "請提供您對 AI 工作成果的意見和建議" %}
{% include 'components/status-indicator.html' %}
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="combinedFeedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋...
💡 小提示:
• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交
• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片"
style="min-height: 150px;"
></textarea>
</div>
<!-- 圖片上傳組件 -->
{% set id_prefix = "combined" %}
{% set min_height = "100px" %}
{% include 'components/image-upload.html' %}
</div>
</div>
</div>
<!-- 設定分頁 -->
<div id="tab-settings" class="tab-content">
<div class="section-description" data-i18n="settings.description">
調整介面設定和偏好選項。
</div>
<!-- 介面設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.interface">🎨 介面設定</h3>
</div>
<div class="settings-card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.layoutMode">界面佈局模式</div>
<div class="setting-description" data-i18n="settings.layoutModeDesc">
選擇 AI 摘要和回饋輸入的顯示方式
</div>
</div>
<div class="layout-mode-selector">
<div class="layout-option">
<input type="radio" id="separateMode" name="layoutMode" value="separate" checked>
<label for="separateMode">
<div class="layout-option-title" data-i18n="settings.separateMode">分離模式</div>
<div class="layout-option-desc" data-i18n="settings.separateModeDesc">AI 摘要和回饋分別在不同頁籤</div>
</label>
</div>
<div class="layout-option">
<input type="radio" id="combinedVertical" name="layoutMode" value="combined-vertical">
<label for="combinedVertical">
<div class="layout-option-title" data-i18n="settings.combinedVertical">合併模式(垂直布局)</div>
<div class="layout-option-desc" data-i18n="settings.combinedVerticalDesc">AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面</div>
</label>
</div>
<div class="layout-option">
<input type="radio" id="combinedHorizontal" name="layoutMode" value="combined-horizontal">
<label for="combinedHorizontal">
<div class="layout-option-title" data-i18n="settings.combinedHorizontal">合併模式(水平布局)</div>
<div class="layout-option-desc" data-i18n="settings.combinedHorizontalDesc">AI 摘要在左,回饋輸入在右,增大摘要可視區域</div>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 語言設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.language">🌐 語言設定</h3>
</div>
<div class="settings-card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.currentLanguage">當前語言</div>
<div class="setting-description" data-i18n="settings.languageDesc">
選擇界面顯示語言
</div>
</div>
<div class="language-selector-modern">
<div class="language-options">
<div class="language-option" data-lang="zh-TW">
<div class="language-flag">🌏</div>
<div class="language-name">繁體中文</div>
</div>
<div class="language-option" data-lang="zh-CN">
<div class="language-flag">🌍</div>
<div class="language-name">简体中文</div>
</div>
<div class="language-option" data-lang="en">
<div class="language-flag">🌎</div>
<div class="language-name">English</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 重置設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.advanced">🔧 進階設定</h3>
</div>
<div class="settings-card-body">
<div class="setting-item" style="border-bottom: none;">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.reset">重置設定</div>
<div class="setting-description" data-i18n="settings.resetDesc">
清除所有已保存的設定,恢復到預設狀態
</div>
</div>
<button id="resetSettingsBtn" class="btn btn-secondary" style="font-size: 12px; padding: 6px 16px;">
<span data-i18n="settings.reset">重置設定</span>
</button>
</div>
</div>
</div>
</div>
<!-- 關於分頁 -->
<div id="tab-about" class="tab-content">
<div class="section-description" data-i18n="about.description">
關於 MCP Feedback Enhanced - 應用程式資訊、專案連結和致謝。
</div>
<!-- 主要資訊卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h3 class="settings-card-title" style="margin: 0;">MCP Feedback Enhanced</h3>
<span style="color: var(--accent-color); font-weight: bold; font-size: 16px;">v{{ version }}</span>
</div>
</div>
<div class="settings-card-body">
<!-- 應用程式描述 -->
<div class="setting-item" style="border-bottom: none; padding-bottom: 16px;">
<div class="setting-info">
<div class="setting-description" data-i18n="about.description" style="color: var(--text-secondary); font-size: 13px; line-height: 1.5;">
一個強大的 MCP 伺服器,為 AI 輔助開發工具提供人在回路的互動回饋功能。支援 Qt GUI 和 Web UI 雙介面,並具備圖片上傳、命令執行、多語言等豐富功能。
</div>
</div>
</div>
<!-- 分隔線 -->
<div style="height: 1px; background: var(--border-color); margin: 16px 0;"></div>
<!-- GitHub 專案 -->
<div class="setting-item" style="border-bottom: none; padding-bottom: 12px;">
<div class="setting-info">
<div class="setting-label">📂 <span data-i18n="about.githubProject">GitHub 專案</span></div>
<div class="setting-description" style="color: var(--text-secondary); font-size: 11px; margin-left: 24px;">
https://github.com/Minidoracat/mcp-feedback-enhanced
</div>
</div>
<button class="btn btn-primary" onclick="window.open('https://github.com/Minidoracat/mcp-feedback-enhanced', '_blank')" style="font-size: 12px; padding: 6px 16px;">
<span data-i18n="about.visitGithub">訪問 GitHub</span>
</button>
</div>
<!-- 分隔線 -->
<div style="height: 1px; background: var(--border-color); margin: 16px 0;"></div>
<!-- Discord 支援 -->
<div class="setting-item" style="border-bottom: none; padding-bottom: 12px;">
<div class="setting-info">
<div class="setting-label">💬 <span data-i18n="about.discordSupport">Discord 支援</span></div>
<div class="setting-description" style="color: var(--text-secondary); font-size: 11px; margin-left: 24px;">
https://discord.gg/ACjf9Q58
</div>
<div class="setting-description" data-i18n="about.contactDescription" style="color: var(--text-secondary); font-size: 12px; margin-left: 24px; margin-top: 8px;">
如需技術支援、問題回報或功能建議,歡迎透過 Discord 社群或 GitHub Issues 與我們聯繫。
</div>
</div>
<button class="btn" onclick="window.open('https://discord.gg/ACjf9Q58', '_blank')" style="background: #5865F2; color: white; font-size: 12px; padding: 6px 16px; border: none;">
<span data-i18n="about.joinDiscord">加入 Discord</span>
</button>
</div>
</div>
</div>
<!-- 致謝與貢獻卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="about.thanks">🙏 致謝與貢獻</h3>
</div>
<div class="settings-card-body">
<div class="setting-item" style="border-bottom: none;">
<div class="setting-info">
<div class="text-input" data-i18n="about.thanksText" style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 12px; color: var(--text-primary); font-size: 12px; line-height: 1.5; min-height: 140px; max-height: 200px; overflow-y: auto; white-space: pre-wrap;">感謝原作者 Fábio Ferreira (@fabiomlferreira) 創建了原始的 interactive-feedback-mcp 專案。
本增強版本由 Minidoracat 開發和維護,大幅擴展了專案功能,新增了 GUI 介面、圖片支援、多語言能力以及許多其他改進功能。
同時感謝 sanshao85 的 mcp-feedback-collector 專案提供的 UI 設計靈感。
開源協作讓技術變得更美好!</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 底部操作按鈕 -->
<footer class="footer-actions">
<button id="cancelBtn" class="btn btn-secondary" data-i18n="buttons.cancel">
❌ 取消
</button>
<button id="submitBtn" class="btn btn-success" data-i18n="buttons.submit">
✅ 提交回饋
</button>
</footer>
</div>
<!-- WebSocket 和 JavaScript -->
<script src="/static/js/i18n.js?v=2025010510"></script>
<script src="/static/js/app.js?v=2025010510"></script>
<script>
// 等待 I18nManager 初始化完成後再初始化 FeedbackApp
async function initializeApp() {
const sessionId = '{{ session_id }}';
// 確保 I18nManager 已經初始化
if (window.i18nManager) {
await window.i18nManager.init();
}
// 初始化 FeedbackApp
window.feedbackApp = new FeedbackApp(sessionId);
}
// 頁面載入完成後初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
</script>
</body>
</html>

View File

@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="zh-TW" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/css/styles.css">
<style>
:root {
/* 深色主題顏色變數 */
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d30;
--bg-tertiary: #252526;
--surface-color: #333333;
--text-primary: #cccccc;
--text-secondary: #9e9e9e;
--accent-color: #007acc;
--accent-hover: #005a9e;
--border-color: #464647;
--success-color: #4caf50;
--warning-color: #ff9800;
--error-color: #f44336;
--info-color: #2196f3;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 等待會話時的樣式 */
.waiting-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
}
.waiting-content {
max-width: 600px;
padding: 40px;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.waiting-title {
font-size: 2.5em;
font-weight: bold;
color: var(--accent-color);
margin-bottom: 20px;
}
.waiting-description {
color: var(--text-secondary);
font-size: 1.2em;
margin-bottom: 30px;
}
.waiting-status {
padding: 20px;
background: rgba(0, 122, 204, 0.1);
border: 1px solid var(--accent-color);
border-radius: 8px;
color: var(--accent-color);
}
/* 連接狀態指示器 */
.connection-status {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.connection-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error-color);
transition: all 0.3s ease;
}
.connection-indicator.connected {
background: var(--success-color);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.5);
}
.connection-indicator.connecting {
background: var(--warning-color);
animation: pulse 1s infinite;
}
.connection-indicator.error {
background: var(--error-color);
}
.connection-indicator.disconnected {
background: var(--text-secondary);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 主容器 - 有會話時顯示 */
.main-container {
display: none;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 20px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
flex-direction: column;
}
.main-container.active {
display: flex;
}
/* 回饋界面樣式 */
.feedback-container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 15px 0;
margin-bottom: 20px;
border-radius: 8px 8px 0 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.title {
font-size: 24px;
font-weight: bold;
color: var(--accent-color);
margin: 0;
}
.project-info {
color: var(--text-secondary);
font-size: 14px;
}
.ai-summary-section,
.feedback-section,
.command-section {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.ai-summary-section h2,
.feedback-section h3,
.command-section h3 {
color: var(--accent-color);
margin-bottom: 16px;
margin-top: 0;
}
.input-group {
margin-bottom: 16px;
}
.input-label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-primary);
}
.text-input,
.command-input-line {
width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
resize: vertical;
font-family: inherit;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.text-input:focus,
.command-input-line:focus {
outline: none;
border-color: var(--accent-color);
}
.command-input-line {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
.command-output {
background: #0f0f0f;
border: 2px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
color: #00ff00;
text-shadow: 0 0 5px #00ff00;
white-space: pre-wrap;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-right: 10px;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--surface-color);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.button-group {
display: flex;
gap: 10px;
margin-top: 16px;
}
</style>
</head>
<body>
<!-- 連接狀態指示器 -->
<div class="connection-status">
<div class="connection-indicator" id="connectionIndicator"></div>
<span id="connectionText">連接中...</span>
</div>
<!-- 等待會話的頁面 -->
<div class="waiting-container" id="waitingContainer">
<div class="waiting-content">
<h1 class="waiting-title">MCP Feedback Enhanced</h1>
<p class="waiting-description">
Web UI 互動式回饋收集工具
</p>
<div class="waiting-status">
等待 MCP 服務調用以建立回饋會話...
</div>
</div>
</div>
<!-- 主要回饋界面 -->
<div class="main-container" id="mainContainer">
<!-- 這裡將動態載入回饋界面內容 -->
</div>
<!-- JavaScript -->
<script src="/static/js/i18n.js?v=2025010505"></script>
<script src="/static/js/app.js?v=2025010505"></script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 工具模組
==============
提供 Web UI 相關的工具函數。
"""
from .network import find_free_port
from .browser import get_browser_opener
__all__ = [
'find_free_port',
'get_browser_opener'
]

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
瀏覽器工具函數
==============
提供瀏覽器相關的工具函數,包含 WSL 環境的特殊處理。
"""
import os
import sys
import subprocess
import webbrowser
from typing import Callable
# 導入調試功能
from ...debug import server_debug_log as debug_log
def is_wsl_environment() -> bool:
"""
檢測是否在 WSL 環境中運行
Returns:
bool: True 表示 WSL 環境False 表示其他環境
"""
try:
# 檢查 /proc/version 文件是否包含 WSL 標識
if os.path.exists('/proc/version'):
with open('/proc/version', 'r') as f:
version_info = f.read().lower()
if 'microsoft' in version_info or 'wsl' in version_info:
return True
# 檢查 WSL 相關環境變數
wsl_env_vars = ['WSL_DISTRO_NAME', 'WSL_INTEROP', 'WSLENV']
for env_var in wsl_env_vars:
if os.getenv(env_var):
return True
# 檢查是否存在 WSL 特有的路徑
wsl_paths = ['/mnt/c', '/mnt/d', '/proc/sys/fs/binfmt_misc/WSLInterop']
for path in wsl_paths:
if os.path.exists(path):
return True
except Exception:
pass
return False
def open_browser_in_wsl(url: str) -> None:
"""
在 WSL 環境中開啟 Windows 瀏覽器
Args:
url: 要開啟的 URL
"""
try:
# 嘗試使用 cmd.exe 啟動瀏覽器
cmd = ['cmd.exe', '/c', 'start', url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
debug_log(f"成功使用 cmd.exe 啟動瀏覽器: {url}")
return
else:
debug_log(f"cmd.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
except Exception as e:
debug_log(f"使用 cmd.exe 啟動瀏覽器失敗: {e}")
try:
# 嘗試使用 powershell.exe 啟動瀏覽器
cmd = ['powershell.exe', '-c', f'Start-Process "{url}"']
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
debug_log(f"成功使用 powershell.exe 啟動瀏覽器: {url}")
return
else:
debug_log(f"powershell.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
except Exception as e:
debug_log(f"使用 powershell.exe 啟動瀏覽器失敗: {e}")
try:
# 最後嘗試使用 wslview如果安裝了 wslu 套件)
cmd = ['wslview', url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
debug_log(f"成功使用 wslview 啟動瀏覽器: {url}")
return
else:
debug_log(f"wslview 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
except Exception as e:
debug_log(f"使用 wslview 啟動瀏覽器失敗: {e}")
# 如果所有方法都失敗,拋出異常
raise Exception("無法在 WSL 環境中啟動 Windows 瀏覽器")
def smart_browser_open(url: str) -> None:
"""
智能瀏覽器開啟函數,根據環境選擇最佳方式
Args:
url: 要開啟的 URL
"""
if is_wsl_environment():
debug_log("檢測到 WSL 環境,使用 WSL 專用瀏覽器啟動方式")
open_browser_in_wsl(url)
else:
debug_log("使用標準瀏覽器啟動方式")
webbrowser.open(url)
def get_browser_opener() -> Callable[[str], None]:
"""
獲取瀏覽器開啟函數
Returns:
Callable: 瀏覽器開啟函數
"""
return smart_browser_open

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
網絡工具函數
============
提供網絡相關的工具函數,如端口檢測等。
"""
import socket
from typing import Optional
def find_free_port(start_port: int = 8765, max_attempts: int = 100, preferred_port: int = 8765) -> int:
"""
尋找可用的端口,優先使用偏好端口
Args:
start_port: 起始端口號
max_attempts: 最大嘗試次數
preferred_port: 偏好端口號(用於保持設定持久性)
Returns:
int: 可用的端口號
Raises:
RuntimeError: 如果找不到可用端口
"""
# 首先嘗試偏好端口(通常是 8765
if is_port_available("127.0.0.1", preferred_port):
return preferred_port
# 如果偏好端口不可用,嘗試其他端口
for i in range(max_attempts):
port = start_port + i
if port == preferred_port: # 跳過已經嘗試過的偏好端口
continue
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"無法在 {start_port}-{start_port + max_attempts - 1} 範圍內找到可用端口")
def is_port_available(host: str, port: int) -> bool:
"""
檢查端口是否可用
Args:
host: 主機地址
port: 端口號
Returns:
bool: 端口是否可用
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, port))
return True
except OSError:
return False