重構 webUI,優化版面以及設置功能選項

This commit is contained in:
Minidoracat 2025-06-03 06:50:19 +08:00
parent aff61718cd
commit 407b08b1bd
22 changed files with 3552 additions and 767 deletions

View File

@ -15,6 +15,7 @@ MCP Interactive Feedback Enhanced
- 命令執行功能
- 圖片上傳支援
- 現代化深色主題
- 重構的模組化架構
"""
__version__ = "2.0.16"
@ -23,13 +24,18 @@ __email__ = "minidora0702@gmail.com"
from .server import main as run_server
from .gui import feedback_ui
from .web_ui import WebUIManager
# 導入新的 Web UI 模組
from .web import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
# 主要導出介面
__all__ = [
"run_server",
"feedback_ui",
"WebUIManager",
"launch_web_feedback_ui",
"get_web_ui_manager",
"stop_web_ui",
"__version__",
"__author__",
]

View File

@ -514,10 +514,11 @@ async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: in
debug_log(f"啟動 Web UI 介面,超時時間: {timeout}")
try:
from .web_ui import get_web_ui_manager
# 使用新的 web 模組
from .web import launch_web_feedback_ui
# 直接運行 Web UI 會話
return await _run_web_ui_session(project_dir, summary, timeout)
return await launch_web_feedback_ui(project_dir, summary)
except ImportError as e:
debug_log(f"無法導入 Web UI 模組: {e}")
return {
@ -525,77 +526,14 @@ async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: in
"interactive_feedback": f"Web UI 模組導入失敗: {str(e)}",
"images": []
}
async def _run_web_ui_session(project_dir: str, summary: str, timeout: int) -> dict:
"""
運行 Web UI 會話
Args:
project_dir: 專案目錄路徑
summary: AI 工作摘要
timeout: 超時時間
Returns:
dict: 收集到的回饋資料
"""
from .web_ui import get_web_ui_manager
manager = get_web_ui_manager()
# 創建會話
session_id = manager.create_session(project_dir, summary)
session_url = f"http://{manager.host}:{manager.port}/session/{session_id}"
debug_log(f"Web UI 已啟動: {session_url}")
# 注意:不能使用 print() 污染 stdout會破壞 MCP 通信
# try:
# print(f"Web UI 已啟動: {session_url}")
# except UnicodeEncodeError:
# print(f"Web UI launched: {session_url}")
# 開啟瀏覽器
manager.open_browser(session_url)
try:
# 等待用戶回饋
session = manager.get_session(session_id)
if not session:
raise RuntimeError("會話創建失敗")
result = await session.wait_for_feedback(timeout=timeout)
debug_log(f"Web UI 回饋收集成功,超時時間: {timeout}")
return result
except TimeoutError:
timeout_msg = f"等待用戶回饋超時({timeout} 秒)"
debug_log(f"{timeout_msg}")
# 注意:不能使用 print() 污染 stdout會破壞 MCP 通信
# try:
# print(f"等待用戶回饋超時({timeout} 秒)")
# except UnicodeEncodeError:
# print(f"Feedback timeout ({timeout} seconds)")
return {
"command_logs": "",
"interactive_feedback": f"回饋超時({timeout} 秒)",
"images": []
}
except Exception as e:
error_msg = f"Web UI 錯誤: {e}"
debug_log(f"{error_msg}")
# 注意:不能使用 print() 污染 stdout會破壞 MCP 通信
# try:
# print(f"Web UI 錯誤: {e}")
# except UnicodeEncodeError:
# print(f"Web UI error: {e}")
return {
"command_logs": "",
"interactive_feedback": f"錯誤: {str(e)}",
"images": []
}
finally:
# 清理會話
manager.remove_session(session_id)
@mcp.tool()

View File

@ -38,8 +38,10 @@ from .i18n import t
# 嘗試導入 Web UI 模組
try:
from .web_ui import get_web_ui_manager, launch_web_feedback_ui
# 使用新的 web 模組
from .web import WebUIManager, launch_web_feedback_ui, get_web_ui_manager
WEB_UI_AVAILABLE = True
debug_log("✅ 使用新的 web 模組")
except ImportError as e:
debug_log(f"⚠️ 無法導入 Web UI 模組: {e}")
WEB_UI_AVAILABLE = False
@ -64,7 +66,8 @@ def test_web_ui(keep_running=False):
# Test import
try:
from .web_ui import WebUIManager, launch_web_feedback_ui
# 使用新的 web 模組
from .web import WebUIManager, launch_web_feedback_ui
debug_log("✅ Web UI 模組匯入成功")
except ImportError as e:
debug_log(f"❌ Web UI 模組匯入失敗: {e}")

View File

@ -0,0 +1,18 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 模組
===========
提供基於 FastAPI Web 用戶介面專為 SSH 遠端開發環境設計
支援文字輸入圖片上傳命令執行等功能並參考 GUI 的設計模式
"""
from .main import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
__all__ = [
'WebUIManager',
'launch_web_feedback_ui',
'get_web_ui_manager',
'stop_web_ui'
]

View File

@ -0,0 +1,138 @@
{
"app": {
"title": "MCP Interactive Feedback System",
"subtitle": "AI Assistant Interactive Feedback Platform",
"projectDirectory": "Project Directory"
},
"tabs": {
"feedback": "💬 Feedback",
"summary": "📋 AI Summary",
"commands": "⚡ Commands",
"command": "⚡ Commands",
"settings": "⚙️ Settings",
"combined": "📝 Combined Mode"
},
"feedback": {
"title": "💬 Provide Feedback",
"description": "Please provide your feedback on the AI assistant's work. You can enter text feedback and upload related images.",
"textLabel": "Text Feedback",
"placeholder": "Please enter your feedback here...",
"detailedPlaceholder": "Please enter your feedback here...\n\n💡 Tips:\n• Press Ctrl+Enter/Cmd+Enter (numpad supported) for quick submit\n• Press Ctrl+V/Cmd+V to paste clipboard images directly",
"imageLabel": "Image Attachments (Optional)",
"imageUploadText": "📎 Click to select images or drag and drop images here\nSupports PNG, JPG, JPEG, GIF, BMP, WebP formats",
"submit": "✅ Submit Feedback",
"uploading": "Uploading...",
"dragdrop": "Drag and drop images here or click to upload",
"selectfiles": "Select Files",
"processing": "Processing...",
"success": "Feedback submitted successfully!",
"error": "Error submitting feedback",
"shortcuts": {
"submit": "Ctrl+Enter to submit (Cmd+Enter on Mac, numpad supported)",
"clear": "Ctrl+Delete to clear (Cmd+Delete on Mac)",
"paste": "Ctrl+V to paste images (Cmd+V on Mac)"
}
},
"summary": {
"title": "📋 AI Work Summary",
"description": "Below is the work summary completed by the AI assistant. Please review carefully and provide your feedback.",
"placeholder": "AI work summary will be displayed here...",
"empty": "No summary content available",
"lastupdate": "Last updated",
"refresh": "Refresh"
},
"commands": {
"title": "⚡ Command Execution",
"description": "Execute commands here to verify results or collect more information. Commands will be executed in the project directory.",
"inputLabel": "Command Input",
"placeholder": "Enter command to execute...",
"execute": "▶️ Execute",
"runButton": "▶️ Execute",
"clear": "Clear",
"output": "Command Output",
"outputLabel": "Command Output",
"running": "Running...",
"completed": "Completed",
"error": "Execution Error",
"history": "Command History"
},
"command": {
"title": "⚡ Command Execution",
"description": "Execute commands here to verify results or collect more information. Commands will be executed in the project directory.",
"inputLabel": "Command Input",
"placeholder": "Enter command to execute...",
"execute": "▶️ Execute",
"runButton": "▶️ Execute",
"clear": "Clear",
"output": "Command Output",
"outputLabel": "Command Output",
"running": "Running...",
"completed": "Completed",
"error": "Execution Error",
"history": "Command History"
},
"settings": {
"title": "⚙️ Settings",
"description": "Adjust interface settings and preference options.",
"language": "Language",
"currentLanguage": "Current Language",
"languageDesc": "Select interface display language",
"interface": "Interface Settings",
"combinedMode": "Combined Mode",
"combinedModeDesc": "Merge AI summary and feedback input in the same tab",
"autoClose": "Auto Close Page",
"autoCloseDesc": "Automatically close page after submitting feedback",
"theme": "Theme",
"notifications": "Notifications",
"advanced": "Advanced Settings",
"save": "Save Settings",
"reset": "Reset",
"timeout": "Connection Timeout (seconds)",
"autorefresh": "Auto Refresh",
"debug": "Debug Mode"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "Dark",
"light": "Light",
"auto": "Auto"
},
"status": {
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected",
"reconnecting": "Reconnecting...",
"error": "Connection Error"
},
"notifications": {
"feedback_sent": "Feedback sent",
"command_executed": "Command executed",
"settings_saved": "Settings saved",
"connection_lost": "Connection lost",
"connection_restored": "Connection restored"
},
"errors": {
"connection_failed": "Connection failed",
"upload_failed": "Upload failed",
"command_failed": "Command execution failed",
"invalid_input": "Invalid input",
"timeout": "Request timeout"
},
"buttons": {
"ok": "OK",
"cancel": "❌ Cancel",
"submit": "✅ Submit Feedback",
"retry": "Retry",
"close": "Close",
"upload": "Upload",
"download": "Download"
},
"dynamic": {
"aiSummary": "Test Web UI Functionality\n\n🎯 **Test Items:**\n- Web UI server startup and operation\n- WebSocket real-time communication\n- Feedback submission functionality\n- Image upload and preview\n- Command execution functionality\n- Smart Ctrl+V image pasting\n- Multi-language interface functionality\n\n📋 **Test Steps:**\n1. Test image upload (drag-drop, file selection, clipboard)\n2. Press Ctrl+V in text box to test smart pasting\n3. Try switching languages (Traditional Chinese/Simplified Chinese/English)\n4. Test command execution functionality\n5. Submit feedback and images\n\nPlease test these features and provide feedback!",
"terminalWelcome": "Welcome to Interactive Feedback Terminal\n========================================\nProject Directory: {sessionId}\nEnter commands and press Enter or click Execute button\nSupported commands: ls, dir, pwd, cat, type, etc.\n\n$ "
}
}

View File

