🐛 修復 WebSocket 狀態檢測導入錯誤 (#78)

This commit is contained in:
Minidoracat 2025-06-27 19:01:05 +08:00
parent de6838c79c
commit 5f9eb6a42e
8 changed files with 154 additions and 70 deletions

View File

@ -68,8 +68,8 @@ class DesktopApp:
self.web_manager.start_server()
# 等待服務器啟動
max_wait = 10 # 最多等待 10 秒
wait_count = 0
max_wait = 10.0 # 最多等待 10 秒
wait_count = 0.0
while wait_count < max_wait:
if (
self.web_manager.server_thread
@ -221,7 +221,8 @@ class DesktopApp:
# Windows 下隱藏控制台視窗
creation_flags = 0
if os.name == "nt":
creation_flags = subprocess.CREATE_NO_WINDOW
# CREATE_NO_WINDOW 只在 Windows 上存在
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
self.app_handle = subprocess.Popen(
[str(tauri_exe)],

View File

@ -331,7 +331,9 @@ class WebUIManager:
# 如果有舊會話,處理狀態轉換和清理
if old_session:
debug_log(f"處理舊會話 {old_session.session_id} 的狀態轉換,當前狀態: {old_session.status.value}")
debug_log(
f"處理舊會話 {old_session.session_id} 的狀態轉換,當前狀態: {old_session.status.value}"
)
# 保存標籤頁狀態到全局
if hasattr(old_session, "active_tabs"):
@ -339,14 +341,18 @@ class WebUIManager:
# 如果舊會話是已提交狀態,進入下一步(已完成)
if old_session.status == SessionStatus.FEEDBACK_SUBMITTED:
debug_log(f"舊會話 {old_session.session_id} 進入下一步:已提交 → 已完成")
debug_log(
f"舊會話 {old_session.session_id} 進入下一步:已提交 → 已完成"
)
success = old_session.next_step("反饋已處理,會話完成")
if success:
debug_log(f"✅ 舊會話 {old_session.session_id} 成功進入已完成狀態")
else:
debug_log(f"❌ 舊會話 {old_session.session_id} 無法進入下一步")
else:
debug_log(f"舊會話 {old_session.session_id} 狀態為 {old_session.status.value},無需轉換")
debug_log(
f"舊會話 {old_session.session_id} 狀態為 {old_session.status.value},無需轉換"
)
# 確保舊會話仍在字典中用於API獲取
if old_session.session_id in self.sessions:
@ -689,10 +695,6 @@ class WebUIManager:
else:
debug_log("沒有活躍的桌面應用程式實例")
async def _safe_close_websocket(self, websocket):
"""安全關閉 WebSocket 連接,避免事件循環衝突 - 僅在連接已轉移後調用"""
if not websocket:
@ -736,8 +738,8 @@ class WebUIManager:
"session_id": self.current_session.session_id,
"project_directory": self.current_session.project_directory,
"summary": self.current_session.summary,
"status": self.current_session.status.value
}
"status": self.current_session.status.value,
},
}
# 發送刷新通知
@ -754,26 +756,61 @@ class WebUIManager:
return False
async def _check_active_tabs(self) -> bool:
"""檢查是否有活躍標籤頁 - 檢查所有會話的WebSocket連接"""
"""檢查是否有活躍標籤頁 - 使用分層檢測機制"""
try:
# 檢查當前會話的WebSocket連接
if self.current_session and self.current_session.websocket:
debug_log("檢測到當前會話的WebSocket連接")
# 快速檢測層:檢查 WebSocket 物件是否存在
if not self.current_session or not self.current_session.websocket:
debug_log("快速檢測:沒有當前會話或 WebSocket 連接")
return False
# 檢查心跳(如果有心跳記錄)
last_heartbeat = getattr(self.current_session, "last_heartbeat", None)
if last_heartbeat:
heartbeat_age = time.time() - last_heartbeat
if heartbeat_age > 10: # 超過 10 秒沒有心跳
debug_log(f"快速檢測:心跳超時 ({heartbeat_age:.1f}秒)")
# 可能連接已死,需要進一步檢測
else:
debug_log(f"快速檢測:心跳正常 ({heartbeat_age:.1f}秒前)")
return True # 心跳正常,認為連接活躍
# 準確檢測層:實際測試連接是否活著
try:
# 檢查 WebSocket 連接狀態
websocket = self.current_session.websocket
# 檢查連接是否已關閉
if hasattr(websocket, "client_state"):
try:
# 嘗試從 starlette 導入FastAPI 基於 Starlette
import starlette.websockets # type: ignore[import-not-found]
if hasattr(starlette.websockets, "WebSocketState"):
WebSocketState = starlette.websockets.WebSocketState
if websocket.client_state != WebSocketState.CONNECTED:
debug_log(
f"準確檢測WebSocket 狀態不是 CONNECTED而是 {websocket.client_state}"
)
# 清理死連接
self.current_session.websocket = None
return False
except ImportError:
# 如果導入失敗,使用替代方法
debug_log("無法導入 WebSocketState使用替代方法檢測連接")
# 跳過狀態檢查,直接測試連接
# 如果連接看起來是活的,嘗試發送 ping非阻塞
# 注意FastAPI WebSocket 沒有內建的 ping 方法,這裡使用自定義消息
await websocket.send_json({"type": "ping", "timestamp": time.time()})
debug_log("準確檢測:成功發送 ping 消息,連接是活躍的")
return True
# 檢查其他會話的WebSocket連接
active_websockets = 0
for session_id, session in self.sessions.items():
if session.websocket:
active_websockets += 1
debug_log(f"檢測到會話 {session_id} 的WebSocket連接")
if active_websockets > 0:
debug_log(f"檢測到 {active_websockets} 個活躍的WebSocket連接")
return True
debug_log("沒有檢測到任何活躍的WebSocket連接")
return False
except Exception as e:
debug_log(f"準確檢測:連接測試失敗 - {e}")
# 連接已死,清理它
if self.current_session:
self.current_session.websocket = None
return False
except Exception as e:
debug_log(f"檢查活躍連接時發生錯誤:{e}")

