mirror of
https://github.com/Minidoracat/mcp-feedback-enhanced.git
synced 2025-07-27 10:42:25 +08:00
✨ 更新測試用例,新增對 timeout 和 force_web_ui 參數的測試,並改善環境檢測功能的輸出信息。重構 Web UI 以支援圖片上傳和回饋提交,提升用戶體驗。
This commit is contained in:
parent
918428dd45
commit
4bce2c30f2
@ -3,243 +3,825 @@
|
||||
<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">
|
||||
<title>互動式回饋收集 - Interactive Feedback MCP</title>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||
<style>
|
||||
/* ===== 基礎樣式 ===== */
|
||||
:root {
|
||||
--bg-primary: #2b2b2b;
|
||||
--bg-secondary: #2d2d30;
|
||||
--bg-tertiary: #1e1e1e;
|
||||
--surface-color: #2d2d30;
|
||||
--surface-hover: #383838;
|
||||
--border-color: #464647;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #9e9e9e;
|
||||
--primary-color: #007acc;
|
||||
--primary-hover: #005a9e;
|
||||
--success-color: #4caf50;
|
||||
--success-hover: #45a049;
|
||||
--error-color: #f44336;
|
||||
--error-hover: #d32f2f;
|
||||
--console-bg: #1e1e1e;
|
||||
--button-bg: #0e639c;
|
||||
--button-hover-bg: #005a9e;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== 標題樣式 ===== */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--surface-color), var(--surface-hover));
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: var(--primary-color);
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header .project-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== AI 工作摘要 ===== */
|
||||
.summary-section {
|
||||
background: linear-gradient(135deg, var(--surface-color), var(--surface-hover));
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
max-height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-section h2 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-content {
|
||||
background: var(--console-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== 分頁標籤 ===== */
|
||||
.tabs-container {
|
||||
flex: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background: var(--surface-color);
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 8px 8px 0 0;
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-button:hover:not(.active) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ===== 回饋分頁樣式 ===== */
|
||||
.feedback-section {
|
||||
flex: 2;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
.feedback-section h3 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feedback-textarea {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.feedback-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2);
|
||||
}
|
||||
|
||||
.feedback-textarea::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ===== 圖片上傳區域 ===== */
|
||||
.image-section {
|
||||
flex: 1;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background: var(--surface-color);
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image-section h3 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: var(--button-bg);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
background: var(--button-hover-bg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.upload-btn.success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.upload-btn.success:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.upload-btn.danger {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.upload-btn.danger:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: var(--surface-color);
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.image-preview-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-preview .remove-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.image-preview .remove-btn:hover {
|
||||
background: var(--error-hover);
|
||||
}
|
||||
|
||||
.image-status {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ===== 命令分頁樣式 ===== */
|
||||
.command-section {
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
background: var(--surface-color);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.command-section h3 {
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command-input-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.command-input {
|
||||
flex: 1;
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.command-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.run-btn:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
.command-output {
|
||||
flex: 1;
|
||||
background: var(--console-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* ===== 操作按鈕 ===== */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: var(--success-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: var(--error-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== 響應式設計 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tab-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 隱藏檔案輸入 ===== */
|
||||
#fileInput {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</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 class="header">
|
||||
<h1>🎯 互動式回饋收集</h1>
|
||||
<div class="project-info">專案目錄: {{ project_directory }}</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<button id="toggle-command" class="toggle-btn">顯示命令區塊</button>
|
||||
<!-- AI 工作摘要 -->
|
||||
<div class="summary-section">
|
||||
<h2>📋 AI 工作摘要</h2>
|
||||
<div class="summary-content">{{ summary }}</div>
|
||||
</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 class="tabs-container">
|
||||
<!-- 分頁按鈕 -->
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" onclick="switchTab('feedback')">💬 回饋</button>
|
||||
<button class="tab-button" onclick="switchTab('command')">⚡ 命令</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 id="feedback-tab" class="tab-content active">
|
||||
<div class="feedback-section">
|
||||
<h3>💬 您的回饋</h3>
|
||||
<textarea id="feedbackText" class="feedback-textarea"
|
||||
placeholder="請在這裡輸入您的回饋、建議或問題... 💡 小提示:按 Ctrl+Enter 可快速提交回饋"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="image-section">
|
||||
<h3>🖼️ 圖片附件(可選)</h3>
|
||||
<div class="upload-buttons">
|
||||
<button class="upload-btn" onclick="selectFiles()">📁 選擇文件</button>
|
||||
<button class="upload-btn success" onclick="pasteFromClipboard()">📋 剪貼板</button>
|
||||
<button class="upload-btn danger" onclick="clearAllImages()">❌ 清除</button>
|
||||
</div>
|
||||
<div class="drop-zone" id="dropZone">
|
||||
🎯 拖拽圖片到這裡 (PNG、JPG、JPEG、GIF、BMP、WebP)
|
||||
</div>
|
||||
<div class="image-preview-area" id="imagePreviewArea"></div>
|
||||
<div class="image-status" id="imageStatus">已選擇 0 張圖片</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 命令分頁 -->
|
||||
<div id="command-tab" class="tab-content">
|
||||
<div class="command-section">
|
||||
<h3>⚡ 命令執行</h3>
|
||||
<div class="command-input-area">
|
||||
<input type="text" id="commandInput" class="command-input"
|
||||
placeholder="輸入要執行的命令..."
|
||||
onkeypress="if(event.key==='Enter') runCommand()">
|
||||
<button class="run-btn" onclick="runCommand()">▶️ 執行</button>
|
||||
</div>
|
||||
<div id="commandOutput" class="command-output"></div>
|
||||
</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 class="action-buttons">
|
||||
<button class="action-btn cancel-btn" onclick="cancelFeedback()">❌ 取消</button>
|
||||
<button class="action-btn submit-btn" onclick="submitFeedback()">✅ 提交回饋</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>正在處理...</p>
|
||||
</div>
|
||||
<!-- 隱藏的檔案輸入 -->
|
||||
<input type="file" id="fileInput" multiple accept="image/*" onchange="handleFileSelect(event)">
|
||||
|
||||
<script>
|
||||
const sessionId = "{{ session_id }}";
|
||||
const wsUrl = `ws://${window.location.host}/ws/${sessionId}`;
|
||||
let socket;
|
||||
// ===== 全域變數 =====
|
||||
let ws = null;
|
||||
let images = [];
|
||||
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');
|
||||
// ===== WebSocket 連接 =====
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/{{ session_id }}`;
|
||||
|
||||
// Initialize WebSocket connection
|
||||
function initWebSocket() {
|
||||
socket = new WebSocket(wsUrl);
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = function() {
|
||||
console.log('WebSocket 已連接');
|
||||
ws.onopen = function() {
|
||||
console.log('WebSocket 連接成功');
|
||||
};
|
||||
|
||||
socket.onmessage = function(event) {
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
};
|
||||
|
||||
socket.onclose = function() {
|
||||
ws.onclose = function() {
|
||||
console.log('WebSocket 連接已關閉');
|
||||
setTimeout(initWebSocket, 3000); // Reconnect after 3 seconds
|
||||
};
|
||||
|
||||
socket.onerror = function(error) {
|
||||
ws.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;
|
||||
if (data.type === 'command_output') {
|
||||
appendCommandOutput(data.output);
|
||||
} else if (data.type === 'command_finished') {
|
||||
appendCommandOutput(`\n進程結束,返回碼: ${data.exit_code}\n`);
|
||||
commandRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendToConsole(text) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'console-line';
|
||||
line.textContent = text;
|
||||
console.appendChild(line);
|
||||
console.scrollTop = console.scrollHeight;
|
||||
// ===== 分頁切換 =====
|
||||
function switchTab(tabName) {
|
||||
// 隱藏所有分頁
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// 移除所有按鈕的活動狀態
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
// 顯示選中的分頁
|
||||
document.getElementById(tabName + '-tab').classList.add('active');
|
||||
|
||||
// 設置按鈕活動狀態
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
function updateRunButtonState() {
|
||||
if (commandRunning) {
|
||||
runBtn.style.display = 'none';
|
||||
stopBtn.style.display = 'inline-block';
|
||||
// ===== 圖片處理 =====
|
||||
function selectFiles() {
|
||||
document.getElementById('fileInput').click();
|
||||
}
|
||||
|
||||
function handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
async function pasteFromClipboard() {
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith('image/')) {
|
||||
const blob = await item.getType(type);
|
||||
const file = new File([blob], `clipboard_${Date.now()}.png`, { type });
|
||||
processFiles([file]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
alert('剪貼板中沒有圖片!');
|
||||
} catch (error) {
|
||||
console.error('剪貼板讀取失敗:', error);
|
||||
alert('無法從剪貼板讀取圖片');
|
||||
}
|
||||
}
|
||||
|
||||
function processFiles(files) {
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert(`檔案 ${file.name} 不是圖片格式!`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > 1024 * 1024) { // 1MB 限制
|
||||
alert(`圖片 ${file.name} 大小超過 1MB 限制!`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const imageData = {
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
data: e.target.result.split(',')[1] // 移除 data:image/xxx;base64, 前綴
|
||||
};
|
||||
|
||||
images.push(imageData);
|
||||
updateImagePreview();
|
||||
updateImageStatus();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function updateImagePreview() {
|
||||
const previewArea = document.getElementById('imagePreviewArea');
|
||||
previewArea.innerHTML = '';
|
||||
|
||||
images.forEach(img => {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'image-preview';
|
||||
preview.innerHTML = `
|
||||
<img src="data:${img.type};base64,${img.data}" alt="${img.name}" title="${img.name}">
|
||||
<button class="remove-btn" onclick="removeImage('${img.id}')" title="刪除圖片">×</button>
|
||||
`;
|
||||
previewArea.appendChild(preview);
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage(imageId) {
|
||||
images = images.filter(img => img.id != imageId);
|
||||
updateImagePreview();
|
||||
updateImageStatus();
|
||||
}
|
||||
|
||||
function clearAllImages() {
|
||||
if (images.length > 0) {
|
||||
if (confirm(`確定要清除所有 ${images.length} 張圖片嗎?`)) {
|
||||
images = [];
|
||||
updateImagePreview();
|
||||
updateImageStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateImageStatus() {
|
||||
const count = images.length;
|
||||
const totalSize = images.reduce((sum, img) => sum + img.size, 0);
|
||||
|
||||
let sizeStr;
|
||||
if (totalSize < 1024) {
|
||||
sizeStr = `${totalSize} B`;
|
||||
} else if (totalSize < 1024 * 1024) {
|
||||
sizeStr = `${(totalSize / 1024).toFixed(1)} KB`;
|
||||
} else {
|
||||
runBtn.style.display = 'inline-block';
|
||||
stopBtn.style.display = 'none';
|
||||
sizeStr = `${(totalSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
document.getElementById('imageStatus').textContent =
|
||||
count > 0 ? `已選擇 ${count} 張圖片 (總計 ${sizeStr})` : '已選擇 0 張圖片';
|
||||
}
|
||||
|
||||
// ===== 拖拽功能 =====
|
||||
function setupDragAndDrop() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropZone.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight() {
|
||||
dropZone.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function unhighlight() {
|
||||
dropZone.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
dropZone.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = Array.from(dt.files);
|
||||
processFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
loading.style.display = 'flex';
|
||||
}
|
||||
// ===== 命令執行 =====
|
||||
function runCommand() {
|
||||
const command = document.getElementById('commandInput').value.trim();
|
||||
if (!command) return;
|
||||
|
||||
function hideLoading() {
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
if (commandRunning) {
|
||||
alert('已有命令在執行中,請等待完成或停止當前命令');
|
||||
return;
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
toggleCommandBtn.addEventListener('click', () => {
|
||||
const isVisible = commandSection.style.display !== 'none';
|
||||
commandSection.style.display = isVisible ? 'none' : 'block';
|
||||
toggleCommandBtn.textContent = isVisible ? '顯示命令區塊' : '隱藏命令區塊';
|
||||
});
|
||||
appendCommandOutput(`$ ${command}\n`);
|
||||
|
||||
runBtn.addEventListener('click', () => {
|
||||
const command = commandInput.value.trim();
|
||||
if (command) {
|
||||
commandRunning = true;
|
||||
updateRunButtonState();
|
||||
socket.send(JSON.stringify({
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'run_command',
|
||||
command: command
|
||||
}));
|
||||
commandRunning = true;
|
||||
} else {
|
||||
appendCommandOutput('WebSocket 連接未建立\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopBtn.addEventListener('click', () => {
|
||||
commandRunning = false;
|
||||
updateRunButtonState();
|
||||
socket.send(JSON.stringify({
|
||||
type: 'stop_command'
|
||||
}));
|
||||
});
|
||||
function appendCommandOutput(text) {
|
||||
const output = document.getElementById('commandOutput');
|
||||
output.textContent += text;
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
commandInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
runBtn.click();
|
||||
// ===== 回饋提交 =====
|
||||
function submitFeedback() {
|
||||
const feedback = document.getElementById('feedbackText').value.trim();
|
||||
|
||||
if (!feedback && images.length === 0) {
|
||||
alert('請輸入回饋內容或上傳圖片!');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
saveConfigBtn.addEventListener('click', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'update_config',
|
||||
config: {
|
||||
run_command: commandInput.value,
|
||||
execute_automatically: autoExecuteCheck.checked
|
||||
}
|
||||
}));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'submit_feedback',
|
||||
feedback: feedback,
|
||||
images: images
|
||||
}));
|
||||
|
||||
// Visual feedback
|
||||
saveConfigBtn.textContent = '已儲存';
|
||||
setTimeout(() => {
|
||||
saveConfigBtn.textContent = '儲存設定';
|
||||
}, 1500);
|
||||
});
|
||||
alert('回饋已提交!感謝您的回饋。');
|
||||
window.close();
|
||||
} else {
|
||||
alert('WebSocket 連接異常,請重新整理頁面');
|
||||
}
|
||||
}
|
||||
|
||||
clearLogsBtn.addEventListener('click', () => {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'clear_logs'
|
||||
}));
|
||||
});
|
||||
function cancelFeedback() {
|
||||
if (confirm('確定要取消回饋嗎?')) {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
submitFeedbackBtn.addEventListener('click', () => {
|
||||
const feedback = feedbackInput.value.trim();
|
||||
socket.send(JSON.stringify({
|
||||
type: 'submit_feedback',
|
||||
feedback: feedback
|
||||
}));
|
||||
});
|
||||
|
||||
feedbackInput.addEventListener('keydown', (e) => {
|
||||
// ===== 快捷鍵支援 =====
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
submitFeedbackBtn.click();
|
||||
e.preventDefault();
|
||||
submitFeedback();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
initWebSocket();
|
||||
feedbackInput.focus();
|
||||
// ===== 初始化 =====
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
connectWebSocket();
|
||||
setupDragAndDrop();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -119,16 +119,16 @@ def test_environment_detection():
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
from server import is_ssh_session, can_use_gui
|
||||
from server import is_remote_environment, can_use_gui
|
||||
|
||||
ssh_detected = is_ssh_session()
|
||||
remote_detected = is_remote_environment()
|
||||
gui_available = can_use_gui()
|
||||
|
||||
print(f"SSH 環境檢測: {'是' if ssh_detected else '否'}")
|
||||
print(f"遠端環境檢測: {'是' if remote_detected else '否'}")
|
||||
print(f"GUI 可用性: {'是' if gui_available else '否'}")
|
||||
|
||||
if ssh_detected:
|
||||
print("✅ 將使用 Web UI (適合 SSH remote 開發)")
|
||||
if remote_detected:
|
||||
print("✅ 將使用 Web UI (適合遠端開發環境)")
|
||||
else:
|
||||
print("✅ 將使用 Qt GUI (本地環境)")
|
||||
|
||||
@ -147,6 +147,12 @@ def test_mcp_integration():
|
||||
from server import interactive_feedback
|
||||
print("✅ MCP 工具函數可用")
|
||||
|
||||
# Test timeout parameter
|
||||
print("✅ 支援 timeout 參數")
|
||||
|
||||
# Test force_web_ui parameter
|
||||
print("✅ 支援 force_web_ui 參數")
|
||||
|
||||
# Test would require actual MCP call, so just verify import
|
||||
print("✅ 準備接受來自 AI 助手的調用")
|
||||
return True
|
||||
@ -155,6 +161,67 @@ def test_mcp_integration():
|
||||
print(f"❌ MCP 整合測試失敗: {e}")
|
||||
return False
|
||||
|
||||
def test_new_parameters():
|
||||
"""Test new timeout and force_web_ui parameters"""
|
||||
print("\n🆕 測試新增參數功能")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
from server import interactive_feedback
|
||||
|
||||
# 測試參數是否存在
|
||||
import inspect
|
||||
sig = inspect.signature(interactive_feedback)
|
||||
|
||||
# 檢查 timeout 參數
|
||||
if 'timeout' in sig.parameters:
|
||||
timeout_param = sig.parameters['timeout']
|
||||
print(f"✅ timeout 參數存在,預設值: {timeout_param.default}")
|
||||
else:
|
||||
print("❌ timeout 參數不存在")
|
||||
return False
|
||||
|
||||
# 檢查 force_web_ui 參數
|
||||
if 'force_web_ui' in sig.parameters:
|
||||
force_web_ui_param = sig.parameters['force_web_ui']
|
||||
print(f"✅ force_web_ui 參數存在,預設值: {force_web_ui_param.default}")
|
||||
else:
|
||||
print("❌ force_web_ui 參數不存在")
|
||||
return False
|
||||
|
||||
print("✅ 所有新參數功能正常")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 新參數測試失敗: {e}")
|
||||
return False
|
||||
|
||||
def test_force_web_ui_mode():
|
||||
"""Test force web UI mode"""
|
||||
print("\n🌐 測試強制 Web UI 模式")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
from server import interactive_feedback, is_remote_environment, can_use_gui
|
||||
|
||||
# 顯示當前環境狀態
|
||||
is_remote = is_remote_environment()
|
||||
gui_available = can_use_gui()
|
||||
|
||||
print(f"當前環境 - 遠端: {is_remote}, GUI 可用: {gui_available}")
|
||||
|
||||
if not is_remote and gui_available:
|
||||
print("✅ 在本地 GUI 環境中可以使用 force_web_ui=True 強制使用 Web UI")
|
||||
print("💡 這對於測試 Web UI 功能非常有用")
|
||||
else:
|
||||
print("ℹ️ 當前環境會自動使用 Web UI")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 強制 Web UI 測試失敗: {e}")
|
||||
return False
|
||||
|
||||
def interactive_demo(session_info):
|
||||
"""Run interactive demo with the Web UI"""
|
||||
print(f"\n🌐 Web UI 持久化運行模式")
|
||||
@ -204,6 +271,12 @@ if __name__ == "__main__":
|
||||
# Test environment detection
|
||||
env_test = test_environment_detection()
|
||||
|
||||
# Test new parameters
|
||||
params_test = test_new_parameters()
|
||||
|
||||
# Test force web UI mode
|
||||
force_test = test_force_web_ui_mode()
|
||||
|
||||
# Test MCP integration
|
||||
mcp_test = test_mcp_integration()
|
||||
|
||||
@ -211,7 +284,7 @@ if __name__ == "__main__":
|
||||
web_test, session_info = test_web_ui()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
if env_test and mcp_test and web_test:
|
||||
if env_test and params_test and force_test and mcp_test and web_test:
|
||||
print("🎊 所有測試完成!準備使用 Interactive Feedback MCP")
|
||||
print("\n📖 使用方法:")
|
||||
print(" 1. 在 Cursor/Cline 中配置此 MCP 服務器")
|
||||
|
680
web_ui.py
680
web_ui.py
@ -1,6 +1,17 @@
|
||||
# Interactive Feedback MCP Web UI
|
||||
# Developed by Fábio Ferreira (https://x.com/fabiomlferreira)
|
||||
# Web UI version for SSH remote development
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
互動式回饋收集 Web UI
|
||||
=====================
|
||||
|
||||
基於 FastAPI 的 Web 用戶介面,專為 SSH 遠端開發環境設計。
|
||||
支援文字輸入、圖片上傳、命令執行等功能。
|
||||
|
||||
作者: Fábio Ferreira
|
||||
靈感來源: dotcursorrules.com
|
||||
增強功能: 圖片支援和現代化界面設計
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
@ -11,285 +22,327 @@ import threading
|
||||
import subprocess
|
||||
import psutil
|
||||
import time
|
||||
import base64
|
||||
import tempfile
|
||||
from typing import Dict, Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, UploadFile, File, Form
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import uvicorn
|
||||
|
||||
# ===== 常數定義 =====
|
||||
MAX_IMAGE_SIZE = 1 * 1024 * 1024 # 1MB 圖片大小限制
|
||||
SUPPORTED_IMAGE_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/bmp', 'image/webp'}
|
||||
TEMP_DIR = Path.home() / ".cache" / "interactive-feedback-mcp-web"
|
||||
|
||||
|
||||
# ===== Web 回饋會話類 =====
|
||||
class WebFeedbackSession:
|
||||
"""Web 回饋會話管理"""
|
||||
|
||||
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.images: List[dict] = []
|
||||
self.feedback_completed = threading.Event()
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self.completed = False
|
||||
self.config = {
|
||||
"run_command": "",
|
||||
"execute_automatically": False
|
||||
}
|
||||
self.command_logs = []
|
||||
|
||||
# 確保臨時目錄存在
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def wait_for_feedback(self, timeout: int = 600) -> dict:
|
||||
"""
|
||||
等待用戶回饋,包含圖片
|
||||
|
||||
Args:
|
||||
timeout: 超時時間(秒)
|
||||
|
||||
Returns:
|
||||
dict: 回饋結果
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def wait_in_thread():
|
||||
return self.feedback_completed.wait(timeout)
|
||||
|
||||
completed = await loop.run_in_executor(None, wait_in_thread)
|
||||
|
||||
if completed:
|
||||
return {
|
||||
"logs": "\n".join(self.command_logs),
|
||||
"interactive_feedback": self.feedback_result or "",
|
||||
"images": self.images
|
||||
}
|
||||
else:
|
||||
raise TimeoutError("等待用戶回饋超時")
|
||||
|
||||
async def submit_feedback(self, feedback: str, images: List[dict]):
|
||||
"""
|
||||
提交回饋和圖片
|
||||
|
||||
Args:
|
||||
feedback: 文字回饋
|
||||
images: 圖片列表
|
||||
"""
|
||||
self.feedback_result = feedback
|
||||
self.images = self._process_images(images)
|
||||
self.feedback_completed.set()
|
||||
|
||||
if self.websocket:
|
||||
try:
|
||||
await self.websocket.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def _process_images(self, images: List[dict]) -> List[dict]:
|
||||
"""
|
||||
處理圖片數據,轉換為統一格式
|
||||
|
||||
Args:
|
||||
images: 原始圖片數據列表
|
||||
|
||||
Returns:
|
||||
List[dict]: 處理後的圖片數據
|
||||
"""
|
||||
processed_images = []
|
||||
|
||||
for img in images:
|
||||
try:
|
||||
if not all(key in img for key in ["name", "data", "size"]):
|
||||
continue
|
||||
|
||||
# 檢查文件大小
|
||||
if img["size"] > MAX_IMAGE_SIZE:
|
||||
print(f"[DEBUG] 圖片 {img['name']} 超過大小限制,跳過")
|
||||
continue
|
||||
|
||||
# 解碼 base64 數據
|
||||
if isinstance(img["data"], str):
|
||||
try:
|
||||
image_bytes = base64.b64decode(img["data"])
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] 圖片 {img['name']} base64 解碼失敗: {e}")
|
||||
continue
|
||||
else:
|
||||
image_bytes = img["data"]
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
print(f"[DEBUG] 圖片 {img['name']} 數據為空,跳過")
|
||||
continue
|
||||
|
||||
processed_images.append({
|
||||
"name": img["name"],
|
||||
"data": image_bytes, # 保存原始 bytes 數據
|
||||
"size": len(image_bytes)
|
||||
})
|
||||
|
||||
print(f"[DEBUG] 圖片 {img['name']} 處理成功,大小: {len(image_bytes)} bytes")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] 圖片處理錯誤: {e}")
|
||||
continue
|
||||
|
||||
return processed_images
|
||||
|
||||
def add_log(self, log_entry: str):
|
||||
"""添加命令日誌"""
|
||||
self.command_logs.append(log_entry)
|
||||
|
||||
async def run_command(self, command: str):
|
||||
"""執行命令並透過 WebSocket 發送輸出"""
|
||||
if self.process:
|
||||
# 終止現有進程
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
except:
|
||||
try:
|
||||
self.process.kill()
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.project_directory,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# 在背景線程中讀取輸出
|
||||
def read_output():
|
||||
try:
|
||||
for line in iter(self.process.stdout.readline, ''):
|
||||
self.add_log(line.rstrip())
|
||||
if self.websocket:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.websocket.send_json({
|
||||
"type": "command_output",
|
||||
"output": line
|
||||
}),
|
||||
asyncio.get_event_loop()
|
||||
)
|
||||
|
||||
# 等待進程完成
|
||||
exit_code = self.process.wait()
|
||||
if self.websocket:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.websocket.send_json({
|
||||
"type": "command_finished",
|
||||
"exit_code": exit_code
|
||||
}),
|
||||
asyncio.get_event_loop()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"命令執行錯誤: {e}")
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
thread = threading.Thread(target=read_output, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"命令執行失敗: {str(e)}\n"
|
||||
self.add_log(error_msg)
|
||||
if self.websocket:
|
||||
await self.websocket.send_json({
|
||||
"type": "command_output",
|
||||
"output": error_msg
|
||||
})
|
||||
|
||||
|
||||
# ===== Web UI 管理器 =====
|
||||
class WebUIManager:
|
||||
"""Web UI 管理器"""
|
||||
|
||||
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.app = FastAPI(title="Interactive Feedback MCP Web UI")
|
||||
self.sessions: Dict[str, WebFeedbackSession] = {}
|
||||
self.server_process = None
|
||||
self.server_thread: Optional[threading.Thread] = 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):
|
||||
"""設置路由"""
|
||||
|
||||
# 確保靜態文件目錄存在
|
||||
static_dir = Path("static")
|
||||
templates_dir = Path("templates")
|
||||
|
||||
# 靜態文件
|
||||
if static_dir.exists():
|
||||
self.app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# 模板
|
||||
templates = Jinja2Templates(directory="templates") if templates_dir.exists() else None
|
||||
|
||||
@self.app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request):
|
||||
return self.templates.TemplateResponse("index.html", {"request": request})
|
||||
"""首頁"""
|
||||
if templates:
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
else:
|
||||
return HTMLResponse(self._get_simple_index_html())
|
||||
|
||||
@self.app.get("/session/{session_id}", response_class=HTMLResponse)
|
||||
async def session_page(request: Request, session_id: str):
|
||||
async def feedback_session(request: Request, session_id: str):
|
||||
"""回饋會話頁面"""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
return HTMLResponse("Session not found", status_code=404)
|
||||
return HTMLResponse("會話不存在", status_code=404)
|
||||
|
||||
return self.templates.TemplateResponse("feedback.html", {
|
||||
"request": request,
|
||||
"session_id": session_id,
|
||||
"project_directory": session.project_directory,
|
||||
"summary": session.summary
|
||||
})
|
||||
if templates:
|
||||
return templates.TemplateResponse("feedback.html", {
|
||||
"request": request,
|
||||
"session_id": session_id,
|
||||
"project_directory": session.project_directory,
|
||||
"summary": session.summary
|
||||
})
|
||||
else:
|
||||
return HTMLResponse(self._get_simple_feedback_html(session_id, session))
|
||||
|
||||
@self.app.websocket("/ws/{session_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, session_id: str):
|
||||
await websocket.accept()
|
||||
|
||||
"""WebSocket 連接處理"""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
await websocket.close(code=4000, reason="Session not found")
|
||||
await websocket.close(code=4004, reason="會話不存在")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
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:
|
||||
print(f"WebSocket 斷開連接: {session_id}")
|
||||
except Exception as e:
|
||||
print(f"WebSocket 錯誤: {e}")
|
||||
finally:
|
||||
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):
|
||||
"""處理 WebSocket 消息"""
|
||||
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)
|
||||
command = data.get("command", "").strip()
|
||||
if command:
|
||||
await session.run_command(command)
|
||||
|
||||
elif message_type == "submit_feedback":
|
||||
feedback = data.get("feedback", "")
|
||||
session.feedback_result = feedback
|
||||
session.completed = True
|
||||
images = data.get("images", [])
|
||||
await session.submit_feedback(feedback, images)
|
||||
|
||||
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"
|
||||
})
|
||||
elif message_type == "stop_command":
|
||||
if session.process:
|
||||
try:
|
||||
session.process.terminate()
|
||||
except:
|
||||
pass
|
||||
|
||||
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 get_session(self, session_id: str) -> Optional[WebFeedbackSession]:
|
||||
"""獲取會話"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def remove_session(self, session_id: str):
|
||||
"""移除會話"""
|
||||
if session_id in self.sessions:
|
||||
session = self.sessions[session_id]
|
||||
if session.process:
|
||||
try:
|
||||
session.process.terminate()
|
||||
except:
|
||||
pass
|
||||
del self.sessions[session_id]
|
||||
|
||||
def start_server(self):
|
||||
"""啟動伺服器"""
|
||||
def run_server():
|
||||
uvicorn.run(
|
||||
self.app,
|
||||
@ -299,57 +352,168 @@ class WebUIManager:
|
||||
access_log=False
|
||||
)
|
||||
|
||||
self.server_process = threading.Thread(target=run_server, daemon=True)
|
||||
self.server_process.start()
|
||||
self.server_thread = threading.Thread(target=run_server, daemon=True)
|
||||
self.server_thread.start()
|
||||
|
||||
# Wait a moment for server to start
|
||||
time.sleep(1)
|
||||
# 等待伺服器啟動
|
||||
time.sleep(2)
|
||||
|
||||
def open_browser(self, session_id: str):
|
||||
"""Open browser to the session page"""
|
||||
url = f"http://{self.host}:{self.port}/session/{session_id}"
|
||||
def open_browser(self, url: str):
|
||||
"""開啟瀏覽器"""
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
print(f"Please open your browser and navigate to: {url}")
|
||||
except Exception as e:
|
||||
print(f"無法開啟瀏覽器: {e}")
|
||||
|
||||
def wait_for_feedback(self, session_id: str, timeout: int = 300) -> dict:
|
||||
"""Wait for user feedback with timeout"""
|
||||
session = self.sessions.get(session_id)
|
||||
def _get_simple_index_html(self) -> str:
|
||||
"""簡單的首頁 HTML"""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Interactive Feedback MCP</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Interactive Feedback MCP Web UI</h1>
|
||||
<p>服務器運行中...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _get_simple_feedback_html(self, session_id: str, session: WebFeedbackSession) -> str:
|
||||
"""簡單的回饋頁面 HTML"""
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>回饋收集</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; background: #1e1e1e; color: white; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; }}
|
||||
textarea {{ width: 100%; height: 200px; background: #2d2d30; color: white; border: 1px solid #464647; }}
|
||||
button {{ background: #007acc; color: white; padding: 10px 20px; border: none; cursor: pointer; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>回饋收集</h1>
|
||||
<div>
|
||||
<h3>AI 工作摘要:</h3>
|
||||
<p>{session.summary}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>您的回饋:</h3>
|
||||
<textarea id="feedback" placeholder="請輸入您的回饋..."></textarea>
|
||||
</div>
|
||||
<button onclick="submitFeedback()">提交回饋</button>
|
||||
</div>
|
||||
<script>
|
||||
const ws = new WebSocket('ws://localhost:{self.port}/ws/{session_id}');
|
||||
function submitFeedback() {{
|
||||
const feedback = document.getElementById('feedback').value;
|
||||
ws.send(JSON.stringify({{
|
||||
type: 'submit_feedback',
|
||||
feedback: feedback,
|
||||
images: []
|
||||
}}));
|
||||
alert('回饋已提交!');
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ===== 全域管理器 =====
|
||||
_web_ui_manager: Optional[WebUIManager] = None
|
||||
|
||||
def get_web_ui_manager() -> WebUIManager:
|
||||
"""獲取全域 Web UI 管理器"""
|
||||
global _web_ui_manager
|
||||
if _web_ui_manager is None:
|
||||
_web_ui_manager = WebUIManager()
|
||||
_web_ui_manager.start_server()
|
||||
return _web_ui_manager
|
||||
|
||||
async def launch_web_feedback_ui(project_directory: str, summary: str) -> dict:
|
||||
"""啟動 Web 回饋 UI 並等待回饋"""
|
||||
manager = get_web_ui_manager()
|
||||
|
||||
# 創建會話
|
||||
session_id = manager.create_session(project_directory, summary)
|
||||
session_url = f"http://{manager.host}:{manager.port}/session/{session_id}"
|
||||
|
||||
print(f"🌐 Web UI 已啟動: {session_url}")
|
||||
|
||||
# 開啟瀏覽器
|
||||
manager.open_browser(session_url)
|
||||
|
||||
try:
|
||||
# 等待用戶回饋
|
||||
session = manager.get_session(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]
|
||||
raise RuntimeError("會話創建失敗")
|
||||
|
||||
result = await session.wait_for_feedback(timeout=600) # 10分鐘超時
|
||||
return result
|
||||
|
||||
# Global instance
|
||||
web_ui_manager = WebUIManager()
|
||||
except TimeoutError:
|
||||
print("⏰ 等待用戶回饋超時")
|
||||
return {
|
||||
"logs": "",
|
||||
"interactive_feedback": "回饋超時",
|
||||
"images": []
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ Web UI 錯誤: {e}")
|
||||
return {
|
||||
"logs": "",
|
||||
"interactive_feedback": f"錯誤: {str(e)}",
|
||||
"images": []
|
||||
}
|
||||
finally:
|
||||
# 清理會話
|
||||
manager.remove_session(session_id)
|
||||
|
||||
def launch_web_feedback_ui(project_directory: str, summary: str) -> dict:
|
||||
"""Launch web UI and wait for feedback"""
|
||||
def stop_web_ui():
|
||||
"""停止 Web UI"""
|
||||
global _web_ui_manager
|
||||
if _web_ui_manager:
|
||||
# 清理所有會話
|
||||
for session_id in list(_web_ui_manager.sessions.keys()):
|
||||
_web_ui_manager.remove_session(session_id)
|
||||
_web_ui_manager = None
|
||||
|
||||
# Start server if not running
|
||||
web_ui_manager.start_server()
|
||||
|
||||
# Create new session
|
||||
session_id = web_ui_manager.create_session(project_directory, summary)
|
||||
# ===== 主程式入口 =====
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
# Open browser
|
||||
web_ui_manager.open_browser(session_id)
|
||||
parser = argparse.ArgumentParser(description="啟動 Interactive Feedback MCP Web UI")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="主機地址")
|
||||
parser.add_argument("--port", type=int, default=8765, help="端口")
|
||||
parser.add_argument("--project-directory", default=os.getcwd(), help="專案目錄")
|
||||
parser.add_argument("--summary", default="測試 Web UI 功能", help="任務摘要")
|
||||
|
||||
# Wait for feedback
|
||||
return web_ui_manager.wait_for_feedback(session_id)
|
||||
args = parser.parse_args()
|
||||
|
||||
async def main():
|
||||
manager = WebUIManager(args.host, args.port)
|
||||
manager.start_server()
|
||||
|
||||
session_id = manager.create_session(args.project_directory, args.summary)
|
||||
session_url = f"http://{args.host}:{args.port}/session/{session_id}"
|
||||
|
||||
print(f"🌐 Web UI 已啟動: {session_url}")
|
||||
manager.open_browser(session_url)
|
||||
|
||||
try:
|
||||
# 保持運行
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Web UI 已停止")
|
||||
|
||||
asyncio.run(main())
|
Loading…
x
Reference in New Issue
Block a user