@ -0,0 +1,138 @@
{
"app": {
"title": "MCP 交互反馈系统",
"subtitle": "AI 助手交互反馈平台",
"projectDirectory": "项目目录"
},
"tabs": {
"feedback": "💬 反馈",
"summary": "📋 AI 总结",
"commands": "⚡ 命令",
"command": "⚡ 命令",
"settings": "⚙️ 设置",
"combined": "📝 合并模式"
},
"feedback": {
"title": "💬 提供反馈",
"description": "请提供您对 AI 工作成果的反馈意见。您可以输入文字反馈并上传相关图片。",
"textLabel": "文字反馈",
"placeholder": "请在这里输入您的反馈...",
"detailedPlaceholder": "请在这里输入您的反馈...\n\n💡 小提示:\n• 按 Ctrl+Enter/Cmd+Enter (支持数字键盘) 可快速提交\n• 按 Ctrl+V/Cmd+V 可直接粘贴剪贴板图片",
"imageLabel": "图片附件(可选)",
"imageUploadText": "📎 点击选择图片或拖放图片到此处\n支持 PNG、JPG、JPEG、GIF、BMP、WebP 等格式",
"submit": "✅ 提交反馈",
"uploading": "上传中...",
"dragdrop": "拖放图片到这里或点击上传",
"selectfiles": "选择文件",
"processing": "处理中...",
"success": "反馈已成功提交!",
"error": "提交反馈时发生错误",
"shortcuts": {
"submit": "Ctrl+Enter 提交 (Mac 用 Cmd+Enter支持数字键盘)",
"clear": "Ctrl+Delete 清除 (Mac 用 Cmd+Delete)",
"paste": "Ctrl+V 粘贴图片 (Mac 用 Cmd+V)"
}
},
"summary": {
"title": "📋 AI 工作摘要",
"description": "以下是 AI 助手完成的工作摘要,请仔细查看并提供您的反馈意见。",
"placeholder": "AI 工作摘要将在这里显示...",
"empty": "目前没有摘要内容",
"lastupdate": "最后更新",
"refresh": "刷新"
},
"commands": {
"title": "⚡ 命令执行",
"description": "在此执行命令来验证结果或收集更多信息。命令将在项目目录中执行。",
"inputLabel": "命令输入",
"placeholder": "输入要执行的命令...",
"execute": "▶️ 执行",
"runButton": "▶️ 执行",
"clear": "清除",
"output": "命令输出",
"outputLabel": "命令输出",
"running": "执行中...",
"completed": "执行完成",
"error": "执行错误",
"history": "命令历史"
},
"command": {
"title": "⚡ 命令执行",
"description": "在此执行命令来验证结果或收集更多信息。命令将在项目目录中执行。",
"inputLabel": "命令输入",
"placeholder": "输入要执行的命令...",
"execute": "▶️ 执行",
"runButton": "▶️ 执行",
"clear": "清除",
"output": "命令输出",
"outputLabel": "命令输出",
"running": "执行中...",
"completed": "执行完成",
"error": "执行错误",
"history": "命令历史"
},
"settings": {
"title": "⚙️ 设定",
"description": "调整界面设定和偏好选项。",
"language": "语言",
"currentLanguage": "当前语言",
"languageDesc": "选择界面显示语言",
"interface": "界面设定",
"combinedMode": "合并模式",
"combinedModeDesc": "将 AI 摘要和回馈输入合并在同一个分页中",
"autoClose": "自动关闭页面",
"autoCloseDesc": "提交回馈后自动关闭页面",
"theme": "主题",
"notifications": "通知",
"advanced": "进阶设定",
"save": "储存设定",
"reset": "重设",
"timeout": "连线逾时 (秒)",
"autorefresh": "自动重新整理",
"debug": "除错模式"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "深色",
"light": "浅色",
"auto": "自动"
},
"status": {
"connected": "已连接",
"connecting": "连接中...",
"disconnected": "已断开连接",
"reconnecting": "重新连接中...",
"error": "连接错误"
},
"notifications": {
"feedback_sent": "反馈已发送",
"command_executed": "命令已执行",
"settings_saved": "设置已保存",
"connection_lost": "连接中断",
"connection_restored": "连接已恢复"
},
"errors": {
"connection_failed": "连接失败",
"upload_failed": "上传失败",
"command_failed": "命令执行失败",
"invalid_input": "输入内容无效",
"timeout": "请求超时"
},
"buttons": {
"ok": "确定",
"cancel": "❌ 取消",
"submit": "✅ 提交反馈",
"retry": "重试",
"close": "关闭",
"upload": "上传",
"download": "下载"
},
"dynamic": {
"aiSummary": "测试 Web UI 功能\n\n🎯 **功能测试项目:**\n- Web UI 服务器启动和运行\n- WebSocket 实时通讯\n- 反馈提交功能\n- 图片上传和预览\n- 命令执行功能\n- 智能 Ctrl+V 图片粘贴\n- 多语言界面功能\n\n📋 **测试步骤:**\n1. 测试图片上传(拖拽、选择文件、剪贴板)\n2. 在文本框内按 Ctrl+V 测试智能粘贴\n3. 尝试切换语言(繁中/简中/英文)\n4. 测试命令执行功能\n5. 提交反馈和图片\n\n请测试这些功能并提供反馈",
"terminalWelcome": "欢迎使用交互反馈终端\n========================================\n项目目录: {sessionId}\n输入命令后按 Enter 或点击执行按钮\n支持的命令: ls, dir, pwd, cat, type 等\n\n$ "
}
}

View File

@ -0,0 +1,138 @@
{
"app": {
"title": "MCP 互動回饋系統",
"subtitle": "AI 助手互動回饋平台",
"projectDirectory": "專案目錄"
},
"tabs": {
"feedback": "💬 回饋",
"summary": "📋 AI 摘要",
"commands": "⚡ 命令",
"command": "⚡ 命令",
"settings": "⚙️ 設定",
"combined": "📝 合併模式"
},
"feedback": {
"title": "💬 提供回饋",
"description": "請提供您對 AI 工作成果的回饋意見。您可以輸入文字回饋並上傳相關圖片。",
"textLabel": "文字回饋",
"placeholder": "請在這裡輸入您的回饋...",
"detailedPlaceholder": "請在這裡輸入您的回饋...\n\n💡 小提示:\n• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交\n• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片",
"imageLabel": "圖片附件(可選)",
"imageUploadText": "📎 點擊選擇圖片或拖放圖片到此處\n支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式",
"submit": "✅ 提交回饋",
"uploading": "上傳中...",
"dragdrop": "拖放圖片到這裡或點擊上傳",
"selectfiles": "選擇檔案",
"processing": "處理中...",
"success": "回饋已成功提交!",
"error": "提交回饋時發生錯誤",
"shortcuts": {
"submit": "Ctrl+Enter 提交 (Mac 用 Cmd+Enter支援數字鍵盤)",
"clear": "Ctrl+Delete 清除 (Mac 用 Cmd+Delete)",
"paste": "Ctrl+V 貼上圖片 (Mac 用 Cmd+V)"
}
},
"summary": {
"title": "📋 AI 工作摘要",
"description": "以下是 AI 助手完成的工作摘要,請仔細查看並提供您的回饋意見。",
"placeholder": "AI 工作摘要將在這裡顯示...",
"empty": "目前沒有摘要內容",
"lastupdate": "最後更新",
"refresh": "重新整理"
},
"commands": {
"title": "⚡ 命令執行",
"description": "在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。",
"inputLabel": "命令輸入",
"placeholder": "輸入要執行的命令...",
"execute": "▶️ 執行",
"runButton": "▶️ 執行",
"clear": "清除",
"output": "命令輸出",
"outputLabel": "命令輸出",
"running": "執行中...",
"completed": "執行完成",
"error": "執行錯誤",
"history": "命令歷史"
},
"command": {
"title": "⚡ 命令執行",
"description": "在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。",
"inputLabel": "命令輸入",
"placeholder": "輸入要執行的命令...",
"execute": "▶️ 執行",
"runButton": "▶️ 執行",
"clear": "清除",
"output": "命令輸出",
"outputLabel": "命令輸出",
"running": "執行中...",
"completed": "執行完成",
"error": "執行錯誤",
"history": "命令歷史"
},
"settings": {
"title": "⚙️ 設定",
"description": "調整介面設定和偏好選項。",
"language": "語言",
"currentLanguage": "當前語言",
"languageDesc": "選擇界面顯示語言",
"interface": "介面設定",
"combinedMode": "合併模式",
"combinedModeDesc": "將 AI 摘要和回饋輸入合併在同一個分頁中",
"autoClose": "自動關閉頁面",
"autoCloseDesc": "提交回饋後自動關閉頁面",
"theme": "主題",
"notifications": "通知",
"advanced": "進階設定",
"save": "儲存設定",
"reset": "重設",
"timeout": "連線逾時 (秒)",
"autorefresh": "自動重新整理",
"debug": "除錯模式"
},
"languages": {
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"en": "English"
},
"themes": {
"dark": "深色",
"light": "淺色",
"auto": "自動"
},
"status": {
"connected": "已連線",
"connecting": "連線中...",
"disconnected": "已中斷連線",
"reconnecting": "重新連線中...",
"error": "連線錯誤"
},
"notifications": {
"feedback_sent": "回饋已發送",
"command_executed": "指令已執行",
"settings_saved": "設定已儲存",
"connection_lost": "連線中斷",
"connection_restored": "連線已恢復"
},
"errors": {
"connection_failed": "連線失敗",
"upload_failed": "上傳失敗",
"command_failed": "指令執行失敗",
"invalid_input": "輸入內容無效",
"timeout": "請求逾時"
},
"buttons": {
"ok": "確定",
"cancel": "❌ 取消",
"submit": "✅ 提交回饋",
"retry": "重試",
"close": "關閉",
"upload": "上傳",
"download": "下載"
},
"dynamic": {
"aiSummary": "測試 Web UI 功能\n\n🎯 **功能測試項目:**\n- Web UI 服務器啟動和運行\n- WebSocket 即時通訊\n- 回饋提交功能\n- 圖片上傳和預覽\n- 命令執行功能\n- 智能 Ctrl+V 圖片貼上\n- 多語言介面功能\n\n📋 **測試步驟:**\n1. 測試圖片上傳(拖拽、選擇檔案、剪貼簿)\n2. 在文字框內按 Ctrl+V 測試智能貼上\n3. 嘗試切換語言(繁中/簡中/英文)\n4. 測試命令執行功能\n5. 提交回饋和圖片\n\n請測試這些功能並提供回饋",
"terminalWelcome": "歡迎使用互動回饋終端\n========================================\n專案目錄: {sessionId}\n輸入命令後按 Enter 或點擊執行按鈕\n支援的命令: ls, dir, pwd, cat, type 等\n\n$ "
}
}

View File

