From d5494943ddcee3b56ef21ef445963c26b2075a5d Mon Sep 17 00:00:00 2001 From: Minidoracat Date: Fri, 6 Jun 2025 16:44:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=E6=96=B0=E5=A2=9E=E5=A2=9E?= =?UTF-8?q?=E5=BC=B7=E7=89=88=20MCP=20=E6=B8=AC=E8=A9=A6=E7=B3=BB=E7=B5=B1?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E5=90=AB=E5=A4=9A=E5=A0=B4=E6=99=AF=E6=B8=AC?= =?UTF-8?q?=E8=A9=A6=E3=80=81=E5=A0=B1=E5=91=8A=E7=94=9F=E6=88=90=E5=8F=8A?= =?UTF-8?q?=E7=8B=80=E6=85=8B=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=B8=A6=E9=87=8D=E6=A7=8B=20Web=20UI=20=E4=BB=A5=E6=94=AF?= =?UTF-8?q?=E6=8F=B4=E5=96=AE=E4=B8=80=E6=B4=BB=E8=BA=8D=E6=9C=83=E8=A9=B1?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E3=80=82=E6=9B=B4=E6=96=B0=E7=9B=B8=E9=97=9C?= =?UTF-8?q?=E6=96=87=E6=AA=94=E5=8F=8A=E6=A8=A3=E5=BC=8F=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E4=BD=BF=E7=94=A8=E9=AB=94=E9=A9=97=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- debug_websocket.html | 184 ++ pyproject.toml | 1 + src/mcp_feedback_enhanced/__main__.py | 112 +- .../test_mcp_enhanced.py | 286 ++ src/mcp_feedback_enhanced/test_web_ui.py | 8 +- src/mcp_feedback_enhanced/testing/__init__.py | 37 + src/mcp_feedback_enhanced/testing/config.py | 133 + .../testing/mcp_client.py | 527 ++++ src/mcp_feedback_enhanced/testing/reporter.py | 447 +++ .../testing/scenarios.py | 469 +++ src/mcp_feedback_enhanced/testing/utils.py | 266 ++ .../testing/validators.py | 394 +++ src/mcp_feedback_enhanced/web/main.py | 300 +- .../web/models/feedback_session.py | 103 +- .../web/routes/main_routes.py | 262 +- .../web/static/js/app.js | 2772 +++++++++-------- .../web/templates/feedback.html | 124 +- .../web/templates/index.html | 277 +- 19 files changed, 5216 insertions(+), 1489 deletions(-) create mode 100644 debug_websocket.html create mode 100644 src/mcp_feedback_enhanced/test_mcp_enhanced.py create mode 100644 src/mcp_feedback_enhanced/testing/__init__.py create mode 100644 src/mcp_feedback_enhanced/testing/config.py create mode 100644 src/mcp_feedback_enhanced/testing/mcp_client.py create mode 100644 src/mcp_feedback_enhanced/testing/reporter.py create mode 100644 src/mcp_feedback_enhanced/testing/scenarios.py create mode 100644 src/mcp_feedback_enhanced/testing/utils.py create mode 100644 src/mcp_feedback_enhanced/testing/validators.py 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 測試報告 + + + +
+
+

🧪 MCP 測試報告

+
+ {status_icon} 測試完成 +
+

生成時間: {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", []) + + 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""" +
+ +
+

📊 性能統計

+
+
+

最快測試

+
{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')}
+
+
+
+ + +
+ + +""" + 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} +
+ ${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 ` +
+ +
+
+
+

MCP Feedback Enhanced

+
+
+ 專案目錄: ${sessionInfo.project_directory} +
+
+
+ + +
+

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 = ` - ${image.name} - - `; - 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 @@

💬 提供回饋

- + + + +
-
@@ -1510,8 +1620,8 @@
- - + + + - \ No newline at end of file + \ No newline at end of file