first commit
This commit is contained in:
57
src/mcp_feedback_enhanced/__init__.py
Normal file
57
src/mcp_feedback_enhanced/__init__.py
Normal 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()
|
||||
168
src/mcp_feedback_enhanced/__main__.py
Normal file
168
src/mcp_feedback_enhanced/__main__.py
Normal 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()
|
||||
85
src/mcp_feedback_enhanced/debug.py
Normal file
85
src/mcp_feedback_enhanced/debug.py
Normal 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"
|
||||
25
src/mcp_feedback_enhanced/gui/__init__.py
Normal file
25
src/mcp_feedback_enhanced/gui/__init__.py
Normal 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']
|
||||
172
src/mcp_feedback_enhanced/gui/locales/README.md
Normal file
172
src/mcp_feedback_enhanced/gui/locales/README.md
Normal 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
|
||||
|
||||
需要幫助可以參考現有的翻譯檔案作為範本。
|
||||
207
src/mcp_feedback_enhanced/gui/locales/en/translations.json
Normal file
207
src/mcp_feedback_enhanced/gui/locales/en/translations.json
Normal 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!"
|
||||
}
|
||||
}
|
||||
202
src/mcp_feedback_enhanced/gui/locales/zh-CN/translations.json
Normal file
202
src/mcp_feedback_enhanced/gui/locales/zh-CN/translations.json
Normal 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开源协作让技术变得更美好!"
|
||||
}
|
||||
}
|
||||
202
src/mcp_feedback_enhanced/gui/locales/zh-TW/translations.json
Normal file
202
src/mcp_feedback_enhanced/gui/locales/zh-TW/translations.json
Normal 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請測試這些功能並提供回饋!"
|
||||
}
|
||||
}
|
||||
147
src/mcp_feedback_enhanced/gui/main.py
Normal file
147
src/mcp_feedback_enhanced/gui/main.py
Normal 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()
|
||||
10
src/mcp_feedback_enhanced/gui/models/__init__.py
Normal file
10
src/mcp_feedback_enhanced/gui/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
GUI 資料模型模組
|
||||
===============
|
||||
|
||||
定義 GUI 相關的資料結構和型別。
|
||||
"""
|
||||
|
||||
from .feedback import FeedbackResult
|
||||
|
||||
__all__ = ['FeedbackResult']
|
||||
17
src/mcp_feedback_enhanced/gui/models/feedback.py
Normal file
17
src/mcp_feedback_enhanced/gui/models/feedback.py
Normal 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]
|
||||
17
src/mcp_feedback_enhanced/gui/styles/__init__.py
Normal file
17
src/mcp_feedback_enhanced/gui/styles/__init__.py
Normal 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'
|
||||
]
|
||||
277
src/mcp_feedback_enhanced/gui/styles/themes.py
Normal file
277
src/mcp_feedback_enhanced/gui/styles/themes.py
Normal 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;
|
||||
}
|
||||
"""
|
||||
22
src/mcp_feedback_enhanced/gui/tabs/__init__.py
Normal file
22
src/mcp_feedback_enhanced/gui/tabs/__init__.py
Normal 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'
|
||||
]
|
||||
261
src/mcp_feedback_enhanced/gui/tabs/about_tab.py
Normal file
261
src/mcp_feedback_enhanced/gui/tabs/about_tab.py
Normal 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'))
|
||||
194
src/mcp_feedback_enhanced/gui/tabs/command_tab.py
Normal file
194
src/mcp_feedback_enhanced/gui/tabs/command_tab.py
Normal 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()
|
||||
168
src/mcp_feedback_enhanced/gui/tabs/feedback_tab.py
Normal file
168
src/mcp_feedback_enhanced/gui/tabs/feedback_tab.py
Normal 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)
|
||||
623
src/mcp_feedback_enhanced/gui/tabs/settings_tab.py
Normal file
623
src/mcp_feedback_enhanced/gui/tabs/settings_tab.py
Normal 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)
|
||||
130
src/mcp_feedback_enhanced/gui/tabs/summary_tab.py
Normal file
130
src/mcp_feedback_enhanced/gui/tabs/summary_tab.py
Normal 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'))
|
||||
14
src/mcp_feedback_enhanced/gui/utils/__init__.py
Normal file
14
src/mcp_feedback_enhanced/gui/utils/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
GUI 工具函數模組
|
||||
===============
|
||||
|
||||
包含各種輔助工具函數。
|
||||
"""
|
||||
|
||||
from .shortcuts import setup_shortcuts
|
||||
from .utils import apply_widget_styles
|
||||
|
||||
__all__ = [
|
||||
'setup_shortcuts',
|
||||
'apply_widget_styles'
|
||||
]
|
||||
35
src/mcp_feedback_enhanced/gui/utils/shortcuts.py
Normal file
35
src/mcp_feedback_enhanced/gui/utils/shortcuts.py
Normal 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)
|
||||
50
src/mcp_feedback_enhanced/gui/utils/utils.py
Normal file
50
src/mcp_feedback_enhanced/gui/utils/utils.py
Normal 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"
|
||||
19
src/mcp_feedback_enhanced/gui/widgets/__init__.py
Normal file
19
src/mcp_feedback_enhanced/gui/widgets/__init__.py
Normal 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'
|
||||
]
|
||||
95
src/mcp_feedback_enhanced/gui/widgets/image_preview.py
Normal file
95
src/mcp_feedback_enhanced/gui/widgets/image_preview.py
Normal 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)
|
||||
740
src/mcp_feedback_enhanced/gui/widgets/image_upload.py
Normal file
740
src/mcp_feedback_enhanced/gui/widgets/image_upload.py
Normal 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()
|
||||
163
src/mcp_feedback_enhanced/gui/widgets/styled_spinbox.py
Normal file
163
src/mcp_feedback_enhanced/gui/widgets/styled_spinbox.py
Normal 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)
|
||||
237
src/mcp_feedback_enhanced/gui/widgets/switch.py
Normal file
237
src/mcp_feedback_enhanced/gui/widgets/switch.py
Normal 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"};
|
||||
}}
|
||||
""")
|
||||
37
src/mcp_feedback_enhanced/gui/widgets/text_edit.py
Normal file
37
src/mcp_feedback_enhanced/gui/widgets/text_edit.py
Normal 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)
|
||||
322
src/mcp_feedback_enhanced/gui/widgets/timeout_widget.py
Normal file
322
src/mcp_feedback_enhanced/gui/widgets/timeout_widget.py
Normal 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;
|
||||
}}
|
||||
"""
|
||||
20
src/mcp_feedback_enhanced/gui/window/__init__.py
Normal file
20
src/mcp_feedback_enhanced/gui/window/__init__.py
Normal 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'
|
||||
]
|
||||
242
src/mcp_feedback_enhanced/gui/window/command_executor.py
Normal file
242
src/mcp_feedback_enhanced/gui/window/command_executor.py
Normal 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()
|
||||
217
src/mcp_feedback_enhanced/gui/window/config_manager.py
Normal file
217
src/mcp_feedback_enhanced/gui/window/config_manager.py
Normal 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:
|
||||
"""獲取圖片大小限制(bytes),0 表示無限制"""
|
||||
return self.get('image_size_limit', 0)
|
||||
|
||||
def set_image_size_limit(self, size_bytes: int) -> None:
|
||||
"""設置圖片大小限制(bytes),0 表示無限制"""
|
||||
# 處理 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
|
||||
791
src/mcp_feedback_enhanced/gui/window/feedback_window.py
Normal file
791
src/mcp_feedback_enhanced/gui/window/feedback_window.py
Normal 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("主窗口已關閉")
|
||||
358
src/mcp_feedback_enhanced/gui/window/tab_manager.py
Normal file
358
src/mcp_feedback_enhanced/gui/window/tab_manager.py
Normal 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}")
|
||||
360
src/mcp_feedback_enhanced/i18n.py
Normal file
360
src/mcp_feedback_enhanced/i18n.py
Normal 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()
|
||||
675
src/mcp_feedback_enhanced/server.py
Normal file
675
src/mcp_feedback_enhanced/server.py
Normal 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 表示可以使用 GUI,False 表示只能使用 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()
|
||||
286
src/mcp_feedback_enhanced/test_mcp_enhanced.py
Normal file
286
src/mcp_feedback_enhanced/test_mcp_enhanced.py
Normal 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())
|
||||
100
src/mcp_feedback_enhanced/test_qt_gui.py
Normal file
100
src/mcp_feedback_enhanced/test_qt_gui.py
Normal 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)
|
||||
375
src/mcp_feedback_enhanced/test_web_ui.py
Normal file
375
src/mcp_feedback_enhanced/test_web_ui.py
Normal 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)
|
||||
37
src/mcp_feedback_enhanced/testing/__init__.py
Normal file
37
src/mcp_feedback_enhanced/testing/__init__.py
Normal 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"
|
||||
133
src/mcp_feedback_enhanced/testing/config.py
Normal file
133
src/mcp_feedback_enhanced/testing/config.py
Normal 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()
|
||||
527
src/mcp_feedback_enhanced/testing/mcp_client.py
Normal file
527
src/mcp_feedback_enhanced/testing/mcp_client.py
Normal 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 客戶端資源已清理")
|
||||
447
src/mcp_feedback_enhanced/testing/reporter.py
Normal file
447
src/mcp_feedback_enhanced/testing/reporter.py
Normal 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)
|
||||
469
src/mcp_feedback_enhanced/testing/scenarios.py
Normal file
469
src/mcp_feedback_enhanced/testing/scenarios.py
Normal 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
|
||||
}
|
||||
266
src/mcp_feedback_enhanced/testing/utils.py
Normal file
266
src/mcp_feedback_enhanced/testing/utils.py
Normal 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)
|
||||
394
src/mcp_feedback_enhanced/testing/validators.py
Normal file
394
src/mcp_feedback_enhanced/testing/validators.py
Normal 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
|
||||
}
|
||||
18
src/mcp_feedback_enhanced/web/__init__.py
Normal file
18
src/mcp_feedback_enhanced/web/__init__.py
Normal 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'
|
||||
]
|
||||
214
src/mcp_feedback_enhanced/web/locales/en/translation.json
Normal file
214
src/mcp_feedback_enhanced/web/locales/en/translation.json
Normal 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."
|
||||
}
|
||||
}
|
||||
214
src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json
Normal file
214
src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json
Normal 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": "建议使用图片编辑软件压缩后再上传,或调整图片大小限制设置。"
|
||||
}
|
||||
}
|
||||
214
src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json
Normal file
214
src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json
Normal 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": "建議使用圖片編輯軟體壓縮後再上傳,或調整圖片大小限制設定。"
|
||||
}
|
||||
}
|
||||
500
src/mcp_feedback_enhanced/web/main.py
Normal file
500
src/mcp_feedback_enhanced/web/main.py
Normal 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())
|
||||
16
src/mcp_feedback_enhanced/web/models/__init__.py
Normal file
16
src/mcp_feedback_enhanced/web/models/__init__.py
Normal 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'
|
||||
]
|
||||
17
src/mcp_feedback_enhanced/web/models/feedback_result.py
Normal file
17
src/mcp_feedback_enhanced/web/models/feedback_result.py
Normal 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]
|
||||
404
src/mcp_feedback_enhanced/web/models/feedback_session.py
Normal file
404
src/mcp_feedback_enhanced/web/models/feedback_session.py
Normal 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()
|
||||
12
src/mcp_feedback_enhanced/web/routes/__init__.py
Normal file
12
src/mcp_feedback_enhanced/web/routes/__init__.py
Normal 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']
|
||||
408
src/mcp_feedback_enhanced/web/routes/main_routes.py
Normal file
408
src/mcp_feedback_enhanced/web/routes/main_routes.py
Normal 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 服務器已因用戶超時而停止")
|
||||
937
src/mcp_feedback_enhanced/web/static/css/styles.css
Normal file
937
src/mcp_feedback_enhanced/web/static/css/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1664
src/mcp_feedback_enhanced/web/static/js/app.js
Normal file
1664
src/mcp_feedback_enhanced/web/static/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
229
src/mcp_feedback_enhanced/web/static/js/i18n.js
Normal file
229
src/mcp_feedback_enhanced/web/static/js/i18n.js
Normal 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();
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
726
src/mcp_feedback_enhanced/web/templates/feedback.html
Normal file
726
src/mcp_feedback_enhanced/web/templates/feedback.html
Normal 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>
|
||||
322
src/mcp_feedback_enhanced/web/templates/index.html
Normal file
322
src/mcp_feedback_enhanced/web/templates/index.html
Normal 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>
|
||||
16
src/mcp_feedback_enhanced/web/utils/__init__.py
Normal file
16
src/mcp_feedback_enhanced/web/utils/__init__.py
Normal 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'
|
||||
]
|
||||
128
src/mcp_feedback_enhanced/web/utils/browser.py
Normal file
128
src/mcp_feedback_enhanced/web/utils/browser.py
Normal 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
|
||||
64
src/mcp_feedback_enhanced/web/utils/network.py
Normal file
64
src/mcp_feedback_enhanced/web/utils/network.py
Normal 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
|
||||
Reference in New Issue
Block a user