@ -0,0 +1,253 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 主要管理器
================
基於 FastAPI Web 用戶介面主要管理類參考 GUI 的設計模式重構
專為 SSH 遠端開發環境設計支援現代化界面和多語言
"""
import asyncio
import json
import logging
import os
import socket
import threading
import time
import webbrowser
from pathlib import Path
from typing import Dict, Optional
import uuid
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import uvicorn
from .models import WebFeedbackSession, FeedbackResult
from .routes import setup_routes
from .utils import find_free_port, get_browser_opener
from ..debug import web_debug_log as debug_log
from ..i18n import get_i18n_manager
class WebUIManager:
"""Web UI 管理器"""
def __init__(self, host: str = "127.0.0.1", port: int = None):
self.host = host
self.port = port or find_free_port()
self.app = FastAPI(title="Interactive Feedback MCP")
self.sessions: Dict[str, WebFeedbackSession] = {}
self.server_thread = None
self.server_process = None
self.i18n = get_i18n_manager()
# 設置靜態文件和模板
self._setup_static_files()
self._setup_templates()
# 設置路由
setup_routes(self)
debug_log(f"WebUIManager 初始化完成,將在 {self.host}:{self.port} 啟動")
def _setup_static_files(self):
"""設置靜態文件服務"""
# Web UI 靜態文件
web_static_path = Path(__file__).parent / "static"
if web_static_path.exists():
self.app.mount("/static", StaticFiles(directory=str(web_static_path)), name="static")
# 備用:原有的靜態文件
fallback_static_path = Path(__file__).parent.parent / "static"
if fallback_static_path.exists():
self.app.mount("/fallback_static", StaticFiles(directory=str(fallback_static_path)), name="fallback_static")
def _setup_templates(self):
"""設置模板引擎"""
# Web UI 模板
web_templates_path = Path(__file__).parent / "templates"
if web_templates_path.exists():
self.templates = Jinja2Templates(directory=str(web_templates_path))
else:
# 備用:原有的模板
fallback_templates_path = Path(__file__).parent.parent / "templates"
self.templates = Jinja2Templates(directory=str(fallback_templates_path))
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
debug_log(f"創建回饋會話: {session_id}")
return session_id
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]
session.cleanup()
del self.sessions[session_id]
debug_log(f"移除回饋會話: {session_id}")
def start_server(self):
"""啟動 Web 伺服器"""
def run_server_with_retry():
max_retries = 5
retry_count = 0
while retry_count < max_retries:
try:
debug_log(f"嘗試啟動伺服器在 {self.host}:{self.port} (嘗試 {retry_count + 1}/{max_retries})")
config = uvicorn.Config(
app=self.app,
host=self.host,
port=self.port,
log_level="warning",
access_log=False
)
server = uvicorn.Server(config)
asyncio.run(server.serve())
break
except OSError as e:
if e.errno == 10048: # Windows: 位址已在使用中
retry_count += 1
if retry_count < max_retries:
debug_log(f"端口 {self.port} 被占用,嘗試下一個端口")
self.port = find_free_port(self.port + 1)
else:
debug_log("已達到最大重試次數,無法啟動伺服器")
break
else:
debug_log(f"伺服器啟動錯誤: {e}")
break
except Exception as e:
debug_log(f"伺服器運行錯誤: {e}")
break
# 在新線程中啟動伺服器
self.server_thread = threading.Thread(target=run_server_with_retry, daemon=True)
self.server_thread.start()
# 等待伺服器啟動
time.sleep(2)
def open_browser(self, url: str):
"""開啟瀏覽器"""
try:
browser_opener = get_browser_opener()
browser_opener(url)
debug_log(f"已開啟瀏覽器:{url}")
except Exception as e:
debug_log(f"無法開啟瀏覽器: {e}")
def get_server_url(self) -> str:
"""獲取伺服器 URL"""
return f"http://{self.host}:{self.port}"
def stop(self):
"""停止 Web UI 服務"""
# 清理所有會話
for session in list(self.sessions.values()):
session.cleanup()
self.sessions.clear()
# 停止伺服器注意uvicorn 的 graceful shutdown 需要額外處理)
if self.server_thread and self.server_thread.is_alive():
debug_log("正在停止 Web UI 服務")
# 全域實例
_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()
return _web_ui_manager
async def launch_web_feedback_ui(project_directory: str, summary: str) -> dict:
"""
啟動 Web 回饋介面並等待用戶回饋
Args:
project_directory: 專案目錄路徑
summary: AI 工作摘要
Returns:
dict: 回饋結果包含 logsinteractive_feedback images
"""
manager = get_web_ui_manager()
# 創建會話
session_id = manager.create_session(project_directory, summary)
session = manager.get_session(session_id)
if not session:
raise RuntimeError("無法創建回饋會話")
# 啟動伺服器(如果尚未啟動)
if not manager.server_thread or not manager.server_thread.is_alive():
manager.start_server()
# 構建完整 URL 並開啟瀏覽器
feedback_url = f"{manager.get_server_url()}/session/{session_id}"
manager.open_browser(feedback_url)
try:
# 等待用戶回饋
result = await session.wait_for_feedback()
debug_log(f"收到用戶回饋,會話: {session_id}")
return result
finally:
# 清理會話
manager.remove_session(session_id)
def stop_web_ui():
"""停止 Web UI 服務"""
global _web_ui_manager
if _web_ui_manager:
_web_ui_manager.stop()
_web_ui_manager = None
debug_log("Web UI 服務已停止")
# 測試用主函數
if __name__ == "__main__":
async def main():
try:
project_dir = os.getcwd()
summary = "這是一個測試摘要,用於驗證 Web UI 功能。"
print(f"啟動 Web UI 測試...")
print(f"專案目錄: {project_dir}")
print("等待用戶回饋...")
result = await launch_web_feedback_ui(project_dir, summary)
print("收到回饋結果:")
print(f"命令日誌: {result.get('logs', '')}")
print(f"互動回饋: {result.get('interactive_feedback', '')}")
print(f"圖片數量: {len(result.get('images', []))}")
except KeyboardInterrupt:
print("\n用戶取消操作")
except Exception as e:
print(f"錯誤: {e}")
finally:
stop_web_ui()
asyncio.run(main())

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 資料模型模組
==================
定義 Web UI 相關的資料結構和型別
"""
from .feedback_session import WebFeedbackSession
from .feedback_result import FeedbackResult
__all__ = [
'WebFeedbackSession',
'FeedbackResult'
]

View File

@ -0,0 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 回饋結果資料模型
======================
定義回饋收集的資料結構 GUI 版本保持一致
"""
from typing import TypedDict, List
class FeedbackResult(TypedDict):
"""回饋結果的型別定義"""
command_logs: str
interactive_feedback: str
images: List[dict]

View File

@ -0,0 +1,238 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web 回饋會話模型
===============
管理 Web 回饋會話的資料和邏輯
"""
import asyncio
import base64
import subprocess
import threading
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import WebSocket
from ...debug import web_debug_log as debug_log
# 常數定義
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"
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.images: List[dict] = []
self.feedback_completed = threading.Event()
self.process: Optional[subprocess.Popen] = None
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:
debug_log(f"圖片 {img['name']} 超過大小限制,跳過")
continue
# 解碼 base64 數據
if isinstance(img["data"], str):
try:
image_bytes = base64.b64decode(img["data"])
except Exception as e:
debug_log(f"圖片 {img['name']} base64 解碼失敗: {e}")
continue
else:
image_bytes = img["data"]
if len(image_bytes) == 0:
debug_log(f"圖片 {img['name']} 數據為空,跳過")
continue
processed_images.append({
"name": img["name"],
"data": image_bytes, # 保存原始 bytes 數據
"size": len(image_bytes)
})
debug_log(f"圖片 {img['name']} 處理成功,大小: {len(image_bytes)} bytes")
except Exception as e:
debug_log(f"圖片處理錯誤: {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:
debug_log(f"執行命令: {command}")
self.process = subprocess.Popen(
command,
shell=True,
cwd=self.project_directory,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
)
# 在背景線程中讀取輸出
async def read_output():
loop = asyncio.get_event_loop()
try:
# 使用線程池執行器來處理阻塞的讀取操作
def read_line():
if self.process and self.process.stdout:
return self.process.stdout.readline()
return ''
while True:
line = await loop.run_in_executor(None, read_line)
if not line:
break
self.add_log(line.rstrip())
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_output",
"output": line
})
except Exception as e:
debug_log(f"WebSocket 發送失敗: {e}")
break
except Exception as e:
debug_log(f"讀取命令輸出錯誤: {e}")
finally:
# 等待進程完成
if self.process:
exit_code = self.process.wait()
# 發送命令完成信號
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_complete",
"exit_code": exit_code
})
except Exception as e:
debug_log(f"發送完成信號失敗: {e}")
# 啟動異步任務讀取輸出
asyncio.create_task(read_output())
except Exception as e:
debug_log(f"執行命令錯誤: {e}")
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_error",
"error": str(e)
})
except:
pass
def cleanup(self):
"""清理會話資源"""
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None

View File

@ -0,0 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 路由模組
==============
提供 Web UI 的路由設置和處理
"""
from .main_routes import setup_routes
__all__ = ['setup_routes']

View File

@ -0,0 +1,126 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
主要路由處理
============
設置 Web UI 的主要路由和處理邏輯
"""
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from ...debug import web_debug_log as debug_log
if TYPE_CHECKING:
from ..main import WebUIManager
def setup_routes(manager: 'WebUIManager'):
"""設置路由"""
@manager.app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""首頁"""
return manager.templates.TemplateResponse("index.html", {
"request": request,
"title": "Interactive Feedback MCP"
})
@manager.app.get("/session/{session_id}", response_class=HTMLResponse)
async def feedback_session(request: Request, session_id: str):
"""回饋會話頁面"""
session = manager.get_session(session_id)
if not session:
return JSONResponse(
status_code=404,
content={"error": "會話不存在"}
)
return manager.templates.TemplateResponse("feedback.html", {
"request": request,
"session_id": session_id,
"project_directory": session.project_directory,
"summary": session.summary,
"title": "Interactive Feedback - 回饋收集"
})
@manager.app.get("/api/translations")
async def get_translations():
"""獲取翻譯數據 - 從 Web 專用翻譯檔案載入"""
translations = {}
# 獲取 Web 翻譯檔案目錄
web_locales_dir = Path(__file__).parent.parent / "locales"
supported_languages = ["zh-TW", "zh-CN", "en"]
for lang_code in supported_languages:
lang_dir = web_locales_dir / lang_code
translation_file = lang_dir / "translation.json"
try:
if translation_file.exists():
with open(translation_file, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
translations[lang_code] = lang_data
debug_log(f"成功載入 Web 翻譯: {lang_code}")
else:
debug_log(f"Web 翻譯檔案不存在: {translation_file}")
translations[lang_code] = {}
except Exception as e:
debug_log(f"載入 Web 翻譯檔案失敗 {lang_code}: {e}")
translations[lang_code] = {}
debug_log(f"Web 翻譯 API 返回 {len(translations)} 種語言的數據")
return JSONResponse(content=translations)
@manager.app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket 端點"""
session = manager.get_session(session_id)
if not session:
await websocket.close(code=4004, reason="會話不存在")
return
await websocket.accept()
session.websocket = websocket
debug_log(f"WebSocket 連接建立: {session_id}")
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
await handle_websocket_message(manager, session, message)
except WebSocketDisconnect:
debug_log(f"WebSocket 連接斷開: {session_id}")
except Exception as e:
debug_log(f"WebSocket 錯誤: {e}")
finally:
session.websocket = None
async def handle_websocket_message(manager: 'WebUIManager', session, data: dict):
"""處理 WebSocket 消息"""
message_type = data.get("type")
if message_type == "submit_feedback":
# 提交回饋
feedback = data.get("feedback", "")
images = data.get("images", [])
await session.submit_feedback(feedback, images)
elif message_type == "run_command":
# 執行命令
command = data.get("command", "")
if command.strip():
await session.run_command(command)
else:
debug_log(f"未知的消息類型: {message_type}")

