mirror of
https://github.com/Minidoracat/mcp-feedback-enhanced.git
synced 2025-07-27 10:42:25 +08:00
✨ 新增 Web UI 功能以支援 SSH 遠端開發,並整合命令執行與即時回饋收集。
This commit is contained in:
parent
b3b9620608
commit
f93a6f9d87
@ -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
103
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
|
# 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
447
static/style.css
Normal 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
245
templates/feedback.html
Normal 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
41
templates/index.html
Normal 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
239
test_web_ui.py
Normal 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
355
web_ui.py
Normal 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)
|
Loading…
x
Reference in New Issue
Block a user