445 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web 回饋會話模型
===============
管理 Web 回饋會話的資料和邏輯。
"""
import asyncio
import base64
import subprocess
import threading
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional
from fastapi import WebSocket
from ...debug import web_debug_log as debug_log
from ...utils.resource_manager import get_resource_manager, register_process
class SessionStatus(Enum):
"""會話狀態枚舉"""
WAITING = "waiting" # 等待中
ACTIVE = "active" # 活躍中
FEEDBACK_SUBMITTED = "feedback_submitted" # 已提交反饋
COMPLETED = "completed" # 已完成
TIMEOUT = "timeout" # 超時
ERROR = "error" # 錯誤
# 常數定義
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.settings: dict = {} # 圖片設定
self.feedback_completed = threading.Event()
self.process: Optional[subprocess.Popen] = None
self.command_logs = []
self._cleanup_done = False # 防止重複清理
# 新增:會話狀態管理
self.status = SessionStatus.WAITING
self.status_message = "等待用戶回饋"
self.created_at = asyncio.get_event_loop().time()
self.last_activity = self.created_at
# 確保臨時目錄存在
TEMP_DIR.mkdir(parents=True, exist_ok=True)
# 獲取資源管理器實例
self.resource_manager = get_resource_manager()
def update_status(self, status: SessionStatus, message: str = None):
"""更新會話狀態"""
self.status = status
if message:
self.status_message = message
self.last_activity = asyncio.get_event_loop().time()
debug_log(f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}")
def get_status_info(self) -> dict:
"""獲取會話狀態信息"""
return {
"status": self.status.value,
"message": self.status_message,
"feedback_completed": self.feedback_completed.is_set(),
"has_websocket": self.websocket is not None,
"created_at": self.created_at,
"last_activity": self.last_activity,
"project_directory": self.project_directory,
"summary": self.summary,
"session_id": self.session_id
}
def is_active(self) -> bool:
"""檢查會話是否活躍"""
return self.status in [SessionStatus.WAITING, SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]
async def wait_for_feedback(self, timeout: int = 600) -> dict:
"""
等待用戶回饋,包含圖片,支援超時自動清理
Args:
timeout: 超時時間(秒)
Returns:
dict: 回饋結果
"""
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,
"settings": self.settings
}
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
async def submit_feedback(self, feedback: str, images: List[dict], settings: dict = None):
"""
提交回饋和圖片
Args:
feedback: 文字回饋
images: 圖片列表
settings: 圖片設定(可選)
"""
self.feedback_result = feedback
# 先設置設定,再處理圖片(因為處理圖片時需要用到設定)
self.settings = settings or {}
self.images = self._process_images(images)
# 更新狀態為已提交反饋
self.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已送出反饋,等待下次 MCP 調用")
self.feedback_completed.set()
# 發送反饋已收到的消息給前端
if self.websocket:
try:
await self.websocket.send_json({
"type": "feedback_received",
"message": "反饋已成功提交",
"status": self.status.value
})
except Exception as e:
debug_log(f"發送反饋確認失敗: {e}")
# 重構:不再自動關閉 WebSocket保持連接以支援頁面持久性
def _process_images(self, images: List[dict]) -> List[dict]:
"""
處理圖片數據,轉換為統一格式
Args:
images: 原始圖片數據列表
Returns:
List[dict]: 處理後的圖片數據
"""
processed_images = []
# 從設定中獲取圖片大小限制,如果沒有設定則使用預設值
size_limit = self.settings.get('image_size_limit', MAX_IMAGE_SIZE)
for img in images:
try:
if not all(key in img for key in ["name", "data", "size"]):
continue
# 檢查文件大小只有當限制大於0時才檢查
if size_limit > 0 and img["size"] > size_limit:
debug_log(f"圖片 {img['name']} 超過大小限制 ({size_limit} bytes),跳過")
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
)
# 註冊進程到資源管理器
register_process(
self.process,
description=f"WebFeedbackSession-{self.session_id}-command",
auto_cleanup=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()
# 從資源管理器取消註冊進程
self.resource_manager.unregister_process(self.process.pid)
# 發送命令完成信號
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
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) # 給前端一點時間處理消息
# 安全關閉 WebSocket
await self._safe_close_websocket()
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}")
def _cleanup_sync(self):
"""同步清理會話資源(但保留 WebSocket 連接)"""
if self._cleanup_done:
return
debug_log(f"同步清理會話 {self.session_id} 資源(保留 WebSocket...")
# 只清理進程,不清理 WebSocket 連接
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None
# 清理臨時數據
self.command_logs.clear()
# 注意:不設置 _cleanup_done = True因為還需要清理 WebSocket
def cleanup(self):
"""同步清理會話資源(保持向後兼容)"""
if self._cleanup_done:
return
self._cleanup_done = True
debug_log(f"同步清理會話 {self.session_id} 資源...")
if self.process:
try:
self.process.terminate()
self.process.wait(timeout=5)
except:
try:
self.process.kill()
except:
pass
self.process = None
# 設置完成事件
self.feedback_completed.set()
async def _safe_close_websocket(self):
"""安全關閉 WebSocket 連接,避免事件循環衝突"""
if not self.websocket:
return
try:
# 檢查連接狀態
if hasattr(self.websocket, 'client_state') and self.websocket.client_state.DISCONNECTED:
debug_log("WebSocket 已斷開,跳過關閉操作")
return
# 嘗試正常關閉
await asyncio.wait_for(self.websocket.close(code=1000, reason="會話清理"), timeout=2.0)
debug_log(f"會話 {self.session_id} WebSocket 已正常關閉")
except asyncio.TimeoutError:
debug_log(f"會話 {self.session_id} WebSocket 關閉超時")
except RuntimeError as e:
if "attached to a different loop" in str(e):
debug_log(f"會話 {self.session_id} WebSocket 事件循環衝突,忽略關閉錯誤: {e}")
else:
debug_log(f"會話 {self.session_id} WebSocket 關閉時發生運行時錯誤: {e}")
except Exception as e:
debug_log(f"會話 {self.session_id} 關閉 WebSocket 時發生未知錯誤: {e}")