View File

@ -0,0 +1,330 @@
/**
* Web UI 樣式
* ===========
*
* 補充樣式和動畫效果
*/
/* 連接狀態指示器 */
.connection-indicator {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
transition: all 0.3s ease;
}
.connection-indicator.connected {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
border: 1px solid #4caf50;
}
.connection-indicator.disconnected {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
border: 1px solid #f44336;
}
/* 載入動畫 */
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #464647;
border-radius: 50%;
border-top-color: #007acc;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 淡入動畫 */
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 滑入動畫 */
.slide-in {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { transform: translateX(-20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* 脈衝動畫 */
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 工具提示 */
.tooltip {
position: relative;
cursor: help;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-primary);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--border-color);
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 1000;
}
.tooltip:hover::after {
opacity: 1;
}
/* 滾動條美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #606060;
}
/* 選擇文字顏色 */
::selection {
background: rgba(0, 122, 204, 0.3);
color: var(--text-primary);
}
/* 無障礙改進 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 焦點可見性 */
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
/* 禁用狀態 */
button:disabled,
input:disabled,
textarea:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 響應式圖片 */
img {
max-width: 100%;
height: auto;
}
/* 表格樣式 */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--bg-tertiary);
font-weight: 600;
color: var(--text-primary);
}
tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* 代碼區塊 */
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
pre {
background: var(--bg-tertiary);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
border: 1px solid var(--border-color);
}
pre code {
background: none;
padding: 0;
}
/* 警告和提示框 */
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 16px;
border-left: 4px solid;
}
.alert-info {
background: rgba(33, 150, 243, 0.1);
border-left-color: var(--info-color);
color: #bbdefb;
}
.alert-success {
background: rgba(76, 175, 80, 0.1);
border-left-color: var(--success-color);
color: #c8e6c9;
}
.alert-warning {
background: rgba(255, 152, 0, 0.1);
border-left-color: var(--warning-color);
color: #ffe0b2;
}
.alert-error {
background: rgba(244, 67, 54, 0.1);
border-left-color: var(--error-color);
color: #ffcdd2;
}
/* 進度條 */
.progress {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent-color);
transition: width 0.3s ease;
}
/* 分隔線 */
.divider {
height: 1px;
background: var(--border-color);
margin: 20px 0;
}
/* 徽章 */
.badge {
display: inline-block;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 12px;
background: var(--accent-color);
color: white;
}
.badge-secondary {
background: var(--text-secondary);
}
.badge-success {
background: var(--success-color);
}
.badge-warning {
background: var(--warning-color);
}
.badge-error {
background: var(--error-color);
}
/* 卡片 */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card-header {
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.card-body {
color: var(--text-secondary);
}
/* 統計數字 */
.stat {
text-align: center;
padding: 20px;
}
.stat-value {
font-size: 2em;
font-weight: bold;
color: var(--accent-color);
display: block;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9em;
margin-top: 4px;
}

View File

@ -0,0 +1,824 @@
/**
* 主要前端應用
* ============
*
* 處理 WebSocket 通信分頁切換圖片上傳命令執行等功能
*/
class FeedbackApp {
constructor(sessionId) {
this.sessionId = sessionId;
this.websocket = null;
this.images = [];
this.isConnected = false;
this.combinedMode = false;
this.autoClose = true; // 預設開啟
this.init();
}
async init() {
// 等待國際化系統加載完成
if (window.i18nManager) {
await window.i18nManager.init();
}
// 處理動態摘要內容
this.processDynamicSummaryContent();
// 設置 WebSocket 連接
this.setupWebSocket();
// 設置事件監聽器
this.setupEventListeners();
// 初始化分頁系統
this.setupTabs();
// 設置圖片上傳
this.setupImageUpload();
// 設置鍵盤快捷鍵
this.setupKeyboardShortcuts();
// 載入設定
this.loadSettings();
// 初始化命令終端
this.initCommandTerminal();
// 確保合併模式狀態正確
this.applyCombinedModeState();
console.log('FeedbackApp 初始化完成');
}
processDynamicSummaryContent() {
// 處理所有帶有 data-dynamic-content 屬性的元素
const dynamicElements = document.querySelectorAll('[data-dynamic-content="aiSummary"]');
dynamicElements.forEach(element => {
const currentContent = element.textContent || element.innerHTML;
// 檢查是否為測試摘要
if (this.isTestSummary(currentContent)) {
// 如果是測試摘要,使用翻譯系統的內容
if (window.i18nManager) {
const translatedSummary = window.i18nManager.t('dynamic.aiSummary');
if (translatedSummary && translatedSummary !== 'dynamic.aiSummary') {
element.textContent = translatedSummary.trim();
console.log('已更新測試摘要為:', window.i18nManager.currentLanguage);
}
}
} else {
// 如果不是測試摘要,清理原有內容的前導和尾隨空白
element.textContent = currentContent.trim();
}
});
}
isTestSummary(content) {
// 簡化的測試摘要檢測邏輯 - 檢查是否包含任何測試相關關鍵詞
const testKeywords = [
// 標題關鍵詞(任何語言版本)
'測試 Web UI 功能', 'Test Web UI Functionality', '测试 Web UI 功能',
'圖片預覽和視窗調整測試', 'Image Preview and Window Adjustment Test', '图片预览和窗口调整测试',
// 功能測試項目關鍵詞
'功能測試項目', 'Test Items', '功能测试项目',
// 特殊標記
'🎯 **功能測試項目', '🎯 **Test Items', '🎯 **功能测试项目',
'📋 測試步驟', '📋 Test Steps', '📋 测试步骤',
// 具體測試功能
'WebSocket 即時通訊', 'WebSocket real-time communication', 'WebSocket 即时通讯',
'智能 Ctrl+V', 'Smart Ctrl+V', '智能 Ctrl+V',
// 測試提示詞
'請測試這些功能', 'Please test these features', '请测试这些功能'
];
// 只要包含任何一個測試關鍵詞就認為是測試摘要
return testKeywords.some(keyword => content.includes(keyword));
}
setupWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/${this.sessionId}`;
try {
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
this.isConnected = true;
console.log('WebSocket 連接已建立');
this.updateConnectionStatus(true);
};
this.websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.websocket.onclose = () => {
this.isConnected = false;
console.log('WebSocket 連接已關閉');
this.updateConnectionStatus(false);
};
this.websocket.onerror = (error) => {
console.error('WebSocket 錯誤:', error);
this.updateConnectionStatus(false);
};
} catch (error) {
console.error('WebSocket 連接失敗:', error);
this.updateConnectionStatus(false);
}
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'command_output':
this.appendCommandOutput(data.output);
break;
case 'command_complete':
this.appendCommandOutput(`\n[命令完成,退出碼: ${data.exit_code}]\n`);
this.enableCommandInput();
break;
case 'command_error':
this.appendCommandOutput(`\n[錯誤: ${data.error}]\n`);
this.enableCommandInput();
break;
case 'feedback_received':
console.log('回饋已收到');
// 顯示成功訊息
this.showSuccessMessage();
break;
default:
console.log('未知的 WebSocket 消息:', data);
}
}
showSuccessMessage() {
// 創建成功訊息提示
const message = document.createElement('div');
message.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--success-color);
color: white;
padding: 12px 20px;
border-radius: 6px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: slideIn 0.3s ease-out;
`;
message.textContent = '✅ 回饋提交成功!';
// 添加動畫樣式
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
document.body.appendChild(message);
// 3秒後移除訊息
setTimeout(() => {
if (message.parentNode) {
message.remove();
}
}, 3000);
}
updateConnectionStatus(connected) {
// 更新連接狀態指示器
const elements = document.querySelectorAll('.connection-indicator');
elements.forEach(el => {
el.textContent = connected ? '✅ 已連接' : '❌ 未連接';
el.className = `connection-indicator ${connected ? 'connected' : 'disconnected'}`;
});
// 更新命令執行按鈕狀態
const runCommandBtn = document.getElementById('runCommandBtn');
if (runCommandBtn) {
runCommandBtn.disabled = !connected;
runCommandBtn.textContent = connected ? '▶️ 執行' : '❌ 未連接';
}
}
setupEventListeners() {
// 提交回饋按鈕
const submitBtn = document.getElementById('submitBtn');
if (submitBtn) {
submitBtn.addEventListener('click', () => this.submitFeedback());
}
// 取消按鈕
const cancelBtn = document.getElementById('cancelBtn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.cancelFeedback());
}
// 執行命令按鈕
const runCommandBtn = document.getElementById('runCommandBtn');
if (runCommandBtn) {
runCommandBtn.addEventListener('click', () => this.runCommand());
}
// 命令輸入框 Enter 事件 - 修正為使用新的 input 元素
const commandInput = document.getElementById('commandInput');
if (commandInput) {
commandInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.runCommand();
}
});
}
// 設定切換
this.setupSettingsListeners();
}
setupTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const targetTab = button.getAttribute('data-tab');
// 移除所有活躍狀態
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// 添加活躍狀態
button.classList.add('active');
const targetContent = document.getElementById(`tab-${targetTab}`);
if (targetContent) {
targetContent.classList.add('active');
}
// 保存當前分頁
localStorage.setItem('activeTab', targetTab);
});
});
// 恢復上次的活躍分頁
const savedTab = localStorage.getItem('activeTab');
if (savedTab) {
const savedButton = document.querySelector(`[data-tab="${savedTab}"]`);
if (savedButton) {
savedButton.click();
}
}
}
setupImageUpload() {
const imageUploadArea = document.getElementById('imageUploadArea');
const imageInput = document.getElementById('imageInput');
const imagePreviewContainer = document.getElementById('imagePreviewContainer');
if (!imageUploadArea || !imageInput || !imagePreviewContainer) {
return;
}
// 原始分頁的圖片上傳
this.setupImageUploadForArea(imageUploadArea, imageInput, imagePreviewContainer);
// 合併模式的圖片上傳
const combinedImageUploadArea = document.getElementById('combinedImageUploadArea');
const combinedImageInput = document.getElementById('combinedImageInput');
const combinedImagePreviewContainer = document.getElementById('combinedImagePreviewContainer');
if (combinedImageUploadArea && combinedImageInput && combinedImagePreviewContainer) {
this.setupImageUploadForArea(combinedImageUploadArea, combinedImageInput, combinedImagePreviewContainer);
}
}
setupImageUploadForArea(uploadArea, input, previewContainer) {
// 點擊上傳區域
uploadArea.addEventListener('click', () => {
input.click();
});
// 文件選擇
input.addEventListener('change', (e) => {
this.handleFileSelection(e.target.files);
});
// 拖放事件
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
this.handleFileSelection(e.dataTransfer.files);
});
}
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+Enter 或 Cmd+Enter 提交回饋
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.submitFeedback();
}
// ESC 取消
if (e.key === 'Escape') {
this.cancelFeedback();
}
});
// 設置 Ctrl+V 貼上圖片監聽器
this.setupPasteListener();
}
setupPasteListener() {
document.addEventListener('paste', (e) => {
// 檢查是否在回饋文字框中
const feedbackText = document.getElementById('feedbackText');
const combinedFeedbackText = document.getElementById('combinedFeedbackText');
const isInFeedbackInput = document.activeElement === feedbackText ||
document.activeElement === combinedFeedbackText;
if (isInFeedbackInput) {
console.log('偵測到在回饋輸入框中貼上');
this.handlePasteEvent(e);
}
});
}
handlePasteEvent(e) {
const clipboardData = e.clipboardData || window.clipboardData;
if (!clipboardData) return;
const items = clipboardData.items;
let hasImages = false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
hasImages = true;
e.preventDefault(); // 防止文字也被貼上
const file = item.getAsFile();
if (file) {
console.log('從剪貼簿貼上圖片:', file.name, file.type);
this.addImage(file);
}
}
}
if (hasImages) {
console.log('已處理剪貼簿圖片');
}
}
setupSettingsListeners() {
// 合併模式開關
const combinedModeToggle = document.getElementById('combinedModeToggle');
if (combinedModeToggle) {
combinedModeToggle.addEventListener('click', () => {
this.toggleCombinedMode();
});
}
// 自動關閉開關
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.addEventListener('click', () => {
this.toggleAutoClose();
});
}
// 語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
option.addEventListener('click', () => {
const language = option.getAttribute('data-lang');
this.setLanguage(language);
});
});
}
setLanguage(language) {
// 更新語言選擇器的活躍狀態
const languageOptions = document.querySelectorAll('.language-option');
languageOptions.forEach(option => {
option.classList.remove('active');
if (option.getAttribute('data-lang') === language) {
option.classList.add('active');
}
});
// 調用國際化管理器
if (window.i18nManager) {
window.i18nManager.setLanguage(language);
// 語言切換後重新處理動態摘要內容
setTimeout(() => {
console.log('語言切換到:', language, '- 重新處理動態內容');
this.processDynamicSummaryContent();
}, 200); // 增加延遲時間確保翻譯加載完成
}
}
handleFileSelection(files) {
for (let file of files) {
if (file.type.startsWith('image/')) {
this.addImage(file);
}
}
}
addImage(file) {
if (file.size > 1024 * 1024) { // 1MB
alert('圖片大小不能超過 1MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const imageData = {
name: file.name,
data: e.target.result.split(',')[1], // 移除 data:image/...;base64, 前綴
size: file.size,
type: file.type,
preview: e.target.result
};
this.images.push(imageData);
this.updateImagePreview();
};
reader.readAsDataURL(file);
}
updateImagePreview() {
// 更新原始分頁的圖片預覽
this.updateImagePreviewForContainer('imagePreviewContainer', 'imageUploadArea');
// 更新合併模式的圖片預覽
this.updateImagePreviewForContainer('combinedImagePreviewContainer', 'combinedImageUploadArea');
}
updateImagePreviewForContainer(containerId, uploadAreaId) {
const container = document.getElementById(containerId);
const uploadArea = document.getElementById(uploadAreaId);
if (!container || !uploadArea) return;
container.innerHTML = '';
// 更新上傳區域的樣式
if (this.images.length > 0) {
uploadArea.classList.add('has-images');
} else {
uploadArea.classList.remove('has-images');
}
this.images.forEach((image, index) => {
const preview = document.createElement('div');
preview.className = 'image-preview';
preview.innerHTML = `
<img src="${image.preview}" alt="${image.name}">
<button class="image-remove" onclick="feedbackApp.removeImage(${index})">×</button>
`;
container.appendChild(preview);
});
}
removeImage(index) {
this.images.splice(index, 1);
this.updateImagePreview();
}
runCommand() {
const commandInput = document.getElementById('commandInput');
const command = commandInput?.value.trim();
if (!command) {
this.appendCommandOutput('⚠️ 請輸入命令\n');
return;
}
if (!this.isConnected) {
this.appendCommandOutput('❌ WebSocket 未連接,無法執行命令\n');
return;
}
// 禁用輸入和按鈕
this.disableCommandInput();
// 顯示執行的命令,使用 terminal 風格
this.appendCommandOutput(`$ ${command}\n`);
// 發送命令
try {
this.websocket.send(JSON.stringify({
type: 'run_command',
command: command
}));
// 清空輸入框
commandInput.value = '';
// 顯示正在執行的狀態
this.appendCommandOutput('[正在執行...]\n');
} catch (error) {
this.appendCommandOutput(`❌ 發送命令失敗: ${error.message}\n`);
this.enableCommandInput();
}
}
disableCommandInput() {
const commandInput = document.getElementById('commandInput');
const runCommandBtn = document.getElementById('runCommandBtn');
if (commandInput) {
commandInput.disabled = true;
commandInput.style.opacity = '0.6';
}
if (runCommandBtn) {
runCommandBtn.disabled = true;
runCommandBtn.textContent = '⏳ 執行中...';
}
}
enableCommandInput() {
const commandInput = document.getElementById('commandInput');
const runCommandBtn = document.getElementById('runCommandBtn');
if (commandInput) {
commandInput.disabled = false;
commandInput.style.opacity = '1';
commandInput.focus(); // 自動聚焦到輸入框
}
if (runCommandBtn) {
runCommandBtn.disabled = false;
runCommandBtn.textContent = '▶️ 執行';
}
}
appendCommandOutput(text) {
const output = document.getElementById('commandOutput');
if (output) {
output.textContent += text;
output.scrollTop = output.scrollHeight;
// 添加時間戳(可選)
if (text.includes('[命令完成') || text.includes('[錯誤:')) {
const timestamp = new Date().toLocaleTimeString();
output.textContent += `[${timestamp}]\n`;
}
}
}
submitFeedback() {
let feedbackText;
// 根據當前模式選擇正確的輸入框
if (this.combinedMode) {
const combinedFeedbackInput = document.getElementById('combinedFeedbackText');
feedbackText = combinedFeedbackInput?.value.trim() || '';
} else {
const feedbackInput = document.getElementById('feedbackText');
feedbackText = feedbackInput?.value.trim() || '';
}
const feedback = feedbackText;
if (!feedback && this.images.length === 0) {
alert('請提供回饋文字或上傳圖片');
return;
}
if (!this.isConnected) {
alert('WebSocket 未連接');
return;
}
// 準備圖片數據
const imageData = this.images.map(img => ({
name: img.name,
data: img.data,
size: img.size,
type: img.type
}));
// 發送回饋
this.websocket.send(JSON.stringify({
type: 'submit_feedback',
feedback: feedback,
images: imageData
}));
console.log('回饋已提交');
// 根據設定決定是否自動關閉頁面
if (this.autoClose) {
// 稍微延遲一下讓用戶看到提交成功的反饋
setTimeout(() => {
window.close();
}, 1000);
}
}
cancelFeedback() {
if (confirm('確定要取消回饋嗎?')) {
window.close();
}
}
toggleCombinedMode() {
this.combinedMode = !this.combinedMode;
const toggle = document.getElementById('combinedModeToggle');
if (toggle) {
toggle.classList.toggle('active', this.combinedMode);
}
// 顯示/隱藏分頁
const feedbackTab = document.querySelector('[data-tab="feedback"]');
const summaryTab = document.querySelector('[data-tab="summary"]');
const combinedTab = document.querySelector('[data-tab="combined"]');
if (this.combinedMode) {
// 啟用合併模式:隱藏原本的回饋和摘要分頁,顯示合併分頁
if (feedbackTab) feedbackTab.classList.add('hidden');
if (summaryTab) summaryTab.classList.add('hidden');
if (combinedTab) {
combinedTab.classList.remove('hidden');
// 如果合併分頁顯示,並且當前在回饋或摘要分頁,則將合併分頁設為活躍
const currentActiveTab = document.querySelector('.tab-button.active');
if (currentActiveTab && (currentActiveTab.getAttribute('data-tab') === 'feedback' || currentActiveTab.getAttribute('data-tab') === 'summary')) {
combinedTab.classList.add('active');
currentActiveTab.classList.remove('active');
// 顯示對應的分頁內容
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById('tab-combined').classList.add('active');
}
}
// 同步數據到合併模式
this.syncDataToCombinedMode();
} else {
// 停用合併模式:顯示原本的分頁,隱藏合併分頁
if (feedbackTab) feedbackTab.classList.remove('hidden');
if (summaryTab) summaryTab.classList.remove('hidden');
if (combinedTab) {
combinedTab.classList.add('hidden');
// 如果當前在合併分頁,則切換到回饋分頁
if (combinedTab.classList.contains('active')) {
combinedTab.classList.remove('active');
if (feedbackTab) {
feedbackTab.classList.add('active');
// 顯示對應的分頁內容
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
document.getElementById('tab-feedback').classList.add('active');
}
}
}
// 同步數據回原始分頁
this.syncDataFromCombinedMode();
}
localStorage.setItem('combinedMode', this.combinedMode.toString());
console.log('合併模式已', this.combinedMode ? '啟用' : '停用');
}
toggleAutoClose() {
this.autoClose = !this.autoClose;
const toggle = document.getElementById('autoCloseToggle');
if (toggle) {
toggle.classList.toggle('active', this.autoClose);
}
localStorage.setItem('autoClose', this.autoClose.toString());
console.log('自動關閉頁面已', this.autoClose ? '啟用' : '停用');
}
syncDataToCombinedMode() {
// 同步回饋文字
const feedbackText = document.getElementById('feedbackText');
const combinedFeedbackText = document.getElementById('combinedFeedbackText');
if (feedbackText && combinedFeedbackText) {
combinedFeedbackText.value = feedbackText.value;
}
// 同步摘要內容
const summaryContent = document.getElementById('summaryContent');
const combinedSummaryContent = document.getElementById('combinedSummaryContent');
if (summaryContent && combinedSummaryContent) {
combinedSummaryContent.textContent = summaryContent.textContent;
}
}
syncDataFromCombinedMode() {
// 同步回饋文字
const feedbackText = document.getElementById('feedbackText');
const combinedFeedbackText = document.getElementById('combinedFeedbackText');
if (feedbackText && combinedFeedbackText) {
feedbackText.value = combinedFeedbackText.value;
}
}
loadSettings() {
// 載入合併模式設定
const savedCombinedMode = localStorage.getItem('combinedMode');
if (savedCombinedMode === 'true') {
this.combinedMode = true;
const toggle = document.getElementById('combinedModeToggle');
if (toggle) {
toggle.classList.add('active');
}
// 應用合併模式設定
this.applyCombinedModeState();
}
// 載入自動關閉設定
const savedAutoClose = localStorage.getItem('autoClose');
if (savedAutoClose !== null) {
this.autoClose = savedAutoClose === 'true';
} else {
// 如果沒有保存的設定使用預設值true
this.autoClose = true;
}
// 更新自動關閉開關狀態
const autoCloseToggle = document.getElementById('autoCloseToggle');
if (autoCloseToggle) {
autoCloseToggle.classList.toggle('active', this.autoClose);
}
}
applyCombinedModeState() {
const feedbackTab = document.querySelector('[data-tab="feedback"]');
const summaryTab = document.querySelector('[data-tab="summary"]');
const combinedTab = document.querySelector('[data-tab="combined"]');
if (this.combinedMode) {
// 隱藏原本的回饋和摘要分頁,顯示合併分頁
if (feedbackTab) feedbackTab.classList.add('hidden');
if (summaryTab) summaryTab.classList.add('hidden');
if (combinedTab) combinedTab.classList.remove('hidden');
} else {
// 顯示原本的分頁,隱藏合併分頁
if (feedbackTab) feedbackTab.classList.remove('hidden');
if (summaryTab) summaryTab.classList.remove('hidden');
if (combinedTab) combinedTab.classList.add('hidden');
}
}
initCommandTerminal() {
// 使用翻譯的歡迎信息
if (window.i18nManager) {
const welcomeTemplate = window.i18nManager.t('dynamic.terminalWelcome');
if (welcomeTemplate && welcomeTemplate !== 'dynamic.terminalWelcome') {
const welcomeMessage = welcomeTemplate.replace('{sessionId}', this.sessionId);
this.appendCommandOutput(welcomeMessage);
return;
}
}
// 回退到預設歡迎信息(如果翻譯不可用)
const welcomeMessage = `Welcome to Interactive Feedback Terminal
========================================
Project Directory: ${this.sessionId}
Enter commands and press Enter or click Execute button
Supported commands: ls, dir, pwd, cat, type, etc.
$ `;
this.appendCommandOutput(welcomeMessage);
}
}
// 全域函數,供 HTML 中的 onclick 使用
window.feedbackApp = null;

