mirror of
https://github.com/Minidoracat/mcp-feedback-enhanced.git
synced 2025-07-27 02:22:26 +08:00
✨ 重構 webUI,優化版面以及設置功能選項
This commit is contained in:
parent
aff61718cd
commit
407b08b1bd
@ -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__",
|
||||
]
|
||||
|
@ -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()
|
||||
|
@ -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}")
|
||||
|
18
src/mcp_feedback_enhanced/web/__init__.py
Normal file
18
src/mcp_feedback_enhanced/web/__init__.py
Normal 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'
|
||||
]
|
138
src/mcp_feedback_enhanced/web/locales/en/translation.json
Normal file
138
src/mcp_feedback_enhanced/web/locales/en/translation.json
Normal 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$ "
|
||||
}
|
||||
}
|
138
src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json
Normal file
138
src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json
Normal 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$ "
|
||||
}
|
||||
}
|
138
src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json
Normal file
138
src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json
Normal 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$ "
|
||||
}
|
||||
}
|
253
src/mcp_feedback_enhanced/web/main.py
Normal file
253
src/mcp_feedback_enhanced/web/main.py
Normal 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: 回饋結果,包含 logs、interactive_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())
|
16
src/mcp_feedback_enhanced/web/models/__init__.py
Normal file
16
src/mcp_feedback_enhanced/web/models/__init__.py
Normal 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'
|
||||
]
|
17
src/mcp_feedback_enhanced/web/models/feedback_result.py
Normal file
17
src/mcp_feedback_enhanced/web/models/feedback_result.py
Normal 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]
|
238
src/mcp_feedback_enhanced/web/models/feedback_session.py
Normal file
238
src/mcp_feedback_enhanced/web/models/feedback_session.py
Normal 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
|
12
src/mcp_feedback_enhanced/web/routes/__init__.py
Normal file
12
src/mcp_feedback_enhanced/web/routes/__init__.py
Normal 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']
|
126
src/mcp_feedback_enhanced/web/routes/main_routes.py
Normal file
126
src/mcp_feedback_enhanced/web/routes/main_routes.py
Normal 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}")
|
330
src/mcp_feedback_enhanced/web/static/css/styles.css
Normal file
330
src/mcp_feedback_enhanced/web/static/css/styles.css
Normal 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;
|
||||
}
|
824
src/mcp_feedback_enhanced/web/static/js/app.js
Normal file
824
src/mcp_feedback_enhanced/web/static/js/app.js
Normal 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;
|
203
src/mcp_feedback_enhanced/web/static/js/i18n.js
Normal file
203
src/mcp_feedback_enhanced/web/static/js/i18n.js
Normal 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();
|
918
src/mcp_feedback_enhanced/web/templates/feedback.html
Normal file
918
src/mcp_feedback_enhanced/web/templates/feedback.html
Normal 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>
|
75
src/mcp_feedback_enhanced/web/templates/index.html
Normal file
75
src/mcp_feedback_enhanced/web/templates/index.html
Normal 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>
|
16
src/mcp_feedback_enhanced/web/utils/__init__.py
Normal file
16
src/mcp_feedback_enhanced/web/utils/__init__.py
Normal 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'
|
||||
]
|
21
src/mcp_feedback_enhanced/web/utils/browser.py
Normal file
21
src/mcp_feedback_enhanced/web/utils/browser.py
Normal 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
|
56
src/mcp_feedback_enhanced/web/utils/network.py
Normal file
56
src/mcp_feedback_enhanced/web/utils/network.py
Normal 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
|
@ -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())
|
Loading…
x
Reference in New Issue
Block a user