View File

@ -32,9 +32,11 @@ class SessionStatus(Enum):
"""會話狀態枚舉 - 單向流轉設計"""
WAITING = "waiting" # 等待中
ACTIVE = "active" # 活躍狀態
FEEDBACK_SUBMITTED = "feedback_submitted" # 已提交反饋
COMPLETED = "completed" # 已完成
ERROR = "error" # 錯誤(終態)
TIMEOUT = "timeout" # 超時(終態)
EXPIRED = "expired" # 已過期(終態)
@ -140,6 +142,7 @@ class WebFeedbackSession:
# 統一使用 time.time() 以避免時間基準不一致
self.created_at = time.time()
self.last_activity = self.created_at
self.last_heartbeat = None # 記錄最後一次心跳時間
# 新增:自動清理配置
self.auto_cleanup_delay = auto_cleanup_delay # 自動清理延遲時間(秒)
@ -179,17 +182,21 @@ class WebFeedbackSession:
# 定義狀態流轉路徑
next_status_map = {
SessionStatus.WAITING: SessionStatus.FEEDBACK_SUBMITTED,
SessionStatus.WAITING: SessionStatus.ACTIVE,
SessionStatus.ACTIVE: SessionStatus.FEEDBACK_SUBMITTED,
SessionStatus.FEEDBACK_SUBMITTED: SessionStatus.COMPLETED,
SessionStatus.COMPLETED: None, # 終態
SessionStatus.ERROR: None, # 終態
SessionStatus.EXPIRED: None # 終態
SessionStatus.ERROR: None, # 終態
SessionStatus.TIMEOUT: None, # 終態
SessionStatus.EXPIRED: None, # 終態
}
next_status = next_status_map.get(self.status)
if next_status is None:
debug_log(f"⚠️ 會話 {self.session_id} 已處於終態 {self.status.value},無法進入下一步")
debug_log(
f"⚠️ 會話 {self.session_id} 已處於終態 {self.status.value},無法進入下一步"
)
return False
# 執行狀態轉換
@ -199,8 +206,9 @@ class WebFeedbackSession:
else:
# 默認消息
default_messages = {
SessionStatus.ACTIVE: "會話已啟動",
SessionStatus.FEEDBACK_SUBMITTED: "用戶已提交反饋",
SessionStatus.COMPLETED: "會話已完成"
SessionStatus.COMPLETED: "會話已完成",
}
self.status_message = default_messages.get(next_status, "狀態已更新")
@ -245,7 +253,12 @@ class WebFeedbackSession:
def is_terminal(self) -> bool:
"""檢查是否處於終態"""
return self.status in [SessionStatus.COMPLETED, SessionStatus.ERROR, SessionStatus.EXPIRED]
return self.status in [
SessionStatus.COMPLETED,
SessionStatus.ERROR,
SessionStatus.TIMEOUT,
SessionStatus.EXPIRED,
]
def get_status_info(self) -> dict[str, Any]:
"""獲取會話狀態信息"""
@ -508,11 +521,13 @@ class WebFeedbackSession:
"content": message_data.get("content", ""),
"images": message_data.get("images", []),
"submission_method": message_data.get("submission_method", "manual"),
"type": "feedback"
"type": "feedback",
}
self.user_messages.append(user_message)
debug_log(f"會話 {self.session_id} 添加用戶消息,總數: {len(self.user_messages)}")
debug_log(
f"會話 {self.session_id} 添加用戶消息,總數: {len(self.user_messages)}"
)
def _process_images(self, images: list[dict]) -> list[dict]:
"""

View File

@ -176,7 +176,7 @@ def setup_routes(manager: "WebUIManager"):
"feedback_completed": session.feedback_completed.is_set(),
"has_websocket": session.websocket is not None,
"is_current": session == manager.current_session,
"user_messages": session.user_messages # 包含用戶消息記錄
"user_messages": session.user_messages, # 包含用戶消息記錄
}
sessions_data.append(session_info)
@ -189,8 +189,7 @@ def setup_routes(manager: "WebUIManager"):
except Exception as e:
debug_log(f"獲取所有會話狀態失敗: {e}")
return JSONResponse(
status_code=500,
content={"error": f"獲取會話狀態失敗: {e!s}"}
status_code=500, content={"error": f"獲取會話狀態失敗: {e!s}"}
)
@manager.app.post("/api/add-user-message")
@ -201,22 +200,20 @@ def setup_routes(manager: "WebUIManager"):
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(
status_code=404,
content={"error": "沒有活躍會話"}
)
return JSONResponse(status_code=404, content={"error": "沒有活躍會話"})
# 添加用戶消息到會話
current_session.add_user_message(data)
debug_log(f"用戶消息已添加到會話 {current_session.session_id}")
return JSONResponse(content={"status": "success", "message": "用戶消息已記錄"})
return JSONResponse(
content={"status": "success", "message": "用戶消息已記錄"}
)
except Exception as e:
debug_log(f"添加用戶消息失敗: {e}")
return JSONResponse(
status_code=500,
content={"error": f"添加用戶消息失敗: {e!s}"}
status_code=500, content={"error": f"添加用戶消息失敗: {e!s}"}
)
@manager.app.websocket("/ws")
@ -556,6 +553,10 @@ async def handle_websocket_message(manager: "WebUIManager", session, data: dict)
elif message_type == "heartbeat":
# WebSocket 心跳處理(簡化版)
# 更新心跳時間
session.last_heartbeat = time.time()
session.last_activity = time.time()
# 發送心跳回應
if session.websocket:
try:
@ -575,6 +576,11 @@ async def handle_websocket_message(manager: "WebUIManager", session, data: dict)
await session._cleanup_resources_on_timeout()
# 重構:不再自動停止服務器,保持服務器運行以支援持久性
elif message_type == "pong":
# 處理來自前端的 pong 回應(用於連接檢測)
debug_log(f"收到 pong 回應,時間戳: {data.get('timestamp', 'N/A')}")
# 可以在這裡記錄延遲或更新連接狀態
else:
debug_log(f"未知的消息類型: {message_type}")

View File

@ -704,7 +704,7 @@
// 檢查是否是新會話創建的通知
if (data.action === 'new_session_created' || data.type === 'new_session_created') {
console.log('🆕 檢測到新會話創建,完全刷新頁面以確保狀態同步');
console.log('🆕 檢測到新會話創建,局部更新頁面內容');
// 播放音效通知
if (this.audioManager) {
@ -713,28 +713,40 @@
// 顯示新會話通知
window.MCPFeedback.Utils.showMessage(
data.message || '新的 MCP 會話已創建,正在打開新窗口...',
data.message || '新的 MCP 會話已創建,正在更新內容...',
window.MCPFeedback.Utils.CONSTANTS.MESSAGE_SUCCESS
);
// 使用 window.open 打開新窗口並關閉當前窗口
// 局部更新頁面內容而非開啟新視窗
const self = this;
setTimeout(function() {
console.log('🔄 使用 window.open 打開新窗口');
console.log('🔄 執行局部更新頁面內容');
// 打開新窗口
const newWindow = window.open(window.location.href, '_blank');
if (newWindow) {
console.log('✅ 新窗口打開成功,關閉當前窗口');
// 短暫延遲後關閉當前窗口
setTimeout(function() {
window.close();
}, 500);
} else {
console.warn('❌ window.open 被阻止,回退到頁面刷新');
window.location.reload();
// 1. 更新會話資訊
if (data.session_info) {
self.currentSessionId = data.session_info.session_id;
console.log('📋 新會話 ID:', self.currentSessionId);
}
}, 1500);
// 2. 刷新頁面內容AI 摘要、表單等)
self.refreshPageContent();
// 3. 重置表單狀態
self.clearFeedback();
// 4. 重置回饋狀態為等待中
if (self.uiManager) {
self.uiManager.setFeedbackState(
window.MCPFeedback.Utils.CONSTANTS.FEEDBACK_WAITING,
self.currentSessionId
);
}
// 5. 檢查並啟動自動提交
self.checkAndStartAutoSubmit();
console.log('✅ 局部更新完成,頁面已準備好接收新的回饋');
}, 500);
return; // 提前返回,不執行後續的局部更新邏輯
}

View File

@ -269,6 +269,14 @@
this.connectionMonitor.recordPong();
}
break;
case 'ping':
// 處理來自伺服器的 ping 消息(用於連接檢測)
console.log('收到伺服器 ping立即回應 pong');
this.send({
type: 'pong',
timestamp: data.timestamp
});
break;
default:
// 其他訊息類型由外部處理
break;

View File

@ -182,8 +182,8 @@ class TestWebFeedbackSessionCleanup:
"""測試狀態更新重置定時器"""
old_timer = self.session.cleanup_timer
# 更新狀態為活躍
self.session.update_status(SessionStatus.ACTIVE, "測試活躍狀態")
# 更新狀態為活躍 - 使用 next_step 方法
self.session.next_step("測試活躍狀態")
# 檢查定時器是否被重置
assert self.session.cleanup_timer != old_timer

View File

@ -108,11 +108,16 @@ class TestWebFeedbackSession:
# 測試初始狀態
assert session.status == SessionStatus.WAITING
# 測試狀態更新
session.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已提交回饋")
# 測試狀態更新 - 使用 next_step 方法
# 首先進入 ACTIVE 狀態
result = session.next_step("會話已激活")
assert result is True
assert session.status == SessionStatus.ACTIVE
# 然後進入 FEEDBACK_SUBMITTED 狀態
result = session.next_step("已提交回饋") # type: ignore[unreachable]
assert result is True
assert session.status == SessionStatus.FEEDBACK_SUBMITTED
# 修復 unreachable 錯誤 - 使用 type: ignore 註解
assert session.status_message == "已提交回饋" # type: ignore[unreachable]
assert session.status_message == "已提交回饋"
def test_session_age_and_idle_time(self, test_project_dir):
"""測試會話年齡和空閒時間"""