View File

@ -0,0 +1,203 @@
/**
* 國際化i18n模組
* =================
*
* 處理多語言支援和界面文字翻譯
* 從後端 /api/translations 載入翻譯數據
*/
class I18nManager {
constructor() {
this.currentLanguage = 'zh-TW';
this.translations = {};
this.loadingPromise = null;
}
async init() {
// 從 localStorage 載入語言偏好
const savedLanguage = localStorage.getItem('language');
if (savedLanguage) {
this.currentLanguage = savedLanguage;
}
// 載入翻譯數據
await this.loadTranslations();
// 應用翻譯
this.applyTranslations();
// 設置語言選擇器
this.setupLanguageSelectors();
// 延遲一點再更新動態內容,確保應用程式已初始化
setTimeout(() => {
this.updateDynamicContent();
}, 100);
}
async loadTranslations() {
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadingPromise = fetch('/api/translations')
.then(response => response.json())
.then(data => {
this.translations = data;
console.log('翻譯數據載入完成:', Object.keys(this.translations));
// 檢查當前語言是否有翻譯數據
if (!this.translations[this.currentLanguage] || Object.keys(this.translations[this.currentLanguage]).length === 0) {
console.warn(`當前語言 ${this.currentLanguage} 沒有翻譯數據,回退到 zh-TW`);
this.currentLanguage = 'zh-TW';
}
})
.catch(error => {
console.error('載入翻譯數據失敗:', error);
// 使用最小的回退翻譯
this.translations = this.getMinimalFallbackTranslations();
});
return this.loadingPromise;
}
getMinimalFallbackTranslations() {
// 最小的回退翻譯,只包含關鍵項目
return {
'zh-TW': {
'app': {
'title': 'Interactive Feedback MCP',
'projectDirectory': '專案目錄'
},
'tabs': {
'feedback': '💬 回饋',
'summary': '📋 AI 摘要',
'command': '⚡ 命令',
'settings': '⚙️ 設定'
},
'buttons': {
'cancel': '❌ 取消',
'submit': '✅ 提交回饋'
},
'settings': {
'language': '語言'
}
}
};
}
// 支援巢狀鍵值的翻譯函數
t(key, defaultValue = '') {
const langData = this.translations[this.currentLanguage] || {};
return this.getNestedValue(langData, key) || defaultValue || key;
}
getNestedValue(obj, path) {
return path.split('.').reduce((current, key) => {
return current && current[key] !== undefined ? current[key] : null;
}, obj);
}
setLanguage(language) {
if (this.translations[language]) {
this.currentLanguage = language;
localStorage.setItem('language', language);
this.applyTranslations();
// 更新語言選擇器(只更新設定頁面的)
const selector = document.getElementById('settingsLanguageSelect');
if (selector) {
selector.value = language;
}
// 更新 HTML lang 屬性
document.documentElement.lang = language;
console.log('語言已切換到:', language);
} else {
console.warn('不支援的語言:', language);
}
}
applyTranslations() {
// 翻譯所有有 data-i18n 屬性的元素
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = this.t(key);
if (translation && translation !== key) {
element.textContent = translation;
}
});
// 翻譯有 data-i18n-placeholder 屬性的元素
const placeholderElements = document.querySelectorAll('[data-i18n-placeholder]');
placeholderElements.forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
const translation = this.t(key);
if (translation && translation !== key) {
element.placeholder = translation;
}
});
// 更新動態內容
this.updateDynamicContent();
console.log('翻譯已應用:', this.currentLanguage);
}
updateDynamicContent() {
// 只更新終端歡迎信息,不要覆蓋 AI 摘要
this.updateTerminalWelcome();
}
updateTerminalWelcome() {
const commandOutput = document.getElementById('commandOutput');
if (commandOutput && window.feedbackApp) {
const welcomeTemplate = this.t('dynamic.terminalWelcome');
if (welcomeTemplate && welcomeTemplate !== 'dynamic.terminalWelcome') {
const welcomeMessage = welcomeTemplate.replace('{sessionId}', window.feedbackApp.sessionId || 'unknown');
commandOutput.textContent = welcomeMessage;
}
}
}
setupLanguageSelectors() {
// 舊版下拉選擇器(兼容性保留)
const selector = document.getElementById('settingsLanguageSelect');
if (selector) {
// 設置當前值
selector.value = this.currentLanguage;
// 添加事件監聽器
selector.addEventListener('change', (e) => {
this.setLanguage(e.target.value);
});
}
// 新版現代化語言選擇器
const languageOptions = document.querySelectorAll('.language-option');
if (languageOptions.length > 0) {
// 設置當前語言的活躍狀態
languageOptions.forEach(option => {
const lang = option.getAttribute('data-lang');
if (lang === this.currentLanguage) {
option.classList.add('active');
} else {
option.classList.remove('active');
}
});
}
}
getCurrentLanguage() {
return this.currentLanguage;
}
getAvailableLanguages() {
return Object.keys(this.translations);
}
}
// 創建全域實例
window.i18nManager = new I18nManager();

