diff --git a/.gitignore b/.gitignore
index 0e7d0ce..5ce68c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,5 @@ venv*/
.cursor/rules/
uv.lock
-.mcp_feedback_settings.json
\ No newline at end of file
+.mcp_feedback_settings.json
+test_reports/
\ No newline at end of file
diff --git a/debug_websocket.html b/debug_websocket.html
new file mode 100644
index 0000000..04f30ae
--- /dev/null
+++ b/debug_websocket.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+ WebSocket 診斷工具
+
+
+
+
+
🔧 WebSocket 診斷工具
+
+
+ 準備開始診斷...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
等待操作...
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 76e85eb..c477945 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ dependencies = [
"uvicorn>=0.30.0",
"jinja2>=3.1.0",
"websockets>=13.0.0",
+ "aiohttp>=3.8.0",
]
[project.optional-dependencies]
diff --git a/src/mcp_feedback_enhanced/__main__.py b/src/mcp_feedback_enhanced/__main__.py
index 7a50647..dbf72e1 100644
--- a/src/mcp_feedback_enhanced/__main__.py
+++ b/src/mcp_feedback_enhanced/__main__.py
@@ -30,6 +30,12 @@ def main():
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='顯示版本資訊')
@@ -58,8 +64,54 @@ def run_tests(args):
"""執行測試"""
# 啟用調試模式以顯示測試過程
os.environ["MCP_DEBUG"] = "true"
-
- if args.web:
+
+ 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()
@@ -77,39 +129,33 @@ def run_tests(args):
if not test_qt_gui():
sys.exit(1)
else:
- # 執行所有測試
- print("🧪 執行完整測試套件...")
- success = True
- session_info = None
-
- try:
- from .test_web_ui import (
- test_environment_detection,
- test_new_parameters,
- test_mcp_integration,
- test_web_ui,
- interactive_demo
- )
-
- if not test_environment_detection():
- success = False
- if not test_new_parameters():
- success = False
- if not test_mcp_integration():
- success = False
-
- web_success, session_info = test_web_ui()
- if not web_success:
- success = False
-
- except Exception as e:
- print(f"❌ 測試執行失敗: {e}")
- success = False
-
+ # 默認執行增強測試系統的快速測試
+ 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("🎉 快速測試通過!")
+ print("💡 使用 'test --enhanced' 執行完整測試套件")
def show_version():
"""顯示版本資訊"""
diff --git a/src/mcp_feedback_enhanced/test_mcp_enhanced.py b/src/mcp_feedback_enhanced/test_mcp_enhanced.py
new file mode 100644
index 0000000..1fdadf6
--- /dev/null
+++ b/src/mcp_feedback_enhanced/test_mcp_enhanced.py
@@ -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())
diff --git a/src/mcp_feedback_enhanced/test_web_ui.py b/src/mcp_feedback_enhanced/test_web_ui.py
index 4fc4a1c..320b456 100644
--- a/src/mcp_feedback_enhanced/test_web_ui.py
+++ b/src/mcp_feedback_enhanced/test_web_ui.py
@@ -140,7 +140,7 @@ def test_web_ui(keep_running=False):
session_info = {
'manager': manager,
'session_id': session_id,
- 'url': f"http://{manager.host}:{manager.port}/session/{session_id}"
+ 'url': f"http://{manager.host}:{manager.port}" # 使用根路徑
}
debug_log(f"✅ 測試會話創建成功 (ID: {session_id[:8]}...)")
debug_log(f"🔗 測試 URL: {session_info['url']}")
@@ -299,16 +299,16 @@ def interactive_demo(session_info):
"""Run interactive demo with the Web UI"""
debug_log(f"\n🌐 Web UI 互動測試模式")
debug_log("=" * 50)
- debug_log(f"服務器地址: http://{session_info['manager'].host}:{session_info['manager'].port}")
- debug_log(f"測試會話: {session_info['url']}")
+ debug_log(f"服務器地址: {session_info['url']}") # 簡化輸出,只顯示服務器地址
debug_log("\n📖 操作指南:")
- debug_log(" 1. 在瀏覽器中開啟上面的測試 URL")
+ 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' 停止服務器")
diff --git a/src/mcp_feedback_enhanced/testing/__init__.py b/src/mcp_feedback_enhanced/testing/__init__.py
new file mode 100644
index 0000000..87e49a3
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/__init__.py
@@ -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"
diff --git a/src/mcp_feedback_enhanced/testing/config.py b/src/mcp_feedback_enhanced/testing/config.py
new file mode 100644
index 0000000..55e91a2
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/config.py
@@ -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()
diff --git a/src/mcp_feedback_enhanced/testing/mcp_client.py b/src/mcp_feedback_enhanced/testing/mcp_client.py
new file mode 100644
index 0000000..ded473e
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/mcp_client.py
@@ -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 客戶端資源已清理")
diff --git a/src/mcp_feedback_enhanced/testing/reporter.py b/src/mcp_feedback_enhanced/testing/reporter.py
new file mode 100644
index 0000000..c3a7c90
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/reporter.py
@@ -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"""
+
+
+
+
+
+ MCP 測試報告
+
+
+
+
+
+
+
+
+
總測試數
+
{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", [])
+
+ html += f"""
+
+
{'✅' if success else '❌'} {scenario_name}
+
{scenario_desc}
+
+
狀態: {'通過' if success else '失敗'}
+
耗時: {duration_str}
+
完成步驟: {completed_steps}/{total_steps}
+
錯誤數: {len(errors)}
+
+"""
+
+ if errors:
+ html += '
錯誤信息:'
+ for error in errors:
+ html += f'- {error}
'
+ html += '
'
+
+ html += '
'
+
+ html += f"""
+
+
+
+
+
+
+
+
+"""
+ 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)
diff --git a/src/mcp_feedback_enhanced/testing/scenarios.py b/src/mcp_feedback_enhanced/testing/scenarios.py
new file mode 100644
index 0000000..c3b6e0b
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/scenarios.py
@@ -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
+ }
diff --git a/src/mcp_feedback_enhanced/testing/utils.py b/src/mcp_feedback_enhanced/testing/utils.py
new file mode 100644
index 0000000..114bf50
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/utils.py
@@ -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)
diff --git a/src/mcp_feedback_enhanced/testing/validators.py b/src/mcp_feedback_enhanced/testing/validators.py
new file mode 100644
index 0000000..108cb69
--- /dev/null
+++ b/src/mcp_feedback_enhanced/testing/validators.py
@@ -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
+ }
diff --git a/src/mcp_feedback_enhanced/web/main.py b/src/mcp_feedback_enhanced/web/main.py
index c4c3b00..15266c8 100644
--- a/src/mcp_feedback_enhanced/web/main.py
+++ b/src/mcp_feedback_enhanced/web/main.py
@@ -33,25 +33,35 @@ from ..i18n import get_i18n_manager
class WebUIManager:
- """Web UI 管理器"""
-
+ """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.sessions: Dict[str, WebFeedbackSession] = {}
+
+ # 重構:使用單一活躍會話而非會話字典
+ 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):
@@ -73,25 +83,129 @@ class WebUIManager:
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"創建新的活躍會話: {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():
@@ -146,6 +260,126 @@ class WebUIManager:
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}"
@@ -176,52 +410,56 @@ def get_web_ui_manager() -> WebUIManager:
async def launch_web_feedback_ui(project_directory: str, summary: str, timeout: int = 600) -> dict:
"""
- 啟動 Web 回饋介面並等待用戶回饋
-
+ 啟動 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_session(session_id)
-
+ 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 = f"{manager.get_server_url()}/session/{session_id}"
- manager.open_browser(feedback_url)
-
+
+ # 使用根路徑 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"收到用戶回饋,會話: {session_id}")
+ debug_log(f"收到用戶回饋")
return result
except TimeoutError:
- debug_log(f"會話 {session_id} 超時")
+ debug_log(f"會話超時")
# 資源已在 wait_for_feedback 中清理,這裡只需要記錄和重新拋出
raise
except Exception as e:
- debug_log(f"會話 {session_id} 發生錯誤: {e}")
+ debug_log(f"會話發生錯誤: {e}")
raise
finally:
- # 清理會話(無論成功還是失敗)
- manager.remove_session(session_id)
- # 如果沒有其他活躍會話,停止服務器
- if len(manager.sessions) == 0:
- debug_log("沒有活躍會話,停止 Web UI 服務器")
- stop_web_ui()
+ # 注意:不再自動清理會話和停止服務器,保持持久性
+ # 會話將保持活躍狀態,等待下次 MCP 調用
+ debug_log("會話保持活躍狀態,等待下次 MCP 調用")
def stop_web_ui():
diff --git a/src/mcp_feedback_enhanced/web/models/feedback_session.py b/src/mcp_feedback_enhanced/web/models/feedback_session.py
index 9294262..f8e52e3 100644
--- a/src/mcp_feedback_enhanced/web/models/feedback_session.py
+++ b/src/mcp_feedback_enhanced/web/models/feedback_session.py
@@ -11,6 +11,7 @@ import asyncio
import base64
import subprocess
import threading
+from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
@@ -18,6 +19,16 @@ 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'}
@@ -39,10 +50,42 @@ class WebFeedbackSession:
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:
"""
等待用戶回饋,包含圖片,支援超時自動清理
@@ -102,13 +145,24 @@ class WebFeedbackSession:
# 先設置設定,再處理圖片(因為處理圖片時需要用到設定)
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.close()
- except:
- pass
+ 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]:
"""
@@ -304,14 +358,14 @@ class WebFeedbackSession:
except Exception as e:
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤: {e}")
- def cleanup(self):
- """同步清理會話資源(保持向後兼容)"""
+ def _cleanup_sync(self):
+ """同步清理會話資源(但保留 WebSocket 連接)"""
if self._cleanup_done:
return
-
- self._cleanup_done = True
- debug_log(f"同步清理會話 {self.session_id} 資源...")
-
+
+ debug_log(f"同步清理會話 {self.session_id} 資源(保留 WebSocket)...")
+
+ # 只清理進程,不清理 WebSocket 連接
if self.process:
try:
self.process.terminate()
@@ -321,7 +375,30 @@ class WebFeedbackSession:
self.process.kill()
except:
pass
- self.process = None
-
+ 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()
\ No newline at end of file
+ self.feedback_completed.set()
\ No newline at end of file
diff --git a/src/mcp_feedback_enhanced/web/routes/main_routes.py b/src/mcp_feedback_enhanced/web/routes/main_routes.py
index 0e0d2b3..230d864 100644
--- a/src/mcp_feedback_enhanced/web/routes/main_routes.py
+++ b/src/mcp_feedback_enhanced/web/routes/main_routes.py
@@ -9,6 +9,7 @@
import json
import os
+import time
from pathlib import Path
from typing import TYPE_CHECKING
@@ -24,32 +25,30 @@ if TYPE_CHECKING:
def setup_routes(manager: 'WebUIManager'):
"""設置路由"""
-
+
@manager.app.get("/", response_class=HTMLResponse)
async def index(request: Request):
- """首頁"""
- return manager.templates.TemplateResponse("index.html", {
- "request": request,
- "title": "MCP Feedback Enhanced"
- })
+ """統一回饋頁面 - 重構後的主頁面"""
+ # 獲取當前活躍會話
+ current_session = manager.get_current_session()
- @manager.app.get("/session/{session_id}", response_class=HTMLResponse)
- async def feedback_session(request: Request, session_id: str):
- """回饋會話頁面"""
- session = manager.get_session(session_id)
- if not session:
- return JSONResponse(
- status_code=404,
- content={"error": "會話不存在"}
- )
-
+ 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,
- "session_id": session_id,
- "project_directory": session.project_directory,
- "summary": session.summary,
+ "project_directory": current_session.project_directory,
+ "summary": current_session.summary,
"title": "Interactive Feedback - 回饋收集",
- "version": __version__
+ "version": __version__,
+ "has_session": True
})
@manager.app.get("/api/translations")
@@ -81,27 +80,93 @@ def setup_routes(manager: 'WebUIManager'):
debug_log(f"Web 翻譯 API 返回 {len(translations)} 種語言的數據")
return JSONResponse(content=translations)
- @manager.app.websocket("/ws/{session_id}")
- async def websocket_endpoint(websocket: WebSocket, session_id: str):
- """WebSocket 端點"""
- session = manager.get_session(session_id)
+ @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="會話不存在")
+ await websocket.close(code=4004, reason="沒有活躍會話")
return
-
+
await websocket.accept()
session.websocket = websocket
-
- debug_log(f"WebSocket 連接建立: {session_id}")
-
+
+ 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 連接斷開: {session_id}")
+ debug_log(f"WebSocket 連接斷開")
except Exception as e:
debug_log(f"WebSocket 錯誤: {e}")
finally:
@@ -181,35 +246,154 @@ def setup_routes(manager: 'WebUIManager'):
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()
- # 如果沒有其他活躍會話,停止服務器
- if len(manager.sessions) <= 1: # 當前會話即將被移除
- debug_log("用戶超時,沒有其他活躍會話,準備停止服務器")
- # 延遲停止服務器,給前端時間關閉
- import asyncio
- asyncio.create_task(_delayed_server_stop(manager))
+ # 重構:不再自動停止服務器,保持服務器運行以支援持久性
else:
debug_log(f"未知的消息類型: {message_type}")
diff --git a/src/mcp_feedback_enhanced/web/static/js/app.js b/src/mcp_feedback_enhanced/web/static/js/app.js
index a66e5e4..d751423 100644
--- a/src/mcp_feedback_enhanced/web/static/js/app.js
+++ b/src/mcp_feedback_enhanced/web/static/js/app.js
@@ -1,248 +1,748 @@
/**
- * 主要前端應用
- * ============
- *
- * 處理 WebSocket 通信、分頁切換、圖片上傳、命令執行等功能
+ * MCP Feedback Enhanced - 完整回饋應用程式
+ * ==========================================
+ *
+ * 支援完整的 UI 交互功能,包括頁籤切換、圖片處理、WebSocket 通信等
*/
-class PersistentSettings {
+/**
+ * 標籤頁管理器 - 處理多標籤頁狀態同步和智能瀏覽器管理
+ */
+class TabManager {
constructor() {
- this.settingsFile = 'ui_settings.json';
- this.storageKey = 'mcp_feedback_settings';
+ this.tabId = this.generateTabId();
+ this.heartbeatInterval = null;
+ this.heartbeatFrequency = 5000; // 5秒心跳
+ this.storageKey = 'mcp_feedback_tabs';
+ this.lastActivityKey = 'mcp_feedback_last_activity';
+
+ this.init();
}
- async saveSettings(settings) {
+ generateTabId() {
+ return `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ }
+
+ init() {
+ // 註冊當前標籤頁
+ this.registerTab();
+
+ // 向服務器註冊標籤頁
+ this.registerTabToServer();
+
+ // 開始心跳
+ this.startHeartbeat();
+
+ // 監聽頁面關閉事件
+ window.addEventListener('beforeunload', () => {
+ this.unregisterTab();
+ });
+
+ // 監聽 localStorage 變化(其他標籤頁的狀態變化)
+ window.addEventListener('storage', (e) => {
+ if (e.key === this.storageKey) {
+ this.handleTabsChange();
+ }
+ });
+
+ console.log(`📋 TabManager 初始化完成,標籤頁 ID: ${this.tabId}`);
+ }
+
+ registerTab() {
+ const tabs = this.getActiveTabs();
+ tabs[this.tabId] = {
+ timestamp: Date.now(),
+ url: window.location.href,
+ active: true
+ };
+ localStorage.setItem(this.storageKey, JSON.stringify(tabs));
+ this.updateLastActivity();
+ console.log(`✅ 標籤頁已註冊: ${this.tabId}`);
+ }
+
+ unregisterTab() {
+ const tabs = this.getActiveTabs();
+ delete tabs[this.tabId];
+ localStorage.setItem(this.storageKey, JSON.stringify(tabs));
+ console.log(`❌ 標籤頁已註銷: ${this.tabId}`);
+ }
+
+ startHeartbeat() {
+ this.heartbeatInterval = setInterval(() => {
+ this.sendHeartbeat();
+ }, this.heartbeatFrequency);
+ }
+
+ sendHeartbeat() {
+ const tabs = this.getActiveTabs();
+ if (tabs[this.tabId]) {
+ tabs[this.tabId].timestamp = Date.now();
+ localStorage.setItem(this.storageKey, JSON.stringify(tabs));
+ this.updateLastActivity();
+ }
+ }
+
+ updateLastActivity() {
+ localStorage.setItem(this.lastActivityKey, Date.now().toString());
+ }
+
+ getActiveTabs() {
try {
- // 嘗試保存到伺服器端
- const response = await fetch('/api/save-settings', {
+ const stored = localStorage.getItem(this.storageKey);
+ const tabs = stored ? JSON.parse(stored) : {};
+
+ // 清理過期的標籤頁(超過30秒沒有心跳)
+ const now = Date.now();
+ const expiredThreshold = 30000; // 30秒
+
+ Object.keys(tabs).forEach(tabId => {
+ if (now - tabs[tabId].timestamp > expiredThreshold) {
+ delete tabs[tabId];
+ }
+ });
+
+ return tabs;
+ } catch (error) {
+ console.error('獲取活躍標籤頁失敗:', error);
+ return {};
+ }
+ }
+
+ hasActiveTabs() {
+ const tabs = this.getActiveTabs();
+ return Object.keys(tabs).length > 0;
+ }
+
+ isOnlyActiveTab() {
+ const tabs = this.getActiveTabs();
+ return Object.keys(tabs).length === 1 && tabs[this.tabId];
+ }
+
+ handleTabsChange() {
+ // 處理其他標籤頁狀態變化
+ console.log('🔄 檢測到其他標籤頁狀態變化');
+ }
+
+ async registerTabToServer() {
+ try {
+ const response = await fetch('/api/register-tab', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
- body: JSON.stringify(settings)
+ body: JSON.stringify({
+ tabId: this.tabId
+ })
});
if (response.ok) {
- console.log('設定已保存到檔案');
+ const data = await response.json();
+ console.log(`✅ 標籤頁已向服務器註冊: ${this.tabId}`);
} else {
- throw new Error('伺服器端保存失敗');
+ console.warn(`⚠️ 標籤頁服務器註冊失敗: ${response.status}`);
}
} catch (error) {
- console.warn('無法保存到檔案,使用 localStorage:', error);
- // 備用方案:保存到 localStorage
- this.saveToLocalStorage(settings);
+ console.warn(`⚠️ 標籤頁服務器註冊錯誤: ${error}`);
}
}
- async loadSettings() {
- try {
- // 嘗試從伺服器端載入
- const response = await fetch('/api/load-settings');
- if (response.ok) {
- const settings = await response.json();
- console.log('從檔案載入設定');
- return settings;
- } else {
- throw new Error('伺服器端載入失敗');
- }
- } catch (error) {
- console.warn('無法從檔案載入,使用 localStorage:', error);
- // 備用方案:從 localStorage 載入
- return this.loadFromLocalStorage();
+ cleanup() {
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
}
- }
-
- saveToLocalStorage(settings) {
- localStorage.setItem(this.storageKey, JSON.stringify(settings));
- }
-
- loadFromLocalStorage() {
- const saved = localStorage.getItem(this.storageKey);
- return saved ? JSON.parse(saved) : {};
- }
-
- async clearSettings() {
- try {
- // 清除伺服器端設定
- await fetch('/api/clear-settings', { method: 'POST' });
- } catch (error) {
- console.warn('無法清除伺服器端設定:', error);
- }
-
- // 清除 localStorage
- localStorage.removeItem(this.storageKey);
-
- // 也清除個別設定項目(向後兼容)
- localStorage.removeItem('layoutMode');
- localStorage.removeItem('autoClose');
- localStorage.removeItem('activeTab');
- localStorage.removeItem('language');
+ this.unregisterTab();
}
}
class FeedbackApp {
- constructor(sessionId) {
+ constructor(sessionId = null) {
+ // 會話信息
this.sessionId = sessionId;
- this.layoutMode = 'separate'; // 預設為分離模式
- this.autoClose = true; // 預設啟用自動關閉
- this.currentTab = 'feedback'; // 預設當前分頁
- this.persistentSettings = new PersistentSettings();
- this.images = []; // 初始化圖片陣列
- this.isConnected = false; // 初始化連接狀態
- this.websocket = null; // 初始化 WebSocket
- this.isHandlingPaste = false; // 防止重複處理貼上事件的標記
- // 圖片設定
- this.imageSizeLimit = 0; // 0 表示無限制
+ // 標籤頁管理
+ this.tabManager = new TabManager();
+
+ // WebSocket 相關
+ this.websocket = null;
+ this.isConnected = false;
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = 5;
+ this.heartbeatInterval = null;
+ this.heartbeatFrequency = 30000; // 30秒 WebSocket 心跳
+
+ // UI 狀態
+ this.currentTab = 'feedback';
+
+ // 回饋狀態管理
+ this.feedbackState = 'waiting_for_feedback'; // waiting_for_feedback, feedback_submitted, processing
+ this.currentSessionId = null;
+ this.lastSubmissionTime = null;
+
+ // 圖片處理
+ this.images = [];
+ this.imageSizeLimit = 0;
this.enableBase64Detail = false;
- // 超時設定
+ // 設定
+ this.autoClose = false;
+ this.layoutMode = 'separate';
this.timeoutEnabled = false;
- this.timeoutDuration = 600; // 預設 10 分鐘
+ this.timeoutDuration = 600;
this.timeoutTimer = null;
- this.countdownTimer = null;
- this.remainingSeconds = 0;
-
- // 立即檢查 DOM 狀態並初始化
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => {
- this.init();
- });
- } else {
- // DOM 已經載入完成,立即初始化
- this.init();
- }
+
+ // 語言設定
+ this.currentLanguage = 'zh-TW';
+
+ this.init();
}
async init() {
- // 等待國際化系統加載完成
- if (window.i18nManager) {
- await window.i18nManager.init();
+ console.log('初始化 MCP Feedback Enhanced 應用程式');
+
+ try {
+ // 等待國際化系統
+ if (window.i18nManager) {
+ await window.i18nManager.init();
+ }
+
+ // 初始化 UI 組件
+ this.initUIComponents();
+
+ // 設置事件監聽器
+ this.setupEventListeners();
+
+ // 設置 WebSocket 連接
+ this.setupWebSocket();
+
+ // 載入設定(異步等待完成)
+ await this.loadSettings();
+
+ // 初始化頁籤(在設定載入完成後)
+ this.initTabs();
+
+ // 初始化圖片處理
+ this.initImageHandling();
+
+ // 設置頁面關閉時的清理
+ window.addEventListener('beforeunload', () => {
+ if (this.tabManager) {
+ this.tabManager.cleanup();
+ }
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ }
+ });
+
+ console.log('MCP Feedback Enhanced 應用程式初始化完成');
+
+ } catch (error) {
+ console.error('應用程式初始化失敗:', error);
}
-
- // 處理動態摘要內容
- this.processDynamicSummaryContent();
-
- // 設置 WebSocket 連接
- this.setupWebSocket();
-
- // 設置事件監聽器
- this.setupEventListeners();
-
- // 初始化分頁系統
- this.setupTabs();
-
- // 設置圖片上傳
- this.setupImageUpload();
-
- // 設置鍵盤快捷鍵
- this.setupKeyboardShortcuts();
-
- // 載入設定(使用 await)
- await this.loadSettings();
-
- // 初始化命令終端
- this.initCommandTerminal();
-
- // 確保合併模式狀態正確
- this.applyCombinedModeState();
-
- // 初始化超時控制
- this.setupTimeoutControl();
-
- // 如果啟用了超時,自動開始倒數計時(在設置載入後)
- this.startTimeoutIfEnabled();
-
- console.log('FeedbackApp 初始化完成');
}
- processDynamicSummaryContent() {
- // 處理所有帶有 data-dynamic-content 屬性的元素
- const dynamicElements = document.querySelectorAll('[data-dynamic-content="aiSummary"]');
-
- dynamicElements.forEach(element => {
- const currentContent = element.textContent || element.innerHTML;
-
- // 檢查是否為測試摘要
- if (this.isTestSummary(currentContent)) {
- // 如果是測試摘要,使用翻譯系統的內容
- if (window.i18nManager) {
- const translatedSummary = window.i18nManager.t('dynamic.aiSummary');
- if (translatedSummary && translatedSummary !== 'dynamic.aiSummary') {
- element.textContent = translatedSummary.trim();
- console.log('已更新測試摘要為:', window.i18nManager.currentLanguage);
- }
- }
+ initUIComponents() {
+ // 基本 UI 元素
+ this.connectionIndicator = document.getElementById('connectionIndicator');
+ this.connectionText = document.getElementById('connectionText');
+
+ // 頁籤相關元素
+ this.tabButtons = document.querySelectorAll('.tab-button');
+ this.tabContents = document.querySelectorAll('.tab-content');
+
+ // 回饋相關元素
+ this.feedbackText = document.getElementById('feedbackText');
+ this.submitBtn = document.getElementById('submitBtn');
+ this.cancelBtn = document.getElementById('cancelBtn');
+
+ // 命令相關元素
+ this.commandInput = document.getElementById('commandInput');
+ this.commandOutput = document.getElementById('commandOutput');
+ this.runCommandBtn = document.getElementById('runCommandBtn');
+
+ // 圖片相關元素
+ this.imageInput = document.getElementById('imageInput');
+ this.imageUploadArea = document.getElementById('imageUploadArea');
+ this.imagePreviewContainer = document.getElementById('imagePreviewContainer');
+ this.imageSizeLimitSelect = document.getElementById('imageSizeLimit');
+ this.enableBase64DetailCheckbox = document.getElementById('enableBase64Detail');
+ }
+
+ initTabs() {
+ // 設置頁籤點擊事件
+ this.tabButtons.forEach(button => {
+ button.addEventListener('click', (e) => {
+ const tabName = button.getAttribute('data-tab');
+ this.switchTab(tabName);
+ });
+ });
+
+ // 設置初始頁籤(不觸發保存,避免循環調用)
+ this.setInitialTab(this.currentTab);
+ }
+
+ setInitialTab(tabName) {
+ // 更新當前頁籤(不觸發保存)
+ this.currentTab = tabName;
+
+ // 更新按鈕狀態
+ this.tabButtons.forEach(button => {
+ if (button.getAttribute('data-tab') === tabName) {
+ button.classList.add('active');
} else {
- // 如果不是測試摘要,清理原有內容的前導和尾隨空白
- element.textContent = currentContent.trim();
+ button.classList.remove('active');
}
});
+
+ // 更新內容顯示
+ this.tabContents.forEach(content => {
+ if (content.id === `tab-${tabName}`) {
+ content.classList.add('active');
+ } else {
+ content.classList.remove('active');
+ }
+ });
+
+ // 特殊處理
+ if (tabName === 'combined') {
+ this.handleCombinedMode();
+ }
+
+ console.log(`初始化頁籤: ${tabName}`);
+ }
+
+ switchTab(tabName) {
+ // 更新當前頁籤
+ this.currentTab = tabName;
+
+ // 更新按鈕狀態
+ this.tabButtons.forEach(button => {
+ if (button.getAttribute('data-tab') === tabName) {
+ button.classList.add('active');
+ } else {
+ button.classList.remove('active');
+ }
+ });
+
+ // 更新內容顯示
+ this.tabContents.forEach(content => {
+ if (content.id === `tab-${tabName}`) {
+ content.classList.add('active');
+ } else {
+ content.classList.remove('active');
+ }
+ });
+
+ // 特殊處理
+ if (tabName === 'combined') {
+ this.handleCombinedMode();
+ }
+
+ // 保存當前頁籤設定
+ this.saveSettings();
+
+ console.log(`切換到頁籤: ${tabName}`);
+ }
+
+ initImageHandling() {
+ if (!this.imageUploadArea || !this.imageInput) return;
+
+ // 文件選擇事件
+ this.imageInput.addEventListener('change', (e) => {
+ this.handleFileSelect(e.target.files);
+ });
+
+ // 點擊上傳區域
+ this.imageUploadArea.addEventListener('click', () => {
+ this.imageInput.click();
+ });
+
+ // 拖放事件
+ this.imageUploadArea.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ this.imageUploadArea.classList.add('dragover');
+ });
+
+ this.imageUploadArea.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ this.imageUploadArea.classList.remove('dragover');
+ });
+
+ this.imageUploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ this.imageUploadArea.classList.remove('dragover');
+ this.handleFileSelect(e.dataTransfer.files);
+ });
+
+ // 剪貼板貼上事件
+ document.addEventListener('paste', (e) => {
+ const items = e.clipboardData.items;
+ for (let item of items) {
+ if (item.type.indexOf('image') !== -1) {
+ e.preventDefault();
+ const file = item.getAsFile();
+ this.handleFileSelect([file]);
+ break;
+ }
+ }
+ });
+
+ // 圖片設定事件
+ if (this.imageSizeLimitSelect) {
+ this.imageSizeLimitSelect.addEventListener('change', (e) => {
+ this.imageSizeLimit = parseInt(e.target.value);
+ });
+ }
+
+ if (this.enableBase64DetailCheckbox) {
+ this.enableBase64DetailCheckbox.addEventListener('change', (e) => {
+ this.enableBase64Detail = e.target.checked;
+ });
+ }
+ }
+
+ handleFileSelect(files) {
+ for (let file of files) {
+ if (file.type.startsWith('image/')) {
+ this.addImage(file);
+ }
+ }
+ }
+
+ async addImage(file) {
+ // 檢查文件大小
+ if (this.imageSizeLimit > 0 && file.size > this.imageSizeLimit) {
+ alert(`圖片大小超過限制 (${this.formatFileSize(this.imageSizeLimit)})`);
+ return;
+ }
+
+ try {
+ const base64 = await this.fileToBase64(file);
+ const imageData = {
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ data: base64
+ };
+
+ this.images.push(imageData);
+ this.updateImagePreview();
+
+ } catch (error) {
+ console.error('圖片處理失敗:', error);
+ alert('圖片處理失敗,請重試');
+ }
+ }
+
+ fileToBase64(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result.split(',')[1]);
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
}
- isTestSummary(content) {
- // 簡化的測試摘要檢測邏輯 - 檢查是否包含任何測試相關關鍵詞
- const testKeywords = [
- // 標題關鍵詞(任何語言版本)
- '測試 Web UI 功能', 'Test Web UI Functionality', '测试 Web UI 功能',
- '圖片預覽和視窗調整測試', 'Image Preview and Window Adjustment Test', '图片预览和窗口调整测试',
-
- // 功能測試項目關鍵詞
- '功能測試項目', 'Test Items', '功能测试项目',
-
- // 特殊標記
- '🎯 **功能測試項目', '🎯 **Test Items', '🎯 **功能测试项目',
- '📋 測試步驟', '📋 Test Steps', '📋 测试步骤',
-
- // 具體測試功能
- 'WebSocket 即時通訊', 'WebSocket real-time communication', 'WebSocket 即时通讯',
- '智能 Ctrl+V', 'Smart Ctrl+V', '智能 Ctrl+V',
-
- // 測試提示詞
- '請測試這些功能', 'Please test these features', '请测试这些功能'
- ];
-
- // 只要包含任何一個測試關鍵詞就認為是測試摘要
- return testKeywords.some(keyword => content.includes(keyword));
+ updateImagePreview() {
+ if (!this.imagePreviewContainer) return;
+
+ this.imagePreviewContainer.innerHTML = '';
+
+ this.images.forEach((image, index) => {
+ const preview = document.createElement('div');
+ preview.className = 'image-preview';
+ preview.innerHTML = `
+
+
+ ${image.name}
+ ${this.formatFileSize(image.size)}
+
+
+ `;
+ this.imagePreviewContainer.appendChild(preview);
+ });
+ }
+
+ removeImage(index) {
+ this.images.splice(index, 1);
+ this.updateImagePreview();
+ }
+
+ formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ }
+
+ // ==================== 狀態管理系統 ====================
+
+ /**
+ * 設置回饋狀態
+ * @param {string} state - waiting_for_feedback, feedback_submitted, processing
+ * @param {string} sessionId - 當前會話 ID
+ */
+ setFeedbackState(state, sessionId = null) {
+ const previousState = this.feedbackState;
+ this.feedbackState = state;
+
+ if (sessionId && sessionId !== this.currentSessionId) {
+ // 新會話開始,重置狀態
+ this.currentSessionId = sessionId;
+ this.lastSubmissionTime = null;
+ console.log(`🔄 新會話開始: ${sessionId.substring(0, 8)}...`);
+ }
+
+ console.log(`📊 狀態變更: ${previousState} → ${state}`);
+ this.updateUIState();
+ this.updateStatusIndicator();
+ }
+
+ /**
+ * 檢查是否可以提交回饋
+ */
+ canSubmitFeedback() {
+ return this.feedbackState === 'waiting_for_feedback' && this.isConnected;
+ }
+
+ /**
+ * 更新 UI 狀態
+ */
+ updateUIState() {
+ // 更新提交按鈕狀態
+ if (this.submitBtn) {
+ const canSubmit = this.canSubmitFeedback();
+ this.submitBtn.disabled = !canSubmit;
+
+ switch (this.feedbackState) {
+ case 'waiting_for_feedback':
+ this.submitBtn.textContent = '提交回饋';
+ this.submitBtn.className = 'btn btn-primary';
+ break;
+ case 'processing':
+ this.submitBtn.textContent = '處理中...';
+ this.submitBtn.className = 'btn btn-secondary';
+ break;
+ case 'feedback_submitted':
+ this.submitBtn.textContent = '已提交';
+ this.submitBtn.className = 'btn btn-success';
+ break;
+ }
+ }
+
+ // 更新回饋文字框狀態
+ if (this.feedbackText) {
+ this.feedbackText.disabled = !this.canSubmitFeedback();
+ }
+
+ // 更新合併模式的回饋文字框狀態
+ const combinedFeedbackText = document.getElementById('combinedFeedbackText');
+ if (combinedFeedbackText) {
+ combinedFeedbackText.disabled = !this.canSubmitFeedback();
+ }
+
+ // 更新圖片上傳狀態
+ if (this.imageUploadArea) {
+ if (this.canSubmitFeedback()) {
+ this.imageUploadArea.classList.remove('disabled');
+ } else {
+ this.imageUploadArea.classList.add('disabled');
+ }
+ }
+
+ // 更新合併模式的圖片上傳狀態
+ const combinedImageUploadArea = document.getElementById('combinedImageUploadArea');
+ if (combinedImageUploadArea) {
+ if (this.canSubmitFeedback()) {
+ combinedImageUploadArea.classList.remove('disabled');
+ } else {
+ combinedImageUploadArea.classList.add('disabled');
+ }
+ }
+ }
+
+ /**
+ * 更新狀態指示器
+ */
+ updateStatusIndicator() {
+ let statusElement = document.getElementById('feedbackStatusIndicator');
+
+ // 如果狀態指示器不存在,創建一個
+ if (!statusElement) {
+ statusElement = document.createElement('div');
+ statusElement.id = 'feedbackStatusIndicator';
+ statusElement.className = 'feedback-status-indicator';
+
+ // 插入到回饋區域的頂部
+ const feedbackContainer = document.querySelector('.feedback-container') ||
+ document.querySelector('#tab-feedback') ||
+ document.body;
+ feedbackContainer.insertBefore(statusElement, feedbackContainer.firstChild);
+ }
+
+ // 更新狀態指示器內容
+ let statusHTML = '';
+ let statusClass = '';
+
+ switch (this.feedbackState) {
+ case 'waiting_for_feedback':
+ statusHTML = `
+ ⏳
+
+ 等待回饋
+ 請提供您的回饋意見
+
+ `;
+ statusClass = 'status-waiting';
+ break;
+
+ case 'processing':
+ statusHTML = `
+ ⚙️
+
+ 處理中
+ 正在提交您的回饋...
+
+ `;
+ statusClass = 'status-processing';
+ break;
+
+ case 'feedback_submitted':
+ const timeStr = this.lastSubmissionTime ?
+ new Date(this.lastSubmissionTime).toLocaleTimeString() : '';
+ statusHTML = `
+ ✅
+
+ 回饋已提交
+ 等待下次 MCP 調用 ${timeStr ? `(${timeStr})` : ''}
+
+ `;
+ statusClass = 'status-submitted';
+ break;
+ }
+
+ statusElement.innerHTML = statusHTML;
+ statusElement.className = `feedback-status-indicator ${statusClass}`;
+
+ // 同步到合併模式的狀態指示器
+ this.syncFeedbackStatusToCombined();
}
setupWebSocket() {
+ // 確保 WebSocket URL 格式正確
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}/ws/${this.sessionId}`;
+ const host = window.location.host;
+ const wsUrl = `${protocol}//${host}/ws`;
+
+ console.log('嘗試連接 WebSocket:', wsUrl);
+ this.updateConnectionStatus('connecting', '連接中...');
try {
+ // 如果已有連接,先關閉
+ if (this.websocket) {
+ this.websocket.close();
+ this.websocket = null;
+ }
+
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
this.isConnected = true;
+ this.updateConnectionStatus('connected', '已連接');
console.log('WebSocket 連接已建立');
- this.updateConnectionStatus(true);
+
+ // 開始 WebSocket 心跳
+ this.startWebSocketHeartbeat();
+
+ // 連接成功後,請求會話狀態
+ this.requestSessionStatus();
};
this.websocket.onmessage = (event) => {
- const data = JSON.parse(event.data);
- this.handleWebSocketMessage(data);
+ try {
+ const data = JSON.parse(event.data);
+ this.handleWebSocketMessage(data);
+ } catch (error) {
+ console.error('解析 WebSocket 消息失敗:', error);
+ }
};
- this.websocket.onclose = () => {
+ this.websocket.onclose = (event) => {
this.isConnected = false;
- console.log('WebSocket 連接已關閉');
- this.updateConnectionStatus(false);
+ console.log('WebSocket 連接已關閉, code:', event.code, 'reason:', event.reason);
+
+ // 停止心跳
+ this.stopWebSocketHeartbeat();
+
+ if (event.code === 4004) {
+ // 沒有活躍會話
+ this.updateConnectionStatus('disconnected', '沒有活躍會話');
+ } else {
+ this.updateConnectionStatus('disconnected', '已斷開');
+
+ // 只有在非正常關閉時才重連
+ if (event.code !== 1000) {
+ console.log('3秒後嘗試重連...');
+ setTimeout(() => this.setupWebSocket(), 3000);
+ }
+ }
};
this.websocket.onerror = (error) => {
console.error('WebSocket 錯誤:', error);
- this.updateConnectionStatus(false);
+ this.updateConnectionStatus('error', '連接錯誤');
};
} catch (error) {
console.error('WebSocket 連接失敗:', error);
- this.updateConnectionStatus(false);
+ this.updateConnectionStatus('error', '連接失敗');
+ }
+ }
+
+ requestSessionStatus() {
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
+ this.websocket.send(JSON.stringify({
+ type: 'get_status'
+ }));
+ }
+ }
+
+ startWebSocketHeartbeat() {
+ // 清理現有心跳
+ this.stopWebSocketHeartbeat();
+
+ this.heartbeatInterval = setInterval(() => {
+ if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
+ this.websocket.send(JSON.stringify({
+ type: 'heartbeat',
+ tabId: this.tabManager.tabId,
+ timestamp: Date.now()
+ }));
+ }
+ }, this.heartbeatFrequency);
+
+ console.log(`💓 WebSocket 心跳已啟動,頻率: ${this.heartbeatFrequency}ms`);
+ }
+
+ stopWebSocketHeartbeat() {
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ console.log('💔 WebSocket 心跳已停止');
}
}
handleWebSocketMessage(data) {
+ console.log('收到 WebSocket 消息:', data);
+
switch (data.type) {
+ case 'connection_established':
+ console.log('WebSocket 連接確認');
+ break;
+ case 'heartbeat_response':
+ // 心跳回應,更新標籤頁活躍狀態
+ this.tabManager.updateLastActivity();
+ break;
case 'command_output':
this.appendCommandOutput(data.output);
break;
@@ -256,112 +756,311 @@ class FeedbackApp {
break;
case 'feedback_received':
console.log('回饋已收到');
- // 顯示成功訊息
- this.showSuccessMessage();
+ this.handleFeedbackReceived(data);
break;
- case 'session_timeout':
- console.log('會話超時:', data.message);
- this.handleSessionTimeout(data.message);
+ case 'status_update':
+ console.log('狀態更新:', data.status_info);
+ this.handleStatusUpdate(data.status_info);
+ break;
+ case 'session_updated':
+ console.log('會話已更新:', data.session_info);
+ this.handleSessionUpdated(data);
break;
default:
- console.log('未知的 WebSocket 消息:', data);
+ console.log('未處理的消息類型:', data.type);
}
}
- showSuccessMessage() {
- const successMessage = window.i18nManager ?
- window.i18nManager.t('feedback.success', '✅ 回饋提交成功!') :
- '✅ 回饋提交成功!';
- this.showMessage(successMessage, 'success');
+ handleFeedbackReceived(data) {
+ // 使用新的狀態管理系統
+ this.setFeedbackState('feedback_submitted');
+ this.lastSubmissionTime = Date.now();
+
+ // 顯示成功訊息
+ this.showSuccessMessage(data.message || '回饋提交成功!');
+
+ // 更新 AI 摘要區域顯示「已送出反饋」狀態
+ this.updateSummaryStatus('已送出反饋,等待下次 MCP 調用...');
+
+ // 重構:不再自動關閉頁面,保持持久性
+ console.log('反饋已提交,頁面保持開啟狀態');
}
- handleSessionTimeout(message) {
- console.log('處理會話超時:', message);
+ handleSessionUpdated(data) {
+ console.log('🔄 處理會話更新:', data.session_info);
+
+ // 顯示更新通知
+ this.showSuccessMessage(data.message || '會話已更新,正在刷新內容...');
+
+ // 重置回饋狀態為等待新回饋
+ this.setFeedbackState('waiting_for_feedback');
+
+ // 更新會話信息
+ if (data.session_info) {
+ this.currentSessionId = data.session_info.session_id;
+
+ // 更新頁面標題
+ if (data.session_info.project_directory) {
+ const projectName = data.session_info.project_directory.split(/[/\\]/).pop();
+ document.title = `MCP Feedback - ${projectName}`;
+ }
+
+ // 刷新頁面內容以顯示新的 AI 工作摘要
+ this.refreshPageContent();
+ }
+
+ console.log('✅ 會話更新處理完成');
+ }
+
+ async refreshPageContent() {
+ console.log('🔄 刷新頁面內容...');
+
+ try {
+ // 保存當前標籤頁狀態到 localStorage(防止重新載入時丟失)
+ if (this.tabManager) {
+ this.tabManager.updateLastActivity();
+ }
+
+ // 延遲一小段時間確保狀態保存完成
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // 重新載入頁面以獲取新的會話內容
+ window.location.reload();
+
+ } catch (error) {
+ console.error('刷新頁面內容失敗:', error);
+ // 備用方案:顯示提示讓用戶手動刷新
+ this.showMessage('請手動刷新頁面以查看新的 AI 工作摘要', 'info');
+ }
+ }
+
+ handleStatusUpdate(statusInfo) {
+ console.log('處理狀態更新:', statusInfo);
+
+ // 更新頁面標題顯示會話信息
+ if (statusInfo.project_directory) {
+ const projectName = statusInfo.project_directory.split(/[/\\]/).pop();
+ document.title = `MCP Feedback - ${projectName}`;
+ }
+
+ // 提取會話 ID(如果有的話)
+ const sessionId = statusInfo.session_id || this.currentSessionId;
+
+ // 根據狀態更新 UI 和狀態管理
+ switch (statusInfo.status) {
+ case 'feedback_submitted':
+ this.setFeedbackState('feedback_submitted', sessionId);
+ this.updateSummaryStatus('已送出反饋,等待下次 MCP 調用...');
+ this.updateConnectionStatus('connected', '已連接 - 反饋已提交');
+ break;
+
+ case 'active':
+ case 'waiting':
+ // 檢查是否是新會話
+ if (sessionId && sessionId !== this.currentSessionId) {
+ // 新會話開始,重置狀態
+ this.setFeedbackState('waiting_for_feedback', sessionId);
+ } else if (this.feedbackState !== 'feedback_submitted') {
+ // 如果不是已提交狀態,設置為等待狀態
+ this.setFeedbackState('waiting_for_feedback', sessionId);
+ }
+
+ if (statusInfo.status === 'waiting') {
+ this.updateSummaryStatus('等待用戶回饋...');
+ }
+ this.updateConnectionStatus('connected', '已連接 - 等待回饋');
+ break;
+
+ default:
+ this.updateConnectionStatus('connected', `已連接 - ${statusInfo.status || '未知狀態'}`);
+ }
+ }
+
+ disableSubmitButton() {
+ const submitBtn = document.getElementById('submitBtn');
+ if (submitBtn) {
+ submitBtn.disabled = true;
+ submitBtn.textContent = '✅ 已提交';
+ submitBtn.style.background = 'var(--success-color)';
+ }
+ }
+
+ enableSubmitButton() {
+ const submitBtn = document.getElementById('submitBtn');
+ if (submitBtn) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = '📤 提交回饋';
+ submitBtn.style.background = 'var(--accent-color)';
+ }
+ }
+
+ updateSummaryStatus(message) {
+ const summaryElements = document.querySelectorAll('.ai-summary-content');
+ summaryElements.forEach(element => {
+ element.innerHTML = `
+
+ ✅ ${message}
+
+ `;
+ });
+ }
+
+ showSuccessMessage(message = '✅ 回饋提交成功!頁面將保持開啟等待下次調用。') {
+ this.showMessage(message, 'success');
+ }
+
+ showMessage(message, type = 'info') {
+ // 創建消息元素
+ const messageDiv = document.createElement('div');
+ messageDiv.className = `message message-${type}`;
+ messageDiv.style.cssText = `
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ z-index: 1001;
+ padding: 12px 20px;
+ background: var(--success-color);
+ color: white;
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ max-width: 300px;
+ word-wrap: break-word;
+ `;
+ messageDiv.textContent = message;
- // 顯示超時訊息
- const timeoutMessage = message || (window.i18nManager ?
- window.i18nManager.t('session.timeout', '⏰ 會話已超時,介面將自動關閉') :
- '⏰ 會話已超時,介面將自動關閉');
+ document.body.appendChild(messageDiv);
- this.showMessage(timeoutMessage, 'warning');
-
- // 禁用所有互動元素
- this.disableAllInputs();
-
- // 3秒後自動關閉頁面
+ // 3秒後自動移除
setTimeout(() => {
- try {
- window.close();
- } catch (e) {
- // 如果無法關閉視窗(可能因為安全限制),重新載入頁面
- console.log('無法關閉視窗,重新載入頁面');
- window.location.reload();
+ if (messageDiv.parentNode) {
+ messageDiv.parentNode.removeChild(messageDiv);
}
}, 3000);
}
- disableAllInputs() {
- // 禁用所有輸入元素
- const inputs = document.querySelectorAll('input, textarea, button');
- inputs.forEach(input => {
- input.disabled = true;
- input.style.opacity = '0.5';
- });
-
- // 特別處理提交和取消按鈕
- const submitBtn = document.getElementById('submitBtn');
- const cancelBtn = document.getElementById('cancelBtn');
-
- if (submitBtn) {
- submitBtn.textContent = '⏰ 已超時';
- submitBtn.disabled = true;
+ updateConnectionStatus(status, text) {
+ if (this.connectionIndicator) {
+ this.connectionIndicator.className = `connection-indicator ${status}`;
}
-
- if (cancelBtn) {
- cancelBtn.textContent = '關閉中...';
- cancelBtn.disabled = true;
+ if (this.connectionText) {
+ this.connectionText.textContent = text;
}
}
- updateConnectionStatus(connected) {
- // 更新連接狀態指示器
- const elements = document.querySelectorAll('.connection-indicator');
- elements.forEach(el => {
- el.textContent = connected ? '✅ 已連接' : '❌ 未連接';
- el.className = `connection-indicator ${connected ? 'connected' : 'disconnected'}`;
- });
-
- // 更新命令執行按鈕狀態
- const runCommandBtn = document.getElementById('runCommandBtn');
- if (runCommandBtn) {
- runCommandBtn.disabled = !connected;
- runCommandBtn.textContent = connected ? '▶️ 執行' : '❌ 未連接';
+ showWaitingInterface() {
+ if (this.waitingContainer) {
+ this.waitingContainer.style.display = 'flex';
}
+ if (this.mainContainer) {
+ this.mainContainer.classList.remove('active');
+ }
+ }
+
+ showMainInterface() {
+ if (this.waitingContainer) {
+ this.waitingContainer.style.display = 'none';
+ }
+ if (this.mainContainer) {
+ this.mainContainer.classList.add('active');
+ }
+ }
+
+ async loadFeedbackInterface(sessionInfo) {
+ if (!this.mainContainer) return;
+
+ this.sessionInfo = sessionInfo;
+
+ // 載入完整的回饋界面
+ this.mainContainer.innerHTML = await this.generateFeedbackHTML(sessionInfo);
+
+ // 重新設置事件監聽器
+ this.setupFeedbackEventListeners();
+ }
+
+ async generateFeedbackHTML(sessionInfo) {
+ return `
+
+
+
+
+
+
+
AI 工作摘要
+
+
${sessionInfo.summary}
+
+
+
+
+
+
提供回饋
+
+
+
+
+
+
+
+
+
+
+
+
+
+
命令執行
+
+
+
+
+
+
+
+ `;
}
setupEventListeners() {
- // 提交回饋按鈕
- const submitBtn = document.getElementById('submitBtn');
- if (submitBtn) {
- submitBtn.addEventListener('click', () => this.submitFeedback());
+ // 提交和取消按鈕
+ if (this.submitBtn) {
+ this.submitBtn.addEventListener('click', () => this.submitFeedback());
}
- // 取消按鈕
- const cancelBtn = document.getElementById('cancelBtn');
- if (cancelBtn) {
- cancelBtn.addEventListener('click', () => this.cancelFeedback());
+ if (this.cancelBtn) {
+ this.cancelBtn.addEventListener('click', () => this.cancelFeedback());
}
- // 執行命令按鈕
- const runCommandBtn = document.getElementById('runCommandBtn');
- if (runCommandBtn) {
- runCommandBtn.addEventListener('click', () => this.runCommand());
+ // 命令執行
+ if (this.runCommandBtn) {
+ this.runCommandBtn.addEventListener('click', () => this.runCommand());
}
- // 命令輸入框 Enter 事件 - 修正為使用新的 input 元素
- const commandInput = document.getElementById('commandInput');
- if (commandInput) {
- commandInput.addEventListener('keydown', (e) => {
+ if (this.commandInput) {
+ this.commandInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.runCommand();
@@ -369,519 +1068,152 @@ class FeedbackApp {
});
}
- // 設置貼上監聽器
- this.setupPasteListener();
-
- // 設定切換
- this.setupSettingsListeners();
-
- // 設定重置按鈕(如果存在)
- const resetSettingsBtn = document.getElementById('resetSettingsBtn');
- if (resetSettingsBtn) {
- resetSettingsBtn.addEventListener('click', () => this.resetSettings());
- }
-
- // 圖片設定監聽器
- this.setupImageSettingsListeners();
- }
-
- setupSettingsListeners() {
- // 設置佈局模式單選按鈕監聽器
- const layoutModeRadios = document.querySelectorAll('input[name="layoutMode"]');
- layoutModeRadios.forEach(radio => {
- radio.addEventListener('change', (e) => {
- if (e.target.checked) {
- this.setLayoutMode(e.target.value);
- }
- });
- });
-
- // 設置自動關閉開關監聽器
- const autoCloseToggle = document.getElementById('autoCloseToggle');
- if (autoCloseToggle) {
- autoCloseToggle.addEventListener('click', () => {
- this.toggleAutoClose();
- });
- }
-
- // 設置語言選擇器
- const languageOptions = document.querySelectorAll('.language-option');
- languageOptions.forEach(option => {
- option.addEventListener('click', () => {
- const lang = option.getAttribute('data-lang');
- this.setLanguage(lang);
- });
- });
- }
-
- setupImageSettingsListeners() {
- // 圖片大小限制設定 - 原始分頁
- const imageSizeLimit = document.getElementById('imageSizeLimit');
- if (imageSizeLimit) {
- imageSizeLimit.addEventListener('change', (e) => {
- this.imageSizeLimit = parseInt(e.target.value);
- this.saveSettings();
- this.syncImageSettings();
- });
- }
-
- // Base64 詳細模式設定 - 原始分頁
- const enableBase64Detail = document.getElementById('enableBase64Detail');
- if (enableBase64Detail) {
- enableBase64Detail.addEventListener('change', (e) => {
- this.enableBase64Detail = e.target.checked;
- this.saveSettings();
- this.syncImageSettings();
- });
- }
-
- // 圖片大小限制設定 - 合併模式
- const combinedImageSizeLimit = document.getElementById('combinedImageSizeLimit');
- if (combinedImageSizeLimit) {
- combinedImageSizeLimit.addEventListener('change', (e) => {
- this.imageSizeLimit = parseInt(e.target.value);
- this.saveSettings();
- this.syncImageSettings();
- });
- }
-
- // Base64 詳細模式設定 - 合併模式
- const combinedEnableBase64Detail = document.getElementById('combinedEnableBase64Detail');
- if (combinedEnableBase64Detail) {
- combinedEnableBase64Detail.addEventListener('change', (e) => {
- this.enableBase64Detail = e.target.checked;
- this.saveSettings();
- this.syncImageSettings();
- });
- }
-
- // 相容性提示按鈕 - 原始分頁
- const enableBase64Hint = document.getElementById('enableBase64Hint');
- if (enableBase64Hint) {
- enableBase64Hint.addEventListener('click', () => {
- this.enableBase64Detail = true;
- this.saveSettings();
- this.syncImageSettings();
- this.hideCompatibilityHint();
- });
- }
-
- // 相容性提示按鈕 - 合併模式
- const combinedEnableBase64Hint = document.getElementById('combinedEnableBase64Hint');
- if (combinedEnableBase64Hint) {
- combinedEnableBase64Hint.addEventListener('click', () => {
- this.enableBase64Detail = true;
- this.saveSettings();
- this.syncImageSettings();
- this.hideCompatibilityHint();
- });
- }
- }
-
- syncImageSettings() {
- // 同步圖片大小限制設定
- const imageSizeLimit = document.getElementById('imageSizeLimit');
- const combinedImageSizeLimit = document.getElementById('combinedImageSizeLimit');
-
- if (imageSizeLimit) {
- imageSizeLimit.value = this.imageSizeLimit;
- }
- if (combinedImageSizeLimit) {
- combinedImageSizeLimit.value = this.imageSizeLimit;
- }
-
- // 同步 Base64 詳細模式設定
- const enableBase64Detail = document.getElementById('enableBase64Detail');
- const combinedEnableBase64Detail = document.getElementById('combinedEnableBase64Detail');
-
- if (enableBase64Detail) {
- enableBase64Detail.checked = this.enableBase64Detail;
- }
- if (combinedEnableBase64Detail) {
- combinedEnableBase64Detail.checked = this.enableBase64Detail;
- }
- }
-
- showCompatibilityHint() {
- const compatibilityHint = document.getElementById('compatibilityHint');
- const combinedCompatibilityHint = document.getElementById('combinedCompatibilityHint');
-
- if (compatibilityHint) {
- compatibilityHint.style.display = 'flex';
- }
- if (combinedCompatibilityHint) {
- combinedCompatibilityHint.style.display = 'flex';
- }
- }
-
- hideCompatibilityHint() {
- const compatibilityHint = document.getElementById('compatibilityHint');
- const combinedCompatibilityHint = document.getElementById('combinedCompatibilityHint');
-
- if (compatibilityHint) {
- compatibilityHint.style.display = 'none';
- }
- if (combinedCompatibilityHint) {
- combinedCompatibilityHint.style.display = 'none';
- }
- }
-
- setupTabs() {
- const tabButtons = document.querySelectorAll('.tab-button');
- const tabContents = document.querySelectorAll('.tab-content');
-
- tabButtons.forEach(button => {
- button.addEventListener('click', () => {
- const targetTab = button.getAttribute('data-tab');
-
- // 移除所有活躍狀態
- tabButtons.forEach(btn => btn.classList.remove('active'));
- tabContents.forEach(content => content.classList.remove('active'));
-
- // 添加活躍狀態
- button.classList.add('active');
- const targetContent = document.getElementById(`tab-${targetTab}`);
- if (targetContent) {
- targetContent.classList.add('active');
- }
-
- // 保存當前分頁
- localStorage.setItem('activeTab', targetTab);
- });
- });
-
- // 恢復上次的活躍分頁
- const savedTab = localStorage.getItem('activeTab');
- if (savedTab) {
- const savedButton = document.querySelector(`[data-tab="${savedTab}"]`);
- if (savedButton) {
- savedButton.click();
- }
- }
- }
-
- setupImageUpload() {
- const imageUploadArea = document.getElementById('imageUploadArea');
- const imageInput = document.getElementById('imageInput');
- const imagePreviewContainer = document.getElementById('imagePreviewContainer');
-
- if (!imageUploadArea || !imageInput || !imagePreviewContainer) {
- return;
- }
-
- // 原始分頁的圖片上傳
- this.setupImageUploadForArea(imageUploadArea, imageInput, imagePreviewContainer);
-
- // 合併模式的圖片上傳
- const combinedImageUploadArea = document.getElementById('combinedImageUploadArea');
- const combinedImageInput = document.getElementById('combinedImageInput');
- const combinedImagePreviewContainer = document.getElementById('combinedImagePreviewContainer');
-
- if (combinedImageUploadArea && combinedImageInput && combinedImagePreviewContainer) {
- this.setupImageUploadForArea(combinedImageUploadArea, combinedImageInput, combinedImagePreviewContainer);
- }
- }
-
- setupImageUploadForArea(uploadArea, input, previewContainer) {
- // 點擊上傳區域
- uploadArea.addEventListener('click', () => {
- input.click();
- });
-
- // 文件選擇
- input.addEventListener('change', (e) => {
- this.handleFileSelection(e.target.files);
- });
-
- // 拖放事件
- uploadArea.addEventListener('dragover', (e) => {
- e.preventDefault();
- uploadArea.classList.add('dragover');
- });
-
- uploadArea.addEventListener('dragleave', (e) => {
- e.preventDefault();
- uploadArea.classList.remove('dragover');
- });
-
- uploadArea.addEventListener('drop', (e) => {
- e.preventDefault();
- uploadArea.classList.remove('dragover');
- this.handleFileSelection(e.dataTransfer.files);
- });
- }
-
- setupKeyboardShortcuts() {
+ // 快捷鍵
document.addEventListener('keydown', (e) => {
- // Ctrl+Enter 或 Cmd+Enter 提交回饋
+ // Ctrl+Enter 提交回饋
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.submitFeedback();
}
- // ESC 取消
+ // Esc 取消
if (e.key === 'Escape') {
this.cancelFeedback();
}
});
- // 設置 Ctrl+V 貼上圖片監聽器
- this.setupPasteListener();
+ // 設定相關事件
+ this.setupSettingsEvents();
}
- setupPasteListener() {
- document.addEventListener('paste', (e) => {
- // 檢查是否在回饋文字框中
- const feedbackText = document.getElementById('feedbackText');
- const combinedFeedbackText = document.getElementById('combinedFeedbackText');
-
- const isInFeedbackInput = document.activeElement === feedbackText ||
- document.activeElement === combinedFeedbackText;
-
- if (isInFeedbackInput) {
- console.log('偵測到在回饋輸入框中貼上');
- this.handlePasteEvent(e);
- }
+ setupSettingsEvents() {
+ // 佈局模式切換
+ const layoutModeInputs = document.querySelectorAll('input[name="layoutMode"]');
+ layoutModeInputs.forEach(input => {
+ input.addEventListener('change', (e) => {
+ this.layoutMode = e.target.value;
+ this.applyLayoutMode();
+ this.saveSettings();
+ });
});
- }
- handlePasteEvent(e) {
- if (this.isHandlingPaste) {
- console.log('Paste event already being handled, skipping subsequent call.');
- return;
- }
- this.isHandlingPaste = true;
-
- const clipboardData = e.clipboardData || window.clipboardData;
- if (!clipboardData) {
- this.isHandlingPaste = false;
- return;
+ // 自動關閉切換
+ const autoCloseToggle = document.getElementById('autoCloseToggle');
+ if (autoCloseToggle) {
+ autoCloseToggle.addEventListener('click', () => {
+ this.autoClose = !this.autoClose;
+ autoCloseToggle.classList.toggle('active', this.autoClose);
+ this.saveSettings();
+ });
}
- const items = clipboardData.items;
- let hasImages = false;
-
- for (let i = 0; i < items.length; i++) {
- const item = items[i];
-
- if (item.type.indexOf('image') !== -1) {
- hasImages = true;
- e.preventDefault();
-
- const file = item.getAsFile();
- if (file) {
- console.log('從剪貼簿貼上圖片:', file.name, file.type);
- this.addImage(file);
- break;
- }
- }
- }
-
- if (hasImages) {
- console.log('已處理剪貼簿圖片');
- }
-
- setTimeout(() => {
- this.isHandlingPaste = false;
- }, 50);
- }
-
- setLayoutMode(mode) {
- if (this.layoutMode === mode) return;
-
- this.layoutMode = mode;
-
- // 保存設定到持久化存儲
- this.saveSettings();
-
- // 只更新分頁可見性,不強制切換分頁
- this.updateTabVisibility();
-
- // 數據同步
- if (mode === 'combined-vertical' || mode === 'combined-horizontal') {
- // 同步數據到合併模式
- this.syncDataToCombinedMode();
- } else {
- // 切換到分離模式時,同步數據回原始分頁
- this.syncDataFromCombinedMode();
- }
-
- // 更新合併分頁的佈局樣式
- this.updateCombinedModeLayout();
-
- console.log('佈局模式已切換至:', mode);
- }
-
- updateTabVisibility() {
- const feedbackTab = document.querySelector('[data-tab="feedback"]');
- const summaryTab = document.querySelector('[data-tab="summary"]');
- const combinedTab = document.querySelector('[data-tab="combined"]');
-
- if (this.layoutMode === 'separate') {
- // 分離模式:顯示原本的分頁,隱藏合併分頁
- if (feedbackTab) feedbackTab.classList.remove('hidden');
- if (summaryTab) summaryTab.classList.remove('hidden');
- if (combinedTab) {
- combinedTab.classList.add('hidden');
- // 只有在當前就在合併分頁時才切換到其他分頁
- if (combinedTab.classList.contains('active')) {
- this.switchToFeedbackTab();
- }
- }
- } else {
- // 合併模式:隱藏原本的分頁,顯示合併分頁
- if (feedbackTab) feedbackTab.classList.add('hidden');
- if (summaryTab) summaryTab.classList.add('hidden');
- if (combinedTab) {
- combinedTab.classList.remove('hidden');
- // 不要強制切換到合併分頁,讓用戶手動選擇
- }
- }
- }
-
- switchToFeedbackTab() {
- // 切換到回饋分頁的輔助方法
- const feedbackTab = document.querySelector('[data-tab="feedback"]');
- if (feedbackTab) {
- // 移除所有分頁按鈕的活躍狀態
- document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
- // 移除所有分頁內容的活躍狀態
- document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
-
- // 設定回饋分頁為活躍
- feedbackTab.classList.add('active');
- document.getElementById('tab-feedback').classList.add('active');
-
- console.log('已切換到回饋分頁');
- }
- }
-
- updateCombinedModeLayout() {
- const combinedTabContent = document.getElementById('tab-combined');
- if (!combinedTabContent) {
- console.warn('找不到合併分頁元素 #tab-combined');
- return;
- }
-
- // 移除所有佈局類
- combinedTabContent.classList.remove('combined-horizontal', 'combined-vertical');
-
- // 根據當前模式添加對應的佈局類
- if (this.layoutMode === 'combined-horizontal') {
- combinedTabContent.classList.add('combined-horizontal');
- } else if (this.layoutMode === 'combined-vertical') {
- combinedTabContent.classList.add('combined-vertical');
- }
- }
-
- setLanguage(language) {
- // 更新語言選擇器的活躍狀態
+ // 語言切換
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
- option.classList.remove('active');
- if (option.getAttribute('data-lang') === language) {
- option.classList.add('active');
- }
+ option.addEventListener('click', () => {
+ const lang = option.getAttribute('data-lang');
+ this.switchLanguage(lang);
+ });
});
- // 調用國際化管理器
- if (window.i18nManager) {
- window.i18nManager.setLanguage(language);
-
- // 語言切換後重新處理動態摘要內容
- setTimeout(() => {
- this.processDynamicSummaryContent();
- }, 200); // 增加延遲時間確保翻譯加載完成
+ // 重置設定
+ const resetBtn = document.getElementById('resetSettingsBtn');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', () => {
+ if (confirm('確定要重置所有設定嗎?')) {
+ this.resetSettings();
+ }
+ });
}
-
- console.log('語言已切換至:', language);
}
- handleFileSelection(files) {
- for (let file of files) {
- if (file.type.startsWith('image/')) {
- this.addImage(file);
+ // 移除重複的事件監聽器設置方法
+ // 所有事件監聽器已在 setupEventListeners() 中統一設置
+
+ submitFeedback() {
+ // 檢查是否可以提交回饋
+ if (!this.canSubmitFeedback()) {
+ console.log('⚠️ 無法提交回饋 - 當前狀態:', this.feedbackState);
+
+ if (this.feedbackState === 'feedback_submitted') {
+ this.showMessage('回饋已提交,請等待下次 MCP 調用', 'warning');
+ } else if (this.feedbackState === 'processing') {
+ this.showMessage('正在處理中,請稍候', 'warning');
+ } else if (!this.isConnected) {
+ this.showMessage('WebSocket 未連接', 'error');
}
- }
- }
-
- addImage(file) {
- // 檢查圖片大小限制
- if (this.imageSizeLimit > 0 && file.size > this.imageSizeLimit) {
- const limitMB = this.imageSizeLimit / (1024 * 1024);
- const fileMB = file.size / (1024 * 1024);
-
- const message = window.i18nManager ?
- window.i18nManager.t('images.sizeLimitExceeded', {
- filename: file.name,
- size: fileMB.toFixed(1) + 'MB',
- limit: limitMB.toFixed(0) + 'MB'
- }) :
- `圖片 ${file.name} 大小為 ${fileMB.toFixed(1)}MB,超過 ${limitMB.toFixed(0)}MB 限制!`;
-
- const advice = window.i18nManager ?
- window.i18nManager.t('images.sizeLimitExceededAdvice') :
- '建議使用圖片編輯軟體壓縮後再上傳,或調整圖片大小限制設定。';
-
- alert(message + '\n\n' + advice);
-
- // 顯示相容性提示(如果圖片上傳失敗)
- this.showCompatibilityHint();
return;
}
- const reader = new FileReader();
- reader.onload = (e) => {
- const imageData = {
- name: file.name,
- data: e.target.result.split(',')[1], // 移除 data:image/...;base64, 前綴
- size: file.size,
- type: file.type,
- preview: e.target.result
- };
-
- this.images.push(imageData);
- this.updateImagePreview();
- };
- reader.readAsDataURL(file);
- }
-
- updateImagePreview() {
- // 更新原始分頁的圖片預覽
- this.updateImagePreviewForContainer('imagePreviewContainer', 'imageUploadArea');
-
- // 更新合併模式的圖片預覽
- this.updateImagePreviewForContainer('combinedImagePreviewContainer', 'combinedImageUploadArea');
- }
-
- updateImagePreviewForContainer(containerId, uploadAreaId) {
- const container = document.getElementById(containerId);
- const uploadArea = document.getElementById(uploadAreaId);
- if (!container || !uploadArea) return;
-
- container.innerHTML = '';
-
- // 更新上傳區域的樣式
- if (this.images.length > 0) {
- uploadArea.classList.add('has-images');
+ // 根據當前佈局模式獲取回饋內容
+ let feedback = '';
+ if (this.layoutMode.startsWith('combined')) {
+ const combinedFeedbackInput = document.getElementById('combinedFeedbackText');
+ feedback = combinedFeedbackInput?.value.trim() || '';
} else {
- uploadArea.classList.remove('has-images');
+ const feedbackInput = document.getElementById('feedbackText');
+ feedback = feedbackInput?.value.trim() || '';
}
- this.images.forEach((image, index) => {
- const preview = document.createElement('div');
- preview.className = 'image-preview';
- preview.innerHTML = `
-
-
- `;
- container.appendChild(preview);
- });
+ if (!feedback && this.images.length === 0) {
+ this.showMessage('請提供回饋文字或上傳圖片', 'warning');
+ return;
+ }
+
+ // 設置處理狀態
+ this.setFeedbackState('processing');
+
+ try {
+ // 發送回饋
+ this.websocket.send(JSON.stringify({
+ type: 'submit_feedback',
+ feedback: feedback,
+ images: this.images,
+ settings: {
+ image_size_limit: this.imageSizeLimit,
+ enable_base64_detail: this.enableBase64Detail
+ }
+ }));
+
+ // 清空表單
+ this.clearFeedback();
+
+ console.log('📤 回饋已發送,等待服務器確認...');
+
+ } catch (error) {
+ console.error('❌ 發送回饋失敗:', error);
+ this.showMessage('發送失敗,請重試', 'error');
+ // 恢復到等待狀態
+ this.setFeedbackState('waiting_for_feedback');
+ }
}
- removeImage(index) {
- this.images.splice(index, 1);
+ clearFeedback() {
+ // 清空分離模式的回饋文字
+ if (this.feedbackText) {
+ this.feedbackText.value = '';
+ }
+
+ // 清空合併模式的回饋文字
+ const combinedFeedbackText = document.getElementById('combinedFeedbackText');
+ if (combinedFeedbackText) {
+ combinedFeedbackText.value = '';
+ }
+
+ this.images = [];
this.updateImagePreview();
+
+ // 同時清空合併模式的圖片預覽
+ const combinedImagePreviewContainer = document.getElementById('combinedImagePreviewContainer');
+ if (combinedImagePreviewContainer) {
+ combinedImagePreviewContainer.innerHTML = '';
+ }
+
+ // 重新啟用提交按鈕
+ if (this.submitBtn) {
+ this.submitBtn.disabled = false;
+ this.submitBtn.textContent = '提交回饋';
+ }
}
runCommand() {
@@ -898,10 +1230,7 @@ class FeedbackApp {
return;
}
- // 禁用輸入和按鈕
- this.disableCommandInput();
-
- // 顯示執行的命令,使用 terminal 風格
+ // 顯示執行的命令
this.appendCommandOutput(`$ ${command}\n`);
// 發送命令
@@ -913,27 +1242,18 @@ class FeedbackApp {
// 清空輸入框
commandInput.value = '';
-
- // 顯示正在執行的狀態
this.appendCommandOutput('[正在執行...]\n');
} catch (error) {
this.appendCommandOutput(`❌ 發送命令失敗: ${error.message}\n`);
- this.enableCommandInput();
}
}
- disableCommandInput() {
- const commandInput = document.getElementById('commandInput');
- const runCommandBtn = document.getElementById('runCommandBtn');
-
- if (commandInput) {
- commandInput.disabled = true;
- commandInput.style.opacity = '0.6';
- }
- if (runCommandBtn) {
- runCommandBtn.disabled = true;
- runCommandBtn.textContent = '⏳ 執行中...';
+ appendCommandOutput(output) {
+ const commandOutput = document.getElementById('commandOutput');
+ if (commandOutput) {
+ commandOutput.textContent += output;
+ commandOutput.scrollTop = commandOutput.scrollHeight;
}
}
@@ -941,639 +1261,399 @@ class FeedbackApp {
const commandInput = document.getElementById('commandInput');
const runCommandBtn = document.getElementById('runCommandBtn');
- if (commandInput) {
- commandInput.disabled = false;
- commandInput.style.opacity = '1';
- commandInput.focus(); // 自動聚焦到輸入框
- }
+ if (commandInput) commandInput.disabled = false;
if (runCommandBtn) {
runCommandBtn.disabled = false;
runCommandBtn.textContent = '▶️ 執行';
}
}
- appendCommandOutput(text) {
- const output = document.getElementById('commandOutput');
- if (output) {
- output.textContent += text;
- output.scrollTop = output.scrollHeight;
-
- // 添加時間戳(可選)
- if (text.includes('[命令完成') || text.includes('[錯誤:')) {
- const timestamp = new Date().toLocaleTimeString();
- output.textContent += `[${timestamp}]\n`;
- }
- }
- }
-
- submitFeedback() {
- let feedbackText;
-
- // 根據當前模式選擇正確的輸入框
- if (this.layoutMode === 'combined-vertical' || this.layoutMode === 'combined-horizontal') {
- const combinedFeedbackInput = document.getElementById('combinedFeedbackText');
- feedbackText = combinedFeedbackInput?.value.trim() || '';
- } else {
- const feedbackInput = document.getElementById('feedbackText');
- feedbackText = feedbackInput?.value.trim() || '';
- }
-
- const feedback = feedbackText;
-
- if (!feedback && this.images.length === 0) {
- alert('請提供回饋文字或上傳圖片');
- return;
- }
-
- if (!this.isConnected) {
- alert('WebSocket 未連接');
- return;
- }
-
- // 準備圖片數據
- const imageData = this.images.map(img => ({
- name: img.name,
- data: img.data,
- size: img.size,
- type: img.type
- }));
-
- // 發送回饋(包含圖片設定)
- this.websocket.send(JSON.stringify({
- type: 'submit_feedback',
- feedback: feedback,
- images: imageData,
- settings: {
- image_size_limit: this.imageSizeLimit,
- enable_base64_detail: this.enableBase64Detail
- }
- }));
-
- console.log('回饋已提交');
-
- // 根據設定決定是否自動關閉頁面
- if (this.autoClose) {
- // 稍微延遲一下讓用戶看到提交成功的反饋
- setTimeout(() => {
- window.close();
- }, 1000);
- }
- }
-
- cancelFeedback() {
- if (confirm('確定要取消回饋嗎?')) {
- window.close();
- }
- }
-
- toggleAutoClose() {
- this.autoClose = !this.autoClose;
-
- const toggle = document.getElementById('autoCloseToggle');
- if (toggle) {
- toggle.classList.toggle('active', this.autoClose);
- }
-
- // 保存設定到持久化存儲
- this.saveSettings();
-
- console.log('自動關閉頁面已', this.autoClose ? '啟用' : '停用');
- }
-
- syncDataToCombinedMode() {
- // 同步回饋文字
- const feedbackText = document.getElementById('feedbackText');
- const combinedFeedbackText = document.getElementById('combinedFeedbackText');
- if (feedbackText && combinedFeedbackText) {
- combinedFeedbackText.value = feedbackText.value;
- }
-
- // 同步摘要內容
- const summaryContent = document.getElementById('summaryContent');
- const combinedSummaryContent = document.getElementById('combinedSummaryContent');
- if (summaryContent && combinedSummaryContent) {
- combinedSummaryContent.textContent = summaryContent.textContent;
- }
- }
-
- syncDataFromCombinedMode() {
- // 同步回饋文字
- const feedbackText = document.getElementById('feedbackText');
- const combinedFeedbackText = document.getElementById('combinedFeedbackText');
- if (feedbackText && combinedFeedbackText) {
- feedbackText.value = combinedFeedbackText.value;
- }
- }
-
- syncLanguageSelector() {
- // 同步語言選擇器的狀態
- if (window.i18nManager) {
- const currentLang = window.i18nManager.currentLanguage;
-
- // 更新現代化語言選擇器
- const languageOptions = document.querySelectorAll('.language-option');
- languageOptions.forEach(option => {
- const lang = option.getAttribute('data-lang');
- option.classList.toggle('active', lang === currentLang);
- });
- }
- }
-
+ // 設定相關方法
async loadSettings() {
try {
- // 使用持久化設定系統載入設定
- const settings = await this.persistentSettings.loadSettings();
-
- // 載入佈局模式設定
- if (settings.layoutMode && ['separate', 'combined-vertical', 'combined-horizontal'].includes(settings.layoutMode)) {
- this.layoutMode = settings.layoutMode;
- } else {
- // 嘗試從舊的 localStorage 載入(向後兼容)
- const savedLayoutMode = localStorage.getItem('layoutMode');
- if (savedLayoutMode && ['separate', 'combined-vertical', 'combined-horizontal'].includes(savedLayoutMode)) {
- this.layoutMode = savedLayoutMode;
- } else {
- this.layoutMode = 'separate'; // 預設為分離模式
+ console.log('開始載入設定...');
+
+ // 優先從伺服器端載入設定
+ let settings = null;
+ try {
+ const response = await fetch('/api/load-settings');
+ if (response.ok) {
+ const serverSettings = await response.json();
+ if (Object.keys(serverSettings).length > 0) {
+ settings = serverSettings;
+ console.log('從伺服器端載入設定成功:', settings);
+
+ // 同步到 localStorage
+ localStorage.setItem('mcp-feedback-settings', JSON.stringify(settings));
+ }
+ }
+ } catch (serverError) {
+ console.warn('從伺服器端載入設定失敗,嘗試從 localStorage 載入:', serverError);
+ }
+
+ // 如果伺服器端載入失敗,回退到 localStorage
+ if (!settings) {
+ const localSettings = localStorage.getItem('mcp-feedback-settings');
+ if (localSettings) {
+ settings = JSON.parse(localSettings);
+ console.log('從 localStorage 載入設定:', settings);
}
}
- // 更新佈局模式單選按鈕狀態
- const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
- layoutRadios.forEach((radio, index) => {
- radio.checked = radio.value === this.layoutMode;
- });
+ // 應用設定
+ if (settings) {
+ this.layoutMode = settings.layoutMode || 'separate';
+ this.autoClose = settings.autoClose || false;
+ this.currentLanguage = settings.language || 'zh-TW';
+ this.imageSizeLimit = settings.imageSizeLimit || 0;
+ this.enableBase64Detail = settings.enableBase64Detail || false;
- // 載入自動關閉設定
- if (settings.autoClose !== undefined) {
- this.autoClose = settings.autoClose;
- } else {
- // 嘗試從舊的 localStorage 載入(向後兼容)
- const savedAutoClose = localStorage.getItem('autoClose');
- if (savedAutoClose !== null) {
- this.autoClose = savedAutoClose === 'true';
- } else {
- this.autoClose = true; // 預設啟用
+ // 處理 activeTab 設定
+ if (settings.activeTab) {
+ this.currentTab = settings.activeTab;
}
- }
- // 更新自動關閉開關狀態
- const autoCloseToggle = document.getElementById('autoCloseToggle');
- if (autoCloseToggle) {
- autoCloseToggle.classList.toggle('active', this.autoClose);
- }
-
- // 載入圖片設定
- if (settings.imageSizeLimit !== undefined) {
- this.imageSizeLimit = settings.imageSizeLimit;
+ console.log('設定載入完成,應用設定...');
+ this.applySettings();
} else {
- this.imageSizeLimit = 0; // 預設無限制
+ console.log('沒有找到設定,使用預設值');
+ this.applySettings();
}
-
- if (settings.enableBase64Detail !== undefined) {
- this.enableBase64Detail = settings.enableBase64Detail;
- } else {
- this.enableBase64Detail = false; // 預設關閉
- }
-
- // 載入超時設定
- if (settings.timeoutEnabled !== undefined) {
- this.timeoutEnabled = settings.timeoutEnabled;
- } else {
- this.timeoutEnabled = false; // 預設關閉
- }
-
- if (settings.timeoutDuration !== undefined) {
- this.timeoutDuration = settings.timeoutDuration;
- } else {
- this.timeoutDuration = 600; // 預設 10 分鐘
- }
-
- // 更新超時 UI
- this.updateTimeoutUI();
-
- // 同步圖片設定到 UI
- this.syncImageSettings();
-
- // 確保語言選擇器與當前語言同步
- this.syncLanguageSelector();
-
- // 應用佈局模式設定
- this.applyCombinedModeState();
-
- // 如果是合併模式,同步數據
- if (this.layoutMode === 'combined-vertical' || this.layoutMode === 'combined-horizontal') {
- this.syncDataToCombinedMode();
- }
-
- console.log('設定已載入:', {
- layoutMode: this.layoutMode,
- autoClose: this.autoClose,
- currentLanguage: window.i18nManager?.currentLanguage,
- source: settings.layoutMode ? 'persistent' : 'localStorage'
- });
-
} catch (error) {
- console.warn('載入設定時發生錯誤:', error);
+ console.error('載入設定失敗:', error);
// 使用預設設定
- this.layoutMode = 'separate';
- this.autoClose = true;
-
- // 仍然需要更新 UI 狀態
- const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
- layoutRadios.forEach((radio, index) => {
- radio.checked = radio.value === this.layoutMode;
- });
-
- const autoCloseToggle = document.getElementById('autoCloseToggle');
- if (autoCloseToggle) {
- autoCloseToggle.classList.toggle('active', this.autoClose);
- }
+ this.applySettings();
}
}
- applyCombinedModeState() {
- // 更新分頁可見性
- this.updateTabVisibility();
-
- // 更新合併分頁的佈局樣式
- if (this.layoutMode !== 'separate') {
- this.updateCombinedModeLayout();
- }
- }
-
- initCommandTerminal() {
- // 使用翻譯的歡迎信息
- if (window.i18nManager) {
- const welcomeTemplate = window.i18nManager.t('dynamic.terminalWelcome');
- if (welcomeTemplate && welcomeTemplate !== 'dynamic.terminalWelcome') {
- const welcomeMessage = welcomeTemplate.replace('{sessionId}', this.sessionId);
- this.appendCommandOutput(welcomeMessage);
- return;
- }
- }
-
- // 回退到預設歡迎信息(如果翻譯不可用)
- const welcomeMessage = `Welcome to Interactive Feedback Terminal
-========================================
-Project Directory: ${this.sessionId}
-Enter commands and press Enter or click Execute button
-Supported commands: ls, dir, pwd, cat, type, etc.
-
-$ `;
- this.appendCommandOutput(welcomeMessage);
- }
-
- async resetSettings() {
- // 確認重置
- const confirmMessage = window.i18nManager ?
- window.i18nManager.t('settings.resetConfirm', '確定要重置所有設定嗎?這將清除所有已保存的偏好設定。') :
- '確定要重置所有設定嗎?這將清除所有已保存的偏好設定。';
-
- if (!confirm(confirmMessage)) {
- return;
- }
-
- try {
- // 使用持久化設定系統清除設定
- await this.persistentSettings.clearSettings();
-
- // 重置本地變數
- this.layoutMode = 'separate';
- this.autoClose = true;
- this.imageSizeLimit = 0;
- this.enableBase64Detail = false;
- this.timeoutEnabled = false;
- this.timeoutDuration = 600;
-
- // 更新佈局模式單選按鈕狀態
- const layoutRadios = document.querySelectorAll('input[name="layoutMode"]');
- layoutRadios.forEach((radio, index) => {
- radio.checked = radio.value === this.layoutMode;
- });
-
- // 更新自動關閉開關狀態
- const autoCloseToggle = document.getElementById('autoCloseToggle');
- if (autoCloseToggle) {
- autoCloseToggle.classList.toggle('active', this.autoClose);
- }
-
- // 同步圖片設定到 UI
- this.syncImageSettings();
-
- // 更新超時 UI
- this.updateTimeoutUI();
- this.stopTimeout();
-
- // 確保語言選擇器與當前語言同步
- this.syncLanguageSelector();
-
- // 應用佈局模式設定
- this.applyCombinedModeState();
-
- // 切換到回饋分頁
- this.switchToFeedbackTab();
-
- // 顯示成功訊息
- const successMessage = window.i18nManager ?
- window.i18nManager.t('settings.resetSuccess', '設定已重置為預設值') :
- '設定已重置為預設值';
-
- this.showMessage(successMessage, 'success');
-
- console.log('設定已重置');
-
- } catch (error) {
- console.error('重置設定時發生錯誤:', error);
-
- // 顯示錯誤訊息
- const errorMessage = window.i18nManager ?
- window.i18nManager.t('settings.resetError', '重置設定時發生錯誤') :
- '重置設定時發生錯誤';
-
- this.showMessage(errorMessage, 'error');
- }
- }
-
- showMessage(text, type = 'info') {
- // 確保動畫樣式已添加
- if (!document.getElementById('slideInAnimation')) {
- const style = document.createElement('style');
- style.id = 'slideInAnimation';
- style.textContent = `
- @keyframes slideIn {
- from { transform: translateX(100%); opacity: 0; }
- to { transform: translateX(0); opacity: 1; }
- }
- `;
- document.head.appendChild(style);
- }
-
- // 創建訊息提示
- const message = document.createElement('div');
- const colors = {
- success: 'var(--success-color)',
- error: 'var(--error-color)',
- warning: 'var(--warning-color)',
- info: 'var(--info-color)'
- };
-
- message.style.cssText = `
- position: fixed;
- top: 20px;
- right: 20px;
- background: ${colors[type] || colors.info};
- color: white;
- padding: 12px 20px;
- border-radius: 6px;
- font-weight: 500;
- z-index: 10000;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
- animation: slideIn 0.3s ease-out;
- `;
- message.textContent = text;
-
- document.body.appendChild(message);
-
- // 3秒後移除訊息
- setTimeout(() => {
- if (message.parentNode) {
- message.remove();
- }
- }, 3000);
- }
-
- setupTimeoutControl() {
- // 設置超時開關監聽器
- const timeoutToggle = document.getElementById('timeoutToggle');
- if (timeoutToggle) {
- timeoutToggle.addEventListener('click', () => {
- this.toggleTimeout();
- });
- }
-
- // 設置超時時間輸入監聽器
- const timeoutDuration = document.getElementById('timeoutDuration');
- if (timeoutDuration) {
- timeoutDuration.addEventListener('change', (e) => {
- this.setTimeoutDuration(parseInt(e.target.value));
- });
- }
-
- // 更新界面狀態
- this.updateTimeoutUI();
- }
-
- startTimeoutIfEnabled() {
- // 如果啟用了超時,自動開始倒數計時
- if (this.timeoutEnabled) {
- this.startTimeout();
- console.log('頁面載入時自動開始倒數計時');
- }
- }
-
- toggleTimeout() {
- this.timeoutEnabled = !this.timeoutEnabled;
- this.updateTimeoutUI();
- this.saveSettings();
-
- if (this.timeoutEnabled) {
- this.startTimeout();
- } else {
- this.stopTimeout();
- }
-
- console.log('超時功能已', this.timeoutEnabled ? '啟用' : '停用');
- }
-
- setTimeoutDuration(seconds) {
- if (seconds >= 30 && seconds <= 7200) {
- this.timeoutDuration = seconds;
- this.saveSettings();
-
- // 如果正在倒數,重新開始
- if (this.timeoutEnabled && this.timeoutTimer) {
- this.startTimeout();
- }
-
- console.log('超時時間設置為', seconds, '秒');
- }
- }
-
- updateTimeoutUI() {
- const timeoutToggle = document.getElementById('timeoutToggle');
- const timeoutDuration = document.getElementById('timeoutDuration');
- const countdownDisplay = document.getElementById('countdownDisplay');
-
- if (timeoutToggle) {
- timeoutToggle.classList.toggle('active', this.timeoutEnabled);
- }
-
- if (timeoutDuration) {
- timeoutDuration.value = this.timeoutDuration;
- }
-
- if (countdownDisplay) {
- countdownDisplay.style.display = this.timeoutEnabled ? 'flex' : 'none';
- }
- }
-
- startTimeout() {
- this.stopTimeout(); // 先停止現有的計時器
-
- this.remainingSeconds = this.timeoutDuration;
-
- // 開始主要的超時計時器
- this.timeoutTimer = setTimeout(() => {
- this.handleTimeout();
- }, this.timeoutDuration * 1000);
-
- // 開始倒數顯示計時器
- this.countdownTimer = setInterval(() => {
- this.updateCountdownDisplay();
- }, 1000);
-
- this.updateCountdownDisplay();
- console.log('開始倒數計時:', this.timeoutDuration, '秒');
- }
-
- stopTimeout() {
- if (this.timeoutTimer) {
- clearTimeout(this.timeoutTimer);
- this.timeoutTimer = null;
- }
-
- if (this.countdownTimer) {
- clearInterval(this.countdownTimer);
- this.countdownTimer = null;
- }
-
- const countdownTimer = document.getElementById('countdownTimer');
- if (countdownTimer) {
- countdownTimer.textContent = '--:--';
- countdownTimer.className = 'countdown-timer';
- }
-
- console.log('倒數計時已停止');
- }
-
- updateCountdownDisplay() {
- this.remainingSeconds--;
-
- const countdownTimer = document.getElementById('countdownTimer');
- if (countdownTimer) {
- if (this.remainingSeconds <= 0) {
- countdownTimer.textContent = '00:00';
- countdownTimer.className = 'countdown-timer danger';
- } else {
- const minutes = Math.floor(this.remainingSeconds / 60);
- const seconds = this.remainingSeconds % 60;
- const timeText = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
- countdownTimer.textContent = timeText;
-
- // 根據剩餘時間調整樣式
- if (this.remainingSeconds <= 60) {
- countdownTimer.className = 'countdown-timer danger';
- } else if (this.remainingSeconds <= 300) {
- countdownTimer.className = 'countdown-timer warning';
- } else {
- countdownTimer.className = 'countdown-timer';
- }
- }
- }
-
- if (this.remainingSeconds <= 0) {
- clearInterval(this.countdownTimer);
- this.countdownTimer = null;
- }
- }
-
- handleTimeout() {
- console.log('用戶設置的超時時間已到,自動關閉介面');
-
- // 通知後端用戶超時
- this.notifyUserTimeout();
-
- // 顯示超時訊息
- const timeoutMessage = window.i18nManager ?
- window.i18nManager.t('timeout.expired', '⏰ 時間已到,介面將自動關閉') :
- '⏰ 時間已到,介面將自動關閉';
-
- this.showMessage(timeoutMessage, 'warning');
-
- // 禁用所有互動元素
- this.disableAllInputs();
-
- // 3秒後自動關閉頁面
- setTimeout(() => {
- try {
- window.close();
- } catch (e) {
- console.log('無法關閉視窗,重新載入頁面');
- window.location.reload();
- }
- }, 3000);
- }
-
- notifyUserTimeout() {
- // 通過 WebSocket 通知後端用戶設置的超時已到
- if (this.websocket && this.isConnected) {
- try {
- this.websocket.send(JSON.stringify({
- type: 'user_timeout',
- message: '用戶設置的超時時間已到'
- }));
- console.log('已通知後端用戶超時');
- } catch (error) {
- console.log('通知後端超時失敗:', error);
- }
- }
- }
-
- disableAllInputs() {
- // 禁用所有輸入元素
- const inputs = document.querySelectorAll('input, textarea, button, select');
- inputs.forEach(input => {
- input.disabled = true;
- });
-
- // 禁用超時控制
- const timeoutToggle = document.getElementById('timeoutToggle');
- if (timeoutToggle) {
- timeoutToggle.style.pointerEvents = 'none';
- timeoutToggle.style.opacity = '0.5';
- }
-
- console.log('所有輸入元素已禁用');
- }
-
async saveSettings() {
try {
const settings = {
layoutMode: this.layoutMode,
autoClose: this.autoClose,
+ language: this.currentLanguage,
imageSizeLimit: this.imageSizeLimit,
enableBase64Detail: this.enableBase64Detail,
- timeoutEnabled: this.timeoutEnabled,
- timeoutDuration: this.timeoutDuration,
- language: window.i18nManager?.currentLanguage || 'zh-TW',
- activeTab: localStorage.getItem('activeTab'),
- lastSaved: new Date().toISOString()
+ activeTab: this.currentTab
};
- await this.persistentSettings.saveSettings(settings);
+ console.log('保存設定:', settings);
- // 同時保存到 localStorage 作為備用(向後兼容)
- localStorage.setItem('layoutMode', this.layoutMode);
- localStorage.setItem('autoClose', this.autoClose.toString());
- localStorage.setItem('imageSizeLimit', this.imageSizeLimit.toString());
- localStorage.setItem('enableBase64Detail', this.enableBase64Detail.toString());
+ // 保存到 localStorage
+ localStorage.setItem('mcp-feedback-settings', JSON.stringify(settings));
- console.log('設定已保存:', settings);
+ // 同步保存到伺服器端
+ try {
+ const response = await fetch('/api/save-settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(settings)
+ });
+
+ if (response.ok) {
+ console.log('設定已同步到伺服器端');
+ } else {
+ console.warn('同步設定到伺服器端失敗:', response.status);
+ }
+ } catch (serverError) {
+ console.warn('同步設定到伺服器端時發生錯誤:', serverError);
+ }
} catch (error) {
- console.warn('保存設定時發生錯誤:', error);
+ console.error('保存設定失敗:', error);
}
}
+
+ applySettings() {
+ // 應用佈局模式
+ this.applyLayoutMode();
+
+ // 應用自動關閉設定
+ const autoCloseToggle = document.getElementById('autoCloseToggle');
+ if (autoCloseToggle) {
+ autoCloseToggle.classList.toggle('active', this.autoClose);
+ }
+
+ // 應用圖片設定
+ if (this.imageSizeLimitSelect) {
+ this.imageSizeLimitSelect.value = this.imageSizeLimit.toString();
+ }
+
+ if (this.enableBase64DetailCheckbox) {
+ this.enableBase64DetailCheckbox.checked = this.enableBase64Detail;
+ }
+ }
+
+ applyLayoutMode() {
+ const layoutModeInputs = document.querySelectorAll('input[name="layoutMode"]');
+ layoutModeInputs.forEach(input => {
+ input.checked = input.value === this.layoutMode;
+ });
+
+ // 應用佈局樣式
+ document.body.className = `layout-${this.layoutMode}`;
+
+ // 控制頁籤顯示/隱藏
+ this.updateTabVisibility();
+
+ // 同步合併佈局和分頁中的內容
+ this.syncCombinedLayoutContent();
+
+ // 如果是合併模式,確保內容同步
+ if (this.layoutMode.startsWith('combined')) {
+ this.setupCombinedModeSync();
+ // 如果當前頁籤不是合併模式,則切換到合併模式頁籤
+ if (this.currentTab !== 'combined') {
+ this.currentTab = 'combined';
+ }
+ } else {
+ // 分離模式時,如果當前頁籤是合併模式,則切換到回饋頁籤
+ if (this.currentTab === 'combined') {
+ this.currentTab = 'feedback';
+ }
+ }
+ }
+
+ updateTabVisibility() {
+ const combinedTab = document.querySelector('.tab-button[data-tab="combined"]');
+ const feedbackTab = document.querySelector('.tab-button[data-tab="feedback"]');
+ const summaryTab = document.querySelector('.tab-button[data-tab="summary"]');
+
+ if (this.layoutMode.startsWith('combined')) {
+ // 合併模式:顯示合併模式頁籤,隱藏回饋和AI摘要頁籤
+ if (combinedTab) combinedTab.style.display = 'inline-block';
+ if (feedbackTab) feedbackTab.style.display = 'none';
+ if (summaryTab) summaryTab.style.display = 'none';
+ } else {
+ // 分離模式:隱藏合併模式頁籤,顯示回饋和AI摘要頁籤
+ if (combinedTab) combinedTab.style.display = 'none';
+ if (feedbackTab) feedbackTab.style.display = 'inline-block';
+ if (summaryTab) summaryTab.style.display = 'inline-block';
+ }
+ }
+
+ syncCombinedLayoutContent() {
+ // 同步文字內容
+ const feedbackText = document.getElementById('feedbackText');
+ const combinedFeedbackText = document.getElementById('combinedFeedbackText');
+
+ if (feedbackText && combinedFeedbackText) {
+ // 雙向同步文字內容
+ if (feedbackText.value && !combinedFeedbackText.value) {
+ combinedFeedbackText.value = feedbackText.value;
+ } else if (combinedFeedbackText.value && !feedbackText.value) {
+ feedbackText.value = combinedFeedbackText.value;
+ }
+ }
+
+ // 同步圖片設定
+ this.syncImageSettings();
+
+ // 同步圖片內容
+ this.syncImageContent();
+ }
+
+ syncImageSettings() {
+ // 同步圖片大小限制設定
+ const imageSizeLimit = document.getElementById('imageSizeLimit');
+ const combinedImageSizeLimit = document.getElementById('combinedImageSizeLimit');
+
+ if (imageSizeLimit && combinedImageSizeLimit) {
+ if (imageSizeLimit.value !== combinedImageSizeLimit.value) {
+ combinedImageSizeLimit.value = imageSizeLimit.value;
+ }
+ }
+
+ // 同步 Base64 設定
+ const enableBase64Detail = document.getElementById('enableBase64Detail');
+ const combinedEnableBase64Detail = document.getElementById('combinedEnableBase64Detail');
+
+ if (enableBase64Detail && combinedEnableBase64Detail) {
+ combinedEnableBase64Detail.checked = enableBase64Detail.checked;
+ }
+ }
+
+ syncImageContent() {
+ // 同步圖片預覽內容
+ const imagePreviewContainer = document.getElementById('imagePreviewContainer');
+ const combinedImagePreviewContainer = document.getElementById('combinedImagePreviewContainer');
+
+ if (imagePreviewContainer && combinedImagePreviewContainer) {
+ combinedImagePreviewContainer.innerHTML = imagePreviewContainer.innerHTML;
+ }
+ }
+
+ setupCombinedModeSync() {
+ // 設置文字輸入的雙向同步
+ const feedbackText = document.getElementById('feedbackText');
+ const combinedFeedbackText = document.getElementById('combinedFeedbackText');
+
+ if (feedbackText && combinedFeedbackText) {
+ // 移除舊的事件監聽器(如果存在)
+ feedbackText.removeEventListener('input', this.syncToCombinetText);
+ combinedFeedbackText.removeEventListener('input', this.syncToSeparateText);
+
+ // 添加新的事件監聽器
+ this.syncToCombinetText = (e) => {
+ combinedFeedbackText.value = e.target.value;
+ };
+ this.syncToSeparateText = (e) => {
+ feedbackText.value = e.target.value;
+ };
+
+ feedbackText.addEventListener('input', this.syncToCombinetText);
+ combinedFeedbackText.addEventListener('input', this.syncToSeparateText);
+ }
+
+ // 設置圖片設定的同步
+ this.setupImageSettingsSync();
+
+ // 設置圖片上傳的同步
+ this.setupImageUploadSync();
+ }
+
+ setupImageSettingsSync() {
+ const imageSizeLimit = document.getElementById('imageSizeLimit');
+ const combinedImageSizeLimit = document.getElementById('combinedImageSizeLimit');
+ const enableBase64Detail = document.getElementById('enableBase64Detail');
+ const combinedEnableBase64Detail = document.getElementById('combinedEnableBase64Detail');
+
+ if (imageSizeLimit && combinedImageSizeLimit) {
+ imageSizeLimit.addEventListener('change', (e) => {
+ combinedImageSizeLimit.value = e.target.value;
+ this.imageSizeLimit = parseInt(e.target.value);
+ this.saveSettings();
+ });
+
+ combinedImageSizeLimit.addEventListener('change', (e) => {
+ imageSizeLimit.value = e.target.value;
+ this.imageSizeLimit = parseInt(e.target.value);
+ this.saveSettings();
+ });
+ }
+
+ if (enableBase64Detail && combinedEnableBase64Detail) {
+ enableBase64Detail.addEventListener('change', (e) => {
+ combinedEnableBase64Detail.checked = e.target.checked;
+ this.enableBase64Detail = e.target.checked;
+ this.saveSettings();
+ });
+
+ combinedEnableBase64Detail.addEventListener('change', (e) => {
+ enableBase64Detail.checked = e.target.checked;
+ this.enableBase64Detail = e.target.checked;
+ this.saveSettings();
+ });
+ }
+ }
+
+ setupImageUploadSync() {
+ // 設置合併模式的圖片上傳功能
+ const combinedImageInput = document.getElementById('combinedImageInput');
+ const combinedImageUploadArea = document.getElementById('combinedImageUploadArea');
+
+ if (combinedImageInput && combinedImageUploadArea) {
+ // 簡化的圖片上傳同步 - 只需要基本的事件監聽器
+ combinedImageInput.addEventListener('change', (e) => {
+ this.handleFileSelect(e.target.files);
+ });
+
+ combinedImageUploadArea.addEventListener('click', () => {
+ combinedImageInput.click();
+ });
+
+ // 拖放事件
+ combinedImageUploadArea.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ combinedImageUploadArea.classList.add('dragover');
+ });
+
+ combinedImageUploadArea.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ combinedImageUploadArea.classList.remove('dragover');
+ });
+
+ combinedImageUploadArea.addEventListener('drop', (e) => {
+ e.preventDefault();
+ combinedImageUploadArea.classList.remove('dragover');
+ this.handleFileSelect(e.dataTransfer.files);
+ });
+ }
+ }
+
+ resetSettings() {
+ localStorage.removeItem('mcp-feedback-settings');
+ this.layoutMode = 'separate';
+ this.autoClose = false;
+ this.currentLanguage = 'zh-TW';
+ this.imageSizeLimit = 0;
+ this.enableBase64Detail = false;
+ this.applySettings();
+ this.saveSettings();
+ }
+
+ switchLanguage(lang) {
+ this.currentLanguage = lang;
+
+ // 更新語言選項顯示
+ const languageOptions = document.querySelectorAll('.language-option');
+ languageOptions.forEach(option => {
+ option.classList.toggle('active', option.getAttribute('data-lang') === lang);
+ });
+
+ // 通知國際化系統
+ if (window.i18nManager) {
+ window.i18nManager.setLanguage(lang);
+ }
+
+ this.saveSettings();
+ }
+
+ handleCombinedMode() {
+ // 處理組合模式的特殊邏輯
+ console.log('切換到組合模式');
+
+ // 同步等待回饋狀態到合併模式
+ this.syncFeedbackStatusToCombined();
+
+ // 確保合併模式的佈局樣式正確應用
+ const combinedTab = document.getElementById('tab-combined');
+ if (combinedTab) {
+ combinedTab.classList.remove('combined-vertical', 'combined-horizontal');
+ if (this.layoutMode === 'combined-vertical') {
+ combinedTab.classList.add('combined-vertical');
+ } else if (this.layoutMode === 'combined-horizontal') {
+ combinedTab.classList.add('combined-horizontal');
+ }
+ }
+ }
+
+ syncFeedbackStatusToCombined() {
+ // 同步等待回饋狀態指示器到合併模式
+ const mainStatusIndicator = document.getElementById('feedbackStatusIndicator');
+ const combinedStatusIndicator = document.getElementById('combinedFeedbackStatusIndicator');
+
+ if (mainStatusIndicator && combinedStatusIndicator) {
+ // 複製狀態
+ combinedStatusIndicator.className = mainStatusIndicator.className;
+ combinedStatusIndicator.style.display = mainStatusIndicator.style.display;
+ combinedStatusIndicator.innerHTML = mainStatusIndicator.innerHTML;
+ }
+ }
+
+ showSuccessMessage() {
+ // 顯示成功提交的消息
+ const message = document.createElement('div');
+ message.className = 'success-message';
+ message.textContent = '回饋已成功提交!';
+ document.body.appendChild(message);
+
+ setTimeout(() => {
+ message.remove();
+ }, 3000);
+ }
}
-// 全域函數,供 HTML 中的 onclick 使用
-window.feedbackApp = null;
\ No newline at end of file
+// 注意:應用程式由模板中的 initializeApp() 函數初始化
+// 不在此處自動初始化,避免重複實例
diff --git a/src/mcp_feedback_enhanced/web/templates/feedback.html b/src/mcp_feedback_enhanced/web/templates/feedback.html
index 747425c..ff3635e 100644
--- a/src/mcp_feedback_enhanced/web/templates/feedback.html
+++ b/src/mcp_feedback_enhanced/web/templates/feedback.html
@@ -828,6 +828,33 @@
background: rgba(0, 122, 204, 0.15);
}
+ /* 佈局模式樣式 */
+
+ /* 預設分離模式 - 顯示回饋和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;
+ }
+
+
+
/* 合併模式分頁的水平佈局樣式 */
#tab-combined.active.combined-horizontal .combined-content {
display: flex !important;
@@ -1025,6 +1052,74 @@
.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;
+ }
@@ -1073,6 +1168,8 @@
+
+
@@ -1209,14 +1306,27 @@
💬 提供回饋
-
+
+
+
+
⏳
+
+ 等待您的回饋
+ 請提供您對 AI 工作成果的意見和建議
+
+
+
-
@@ -1510,8 +1620,8 @@
-
-
+
+
+
-