2025-06-03 06:50:19 +08:00
|
|
|
|
#!/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 = []
|
2025-06-03 22:26:38 +08:00
|
|
|
|
self._cleanup_done = False # 防止重複清理
|
2025-06-03 06:50:19 +08:00
|
|
|
|
|
|
|
|
|
# 確保臨時目錄存在
|
|
|
|
|
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
async def wait_for_feedback(self, timeout: int = 600) -> dict:
|
|
|
|
|
"""
|
2025-06-03 22:26:38 +08:00
|
|
|
|
等待用戶回饋,包含圖片,支援超時自動清理
|
2025-06-03 06:50:19 +08:00
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
timeout: 超時時間(秒)
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
dict: 回饋結果
|
|
|
|
|
"""
|
2025-06-03 22:26:38 +08:00
|
|
|
|
try:
|
|
|
|
|
# 使用比 MCP 超時稍短的時間(提前處理,避免邊界競爭)
|
|
|
|
|
# 對於短超時(<30秒),提前1秒;對於長超時,提前5秒
|
|
|
|
|
if timeout <= 30:
|
|
|
|
|
actual_timeout = max(timeout - 1, 5) # 短超時提前1秒,最少5秒
|
|
|
|
|
else:
|
|
|
|
|
actual_timeout = timeout - 5 # 長超時提前5秒
|
|
|
|
|
debug_log(f"會話 {self.session_id} 開始等待回饋,超時時間: {actual_timeout} 秒(原始: {timeout} 秒)")
|
|
|
|
|
|
|
|
|
|
loop = asyncio.get_event_loop()
|
|
|
|
|
|
|
|
|
|
def wait_in_thread():
|
|
|
|
|
return self.feedback_completed.wait(actual_timeout)
|
|
|
|
|
|
|
|
|
|
completed = await loop.run_in_executor(None, wait_in_thread)
|
|
|
|
|
|
|
|
|
|
if completed:
|
|
|
|
|
debug_log(f"會話 {self.session_id} 收到用戶回饋")
|
|
|
|
|
return {
|
|
|
|
|
"logs": "\n".join(self.command_logs),
|
|
|
|
|
"interactive_feedback": self.feedback_result or "",
|
|
|
|
|
"images": self.images
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
# 超時了,立即清理資源
|
|
|
|
|
debug_log(f"會話 {self.session_id} 在 {actual_timeout} 秒後超時,開始清理資源...")
|
|
|
|
|
await self._cleanup_resources_on_timeout()
|
|
|
|
|
raise TimeoutError(f"等待用戶回饋超時({actual_timeout}秒),介面已自動關閉")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# 任何異常都要確保清理資源
|
|
|
|
|
debug_log(f"會話 {self.session_id} 發生異常: {e}")
|
|
|
|
|
await self._cleanup_resources_on_timeout()
|
|
|
|
|
raise
|
2025-06-03 06:50:19 +08:00
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-06-03 22:26:38 +08:00
|
|
|
|
async def _cleanup_resources_on_timeout(self):
|
|
|
|
|
"""超時時清理所有資源"""
|
|
|
|
|
if self._cleanup_done:
|
|
|
|
|
return # 避免重複清理
|
|
|
|
|
|
|
|
|
|
self._cleanup_done = True
|
|
|
|
|
debug_log(f"開始清理會話 {self.session_id} 的資源...")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# 1. 關閉 WebSocket 連接
|
|
|
|
|
if self.websocket:
|
|
|
|
|
try:
|
|
|
|
|
# 先通知前端超時
|
|
|
|
|
await self.websocket.send_json({
|
|
|
|
|
"type": "session_timeout",
|
|
|
|
|
"message": "會話已超時,介面將自動關閉"
|
|
|
|
|
})
|
|
|
|
|
await asyncio.sleep(0.1) # 給前端一點時間處理消息
|
|
|
|
|
await self.websocket.close()
|
|
|
|
|
debug_log(f"會話 {self.session_id} WebSocket 已關閉")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
debug_log(f"關閉 WebSocket 時發生錯誤: {e}")
|
|
|
|
|
finally:
|
|
|
|
|
self.websocket = None
|
|
|
|
|
|
|
|
|
|
# 2. 終止正在運行的命令進程
|
|
|
|
|
if self.process:
|
|
|
|
|
try:
|
|
|
|
|
self.process.terminate()
|
|
|
|
|
try:
|
|
|
|
|
self.process.wait(timeout=3)
|
|
|
|
|
debug_log(f"會話 {self.session_id} 命令進程已正常終止")
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
self.process.kill()
|
|
|
|
|
debug_log(f"會話 {self.session_id} 命令進程已強制終止")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
debug_log(f"終止命令進程時發生錯誤: {e}")
|
|
|
|
|
finally:
|
|
|
|
|
self.process = None
|
|
|
|
|
|
|
|
|
|
# 3. 設置完成事件(防止其他地方還在等待)
|
|
|
|
|
self.feedback_completed.set()
|
|
|
|
|
|
|
|
|
|
# 4. 清理臨時數據
|
|
|
|
|
self.command_logs.clear()
|
|
|
|
|
self.images.clear()
|
|
|
|
|
|
|
|
|
|
debug_log(f"會話 {self.session_id} 資源清理完成")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤: {e}")
|
|
|
|
|
|
2025-06-03 06:50:19 +08:00
|
|
|
|
def cleanup(self):
|
2025-06-03 22:26:38 +08:00
|
|
|
|
"""同步清理會話資源(保持向後兼容)"""
|
|
|
|
|
if self._cleanup_done:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._cleanup_done = True
|
|
|
|
|
debug_log(f"同步清理會話 {self.session_id} 資源...")
|
|
|
|
|
|
2025-06-03 06:50:19 +08:00
|
|
|
|
if self.process:
|
|
|
|
|
try:
|
|
|
|
|
self.process.terminate()
|
|
|
|
|
self.process.wait(timeout=5)
|
|
|
|
|
except:
|
|
|
|
|
try:
|
|
|
|
|
self.process.kill()
|
|
|
|
|
except:
|
|
|
|
|
pass
|
2025-06-03 22:26:38 +08:00
|
|
|
|
self.process = None
|
|
|
|
|
|
|
|
|
|
# 設置完成事件
|
|
|
|
|
self.feedback_completed.set()
|