diff --git a/pyproject.toml b/pyproject.toml
index e8752ed..399f851 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,4 +8,8 @@ dependencies = [
"fastmcp>=2.0.0",
"psutil>=7.0.0",
"pyside6>=6.8.2.1",
+ "fastapi>=0.115.0",
+ "uvicorn>=0.30.0",
+ "jinja2>=3.1.0",
+ "websockets>=13.0.0",
]
diff --git a/server.py b/server.py
index f0070f0..bccc012 100644
--- a/server.py
+++ b/server.py
@@ -15,7 +15,59 @@ from pydantic import Field
# The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81
mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR")
+def is_ssh_session() -> bool:
+ """Check if we're running in an SSH session or remote environment"""
+ # Check for SSH environment variables
+ ssh_indicators = [
+ 'SSH_CONNECTION',
+ 'SSH_CLIENT',
+ 'SSH_TTY'
+ ]
+
+ for indicator in ssh_indicators:
+ if os.getenv(indicator):
+ return True
+
+ # Check if DISPLAY is not set (common in SSH without X11 forwarding)
+ if sys.platform.startswith('linux') and not os.getenv('DISPLAY'):
+ return True
+
+ # Check for other remote indicators
+ if os.getenv('TERM_PROGRAM') == 'vscode' and os.getenv('VSCODE_INJECTION') == '1':
+ # VSCode remote development
+ return True
+
+ return False
+
+def can_use_gui() -> bool:
+ """Check if GUI can be used in current environment"""
+ if is_ssh_session():
+ return False
+
+ try:
+ # Try to import Qt and check if display is available
+ if sys.platform == 'win32':
+ return True # Windows should generally support GUI
+ elif sys.platform == 'darwin':
+ return True # macOS should generally support GUI
+ else:
+ # Linux - check for DISPLAY
+ return bool(os.getenv('DISPLAY'))
+ except ImportError:
+ return False
+
def launch_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
+ """Launch appropriate UI based on environment"""
+
+ if can_use_gui():
+ # Use Qt GUI (original implementation)
+ return launch_qt_feedback_ui(project_directory, summary)
+ else:
+ # Use Web UI
+ return launch_web_feedback_ui(project_directory, summary)
+
+def launch_qt_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
+ """Original Qt GUI implementation"""
# Create a temporary file for the feedback result
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
output_file = tmp.name
@@ -58,6 +110,57 @@ def launch_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
os.unlink(output_file)
raise e
+def launch_web_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
+ """Launch Web UI implementation"""
+ try:
+ from web_ui import launch_web_feedback_ui as launch_web
+ return launch_web(project_directory, summary)
+ except ImportError as e:
+ # Fallback to command line if web UI fails
+ print(f"Web UI not available: {e}")
+ return launch_cli_feedback_ui(project_directory, summary)
+
+def launch_cli_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
+ """Simple command line fallback"""
+ print(f"\n{'='*60}")
+ print("Interactive Feedback MCP")
+ print(f"{'='*60}")
+ print(f"專案目錄: {project_directory}")
+ print(f"任務描述: {summary}")
+ print(f"{'='*60}")
+
+ # Ask for command to run
+ command = input("要執行的命令 (留空跳過): ").strip()
+ command_logs = ""
+
+ if command:
+ print(f"執行命令: {command}")
+ try:
+ result = subprocess.run(
+ command,
+ shell=True,
+ cwd=project_directory,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="ignore"
+ )
+ command_logs = f"$ {command}\n{result.stdout}{result.stderr}"
+ print(command_logs)
+ except Exception as e:
+ command_logs = f"$ {command}\nError: {str(e)}\n"
+ print(command_logs)
+
+ # Ask for feedback
+ print(f"\n{'='*60}")
+ print("請提供您的回饋意見:")
+ feedback = input().strip()
+
+ return {
+ "command_logs": command_logs,
+ "interactive_feedback": feedback
+ }
+
def first_line(text: str) -> str:
return text.split("\n")[0].strip()
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..c4da73a
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,447 @@
+/* Interactive Feedback MCP - Modern Dark Theme */
+:root {
+ --primary-color: #007acc;
+ --primary-hover: #005999;
+ --background-color: #1e1e1e;
+ --surface-color: #2d2d30;
+ --surface-hover: #383838;
+ --text-primary: #cccccc;
+ --text-secondary: #9e9e9e;
+ --text-accent: #007acc;
+ --border-color: #464647;
+ --success-color: #4caf50;
+ --warning-color: #ff9800;
+ --error-color: #f44336;
+ --console-bg: #1a1a1a;
+ --input-bg: #2d2d30;
+ --button-bg: #0e639c;
+ --button-hover-bg: #1177bb;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ background-color: var(--background-color);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+h1 {
+ text-align: center;
+ color: var(--text-accent);
+ margin-bottom: 30px;
+ font-size: 2.5em;
+ font-weight: 300;
+}
+
+h2, h3 {
+ color: var(--text-primary);
+ margin-bottom: 15px;
+}
+
+h3 {
+ font-size: 1.3em;
+ font-weight: 500;
+}
+
+.section {
+ background-color: var(--surface-color);
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ border: 1px solid var(--border-color);
+ transition: all 0.3s ease;
+}
+
+.section:hover {
+ background-color: var(--surface-hover);
+}
+
+.session-info {
+ background: linear-gradient(135deg, var(--surface-color), var(--surface-hover));
+ border-left: 4px solid var(--primary-color);
+}
+
+.session-info p {
+ margin-bottom: 8px;
+ font-size: 1.1em;
+}
+
+.session-info strong {
+ color: var(--text-accent);
+}
+
+.toggle-btn {
+ width: 100%;
+ background-color: var(--button-bg);
+ color: white;
+ border: none;
+ padding: 12px 20px;
+ border-radius: 6px;
+ font-size: 1.1em;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ margin-bottom: 10px;
+}
+
+.toggle-btn:hover {
+ background-color: var(--button-hover-bg);
+ transform: translateY(-1px);
+}
+
+.command-section {
+ animation: slideDown 0.3s ease-out;
+}
+
+@keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.input-group {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 15px;
+ align-items: center;
+}
+
+.input-group input {
+ flex: 1;
+ background-color: var(--input-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px 15px;
+ color: var(--text-primary);
+ font-size: 14px;
+ transition: all 0.3s ease;
+}
+
+.input-group input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 122, 204, 0.1);
+}
+
+.input-group button {
+ background-color: var(--button-bg);
+ color: white;
+ border: none;
+ padding: 12px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: all 0.3s ease;
+ min-width: 80px;
+}
+
+.input-group button:hover {
+ background-color: var(--button-hover-bg);
+ transform: translateY(-1px);
+}
+
+#stop-btn {
+ background-color: var(--error-color);
+}
+
+#stop-btn:hover {
+ background-color: #d32f2f;
+}
+
+.checkbox-group {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ margin-bottom: 20px;
+ padding: 10px 0;
+}
+
+.checkbox-group label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.checkbox-group input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ accent-color: var(--primary-color);
+}
+
+#save-config {
+ background-color: var(--success-color);
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+ transition: all 0.3s ease;
+}
+
+#save-config:hover {
+ background-color: #45a049;
+}
+
+.console-section {
+ margin-top: 20px;
+}
+
+.console-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.console-header h4 {
+ margin: 0;
+ color: var(--text-secondary);
+}
+
+#clear-logs {
+ background-color: var(--warning-color);
+ color: white;
+ border: none;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ transition: all 0.3s ease;
+}
+
+#clear-logs:hover {
+ background-color: #f57c00;
+}
+
+.console {
+ background-color: var(--console-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 15px;
+ height: 300px;
+ overflow-y: auto;
+ font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
+ font-size: 13px;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.console-line {
+ margin-bottom: 2px;
+ color: var(--text-primary);
+}
+
+.console::-webkit-scrollbar {
+ width: 8px;
+}
+
+.console::-webkit-scrollbar-track {
+ background: var(--surface-color);
+ border-radius: 4px;
+}
+
+.console::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 4px;
+}
+
+.console::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+.feedback-section {
+ background: linear-gradient(135deg, var(--surface-color), var(--surface-hover));
+ border-left: 4px solid var(--success-color);
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.feedback-description {
+ background-color: var(--input-bg);
+ padding: 15px;
+ border-radius: 6px;
+ margin-bottom: 15px;
+ border-left: 3px solid var(--primary-color);
+ font-style: italic;
+ color: var(--text-secondary);
+}
+
+#feedback-input {
+ flex-grow: 1;
+ min-height: 150px;
+ background-color: var(--input-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 15px;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-family: inherit;
+ resize: vertical;
+ transition: all 0.3s ease;
+ margin-bottom: 15px;
+}
+
+#feedback-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(0, 122, 204, 0.1);
+}
+
+#feedback-input::placeholder {
+ color: var(--text-secondary);
+}
+
+#submit-feedback {
+ background: linear-gradient(135deg, var(--success-color), #66bb6a);
+ color: white;
+ border: none;
+ padding: 15px 30px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 16px;
+ font-weight: 600;
+ transition: all 0.3s ease;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+#submit-feedback:hover {
+ background: linear-gradient(135deg, #45a049, #4caf50);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
+}
+
+.footer {
+ text-align: center;
+ margin-top: 30px;
+ padding: 20px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ border-top: 1px solid var(--border-color);
+}
+
+.footer a {
+ color: var(--text-accent);
+ text-decoration: none;
+ transition: color 0.3s ease;
+}
+
+.footer a:hover {
+ color: var(--primary-hover);
+ text-decoration: underline;
+}
+
+.loading {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.8);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+}
+
+.spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid var(--border-color);
+ border-top: 4px solid var(--primary-color);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 20px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.loading p {
+ color: var(--text-primary);
+ font-size: 18px;
+ font-weight: 500;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .container {
+ padding: 15px;
+ }
+
+ h1 {
+ font-size: 2em;
+ margin-bottom: 20px;
+ }
+
+ .input-group {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .input-group button {
+ margin-top: 10px;
+ }
+
+ .checkbox-group {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .console {
+ height: 200px;
+ }
+
+ #feedback-input {
+ min-height: 120px;
+ }
+}
+
+/* Smooth transitions for all interactive elements */
+* {
+ transition: color 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
+}
+
+/* Focus styles for accessibility */
+button:focus,
+input:focus,
+textarea:focus {
+ outline: 2px solid var(--primary-color);
+ outline-offset: 2px;
+}
+
+/* Custom selection colors */
+::selection {
+ background-color: var(--primary-color);
+ color: white;
+}
\ No newline at end of file
diff --git a/templates/feedback.html b/templates/feedback.html
new file mode 100644
index 0000000..38502ac
--- /dev/null
+++ b/templates/feedback.html
@@ -0,0 +1,245 @@
+
+
+
+
+
+ Interactive Feedback MCP
+
+
+
+
+
+
Interactive Feedback MCP
+
+
+
專案資訊
+
工作目錄: {{ project_directory }}
+
任務描述: {{ summary }}
+
+
+
+
+
+
+
+
命令執行
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
回饋意見
+
{{ summary }}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..ddce071
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ Interactive Feedback MCP Server
+
+
+
+
+
Interactive Feedback MCP
+
+
+
服務器狀態
+
🟢 MCP 服務器正在運行
+
等待來自 AI 助手的互動請求...
+
+
+
+
關於此服務
+
這是一個 Model Context Protocol (MCP) 服務器,用於在 AI 輔助開發工具中提供人在回路的互動回饋功能。
+
當 AI 助手需要用戶回饋時,會自動在瀏覽器中開啟互動頁面。
+
+
+
+
功能特色
+
+ - 🌐 Web UI 支援 SSH remote 開發
+ - 💻 即時命令執行和輸出顯示
+ - 💬 結構化回饋收集
+ - ⚙️ 專案特定的設定管理
+ - 🔄 WebSocket 即時通訊
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test_web_ui.py b/test_web_ui.py
new file mode 100644
index 0000000..19d2164
--- /dev/null
+++ b/test_web_ui.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""
+Test script for Interactive Feedback MCP Web UI
+"""
+import sys
+import threading
+import time
+import socket
+from pathlib import Path
+
+def find_free_port():
+ """Find a free port to use for testing"""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(('', 0))
+ s.listen(1)
+ port = s.getsockname()[1]
+ return port
+
+def test_web_ui(keep_running=False):
+ """Test the Web UI functionality"""
+
+ print("🧪 測試 Interactive Feedback MCP Web UI")
+ print("=" * 50)
+
+ # Test import
+ try:
+ from web_ui import WebUIManager, launch_web_feedback_ui
+ print("✅ Web UI 模組匯入成功")
+ except ImportError as e:
+ print(f"❌ Web UI 模組匯入失敗: {e}")
+ return False, None
+
+ # Find free port
+ try:
+ free_port = find_free_port()
+ print(f"🔍 找到可用端口: {free_port}")
+ except Exception as e:
+ print(f"❌ 尋找可用端口失敗: {e}")
+ return False, None
+
+ # Test manager creation
+ try:
+ manager = WebUIManager(port=free_port)
+ print("✅ WebUIManager 創建成功")
+ except Exception as e:
+ print(f"❌ WebUIManager 創建失敗: {e}")
+ return False, None
+
+ # Test server start (with timeout)
+ server_started = False
+ try:
+ print("🚀 啟動 Web 服務器...")
+
+ def start_server():
+ try:
+ manager.start_server()
+ return True
+ except Exception as e:
+ print(f"服務器啟動錯誤: {e}")
+ return False
+
+ # Start server in thread
+ server_thread = threading.Thread(target=start_server)
+ server_thread.daemon = True
+ server_thread.start()
+
+ # Wait a moment and test if server is responsive
+ time.sleep(3)
+
+ # Test if port is listening
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(1)
+ result = s.connect_ex((manager.host, manager.port))
+ if result == 0:
+ server_started = True
+ print("✅ Web 服務器啟動成功")
+ print(f"🌐 服務器運行在: http://{manager.host}:{manager.port}")
+ else:
+ print(f"❌ 無法連接到服務器端口 {manager.port}")
+
+ except Exception as e:
+ print(f"❌ Web 服務器啟動失敗: {e}")
+ return False, None
+
+ if not server_started:
+ print("❌ 服務器未能正常啟動")
+ return False, None
+
+ # Test session creation
+ session_info = None
+ try:
+ project_dir = str(Path.cwd())
+ summary = "測試 Web UI 功能"
+ session_id = manager.create_session(project_dir, summary)
+ session_info = {
+ 'manager': manager,
+ 'session_id': session_id,
+ 'url': f"http://{manager.host}:{manager.port}/session/{session_id}"
+ }
+ print(f"✅ 測試會話創建成功 (ID: {session_id[:8]}...)")
+ print(f"🔗 測試 URL: {session_info['url']}")
+ except Exception as e:
+ print(f"❌ 會話創建失敗: {e}")
+ return False, None
+
+ print("\n" + "=" * 50)
+ print("🎉 所有測試通過!Web UI 準備就緒")
+ print("📝 注意事項:")
+ print(" - Web UI 會在 SSH remote 環境下自動啟用")
+ print(" - 本地環境會繼續使用 Qt GUI")
+ print(" - 支援即時命令執行和 WebSocket 通訊")
+ print(" - 提供現代化的深色主題界面")
+
+ return True, session_info
+
+def test_environment_detection():
+ """Test environment detection logic"""
+ print("🔍 測試環境檢測功能")
+ print("-" * 30)
+
+ try:
+ from server import is_ssh_session, can_use_gui
+
+ ssh_detected = is_ssh_session()
+ gui_available = can_use_gui()
+
+ print(f"SSH 環境檢測: {'是' if ssh_detected else '否'}")
+ print(f"GUI 可用性: {'是' if gui_available else '否'}")
+
+ if ssh_detected:
+ print("✅ 將使用 Web UI (適合 SSH remote 開發)")
+ else:
+ print("✅ 將使用 Qt GUI (本地環境)")
+
+ return True
+
+ except Exception as e:
+ print(f"❌ 環境檢測失敗: {e}")
+ return False
+
+def test_mcp_integration():
+ """Test MCP server integration"""
+ print("\n🔧 測試 MCP 整合功能")
+ print("-" * 30)
+
+ try:
+ from server import interactive_feedback
+ print("✅ MCP 工具函數可用")
+
+ # Test would require actual MCP call, so just verify import
+ print("✅ 準備接受來自 AI 助手的調用")
+ return True
+
+ except Exception as e:
+ print(f"❌ MCP 整合測試失敗: {e}")
+ return False
+
+def interactive_demo(session_info):
+ """Run interactive demo with the Web UI"""
+ print(f"\n🌐 Web UI 持久化運行模式")
+ print("=" * 50)
+ print(f"服務器地址: http://{session_info['manager'].host}:{session_info['manager'].port}")
+ print(f"測試會話: {session_info['url']}")
+ print("\n📖 操作指南:")
+ print(" 1. 在瀏覽器中開啟上面的測試 URL")
+ print(" 2. 嘗試以下功能:")
+ print(" - 點擊 '顯示命令區塊' 按鈕")
+ print(" - 輸入命令如 'echo Hello World' 並執行")
+ print(" - 在回饋區域輸入文字")
+ print(" - 使用 Ctrl+Enter 提交回饋")
+ print(" 3. 測試 WebSocket 即時通訊功能")
+ print("\n⌨️ 控制選項:")
+ print(" - 按 Enter 繼續運行")
+ print(" - 輸入 'q' 或 'quit' 停止服務器")
+
+ while True:
+ try:
+ user_input = input("\n>>> ").strip().lower()
+ if user_input in ['q', 'quit', 'exit']:
+ print("🛑 停止服務器...")
+ break
+ elif user_input == '':
+ print(f"🔄 服務器持續運行在: {session_info['url']}")
+ print(" 瀏覽器應該仍可正常訪問")
+ else:
+ print("❓ 未知命令。按 Enter 繼續運行,或輸入 'q' 退出")
+ except KeyboardInterrupt:
+ print("\n🛑 收到中斷信號,停止服務器...")
+ break
+
+ print("✅ Web UI 測試完成")
+
+if __name__ == "__main__":
+ print("Interactive Feedback MCP - Web UI 測試")
+ print("=" * 60)
+
+ # Check if user wants persistent mode
+ persistent_mode = len(sys.argv) > 1 and sys.argv[1] in ['--persistent', '-p', '--demo']
+
+ if not persistent_mode:
+ print("💡 提示: 使用 'python test_web_ui.py --persistent' 啟動持久化測試模式")
+ print()
+
+ # Test environment detection
+ env_test = test_environment_detection()
+
+ # Test MCP integration
+ mcp_test = test_mcp_integration()
+
+ # Test Web UI
+ web_test, session_info = test_web_ui()
+
+ print("\n" + "=" * 60)
+ if env_test and mcp_test and web_test:
+ print("🎊 所有測試完成!準備使用 Interactive Feedback MCP")
+ print("\n📖 使用方法:")
+ print(" 1. 在 Cursor/Cline 中配置此 MCP 服務器")
+ print(" 2. AI 助手會自動調用 interactive_feedback 工具")
+ print(" 3. 根據環境自動選擇 GUI 或 Web UI")
+ print(" 4. 提供回饋後繼續工作流程")
+
+ print("\n✨ Web UI 新功能:")
+ print(" - 支援 SSH remote 開發環境")
+ print(" - 現代化深色主題界面")
+ print(" - WebSocket 即時通訊")
+ print(" - 自動瀏覽器啟動")
+ print(" - 命令執行和即時輸出")
+
+ if persistent_mode and session_info:
+ interactive_demo(session_info)
+ else:
+ print("\n✅ 測試完成 - 系統已準備就緒!")
+ if session_info:
+ print(f"💡 您可以現在就在瀏覽器中測試: {session_info['url']}")
+ print(" (服務器會繼續運行一小段時間)")
+ time.sleep(10) # Keep running for a short time for immediate testing
+ else:
+ print("❌ 部分測試失敗,請檢查錯誤信息")
+ sys.exit(1)
\ No newline at end of file
diff --git a/web_ui.py b/web_ui.py
new file mode 100644
index 0000000..98025c3
--- /dev/null
+++ b/web_ui.py
@@ -0,0 +1,355 @@
+# Interactive Feedback MCP Web UI
+# Developed by Fábio Ferreira (https://x.com/fabiomlferreira)
+# Web UI version for SSH remote development
+import os
+import sys
+import json
+import uuid
+import asyncio
+import webbrowser
+import threading
+import subprocess
+import psutil
+import time
+from typing import Dict, Optional, List
+from pathlib import Path
+
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+import uvicorn
+
+class WebFeedbackSession:
+ def __init__(self, session_id: str, project_directory: str, summary: str):
+ self.session_id = session_id
+ self.project_directory = project_directory
+ self.summary = summary
+ self.websocket: Optional[WebSocket] = None
+ self.feedback_result: Optional[str] = None
+ self.command_logs: List[str] = []
+ self.process: Optional[subprocess.Popen] = None
+ self.completed = False
+ self.config = {
+ "run_command": "",
+ "execute_automatically": False
+ }
+
+class WebUIManager:
+ def __init__(self, host: str = "127.0.0.1", port: int = 8765):
+ self.host = host
+ self.port = port
+ self.app = FastAPI(title="Interactive Feedback MCP")
+ self.sessions: Dict[str, WebFeedbackSession] = {}
+ self.server_process = None
+ self.setup_routes()
+
+ # Setup static files and templates
+ script_dir = Path(__file__).parent
+ static_dir = script_dir / "static"
+ templates_dir = script_dir / "templates"
+ static_dir.mkdir(exist_ok=True)
+ templates_dir.mkdir(exist_ok=True)
+
+ self.app.mount("/static", StaticFiles(directory=static_dir), name="static")
+ self.templates = Jinja2Templates(directory=templates_dir)
+
+ def setup_routes(self):
+ @self.app.get("/", response_class=HTMLResponse)
+ async def index(request: Request):
+ return self.templates.TemplateResponse("index.html", {"request": request})
+
+ @self.app.get("/session/{session_id}", response_class=HTMLResponse)
+ async def session_page(request: Request, session_id: str):
+ session = self.sessions.get(session_id)
+ if not session:
+ return HTMLResponse("Session not found", status_code=404)
+
+ return self.templates.TemplateResponse("feedback.html", {
+ "request": request,
+ "session_id": session_id,
+ "project_directory": session.project_directory,
+ "summary": session.summary
+ })
+
+ @self.app.websocket("/ws/{session_id}")
+ async def websocket_endpoint(websocket: WebSocket, session_id: str):
+ await websocket.accept()
+
+ session = self.sessions.get(session_id)
+ if not session:
+ await websocket.close(code=4000, reason="Session not found")
+ return
+
+ session.websocket = websocket
+
+ # Send initial data
+ await websocket.send_json({
+ "type": "init",
+ "project_directory": session.project_directory,
+ "summary": session.summary,
+ "config": session.config,
+ "logs": session.command_logs
+ })
+
+ try:
+ while True:
+ data = await websocket.receive_json()
+ await self.handle_websocket_message(session, data)
+
+ except WebSocketDisconnect:
+ session.websocket = None
+
+ @self.app.post("/api/complete/{session_id}")
+ async def complete_session(session_id: str, feedback_data: dict):
+ session = self.sessions.get(session_id)
+ if not session:
+ return {"error": "Session not found"}
+
+ session.feedback_result = feedback_data.get("feedback", "")
+ session.completed = True
+
+ return {"success": True}
+
+ async def handle_websocket_message(self, session: WebFeedbackSession, data: dict):
+ message_type = data.get("type")
+
+ if message_type == "run_command":
+ command = data.get("command", "")
+ await self.run_command(session, command)
+
+ elif message_type == "stop_command":
+ await self.stop_command(session)
+
+ elif message_type == "submit_feedback":
+ feedback = data.get("feedback", "")
+ session.feedback_result = feedback
+ session.completed = True
+
+ await session.websocket.send_json({
+ "type": "feedback_submitted",
+ "message": "Feedback submitted successfully"
+ })
+
+ elif message_type == "update_config":
+ session.config.update(data.get("config", {}))
+
+ elif message_type == "clear_logs":
+ session.command_logs.clear()
+ await session.websocket.send_json({
+ "type": "logs_cleared"
+ })
+
+ async def run_command(self, session: WebFeedbackSession, command: str):
+ if session.process:
+ await self.stop_command(session)
+
+ if not command.strip():
+ await session.websocket.send_json({
+ "type": "log",
+ "data": "Please enter a command to run\n"
+ })
+ return
+
+ session.command_logs.append(f"$ {command}\n")
+ await session.websocket.send_json({
+ "type": "log",
+ "data": f"$ {command}\n"
+ })
+
+ try:
+ session.process = subprocess.Popen(
+ command,
+ shell=True,
+ cwd=session.project_directory,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ encoding="utf-8",
+ errors="ignore"
+ )
+
+ # Start threads to read output
+ threading.Thread(
+ target=self.read_process_output,
+ args=(session, session.process.stdout),
+ daemon=True
+ ).start()
+
+ threading.Thread(
+ target=self.read_process_output,
+ args=(session, session.process.stderr),
+ daemon=True
+ ).start()
+
+ # Monitor process completion
+ threading.Thread(
+ target=self.monitor_process,
+ args=(session,),
+ daemon=True
+ ).start()
+
+ except Exception as e:
+ error_msg = f"Error running command: {str(e)}\n"
+ session.command_logs.append(error_msg)
+ await session.websocket.send_json({
+ "type": "log",
+ "data": error_msg
+ })
+
+ def read_process_output(self, session: WebFeedbackSession, pipe):
+ try:
+ for line in iter(pipe.readline, ""):
+ if not line:
+ break
+ session.command_logs.append(line)
+ if session.websocket:
+ # Use threading to send async message
+ threading.Thread(
+ target=self._send_websocket_message,
+ args=(session.websocket, {
+ "type": "log",
+ "data": line
+ }),
+ daemon=True
+ ).start()
+ except Exception:
+ pass
+
+ def monitor_process(self, session: WebFeedbackSession):
+ if session.process:
+ exit_code = session.process.wait()
+ completion_msg = f"\nProcess exited with code {exit_code}\n"
+ session.command_logs.append(completion_msg)
+
+ if session.websocket:
+ threading.Thread(
+ target=self._send_websocket_message,
+ args=(session.websocket, {
+ "type": "log",
+ "data": completion_msg
+ }),
+ daemon=True
+ ).start()
+
+ threading.Thread(
+ target=self._send_websocket_message,
+ args=(session.websocket, {
+ "type": "process_completed",
+ "exit_code": exit_code
+ }),
+ daemon=True
+ ).start()
+
+ session.process = None
+
+ def _send_websocket_message(self, websocket: WebSocket, message: dict):
+ """Helper to send websocket message from thread"""
+ try:
+ import asyncio
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(websocket.send_json(message))
+ loop.close()
+ except Exception:
+ pass
+
+ async def stop_command(self, session: WebFeedbackSession):
+ if session.process:
+ try:
+ # Kill process tree
+ parent = psutil.Process(session.process.pid)
+ for child in parent.children(recursive=True):
+ try:
+ child.kill()
+ except psutil.Error:
+ pass
+ parent.kill()
+ session.process = None
+
+ await session.websocket.send_json({
+ "type": "log",
+ "data": "\nProcess stopped\n"
+ })
+
+ except Exception as e:
+ await session.websocket.send_json({
+ "type": "log",
+ "data": f"\nError stopping process: {str(e)}\n"
+ })
+
+ def create_session(self, project_directory: str, summary: str) -> str:
+ session_id = str(uuid.uuid4())
+ session = WebFeedbackSession(session_id, project_directory, summary)
+ self.sessions[session_id] = session
+ return session_id
+
+ def start_server(self):
+ """Start the web server in a separate thread"""
+ if self.server_process is not None:
+ return # Server already running
+
+ def run_server():
+ uvicorn.run(
+ self.app,
+ host=self.host,
+ port=self.port,
+ log_level="error",
+ access_log=False
+ )
+
+ self.server_process = threading.Thread(target=run_server, daemon=True)
+ self.server_process.start()
+
+ # Wait a moment for server to start
+ time.sleep(1)
+
+ def open_browser(self, session_id: str):
+ """Open browser to the session page"""
+ url = f"http://{self.host}:{self.port}/session/{session_id}"
+ try:
+ webbrowser.open(url)
+ except Exception:
+ print(f"Please open your browser and navigate to: {url}")
+
+ def wait_for_feedback(self, session_id: str, timeout: int = 300) -> dict:
+ """Wait for user feedback with timeout"""
+ session = self.sessions.get(session_id)
+ if not session:
+ return {"command_logs": "", "interactive_feedback": "Session not found"}
+
+ # Wait for feedback with timeout
+ start_time = time.time()
+ while not session.completed:
+ if time.time() - start_time > timeout:
+ return {"command_logs": "", "interactive_feedback": "Timeout waiting for feedback"}
+ time.sleep(0.1)
+
+ result = {
+ "command_logs": "".join(session.command_logs),
+ "interactive_feedback": session.feedback_result or ""
+ }
+
+ # Clean up session
+ del self.sessions[session_id]
+
+ return result
+
+# Global instance
+web_ui_manager = WebUIManager()
+
+def launch_web_feedback_ui(project_directory: str, summary: str) -> dict:
+ """Launch web UI and wait for feedback"""
+
+ # Start server if not running
+ web_ui_manager.start_server()
+
+ # Create new session
+ session_id = web_ui_manager.create_session(project_directory, summary)
+
+ # Open browser
+ web_ui_manager.open_browser(session_id)
+
+ # Wait for feedback
+ return web_ui_manager.wait_for_feedback(session_id)
\ No newline at end of file