新增 Web UI 功能以支援 SSH 遠端開發,並整合命令執行與即時回饋收集。

This commit is contained in:
Minidoracat 2025-05-29 12:34:38 +08:00
parent b3b9620608
commit f93a6f9d87
7 changed files with 1434 additions and 0 deletions

View File

@ -8,4 +8,8 @@ dependencies = [
"fastmcp>=2.0.0", "fastmcp>=2.0.0",
"psutil>=7.0.0", "psutil>=7.0.0",
"pyside6>=6.8.2.1", "pyside6>=6.8.2.1",
"fastapi>=0.115.0",
"uvicorn>=0.30.0",
"jinja2>=3.1.0",
"websockets>=13.0.0",
] ]

103
server.py
View File

@ -15,7 +15,59 @@ from pydantic import Field
# The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81 # The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81
mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR") 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]: 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 # Create a temporary file for the feedback result
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
output_file = tmp.name output_file = tmp.name
@ -58,6 +110,57 @@ def launch_feedback_ui(project_directory: str, summary: str) -> dict[str, str]:
os.unlink(output_file) os.unlink(output_file)
raise e 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: def first_line(text: str) -> str:
return text.split("\n")[0].strip() return text.split("\n")[0].strip()

447
static/style.css Normal file
View File

@ -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;
}

245
templates/feedback.html Normal file
View File

@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Feedback MCP</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" type="image/png" href="/static/favicon.png">
</head>
<body>
<div class="container">
<h1>Interactive Feedback MCP</h1>
<div class="session-info">
<h2>專案資訊</h2>
<p><strong>工作目錄:</strong> <span id="project-dir">{{ project_directory }}</span></p>
<p><strong>任務描述:</strong> <span id="summary">{{ summary }}</span></p>
</div>
<div class="section">
<button id="toggle-command" class="toggle-btn">顯示命令區塊</button>
</div>
<div id="command-section" class="section command-section" style="display: none;">
<h3>命令執行</h3>
<div class="input-group">
<input type="text" id="command-input" placeholder="輸入要執行的命令...">
<button id="run-btn">執行</button>
<button id="stop-btn" style="display: none;">停止</button>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" id="auto-execute"> 下次自動執行
</label>
<button id="save-config">儲存設定</button>
</div>
<div class="console-section">
<div class="console-header">
<h4>控制台輸出</h4>
<button id="clear-logs">清除</button>
</div>
<div id="console" class="console"></div>
</div>
</div>
<div class="section feedback-section">
<h3>回饋意見</h3>
<p class="feedback-description">{{ summary }}</p>
<textarea id="feedback-input" placeholder="請在此輸入您的回饋意見... (Ctrl+Enter 提交)"></textarea>
<button id="submit-feedback">提交回饋 (Ctrl+Enter)</button>
</div>
<div class="footer">
<p>需要改進?聯繫 Fábio Ferreira 在 <a href="https://x.com/fabiomlferreira" target="_blank">X.com</a> 或訪問 <a href="https://dotcursorrules.com/" target="_blank">dotcursorrules.com</a></p>
</div>
</div>
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>正在處理...</p>
</div>
<script>
const sessionId = "{{ session_id }}";
const wsUrl = `ws://${window.location.host}/ws/${sessionId}`;
let socket;
let commandRunning = false;
// DOM elements
const toggleCommandBtn = document.getElementById('toggle-command');
const commandSection = document.getElementById('command-section');
const commandInput = document.getElementById('command-input');
const runBtn = document.getElementById('run-btn');
const stopBtn = document.getElementById('stop-btn');
const autoExecuteCheck = document.getElementById('auto-execute');
const saveConfigBtn = document.getElementById('save-config');
const console = document.getElementById('console');
const clearLogsBtn = document.getElementById('clear-logs');
const feedbackInput = document.getElementById('feedback-input');
const submitFeedbackBtn = document.getElementById('submit-feedback');
const loading = document.getElementById('loading');
// Initialize WebSocket connection
function initWebSocket() {
socket = new WebSocket(wsUrl);
socket.onopen = function() {
console.log('WebSocket 已連接');
};
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
};
socket.onclose = function() {
console.log('WebSocket 連接已關閉');
setTimeout(initWebSocket, 3000); // Reconnect after 3 seconds
};
socket.onerror = function(error) {
console.error('WebSocket 錯誤:', error);
};
}
function handleWebSocketMessage(data) {
switch(data.type) {
case 'init':
// Set initial values
commandInput.value = data.config.run_command || '';
autoExecuteCheck.checked = data.config.execute_automatically || false;
// Load existing logs
data.logs.forEach(log => {
appendToConsole(log);
});
break;
case 'log':
appendToConsole(data.data);
break;
case 'process_completed':
commandRunning = false;
updateRunButtonState();
feedbackInput.focus();
break;
case 'logs_cleared':
console.innerHTML = '';
break;
case 'feedback_submitted':
showLoading();
setTimeout(() => {
window.close();
}, 2000);
break;
}
}
function appendToConsole(text) {
const line = document.createElement('div');
line.className = 'console-line';
line.textContent = text;
console.appendChild(line);
console.scrollTop = console.scrollHeight;
}
function updateRunButtonState() {
if (commandRunning) {
runBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
runBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
function showLoading() {
loading.style.display = 'flex';
}
function hideLoading() {
loading.style.display = 'none';
}
// Event listeners
toggleCommandBtn.addEventListener('click', () => {
const isVisible = commandSection.style.display !== 'none';
commandSection.style.display = isVisible ? 'none' : 'block';
toggleCommandBtn.textContent = isVisible ? '顯示命令區塊' : '隱藏命令區塊';
});
runBtn.addEventListener('click', () => {
const command = commandInput.value.trim();
if (command) {
commandRunning = true;
updateRunButtonState();
socket.send(JSON.stringify({
type: 'run_command',
command: command
}));
}
});
stopBtn.addEventListener('click', () => {
commandRunning = false;
updateRunButtonState();
socket.send(JSON.stringify({
type: 'stop_command'
}));
});
commandInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
runBtn.click();
}
});
saveConfigBtn.addEventListener('click', () => {
socket.send(JSON.stringify({
type: 'update_config',
config: {
run_command: commandInput.value,
execute_automatically: autoExecuteCheck.checked
}
}));
// Visual feedback
saveConfigBtn.textContent = '已儲存';
setTimeout(() => {
saveConfigBtn.textContent = '儲存設定';
}, 1500);
});
clearLogsBtn.addEventListener('click', () => {
socket.send(JSON.stringify({
type: 'clear_logs'
}));
});
submitFeedbackBtn.addEventListener('click', () => {
const feedback = feedbackInput.value.trim();
socket.send(JSON.stringify({
type: 'submit_feedback',
feedback: feedback
}));
});
feedbackInput.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
submitFeedbackBtn.click();
}
});
// Initialize
initWebSocket();
feedbackInput.focus();
</script>
</body>
</html>