View File

@ -0,0 +1,918 @@
<!DOCTYPE html>
<html lang="zh-TW" id="html-root">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="/static/css/styles.css">
<style>
:root {
/* 深色主題顏色變數 */
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d30;
--bg-tertiary: #252526;
--surface-color: #333333;
--text-primary: #cccccc;
--text-secondary: #9e9e9e;
--accent-color: #007acc;
--accent-hover: #005a9e;
--border-color: #464647;
--success-color: #4caf50;
--warning-color: #ff9800;
--error-color: #f44336;
--info-color: #2196f3;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 20px;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 頭部 */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 15px 0;
margin-bottom: 20px;
border-radius: 8px 8px 0 0;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.title {
font-size: 24px;
font-weight: bold;
color: var(--accent-color);
margin: 0;
}
.project-info {
color: var(--text-secondary);
font-size: 14px;
}
.language-selector {
display: flex;
align-items: center;
gap: 10px;
}
.language-selector select {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 10px;
font-size: 14px;
}
/* 主內容區域 */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
/* 分頁樣式 */
.tabs {
border-bottom: 2px solid var(--border-color);
margin-bottom: 20px;
}
.tab-buttons {
display: flex;
gap: 2px;
}
.tab-button {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 12px 20px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
border-radius: 4px 4px 0 0;
}
.tab-button.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
background: var(--bg-tertiary);
}
.tab-button:hover:not(.active) {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
/* 隱藏的分頁按鈕 */
.tab-button.hidden {
display: none;
}
/* 分頁內容 */
.tab-content {
display: none;
flex: 1;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
overflow-y: auto;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: 600px;
}
.tab-content.active {
display: flex;
flex-direction: column;
}
/* 分割器樣式(用於合併模式) */
.splitter-container {
display: flex;
flex-direction: column;
flex: 1;
gap: 8px;
}
.splitter-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 16px;
transition: all 0.3s ease;
}
.splitter-handle {
height: 8px;
background: var(--border-color);
border-radius: 4px;
cursor: row-resize;
transition: background 0.3s ease;
margin: 4px 0;
}
.splitter-handle:hover {
background: var(--accent-color);
}
/* 表單元素統一寬度 */
.input-group {
margin-bottom: 20px;
width: 100%;
}
.input-label {
display: block;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-primary);
}
.text-input,
.command-input {
width: 100%;
max-width: 100%;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
resize: vertical;
min-height: 220px;
font-family: inherit;
transition: border-color 0.3s ease;
box-sizing: border-box;
}
.text-input:focus,
.command-input:focus {
outline: none;
border-color: var(--accent-color);
}
.command-input {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
min-height: 80px;
}
/* 新增:單行命令輸入框樣式 */
.command-input-line {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
border: 1px solid var(--border-color);
transition: border-color 0.3s ease;
}
.command-input-line:focus {
outline: none;
border-color: var(--accent-color);
}
/* 圖片上傳區域 */
.image-upload-area {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 16px;
min-height: 120px;
width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.image-upload-area:hover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.05);
}
.image-upload-area.dragover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.1);
}
.image-upload-area.has-images {
padding: 16px;
text-align: left;
min-height: auto;
align-items: flex-start;
justify-content: flex-start;
}
.image-upload-area.has-images #imageUploadText {
display: none;
}
.image-preview-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
width: 100%;
}
.image-upload-area.has-images .image-preview-container {
margin-top: 0;
}
.image-preview {
position: relative;
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
flex-shrink: 0;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-remove {
position: absolute;
top: 4px;
right: 4px;
background: var(--error-color);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
/* 命令輸出區域 */
.command-output {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
color: var(--text-primary);
white-space: pre-wrap;
overflow-y: auto;
height: 320px;
width: 100%;
box-sizing: border-box;
/* 添加 terminal 風格 */
background: #0f0f0f;
border: 2px solid var(--border-color);
color: #00ff00;
text-shadow: 0 0 5px #00ff00;
/* 確保尺寸固定 */
flex-shrink: 0;
resize: none;
}
/* Terminal 提示符樣式 */
.terminal-prompt {
color: var(--accent-color);
font-weight: bold;
}
/* 按鈕 */
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: var(--accent-color);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--surface-color);
}
.btn-success {
background: var(--success-color);
color: white;
}
.btn-success:hover {
background: #45a049;
}
/* 底部操作按鈕 */
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 0 0 8px 8px;
margin-top: auto;
}
/* 響應式設計 */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header-content {
flex-direction: column;
gap: 10px;
}
.tab-buttons {
flex-wrap: wrap;
}
.footer-actions {
flex-direction: column-reverse;
}
/* 小屏幕下調整命令輸出區域高度 */
.command-output {
height: 250px;
}
}
/* 更小屏幕的調整 */
@media (max-width: 480px) {
.command-output {
height: 200px;
font-size: 12px;
}
}
/* 設定頁面樣式 */
.settings-group {
margin-bottom: 24px;
}
.settings-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.setting-item:last-child {
border-bottom: none;
}
.setting-label {
font-weight: 500;
color: var(--text-primary);
}
.setting-description {
color: var(--text-secondary);
font-size: 12px;
margin-top: 4px;
}
.toggle-switch {
position: relative;
width: 50px;
height: 24px;
background: var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: background 0.3s ease;
}
.toggle-switch.active {
background: var(--accent-color);
}
.toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s ease;
}
.toggle-switch.active .toggle-knob {
transform: translateX(26px);
}
/* 分頁描述文字 */
.section-description {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 20px;
line-height: 1.5;
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 6px;
border-left: 3px solid var(--accent-color);
}
/* 現代化語言選擇器樣式 */
.language-selector-modern {
display: flex;
align-items: center;
gap: 10px;
}
.language-options {
display: flex;
gap: 12px;
}
.language-option {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 16px;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 80px;
}
.language-option:hover {
border-color: var(--accent-color);
background: rgba(0, 122, 204, 0.1);
}
.language-option.active {
border-color: var(--accent-color);
background: var(--accent-color);
color: white;
}
.language-flag {
font-size: 24px;
margin-bottom: 4px;
}
.language-name {
font-size: 12px;
font-weight: 500;
text-align: center;
}
.language-option.active .language-name {
color: white;
}
/* 現代化設定卡片樣式 */
.settings-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 20px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.settings-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.settings-card-header {
background: var(--bg-secondary);
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.settings-card-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.settings-card-body {
padding: 20px;
}
.settings-card .setting-item {
padding: 16px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.settings-card .setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-info {
flex: 1;
}
.settings-card .setting-label {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.settings-card .setting-description {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
/* 合併模式樣式 */
.combined-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.combined-section-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid var(--accent-color);
}
</style>
</head>
<body>
<div class="container">
<!-- 頭部 -->
<header class="header">
<div class="header-content">
<h1 class="title" data-i18n="app.title">Interactive Feedback MCP</h1>
<div class="project-info">
<span data-i18n="app.projectDirectory">專案目錄</span>: {{ project_directory }}
</div>
</div>
</header>
<!-- 主內容 -->
<main class="main-content">
<!-- 分頁導航 -->
<div class="tabs">
<div class="tab-buttons">
<!-- 合併模式分頁 - 移到最左邊第一個 -->
<button class="tab-button hidden" data-tab="combined" data-i18n="tabs.combined">
📝 合併模式
</button>
<button class="tab-button active" data-tab="feedback" data-i18n="tabs.feedback">
💬 回饋
</button>
<button class="tab-button" data-tab="summary" data-i18n="tabs.summary">
📋 AI 摘要
</button>
<button class="tab-button" data-tab="command" data-i18n="tabs.command">
⚡ 命令
</button>
<button class="tab-button" data-tab="settings" data-i18n="tabs.settings">
⚙️ 設定
</button>
</div>
</div>
<!-- 回饋分頁 -->
<div id="tab-feedback" class="tab-content active">
<div class="section-description" data-i18n="feedback.description">
請提供您對 AI 工作成果的回饋意見。您可以輸入文字回饋並上傳相關圖片。
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="feedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋...
💡 小提示:
• 按 Ctrl+Enter/Cmd+Enter (支援數字鍵盤) 可快速提交
• 按 Ctrl+V/Cmd+V 可直接貼上剪貼板圖片"
></textarea>
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">圖片附件(可選)</label>
<div id="imageUploadArea" class="image-upload-area">
<div id="imageUploadText" data-i18n="feedback.imageUploadText">
📎 點擊選擇圖片或拖放圖片到此處<br>
<small>支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式</small>
</div>
<div id="imagePreviewContainer" class="image-preview-container"></div>
<input type="file" id="imageInput" multiple accept="image/*" style="display: none;">
</div>
</div>
</div>
<!-- AI 摘要分頁 -->
<div id="tab-summary" class="tab-content">
<div class="section-description" data-i18n="summary.description">
以下是 AI 助手完成的工作摘要,請仔細查看並提供您的回饋意見。
</div>
<div class="input-group">
<div id="summaryContent" class="text-input" style="min-height: 300px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
</div>
</div>
<!-- 命令分頁 -->
<div id="tab-command" class="tab-content">
<div class="section-description" data-i18n="command.description">
在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。
</div>
<!-- 命令輸出區域 - 放在上面 -->
<div class="input-group">
<label class="input-label" data-i18n="command.outputLabel">命令輸出</label>
<div id="commandOutput" class="command-output"></div>
</div>
<!-- 命令輸入區域 - 放在下面 -->
<div class="input-group" style="margin-bottom: 0;">
<label class="input-label" data-i18n="command.inputLabel">命令輸入</label>
<div style="display: flex; gap: 10px; align-items: flex-start;">
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
<span style="color: var(--accent-color); font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-weight: bold;">$</span>
<input
type="text"
id="commandInput"
class="command-input-line"
data-i18n-placeholder="command.placeholder"
placeholder="輸入要執行的命令..."
style="flex: 1; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px 12px; color: var(--text-primary); font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 14px;"
/>
</div>
<button id="runCommandBtn" class="btn btn-primary" data-i18n="command.runButton" style="white-space: nowrap;">
▶️ 執行
</button>
</div>
</div>
</div>
<!-- 設定分頁 -->
<div id="tab-settings" class="tab-content">
<div class="section-description" data-i18n="settings.description">
調整介面設定和偏好選項。
</div>
<!-- 介面設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.interface">🎨 介面設定</h3>
</div>
<div class="settings-card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.combinedMode">合併模式</div>
<div class="setting-description" data-i18n="settings.combinedModeDesc">
將 AI 摘要和回饋輸入合併在同一個分頁中
</div>
</div>
<div id="combinedModeToggle" class="toggle-switch">
<div class="toggle-knob"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.autoClose">自動關閉頁面</div>
<div class="setting-description" data-i18n="settings.autoCloseDesc">
提交回饋後自動關閉頁面
</div>
</div>
<div id="autoCloseToggle" class="toggle-switch active">
<div class="toggle-knob"></div>
</div>
</div>
</div>
</div>
<!-- 語言設定卡片 -->
<div class="settings-card">
<div class="settings-card-header">
<h3 class="settings-card-title" data-i18n="settings.language">🌐 語言設定</h3>
</div>
<div class="settings-card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label" data-i18n="settings.currentLanguage">當前語言</div>
<div class="setting-description" data-i18n="settings.languageDesc">
選擇界面顯示語言
</div>
</div>
<div class="language-selector-modern">
<div class="language-options">
<div class="language-option" data-lang="zh-TW">
<div class="language-flag">🇹🇼</div>
<div class="language-name">繁體中文</div>
</div>
<div class="language-option" data-lang="zh-CN">
<div class="language-flag">🇨🇳</div>
<div class="language-name">简体中文</div>
</div>
<div class="language-option" data-lang="en">
<div class="language-flag">🇺🇸</div>
<div class="language-name">English</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 合併模式分頁 - 新增 -->
<div id="tab-combined" class="tab-content">
<div class="section-description">
合併模式AI 摘要和回饋輸入在同一頁面中,方便對照查看。
</div>
<!-- AI 摘要區域 -->
<div class="combined-section">
<h3 class="combined-section-title">📋 AI 工作摘要</h3>
<div class="combined-summary">
<div id="combinedSummaryContent" class="text-input" style="min-height: 200px; white-space: pre-wrap; cursor: text;" data-dynamic-content="aiSummary">
{{ summary }}
</div>
</div>
</div>
<!-- 回饋輸入區域 -->
<div class="combined-section">
<h3 class="combined-section-title">💬 提供回饋</h3>
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="combinedFeedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋..."
style="min-height: 150px;"
></textarea>
</div>
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">圖片附件(可選)</label>
<div id="combinedImageUploadArea" class="image-upload-area" style="min-height: 100px;">
<div id="combinedImageUploadText" data-i18n="feedback.imageUploadText">
📎 點擊選擇圖片或拖放圖片到此處<br>
<small>支援 PNG、JPG、JPEG、GIF、BMP、WebP 等格式</small>
</div>
<div id="combinedImagePreviewContainer" class="image-preview-container"></div>
<input type="file" id="combinedImageInput" multiple accept="image/*" style="display: none;">
</div>
</div>
</div>
</div>
</main>
<!-- 底部操作按鈕 -->
<footer class="footer-actions">
<button id="cancelBtn" class="btn btn-secondary" data-i18n="buttons.cancel">
❌ 取消
</button>
<button id="submitBtn" class="btn btn-success" data-i18n="buttons.submit">
✅ 提交回饋
</button>
</footer>
</div>
<!-- WebSocket 和 JavaScript -->
<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
<script>
// 初始化頁面
document.addEventListener('DOMContentLoaded', function() {
// 初始化 WebSocket 連接
const sessionId = '{{ session_id }}';
window.feedbackApp = new FeedbackApp(sessionId);
});
</script>
</body>
</html>

View File

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<style>
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d30;
--text-primary: #cccccc;
--text-secondary: #9e9e9e;
--accent-color: #007acc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
max-width: 600px;
padding: 40px;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.title {
font-size: 2.5em;
font-weight: bold;
color: var(--accent-color);
margin-bottom: 20px;
}
.description {
color: var(--text-secondary);
font-size: 1.2em;
margin-bottom: 30px;
}
.status {
padding: 20px;
background: rgba(0, 122, 204, 0.1);
border: 1px solid var(--accent-color);
border-radius: 8px;
color: var(--accent-color);
}
</style>
</head>
<body>
<div class="container">
<h1 class="title">Interactive Feedback MCP</h1>
<p class="description">
Web UI 互動式回饋收集工具
</p>
<div class="status">
Web UI 服務已啟動。請等待會話建立或直接訪問具體的會話 URL。
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 工具模組
==============
提供 Web UI 相關的工具函數
"""
from .network import find_free_port
from .browser import get_browser_opener
__all__ = [
'find_free_port',
'get_browser_opener'
]

View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
瀏覽器工具函數
==============
提供瀏覽器相關的工具函數
"""
import webbrowser
from typing import Callable
def get_browser_opener() -> Callable[[str], None]:
"""
獲取瀏覽器開啟函數
Returns:
Callable: 瀏覽器開啟函數
"""
return webbrowser.open

View File

@ -0,0 +1,56 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
網絡工具函數
============
提供網絡相關的工具函數如端口檢測等
"""
import socket
from typing import Optional
def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
"""
尋找可用的端口
Args:
start_port: 起始端口號
max_attempts: 最大嘗試次數
Returns:
int: 可用的端口號
Raises:
RuntimeError: 如果找不到可用端口
"""
for i in range(max_attempts):
port = start_port + i
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"無法在 {start_port}-{start_port + max_attempts - 1} 範圍內找到可用端口")
def is_port_available(host: str, port: int) -> bool:
"""
檢查端口是否可用
Args:
host: 主機地址
port: 端口號
Returns:
bool: 端口是否可用
"""
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, port))
return True
except OSError:
return False

View File

@ -1,699 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
互動式回饋收集 Web UI
=====================
基於 FastAPI Web 用戶介面專為 SSH 遠端開發環境設計
支援文字輸入圖片上傳命令執行等功能
作者: Minidoracat
靈感來源: dotcursorrules.com
增強功能: 圖片支援和現代化界面設計
"""
import asyncio
import json
import logging
import os
import socket
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import uuid
from datetime import datetime
import base64
import tempfile
from typing import Dict, Optional, List
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, UploadFile, File, Form
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates
import uvicorn
from .debug import web_debug_log as debug_log
# ===== 常數定義 =====
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.images: List[dict] = []
self.feedback_completed = threading.Event()
self.process: Optional[subprocess.Popen] = None
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:
debug_log(f"圖片 {img['name']} 超過大小限制,跳過")
continue
# 解碼 base64 數據
if isinstance(img["data"], str):
try:
image_bytes = base64.b64decode(img["data"])
except Exception as e:
debug_log(f"圖片 {img['name']} base64 解碼失敗: {e}")
continue
else:
image_bytes = img["data"]
if len(image_bytes) == 0:
debug_log(f"圖片 {img['name']} 數據為空,跳過")
continue
processed_images.append({
"name": img["name"],
"data": image_bytes, # 保存原始 bytes 數據
"size": len(image_bytes)
})
debug_log(f"圖片 {img['name']} 處理成功,大小: {len(image_bytes)} bytes")
except Exception as e:
debug_log(f"圖片處理錯誤: {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:
debug_log(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 = None):
self.host = host
self.port = port or self._find_free_port()
self.app = FastAPI(title="Interactive Feedback MCP Web UI")
self.sessions: Dict[str, WebFeedbackSession] = {}
self.server_thread: Optional[threading.Thread] = None
self.setup_routes()
def _find_free_port(self, start_port: int = 8765, max_attempts: int = 100) -> int:
"""尋找可用的端口"""
for port in range(start_port, start_port + max_attempts):
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((self.host, port))
debug_log(f"找到可用端口: {port}")
return port
except OSError:
continue
# 如果沒有找到可用端口,使用系統分配
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((self.host, 0))
port = s.getsockname()[1]
debug_log(f"使用系統分配端口: {port}")
return port
def setup_routes(self):
"""設置路由"""
# 確保靜態文件目錄存在(相對於套件位置)
package_dir = Path(__file__).parent
static_dir = package_dir / "static"
templates_dir = package_dir / "templates"
# 靜態文件
if static_dir.exists():
self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 模板
templates = Jinja2Templates(directory=str(templates_dir)) if templates_dir.exists() else None
@self.app.get("/", response_class=HTMLResponse)
async def index(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 feedback_session(request: Request, session_id: str):
"""回饋會話頁面"""
session = self.sessions.get(session_id)
if not session:
return HTMLResponse("會話不存在", status_code=404)
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.get("/api/translations")
async def get_translations():
"""提供語系檔案 API"""
try:
translations = {}
locales_dir = package_dir / "locales"
if locales_dir.exists():
for lang_dir in locales_dir.iterdir():
if lang_dir.is_dir():
lang_code = lang_dir.name
translation_file = lang_dir / "translations.json"
if translation_file.exists():
try:
with open(translation_file, 'r', encoding='utf-8') as f:
translations[lang_code] = json.load(f)
except Exception as e:
debug_log(f"載入語言檔案失敗 {lang_code}: {e}")
return JSONResponse(translations)
except Exception as e:
debug_log(f"語系 API 錯誤: {e}")
return JSONResponse({}, status_code=500)
@self.app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket 連接處理"""
session = self.sessions.get(session_id)
if not session:
await websocket.close(code=4004, reason="會話不存在")
return
await websocket.accept()
session.websocket = websocket
try:
while True:
data = await websocket.receive_json()
await self.handle_websocket_message(session, data)
except WebSocketDisconnect:
debug_log(f"WebSocket 斷開連接: {session_id}")
except Exception as e:
debug_log(f"WebSocket 錯誤: {e}")
finally:
session.websocket = None
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", "").strip()
if command:
await session.run_command(command)
elif message_type == "submit_feedback":
feedback = data.get("feedback", "")
images = data.get("images", [])
await session.submit_feedback(feedback, images)
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 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):
"""啟動伺服器"""
max_retries = 10
retry_count = 0
def run_server_with_retry():
nonlocal retry_count
while retry_count < max_retries:
try:
debug_log(f"嘗試在端口 {self.port} 啟動伺服器(第 {retry_count + 1} 次嘗試)")
uvicorn.run(
self.app,
host=self.host,
port=self.port,
log_level="error",
access_log=False
)
break # 成功啟動,跳出循環
except OSError as e:
if "10048" in str(e) or "Address already in use" in str(e):
retry_count += 1
debug_log(f"端口 {self.port} 被占用,尋找新端口(第 {retry_count} 次重試)")
if retry_count < max_retries:
# 尋找新的可用端口
self.port = self._find_free_port(self.port + 1)
debug_log(f"切換到新端口: {self.port}")
else:
debug_log(f"已達到最大重試次數 {max_retries},無法啟動伺服器")
raise Exception(f"無法找到可用端口,已嘗試 {max_retries}")
else:
debug_log(f"伺服器啟動失敗: {e}")
raise e
except Exception as e:
debug_log(f"伺服器啟動時發生未預期錯誤: {e}")
raise e
self.server_thread = threading.Thread(target=run_server_with_retry, daemon=True)
self.server_thread.start()
# 等待伺服器啟動,並給足夠時間處理重試
time.sleep(3)
def open_browser(self, url: str):
"""開啟瀏覽器"""
try:
webbrowser.open(url)
except Exception as e:
debug_log(f"無法開啟瀏覽器: {e}")
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; padding: 10px; }}
button {{ background: #007acc; color: white; padding: 10px 20px; border: none; cursor: pointer; margin: 5px; }}
button:hover {{ background: #005a9e; }}
.notification {{ position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 6px; color: white; font-weight: bold; z-index: 10000; }}
.notification.error {{ background: #dc3545; }}
.notification.warning {{ background: #ffc107; }}
.notification.info {{ background: #007acc; }}
</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()" class="submit-btn">提交回饋</button>
<button onclick="cancelFeedback()">取消</button>
</div>
<script>
// ===== 全域變數 =====
let ws = null;
// ===== WebSocket 連接 =====
function connectWebSocket() {{
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${{protocol}}//${{window.location.host}}/ws/{session_id}`;
ws = new WebSocket(wsUrl);
ws.onopen = function() {{
console.log('WebSocket 連接成功');
}};
ws.onmessage = function(event) {{
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
}};
ws.onclose = function() {{
console.log('WebSocket 連接已關閉');
}};
ws.onerror = function(error) {{
console.error('WebSocket 錯誤:', error);
}};
}}
function handleWebSocketMessage(data) {{
if (data.type === 'command_output') {{
// 處理命令輸出如果需要
console.log('命令輸出:', data.output);
}} else if (data.type === 'command_finished') {{
console.log('命令完成,返回碼:', data.exit_code);
}}
}}
// ===== 回饋提交 =====
function submitFeedback() {{
const feedback = document.getElementById('feedback').value.trim();
if (!feedback) {{
showNotification('請輸入回饋內容!', 'warning');
return;
}}
if (ws && ws.readyState === WebSocket.OPEN) {{
// 顯示提交中狀態
const submitBtn = document.querySelector('.submit-btn');
const originalText = submitBtn.textContent;
submitBtn.textContent = '提交中...';
submitBtn.disabled = true;
ws.send(JSON.stringify({{
type: 'submit_feedback',
feedback: feedback,
images: []
}}));
// 簡短延遲後自動關閉不顯示 alert
setTimeout(() => {{
window.close();
}}, 500);
}} else {{
showNotification('WebSocket 連接異常,請重新整理頁面', 'error');
}}
}}
// 添加通知函數替代 alert
function showNotification(message, type = 'info') {{
// 創建通知元素
const notification = document.createElement('div');
notification.className = `notification ${{type}}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3 秒後自動移除
setTimeout(() => {{
if (notification.parentNode) {{
notification.parentNode.removeChild(notification);
}}
}}, 3000);
}}
function cancelFeedback() {{
if (confirm('確定要取消回饋嗎?')) {{
window.close();
}}
}}
// ===== 快捷鍵支援 =====
document.addEventListener('keydown', function(e) {{
if (e.ctrlKey && e.key === 'Enter') {{
e.preventDefault();
submitFeedback();
}}
}});
// ===== 初始化 =====
document.addEventListener('DOMContentLoaded', function() {{
connectWebSocket();
}});
</script>
</body>
</html>
"""
# ===== 全域管理器 =====
_web_ui_managers: Dict[int, WebUIManager] = {}
def get_web_ui_manager() -> WebUIManager:
"""獲取 Web UI 管理器 - 每個進程獲得獨立的實例"""
process_id = os.getpid()
global _web_ui_managers
if process_id not in _web_ui_managers:
# 為每個進程創建獨立的管理器,使用不同的端口
manager = WebUIManager()
manager.start_server()
_web_ui_managers[process_id] = manager
debug_log(f"為進程 {process_id} 創建新的 Web UI 管理器,端口: {manager.port}")
return _web_ui_managers[process_id]
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}"
debug_log(f"🌐 Web UI 已啟動: {session_url}")
# 開啟瀏覽器
manager.open_browser(session_url)
try:
# 等待用戶回饋
session = manager.get_session(session_id)
if not session:
raise RuntimeError("會話創建失敗")
result = await session.wait_for_feedback(timeout=600) # 10分鐘超時
return result
except TimeoutError:
debug_log("⏰ 等待用戶回饋超時")
return {
"logs": "",
"interactive_feedback": "回饋超時",
"images": []
}
except Exception as e:
debug_log(f"❌ Web UI 錯誤: {e}")
return {
"logs": "",
"interactive_feedback": f"錯誤: {str(e)}",
"images": []
}
finally:
# 清理會話
manager.remove_session(session_id)
def stop_web_ui():
"""停止 Web UI"""
global _web_ui_managers
if _web_ui_managers:
# 清理所有會話
for process_id, manager in list(_web_ui_managers.items()):
for session_id in list(manager.sessions.keys()):
manager.remove_session(session_id)
manager.sessions.clear()
_web_ui_managers.pop(process_id)
# ===== 主程式入口 =====
if __name__ == "__main__":
import argparse
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="任務摘要")
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}"
debug_log(f"🌐 Web UI 已啟動: {session_url}")
manager.open_browser(session_url)
try:
# 保持運行
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
debug_log("\n👋 Web UI 已停止")
asyncio.run(main())