41
templates/index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Feedback MCP Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<h1>Interactive Feedback MCP</h1>
<div class="section">
<h2>服務器狀態</h2>
<p>🟢 MCP 服務器正在運行</p>
<p>等待來自 AI 助手的互動請求...</p>
</div>
<div class="section">
<h3>關於此服務</h3>
<p>這是一個 Model Context Protocol (MCP) 服務器,用於在 AI 輔助開發工具中提供人在回路的互動回饋功能。</p>
<p>當 AI 助手需要用戶回饋時,會自動在瀏覽器中開啟互動頁面。</p>
</div>
<div class="section">
<h3>功能特色</h3>
<ul style="color: var(--text-primary); margin-left: 20px;">
<li>🌐 Web UI 支援 SSH remote 開發</li>
<li>💻 即時命令執行和輸出顯示</li>
<li>💬 結構化回饋收集</li>
<li>⚙️ 專案特定的設定管理</li>
<li>🔄 WebSocket 即時通訊</li>
</ul>
</div>
<div class="footer">
<p>開發者: Fábio Ferreira | <a href="https://x.com/fabiomlferreira" target="_blank">X.com</a> | <a href="https://dotcursorrules.com/" target="_blank">dotcursorrules.com</a></p>
</div>
</div>
</body>
</html>

239
test_web_ui.py Normal file
View File

@ -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)

355
web_ui.py Normal file
View File

@ -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)