🔨 執行檢測後的修改

This commit is contained in:
Minidoracat 2025-06-11 03:25:08 +08:00
parent 50dbcc6e31
commit 5d1a8f0222
69 changed files with 3360 additions and 3029 deletions

View File

@ -19,44 +19,44 @@ jobs:
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Set up Python
run: uv python install
- name: Install dependencies
run: |
uv sync --dev
- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Commit dependency changes if any
run: |
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "📦 Update dependencies" || true
fi
- name: Get current version
id: current_version
run: |
CURRENT_VERSION=$(grep '^version =' pyproject.toml | cut -d'"' -f2)
echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"
- name: Bump version
id: bump_version
run: |
@ -64,12 +64,12 @@ jobs:
NEW_VERSION=$(grep '^version =' pyproject.toml | cut -d'"' -f2)
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"
- name: Update __init__.py version
run: |
NEW_VERSION="${{ steps.bump_version.outputs.new }}"
sed -i "s/__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" src/mcp_feedback_enhanced/__init__.py
- name: Extract Release Highlights
id: extract_highlights
run: |
@ -112,7 +112,7 @@ jobs:
echo "- 🚀 New features and improvements" > highlights.txt
echo "- 🐛 Bug fixes and optimizations" >> highlights.txt
fi
- name: Generate Release Body
id: release_body
run: |
@ -173,7 +173,7 @@ jobs:
echo "**Release automatically generated from CHANGELOG system** 🤖" >> release_body.md
echo "Release body generated successfully"
- name: Verify CHANGELOG Files
run: |
NEW_VERSION="v${{ steps.bump_version.outputs.new }}"
@ -227,7 +227,7 @@ jobs:
else
echo "✅ All CHANGELOG files verified successfully"
fi
- name: Commit version bump
run: |
git add .
@ -236,24 +236,24 @@ jobs:
- Updated version to ${{ steps.bump_version.outputs.new }}
- Auto-generated release from simplified workflow"
git tag "v${{ steps.bump_version.outputs.new }}"
- name: Build package
run: uv build
- name: Check package
run: uv run twine check dist/*
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
- name: Push changes and tags
run: |
git push origin main
git push origin "v${{ steps.bump_version.outputs.new }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
@ -265,7 +265,7 @@ jobs:
generate_release_notes: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
run: |
echo "🎉 Release v${{ steps.bump_version.outputs.new }} completed successfully!"
@ -279,4 +279,4 @@ jobs:
echo " - Verify the package on PyPI"
echo " - Test installation with: uvx mcp-feedback-enhanced@v${{ steps.bump_version.outputs.new }}"
echo ""
echo "📋 Note: Make sure CHANGELOG files are updated for future releases"
echo "📋 Note: Make sure CHANGELOG files are updated for future releases"

2
.gitignore vendored
View File

@ -68,4 +68,4 @@ test_*.py
# User configuration files
ui_settings.json
.config/
.config/

View File

@ -2,7 +2,7 @@ MIT License
Copyright (c) 2024 Fábio Ferreira
Portions of this software are modifications and enhancements
Portions of this software are modifications and enhancements
Copyright (c) 2024 Minidoracat
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@ -182,4 +182,4 @@ This version focuses on improving system stability and user experience, particul
---
**Full Project Info:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)
**Full Project Info:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)

View File

@ -182,4 +182,4 @@
---
**完整项目信息:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)
**完整项目信息:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)

View File

@ -182,4 +182,4 @@
---
**完整專案資訊:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)
**完整專案資訊:** [GitHub - mcp-feedback-enhanced](https://github.com/Minidoracat/mcp-feedback-enhanced)

View File

@ -63,4 +63,4 @@ GitHub Actions 工作流程會自動執行:
- **簡潔描述**: 保持項目符號簡潔但具描述性
- **問題引用**: 在適當的地方包含問題引用(例如 `fixes #10`
- **平行結構**: 在所有語言中保持平行結構
- **時間順序**: 在 CHANGELOG 檔案中將最新版本放在頂部
- **時間順序**: 在 CHANGELOG 檔案中將最新版本放在頂部

View File

@ -14,7 +14,7 @@ This project now uses a simplified release workflow that no longer requires crea
Before releasing, manually update these three files:
- `RELEASE_NOTES/CHANGELOG.en.md`
- `RELEASE_NOTES/CHANGELOG.zh-TW.md`
- `RELEASE_NOTES/CHANGELOG.zh-TW.md`
- `RELEASE_NOTES/CHANGELOG.zh-CN.md`
### 2. CHANGELOG 格式要求 / CHANGELOG Format Requirements

View File

@ -21,4 +21,4 @@
---
**說明**: 此模板應該適應每種語言CHANGELOG.en.md, CHANGELOG.zh-TW.md, CHANGELOG.zh-CN.md
**注意**: 版本發佈文件不包含安裝與相關連結部分,這些內容已移至各語言的完整 CHANGELOG 文件中
**注意**: 版本發佈文件不包含安裝與相關連結部分,這些內容已移至各語言的完整 CHANGELOG 文件中

View File

@ -59,24 +59,24 @@
<body>
<div class="container">
<h1>🔧 WebSocket 診斷工具</h1>
<div id="status" class="status info">
準備開始診斷...
</div>
<div>
<label>WebSocket URL:</label>
<input type="text" id="wsUrl" value="ws://127.0.0.1:8767/ws" style="width: 300px;">
<button onclick="testConnection()">🔗 測試連接</button>
<button onclick="clearLog()">🗑️ 清除日誌</button>
</div>
<div>
<label>發送消息:</label>
<input type="text" id="messageInput" placeholder='{"type": "get_status"}' style="width: 300px;">
<button onclick="sendMessage()">📤 發送</button>
</div>
<div id="log" class="log">等待操作...</div>
</div>
@ -84,43 +84,43 @@
let websocket = null;
let logElement = document.getElementById('log');
let statusElement = document.getElementById('status');
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
logElement.textContent += `[${timestamp}] ${message}\n`;
logElement.scrollTop = logElement.scrollHeight;
// 更新狀態
statusElement.textContent = message;
statusElement.className = `status ${type}`;
}
function clearLog() {
logElement.textContent = '';
log('日誌已清除');
}
function testConnection() {
const url = document.getElementById('wsUrl').value;
if (websocket) {
log('關閉現有連接...', 'warning');
websocket.close();
websocket = null;
}
log(`嘗試連接到: ${url}`, 'info');
try {
websocket = new WebSocket(url);
websocket.onopen = function(event) {
log('✅ WebSocket 連接成功!', 'success');
};
websocket.onmessage = function(event) {
log(`📨 收到消息: ${event.data}`, 'success');
try {
const data = JSON.parse(event.data);
log(`📋 解析後的數據: ${JSON.stringify(data, null, 2)}`, 'info');
@ -128,36 +128,36 @@
log(`⚠️ JSON 解析失敗: ${e.message}`, 'warning');
}
};
websocket.onclose = function(event) {
log(`🔌 連接已關閉 - Code: ${event.code}, Reason: ${event.reason}`, 'warning');
websocket = null;
};
websocket.onerror = function(error) {
log(`❌ WebSocket 錯誤: ${error}`, 'error');
console.error('WebSocket error:', error);
};
} catch (error) {
log(`❌ 連接失敗: ${error.message}`, 'error');
}
}
function sendMessage() {
const messageInput = document.getElementById('messageInput');
const message = messageInput.value.trim();
if (!message) {
log('⚠️ 請輸入要發送的消息', 'warning');
return;
}
if (!websocket || websocket.readyState !== WebSocket.OPEN) {
log('❌ WebSocket 未連接', 'error');
return;
}
try {
websocket.send(message);
log(`📤 已發送: ${message}`, 'info');
@ -166,13 +166,13 @@
log(`❌ 發送失敗: ${error.message}`, 'error');
}
}
// 頁面加載時自動測試
window.onload = function() {
log('🚀 WebSocket 診斷工具已載入');
log('💡 點擊 "測試連接" 開始診斷');
};
// Enter 鍵發送消息
document.getElementById('messageInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {

View File

@ -13,25 +13,25 @@ graph TB
TOOL[interactive_feedback<br/>核心工具]
I18N[i18n.py<br/>國際化支援]
end
subgraph "第二層Web UI 管理層"
MANAGER[WebUIManager<br/>單例管理器]
SESSION[WebFeedbackSession<br/>會話模型]
RESULT[FeedbackResult<br/>結果模型]
end
subgraph "第三層Web 服務層"
MAIN[main.py<br/>FastAPI 應用]
ROUTES[main_routes.py<br/>路由處理]
WS[WebSocket<br/>實時通信]
end
subgraph "第四層:前端交互層"
HTML[feedback.html<br/>主頁面]
JS[app.js<br/>交互邏輯]
CSS[樣式文件]
end
subgraph "工具層"
BROWSER[browser.py<br/>瀏覽器控制]
NETWORK[network.py<br/>網路工具]
@ -39,14 +39,14 @@ graph TB
CLEANUP[session_cleanup_manager.py<br/>清理管理]
COMPRESS[compression_*.py<br/>壓縮工具]
end
SERVER --> MANAGER
TOOL --> SESSION
MANAGER --> MAIN
SESSION --> ROUTES
ROUTES --> HTML
HTML --> JS
BROWSER --> MANAGER
NETWORK --> MAIN
PORT --> MAIN
@ -63,7 +63,7 @@ class MCPServer:
def __init__(self):
self.app = FastMCP("mcp-feedback-enhanced")
self.setup_tools()
@self.app.tool()
async def interactive_feedback(
project_directory: str,
@ -128,12 +128,12 @@ stateDiagram-v2
FEEDBACK_PROCESSING --> FEEDBACK_SUBMITTED: 處理完成
FEEDBACK_SUBMITTED --> WAITING: 新會話更新
FEEDBACK_SUBMITTED --> [*]: 會話結束
note right of WAITING
等待用戶輸入
顯示 AI 摘要
end note
note right of FEEDBACK_PROCESSING
處理回饋數據
圖片壓縮等
@ -155,7 +155,7 @@ class FastAPIApp:
self.setup_middleware()
self.setup_routes()
self.setup_websocket()
def setup_middleware(self):
# CORS 設定
# 靜態文件服務
@ -176,13 +176,13 @@ graph LR
FEEDBACK[GET /feedback]
STATIC[靜態資源]
end
subgraph "WebSocket 路由"
WS[/ws]
MSG[訊息處理]
BROADCAST[廣播機制]
end
GET --> FEEDBACK
FEEDBACK --> STATIC
WS --> MSG
@ -220,15 +220,15 @@ class FeedbackApp {
this.currentSession = null;
this.feedbackState = 'WAITING';
}
// WebSocket 管理
initWebSocket() { /* ... */ }
handleWebSocketMessage(data) { /* ... */ }
// 用戶交互
submitFeedback() { /* ... */ }
handleImageUpload() { /* ... */ }
// UI 更新
updateSessionDisplay() { /* ... */ }
updateFeedbackState() { /* ... */ }

View File

@ -13,7 +13,7 @@ graph TB
LOCAL_BROWSER[本地瀏覽器]
LOCAL --> LOCAL_BROWSER
end
subgraph "SSH 遠程環境"
REMOTE[遠程服務器]
SSH_TUNNEL[SSH 隧道]
@ -21,13 +21,13 @@ graph TB
REMOTE --> SSH_TUNNEL
SSH_TUNNEL --> LOCAL_CLIENT
end
subgraph "WSL 環境"
WSL[WSL 子系統]
WIN_BROWSER[Windows 瀏覽器]
WSL --> WIN_BROWSER
end
subgraph "容器化部署"
DOCKER[Docker 容器]
PORT_MAP[埠映射]
@ -97,11 +97,11 @@ flowchart TD
SSH -->|否| WSL{WSL 環境?}
WSL -->|是| WSL_CONFIG[WSL 配置]
WSL -->|否| LOCAL_CONFIG[本地配置]
SSH_CONFIG --> TUNNEL[建立 SSH 隧道]
WSL_CONFIG --> WSL_BROWSER[WSL 瀏覽器開啟]
LOCAL_CONFIG --> LOCAL_BROWSER[本地瀏覽器開啟]
TUNNEL --> SUCCESS[部署成功]
WSL_BROWSER --> SUCCESS
LOCAL_BROWSER --> SUCCESS

View File

@ -16,7 +16,7 @@ sequenceDiagram
participant WS as WebSocket
participant UI as Web UI
participant User as 用戶
Note over AI,User: 第一次調用流程
AI->>MCP: interactive_feedback(summary, timeout)
MCP->>WM: launch_web_feedback_ui()
@ -26,14 +26,14 @@ sequenceDiagram
User->>UI: 訪問回饋頁面
UI->>WS: 建立 WebSocket 連接
WS->>UI: connection_established
Note over AI,User: 用戶回饋流程
User->>UI: 填寫回饋內容
UI->>WS: submit_feedback
WS->>WM: 處理回饋數據
WM->>MCP: 設置回饋完成
MCP->>AI: 返回回饋結果
Note over AI,User: 第二次調用流程
AI->>MCP: interactive_feedback(new_summary, timeout)
MCP->>WM: 更新現有會話
@ -98,7 +98,7 @@ async def create_session(self, summary: str, project_dir: str):
old_websockets = []
if self.current_session:
old_websockets = list(self.current_session.websockets)
# 創建新會話
session_id = str(uuid.uuid4())
self.current_session = WebFeedbackSession(
@ -106,11 +106,11 @@ async def create_session(self, summary: str, project_dir: str):
summary=summary,
project_directory=project_dir
)
# 繼承 WebSocket 連接
for ws in old_websockets:
self.current_session.add_websocket(ws)
# 標記需要發送會話更新
self._pending_session_update = True
```
@ -123,13 +123,13 @@ sequenceDiagram
participant UI as Web UI
participant WS as WebSocket
participant Session as 會話管理
Browser->>UI: 訪問 /feedback
UI->>WS: 建立 WebSocket 連接
WS->>Session: 註冊連接
Session->>WS: connection_established
WS->>UI: 發送連接確認
alt 有待處理的會話更新
Session->>WS: session_updated
WS->>UI: 會話更新訊息
@ -153,13 +153,13 @@ stateDiagram-v2
AIProcessing --> SecondCall: AI 再次調用
SecondCall --> SessionUpdated: 會話更新
SessionUpdated --> UserFeedback: 等待新回饋
note right of SessionActive
Web 服務器持續運行
瀏覽器標籤頁保持開啟
WebSocket 連接維持
end note
note right of SessionUpdated
無需重新開啟瀏覽器
局部更新頁面內容
@ -199,19 +199,19 @@ flowchart TD
function handleSessionUpdated(data) {
// 顯示會話更新通知
showNotification('會話已更新', 'info');
// 重置回饋狀態
feedbackState = 'FEEDBACK_WAITING';
// 局部更新 AI 摘要
updateAISummary(data.summary);
// 清空回饋表單
clearFeedbackForm();
// 更新會話 ID
currentSessionId = data.session_id;
// 保持 WebSocket 連接不變
// 無需重新建立連接
}
@ -229,7 +229,7 @@ graph LR
FR[feedback_received<br/>回饋確認]
ST[status_update<br/>狀態更新]
end
subgraph "客戶端 → 服務器"
SF[submit_feedback<br/>提交回饋]
HB[heartbeat<br/>心跳檢測]
@ -246,7 +246,7 @@ stateDiagram-v2
FEEDBACK_PROCESSING --> FEEDBACK_SUBMITTED: 處理完成
FEEDBACK_SUBMITTED --> WAITING: 新會話更新
FEEDBACK_SUBMITTED --> [*]: 會話結束
WAITING --> ERROR: 連接錯誤
FEEDBACK_PROCESSING --> ERROR: 處理錯誤
ERROR --> WAITING: 錯誤恢復
@ -260,7 +260,7 @@ stateDiagram-v2
// WebSocket 重連機制
function handleWebSocketClose() {
console.log('WebSocket 連接已關閉,嘗試重連...');
setTimeout(() => {
initWebSocket();
}, 3000); // 3秒後重連

View File

@ -11,40 +11,40 @@ graph TB
subgraph "AI 助手環境"
AI[AI 助手<br/>Claude/GPT等]
end
subgraph "MCP Feedback Enhanced"
subgraph "MCP 服務層"
MCP[MCP Server<br/>server.py]
TOOL[interactive_feedback<br/>工具]
end
subgraph "Web UI 管理層"
WM[WebUIManager<br/>單例模式]
SESSION[WebFeedbackSession<br/>會話管理]
end
subgraph "Web 服務層"
API[FastAPI<br/>HTTP/WebSocket]
ROUTES[路由處理<br/>main_routes.py]
end
subgraph "前端交互層"
UI[Web UI<br/>HTML/JS]
WS[WebSocket<br/>實時通信]
end
subgraph "工具層"
ENV[環境檢測]
BROWSER[智能瀏覽器開啟]
RESOURCE[資源管理]
end
end
subgraph "用戶環境"
USER[用戶瀏覽器]
FILES[專案文件]
end
AI -->|調用 MCP 工具| MCP
MCP --> TOOL
TOOL --> WM
@ -54,11 +54,11 @@ graph TB
ROUTES --> UI
UI --> WS
WS --> USER
ENV --> MCP
BROWSER --> USER
RESOURCE --> SESSION
USER -->|回饋提交| WS
FILES -->|專案內容| TOOL
```
@ -74,7 +74,7 @@ stateDiagram-v2
SessionUpdated --> ActiveSession: 會話切換完成
ActiveSession --> Cleanup: 超時或手動清理
Cleanup --> NoSession: 資源釋放
note right of ActiveSession
只維護一個活躍會話
提升性能和用戶體驗
@ -97,7 +97,7 @@ flowchart TD
REMOTE -->|否| WSL{WSL 環境?}
WSL -->|是| WSLOPEN[WSL 瀏覽器開啟]
WSL -->|否| FALLBACK[回退模式]
DIRECT --> SUCCESS[成功啟動]
TUNNEL --> SUCCESS
WSLOPEN --> SUCCESS
@ -148,7 +148,7 @@ sequenceDiagram
participant WM as WebUIManager
participant UI as Web UI
participant User as 用戶
AI->>MCP: interactive_feedback()
MCP->>WM: 創建/更新會話
WM->>UI: 啟動 Web 服務
@ -168,7 +168,7 @@ graph LR
D --> E[會話無縫更新]
E --> F[用戶再次回饋]
F --> G[持續循環...]
style D fill:#e1f5fe
style E fill:#e8f5e8
```

View File

@ -57,7 +57,7 @@ python scripts/cleanup_cache.py --force
# Windows
taskkill /f /im uvx.exe
taskkill /f /im python.exe /fi "WINDOWTITLE eq *mcp-feedback-enhanced*"
# Then execute cleanup
uv cache clean
```

View File

@ -57,7 +57,7 @@ python scripts/cleanup_cache.py --force
# Windows
taskkill /f /im uvx.exe
taskkill /f /im python.exe /fi "WINDOWTITLE eq *mcp-feedback-enhanced*"
# 然后执行清理
uv cache clean
```

View File

@ -57,7 +57,7 @@ python scripts/cleanup_cache.py --force
# Windows
taskkill /f /im uvx.exe
taskkill /f /im python.exe /fi "WINDOWTITLE eq *mcp-feedback-enhanced*"
# 然後執行清理
uv cache clean
```

View File

@ -160,6 +160,18 @@ ignore = [
"S110", # 允許 try-except-pass暫時
"E712", # 允許布林比較(暫時)
"E722", # 允許裸露 except暫時
"ARG001", # 允許未使用函數參數(暫時)
"ARG002", # 允許未使用方法參數(暫時)
"PLW0603", # 允許使用 global 語句(暫時)
"RUF012", # 允許可變類別屬性(暫時)
"RUF006", # 允許未儲存 asyncio.create_task 返回值(暫時)
"PLR0915", # 允許函數語句過多(暫時)
"SIM110", # 允許使用 for 迴圈而非 any()(暫時)
"A002", # 允許遮蔽內建函數名稱(暫時)
"S104", # 允許綁定所有介面(暫時)
"RUF013", # 允許隱式 Optional暫時
"SIM108", # 允許 if-else 而非三元運算子(暫時)
"S602", # 允許 subprocess shell=True暫時
]
# 每個檔案的最大複雜度

View File

@ -8,7 +8,7 @@ testpaths = tests
minversion = 6.0
# 添加選項
addopts =
addopts =
--strict-markers
--strict-config
--disable-warnings

View File

@ -19,27 +19,26 @@ UV Cache 清理腳本
- 支援 Windows/macOS/Linux 跨平台
"""
import subprocess
import sys
import argparse
import shutil
from pathlib import Path
import os
import subprocess
from pathlib import Path
def get_cache_dir():
"""取得 uv cache 目錄"""
# Windows 預設路徑
if os.name == 'nt':
if os.name == "nt":
return Path.home() / "AppData" / "Local" / "uv"
# macOS/Linux 預設路徑
else:
return Path.home() / ".cache" / "uv"
return Path.home() / ".cache" / "uv"
def get_cache_size(cache_dir):
"""計算 cache 目錄大小"""
if not cache_dir.exists():
return 0
total_size = 0
for dirpath, dirnames, filenames in os.walk(cache_dir):
for filename in filenames:
@ -50,25 +49,24 @@ def get_cache_size(cache_dir):
pass
return total_size
def format_size(size_bytes):
"""格式化檔案大小顯示"""
if size_bytes == 0:
return "0 B"
for unit in ['B', 'KB', 'MB', 'GB']:
for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} TB"
def run_uv_command(command, check=True):
"""執行 uv 命令"""
try:
result = subprocess.run(
["uv"] + command,
capture_output=True,
text=True,
check=check
["uv"] + command, capture_output=True, text=True, check=check
)
return result
except subprocess.CalledProcessError as e:
@ -79,25 +77,26 @@ def run_uv_command(command, check=True):
print("❌ 找不到 uv 命令,請確認 uv 已正確安裝")
return None
def show_cache_info():
"""顯示 cache 資訊"""
print("🔍 UV Cache 資訊")
print("=" * 50)
cache_dir = get_cache_dir()
print(f"Cache 目錄: {cache_dir}")
if cache_dir.exists():
cache_size = get_cache_size(cache_dir)
print(f"Cache 大小: {format_size(cache_size)}")
# 顯示子目錄大小
subdirs = []
for subdir in cache_dir.iterdir():
if subdir.is_dir():
subdir_size = get_cache_size(subdir)
subdirs.append((subdir.name, subdir_size))
if subdirs:
print("\n📁 子目錄大小:")
subdirs.sort(key=lambda x: x[1], reverse=True)
@ -106,6 +105,7 @@ def show_cache_info():
else:
print("Cache 目錄不存在")
def clean_cache_selective(cache_dir, dry_run=False):
"""選擇性清理 cache跳過正在使用的檔案"""
cleaned_count = 0
@ -117,7 +117,7 @@ def clean_cache_selective(cache_dir, dry_run=False):
# 遍歷 cache 目錄
for root, dirs, files in os.walk(cache_dir):
# 跳過一些可能正在使用的目錄
if any(skip_dir in root for skip_dir in ['Scripts', 'Lib', 'pyvenv.cfg']):
if any(skip_dir in root for skip_dir in ["Scripts", "Lib", "pyvenv.cfg"]):
continue
for file in files:
@ -128,19 +128,22 @@ def clean_cache_selective(cache_dir, dry_run=False):
total_saved += file_size
cleaned_count += 1
if cleaned_count <= 10: # 只顯示前10個
print(f" 將清理: {file_path.relative_to(cache_dir)} ({format_size(file_size)})")
print(
f" 將清理: {file_path.relative_to(cache_dir)} ({format_size(file_size)})"
)
else:
file_size = file_path.stat().st_size
file_path.unlink()
total_saved += file_size
cleaned_count += 1
except (OSError, PermissionError, FileNotFoundError) as e:
except (OSError, PermissionError, FileNotFoundError):
skipped_count += 1
if not dry_run and skipped_count <= 5: # 只顯示前5個錯誤
print(f" ⚠️ 跳過: {file_path.name} (正在使用中)")
return cleaned_count, skipped_count, total_saved
def clean_cache(dry_run=False):
"""清理 cache"""
action = "預覽" if dry_run else "執行"
@ -164,8 +167,10 @@ def clean_cache(dry_run=False):
print(result.stdout)
else:
print(" 使用自定義掃描...")
cleaned_count, skipped_count, total_saved = clean_cache_selective(cache_dir, dry_run=True)
print(f"\n📊 預覽結果:")
cleaned_count, skipped_count, total_saved = clean_cache_selective(
cache_dir, dry_run=True
)
print("\n📊 預覽結果:")
print(f" 可清理檔案: {cleaned_count}")
print(f" 預計節省: {format_size(total_saved)}")
else:
@ -177,9 +182,11 @@ def clean_cache(dry_run=False):
print("✅ 標準 Cache 清理完成")
else:
print("⚠️ 標準清理失敗,使用選擇性清理...")
cleaned_count, skipped_count, total_saved = clean_cache_selective(cache_dir, dry_run=False)
cleaned_count, skipped_count, total_saved = clean_cache_selective(
cache_dir, dry_run=False
)
print(f"\n📊 清理結果:")
print("\n📊 清理結果:")
print(f" 已清理檔案: {cleaned_count}")
print(f" 跳過檔案: {skipped_count}")
print(f" 節省空間: {format_size(total_saved)}")
@ -192,13 +199,14 @@ def clean_cache(dry_run=False):
if cache_dir.exists():
after_size = get_cache_size(cache_dir)
saved_size = before_size - after_size
print(f"\n📈 總體效果:")
print("\n📈 總體效果:")
print(f" 清理前: {format_size(before_size)}")
print(f" 清理後: {format_size(after_size)}")
print(f" 實際節省: {format_size(saved_size)}")
else:
print(f" 節省空間: {format_size(before_size)}")
def force_clean_cache():
"""強制清理 cache關閉相關程序後"""
print("🔥 強制清理模式")
@ -206,7 +214,7 @@ def force_clean_cache():
print("⚠️ 警告:此模式會嘗試關閉可能使用 cache 的程序")
confirm = input("確定要繼續嗎?(y/N): ")
if confirm.lower() != 'y':
if confirm.lower() != "y":
print("❌ 已取消")
return
@ -222,22 +230,28 @@ def force_clean_cache():
print("\n🔍 檢查相關程序...")
try:
import psutil
killed_processes = []
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
try:
if proc.info['name'] and any(name in proc.info['name'].lower()
for name in ['uvx', 'uv.exe', 'python.exe']):
cmdline = ' '.join(proc.info['cmdline'] or [])
if 'mcp-feedback-enhanced' in cmdline or 'uvx' in cmdline:
print(f" 終止程序: {proc.info['name']} (PID: {proc.info['pid']})")
if proc.info["name"] and any(
name in proc.info["name"].lower()
for name in ["uvx", "uv.exe", "python.exe"]
):
cmdline = " ".join(proc.info["cmdline"] or [])
if "mcp-feedback-enhanced" in cmdline or "uvx" in cmdline:
print(
f" 終止程序: {proc.info['name']} (PID: {proc.info['pid']})"
)
proc.terminate()
killed_processes.append(proc.info['pid'])
killed_processes.append(proc.info["pid"])
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if killed_processes:
print(f" 已終止 {len(killed_processes)} 個程序")
import time
time.sleep(2) # 等待程序完全關閉
else:
print(" 未發現相關程序")
@ -252,22 +266,29 @@ def force_clean_cache():
print("✅ 強制清理成功")
else:
print("⚠️ 標準清理仍然失敗,使用檔案級清理...")
cleaned_count, skipped_count, total_saved = clean_cache_selective(cache_dir, dry_run=False)
cleaned_count, skipped_count, total_saved = clean_cache_selective(
cache_dir, dry_run=False
)
print(f" 清理檔案: {cleaned_count}, 跳過: {skipped_count}")
# 顯示結果
after_size = get_cache_size(cache_dir)
saved_size = before_size - after_size
print(f"\n📈 清理結果:")
print("\n📈 清理結果:")
print(f" 節省空間: {format_size(saved_size)}")
def main():
parser = argparse.ArgumentParser(description="UV Cache 清理工具")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--size", action="store_true", help="顯示 cache 大小資訊")
group.add_argument("--dry-run", action="store_true", help="預覽清理內容(不實際清理)")
group.add_argument(
"--dry-run", action="store_true", help="預覽清理內容(不實際清理)"
)
group.add_argument("--clean", action="store_true", help="執行 cache 清理")
group.add_argument("--force", action="store_true", help="強制清理(會嘗試關閉相關程序)")
group.add_argument(
"--force", action="store_true", help="強制清理(會嘗試關閉相關程序)"
)
args = parser.parse_args()
@ -280,5 +301,6 @@ def main():
elif args.force:
force_clean_cache()
if __name__ == "__main__":
main()

View File

@ -3,24 +3,28 @@
本地發布腳本
用法
python scripts/release.py patch # 2.0.0 -> 2.0.1
python scripts/release.py minor # 2.0.0 -> 2.1.0
python scripts/release.py minor # 2.0.0 -> 2.1.0
python scripts/release.py major # 2.0.0 -> 3.0.0
"""
import re
import subprocess
import sys
import re
from pathlib import Path
def run_cmd(cmd, check=True):
"""執行命令並返回結果"""
print(f"🔨 執行: {cmd}")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, check=False
)
if check and result.returncode != 0:
print(f"❌ 錯誤: {result.stderr}")
sys.exit(1)
return result
def get_current_version():
"""從 pyproject.toml 獲取當前版本"""
pyproject_path = Path("pyproject.toml")
@ -30,73 +34,76 @@ def get_current_version():
return match.group(1)
raise ValueError("無法找到版本號")
def bump_version(version_type):
"""更新版本號"""
if version_type not in ['patch', 'minor', 'major']:
if version_type not in ["patch", "minor", "major"]:
print("❌ 版本類型必須是: patch, minor, major")
sys.exit(1)
current = get_current_version()
print(f"📦 當前版本: {current}")
# 使用 bump2version with allow-dirty
run_cmd(f"uv run bump2version --allow-dirty {version_type}")
new_version = get_current_version()
print(f"🎉 新版本: {new_version}")
return current, new_version
def main():
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
version_type = sys.argv[1]
print("🚀 開始發布流程...")
# 檢查 Git 狀態(僅提示,不阻止)
result = run_cmd("git status --porcelain", check=False)
if result.stdout.strip():
print("⚠️ 有未提交的變更:")
print(result.stdout)
print("💡 將繼續執行(使用 --allow-dirty 模式)")
# 更新版本
old_version, new_version = bump_version(version_type)
# 建置套件
print("📦 建置套件...")
run_cmd("uv build")
# 檢查套件
print("🔍 檢查套件...")
run_cmd("uv run twine check dist/*")
# 提交所有變更(包括版本更新)
print("💾 提交版本更新...")
run_cmd("git add .")
run_cmd(f'git commit -m "🔖 Release v{new_version}"')
run_cmd(f'git tag "v{new_version}"')
# 詢問是否發布
print(f"\n✅ 準備發布版本 {old_version} -> {new_version}")
choice = input("是否發布到 PyPI (y/N): ")
if choice.lower() == 'y':
if choice.lower() == "y":
print("🚀 發布到 PyPI...")
run_cmd("uv run twine upload dist/*")
print("📤 推送到 GitHub...")
run_cmd("git push origin main")
run_cmd(f'git push origin "v{new_version}"')
print(f"🎉 發布完成!版本 v{new_version} 已上線")
print(f"📦 安裝命令: uvx mcp-feedback-enhanced")
print("📦 安裝命令: uvx mcp-feedback-enhanced")
else:
print("⏸️ 發布已取消,版本已更新但未發布")
print("💡 您可以稍後手動發布: uv run twine upload dist/*")
if __name__ == "__main__":
main()
main()

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Interactive Feedback Enhanced
==================================
@ -27,24 +26,27 @@ import os
from .server import main as run_server
# 導入新的 Web UI 模組
from .web import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
from .web import WebUIManager, get_web_ui_manager, launch_web_feedback_ui, stop_web_ui
# 保持向後兼容性
feedback_ui = None
# 主要導出介面
__all__ = [
"run_server",
"feedback_ui",
"WebUIManager",
"launch_web_feedback_ui",
"get_web_ui_manager",
"stop_web_ui",
"__version__",
"__author__",
"__version__",
"feedback_ui",
"get_web_ui_manager",
"launch_web_feedback_ui",
"run_server",
"stop_web_ui",
]
def main():
"""主要入口點,用於 uvx 執行"""
from .__main__ import main as cli_main
return cli_main()
return cli_main()

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Interactive Feedback Enhanced - 主程式入口
==============================================
@ -11,15 +10,18 @@ MCP Interactive Feedback Enhanced - 主程式入口
python -m mcp_feedback_enhanced test # 執行測試
"""
import sys
import argparse
import os
import asyncio
import os
import sys
import warnings
# 抑制 Windows 上的 asyncio ResourceWarning
if sys.platform == 'win32':
warnings.filterwarnings("ignore", category=ResourceWarning, message=".*unclosed transport.*")
if sys.platform == "win32":
warnings.filterwarnings(
"ignore", category=ResourceWarning, message=".*unclosed transport.*"
)
warnings.filterwarnings("ignore", category=ResourceWarning, message=".*unclosed.*")
# 設置 asyncio 事件循環策略以減少警告
@ -28,55 +30,67 @@ if sys.platform == 'win32':
except AttributeError:
pass
def main():
"""主程式入口點"""
parser = argparse.ArgumentParser(
description="MCP Feedback Enhanced Enhanced - 互動式回饋收集 MCP 伺服器"
)
subparsers = parser.add_subparsers(dest='command', help='可用命令')
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 伺服器命令(預設)
server_parser = subparsers.add_parser('server', help='啟動 MCP 伺服器(預設)')
server_parser = subparsers.add_parser("server", help="啟動 MCP 伺服器(預設)")
# 測試命令
test_parser = subparsers.add_parser('test', help='執行測試')
test_parser.add_argument('--web', action='store_true', help='測試 Web UI (自動持續運行)')
test_parser.add_argument('--desktop', action='store_true', help='測試桌面應用 (啟動 Electron 應用)')
test_parser.add_argument('--full', action='store_true', help='完整整合測試 (Web + 桌面)')
test_parser.add_argument('--electron-only', action='store_true', help='僅測試 Electron 環境')
test_parser.add_argument('--timeout', type=int, default=60, help='測試超時時間 (秒)')
test_parser = subparsers.add_parser("test", help="執行測試")
test_parser.add_argument(
"--web", action="store_true", help="測試 Web UI (自動持續運行)"
)
test_parser.add_argument(
"--desktop", action="store_true", help="測試桌面應用 (啟動 Electron 應用)"
)
test_parser.add_argument(
"--full", action="store_true", help="完整整合測試 (Web + 桌面)"
)
test_parser.add_argument(
"--electron-only", action="store_true", help="僅測試 Electron 環境"
)
test_parser.add_argument(
"--timeout", type=int, default=60, help="測試超時時間 (秒)"
)
# 版本命令
version_parser = subparsers.add_parser('version', help='顯示版本資訊')
version_parser = subparsers.add_parser("version", help="顯示版本資訊")
args = parser.parse_args()
if args.command == 'test':
if args.command == "test":
run_tests(args)
elif args.command == 'version':
elif args.command == "version":
show_version()
elif args.command == 'server':
run_server()
elif args.command is None:
elif args.command == "server" or args.command is None:
run_server()
else:
# 不應該到達這裡
parser.print_help()
sys.exit(1)
def run_server():
"""啟動 MCP 伺服器"""
from .server import main as server_main
return server_main()
def run_tests(args):
"""執行測試"""
# 啟用調試模式以顯示測試過程
os.environ["MCP_DEBUG"] = "true"
# 在 Windows 上抑制 asyncio 警告
if sys.platform == 'win32':
if sys.platform == "win32":
os.environ["PYTHONWARNINGS"] = "ignore::ResourceWarning"
if args.web:
@ -113,20 +127,18 @@ def run_tests(args):
def test_web_ui_simple():
"""簡單的 Web UI 測試"""
try:
from .web.main import WebUIManager
import tempfile
import time
import webbrowser
from .web.main import WebUIManager
print("🔧 創建 Web UI 管理器...")
manager = WebUIManager(host="127.0.0.1", port=8765) # 使用固定端口
print("🔧 創建測試會話...")
with tempfile.TemporaryDirectory() as temp_dir:
session_id = manager.create_session(
temp_dir,
"Web UI 測試 - 驗證基本功能"
)
session_id = manager.create_session(temp_dir, "Web UI 測試 - 驗證基本功能")
if session_id:
print("✅ 會話創建成功")
@ -170,6 +182,7 @@ def test_web_ui_simple():
except Exception as e:
print(f"❌ Web UI 測試失敗: {e}")
import traceback
traceback.print_exc()
return False
@ -181,6 +194,7 @@ def test_desktop_app():
# 檢查桌面環境可用性
from .desktop import is_desktop_available
if not is_desktop_available():
print("❌ 桌面環境不可用")
print("💡 請確保 Node.js 已安裝且不在遠程環境中")
@ -189,10 +203,9 @@ def test_desktop_app():
print("✅ 桌面環境檢查通過")
# 設置桌面模式
os.environ['MCP_FEEDBACK_MODE'] = 'desktop'
os.environ["MCP_FEEDBACK_MODE"] = "desktop"
print("🔧 創建 Electron 管理器...")
from .desktop.electron_manager import ElectronManager
import asyncio
async def run_desktop_test():
@ -208,7 +221,7 @@ def test_desktop_app():
result = await launch_desktop_app(
os.getcwd(),
"桌面應用測試 - 驗證 Electron 整合功能",
300 # 5分鐘超時
300, # 5分鐘超時
)
print("✅ 桌面應用測試完成")
@ -224,6 +237,7 @@ def test_desktop_app():
except Exception as e:
print(f"❌ 桌面應用測試失敗: {e}")
import traceback
traceback.print_exc()
return False
@ -236,11 +250,11 @@ async def wait_for_process(process):
# 確保管道正確關閉
try:
if hasattr(process, 'stdout') and process.stdout:
if hasattr(process, "stdout") and process.stdout:
process.stdout.close()
if hasattr(process, 'stderr') and process.stderr:
if hasattr(process, "stderr") and process.stderr:
process.stderr.close()
if hasattr(process, 'stdin') and process.stdin:
if hasattr(process, "stdin") and process.stdin:
process.stdin.close()
except Exception as close_error:
print(f"關閉進程管道時出錯: {close_error}")
@ -256,9 +270,15 @@ def test_electron_environment():
# 檢查 Node.js
import subprocess
try:
result = subprocess.run(['node', '--version'],
capture_output=True, text=True, timeout=10)
result = subprocess.run(
["node", "--version"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode == 0:
print(f"✅ Node.js 版本: {result.stdout.strip()}")
else:
@ -270,6 +290,7 @@ def test_electron_environment():
# 檢查桌面模組
from .desktop import is_desktop_available
if is_desktop_available():
print("✅ 桌面環境可用")
else:
@ -278,6 +299,7 @@ def test_electron_environment():
# 檢查 Electron 管理器
from .desktop.electron_manager import ElectronManager
manager = ElectronManager()
if manager.is_electron_available():
@ -288,7 +310,7 @@ def test_electron_environment():
# 檢查文件結構
desktop_dir = manager.desktop_dir
required_files = ['main.js', 'preload.js', 'package.json']
required_files = ["main.js", "preload.js", "package.json"]
for file_name in required_files:
file_path = desktop_dir / file_name
@ -324,20 +346,24 @@ def test_full_integration():
test_cases = [("auto", "auto"), ("web", "web"), ("desktop", "desktop")]
for env_value, expected in test_cases:
os.environ['MCP_FEEDBACK_MODE'] = env_value
os.environ["MCP_FEEDBACK_MODE"] = env_value
# 重新導入以獲取新的環境變數值
import sys
if 'mcp_feedback_enhanced.server' in sys.modules:
del sys.modules['mcp_feedback_enhanced.server']
if "mcp_feedback_enhanced.server" in sys.modules:
del sys.modules["mcp_feedback_enhanced.server"]
from .server import get_feedback_mode
actual = get_feedback_mode().value
if actual == expected:
print(f" ✅ MCP_FEEDBACK_MODE='{env_value}'{actual}")
else:
print(f" ❌ MCP_FEEDBACK_MODE='{env_value}'{actual} (期望: {expected})")
print(
f" ❌ MCP_FEEDBACK_MODE='{env_value}'{actual} (期望: {expected})"
)
return False
# 2. Electron 環境測試
@ -348,9 +374,10 @@ def test_full_integration():
# 3. Web UI 基本功能測試
print("\n📋 3. 測試 Web UI 基本功能...")
from .web.main import WebUIManager
import tempfile
from .web.main import WebUIManager
with tempfile.TemporaryDirectory() as temp_dir:
manager = WebUIManager(host="127.0.0.1", port=8766) # 使用不同端口避免衝突
session_id = manager.create_session(temp_dir, "整合測試會話")
@ -363,7 +390,7 @@ def test_full_integration():
# 4. 桌面模式檢測測試
print("\n📋 4. 測試桌面模式檢測...")
os.environ['MCP_FEEDBACK_MODE'] = 'desktop'
os.environ["MCP_FEEDBACK_MODE"] = "desktop"
manager = WebUIManager()
if manager.should_use_desktop_mode():
@ -379,16 +406,19 @@ def test_full_integration():
except Exception as e:
print(f"❌ 完整整合測試失敗: {e}")
import traceback
traceback.print_exc()
return False
def show_version():
"""顯示版本資訊"""
from . import __version__, __author__
from . import __author__, __version__
print(f"MCP Feedback Enhanced Enhanced v{__version__}")
print(f"作者: {__author__}")
print("GitHub: https://github.com/Minidoracat/mcp-feedback-enhanced")
if __name__ == "__main__":
main()
main()

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
統一調試日誌模組
================
@ -29,26 +28,26 @@ from typing import Any
def debug_log(message: Any, prefix: str = "DEBUG") -> None:
"""
輸出調試訊息到標準錯誤避免污染標準輸出
Args:
message: 要輸出的調試信息
prefix: 調試信息的前綴標識默認為 "DEBUG"
"""
# 只在啟用調試模式時才輸出,避免干擾 MCP 通信
if not os.getenv("MCP_DEBUG", "").lower() in ("true", "1", "yes", "on"):
if os.getenv("MCP_DEBUG", "").lower() not in ("true", "1", "yes", "on"):
return
try:
# 確保消息是字符串類型
if not isinstance(message, str):
message = str(message)
# 安全地輸出到 stderr處理編碼問題
try:
print(f"[{prefix}] {message}", file=sys.stderr, flush=True)
except UnicodeEncodeError:
# 如果遇到編碼問題,使用 ASCII 安全模式
safe_message = message.encode('ascii', errors='replace').decode('ascii')
safe_message = message.encode("ascii", errors="replace").decode("ascii")
print(f"[{prefix}] {safe_message}", file=sys.stderr, flush=True)
except Exception:
# 最後的備用方案:靜默失敗,不影響主程序
@ -77,4 +76,4 @@ def is_debug_enabled() -> bool:
def set_debug_mode(enabled: bool) -> None:
"""設置調試模式(用於測試)"""
os.environ["MCP_DEBUG"] = "true" if enabled else "false"
os.environ["MCP_DEBUG"] = "true" if enabled else "false"

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
桌面應用模組
===========
@ -26,30 +25,35 @@ from ..debug import web_debug_log as debug_log
def is_desktop_available() -> bool:
"""
檢測桌面環境是否可用
Returns:
bool: True 表示桌面環境可用
"""
try:
# 檢查是否有 Node.js 環境
import subprocess
result = subprocess.run(['node', '--version'],
capture_output=True,
text=True,
timeout=5)
result = subprocess.run(
["node", "--version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode != 0:
debug_log("Node.js 不可用,桌面模式不可用")
return False
# 檢查是否為遠程環境
from ..server import is_remote_environment
if is_remote_environment():
debug_log("檢測到遠程環境,桌面模式不適用")
return False
debug_log("桌面環境檢測通過")
return True
except (subprocess.TimeoutExpired, FileNotFoundError, ImportError) as e:
debug_log(f"桌面環境檢測失敗: {e}")
return False
@ -75,10 +79,12 @@ async def launch_desktop_app(project_dir: str, summary: str, timeout: int) -> di
try:
# 創建 Electron 管理器
from .electron_manager import ElectronManager
manager = ElectronManager()
# 首先啟動 Web 服務器(桌面應用需要載入 Web UI
from ..web import get_web_ui_manager
web_manager = get_web_ui_manager()
# 創建會話
@ -95,6 +101,7 @@ async def launch_desktop_app(project_dir: str, summary: str, timeout: int) -> di
# 等待 Web 服務器完全啟動
import time
debug_log("等待 Web 服務器啟動...")
time.sleep(5) # 增加等待時間
@ -102,7 +109,7 @@ async def launch_desktop_app(project_dir: str, summary: str, timeout: int) -> di
if web_manager.server_thread and web_manager.server_thread.is_alive():
debug_log(f"✅ Web 服務器成功啟動在端口: {web_manager.port}")
else:
raise RuntimeError(f"Web 服務器啟動失敗")
raise RuntimeError("Web 服務器啟動失敗")
# 設置 Web 服務器端口
manager.set_web_server_port(web_manager.port)
@ -117,52 +124,49 @@ async def launch_desktop_app(project_dir: str, summary: str, timeout: int) -> di
result = await session.wait_for_feedback(timeout)
debug_log("收到桌面應用用戶回饋")
return result
else:
debug_log("桌面應用啟動失敗,回退到 Web 模式")
# 回退到 Web 模式
from ..web import launch_web_feedback_ui
return await launch_web_feedback_ui(project_dir, summary, timeout)
debug_log("桌面應用啟動失敗,回退到 Web 模式")
# 回退到 Web 模式
from ..web import launch_web_feedback_ui
return await launch_web_feedback_ui(project_dir, summary, timeout)
except Exception as e:
debug_log(f"桌面應用啟動過程中出錯: {e}")
debug_log("回退到 Web 模式")
# 回退到 Web 模式
from ..web import launch_web_feedback_ui
return await launch_web_feedback_ui(project_dir, summary, timeout)
class ElectronManager:
"""Electron 管理器 - 預留接口"""
def __init__(self):
"""初始化 Electron 管理器"""
self.electron_process = None
self.web_server_port = None
debug_log("ElectronManager 初始化(預留實現)")
async def launch_desktop_app(self, summary: str, project_dir: str) -> bool:
"""
啟動桌面應用
Args:
summary: AI 工作摘要
project_dir: 專案目錄
Returns:
bool: 啟動是否成功
"""
debug_log("桌面應用啟動功能尚未實現")
debug_log("此功能將在階段 2 中實現")
return False
def is_available(self) -> bool:
"""檢查桌面管理器是否可用"""
return is_desktop_available()
# 主要導出介面
__all__ = [
"is_desktop_available",
"launch_desktop_app",
"ElectronManager"
]
__all__ = ["ElectronManager", "is_desktop_available", "launch_desktop_app"]

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Electron 管理器
==============
@ -16,11 +15,9 @@ Electron 管理器
版本: 2.3.0
"""
import subprocess
import asyncio
import os
import subprocess
from pathlib import Path
from typing import Optional
from ..debug import web_debug_log as debug_log
from ..utils.error_handler import ErrorHandler, ErrorType
@ -28,16 +25,16 @@ from ..utils.error_handler import ErrorHandler, ErrorType
class ElectronManager:
"""Electron 進程管理器"""
def __init__(self):
"""初始化 Electron 管理器"""
self.electron_process: Optional[subprocess.Popen] = None
self.electron_process: subprocess.Popen | None = None
self.desktop_dir = Path(__file__).parent
self.web_server_port: Optional[int] = None
self.web_server_port: int | None = None
debug_log("ElectronManager 初始化完成")
debug_log(f"桌面模組目錄: {self.desktop_dir}")
async def launch_desktop_app(self, summary: str, project_dir: str) -> bool:
"""
啟動 Electron 桌面應用
@ -64,50 +61,52 @@ class ElectronManager:
if success:
debug_log("Electron 桌面應用啟動成功")
return True
else:
debug_log("Electron 桌面應用啟動失敗")
return False
debug_log("Electron 桌面應用啟動失敗")
return False
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "桌面應用啟動", "project_dir": project_dir},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"桌面應用啟動異常 [錯誤ID: {error_id}]: {e}")
return False
def set_web_server_port(self, port: int):
"""設置 Web 服務器端口"""
self.web_server_port = port
debug_log(f"設置 Web 服務器端口: {port}")
def is_electron_available(self) -> bool:
"""檢查 Electron 是否可用"""
try:
# 檢查 Node.js
result = subprocess.run(['node', '--version'],
capture_output=True,
text=True,
timeout=5)
result = subprocess.run(
["node", "--version"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode != 0:
debug_log("Node.js 不可用")
return False
debug_log(f"Node.js 版本: {result.stdout.strip()}")
# 檢查 package.json 是否存在
package_json = self.desktop_dir / "package.json"
if not package_json.exists():
debug_log("package.json 不存在,需要在階段 2 中創建")
return False
return True
except Exception as e:
debug_log(f"Electron 可用性檢查失敗: {e}")
return False
async def ensure_dependencies(self) -> bool:
"""確保依賴已安裝"""
debug_log("檢查 Electron 依賴...")
@ -137,13 +136,11 @@ class ElectronManager:
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "依賴檢查"},
error_type=ErrorType.DEPENDENCY
e, context={"operation": "依賴檢查"}, error_type=ErrorType.DEPENDENCY
)
debug_log(f"依賴檢查失敗 [錯誤ID: {error_id}]: {e}")
return False
def cleanup(self):
"""清理資源"""
if self.electron_process:
@ -166,21 +163,30 @@ class ElectronManager:
# 關閉管道以避免 ResourceWarning
try:
# 對於 asyncio 子進程,需要特殊處理
if hasattr(self.electron_process, 'stdout') and self.electron_process.stdout:
if hasattr(self.electron_process.stdout, 'close'):
if (
hasattr(self.electron_process, "stdout")
and self.electron_process.stdout
):
if hasattr(self.electron_process.stdout, "close"):
self.electron_process.stdout.close()
if hasattr(self.electron_process, 'stderr') and self.electron_process.stderr:
if hasattr(self.electron_process.stderr, 'close'):
if (
hasattr(self.electron_process, "stderr")
and self.electron_process.stderr
):
if hasattr(self.electron_process.stderr, "close"):
self.electron_process.stderr.close()
if hasattr(self.electron_process, 'stdin') and self.electron_process.stdin:
if hasattr(self.electron_process.stdin, 'close'):
if (
hasattr(self.electron_process, "stdin")
and self.electron_process.stdin
):
if hasattr(self.electron_process.stdin, "close"):
self.electron_process.stdin.close()
except Exception:
# 忽略管道關閉錯誤,這些通常是無害的
pass
self.electron_process = None
async def _create_package_json(self):
"""創建 package.json 文件"""
package_config = {
@ -188,21 +194,15 @@ class ElectronManager:
"version": "2.3.0",
"description": "MCP Feedback Enhanced Desktop Application",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev"
},
"dependencies": {
"electron": "^28.0.0"
},
"devDependencies": {
"electron-builder": "^24.0.0"
}
"scripts": {"start": "electron .", "dev": "electron . --dev"},
"dependencies": {"electron": "^28.0.0"},
"devDependencies": {"electron-builder": "^24.0.0"},
}
package_json_path = self.desktop_dir / "package.json"
with open(package_json_path, 'w', encoding='utf-8') as f:
with open(package_json_path, "w", encoding="utf-8") as f:
import json
json.dump(package_config, f, indent=2, ensure_ascii=False)
debug_log(f"已創建 package.json: {package_json_path}")
@ -213,12 +213,12 @@ class ElectronManager:
try:
# 使用 npm install
install_cmd = ['npm', 'install']
install_cmd = ["npm", "install"]
process = await asyncio.create_subprocess_exec(
*install_cmd,
cwd=self.desktop_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
@ -226,9 +226,8 @@ class ElectronManager:
if process.returncode == 0:
debug_log("Node.js 依賴安裝成功")
return True
else:
debug_log(f"依賴安裝失敗: {stderr.decode()}")
return False
debug_log(f"依賴安裝失敗: {stderr.decode()}")
return False
except Exception as e:
debug_log(f"依賴安裝過程中出錯: {e}")
@ -241,21 +240,29 @@ class ElectronManager:
try:
# 構建 Electron 命令 - 使用本地安裝的 electron
import platform
if platform.system() == "Windows":
electron_path = self.desktop_dir / "node_modules" / ".bin" / "electron.cmd"
electron_path = (
self.desktop_dir / "node_modules" / ".bin" / "electron.cmd"
)
else:
electron_path = self.desktop_dir / "node_modules" / ".bin" / "electron"
if electron_path.exists():
electron_cmd = [
str(electron_path), '.',
'--port', str(self.web_server_port or 8765)
str(electron_path),
".",
"--port",
str(self.web_server_port or 8765),
]
else:
# 回退到 npx
electron_cmd = [
'npx', 'electron', '.',
'--port', str(self.web_server_port or 8765)
"npx",
"electron",
".",
"--port",
str(self.web_server_port or 8765),
]
debug_log(f"使用 Electron 命令: {' '.join(electron_cmd)}")
@ -265,7 +272,7 @@ class ElectronManager:
*electron_cmd,
cwd=self.desktop_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
debug_log(f"Electron 進程已啟動PID: {self.electron_process.pid}")
@ -277,16 +284,17 @@ class ElectronManager:
if self.electron_process.returncode is None:
debug_log("Electron 進程運行正常")
return True
else:
debug_log(f"Electron 進程異常退出,返回碼: {self.electron_process.returncode}")
# 讀取錯誤輸出
try:
_, stderr = await self.electron_process.communicate()
if stderr:
debug_log(f"Electron 錯誤輸出: {stderr.decode()}")
except Exception as e:
debug_log(f"讀取 Electron 錯誤輸出失敗: {e}")
return False
debug_log(
f"Electron 進程異常退出,返回碼: {self.electron_process.returncode}"
)
# 讀取錯誤輸出
try:
_, stderr = await self.electron_process.communicate()
if stderr:
debug_log(f"Electron 錯誤輸出: {stderr.decode()}")
except Exception as e:
debug_log(f"讀取 Electron 錯誤輸出失敗: {e}")
return False
except Exception as e:
debug_log(f"啟動 Electron 進程失敗: {e}")
@ -301,9 +309,9 @@ class ElectronManager:
async def create_electron_manager() -> ElectronManager:
"""創建 Electron 管理器實例"""
manager = ElectronManager()
# 檢查可用性
if not manager.is_electron_available():
debug_log("Electron 環境不可用,建議使用 Web 模式")
return manager

View File

@ -2,16 +2,16 @@
/**
* Electron 主進程
* ===============
*
*
* 此文件是 MCP Feedback Enhanced 桌面應用的主進程入口點
* 負責創建和管理 BrowserWindow以及與現有 Web UI 的整合
*
*
* 主要功能
* - 創建和管理應用視窗
* - 載入本地 Web 服務器內容
* - 處理應用生命週期事件
* - 提供桌面應用特有的功能
*
*
* 作者: Augment Agent
* 版本: 2.3.0
*/
@ -42,7 +42,7 @@ class ElectronApp {
this.mainWindow = null;
this.webServerPort = APP_CONFIG.defaultPort;
this.isDevMode = process.argv.includes('--dev');
this.setupEventHandlers();
this.parseCommandLineArgs();
}
@ -53,7 +53,7 @@ class ElectronApp {
parseCommandLineArgs() {
const args = process.argv;
const portIndex = args.indexOf('--port');
if (portIndex !== -1 && portIndex + 1 < args.length) {
const port = parseInt(args[portIndex + 1]);
if (!isNaN(port) && port > 0 && port < 65536) {
@ -197,7 +197,7 @@ class ElectronApp {
<head>
<title>連接錯誤 - ${APP_CONFIG.name}</title>
<style>
body {
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
margin: 0;
@ -247,7 +247,7 @@ class ElectronApp {
*/
getAppIcon() {
const iconPath = path.join(__dirname, 'assets');
if (process.platform === 'win32') {
return path.join(iconPath, 'icon.ico');
} else if (process.platform === 'darwin') {

View File

@ -1,16 +1,16 @@
/**
* Electron 預載腳本
* ==================
*
*
* 此腳本在渲染進程中運行但在網頁內容載入之前執行
* 它提供了安全的方式讓網頁與主進程通信同時保持安全性
*
*
* 主要功能
* - 提供安全的 IPC 通信接口
* - 擴展現有的 WebSocket 管理器
* - 添加桌面應用特有的 API
* - 標記桌面環境
*
*
* 作者: Augment Agent
* 版本: 2.3.0
*/
@ -45,7 +45,7 @@ const desktopAPI = {
onSessionUpdate: (callback) => {
const wrappedCallback = (event, ...args) => callback(...args);
ipcRenderer.on('session-updated', wrappedCallback);
// 返回清理函數
return () => {
ipcRenderer.removeListener('session-updated', wrappedCallback);
@ -55,7 +55,7 @@ const desktopAPI = {
onFeedbackRequest: (callback) => {
const wrappedCallback = (event, ...args) => callback(...args);
ipcRenderer.on('feedback-request', wrappedCallback);
return () => {
ipcRenderer.removeListener('feedback-request', wrappedCallback);
};
@ -124,18 +124,18 @@ const logger = {
try {
// 主要的桌面 API
contextBridge.exposeInMainWorld('electronAPI', desktopAPI);
// Web UI 擴展
contextBridge.exposeInMainWorld('desktopExtensions', webUIExtensions);
// 日誌工具
contextBridge.exposeInMainWorld('desktopLogger', logger);
// 標記桌面環境(向後兼容)
contextBridge.exposeInMainWorld('MCP_DESKTOP_MODE', true);
logger.info('桌面 API 已成功暴露到渲染進程');
} catch (error) {
console.error('暴露桌面 API 失敗:', error);
}
@ -145,19 +145,19 @@ try {
*/
window.addEventListener('DOMContentLoaded', () => {
logger.debug('DOM 載入完成,開始桌面環境初始化');
// 添加桌面環境樣式類
document.body.classList.add('desktop-mode');
document.body.classList.add(`platform-${process.platform}`);
// 設置桌面環境變數
document.documentElement.style.setProperty('--is-desktop', '1');
// 如果是 macOS添加特殊樣式
if (process.platform === 'darwin') {
document.body.classList.add('macos-titlebar');
}
// 監聽鍵盤快捷鍵
document.addEventListener('keydown', (event) => {
// Ctrl/Cmd + R: 重新載入
@ -167,20 +167,20 @@ window.addEventListener('DOMContentLoaded', () => {
location.reload();
}
}
// F12: 開發者工具
if (event.key === 'F12' && process.env.NODE_ENV === 'development') {
event.preventDefault();
desktopAPI.dev.openDevTools();
}
// Escape: 最小化視窗
if (event.key === 'Escape' && event.ctrlKey) {
event.preventDefault();
desktopAPI.window.minimize();
}
});
logger.debug('桌面環境初始化完成');
});

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
國際化支援模組
===============
@ -16,53 +15,54 @@
作者: Minidoracat
"""
import os
import sys
import locale
import json
from typing import Dict, Any, Optional, Union
import locale
import os
from pathlib import Path
from typing import Any
from .debug import i18n_debug_log as debug_log
class I18nManager:
"""國際化管理器 - 新架構版本"""
def __init__(self):
self._current_language = None
self._translations = {}
self._supported_languages = ['zh-TW', 'en', 'zh-CN']
self._fallback_language = 'en'
self._supported_languages = ["zh-TW", "en", "zh-CN"]
self._fallback_language = "en"
self._config_file = self._get_config_file_path()
self._locales_dir = Path(__file__).parent / "web" / "locales"
# 載入翻譯
self._load_all_translations()
# 設定語言
self._current_language = self._detect_language()
def _get_config_file_path(self) -> Path:
"""獲取配置文件路徑"""
config_dir = Path.home() / ".config" / "mcp-feedback-enhanced"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir / "language.json"
def _load_all_translations(self) -> None:
"""載入所有語言的翻譯檔案"""
self._translations = {}
for lang_code in self._supported_languages:
lang_dir = self._locales_dir / lang_code
translation_file = lang_dir / "translation.json"
if translation_file.exists():
try:
with open(translation_file, 'r', encoding='utf-8') as f:
with open(translation_file, encoding="utf-8") as f:
data = json.load(f)
self._translations[lang_code] = data
debug_log(f"成功載入語言 {lang_code}: {data.get('meta', {}).get('displayName', lang_code)}")
debug_log(
f"成功載入語言 {lang_code}: {data.get('meta', {}).get('displayName', lang_code)}"
)
except Exception as e:
debug_log(f"載入語言檔案失敗 {lang_code}: {e}")
# 如果載入失敗,使用空的翻譯
@ -70,60 +70,64 @@ class I18nManager:
else:
debug_log(f"找不到語言檔案: {translation_file}")
self._translations[lang_code] = {}
def _detect_language(self) -> str:
"""自動偵測語言"""
# 1. 優先使用用戶保存的語言設定
saved_lang = self._load_saved_language()
if saved_lang and saved_lang in self._supported_languages:
return saved_lang
# 2. 檢查環境變數
env_lang = os.getenv('MCP_LANGUAGE', '').strip()
env_lang = os.getenv("MCP_LANGUAGE", "").strip()
if env_lang and env_lang in self._supported_languages:
return env_lang
# 3. 自動偵測系統語言
try:
# 獲取系統語言
system_locale = locale.getdefaultlocale()[0]
if system_locale:
if system_locale.startswith('zh_TW') or system_locale.startswith('zh_Hant'):
return 'zh-TW'
elif system_locale.startswith('zh_CN') or system_locale.startswith('zh_Hans'):
return 'zh-CN'
elif system_locale.startswith('en'):
return 'en'
if system_locale.startswith("zh_TW") or system_locale.startswith(
"zh_Hant"
):
return "zh-TW"
if system_locale.startswith("zh_CN") or system_locale.startswith(
"zh_Hans"
):
return "zh-CN"
if system_locale.startswith("en"):
return "en"
except Exception:
pass
# 4. 回退到默認語言
return self._fallback_language
def _load_saved_language(self) -> Optional[str]:
def _load_saved_language(self) -> str | None:
"""載入保存的語言設定"""
try:
if self._config_file.exists():
with open(self._config_file, 'r', encoding='utf-8') as f:
with open(self._config_file, encoding="utf-8") as f:
config = json.load(f)
return config.get('language')
return config.get("language")
except Exception:
pass
return None
def save_language(self, language: str) -> None:
"""保存語言設定"""
try:
config = {'language': language}
with open(self._config_file, 'w', encoding='utf-8') as f:
config = {"language": language}
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception:
pass
def get_current_language(self) -> str:
"""獲取當前語言"""
return self._current_language
def set_language(self, language: str) -> bool:
"""設定語言"""
if language in self._supported_languages:
@ -131,201 +135,197 @@ class I18nManager:
self.save_language(language)
return True
return False
def get_supported_languages(self) -> list:
"""獲取支援的語言列表"""
return self._supported_languages.copy()
def get_language_info(self, language_code: str) -> Dict[str, Any]:
def get_language_info(self, language_code: str) -> dict[str, Any]:
"""獲取語言的元資料信息"""
if language_code in self._translations:
return self._translations[language_code].get('meta', {})
return self._translations[language_code].get("meta", {})
return {}
def _get_nested_value(self, data: Dict[str, Any], key_path: str) -> Optional[str]:
def _get_nested_value(self, data: dict[str, Any], key_path: str) -> str | None:
"""從巢狀字典中獲取值,支援點分隔的鍵路徑"""
keys = key_path.split('.')
keys = key_path.split(".")
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return None
return current if isinstance(current, str) else None
def t(self, key: str, **kwargs) -> str:
"""
翻譯函數 - 支援新舊兩種鍵值格式
新格式: 'buttons.submit' -> data['buttons']['submit']
舊格式: 'btn_submit_feedback' -> 兼容舊的鍵值
"""
# 獲取當前語言的翻譯
current_translations = self._translations.get(self._current_language, {})
# 嘗試新格式(巢狀鍵)
text = self._get_nested_value(current_translations, key)
# 如果沒有找到,嘗試舊格式的兼容映射
if text is None:
text = self._get_legacy_translation(current_translations, key)
# 如果還是沒有找到,嘗試使用回退語言
if text is None:
fallback_translations = self._translations.get(self._fallback_language, {})
text = self._get_nested_value(fallback_translations, key)
if text is None:
text = self._get_legacy_translation(fallback_translations, key)
# 最後回退到鍵本身
if text is None:
text = key
# 處理格式化參數
if kwargs:
try:
text = text.format(**kwargs)
except (KeyError, ValueError):
pass
return text
def _get_legacy_translation(self, translations: Dict[str, Any], key: str) -> Optional[str]:
def _get_legacy_translation(
self, translations: dict[str, Any], key: str
) -> str | None:
"""獲取舊格式翻譯的兼容方法"""
# 舊鍵到新鍵的映射
legacy_mapping = {
# 應用程式
'app_title': 'app.title',
'project_directory': 'app.projectDirectory',
'language': 'app.language',
'settings': 'app.settings',
"app_title": "app.title",
"project_directory": "app.projectDirectory",
"language": "app.language",
"settings": "app.settings",
# 分頁
'feedback_tab': 'tabs.feedback',
'command_tab': 'tabs.command',
'images_tab': 'tabs.images',
"feedback_tab": "tabs.feedback",
"command_tab": "tabs.command",
"images_tab": "tabs.images",
# 回饋
'feedback_title': 'feedback.title',
'feedback_description': 'feedback.description',
'feedback_placeholder': 'feedback.placeholder',
"feedback_title": "feedback.title",
"feedback_description": "feedback.description",
"feedback_placeholder": "feedback.placeholder",
# 命令
'command_title': 'command.title',
'command_description': 'command.description',
'command_placeholder': 'command.placeholder',
'command_output': 'command.output',
"command_title": "command.title",
"command_description": "command.description",
"command_placeholder": "command.placeholder",
"command_output": "command.output",
# 圖片
'images_title': 'images.title',
'images_select': 'images.select',
'images_paste': 'images.paste',
'images_clear': 'images.clear',
'images_status': 'images.status',
'images_status_with_size': 'images.statusWithSize',
'images_drag_hint': 'images.dragHint',
'images_delete_confirm': 'images.deleteConfirm',
'images_delete_title': 'images.deleteTitle',
'images_size_warning': 'images.sizeWarning',
'images_format_error': 'images.formatError',
"images_title": "images.title",
"images_select": "images.select",
"images_paste": "images.paste",
"images_clear": "images.clear",
"images_status": "images.status",
"images_status_with_size": "images.statusWithSize",
"images_drag_hint": "images.dragHint",
"images_delete_confirm": "images.deleteConfirm",
"images_delete_title": "images.deleteTitle",
"images_size_warning": "images.sizeWarning",
"images_format_error": "images.formatError",
# 按鈕
'submit': 'buttons.submit',
'cancel': 'buttons.cancel',
'close': 'buttons.close',
'clear': 'buttons.clear',
'btn_submit_feedback': 'buttons.submitFeedback',
'btn_cancel': 'buttons.cancel',
'btn_select_files': 'buttons.selectFiles',
'btn_paste_clipboard': 'buttons.pasteClipboard',
'btn_clear_all': 'buttons.clearAll',
'btn_run_command': 'buttons.runCommand',
"submit": "buttons.submit",
"cancel": "buttons.cancel",
"close": "buttons.close",
"clear": "buttons.clear",
"btn_submit_feedback": "buttons.submitFeedback",
"btn_cancel": "buttons.cancel",
"btn_select_files": "buttons.selectFiles",
"btn_paste_clipboard": "buttons.pasteClipboard",
"btn_clear_all": "buttons.clearAll",
"btn_run_command": "buttons.runCommand",
# 狀態
'feedback_submitted': 'status.feedbackSubmitted',
'feedback_cancelled': 'status.feedbackCancelled',
'timeout_message': 'status.timeoutMessage',
'error_occurred': 'status.errorOccurred',
'loading': 'status.loading',
'connecting': 'status.connecting',
'connected': 'status.connected',
'disconnected': 'status.disconnected',
'uploading': 'status.uploading',
'upload_success': 'status.uploadSuccess',
'upload_failed': 'status.uploadFailed',
'command_running': 'status.commandRunning',
'command_finished': 'status.commandFinished',
'paste_success': 'status.pasteSuccess',
'paste_failed': 'status.pasteFailed',
'invalid_file_type': 'status.invalidFileType',
'file_too_large': 'status.fileTooLarge',
"feedback_submitted": "status.feedbackSubmitted",
"feedback_cancelled": "status.feedbackCancelled",
"timeout_message": "status.timeoutMessage",
"error_occurred": "status.errorOccurred",
"loading": "status.loading",
"connecting": "status.connecting",
"connected": "status.connected",
"disconnected": "status.disconnected",
"uploading": "status.uploading",
"upload_success": "status.uploadSuccess",
"upload_failed": "status.uploadFailed",
"command_running": "status.commandRunning",
"command_finished": "status.commandFinished",
"paste_success": "status.pasteSuccess",
"paste_failed": "status.pasteFailed",
"invalid_file_type": "status.invalidFileType",
"file_too_large": "status.fileTooLarge",
# 其他
'ai_summary': 'aiSummary',
'language_selector': 'languageSelector',
'language_zh_tw': 'languageNames.zhTw',
'language_en': 'languageNames.en',
'language_zh_cn': 'languageNames.zhCn',
"ai_summary": "aiSummary",
"language_selector": "languageSelector",
"language_zh_tw": "languageNames.zhTw",
"language_en": "languageNames.en",
"language_zh_cn": "languageNames.zhCn",
# 測試
'test_web_ui_summary': 'test.webUiSummary',
"test_web_ui_summary": "test.webUiSummary",
}
# 檢查是否有對應的新鍵
new_key = legacy_mapping.get(key)
if new_key:
return self._get_nested_value(translations, new_key)
return None
def get_language_display_name(self, language_code: str) -> str:
"""獲取語言的顯示名稱"""
# 直接從當前語言的翻譯中獲取,避免遞歸
current_translations = self._translations.get(self._current_language, {})
# 根據語言代碼構建鍵值
lang_key = None
if language_code == 'zh-TW':
lang_key = 'languageNames.zhTw'
elif language_code == 'zh-CN':
lang_key = 'languageNames.zhCn'
elif language_code == 'en':
lang_key = 'languageNames.en'
if language_code == "zh-TW":
lang_key = "languageNames.zhTw"
elif language_code == "zh-CN":
lang_key = "languageNames.zhCn"
elif language_code == "en":
lang_key = "languageNames.en"
else:
# 通用格式
lang_key = f"languageNames.{language_code.replace('-', '').lower()}"
# 直接獲取翻譯,避免調用 self.t() 產生遞歸
if lang_key:
display_name = self._get_nested_value(current_translations, lang_key)
if display_name:
return display_name
# 回退到元資料中的顯示名稱
meta = self.get_language_info(language_code)
return meta.get('displayName', language_code)
return meta.get("displayName", language_code)
def reload_translations(self) -> None:
"""重新載入所有翻譯檔案(開發時使用)"""
self._load_all_translations()
def add_language(self, language_code: str, translation_file_path: str) -> bool:
"""動態添加新語言支援"""
try:
translation_file = Path(translation_file_path)
if not translation_file.exists():
return False
with open(translation_file, 'r', encoding='utf-8') as f:
with open(translation_file, encoding="utf-8") as f:
data = json.load(f)
self._translations[language_code] = data
if language_code not in self._supported_languages:
self._supported_languages.append(language_code)
debug_log(f"成功添加語言 {language_code}: {data.get('meta', {}).get('displayName', language_code)}")
debug_log(
f"成功添加語言 {language_code}: {data.get('meta', {}).get('displayName', language_code)}"
)
return True
except Exception as e:
debug_log(f"添加語言失敗 {language_code}: {e}")
@ -335,6 +335,7 @@ class I18nManager:
# 全域的國際化管理器實例
_i18n_manager = None
def get_i18n_manager() -> I18nManager:
"""獲取全域的國際化管理器實例"""
global _i18n_manager
@ -342,18 +343,22 @@ def get_i18n_manager() -> I18nManager:
_i18n_manager = I18nManager()
return _i18n_manager
def t(key: str, **kwargs) -> str:
"""便捷的翻譯函數"""
return get_i18n_manager().t(key, **kwargs)
def set_language(language: str) -> bool:
"""設定語言"""
return get_i18n_manager().set_language(language)
def get_current_language() -> str:
"""獲取當前語言"""
return get_i18n_manager().get_current_language()
def reload_translations() -> None:
"""重新載入翻譯(開發用)"""
get_i18n_manager().reload_translations()
get_i18n_manager().reload_translations()

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Feedback Enhanced 伺服器主要模組
@ -24,95 +23,92 @@ MCP Feedback Enhanced 伺服器主要模組
重構: 模塊化設計
"""
import base64
import io
import json
import os
import sys
import json
import tempfile
import asyncio
import base64
from typing import Annotated, List
from enum import Enum
import io
from typing import Annotated
from fastmcp import FastMCP
from fastmcp.utilities.types import Image as MCPImage
from mcp.types import TextContent
from pydantic import Field
# 導入多語系支援
from .i18n import get_i18n_manager
# 導入統一的調試功能
from .debug import server_debug_log as debug_log
# 導入多語系支援
# 導入錯誤處理框架
from .utils.error_handler import ErrorHandler, ErrorType
# 導入資源管理器
from .utils.resource_manager import get_resource_manager, create_temp_file
from .utils.resource_manager import create_temp_file
# ===== 編碼初始化 =====
def init_encoding():
"""初始化編碼設置,確保正確處理中文字符"""
try:
# Windows 特殊處理
if sys.platform == 'win32':
if sys.platform == "win32":
import msvcrt
# 設置為二進制模式
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
# 重新包裝為 UTF-8 文本流,並禁用緩衝
sys.stdin = io.TextIOWrapper(
sys.stdin.detach(),
encoding='utf-8',
errors='replace',
newline=None
sys.stdin.detach(), encoding="utf-8", errors="replace", newline=None
)
sys.stdout = io.TextIOWrapper(
sys.stdout.detach(),
encoding='utf-8',
errors='replace',
newline='',
write_through=True # 關鍵:禁用寫入緩衝
sys.stdout.detach(),
encoding="utf-8",
errors="replace",
newline="",
write_through=True, # 關鍵:禁用寫入緩衝
)
else:
# 非 Windows 系統的標準設置
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdin, 'reconfigure'):
sys.stdin.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
# 設置 stderr 編碼(用於調試訊息)
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
return True
except Exception as e:
except Exception:
# 如果編碼設置失敗,嘗試基本設置
try:
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdin, 'reconfigure'):
sys.stdin.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stdin, "reconfigure"):
sys.stdin.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except:
pass
return False
# 初始化編碼(在導入時就執行)
_encoding_initialized = init_encoding()
# ===== 常數定義 =====
SERVER_NAME = "互動式回饋收集 MCP"
SSH_ENV_VARS = ['SSH_CONNECTION', 'SSH_CLIENT', 'SSH_TTY']
REMOTE_ENV_VARS = ['REMOTE_CONTAINERS', 'CODESPACES']
SSH_ENV_VARS = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
REMOTE_ENV_VARS = ["REMOTE_CONTAINERS", "CODESPACES"]
# ===== 回饋模式枚舉 =====
class FeedbackMode(Enum):
"""回饋模式枚舉"""
WEB = "web"
DESKTOP = "desktop"
AUTO = "auto"
@ -130,16 +126,18 @@ def get_feedback_mode() -> FeedbackMode:
Returns:
FeedbackMode: 回饋模式
"""
mode = os.environ.get('MCP_FEEDBACK_MODE', 'auto').lower()
mode = os.environ.get("MCP_FEEDBACK_MODE", "auto").lower()
try:
return FeedbackMode(mode)
except ValueError:
debug_log(f"無效的 MCP_FEEDBACK_MODE 值: {mode},使用預設值 'auto'")
return FeedbackMode.AUTO
# 初始化 MCP 服務器
from . import __version__
# 確保 log_level 設定為正確的大寫格式
fastmcp_settings = {}
@ -164,22 +162,22 @@ def is_wsl_environment() -> bool:
"""
try:
# 檢查 /proc/version 文件是否包含 WSL 標識
if os.path.exists('/proc/version'):
with open('/proc/version', 'r') as f:
if os.path.exists("/proc/version"):
with open("/proc/version") as f:
version_info = f.read().lower()
if 'microsoft' in version_info or 'wsl' in version_info:
if "microsoft" in version_info or "wsl" in version_info:
debug_log("偵測到 WSL 環境(通過 /proc/version")
return True
# 檢查 WSL 相關環境變數
wsl_env_vars = ['WSL_DISTRO_NAME', 'WSL_INTEROP', 'WSLENV']
wsl_env_vars = ["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"]
for env_var in wsl_env_vars:
if os.getenv(env_var):
debug_log(f"偵測到 WSL 環境變數: {env_var}")
return True
# 檢查是否存在 WSL 特有的路徑
wsl_paths = ['/mnt/c', '/mnt/d', '/proc/sys/fs/binfmt_misc/WSLInterop']
wsl_paths = ["/mnt/c", "/mnt/d", "/proc/sys/fs/binfmt_misc/WSLInterop"]
for path in wsl_paths:
if os.path.exists(path):
debug_log(f"偵測到 WSL 特有路徑: {path}")
@ -216,51 +214,52 @@ def is_remote_environment() -> bool:
return True
# 檢查 Docker 容器
if os.path.exists('/.dockerenv'):
if os.path.exists("/.dockerenv"):
debug_log("偵測到 Docker 容器環境")
return True
# Windows 遠端桌面檢查
if sys.platform == 'win32':
session_name = os.getenv('SESSIONNAME', '')
if session_name and 'RDP' in session_name:
if sys.platform == "win32":
session_name = os.getenv("SESSIONNAME", "")
if session_name and "RDP" in session_name:
debug_log(f"偵測到 Windows 遠端桌面: {session_name}")
return True
# Linux 無顯示環境檢查(但排除 WSL
if sys.platform.startswith('linux') and not os.getenv('DISPLAY') and not is_wsl_environment():
if (
sys.platform.startswith("linux")
and not os.getenv("DISPLAY")
and not is_wsl_environment()
):
debug_log("偵測到 Linux 無顯示環境")
return True
return False
def save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
"""
將回饋資料儲存到 JSON 文件
Args:
feedback_data: 回饋資料字典
file_path: 儲存路徑若為 None 則自動產生臨時文件
Returns:
str: 儲存的文件路徑
"""
if file_path is None:
# 使用資源管理器創建臨時文件
file_path = create_temp_file(suffix='.json', prefix='feedback_')
file_path = create_temp_file(suffix=".json", prefix="feedback_")
# 確保目錄存在
directory = os.path.dirname(file_path)
if directory and not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
# 複製數據以避免修改原始數據
json_data = feedback_data.copy()
# 處理圖片數據:將 bytes 轉換為 base64 字符串以便 JSON 序列化
if "images" in json_data and isinstance(json_data["images"], list):
processed_images = []
@ -269,17 +268,19 @@ def save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
processed_img = img.copy()
# 如果 data 是 bytes轉換為 base64 字符串
if isinstance(img["data"], bytes):
processed_img["data"] = base64.b64encode(img["data"]).decode('utf-8')
processed_img["data"] = base64.b64encode(img["data"]).decode(
"utf-8"
)
processed_img["data_type"] = "base64"
processed_images.append(processed_img)
else:
processed_images.append(img)
json_data["images"] = processed_images
# 儲存資料
with open(file_path, "w", encoding="utf-8") as f:
json.dump(json_data, f, ensure_ascii=False, indent=2)
debug_log(f"回饋資料已儲存至: {file_path}")
return file_path
@ -287,32 +288,32 @@ def save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
def create_feedback_text(feedback_data: dict) -> str:
"""
建立格式化的回饋文字
Args:
feedback_data: 回饋資料字典
Returns:
str: 格式化後的回饋文字
"""
text_parts = []
# 基本回饋內容
if feedback_data.get("interactive_feedback"):
text_parts.append(f"=== 用戶回饋 ===\n{feedback_data['interactive_feedback']}")
# 命令執行日誌
if feedback_data.get("command_logs"):
text_parts.append(f"=== 命令執行日誌 ===\n{feedback_data['command_logs']}")
# 圖片附件概要
if feedback_data.get("images"):
images = feedback_data["images"]
text_parts.append(f"=== 圖片附件概要 ===\n用戶提供了 {len(images)} 張圖片:")
for i, img in enumerate(images, 1):
size = img.get("size", 0)
name = img.get("name", "unknown")
# 智能單位顯示
if size < 1024:
size_str = f"{size} B"
@ -322,79 +323,89 @@ def create_feedback_text(feedback_data: dict) -> str:
else:
size_mb = size / (1024 * 1024)
size_str = f"{size_mb:.1f} MB"
img_info = f" {i}. {name} ({size_str})"
# 為提高兼容性,添加 base64 預覽信息
if img.get("data"):
try:
if isinstance(img["data"], bytes):
img_base64 = base64.b64encode(img["data"]).decode('utf-8')
img_base64 = base64.b64encode(img["data"]).decode("utf-8")
elif isinstance(img["data"], str):
img_base64 = img["data"]
else:
img_base64 = None
if img_base64:
# 只顯示前50個字符的預覽
preview = img_base64[:50] + "..." if len(img_base64) > 50 else img_base64
preview = (
img_base64[:50] + "..."
if len(img_base64) > 50
else img_base64
)
img_info += f"\n Base64 預覽: {preview}"
img_info += f"\n 完整 Base64 長度: {len(img_base64)} 字符"
# 如果 AI 助手不支援 MCP 圖片,可以提供完整 base64
debug_log(f"圖片 {i} Base64 已準備,長度: {len(img_base64)}")
# 檢查是否啟用 Base64 詳細模式(從 UI 設定中獲取)
include_full_base64 = feedback_data.get("settings", {}).get("enable_base64_detail", False)
include_full_base64 = feedback_data.get("settings", {}).get(
"enable_base64_detail", False
)
if include_full_base64:
# 根據檔案名推斷 MIME 類型
file_name = img.get("name", "image.png")
if file_name.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg'
elif file_name.lower().endswith('.gif'):
mime_type = 'image/gif'
elif file_name.lower().endswith('.webp'):
mime_type = 'image/webp'
if file_name.lower().endswith((".jpg", ".jpeg")):
mime_type = "image/jpeg"
elif file_name.lower().endswith(".gif"):
mime_type = "image/gif"
elif file_name.lower().endswith(".webp"):
mime_type = "image/webp"
else:
mime_type = 'image/png'
mime_type = "image/png"
img_info += f"\n 完整 Base64: data:{mime_type};base64,{img_base64}"
except Exception as e:
debug_log(f"圖片 {i} Base64 處理失敗: {e}")
text_parts.append(img_info)
# 添加兼容性說明
text_parts.append("\n💡 注意:如果 AI 助手無法顯示圖片,圖片數據已包含在上述 Base64 信息中。")
text_parts.append(
"\n💡 注意:如果 AI 助手無法顯示圖片,圖片數據已包含在上述 Base64 信息中。"
)
return "\n\n".join(text_parts) if text_parts else "用戶未提供任何回饋內容。"
def process_images(images_data: List[dict]) -> List[MCPImage]:
def process_images(images_data: list[dict]) -> list[MCPImage]:
"""
處理圖片資料轉換為 MCP 圖片對象
Args:
images_data: 圖片資料列表
Returns:
List[MCPImage]: MCP 圖片對象列表
"""
mcp_images = []
for i, img in enumerate(images_data, 1):
try:
if not img.get("data"):
debug_log(f"圖片 {i} 沒有資料,跳過")
continue
# 檢查數據類型並相應處理
if isinstance(img["data"], bytes):
# 如果是原始 bytes 數據,直接使用
image_bytes = img["data"]
debug_log(f"圖片 {i} 使用原始 bytes 數據,大小: {len(image_bytes)} bytes")
debug_log(
f"圖片 {i} 使用原始 bytes 數據,大小: {len(image_bytes)} bytes"
)
elif isinstance(img["data"], str):
# 如果是 base64 字符串,進行解碼
image_bytes = base64.b64decode(img["data"])
@ -402,136 +413,143 @@ def process_images(images_data: List[dict]) -> List[MCPImage]:
else:
debug_log(f"圖片 {i} 數據類型不支援: {type(img['data'])}")
continue
if len(image_bytes) == 0:
debug_log(f"圖片 {i} 數據為空,跳過")
continue
# 根據文件名推斷格式
file_name = img.get("name", "image.png")
if file_name.lower().endswith(('.jpg', '.jpeg')):
image_format = 'jpeg'
elif file_name.lower().endswith('.gif'):
image_format = 'gif'
if file_name.lower().endswith((".jpg", ".jpeg")):
image_format = "jpeg"
elif file_name.lower().endswith(".gif"):
image_format = "gif"
else:
image_format = 'png' # 默認使用 PNG
image_format = "png" # 默認使用 PNG
# 創建 MCPImage 對象
mcp_image = MCPImage(data=image_bytes, format=image_format)
mcp_images.append(mcp_image)
debug_log(f"圖片 {i} ({file_name}) 處理成功,格式: {image_format}")
except Exception as e:
# 使用統一錯誤處理(不影響 JSON RPC
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "圖片處理", "image_index": i},
error_type=ErrorType.FILE_IO
error_type=ErrorType.FILE_IO,
)
debug_log(f"圖片 {i} 處理失敗 [錯誤ID: {error_id}]: {e}")
debug_log(f"共處理 {len(mcp_images)} 張圖片")
return mcp_images
# ===== MCP 工具定義 =====
@mcp.tool()
async def interactive_feedback(
project_directory: Annotated[str, Field(description="專案目錄路徑")] = ".",
summary: Annotated[str, Field(description="AI 工作完成的摘要說明")] = "我已完成了您請求的任務。",
timeout: Annotated[int, Field(description="等待用戶回饋的超時時間(秒)")] = 600
) -> List:
summary: Annotated[
str, Field(description="AI 工作完成的摘要說明")
] = "我已完成了您請求的任務。",
timeout: Annotated[int, Field(description="等待用戶回饋的超時時間(秒)")] = 600,
) -> list:
"""
收集用戶的互動回饋支援文字和圖片
此工具使用 Web UI 介面收集用戶回饋支援智能環境檢測
用戶可以
1. 執行命令來驗證結果
2. 提供文字回饋
3. 上傳圖片作為回饋
4. 查看 AI 的工作摘要
調試模式
- 設置環境變數 MCP_DEBUG=true 可啟用詳細調試輸出
- 生產環境建議關閉調試模式以避免輸出干擾
Args:
project_directory: 專案目錄路徑
summary: AI 工作完成的摘要說明
timeout: 等待用戶回饋的超時時間預設為 600 10 分鐘
Returns:
List: 包含 TextContent MCPImage 對象的列表
"""
# 環境偵測
is_remote = is_remote_environment()
is_wsl = is_wsl_environment()
debug_log(f"環境偵測結果 - 遠端: {is_remote}, WSL: {is_wsl}")
debug_log("使用介面: Web UI")
try:
# 確保專案目錄存在
if not os.path.exists(project_directory):
project_directory = os.getcwd()
project_directory = os.path.abspath(project_directory)
# 根據模式選擇啟動方式
mode = get_feedback_mode()
debug_log(f"回饋模式: {mode.value}")
if mode == FeedbackMode.DESKTOP:
result = await launch_desktop_feedback_ui(project_directory, summary, timeout)
result = await launch_desktop_feedback_ui(
project_directory, summary, timeout
)
elif mode == FeedbackMode.WEB:
result = await launch_web_feedback_ui(project_directory, summary, timeout)
else: # AUTO
result = await launch_auto_feedback_ui(project_directory, summary, timeout)
# 處理取消情況
if not result:
return [TextContent(type="text", text="用戶取消了回饋。")]
# 儲存詳細結果
save_feedback_to_file(result)
# 建立回饋項目列表
feedback_items = []
# 添加文字回饋
if result.get("interactive_feedback") or result.get("command_logs") or result.get("images"):
if (
result.get("interactive_feedback")
or result.get("command_logs")
or result.get("images")
):
feedback_text = create_feedback_text(result)
feedback_items.append(TextContent(type="text", text=feedback_text))
debug_log("文字回饋已添加")
# 添加圖片回饋
if result.get("images"):
mcp_images = process_images(result["images"])
feedback_items.extend(mcp_images)
debug_log(f"已添加 {len(mcp_images)} 張圖片")
# 確保至少有一個回饋項目
if not feedback_items:
feedback_items.append(TextContent(type="text", text="用戶未提供任何回饋內容。"))
feedback_items.append(
TextContent(type="text", text="用戶未提供任何回饋內容。")
)
debug_log(f"回饋收集完成,共 {len(feedback_items)} 個項目")
return feedback_items
except Exception as e:
# 使用統一錯誤處理,但不影響 JSON RPC 響應
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "回饋收集", "project_dir": project_directory},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
# 生成用戶友好的錯誤信息
user_error_msg = ErrorHandler.format_user_error(e, include_technical=False)
debug_log(f"回饋收集錯誤 [錯誤ID: {error_id}]: {str(e)}")
debug_log(f"回饋收集錯誤 [錯誤ID: {error_id}]: {e!s}")
return [TextContent(type="text", text=user_error_msg)]
@ -552,7 +570,7 @@ async def launch_web_feedback_ui(project_dir: str, summary: str, timeout: int) -
try:
# 使用新的 web 模組
from .web import launch_web_feedback_ui as web_launch, stop_web_ui
from .web import launch_web_feedback_ui as web_launch
# 傳遞 timeout 參數給 Web UI
return await web_launch(project_dir, summary, timeout)
@ -561,19 +579,23 @@ async def launch_web_feedback_ui(project_dir: str, summary: str, timeout: int) -
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "Web UI 模組導入", "module": "web"},
error_type=ErrorType.DEPENDENCY
error_type=ErrorType.DEPENDENCY,
)
user_error_msg = ErrorHandler.format_user_error(
e, ErrorType.DEPENDENCY, include_technical=False
)
user_error_msg = ErrorHandler.format_user_error(e, ErrorType.DEPENDENCY, include_technical=False)
debug_log(f"Web UI 模組導入失敗 [錯誤ID: {error_id}]: {e}")
return {
"command_logs": "",
"interactive_feedback": user_error_msg,
"images": []
"images": [],
}
async def launch_desktop_feedback_ui(project_dir: str, summary: str, timeout: int) -> dict:
async def launch_desktop_feedback_ui(
project_dir: str, summary: str, timeout: int
) -> dict:
"""
啟動桌面應用收集回饋
@ -590,6 +612,7 @@ async def launch_desktop_feedback_ui(project_dir: str, summary: str, timeout: in
try:
# 嘗試導入桌面模組
from .desktop import launch_desktop_app
return await launch_desktop_app(project_dir, summary, timeout)
except ImportError as e:
debug_log(f"桌面模組未安裝或不可用,回退到 Web 模式: {e}")
@ -623,6 +646,7 @@ async def launch_auto_feedback_ui(project_dir: str, summary: str, timeout: int)
# 本地環境:嘗試桌面模式,失敗則回退到 Web 模式
try:
from .desktop import is_desktop_available
if is_desktop_available():
debug_log("檢測到桌面環境可用,使用桌面模式")
return await launch_desktop_feedback_ui(project_dir, summary, timeout)
@ -659,7 +683,7 @@ def get_system_info() -> str:
"WSL_DISTRO_NAME": os.getenv("WSL_DISTRO_NAME"),
"WSL_INTEROP": os.getenv("WSL_INTEROP"),
"WSLENV": os.getenv("WSLENV"),
}
},
}
return json.dumps(system_info, ensure_ascii=False, indent=2)
@ -670,7 +694,7 @@ def main():
"""主要入口點,用於套件執行"""
# 檢查是否啟用調試模式
debug_enabled = os.getenv("MCP_DEBUG", "").lower() in ("true", "1", "yes", "on")
if debug_enabled:
debug_log("🚀 啟動互動式回饋收集 MCP 服務器")
debug_log(f" 服務器名稱: {SERVER_NAME}")
@ -679,11 +703,11 @@ def main():
debug_log(f" 編碼初始化: {'成功' if _encoding_initialized else '失敗'}")
debug_log(f" 遠端環境: {is_remote_environment()}")
debug_log(f" WSL 環境: {is_wsl_environment()}")
debug_log(f" 介面類型: Web UI")
debug_log(" 介面類型: Web UI")
debug_log(" 等待來自 AI 助手的調用...")
debug_log("準備啟動 MCP 伺服器...")
debug_log("調用 mcp.run()...")
try:
# 使用正確的 FastMCP API
mcp.run()
@ -695,6 +719,7 @@ def main():
if debug_enabled:
debug_log(f"MCP 服務器啟動失敗: {e}")
import traceback
debug_log(f"詳細錯誤: {traceback.format_exc()}")
sys.exit(1)

View File

@ -8,20 +8,21 @@ MCP Feedback Enhanced 工具模組
from .error_handler import ErrorHandler, ErrorType
from .resource_manager import (
ResourceManager,
get_resource_manager,
create_temp_file,
cleanup_all_resources,
create_temp_dir,
create_temp_file,
get_resource_manager,
register_process,
cleanup_all_resources
)
__all__ = [
'ErrorHandler',
'ErrorType',
'ResourceManager',
'get_resource_manager',
'create_temp_file',
'create_temp_dir',
'register_process',
'cleanup_all_resources'
"ErrorHandler",
"ErrorType",
"ResourceManager",
"cleanup_all_resources",
"create_temp_dir",
"create_temp_file",
"get_resource_manager",
"register_process",
]

View File

@ -13,188 +13,159 @@
"""
import os
import sys
import traceback
import time
import traceback
from enum import Enum
from typing import Dict, Any, Optional, List, Tuple
from typing import Any
from ..debug import debug_log
class ErrorType(Enum):
"""錯誤類型枚舉"""
NETWORK = "network" # 網絡相關錯誤
FILE_IO = "file_io" # 文件 I/O 錯誤
PROCESS = "process" # 進程相關錯誤
TIMEOUT = "timeout" # 超時錯誤
NETWORK = "network" # 網絡相關錯誤
FILE_IO = "file_io" # 文件 I/O 錯誤
PROCESS = "process" # 進程相關錯誤
TIMEOUT = "timeout" # 超時錯誤
USER_CANCEL = "user_cancel" # 用戶取消操作
SYSTEM = "system" # 系統錯誤
PERMISSION = "permission" # 權限錯誤
VALIDATION = "validation" # 數據驗證錯誤
DEPENDENCY = "dependency" # 依賴錯誤
CONFIGURATION = "config" # 配置錯誤
SYSTEM = "system" # 系統錯誤
PERMISSION = "permission" # 權限錯誤
VALIDATION = "validation" # 數據驗證錯誤
DEPENDENCY = "dependency" # 依賴錯誤
CONFIGURATION = "config" # 配置錯誤
class ErrorSeverity(Enum):
"""錯誤嚴重程度"""
LOW = "low" # 低:不影響核心功能
MEDIUM = "medium" # 中:影響部分功能
HIGH = "high" # 高:影響核心功能
CRITICAL = "critical" # 嚴重:系統無法正常運行
LOW = "low" # 低:不影響核心功能
MEDIUM = "medium" # 中:影響部分功能
HIGH = "high" # 高:影響核心功能
CRITICAL = "critical" # 嚴重:系統無法正常運行
class ErrorHandler:
"""統一錯誤處理器"""
# 錯誤類型到用戶友好信息的映射
_ERROR_MESSAGES = {
ErrorType.NETWORK: {
"zh-TW": "網絡連接出現問題",
"zh-CN": "网络连接出现问题",
"en": "Network connection issue"
"zh-CN": "网络连接出现问题",
"en": "Network connection issue",
},
ErrorType.FILE_IO: {
"zh-TW": "文件讀寫出現問題",
"zh-CN": "文件读写出现问题",
"en": "File read/write issue"
"en": "File read/write issue",
},
ErrorType.PROCESS: {
"zh-TW": "進程執行出現問題",
"zh-CN": "进程执行出现问题",
"en": "Process execution issue"
"en": "Process execution issue",
},
ErrorType.TIMEOUT: {
"zh-TW": "操作超時",
"zh-CN": "操作超时",
"en": "Operation timeout"
"en": "Operation timeout",
},
ErrorType.USER_CANCEL: {
"zh-TW": "用戶取消了操作",
"zh-CN": "用户取消了操作",
"en": "User cancelled the operation"
"en": "User cancelled the operation",
},
ErrorType.SYSTEM: {
"zh-TW": "系統出現問題",
"zh-CN": "系统出现问题",
"en": "System issue"
"en": "System issue",
},
ErrorType.PERMISSION: {
"zh-TW": "權限不足",
"zh-CN": "权限不足",
"en": "Insufficient permissions"
"en": "Insufficient permissions",
},
ErrorType.VALIDATION: {
"zh-TW": "數據驗證失敗",
"zh-CN": "数据验证失败",
"en": "Data validation failed"
"en": "Data validation failed",
},
ErrorType.DEPENDENCY: {
"zh-TW": "依賴組件出現問題",
"zh-CN": "依赖组件出现问题",
"en": "Dependency issue"
"en": "Dependency issue",
},
ErrorType.CONFIGURATION: {
"zh-TW": "配置出現問題",
"zh-CN": "配置出现问题",
"en": "Configuration issue"
}
"en": "Configuration issue",
},
}
# 錯誤解決建議
_ERROR_SOLUTIONS = {
ErrorType.NETWORK: {
"zh-TW": [
"檢查網絡連接是否正常",
"確認防火牆設置",
"嘗試重新啟動應用程序"
],
"zh-CN": [
"检查网络连接是否正常",
"确认防火墙设置",
"尝试重新启动应用程序"
],
"zh-TW": ["檢查網絡連接是否正常", "確認防火牆設置", "嘗試重新啟動應用程序"],
"zh-CN": ["检查网络连接是否正常", "确认防火墙设置", "尝试重新启动应用程序"],
"en": [
"Check network connection",
"Verify firewall settings",
"Try restarting the application"
]
"Try restarting the application",
],
},
ErrorType.FILE_IO: {
"zh-TW": [
"檢查文件是否存在",
"確認文件權限",
"檢查磁盤空間是否足夠"
],
"zh-CN": [
"检查文件是否存在",
"确认文件权限",
"检查磁盘空间是否足够"
],
"zh-TW": ["檢查文件是否存在", "確認文件權限", "檢查磁盤空間是否足夠"],
"zh-CN": ["检查文件是否存在", "确认文件权限", "检查磁盘空间是否足够"],
"en": [
"Check if file exists",
"Verify file permissions",
"Check available disk space"
]
"Check available disk space",
],
},
ErrorType.PROCESS: {
"zh-TW": [
"檢查進程是否正在運行",
"確認系統資源是否足夠",
"嘗試重新啟動相關服務"
"嘗試重新啟動相關服務",
],
"zh-CN": [
"检查进程是否正在运行",
"确认系统资源是否足够",
"尝试重新启动相关服务"
"尝试重新启动相关服务",
],
"en": [
"Check if process is running",
"Verify system resources",
"Try restarting related services"
]
"Try restarting related services",
],
},
ErrorType.TIMEOUT: {
"zh-TW": [
"增加超時時間設置",
"檢查網絡延遲",
"稍後重試操作"
],
"zh-CN": [
"增加超时时间设置",
"检查网络延迟",
"稍后重试操作"
],
"zh-TW": ["增加超時時間設置", "檢查網絡延遲", "稍後重試操作"],
"zh-CN": ["增加超时时间设置", "检查网络延迟", "稍后重试操作"],
"en": [
"Increase timeout settings",
"Check network latency",
"Retry the operation later"
]
"Retry the operation later",
],
},
ErrorType.PERMISSION: {
"zh-TW": [
"以管理員身份運行",
"檢查文件/目錄權限",
"聯繫系統管理員"
],
"zh-CN": [
"以管理员身份运行",
"检查文件/目录权限",
"联系系统管理员"
],
"zh-TW": ["以管理員身份運行", "檢查文件/目錄權限", "聯繫系統管理員"],
"zh-CN": ["以管理员身份运行", "检查文件/目录权限", "联系系统管理员"],
"en": [
"Run as administrator",
"Check file/directory permissions",
"Contact system administrator"
]
}
"Contact system administrator",
],
},
}
@staticmethod
def get_current_language() -> str:
"""獲取當前語言設置"""
try:
# 嘗試從 i18n 模組獲取當前語言
from ..i18n import get_i18n_manager
return get_i18n_manager().get_current_language()
except Exception:
# 回退到環境變數或默認語言
@ -205,6 +176,7 @@ class ErrorHandler:
"""從國際化系統獲取錯誤信息"""
try:
from ..i18n import get_i18n_manager
i18n = get_i18n_manager()
key = f"errors.types.{error_type.value}"
message = i18n.t(key)
@ -216,13 +188,16 @@ class ErrorHandler:
# 回退到內建映射
language = ErrorHandler.get_current_language()
error_messages = ErrorHandler._ERROR_MESSAGES.get(error_type, {})
return error_messages.get(language, error_messages.get("zh-TW", "發生未知錯誤"))
return error_messages.get(
language, error_messages.get("zh-TW", "發生未知錯誤")
)
@staticmethod
def get_i18n_error_solutions(error_type: ErrorType) -> List[str]:
def get_i18n_error_solutions(error_type: ErrorType) -> list[str]:
"""從國際化系統獲取錯誤解決方案"""
try:
from ..i18n import get_i18n_manager
i18n = get_i18n_manager()
key = f"errors.solutions.{error_type.value}"
solutions = i18n.t(key)
@ -235,92 +210,111 @@ class ErrorHandler:
language = ErrorHandler.get_current_language()
solutions = ErrorHandler._ERROR_SOLUTIONS.get(error_type, {})
return solutions.get(language, solutions.get("zh-TW", []))
@staticmethod
def classify_error(error: Exception) -> ErrorType:
"""
根據異常類型自動分類錯誤
Args:
error: Python 異常對象
Returns:
ErrorType: 錯誤類型
"""
error_name = type(error).__name__
error_message = str(error).lower()
# 超時錯誤(優先檢查,避免被網絡錯誤覆蓋)
if 'timeout' in error_name.lower() or 'timeout' in error_message:
if "timeout" in error_name.lower() or "timeout" in error_message:
return ErrorType.TIMEOUT
# 權限錯誤(優先檢查,避免被文件錯誤覆蓋)
if 'permission' in error_name.lower():
if "permission" in error_name.lower():
return ErrorType.PERMISSION
if any(keyword in error_message for keyword in ['permission denied', 'access denied', 'forbidden']):
if any(
keyword in error_message
for keyword in ["permission denied", "access denied", "forbidden"]
):
return ErrorType.PERMISSION
# 網絡相關錯誤
if any(keyword in error_name.lower() for keyword in ['connection', 'network', 'socket']):
if any(
keyword in error_name.lower()
for keyword in ["connection", "network", "socket"]
):
return ErrorType.NETWORK
if any(keyword in error_message for keyword in ['connection', 'network', 'socket']):
if any(
keyword in error_message for keyword in ["connection", "network", "socket"]
):
return ErrorType.NETWORK
# 文件 I/O 錯誤
if any(keyword in error_name.lower() for keyword in ['file', 'ioerror']): # 使用更精確的匹配
if any(
keyword in error_name.lower() for keyword in ["file", "ioerror"]
): # 使用更精確的匹配
return ErrorType.FILE_IO
if any(keyword in error_message for keyword in ['file', 'directory', 'no such file']):
if any(
keyword in error_message
for keyword in ["file", "directory", "no such file"]
):
return ErrorType.FILE_IO
# 進程相關錯誤
if any(keyword in error_name.lower() for keyword in ['process', 'subprocess']):
if any(keyword in error_name.lower() for keyword in ["process", "subprocess"]):
return ErrorType.PROCESS
if any(keyword in error_message for keyword in ['process', 'command', 'executable']):
if any(
keyword in error_message for keyword in ["process", "command", "executable"]
):
return ErrorType.PROCESS
# 驗證錯誤
if any(keyword in error_name.lower() for keyword in ['validation', 'value', 'type']):
if any(
keyword in error_name.lower() for keyword in ["validation", "value", "type"]
):
return ErrorType.VALIDATION
# 配置錯誤
if any(keyword in error_message for keyword in ['config', 'setting', 'environment']):
if any(
keyword in error_message for keyword in ["config", "setting", "environment"]
):
return ErrorType.CONFIGURATION
# 默認為系統錯誤
return ErrorType.SYSTEM
@staticmethod
def format_user_error(
error: Exception,
error_type: Optional[ErrorType] = None,
context: Optional[Dict[str, Any]] = None,
include_technical: bool = False
error: Exception,
error_type: ErrorType | None = None,
context: dict[str, Any] | None = None,
include_technical: bool = False,
) -> str:
"""
將技術錯誤轉換為用戶友好的錯誤信息
Args:
error: Python 異常對象
error_type: 錯誤類型可選會自動分類
context: 錯誤上下文信息
include_technical: 是否包含技術細節
Returns:
str: 用戶友好的錯誤信息
"""
# 自動分類錯誤類型
if error_type is None:
error_type = ErrorHandler.classify_error(error)
# 獲取當前語言
language = ErrorHandler.get_current_language()
# 獲取用戶友好的錯誤信息(優先使用國際化系統)
user_message = ErrorHandler.get_i18n_error_message(error_type)
# 構建完整的錯誤信息
parts = [f"{user_message}"]
# 添加上下文信息
if context:
if context.get("operation"):
@ -328,24 +322,24 @@ class ErrorHandler:
parts.append(f"Operation: {context['operation']}")
else:
parts.append(f"操作:{context['operation']}")
if context.get("file_path"):
if language == "en":
parts.append(f"File: {context['file_path']}")
else:
parts.append(f"文件:{context['file_path']}")
# 添加技術細節(如果需要)
if include_technical:
if language == "en":
parts.append(f"Technical details: {type(error).__name__}: {str(error)}")
parts.append(f"Technical details: {type(error).__name__}: {error!s}")
else:
parts.append(f"技術細節:{type(error).__name__}: {str(error)}")
parts.append(f"技術細節:{type(error).__name__}: {error!s}")
return "\n".join(parts)
@staticmethod
def get_error_solutions(error_type: ErrorType) -> List[str]:
def get_error_solutions(error_type: ErrorType) -> list[str]:
"""
獲取錯誤解決建議
@ -356,33 +350,33 @@ class ErrorHandler:
List[str]: 解決建議列表
"""
return ErrorHandler.get_i18n_error_solutions(error_type)
@staticmethod
def log_error_with_context(
error: Exception,
context: Optional[Dict[str, Any]] = None,
error_type: Optional[ErrorType] = None,
severity: ErrorSeverity = ErrorSeverity.MEDIUM
context: dict[str, Any] | None = None,
error_type: ErrorType | None = None,
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
) -> str:
"""
記錄帶上下文的錯誤信息不影響 JSON RPC
Args:
error: Python 異常對象
context: 錯誤上下文信息
error_type: 錯誤類型
severity: 錯誤嚴重程度
Returns:
str: 錯誤 ID用於追蹤
"""
# 生成錯誤 ID
error_id = f"ERR_{int(time.time())}_{id(error) % 10000}"
# 自動分類錯誤
if error_type is None:
error_type = ErrorHandler.classify_error(error)
# 構建錯誤記錄
error_record = {
"error_id": error_id,
@ -392,64 +386,68 @@ class ErrorHandler:
"exception_type": type(error).__name__,
"exception_message": str(error),
"context": context or {},
"traceback": traceback.format_exc() if severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL] else None
"traceback": traceback.format_exc()
if severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL]
else None,
}
# 記錄到調試日誌(不影響 JSON RPC
debug_log(f"錯誤記錄 [{error_id}]: {error_type.value} - {str(error)}")
debug_log(f"錯誤記錄 [{error_id}]: {error_type.value} - {error!s}")
if context:
debug_log(f"錯誤上下文 [{error_id}]: {context}")
# 對於嚴重錯誤,記錄完整堆棧跟蹤
if severity in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL]:
debug_log(f"錯誤堆棧 [{error_id}]:\n{traceback.format_exc()}")
return error_id
@staticmethod
def create_error_response(
error: Exception,
context: Optional[Dict[str, Any]] = None,
error_type: Optional[ErrorType] = None,
context: dict[str, Any] | None = None,
error_type: ErrorType | None = None,
include_solutions: bool = True,
for_user: bool = True
) -> Dict[str, Any]:
for_user: bool = True,
) -> dict[str, Any]:
"""
創建標準化的錯誤響應
Args:
error: Python 異常對象
context: 錯誤上下文
error_type: 錯誤類型
include_solutions: 是否包含解決建議
for_user: 是否為用戶界面使用
Returns:
Dict[str, Any]: 標準化錯誤響應
"""
# 自動分類錯誤
if error_type is None:
error_type = ErrorHandler.classify_error(error)
# 記錄錯誤
error_id = ErrorHandler.log_error_with_context(error, context, error_type)
# 構建響應
response = {
"success": False,
"error_id": error_id,
"error_type": error_type.value,
"message": ErrorHandler.format_user_error(error, error_type, context, include_technical=not for_user)
"message": ErrorHandler.format_user_error(
error, error_type, context, include_technical=not for_user
),
}
# 添加解決建議
if include_solutions:
solutions = ErrorHandler.get_error_solutions(error_type)
response["solutions"] = solutions # 即使為空列表也添加
# 添加上下文(僅用於調試)
if context and not for_user:
response["context"] = context
return response

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
集成式內存監控系統
==================
@ -11,15 +10,16 @@
- 性能優化建議
"""
import os
import gc
import time
import threading
import psutil
from typing import Dict, List, Optional, Callable, Any
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from collections import deque
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
import psutil
from ..debug import debug_log
from .error_handler import ErrorHandler, ErrorType
@ -27,6 +27,7 @@ from .error_handler import ErrorHandler, ErrorType
@dataclass
class MemorySnapshot:
"""內存快照數據類"""
timestamp: datetime
system_total: int # 系統總內存 (bytes)
system_available: int # 系統可用內存 (bytes)
@ -41,6 +42,7 @@ class MemorySnapshot:
@dataclass
class MemoryAlert:
"""內存警告數據類"""
level: str # warning, critical, emergency
message: str
timestamp: datetime
@ -51,6 +53,7 @@ class MemoryAlert:
@dataclass
class MemoryStats:
"""內存統計數據類"""
monitoring_duration: float # 監控持續時間 (秒)
snapshots_count: int # 快照數量
average_system_usage: float # 平均系統內存使用率
@ -64,16 +67,18 @@ class MemoryStats:
class MemoryMonitor:
"""集成式內存監控器"""
def __init__(self,
warning_threshold: float = 0.8,
critical_threshold: float = 0.9,
emergency_threshold: float = 0.95,
monitoring_interval: int = 30,
max_snapshots: int = 1000):
def __init__(
self,
warning_threshold: float = 0.8,
critical_threshold: float = 0.9,
emergency_threshold: float = 0.95,
monitoring_interval: int = 30,
max_snapshots: int = 1000,
):
"""
初始化內存監控器
Args:
warning_threshold: 警告閾值 (0.0-1.0)
critical_threshold: 危險閾值 (0.0-1.0)
@ -86,140 +91,134 @@ class MemoryMonitor:
self.emergency_threshold = emergency_threshold
self.monitoring_interval = monitoring_interval
self.max_snapshots = max_snapshots
# 監控狀態
self.is_monitoring = False
self.monitor_thread: Optional[threading.Thread] = None
self.monitor_thread: threading.Thread | None = None
self._stop_event = threading.Event()
# 數據存儲
self.snapshots: deque = deque(maxlen=max_snapshots)
self.alerts: List[MemoryAlert] = []
self.alerts: list[MemoryAlert] = []
self.max_alerts = 100
# 回調函數
self.cleanup_callbacks: List[Callable] = []
self.alert_callbacks: List[Callable[[MemoryAlert], None]] = []
self.cleanup_callbacks: list[Callable] = []
self.alert_callbacks: list[Callable[[MemoryAlert], None]] = []
# 統計數據
self.start_time: Optional[datetime] = None
self.start_time: datetime | None = None
self.cleanup_triggers_count = 0
# 進程信息
self.process = psutil.Process()
debug_log("MemoryMonitor 初始化完成")
def start_monitoring(self) -> bool:
"""
開始內存監控
Returns:
bool: 是否成功啟動
"""
if self.is_monitoring:
debug_log("內存監控已在運行")
return True
try:
self.is_monitoring = True
self.start_time = datetime.now()
self._stop_event.clear()
self.monitor_thread = threading.Thread(
target=self._monitoring_loop,
name="MemoryMonitor",
daemon=True
target=self._monitoring_loop, name="MemoryMonitor", daemon=True
)
self.monitor_thread.start()
debug_log(f"內存監控已啟動,間隔 {self.monitoring_interval}")
return True
except Exception as e:
self.is_monitoring = False
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "啟動內存監控"},
error_type=ErrorType.SYSTEM
e, context={"operation": "啟動內存監控"}, error_type=ErrorType.SYSTEM
)
debug_log(f"啟動內存監控失敗 [錯誤ID: {error_id}]: {e}")
return False
def stop_monitoring(self) -> bool:
"""
停止內存監控
Returns:
bool: 是否成功停止
"""
if not self.is_monitoring:
debug_log("內存監控未在運行")
return True
try:
self.is_monitoring = False
self._stop_event.set()
if self.monitor_thread and self.monitor_thread.is_alive():
self.monitor_thread.join(timeout=5)
debug_log("內存監控已停止")
return True
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "停止內存監控"},
error_type=ErrorType.SYSTEM
e, context={"operation": "停止內存監控"}, error_type=ErrorType.SYSTEM
)
debug_log(f"停止內存監控失敗 [錯誤ID: {error_id}]: {e}")
return False
def _monitoring_loop(self):
"""內存監控主循環"""
debug_log("內存監控循環開始")
while not self._stop_event.is_set():
try:
# 收集內存快照
snapshot = self._collect_memory_snapshot()
self.snapshots.append(snapshot)
# 檢查內存使用情況
self._check_memory_usage(snapshot)
# 等待下次監控
if self._stop_event.wait(self.monitoring_interval):
break
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "內存監控循環"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"內存監控循環錯誤 [錯誤ID: {error_id}]: {e}")
# 發生錯誤時等待較短時間後重試
if self._stop_event.wait(5):
break
debug_log("內存監控循環結束")
def _collect_memory_snapshot(self) -> MemorySnapshot:
"""收集內存快照"""
try:
# 系統內存信息
system_memory = psutil.virtual_memory()
# 進程內存信息
process_memory = self.process.memory_info()
process_percent = self.process.memory_percent()
# Python 垃圾回收信息
gc_objects = len(gc.get_objects())
return MemorySnapshot(
timestamp=datetime.now(),
system_total=system_memory.total,
@ -229,22 +228,20 @@ class MemoryMonitor:
process_rss=process_memory.rss,
process_vms=process_memory.vms,
process_percent=process_percent,
gc_objects=gc_objects
gc_objects=gc_objects,
)
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "收集內存快照"},
error_type=ErrorType.SYSTEM
e, context={"operation": "收集內存快照"}, error_type=ErrorType.SYSTEM
)
debug_log(f"收集內存快照失敗 [錯誤ID: {error_id}]: {e}")
raise
def _check_memory_usage(self, snapshot: MemorySnapshot):
"""檢查內存使用情況並觸發相應動作"""
usage_percent = snapshot.system_percent / 100.0
# 檢查緊急閾值
if usage_percent >= self.emergency_threshold:
alert = MemoryAlert(
@ -252,11 +249,11 @@ class MemoryMonitor:
message=f"內存使用率達到緊急水平: {snapshot.system_percent:.1f}%",
timestamp=snapshot.timestamp,
memory_percent=snapshot.system_percent,
recommended_action="立即執行強制清理和垃圾回收"
recommended_action="立即執行強制清理和垃圾回收",
)
self._handle_alert(alert)
self._trigger_emergency_cleanup()
# 檢查危險閾值
elif usage_percent >= self.critical_threshold:
alert = MemoryAlert(
@ -264,11 +261,11 @@ class MemoryMonitor:
message=f"內存使用率達到危險水平: {snapshot.system_percent:.1f}%",
timestamp=snapshot.timestamp,
memory_percent=snapshot.system_percent,
recommended_action="執行資源清理和垃圾回收"
recommended_action="執行資源清理和垃圾回收",
)
self._handle_alert(alert)
self._trigger_cleanup()
# 檢查警告閾值
elif usage_percent >= self.warning_threshold:
alert = MemoryAlert(
@ -276,61 +273,62 @@ class MemoryMonitor:
message=f"內存使用率較高: {snapshot.system_percent:.1f}%",
timestamp=snapshot.timestamp,
memory_percent=snapshot.system_percent,
recommended_action="考慮執行輕量級清理"
recommended_action="考慮執行輕量級清理",
)
self._handle_alert(alert)
def _handle_alert(self, alert: MemoryAlert):
"""處理內存警告"""
# 添加到警告列表
self.alerts.append(alert)
# 限制警告數量
if len(self.alerts) > self.max_alerts:
self.alerts = self.alerts[-self.max_alerts:]
self.alerts = self.alerts[-self.max_alerts :]
# 調用警告回調
for callback in self.alert_callbacks:
try:
callback(alert)
except Exception as e:
debug_log(f"警告回調執行失敗: {e}")
debug_log(f"內存警告 [{alert.level}]: {alert.message}")
def _trigger_cleanup(self):
"""觸發清理操作"""
self.cleanup_triggers_count += 1
debug_log("觸發內存清理操作")
# 執行 Python 垃圾回收
collected = gc.collect()
debug_log(f"垃圾回收清理了 {collected} 個對象")
# 調用清理回調
for callback in self.cleanup_callbacks:
try:
callback()
except Exception as e:
debug_log(f"清理回調執行失敗: {e}")
def _trigger_emergency_cleanup(self):
"""觸發緊急清理操作"""
debug_log("觸發緊急內存清理操作")
# 執行強制垃圾回收
for _ in range(3):
collected = gc.collect()
debug_log(f"強制垃圾回收清理了 {collected} 個對象")
# 調用清理回調(強制模式)
for callback in self.cleanup_callbacks:
try:
if hasattr(callback, '__call__'):
if callable(callback):
# 嘗試傳遞 force 參數
import inspect
sig = inspect.signature(callback)
if 'force' in sig.parameters:
if "force" in sig.parameters:
callback(force=True)
else:
callback()
@ -339,7 +337,6 @@ class MemoryMonitor:
except Exception as e:
debug_log(f"緊急清理回調執行失敗: {e}")
def add_cleanup_callback(self, callback: Callable):
"""添加清理回調函數"""
if callback not in self.cleanup_callbacks:
@ -364,7 +361,7 @@ class MemoryMonitor:
self.alert_callbacks.remove(callback)
debug_log("移除警告回調函數")
def get_current_memory_info(self) -> Dict[str, Any]:
def get_current_memory_info(self) -> dict[str, Any]:
"""獲取當前內存信息"""
try:
snapshot = self._collect_memory_snapshot()
@ -374,21 +371,21 @@ class MemoryMonitor:
"total_gb": round(snapshot.system_total / (1024**3), 2),
"available_gb": round(snapshot.system_available / (1024**3), 2),
"used_gb": round(snapshot.system_used / (1024**3), 2),
"usage_percent": round(snapshot.system_percent, 1)
"usage_percent": round(snapshot.system_percent, 1),
},
"process": {
"rss_mb": round(snapshot.process_rss / (1024**2), 2),
"vms_mb": round(snapshot.process_vms / (1024**2), 2),
"usage_percent": round(snapshot.process_percent, 1)
"usage_percent": round(snapshot.process_percent, 1),
},
"gc_objects": snapshot.gc_objects,
"status": self._get_memory_status(snapshot.system_percent / 100.0)
"status": self._get_memory_status(snapshot.system_percent / 100.0),
}
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "獲取當前內存信息"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"獲取內存信息失敗 [錯誤ID: {error_id}]: {e}")
return {}
@ -405,7 +402,7 @@ class MemoryMonitor:
peak_process_usage=0.0,
alerts_count=0,
cleanup_triggers=0,
memory_trend="unknown"
memory_trend="unknown",
)
# 計算統計數據
@ -425,10 +422,10 @@ class MemoryMonitor:
peak_process_usage=max(process_usages),
alerts_count=len(self.alerts),
cleanup_triggers=self.cleanup_triggers_count,
memory_trend=self._analyze_memory_trend()
memory_trend=self._analyze_memory_trend(),
)
def get_recent_alerts(self, limit: int = 10) -> List[MemoryAlert]:
def get_recent_alerts(self, limit: int = 10) -> list[MemoryAlert]:
"""獲取最近的警告"""
return self.alerts[-limit:] if self.alerts else []
@ -436,12 +433,11 @@ class MemoryMonitor:
"""獲取內存狀態描述"""
if usage_percent >= self.emergency_threshold:
return "emergency"
elif usage_percent >= self.critical_threshold:
if usage_percent >= self.critical_threshold:
return "critical"
elif usage_percent >= self.warning_threshold:
if usage_percent >= self.warning_threshold:
return "warning"
else:
return "normal"
return "normal"
def _analyze_memory_trend(self) -> str:
"""分析內存使用趨勢"""
@ -463,10 +459,9 @@ class MemoryMonitor:
if abs(diff) < 2.0: # 變化小於 2%
return "stable"
elif diff > 0:
if diff > 0:
return "increasing"
else:
return "decreasing"
return "decreasing"
def force_cleanup(self):
"""手動觸發清理操作"""
@ -486,14 +481,14 @@ class MemoryMonitor:
self.start_time = datetime.now() if self.is_monitoring else None
debug_log("內存監控統計數據已重置")
def export_memory_data(self) -> Dict[str, Any]:
def export_memory_data(self) -> dict[str, Any]:
"""導出內存數據"""
return {
"config": {
"warning_threshold": self.warning_threshold,
"critical_threshold": self.critical_threshold,
"emergency_threshold": self.emergency_threshold,
"monitoring_interval": self.monitoring_interval
"monitoring_interval": self.monitoring_interval,
},
"current_info": self.get_current_memory_info(),
"stats": self.get_memory_stats().__dict__,
@ -503,16 +498,16 @@ class MemoryMonitor:
"message": alert.message,
"timestamp": alert.timestamp.isoformat(),
"memory_percent": alert.memory_percent,
"recommended_action": alert.recommended_action
"recommended_action": alert.recommended_action,
}
for alert in self.get_recent_alerts()
],
"is_monitoring": self.is_monitoring
"is_monitoring": self.is_monitoring,
}
# 全域內存監控器實例
_memory_monitor: Optional[MemoryMonitor] = None
_memory_monitor: MemoryMonitor | None = None
_monitor_lock = threading.Lock()

View File

@ -9,23 +9,23 @@
- 資源使用監控
"""
import os
import sys
import time
import atexit
import os
import shutil
import subprocess
import tempfile
import threading
import subprocess
import time
import weakref
from pathlib import Path
from typing import Set, Dict, Any, Optional, List, Union
from typing import Any
from ..debug import debug_log
from .error_handler import ErrorHandler, ErrorType
class ResourceType:
"""資源類型常量"""
TEMP_FILE = "temp_file"
TEMP_DIR = "temp_dir"
PROCESS = "process"
@ -34,10 +34,10 @@ class ResourceType:
class ResourceManager:
"""統一資源管理器 - 提供完整的資源生命週期管理"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""單例模式實現"""
if cls._instance is None:
@ -45,41 +45,41 @@ class ResourceManager:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""初始化資源管理器"""
if hasattr(self, '_initialized'):
if hasattr(self, "_initialized"):
return
self._initialized = True
# 資源追蹤集合
self.temp_files: Set[str] = set()
self.temp_dirs: Set[str] = set()
self.processes: Dict[int, Dict[str, Any]] = {}
self.file_handles: Set[Any] = set()
self.temp_files: set[str] = set()
self.temp_dirs: set[str] = set()
self.processes: dict[int, dict[str, Any]] = {}
self.file_handles: set[Any] = set()
# 資源統計
self.stats = {
"temp_files_created": 0,
"temp_dirs_created": 0,
"processes_registered": 0,
"cleanup_runs": 0,
"last_cleanup": None
"last_cleanup": None,
}
# 配置
self.auto_cleanup_enabled = True
self.cleanup_interval = 300 # 5分鐘
self.temp_file_max_age = 3600 # 1小時
# 清理線程
self._cleanup_thread: Optional[threading.Thread] = None
self._cleanup_thread: threading.Thread | None = None
self._stop_cleanup = threading.Event()
# 註冊退出清理
atexit.register(self.cleanup_all)
# 啟動自動清理
self._start_auto_cleanup()
@ -107,9 +107,7 @@ class ResourceManager:
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "設置內存監控"},
error_type=ErrorType.SYSTEM
e, context={"operation": "設置內存監控"}, error_type=ErrorType.SYSTEM
)
debug_log(f"設置內存監控失敗 [錯誤ID: {error_id}]: {e}")
@ -132,8 +130,10 @@ class ResourceManager:
if force:
cleaned_processes = self.cleanup_processes(force=True)
debug_log(f"內存觸發清理完成: 文件={cleaned_files}, 目錄={cleaned_dirs}, "
f"句柄={cleaned_handles}, 進程={cleaned_processes}")
debug_log(
f"內存觸發清理完成: 文件={cleaned_files}, 目錄={cleaned_dirs}, "
f"句柄={cleaned_handles}, 進程={cleaned_processes}"
)
# 更新統計
self.stats["cleanup_runs"] += 1
@ -143,110 +143,108 @@ class ResourceManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "內存觸發清理", "force": force},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"內存觸發清理失敗 [錯誤ID: {error_id}]: {e}")
def create_temp_file(
self,
suffix: str = "",
prefix: str = "mcp_",
dir: Optional[str] = None,
text: bool = True
self,
suffix: str = "",
prefix: str = "mcp_",
dir: str | None = None,
text: bool = True,
) -> str:
"""
創建臨時文件並追蹤
Args:
suffix: 文件後綴
prefix: 文件前綴
dir: 臨時目錄None 使用系統默認
text: 是否為文本模式
Returns:
str: 臨時文件路徑
"""
try:
# 創建臨時文件
fd, temp_path = tempfile.mkstemp(
suffix=suffix,
prefix=prefix,
dir=dir,
text=text
suffix=suffix, prefix=prefix, dir=dir, text=text
)
os.close(fd) # 關閉文件描述符
# 追蹤文件
self.temp_files.add(temp_path)
self.stats["temp_files_created"] += 1
debug_log(f"創建臨時文件: {temp_path}")
return temp_path
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "創建臨時文件", "suffix": suffix, "prefix": prefix},
error_type=ErrorType.FILE_IO
context={
"operation": "創建臨時文件",
"suffix": suffix,
"prefix": prefix,
},
error_type=ErrorType.FILE_IO,
)
debug_log(f"創建臨時文件失敗 [錯誤ID: {error_id}]: {e}")
raise
def create_temp_dir(
self,
suffix: str = "",
prefix: str = "mcp_",
dir: Optional[str] = None
self, suffix: str = "", prefix: str = "mcp_", dir: str | None = None
) -> str:
"""
創建臨時目錄並追蹤
Args:
suffix: 目錄後綴
prefix: 目錄前綴
dir: 父目錄None 使用系統默認
Returns:
str: 臨時目錄路徑
"""
try:
# 創建臨時目錄
temp_dir = tempfile.mkdtemp(
suffix=suffix,
prefix=prefix,
dir=dir
)
temp_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
# 追蹤目錄
self.temp_dirs.add(temp_dir)
self.stats["temp_dirs_created"] += 1
debug_log(f"創建臨時目錄: {temp_dir}")
return temp_dir
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "創建臨時目錄", "suffix": suffix, "prefix": prefix},
error_type=ErrorType.FILE_IO
context={
"operation": "創建臨時目錄",
"suffix": suffix,
"prefix": prefix,
},
error_type=ErrorType.FILE_IO,
)
debug_log(f"創建臨時目錄失敗 [錯誤ID: {error_id}]: {e}")
raise
def register_process(
self,
process: Union[subprocess.Popen, int],
self,
process: subprocess.Popen | int,
description: str = "",
auto_cleanup: bool = True
auto_cleanup: bool = True,
) -> int:
"""
註冊進程追蹤
Args:
process: 進程對象或 PID
description: 進程描述
auto_cleanup: 是否自動清理
Returns:
int: 進程 PID
"""
@ -257,34 +255,34 @@ class ResourceManager:
else:
pid = process
process_obj = None
# 註冊進程
self.processes[pid] = {
"process": process_obj,
"description": description,
"auto_cleanup": auto_cleanup,
"registered_at": time.time(),
"last_check": time.time()
"last_check": time.time(),
}
self.stats["processes_registered"] += 1
debug_log(f"註冊進程追蹤: PID {pid} - {description}")
return pid
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "註冊進程", "description": description},
error_type=ErrorType.PROCESS
error_type=ErrorType.PROCESS,
)
debug_log(f"註冊進程失敗 [錯誤ID: {error_id}]: {e}")
raise
def register_file_handle(self, file_handle: Any) -> None:
"""
註冊文件句柄追蹤
Args:
file_handle: 文件句柄對象
"""
@ -292,22 +290,20 @@ class ResourceManager:
# 使用弱引用避免循環引用
self.file_handles.add(weakref.ref(file_handle))
debug_log(f"註冊文件句柄: {type(file_handle).__name__}")
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "註冊文件句柄"},
error_type=ErrorType.FILE_IO
e, context={"operation": "註冊文件句柄"}, error_type=ErrorType.FILE_IO
)
debug_log(f"註冊文件句柄失敗 [錯誤ID: {error_id}]: {e}")
def unregister_temp_file(self, file_path: str) -> bool:
"""
取消臨時文件追蹤
Args:
file_path: 文件路徑
Returns:
bool: 是否成功取消追蹤
"""
@ -317,23 +313,23 @@ class ResourceManager:
debug_log(f"取消臨時文件追蹤: {file_path}")
return True
return False
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "取消文件追蹤", "file_path": file_path},
error_type=ErrorType.FILE_IO
error_type=ErrorType.FILE_IO,
)
debug_log(f"取消文件追蹤失敗 [錯誤ID: {error_id}]: {e}")
return False
def unregister_process(self, pid: int) -> bool:
"""
取消進程追蹤
Args:
pid: 進程 PID
Returns:
bool: 是否成功取消追蹤
"""
@ -343,39 +339,39 @@ class ResourceManager:
debug_log(f"取消進程追蹤: PID {pid}")
return True
return False
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "取消進程追蹤", "pid": pid},
error_type=ErrorType.PROCESS
error_type=ErrorType.PROCESS,
)
debug_log(f"取消進程追蹤失敗 [錯誤ID: {error_id}]: {e}")
return False
def cleanup_temp_files(self, max_age: Optional[int] = None) -> int:
def cleanup_temp_files(self, max_age: int | None = None) -> int:
"""
清理臨時文件
Args:
max_age: 最大文件年齡None 使用默認值
Returns:
int: 清理的文件數量
"""
if max_age is None:
max_age = self.temp_file_max_age
cleaned_count = 0
current_time = time.time()
files_to_remove = set()
for file_path in self.temp_files.copy():
try:
if not os.path.exists(file_path):
files_to_remove.add(file_path)
continue
# 檢查文件年齡
file_age = current_time - os.path.getmtime(file_path)
if file_age > max_age:
@ -383,19 +379,19 @@ class ResourceManager:
files_to_remove.add(file_path)
cleaned_count += 1
debug_log(f"清理過期臨時文件: {file_path}")
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "清理臨時文件", "file_path": file_path},
error_type=ErrorType.FILE_IO
error_type=ErrorType.FILE_IO,
)
debug_log(f"清理臨時文件失敗 [錯誤ID: {error_id}]: {e}")
files_to_remove.add(file_path) # 移除無效追蹤
# 移除已清理的文件追蹤
self.temp_files -= files_to_remove
return cleaned_count
def cleanup_temp_dirs(self) -> int:
@ -424,7 +420,7 @@ class ResourceManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "清理臨時目錄", "dir_path": dir_path},
error_type=ErrorType.FILE_IO
error_type=ErrorType.FILE_IO,
)
debug_log(f"清理臨時目錄失敗 [錯誤ID: {error_id}]: {e}")
dirs_to_remove.add(dir_path) # 移除無效追蹤
@ -456,7 +452,7 @@ class ResourceManager:
continue
# 檢查進程是否還在運行
if process_obj and hasattr(process_obj, 'poll'):
if process_obj and hasattr(process_obj, "poll"):
if process_obj.poll() is None: # 進程還在運行
if force:
debug_log(f"強制終止進程: PID {pid}")
@ -481,6 +477,7 @@ class ResourceManager:
# 使用 psutil 檢查進程
try:
import psutil
if psutil.pid_exists(pid):
proc = psutil.Process(pid)
if force:
@ -501,7 +498,7 @@ class ResourceManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "清理進程", "pid": pid},
error_type=ErrorType.PROCESS
error_type=ErrorType.PROCESS,
)
debug_log(f"清理進程失敗 [錯誤ID: {error_id}]: {e}")
processes_to_remove.append(pid)
@ -531,7 +528,7 @@ class ResourceManager:
continue
# 嘗試關閉文件句柄
if hasattr(handle, 'close') and not handle.closed:
if hasattr(handle, "close") and not handle.closed:
handle.close()
cleaned_count += 1
debug_log(f"關閉文件句柄: {type(handle).__name__}")
@ -542,7 +539,7 @@ class ResourceManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "清理文件句柄"},
error_type=ErrorType.FILE_IO
error_type=ErrorType.FILE_IO,
)
debug_log(f"清理文件句柄失敗 [錯誤ID: {error_id}]: {e}")
handles_to_remove.add(handle_ref)
@ -552,7 +549,7 @@ class ResourceManager:
return cleaned_count
def cleanup_all(self, force: bool = False) -> Dict[str, int]:
def cleanup_all(self, force: bool = False) -> dict[str, int]:
"""
清理所有資源
@ -564,12 +561,7 @@ class ResourceManager:
"""
debug_log("開始全面資源清理...")
results = {
"temp_files": 0,
"temp_dirs": 0,
"processes": 0,
"file_handles": 0
}
results = {"temp_files": 0, "temp_dirs": 0, "processes": 0, "file_handles": 0}
try:
# 清理文件句柄
@ -593,9 +585,7 @@ class ResourceManager:
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "全面資源清理"},
error_type=ErrorType.SYSTEM
e, context={"operation": "全面資源清理"}, error_type=ErrorType.SYSTEM
)
debug_log(f"全面資源清理失敗 [錯誤ID: {error_id}]: {e}")
@ -618,14 +608,12 @@ class ResourceManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "自動清理"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"自動清理失敗 [錯誤ID: {error_id}]: {e}")
self._cleanup_thread = threading.Thread(
target=cleanup_worker,
name="ResourceManager-AutoCleanup",
daemon=True
target=cleanup_worker, name="ResourceManager-AutoCleanup", daemon=True
)
self._cleanup_thread.start()
debug_log("自動清理線程已啟動")
@ -647,7 +635,7 @@ class ResourceManager:
process_info["last_check"] = current_time
# 檢查進程是否還在運行
if process_obj and hasattr(process_obj, 'poll'):
if process_obj and hasattr(process_obj, "poll"):
if process_obj.poll() is not None:
# 進程已結束,移除追蹤
debug_log(f"檢測到進程 {pid} 已結束,移除追蹤")
@ -664,7 +652,7 @@ class ResourceManager:
self._cleanup_thread = None
debug_log("自動清理線程已停止")
def get_resource_stats(self) -> Dict[str, Any]:
def get_resource_stats(self) -> dict[str, Any]:
"""
獲取資源統計信息
@ -672,35 +660,41 @@ class ResourceManager:
Dict[str, Any]: 資源統計
"""
current_stats = self.stats.copy()
current_stats.update({
"current_temp_files": len(self.temp_files),
"current_temp_dirs": len(self.temp_dirs),
"current_processes": len(self.processes),
"current_file_handles": len(self.file_handles),
"auto_cleanup_enabled": self.auto_cleanup_enabled,
"cleanup_interval": self.cleanup_interval,
"temp_file_max_age": self.temp_file_max_age
})
current_stats.update(
{
"current_temp_files": len(self.temp_files),
"current_temp_dirs": len(self.temp_dirs),
"current_processes": len(self.processes),
"current_file_handles": len(self.file_handles),
"auto_cleanup_enabled": self.auto_cleanup_enabled,
"cleanup_interval": self.cleanup_interval,
"temp_file_max_age": self.temp_file_max_age,
}
)
# 添加內存監控統計
try:
if hasattr(self, 'memory_monitor') and self.memory_monitor:
if hasattr(self, "memory_monitor") and self.memory_monitor:
memory_info = self.memory_monitor.get_current_memory_info()
memory_stats = self.memory_monitor.get_memory_stats()
current_stats.update({
"memory_monitoring_enabled": self.memory_monitor.is_monitoring,
"current_memory_usage": memory_info.get("system", {}).get("usage_percent", 0),
"memory_status": memory_info.get("status", "unknown"),
"memory_cleanup_triggers": memory_stats.cleanup_triggers,
"memory_alerts_count": memory_stats.alerts_count
})
current_stats.update(
{
"memory_monitoring_enabled": self.memory_monitor.is_monitoring,
"current_memory_usage": memory_info.get("system", {}).get(
"usage_percent", 0
),
"memory_status": memory_info.get("status", "unknown"),
"memory_cleanup_triggers": memory_stats.cleanup_triggers,
"memory_alerts_count": memory_stats.alerts_count,
}
)
except Exception as e:
debug_log(f"獲取內存統計失敗: {e}")
return current_stats
def get_detailed_info(self) -> Dict[str, Any]:
def get_detailed_info(self) -> dict[str, Any]:
"""
獲取詳細資源信息
@ -715,19 +709,19 @@ class ResourceManager:
"description": info.get("description", ""),
"auto_cleanup": info.get("auto_cleanup", True),
"registered_at": info.get("registered_at", 0),
"last_check": info.get("last_check", 0)
"last_check": info.get("last_check", 0),
}
for pid, info in self.processes.items()
},
"file_handles_count": len(self.file_handles),
"stats": self.get_resource_stats()
"stats": self.get_resource_stats(),
}
def configure(
self,
auto_cleanup_enabled: Optional[bool] = None,
cleanup_interval: Optional[int] = None,
temp_file_max_age: Optional[int] = None
auto_cleanup_enabled: bool | None = None,
cleanup_interval: int | None = None,
temp_file_max_age: int | None = None,
) -> None:
"""
配置資源管理器
@ -755,8 +749,10 @@ class ResourceManager:
if temp_file_max_age is not None:
self.temp_file_max_age = max(300, temp_file_max_age) # 最小5分鐘
debug_log(f"ResourceManager 配置已更新: auto_cleanup={self.auto_cleanup_enabled}, "
f"interval={self.cleanup_interval}, max_age={self.temp_file_max_age}")
debug_log(
f"ResourceManager 配置已更新: auto_cleanup={self.auto_cleanup_enabled}, "
f"interval={self.cleanup_interval}, max_age={self.temp_file_max_age}"
)
# 全局資源管理器實例
@ -779,19 +775,27 @@ def get_resource_manager() -> ResourceManager:
# 便捷函數
def create_temp_file(suffix: str = "", prefix: str = "mcp_", **kwargs) -> str:
"""創建臨時文件的便捷函數"""
return get_resource_manager().create_temp_file(suffix=suffix, prefix=prefix, **kwargs)
return get_resource_manager().create_temp_file(
suffix=suffix, prefix=prefix, **kwargs
)
def create_temp_dir(suffix: str = "", prefix: str = "mcp_", **kwargs) -> str:
"""創建臨時目錄的便捷函數"""
return get_resource_manager().create_temp_dir(suffix=suffix, prefix=prefix, **kwargs)
return get_resource_manager().create_temp_dir(
suffix=suffix, prefix=prefix, **kwargs
)
def register_process(process: Union[subprocess.Popen, int], description: str = "", **kwargs) -> int:
def register_process(
process: subprocess.Popen | int, description: str = "", **kwargs
) -> int:
"""註冊進程的便捷函數"""
return get_resource_manager().register_process(process, description=description, **kwargs)
return get_resource_manager().register_process(
process, description=description, **kwargs
)
def cleanup_all_resources(force: bool = False) -> Dict[str, int]:
def cleanup_all_resources(force: bool = False) -> dict[str, int]:
"""清理所有資源的便捷函數"""
return get_resource_manager().cleanup_all(force=force)

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP Feedback Enhanced Web UI 模組
@ -16,11 +15,12 @@ MCP Feedback Enhanced Web UI 模組
- 本地和遠端環境適配
"""
from .main import WebUIManager, launch_web_feedback_ui, get_web_ui_manager, stop_web_ui
from .main import WebUIManager, get_web_ui_manager, launch_web_feedback_ui, stop_web_ui
__all__ = [
'WebUIManager',
'launch_web_feedback_ui',
'get_web_ui_manager',
'stop_web_ui'
]
"WebUIManager",
"get_web_ui_manager",
"launch_web_feedback_ui",
"stop_web_ui",
]

View File

@ -218,4 +218,4 @@
"sizeLimitExceeded": "Image {filename} size is {size}, exceeds {limit} limit!",
"sizeLimitExceededAdvice": "Consider compressing the image with editing software before uploading, or adjust the image size limit settings."
}
}
}

View File

@ -218,4 +218,4 @@
"sizeLimitExceeded": "图片 {filename} 大小为 {size},超过 {limit} 限制!",
"sizeLimitExceededAdvice": "建议使用图片编辑软件压缩后再上传,或调整图片大小限制设置。"
}
}
}

View File

@ -223,4 +223,4 @@
"sizeLimitExceeded": "圖片 {filename} 大小為 {size},超過 {limit} 限制!",
"sizeLimitExceededAdvice": "建議使用圖片編輯軟體壓縮後再上傳,或調整圖片大小限制設定。"
}
}
}

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 主要管理類
@ -8,33 +7,28 @@ Web UI 主要管理類
"""
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, List
from datetime import datetime
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import FastAPI, Request, Response
import uvicorn
from fastapi import FastAPI, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.gzip import GZipMiddleware
import uvicorn
from .models import WebFeedbackSession, FeedbackResult, CleanupReason, SessionStatus
from .routes import setup_routes
from .utils import find_free_port, get_browser_opener
from .utils.port_manager import PortManager
from .utils.compression_config import get_compression_manager
from ..utils.error_handler import ErrorHandler, ErrorType
from ..utils.memory_monitor import get_memory_monitor
from ..debug import web_debug_log as debug_log
from ..i18n import get_i18n_manager
from ..utils.error_handler import ErrorHandler, ErrorType
from ..utils.memory_monitor import get_memory_monitor
from .models import CleanupReason, SessionStatus, WebFeedbackSession
from .routes import setup_routes
from .utils import get_browser_opener
from .utils.compression_config import get_compression_manager
from .utils.port_manager import PortManager
class WebUIManager:
@ -55,17 +49,19 @@ class WebUIManager:
preferred_port = custom_port
debug_log(f"使用環境變數指定的端口: {preferred_port}")
else:
debug_log(f"MCP_WEB_PORT 值無效 ({custom_port}),必須在 1024-65535 範圍內,使用預設端口 8765")
debug_log(
f"MCP_WEB_PORT 值無效 ({custom_port}),必須在 1024-65535 範圍內,使用預設端口 8765"
)
except ValueError:
debug_log(f"MCP_WEB_PORT 格式錯誤 ({env_port}),必須為數字,使用預設端口 8765")
debug_log(
f"MCP_WEB_PORT 格式錯誤 ({env_port}),必須為數字,使用預設端口 8765"
)
else:
debug_log(f"未設定 MCP_WEB_PORT 環境變數,使用預設端口 {preferred_port}")
# 使用增強的端口管理,支持自動清理
self.port = port or PortManager.find_free_port_enhanced(
preferred_port=preferred_port,
auto_cleanup=True,
host=self.host
preferred_port=preferred_port, auto_cleanup=True, host=self.host
)
self.app = FastAPI(title="MCP Feedback Enhanced")
@ -76,11 +72,11 @@ class WebUIManager:
self._setup_memory_monitoring()
# 重構:使用單一活躍會話而非會話字典
self.current_session: Optional[WebFeedbackSession] = None
self.sessions: Dict[str, WebFeedbackSession] = {} # 保留用於向後兼容
self.current_session: WebFeedbackSession | None = None
self.sessions: dict[str, WebFeedbackSession] = {} # 保留用於向後兼容
# 全局標籤頁狀態管理 - 跨會話保持
self.global_active_tabs: Dict[str, dict] = {}
self.global_active_tabs: dict[str, dict] = {}
# 會話更新通知標記
self._pending_session_update = False
@ -93,7 +89,7 @@ class WebUIManager:
"manual_cleanups": 0,
"last_cleanup_time": None,
"total_cleanup_duration": 0.0,
"sessions_cleaned": 0
"sessions_cleaned": 0,
}
self.server_thread = None
@ -120,18 +116,18 @@ class WebUIManager:
def _detect_feedback_mode(self) -> str:
"""檢測回饋模式"""
mode = os.environ.get('MCP_FEEDBACK_MODE', 'auto').lower()
if mode in ['web', 'desktop', 'auto']:
mode = os.environ.get("MCP_FEEDBACK_MODE", "auto").lower()
if mode in ["web", "desktop", "auto"]:
return mode
else:
debug_log(f"無效的 MCP_FEEDBACK_MODE 值: {mode},使用預設值 'auto'")
return 'auto'
debug_log(f"無效的 MCP_FEEDBACK_MODE 值: {mode},使用預設值 'auto'")
return "auto"
def _init_desktop_manager(self):
"""初始化桌面管理器(如果可用)"""
try:
# 嘗試導入桌面模組
from ..desktop import ElectronManager
self.desktop_manager = ElectronManager()
debug_log("桌面管理器初始化成功")
except ImportError:
@ -145,14 +141,15 @@ class WebUIManager:
"""判斷是否應該使用桌面模式"""
if self.mode == "web":
return False
elif self.mode == "desktop":
return self.desktop_manager is not None
else: # auto
# 自動模式:檢測環境
from ..server import is_remote_environment
if is_remote_environment():
return False
if self.mode == "desktop":
return self.desktop_manager is not None
# auto
# 自動模式:檢測環境
from ..server import is_remote_environment
if is_remote_environment():
return False
return self.desktop_manager is not None
def _setup_compression_middleware(self):
"""設置壓縮和緩存中間件"""
@ -161,10 +158,7 @@ class WebUIManager:
config = compression_manager.config
# 添加 Gzip 壓縮中間件
self.app.add_middleware(
GZipMiddleware,
minimum_size=config.minimum_size
)
self.app.add_middleware(GZipMiddleware, minimum_size=config.minimum_size)
# 添加緩存和壓縮統計中間件
@self.app.middleware("http")
@ -180,14 +174,20 @@ class WebUIManager:
# 更新壓縮統計(如果可能)
try:
content_length = int(response.headers.get('content-length', 0))
content_encoding = response.headers.get('content-encoding', '')
was_compressed = 'gzip' in content_encoding
content_length = int(response.headers.get("content-length", 0))
content_encoding = response.headers.get("content-encoding", "")
was_compressed = "gzip" in content_encoding
if content_length > 0:
# 估算原始大小(如果已壓縮,假設壓縮比為 30%
original_size = content_length if not was_compressed else int(content_length / 0.7)
compression_manager.update_stats(original_size, content_length, was_compressed)
original_size = (
content_length
if not was_compressed
else int(content_length / 0.7)
)
compression_manager.update_stats(
original_size, content_length, was_compressed
)
except (ValueError, TypeError):
# 忽略統計錯誤,不影響正常響應
pass
@ -233,7 +233,7 @@ class WebUIManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "內存監控會話清理", "force": force},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"內存監控會話清理失敗 [錯誤ID: {error_id}]: {e}")
@ -249,7 +249,7 @@ class WebUIManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "設置 Web UI 內存監控"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"設置 Web UI 內存監控失敗 [錯誤ID: {error_id}]: {e}")
@ -258,7 +258,9 @@ class WebUIManager:
# 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")
self.app.mount(
"/static", StaticFiles(directory=str(web_static_path)), name="static"
)
else:
raise RuntimeError(f"Static files directory not found: {web_static_path}")
@ -283,7 +285,7 @@ class WebUIManager:
if self.current_session:
debug_log("保存現有會話的標籤頁狀態並清理會話")
# 保存標籤頁狀態到全局
if hasattr(self.current_session, 'active_tabs'):
if hasattr(self.current_session, "active_tabs"):
self._merge_tabs_to_global(self.current_session.active_tabs)
# 同步清理會話資源(但保留 WebSocket 連接)
@ -312,6 +314,7 @@ class WebUIManager:
# 立即發送會話更新通知
import asyncio
try:
# 在後台任務中發送通知並轉移連接
asyncio.create_task(self._send_immediate_session_update())
@ -328,11 +331,11 @@ class WebUIManager:
return session_id
def get_session(self, session_id: str) -> Optional[WebFeedbackSession]:
def get_session(self, session_id: str) -> WebFeedbackSession | None:
"""獲取回饋會話 - 保持向後兼容"""
return self.sessions.get(session_id)
def get_current_session(self) -> Optional[WebFeedbackSession]:
def get_current_session(self) -> WebFeedbackSession | None:
"""獲取當前活躍會話"""
return self.current_session
@ -372,12 +375,12 @@ class WebUIManager:
self.global_active_tabs = {
tab_id: tab_info
for tab_id, tab_info in self.global_active_tabs.items()
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
if current_time - tab_info.get("last_seen", 0) <= expired_threshold
}
# 合併會話標籤頁到全局
for tab_id, tab_info in session_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
if current_time - tab_info.get("last_seen", 0) <= expired_threshold:
self.global_active_tabs[tab_id] = tab_info
debug_log(f"合併標籤頁狀態,全局活躍標籤頁數量: {len(self.global_active_tabs)}")
@ -391,7 +394,7 @@ class WebUIManager:
valid_tabs = {
tab_id: tab_info
for tab_id, tab_info in self.global_active_tabs.items()
if current_time - tab_info.get('last_seen', 0) <= expired_threshold
if current_time - tab_info.get("last_seen", 0) <= expired_threshold
}
self.global_active_tabs = valid_tabs
@ -411,47 +414,57 @@ class WebUIManager:
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})")
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
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} 被占用,使用增強端口管理查找新端口")
debug_log(
f"端口 {self.port} 被占用,使用增強端口管理查找新端口"
)
# 使用增強的端口管理查找新端口
try:
self.port = PortManager.find_free_port_enhanced(
preferred_port=self.port + 1,
auto_cleanup=False, # 啟動時不自動清理,避免誤殺其他服務
host=self.host
host=self.host,
)
debug_log(f"找到新的可用端口: {self.port}")
except RuntimeError as port_error:
# 使用統一錯誤處理
error_id = ErrorHandler.log_error_with_context(
port_error,
context={"operation": "端口查找", "current_port": self.port},
error_type=ErrorType.NETWORK
context={
"operation": "端口查找",
"current_port": self.port,
},
error_type=ErrorType.NETWORK,
)
debug_log(
f"無法找到可用端口 [錯誤ID: {error_id}]: {port_error}"
)
debug_log(f"無法找到可用端口 [錯誤ID: {error_id}]: {port_error}")
break
else:
debug_log("已達到最大重試次數,無法啟動伺服器")
@ -460,8 +473,12 @@ class WebUIManager:
# 使用統一錯誤處理
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "伺服器啟動", "host": self.host, "port": self.port},
error_type=ErrorType.NETWORK
context={
"operation": "伺服器啟動",
"host": self.host,
"port": self.port,
},
error_type=ErrorType.NETWORK,
)
debug_log(f"伺服器啟動錯誤 [錯誤ID: {error_id}]: {e}")
break
@ -469,8 +486,12 @@ class WebUIManager:
# 使用統一錯誤處理
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "伺服器運行", "host": self.host, "port": self.port},
error_type=ErrorType.SYSTEM
context={
"operation": "伺服器運行",
"host": self.host,
"port": self.port,
},
error_type=ErrorType.SYSTEM,
)
debug_log(f"伺服器運行錯誤 [錯誤ID: {error_id}]: {e}")
break
@ -478,7 +499,7 @@ class WebUIManager:
# 在新線程中啟動伺服器
self.server_thread = threading.Thread(target=run_server_with_retry, daemon=True)
self.server_thread.start()
# 等待伺服器啟動
time.sleep(2)
@ -497,8 +518,6 @@ class WebUIManager:
Returns:
bool: True 表示檢測到活躍標籤頁False 表示開啟了新視窗
"""
import asyncio
import aiohttp
try:
# 檢查是否有活躍標籤頁
@ -525,15 +544,17 @@ class WebUIManager:
# 檢查是否有活躍的 WebSocket 連接
if session.websocket:
# 直接通過當前會話的 WebSocket 發送
await session.websocket.send_json({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id
await session.websocket.send_json(
{
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id,
},
}
})
)
debug_log("會話更新通知已通過 WebSocket 發送")
else:
# 沒有活躍連接,設置待更新標記
@ -548,7 +569,9 @@ class WebUIManager:
"""立即發送會話更新通知(使用舊的 WebSocket 連接)"""
try:
# 檢查是否有保存的舊 WebSocket 連接
if hasattr(self, '_old_websocket_for_update') and hasattr(self, '_new_session_for_update'):
if hasattr(self, "_old_websocket_for_update") and hasattr(
self, "_new_session_for_update"
):
old_websocket = self._old_websocket_for_update
new_session = self._new_session_for_update
@ -557,8 +580,11 @@ class WebUIManager:
if old_websocket:
try:
# 檢查 WebSocket 連接狀態
if hasattr(old_websocket, 'client_state'):
websocket_valid = old_websocket.client_state != old_websocket.client_state.DISCONNECTED
if hasattr(old_websocket, "client_state"):
websocket_valid = (
old_websocket.client_state
!= old_websocket.client_state.DISCONNECTED
)
else:
# 如果沒有 client_state 屬性,嘗試發送測試消息來檢查連接
websocket_valid = True
@ -569,15 +595,17 @@ class WebUIManager:
if websocket_valid:
try:
# 發送會話更新通知
await old_websocket.send_json({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": new_session.project_directory,
"summary": new_session.summary,
"session_id": new_session.session_id
await old_websocket.send_json(
{
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": new_session.project_directory,
"summary": new_session.summary,
"session_id": new_session.session_id,
},
}
})
)
debug_log("已通過舊 WebSocket 連接發送會話更新通知")
# 延遲一小段時間讓前端處理消息
@ -597,8 +625,8 @@ class WebUIManager:
self._pending_session_update = True
# 清理臨時變數
delattr(self, '_old_websocket_for_update')
delattr(self, '_new_session_for_update')
delattr(self, "_old_websocket_for_update")
delattr(self, "_new_session_for_update")
else:
# 沒有舊連接,設置待更新標記
@ -619,7 +647,10 @@ class WebUIManager:
# 只有在確認連接沒有被新會話使用時才關閉
try:
# 檢查連接狀態
if hasattr(websocket, 'client_state') and websocket.client_state.DISCONNECTED:
if (
hasattr(websocket, "client_state")
and websocket.client_state.DISCONNECTED
):
debug_log("WebSocket 已斷開,跳過關閉操作")
return
@ -645,18 +676,20 @@ class WebUIManager:
# 調用活躍標籤頁 API
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(f"{self.get_server_url()}/api/active-tabs", timeout=2) as response:
async with session.get(
f"{self.get_server_url()}/api/active-tabs", timeout=2
) as response:
if response.status == 200:
data = await response.json()
tab_count = data.get("count", 0)
debug_log(f"API 檢測到 {tab_count} 個活躍標籤頁")
return tab_count > 0
else:
debug_log(f"檢查活躍標籤頁失敗,狀態碼:{response.status}")
return False
debug_log(f"檢查活躍標籤頁失敗,狀態碼:{response.status}")
return False
except asyncio.TimeoutError:
except TimeoutError:
debug_log("檢查活躍標籤頁超時")
return False
except Exception as e:
@ -689,7 +722,10 @@ class WebUIManager:
cleaned_count += 1
# 如果清理的是當前活躍會話,清空當前會話
if self.current_session and self.current_session.session_id == session_id:
if (
self.current_session
and self.current_session.session_id == session_id
):
self.current_session = None
debug_log("清空過期的當前活躍會話")
@ -697,22 +733,28 @@ class WebUIManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"session_id": session_id, "operation": "清理過期會話"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"清理過期會話 {session_id} 失敗 [錯誤ID: {error_id}]: {e}")
# 更新統計
cleanup_duration = time.time() - cleanup_start_time
self.cleanup_stats.update({
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"expired_cleanups": self.cleanup_stats["expired_cleanups"] + 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"] + cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"] + cleaned_count
})
self.cleanup_stats.update(
{
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"expired_cleanups": self.cleanup_stats["expired_cleanups"] + 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"]
+ cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"]
+ cleaned_count,
}
)
if cleaned_count > 0:
debug_log(f"清理了 {cleaned_count} 個過期會話,耗時: {cleanup_duration:.2f}")
debug_log(
f"清理了 {cleaned_count} 個過期會話,耗時: {cleanup_duration:.2f}"
)
return cleaned_count
@ -725,11 +767,19 @@ class WebUIManager:
# 優先級:已完成 > 已提交反饋 > 錯誤狀態 > 空閒時間最長
for session_id, session in self.sessions.items():
# 跳過當前活躍會話(除非強制清理)
if not force and self.current_session and session.session_id == self.current_session.session_id:
if (
not force
and self.current_session
and session.session_id == self.current_session.session_id
):
continue
# 優先清理已完成或錯誤狀態的會話
if session.status in [SessionStatus.COMPLETED, SessionStatus.ERROR, SessionStatus.TIMEOUT]:
if session.status in [
SessionStatus.COMPLETED,
SessionStatus.ERROR,
SessionStatus.TIMEOUT,
]:
sessions_to_clean.append((session_id, session, 1)) # 高優先級
elif session.status == SessionStatus.FEEDBACK_SUBMITTED:
# 已提交反饋但空閒時間較長的會話
@ -742,7 +792,9 @@ class WebUIManager:
sessions_to_clean.sort(key=lambda x: x[2])
# 清理會話(限制數量避免過度清理)
max_cleanup = min(len(sessions_to_clean), 5 if not force else len(sessions_to_clean))
max_cleanup = min(
len(sessions_to_clean), 5 if not force else len(sessions_to_clean)
)
cleaned_count = 0
for i in range(max_cleanup):
@ -754,7 +806,10 @@ class WebUIManager:
cleaned_count += 1
# 如果清理的是當前活躍會話,清空當前會話
if self.current_session and self.current_session.session_id == session_id:
if (
self.current_session
and self.current_session.session_id == session_id
):
self.current_session = None
debug_log("因內存壓力清空當前活躍會話")
@ -762,47 +817,69 @@ class WebUIManager:
error_id = ErrorHandler.log_error_with_context(
e,
context={"session_id": session_id, "operation": "內存壓力清理"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(
f"內存壓力清理會話 {session_id} 失敗 [錯誤ID: {error_id}]: {e}"
)
debug_log(f"內存壓力清理會話 {session_id} 失敗 [錯誤ID: {error_id}]: {e}")
# 更新統計
cleanup_duration = time.time() - cleanup_start_time
self.cleanup_stats.update({
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"memory_pressure_cleanups": self.cleanup_stats["memory_pressure_cleanups"] + 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"] + cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"] + cleaned_count
})
self.cleanup_stats.update(
{
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"memory_pressure_cleanups": self.cleanup_stats[
"memory_pressure_cleanups"
]
+ 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"]
+ cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"]
+ cleaned_count,
}
)
if cleaned_count > 0:
debug_log(f"因內存壓力清理了 {cleaned_count} 個會話,耗時: {cleanup_duration:.2f}")
debug_log(
f"因內存壓力清理了 {cleaned_count} 個會話,耗時: {cleanup_duration:.2f}"
)
return cleaned_count
def get_session_cleanup_stats(self) -> dict:
"""獲取會話清理統計"""
stats = self.cleanup_stats.copy()
stats.update({
"active_sessions": len(self.sessions),
"current_session_id": self.current_session.session_id if self.current_session else None,
"expired_sessions": sum(1 for s in self.sessions.values() if s.is_expired()),
"idle_sessions": sum(1 for s in self.sessions.values() if s.get_idle_time() > 300),
"memory_usage_mb": 0 # 將在下面計算
})
stats.update(
{
"active_sessions": len(self.sessions),
"current_session_id": self.current_session.session_id
if self.current_session
else None,
"expired_sessions": sum(
1 for s in self.sessions.values() if s.is_expired()
),
"idle_sessions": sum(
1 for s in self.sessions.values() if s.get_idle_time() > 300
),
"memory_usage_mb": 0, # 將在下面計算
}
)
# 計算內存使用(如果可能)
try:
import psutil
process = psutil.Process()
stats["memory_usage_mb"] = round(process.memory_info().rss / (1024 * 1024), 2)
stats["memory_usage_mb"] = round(
process.memory_info().rss / (1024 * 1024), 2
)
except:
pass
return stats
def _scan_expired_sessions(self) -> List[str]:
def _scan_expired_sessions(self) -> list[str]:
"""掃描過期會話ID列表"""
expired_sessions = []
for session_id, session in self.sessions.items():
@ -827,15 +904,21 @@ class WebUIManager:
# 更新統計
cleanup_duration = time.time() - cleanup_start_time
self.cleanup_stats.update({
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"manual_cleanups": self.cleanup_stats["manual_cleanups"] + 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"] + cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"] + session_count
})
self.cleanup_stats.update(
{
"total_cleanups": self.cleanup_stats["total_cleanups"] + 1,
"manual_cleanups": self.cleanup_stats["manual_cleanups"] + 1,
"last_cleanup_time": datetime.now().isoformat(),
"total_cleanup_duration": self.cleanup_stats["total_cleanup_duration"]
+ cleanup_duration,
"sessions_cleaned": self.cleanup_stats["sessions_cleaned"]
+ session_count,
}
)
debug_log(f"停止服務時清理了 {session_count} 個會話,耗時: {cleanup_duration:.2f}")
debug_log(
f"停止服務時清理了 {session_count} 個會話,耗時: {cleanup_duration:.2f}"
)
# 停止伺服器注意uvicorn 的 graceful shutdown 需要額外處理)
if self.server_thread and self.server_thread.is_alive():
@ -843,7 +926,7 @@ class WebUIManager:
# 全域實例
_web_ui_manager: Optional[WebUIManager] = None
_web_ui_manager: WebUIManager | None = None
def get_web_ui_manager() -> WebUIManager:
@ -854,7 +937,9 @@ def get_web_ui_manager() -> WebUIManager:
return _web_ui_manager
async def launch_web_feedback_ui(project_directory: str, summary: str, timeout: int = 600) -> dict:
async def launch_web_feedback_ui(
project_directory: str, summary: str, timeout: int = 600
) -> dict:
"""
啟動 Web 回饋介面並等待用戶回饋 - 重構為使用根路徑
@ -893,10 +978,10 @@ async def launch_web_feedback_ui(project_directory: str, summary: str, timeout:
try:
# 等待用戶回饋,傳遞 timeout 參數
result = await session.wait_for_feedback(timeout)
debug_log(f"收到用戶回饋")
debug_log("收到用戶回饋")
return result
except TimeoutError:
debug_log(f"會話超時")
debug_log("會話超時")
# 資源已在 wait_for_feedback 中清理,這裡只需要記錄和重新拋出
raise
except Exception as e:
@ -919,13 +1004,15 @@ def stop_web_ui():
# 測試用主函數
if __name__ == "__main__":
async def main():
try:
project_dir = os.getcwd()
summary = "這是一個測試摘要,用於驗證 Web UI 功能。"
from ..debug import debug_log
debug_log(f"啟動 Web UI 測試...")
debug_log("啟動 Web UI 測試...")
debug_log(f"專案目錄: {project_dir}")
debug_log("等待用戶回饋...")
@ -943,4 +1030,4 @@ if __name__ == "__main__":
finally:
stop_web_ui()
asyncio.run(main())
asyncio.run(main())

View File

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

View File

@ -1,16 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回饋結果資料模型
定義回饋收集的資料結構用於 Web UI 與後端的資料傳輸
"""
from typing import TypedDict, List
from typing import TypedDict
class FeedbackResult(TypedDict):
"""回饋結果的型別定義"""
command_logs: str
interactive_feedback: str
images: List[dict]
images: list[dict]

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web 回饋會話模型
===============
@ -12,58 +11,74 @@ import base64
import subprocess
import threading
import time
from datetime import datetime, timedelta
from collections.abc import Callable
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, Callable
from fastapi import WebSocket
from ...debug import web_debug_log as debug_log
from ...utils.resource_manager import get_resource_manager, register_process
from ...utils.error_handler import ErrorHandler, ErrorType
from ...utils.resource_manager import get_resource_manager, register_process
class SessionStatus(Enum):
"""會話狀態枚舉"""
WAITING = "waiting" # 等待中
ACTIVE = "active" # 活躍中
WAITING = "waiting" # 等待中
ACTIVE = "active" # 活躍中
FEEDBACK_SUBMITTED = "feedback_submitted" # 已提交反饋
COMPLETED = "completed" # 已完成
TIMEOUT = "timeout" # 超時
ERROR = "error" # 錯誤
EXPIRED = "expired" # 已過期
COMPLETED = "completed" # 已完成
TIMEOUT = "timeout" # 超時
ERROR = "error" # 錯誤
EXPIRED = "expired" # 已過期
class CleanupReason(Enum):
"""清理原因枚舉"""
TIMEOUT = "timeout" # 超時清理
EXPIRED = "expired" # 過期清理
TIMEOUT = "timeout" # 超時清理
EXPIRED = "expired" # 過期清理
MEMORY_PRESSURE = "memory_pressure" # 內存壓力清理
MANUAL = "manual" # 手動清理
ERROR = "error" # 錯誤清理
SHUTDOWN = "shutdown" # 系統關閉清理
MANUAL = "manual" # 手動清理
ERROR = "error" # 錯誤清理
SHUTDOWN = "shutdown" # 系統關閉清理
# 常數定義
MAX_IMAGE_SIZE = 1 * 1024 * 1024 # 1MB 圖片大小限制
SUPPORTED_IMAGE_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/bmp', 'image/webp'}
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,
auto_cleanup_delay: int = 3600, max_idle_time: int = 1800):
def __init__(
self,
session_id: str,
project_directory: str,
summary: str,
auto_cleanup_delay: int = 3600,
max_idle_time: int = 1800,
):
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.websocket: WebSocket | None = None
self.feedback_result: str | None = None
self.images: list[dict] = []
self.settings: dict = {} # 圖片設定
self.feedback_completed = threading.Event()
self.process: Optional[subprocess.Popen] = None
self.process: subprocess.Popen | None = None
self.command_logs = []
self._cleanup_done = False # 防止重複清理
@ -77,8 +92,8 @@ class WebFeedbackSession:
# 新增:自動清理配置
self.auto_cleanup_delay = auto_cleanup_delay # 自動清理延遲時間(秒)
self.max_idle_time = max_idle_time # 最大空閒時間(秒)
self.cleanup_timer: Optional[threading.Timer] = None
self.cleanup_callbacks: List[Callable] = [] # 清理回調函數列表
self.cleanup_timer: threading.Timer | None = None
self.cleanup_callbacks: list[Callable] = [] # 清理回調函數列表
# 新增:清理統計
self.cleanup_stats = {
@ -87,7 +102,7 @@ class WebFeedbackSession:
"cleanup_reason": None,
"cleanup_duration": 0.0,
"memory_freed": 0,
"resources_cleaned": 0
"resources_cleaned": 0,
}
# 確保臨時目錄存在
@ -99,7 +114,9 @@ class WebFeedbackSession:
# 啟動自動清理定時器
self._schedule_auto_cleanup()
debug_log(f"會話 {self.session_id} 初始化完成,自動清理延遲: {auto_cleanup_delay}秒,最大空閒: {max_idle_time}")
debug_log(
f"會話 {self.session_id} 初始化完成,自動清理延遲: {auto_cleanup_delay}秒,最大空閒: {max_idle_time}"
)
def update_status(self, status: SessionStatus, message: str = None):
"""更新會話狀態"""
@ -113,7 +130,9 @@ class WebFeedbackSession:
if status in [SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]:
self._schedule_auto_cleanup()
debug_log(f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}")
debug_log(
f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}"
)
def get_status_info(self) -> dict:
"""獲取會話狀態信息"""
@ -126,12 +145,16 @@ class WebFeedbackSession:
"last_activity": self.last_activity,
"project_directory": self.project_directory,
"summary": self.summary,
"session_id": self.session_id
"session_id": self.session_id,
}
def is_active(self) -> bool:
"""檢查會話是否活躍"""
return self.status in [SessionStatus.WAITING, SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]
return self.status in [
SessionStatus.WAITING,
SessionStatus.ACTIVE,
SessionStatus.FEEDBACK_SUBMITTED,
]
def is_expired(self) -> bool:
"""檢查會話是否已過期"""
@ -141,7 +164,9 @@ class WebFeedbackSession:
# 檢查是否超過最大空閒時間
idle_time = current_time - self.last_activity
if idle_time > self.max_idle_time:
debug_log(f"會話 {self.session_id} 空閒時間過長: {idle_time:.1f}秒 > {self.max_idle_time}")
debug_log(
f"會話 {self.session_id} 空閒時間過長: {idle_time:.1f}秒 > {self.max_idle_time}"
)
return True
# 檢查是否處於已過期狀態
@ -152,7 +177,9 @@ class WebFeedbackSession:
if self.status in [SessionStatus.ERROR, SessionStatus.TIMEOUT]:
error_time = current_time - self.last_activity
if error_time > 300: # 錯誤狀態超過5分鐘視為過期
debug_log(f"會話 {self.session_id} 錯誤狀態時間過長: {error_time:.1f}")
debug_log(
f"會話 {self.session_id} 錯誤狀態時間過長: {error_time:.1f}"
)
return True
return False
@ -179,9 +206,12 @@ class WebFeedbackSession:
debug_log(f"會話 {self.session_id} 觸發自動清理(過期)")
# 使用異步方式執行清理
import asyncio
try:
loop = asyncio.get_event_loop()
loop.create_task(self._cleanup_resources_enhanced(CleanupReason.EXPIRED))
loop.create_task(
self._cleanup_resources_enhanced(CleanupReason.EXPIRED)
)
except RuntimeError:
# 如果沒有事件循環,使用同步清理
self._cleanup_sync_enhanced(CleanupReason.EXPIRED)
@ -192,14 +222,16 @@ class WebFeedbackSession:
error_id = ErrorHandler.log_error_with_context(
e,
context={"session_id": self.session_id, "operation": "自動清理"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"自動清理失敗 [錯誤ID: {error_id}]: {e}")
self.cleanup_timer = threading.Timer(self.auto_cleanup_delay, auto_cleanup)
self.cleanup_timer.daemon = True
self.cleanup_timer.start()
debug_log(f"會話 {self.session_id} 自動清理定時器已設置,{self.auto_cleanup_delay}秒後觸發")
debug_log(
f"會話 {self.session_id} 自動清理定時器已設置,{self.auto_cleanup_delay}秒後觸發"
)
def extend_cleanup_timer(self, additional_time: int = None):
"""延長清理定時器"""
@ -230,27 +262,29 @@ class WebFeedbackSession:
def get_cleanup_stats(self) -> dict:
"""獲取清理統計信息"""
stats = self.cleanup_stats.copy()
stats.update({
"session_id": self.session_id,
"age": self.get_age(),
"idle_time": self.get_idle_time(),
"is_expired": self.is_expired(),
"is_active": self.is_active(),
"status": self.status.value,
"has_websocket": self.websocket is not None,
"has_process": self.process is not None,
"command_logs_count": len(self.command_logs),
"images_count": len(self.images)
})
stats.update(
{
"session_id": self.session_id,
"age": self.get_age(),
"idle_time": self.get_idle_time(),
"is_expired": self.is_expired(),
"is_active": self.is_active(),
"status": self.status.value,
"has_websocket": self.websocket is not None,
"has_process": self.process is not None,
"command_logs_count": len(self.command_logs),
"images_count": len(self.images),
}
)
return stats
async def wait_for_feedback(self, timeout: int = 600) -> dict:
"""
等待用戶回饋包含圖片支援超時自動清理
Args:
timeout: 超時時間
Returns:
dict: 回饋結果
"""
@ -261,36 +295,43 @@ class WebFeedbackSession:
actual_timeout = max(timeout - 1, 5) # 短超時提前1秒最少5秒
else:
actual_timeout = timeout - 5 # 長超時提前5秒
debug_log(f"會話 {self.session_id} 開始等待回饋,超時時間: {actual_timeout} 秒(原始: {timeout} 秒)")
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
"settings": self.settings,
}
else:
# 超時了,立即清理資源
debug_log(f"會話 {self.session_id}{actual_timeout} 秒後超時,開始清理資源...")
await self._cleanup_resources_on_timeout()
raise TimeoutError(f"等待用戶回饋超時({actual_timeout}秒),介面已自動關閉")
# 超時了,立即清理資源
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):
async def submit_feedback(
self, feedback: str, images: list[dict], settings: dict = None
):
"""
提交回饋和圖片
@ -305,24 +346,28 @@ class WebFeedbackSession:
self.images = self._process_images(images)
# 更新狀態為已提交反饋
self.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已送出反饋,等待下次 MCP 調用")
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
})
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]:
def _process_images(self, images: list[dict]) -> list[dict]:
"""
處理圖片數據轉換為統一格式
@ -335,7 +380,7 @@ class WebFeedbackSession:
processed_images = []
# 從設定中獲取圖片大小限制,如果沒有設定則使用預設值
size_limit = self.settings.get('image_size_limit', MAX_IMAGE_SIZE)
size_limit = self.settings.get("image_size_limit", MAX_IMAGE_SIZE)
for img in images:
try:
@ -344,9 +389,11 @@ class WebFeedbackSession:
# 檢查文件大小只有當限制大於0時才檢查
if size_limit > 0 and img["size"] > size_limit:
debug_log(f"圖片 {img['name']} 超過大小限制 ({size_limit} bytes),跳過")
debug_log(
f"圖片 {img['name']} 超過大小限制 ({size_limit} bytes),跳過"
)
continue
# 解碼 base64 數據
if isinstance(img["data"], str):
try:
@ -356,23 +403,27 @@ class WebFeedbackSession:
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")
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):
@ -395,7 +446,7 @@ class WebFeedbackSession:
try:
debug_log(f"執行命令: {command}")
self.process = subprocess.Popen(
command,
shell=True,
@ -404,14 +455,14 @@ class WebFeedbackSession:
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True
universal_newlines=True,
)
# 註冊進程到資源管理器
register_process(
self.process,
description=f"WebFeedbackSession-{self.session_id}-command",
auto_cleanup=True
auto_cleanup=True,
)
# 在背景線程中讀取輸出
@ -422,24 +473,23 @@ class WebFeedbackSession:
def read_line():
if self.process and self.process.stdout:
return self.process.stdout.readline()
return ''
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
})
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:
@ -453,10 +503,9 @@ class WebFeedbackSession:
# 發送命令完成信號
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_complete",
"exit_code": exit_code
})
await self.websocket.send_json(
{"type": "command_complete", "exit_code": exit_code}
)
except Exception as e:
debug_log(f"發送完成信號失敗: {e}")
@ -467,10 +516,9 @@ class WebFeedbackSession:
debug_log(f"執行命令錯誤: {e}")
if self.websocket:
try:
await self.websocket.send_json({
"type": "command_error",
"error": str(e)
})
await self.websocket.send_json(
{"type": "command_error", "error": str(e)}
)
except:
pass
@ -500,6 +548,7 @@ class WebFeedbackSession:
# 記錄清理前的內存使用(如果可能)
try:
import psutil
process = psutil.Process()
memory_before = process.memory_info().rss
except:
@ -521,14 +570,16 @@ class WebFeedbackSession:
CleanupReason.MEMORY_PRESSURE: "系統內存不足,會話將被清理",
CleanupReason.MANUAL: "會話已被手動清理",
CleanupReason.ERROR: "會話發生錯誤,將被清理",
CleanupReason.SHUTDOWN: "系統正在關閉,會話將被清理"
CleanupReason.SHUTDOWN: "系統正在關閉,會話將被清理",
}
await self.websocket.send_json({
"type": "session_cleanup",
"reason": reason.value,
"message": message_map.get(reason, "會話將被清理")
})
await self.websocket.send_json(
{
"type": "session_cleanup",
"reason": reason.value,
"message": message_map.get(reason, "會話將被清理"),
}
)
await asyncio.sleep(0.1) # 給前端一點時間處理消息
# 安全關閉 WebSocket
@ -596,6 +647,7 @@ class WebFeedbackSession:
memory_after = 0
try:
import psutil
process = psutil.Process()
memory_after = process.memory_info().rss
except:
@ -604,14 +656,18 @@ class WebFeedbackSession:
memory_freed = max(0, memory_before - memory_after)
# 更新清理統計
self.cleanup_stats.update({
"cleanup_duration": cleanup_duration,
"memory_freed": memory_freed,
"resources_cleaned": resources_cleaned
})
self.cleanup_stats.update(
{
"cleanup_duration": cleanup_duration,
"memory_freed": memory_freed,
"resources_cleaned": resources_cleaned,
}
)
debug_log(f"會話 {self.session_id} 資源清理完成,耗時: {cleanup_duration:.2f}秒,"
f"清理資源: {resources_cleaned}個,釋放內存: {memory_freed}字節")
debug_log(
f"會話 {self.session_id} 資源清理完成,耗時: {cleanup_duration:.2f}秒,"
f"清理資源: {resources_cleaned}個,釋放內存: {memory_freed}字節"
)
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
@ -619,11 +675,13 @@ class WebFeedbackSession:
context={
"session_id": self.session_id,
"cleanup_reason": reason.value,
"operation": "增強資源清理"
"operation": "增強資源清理",
},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(
f"清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}"
)
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}")
# 即使發生錯誤也要更新統計
self.cleanup_stats["cleanup_duration"] = time.time() - cleanup_start_time
@ -632,13 +690,17 @@ class WebFeedbackSession:
"""同步清理會話資源(但保留 WebSocket 連接)- 保持向後兼容"""
self._cleanup_sync_enhanced(CleanupReason.MANUAL, preserve_websocket=True)
def _cleanup_sync_enhanced(self, reason: CleanupReason, preserve_websocket: bool = False):
def _cleanup_sync_enhanced(
self, reason: CleanupReason, preserve_websocket: bool = False
):
"""增強的同步清理會話資源"""
if self._cleanup_done and not preserve_websocket:
return
cleanup_start_time = time.time()
debug_log(f"同步清理會話 {self.session_id} 資源,原因: {reason.value}保留WebSocket: {preserve_websocket}")
debug_log(
f"同步清理會話 {self.session_id} 資源,原因: {reason.value}保留WebSocket: {preserve_websocket}"
)
# 更新清理統計
self.cleanup_stats["cleanup_count"] += 1
@ -652,6 +714,7 @@ class WebFeedbackSession:
# 記錄清理前的內存使用
try:
import psutil
process = psutil.Process()
memory_before = process.memory_info().rss
except:
@ -721,6 +784,7 @@ class WebFeedbackSession:
memory_after = 0
try:
import psutil
process = psutil.Process()
memory_after = process.memory_info().rss
except:
@ -729,14 +793,18 @@ class WebFeedbackSession:
memory_freed = max(0, memory_before - memory_after)
# 更新清理統計
self.cleanup_stats.update({
"cleanup_duration": cleanup_duration,
"memory_freed": memory_freed,
"resources_cleaned": resources_cleaned
})
self.cleanup_stats.update(
{
"cleanup_duration": cleanup_duration,
"memory_freed": memory_freed,
"resources_cleaned": resources_cleaned,
}
)
debug_log(f"會話 {self.session_id} 同步清理完成,耗時: {cleanup_duration:.2f}秒,"
f"清理資源: {resources_cleaned}個,釋放內存: {memory_freed}字節")
debug_log(
f"會話 {self.session_id} 同步清理完成,耗時: {cleanup_duration:.2f}秒,"
f"清理資源: {resources_cleaned}個,釋放內存: {memory_freed}字節"
)
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
@ -745,11 +813,13 @@ class WebFeedbackSession:
"session_id": self.session_id,
"cleanup_reason": reason.value,
"preserve_websocket": preserve_websocket,
"operation": "同步資源清理"
"operation": "同步資源清理",
},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(
f"同步清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}"
)
debug_log(f"同步清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}")
# 即使發生錯誤也要更新統計
self.cleanup_stats["cleanup_duration"] = time.time() - cleanup_start_time
@ -765,20 +835,27 @@ class WebFeedbackSession:
try:
# 檢查連接狀態
if hasattr(self.websocket, 'client_state') and self.websocket.client_state.DISCONNECTED:
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)
await asyncio.wait_for(
self.websocket.close(code=1000, reason="會話清理"), timeout=2.0
)
debug_log(f"會話 {self.session_id} WebSocket 已正常關閉")
except asyncio.TimeoutError:
except 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}")
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}")
debug_log(f"會話 {self.session_id} 關閉 WebSocket 時發生未知錯誤: {e}")

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 路由模組
==============
@ -9,4 +8,5 @@ Web UI 路由模組
from .main_routes import setup_routes
__all__ = ['setup_routes']
__all__ = ["setup_routes"]

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
主要路由處理
============
@ -8,7 +7,6 @@
"""
import json
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING
@ -16,8 +14,9 @@ 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
from ... import __version__
from ...debug import web_debug_log as debug_log
if TYPE_CHECKING:
from ..main import WebUIManager
@ -31,20 +30,20 @@ def load_user_layout_settings() -> str:
settings_file = config_dir / "ui_settings.json"
if settings_file.exists():
with open(settings_file, 'r', encoding='utf-8') as f:
with open(settings_file, encoding="utf-8") as f:
settings = json.load(f)
layout_mode = settings.get('layoutMode', 'combined-vertical')
layout_mode = settings.get("layoutMode", "combined-vertical")
debug_log(f"從設定檔案載入佈局模式: {layout_mode}")
return layout_mode
else:
debug_log("設定檔案不存在,使用預設佈局模式: combined-vertical")
return 'combined-vertical'
return "combined-vertical"
except Exception as e:
debug_log(f"載入佈局設定失敗: {e},使用預設佈局模式: combined-vertical")
return 'combined-vertical'
return "combined-vertical"
def setup_routes(manager: 'WebUIManager'):
def setup_routes(manager: "WebUIManager"):
"""設置路由"""
@manager.app.get("/", response_class=HTMLResponse)
@ -55,44 +54,50 @@ def setup_routes(manager: 'WebUIManager'):
if not current_session:
# 沒有活躍會話時顯示等待頁面
return manager.templates.TemplateResponse("index.html", {
"request": request,
"title": "MCP Feedback Enhanced",
"has_session": False,
"version": __version__
})
return manager.templates.TemplateResponse(
"index.html",
{
"request": request,
"title": "MCP Feedback Enhanced",
"has_session": False,
"version": __version__,
},
)
# 有活躍會話時顯示回饋頁面
# 載入用戶的佈局模式設定
layout_mode = load_user_layout_settings()
return manager.templates.TemplateResponse("feedback.html", {
"request": request,
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"title": "Interactive Feedback - 回饋收集",
"version": __version__,
"has_session": True,
"layout_mode": layout_mode,
"i18n": manager.i18n
})
return manager.templates.TemplateResponse(
"feedback.html",
{
"request": request,
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"title": "Interactive Feedback - 回饋收集",
"version": __version__,
"has_session": True,
"layout_mode": layout_mode,
"i18n": manager.i18n,
},
)
@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:
with open(translation_file, encoding="utf-8") as f:
lang_data = json.load(f)
translations[lang_code] = lang_data
debug_log(f"成功載入 Web 翻譯: {lang_code}")
@ -102,7 +107,7 @@ def setup_routes(manager: 'WebUIManager'):
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)
@ -112,21 +117,25 @@ def setup_routes(manager: 'WebUIManager'):
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(content={
"has_session": False,
"status": "no_session",
"message": "沒有活躍會話"
})
return JSONResponse(
content={
"has_session": False,
"status": "no_session",
"message": "沒有活躍會話",
}
)
return JSONResponse(content={
"has_session": True,
"status": "active",
"session_info": {
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set()
return JSONResponse(
content={
"has_session": True,
"status": "active",
"session_info": {
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set(),
},
}
})
)
@manager.app.get("/api/current-session")
async def get_current_session():
@ -134,19 +143,18 @@ 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": "沒有活躍會話"})
return JSONResponse(content={
"session_id": current_session.session_id,
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set(),
"command_logs": current_session.command_logs,
"images_count": len(current_session.images)
})
return JSONResponse(
content={
"session_id": current_session.session_id,
"project_directory": current_session.project_directory,
"summary": current_session.summary,
"feedback_completed": current_session.feedback_completed.is_set(),
"command_logs": current_session.command_logs,
"images_count": len(current_session.images),
}
)
@manager.app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
@ -168,31 +176,31 @@ def setup_routes(manager: 'WebUIManager'):
# 發送連接成功消息
try:
await websocket.send_json({
"type": "connection_established",
"message": "WebSocket 連接已建立"
})
await websocket.send_json(
{"type": "connection_established", "message": "WebSocket 連接已建立"}
)
# 檢查是否有待發送的會話更新
if getattr(manager, '_pending_session_update', False):
if getattr(manager, "_pending_session_update", False):
debug_log("檢測到待發送的會話更新,準備發送通知")
await websocket.send_json({
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id
await websocket.send_json(
{
"type": "session_updated",
"message": "新會話已創建,正在更新頁面內容",
"session_info": {
"project_directory": session.project_directory,
"summary": session.summary,
"session_id": session.session_id,
},
}
})
)
manager._pending_session_update = False
debug_log("✅ 已發送會話更新通知到前端")
else:
# 發送當前會話狀態
await websocket.send_json({
"type": "status_update",
"status_info": session.get_status_info()
})
await websocket.send_json(
{"type": "status_update", "status_info": session.get_status_info()}
)
debug_log("已發送當前會話狀態到前端")
except Exception as e:
@ -212,9 +220,9 @@ def setup_routes(manager: 'WebUIManager'):
break
except WebSocketDisconnect:
debug_log(f"WebSocket 連接正常斷開")
debug_log("WebSocket 連接正常斷開")
except ConnectionResetError:
debug_log(f"WebSocket 連接被重置")
debug_log("WebSocket 連接被重置")
except Exception as e:
debug_log(f"WebSocket 錯誤: {e}")
finally:
@ -236,7 +244,7 @@ def setup_routes(manager: 'WebUIManager'):
settings_file = config_dir / "ui_settings.json"
# 保存設定到檔案
with open(settings_file, 'w', encoding='utf-8') as f:
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
debug_log(f"設定已保存到: {settings_file}")
@ -247,7 +255,7 @@ def setup_routes(manager: 'WebUIManager'):
debug_log(f"保存設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"保存失敗: {str(e)}"}
content={"status": "error", "message": f"保存失敗: {e!s}"},
)
@manager.app.get("/api/load-settings")
@ -259,20 +267,19 @@ def setup_routes(manager: 'WebUIManager'):
settings_file = config_dir / "ui_settings.json"
if settings_file.exists():
with open(settings_file, 'r', encoding='utf-8') as f:
with open(settings_file, encoding="utf-8") as f:
settings = json.load(f)
debug_log(f"設定已從檔案載入: {settings_file}")
return JSONResponse(content=settings)
else:
debug_log("設定檔案不存在,返回空設定")
return JSONResponse(content={})
debug_log("設定檔案不存在,返回空設定")
return JSONResponse(content={})
except Exception as e:
debug_log(f"載入設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"載入失敗: {str(e)}"}
content={"status": "error", "message": f"載入失敗: {e!s}"},
)
@manager.app.post("/api/clear-settings")
@ -295,7 +302,7 @@ def setup_routes(manager: 'WebUIManager'):
debug_log(f"清除設定失敗: {e}")
return JSONResponse(
status_code=500,
content={"status": "error", "message": f"清除失敗: {str(e)}"}
content={"status": "error", "message": f"清除失敗: {e!s}"},
)
@manager.app.get("/api/active-tabs")
@ -307,7 +314,7 @@ def setup_routes(manager: 'WebUIManager'):
# 清理過期的全局標籤頁
valid_global_tabs = {}
for tab_id, tab_info in manager.global_active_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
if current_time - tab_info.get("last_seen", 0) <= expired_threshold:
valid_global_tabs[tab_id] = tab_info
manager.global_active_tabs = valid_global_tabs
@ -316,20 +323,22 @@ def setup_routes(manager: 'WebUIManager'):
current_session = manager.get_current_session()
if current_session:
# 合併會話標籤頁到全局(如果有的話)
session_tabs = getattr(current_session, 'active_tabs', {})
session_tabs = getattr(current_session, "active_tabs", {})
for tab_id, tab_info in session_tabs.items():
if current_time - tab_info.get('last_seen', 0) <= expired_threshold:
if current_time - tab_info.get("last_seen", 0) <= expired_threshold:
valid_global_tabs[tab_id] = tab_info
# 更新會話的活躍標籤頁
current_session.active_tabs = valid_global_tabs.copy()
manager.global_active_tabs = valid_global_tabs
return JSONResponse(content={
"has_session": current_session is not None,
"active_tabs": valid_global_tabs,
"count": len(valid_global_tabs)
})
return JSONResponse(
content={
"has_session": current_session is not None,
"active_tabs": valid_global_tabs,
"count": len(valid_global_tabs),
}
)
@manager.app.post("/api/register-tab")
async def register_tab(request: Request):
@ -339,26 +348,20 @@ def setup_routes(manager: 'WebUIManager'):
tab_id = data.get("tabId")
if not tab_id:
return JSONResponse(
status_code=400,
content={"error": "缺少 tabId"}
)
return JSONResponse(status_code=400, content={"error": "缺少 tabId"})
current_session = manager.get_current_session()
if not current_session:
return JSONResponse(
status_code=404,
content={"error": "沒有活躍會話"}
)
return JSONResponse(status_code=404, content={"error": "沒有活躍會話"})
# 註冊標籤頁
tab_info = {
'timestamp': time.time() * 1000, # 毫秒時間戳
'last_seen': time.time(),
'registered_at': time.time()
"timestamp": time.time() * 1000, # 毫秒時間戳
"last_seen": time.time(),
"registered_at": time.time(),
}
if not hasattr(current_session, 'active_tabs'):
if not hasattr(current_session, "active_tabs"):
current_session.active_tabs = {}
current_session.active_tabs[tab_id] = tab_info
@ -368,21 +371,16 @@ def setup_routes(manager: 'WebUIManager'):
debug_log(f"標籤頁已註冊: {tab_id}")
return JSONResponse(content={
"status": "success",
"tabId": tab_id,
"registered": True
})
return JSONResponse(
content={"status": "success", "tabId": tab_id, "registered": True}
)
except Exception as e:
debug_log(f"註冊標籤頁失敗: {e}")
return JSONResponse(
status_code=500,
content={"error": f"註冊失敗: {str(e)}"}
)
return JSONResponse(status_code=500, content={"error": f"註冊失敗: {e!s}"})
async def handle_websocket_message(manager: 'WebUIManager', session, data: dict):
async def handle_websocket_message(manager: "WebUIManager", session, data: dict):
"""處理 WebSocket 消息"""
message_type = data.get("type")
@ -403,10 +401,9 @@ async def handle_websocket_message(manager: 'WebUIManager', session, data: dict)
# 獲取會話狀態
if session.websocket:
try:
await session.websocket.send_json({
"type": "status_update",
"status_info": session.get_status_info()
})
await session.websocket.send_json(
{"type": "status_update", "status_info": session.get_status_info()}
)
except Exception as e:
debug_log(f"發送狀態更新失敗: {e}")
@ -415,13 +412,10 @@ async def handle_websocket_message(manager: 'WebUIManager', session, data: dict)
tab_id = data.get("tabId", "unknown")
timestamp = data.get("timestamp", 0)
tab_info = {
'timestamp': timestamp,
'last_seen': time.time()
}
tab_info = {"timestamp": timestamp, "last_seen": time.time()}
# 更新會話的標籤頁信息
if hasattr(session, 'active_tabs'):
if hasattr(session, "active_tabs"):
session.active_tabs[tab_id] = tab_info
else:
session.active_tabs = {tab_id: tab_info}
@ -432,11 +426,13 @@ async def handle_websocket_message(manager: 'WebUIManager', session, data: dict)
# 發送心跳回應
if session.websocket:
try:
await session.websocket.send_json({
"type": "heartbeat_response",
"tabId": tab_id,
"timestamp": timestamp
})
await session.websocket.send_json(
{
"type": "heartbeat_response",
"tabId": tab_id,
"timestamp": timestamp,
}
)
except Exception as e:
debug_log(f"發送心跳回應失敗: {e}")
@ -451,10 +447,12 @@ async def handle_websocket_message(manager: 'WebUIManager', session, data: dict)
debug_log(f"未知的消息類型: {message_type}")
async def _delayed_server_stop(manager: 'WebUIManager'):
async def _delayed_server_stop(manager: "WebUIManager"):
"""延遲停止服務器"""
import asyncio
await asyncio.sleep(5) # 等待 5 秒讓前端有時間關閉
from ..main import stop_web_ui
stop_web_ui()
debug_log("Web UI 服務器已因用戶超時而停止")
debug_log("Web UI 服務器已因用戶超時而停止")

View File

@ -50,7 +50,7 @@
<div class="input-group">
<label class="input-label" data-i18n="feedback.imageLabel">{{ label_text }}</label>
<!-- 相容性提示區域 -->
<div id="{{ id_prefix }}CompatibilityHint" class="compatibility-hint" style="display: none;">
<span data-i18n="images.settings.compatibilityHint">💡 圖片無法正確識別?</span>
@ -58,7 +58,7 @@
嘗試啟用 Base64 相容模式
</button>
</div>
<!-- 圖片上傳區域 -->
<div id="{{ id_prefix }}ImageUploadArea" class="image-upload-area" style="min-height: {{ min_height }};">
<div id="{{ id_prefix }}ImageUploadText" data-i18n="feedback.imageUploadText">

View File

@ -11,11 +11,11 @@
- visible: 是否顯示 (預設: false)
使用方式:
{% include 'components/status-indicator.html' with
id="feedbackStatusIndicator",
status="waiting",
icon="⏳",
title="等待您的回饋",
{% include 'components/status-indicator.html' with
id="feedbackStatusIndicator",
status="waiting",
icon="⏳",
title="等待您的回饋",
message="請提供您對 AI 工作成果的意見和建議" %}
#}

View File

@ -425,9 +425,9 @@
<div class="input-group">
<label class="input-label" data-i18n="feedback.textLabel">文字回饋</label>
<textarea
id="feedbackText"
class="text-input"
<textarea
id="feedbackText"
class="text-input"
data-i18n-placeholder="feedback.detailedPlaceholder"
placeholder="請在這裡輸入您的回饋...
@ -447,7 +447,7 @@
<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 !important; cursor: text; padding: 12px; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word;" data-dynamic-content="aiSummary">
{{ summary }}
@ -460,7 +460,7 @@
<div class="section-description" data-i18n="command.description">
在此執行命令來驗證結果或收集更多資訊。命令將在專案目錄中執行。
</div>
<!-- 命令輸出區域 - 放在上面 -->
<div class="input-group">
<label class="input-label" data-i18n="command.outputLabel">命令輸出</label>
@ -473,10 +473,10 @@
<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
<input
type="text"
id="commandInput"
class="command-input-line"
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;"
@ -494,7 +494,7 @@
<div class="section-description" style="margin-bottom: 12px; padding: 8px 12px; font-size: 13px;" data-i18n="combined.description">
AI 摘要和回饋輸入在同一頁面中,方便對照查看。
</div>
<div class="combined-content">
<!-- AI 摘要區域 -->
<div class="combined-section">
@ -797,4 +797,4 @@
}
</script>
</body>
</html>
</html>

View File

@ -328,4 +328,4 @@
<!-- 主應用程式 -->
<script src="/static/js/app.js?v=2025010505"></script>
</body>
</html>
</html>

View File

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

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
瀏覽器工具函數
==============
@ -8,10 +7,9 @@
"""
import os
import sys
import subprocess
import webbrowser
from typing import Callable
from collections.abc import Callable
# 導入調試功能
from ...debug import server_debug_log as debug_log
@ -26,20 +24,20 @@ def is_wsl_environment() -> bool:
"""
try:
# 檢查 /proc/version 文件是否包含 WSL 標識
if os.path.exists('/proc/version'):
with open('/proc/version', 'r') as f:
if os.path.exists("/proc/version"):
with open("/proc/version") as f:
version_info = f.read().lower()
if 'microsoft' in version_info or 'wsl' in version_info:
if "microsoft" in version_info or "wsl" in version_info:
return True
# 檢查 WSL 相關環境變數
wsl_env_vars = ['WSL_DISTRO_NAME', 'WSL_INTEROP', 'WSLENV']
wsl_env_vars = ["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"]
for env_var in wsl_env_vars:
if os.getenv(env_var):
return True
# 檢查是否存在 WSL 特有的路徑
wsl_paths = ['/mnt/c', '/mnt/d', '/proc/sys/fs/binfmt_misc/WSLInterop']
wsl_paths = ["/mnt/c", "/mnt/d", "/proc/sys/fs/binfmt_misc/WSLInterop"]
for path in wsl_paths:
if os.path.exists(path):
return True
@ -59,42 +57,51 @@ def open_browser_in_wsl(url: str) -> None:
"""
try:
# 嘗試使用 cmd.exe 啟動瀏覽器
cmd = ['cmd.exe', '/c', 'start', url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
cmd = ["cmd.exe", "/c", "start", url]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, check=False
)
if result.returncode == 0:
debug_log(f"成功使用 cmd.exe 啟動瀏覽器: {url}")
return
else:
debug_log(f"cmd.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
debug_log(
f"cmd.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}"
)
except Exception as e:
debug_log(f"使用 cmd.exe 啟動瀏覽器失敗: {e}")
try:
# 嘗試使用 powershell.exe 啟動瀏覽器
cmd = ['powershell.exe', '-c', f'Start-Process "{url}"']
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
cmd = ["powershell.exe", "-c", f'Start-Process "{url}"']
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, check=False
)
if result.returncode == 0:
debug_log(f"成功使用 powershell.exe 啟動瀏覽器: {url}")
return
else:
debug_log(f"powershell.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
debug_log(
f"powershell.exe 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}"
)
except Exception as e:
debug_log(f"使用 powershell.exe 啟動瀏覽器失敗: {e}")
try:
# 最後嘗試使用 wslview如果安裝了 wslu 套件)
cmd = ['wslview', url]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
cmd = ["wslview", url]
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10, check=False
)
if result.returncode == 0:
debug_log(f"成功使用 wslview 啟動瀏覽器: {url}")
return
else:
debug_log(f"wslview 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}")
debug_log(
f"wslview 啟動失敗,返回碼: {result.returncode}, 錯誤: {result.stderr}"
)
except Exception as e:
debug_log(f"使用 wslview 啟動瀏覽器失敗: {e}")
@ -125,4 +132,4 @@ def get_browser_opener() -> Callable[[str], None]:
Returns:
Callable: 瀏覽器開啟函數
"""
return smart_browser_open
return smart_browser_open

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
壓縮配置管理器
==============
@ -9,174 +8,177 @@
"""
import os
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from pathlib import Path
@dataclass
class CompressionConfig:
"""壓縮配置類"""
# Gzip 壓縮設定
minimum_size: int = 1000 # 最小壓縮大小bytes
compression_level: int = 6 # 壓縮級別 (1-9, 6為平衡點)
# 緩存設定
static_cache_max_age: int = 3600 # 靜態文件緩存時間(秒)
api_cache_max_age: int = 0 # API 響應緩存時間0表示不緩存
# 支援的 MIME 類型
compressible_types: List[str] = None
compressible_types: list[str] = None
# 排除的路徑
exclude_paths: List[str] = None
exclude_paths: list[str] = None
def __post_init__(self):
"""初始化後處理"""
if self.compressible_types is None:
self.compressible_types = [
'text/html',
'text/css',
'text/javascript',
'text/plain',
'application/json',
'application/javascript',
'application/xml',
'application/rss+xml',
'application/atom+xml',
'image/svg+xml'
"text/html",
"text/css",
"text/javascript",
"text/plain",
"application/json",
"application/javascript",
"application/xml",
"application/rss+xml",
"application/atom+xml",
"image/svg+xml",
]
if self.exclude_paths is None:
self.exclude_paths = [
'/ws', # WebSocket 連接
'/api/ws', # WebSocket API
'/health', # 健康檢查
"/ws", # WebSocket 連接
"/api/ws", # WebSocket API
"/health", # 健康檢查
]
@classmethod
def from_env(cls) -> 'CompressionConfig':
def from_env(cls) -> "CompressionConfig":
"""從環境變數創建配置"""
return cls(
minimum_size=int(os.getenv('MCP_GZIP_MIN_SIZE', '1000')),
compression_level=int(os.getenv('MCP_GZIP_LEVEL', '6')),
static_cache_max_age=int(os.getenv('MCP_STATIC_CACHE_AGE', '3600')),
api_cache_max_age=int(os.getenv('MCP_API_CACHE_AGE', '0'))
minimum_size=int(os.getenv("MCP_GZIP_MIN_SIZE", "1000")),
compression_level=int(os.getenv("MCP_GZIP_LEVEL", "6")),
static_cache_max_age=int(os.getenv("MCP_STATIC_CACHE_AGE", "3600")),
api_cache_max_age=int(os.getenv("MCP_API_CACHE_AGE", "0")),
)
def should_compress(self, content_type: str, content_length: int) -> bool:
"""判斷是否應該壓縮"""
if content_length < self.minimum_size:
return False
if not content_type:
return False
# 檢查 MIME 類型
for mime_type in self.compressible_types:
if content_type.startswith(mime_type):
return True
return False
def should_exclude_path(self, path: str) -> bool:
"""判斷路徑是否應該排除壓縮"""
for exclude_path in self.exclude_paths:
if path.startswith(exclude_path):
return True
return False
def get_cache_headers(self, path: str) -> Dict[str, str]:
def get_cache_headers(self, path: str) -> dict[str, str]:
"""獲取緩存頭"""
headers = {}
if path.startswith('/static/'):
if path.startswith("/static/"):
# 靜態文件緩存
headers['Cache-Control'] = f'public, max-age={self.static_cache_max_age}'
headers['Expires'] = self._get_expires_header(self.static_cache_max_age)
elif path.startswith('/api/') and self.api_cache_max_age > 0:
headers["Cache-Control"] = f"public, max-age={self.static_cache_max_age}"
headers["Expires"] = self._get_expires_header(self.static_cache_max_age)
elif path.startswith("/api/") and self.api_cache_max_age > 0:
# API 緩存(如果啟用)
headers['Cache-Control'] = f'public, max-age={self.api_cache_max_age}'
headers['Expires'] = self._get_expires_header(self.api_cache_max_age)
headers["Cache-Control"] = f"public, max-age={self.api_cache_max_age}"
headers["Expires"] = self._get_expires_header(self.api_cache_max_age)
else:
# 其他路徑不緩存
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
headers['Pragma'] = 'no-cache'
headers['Expires'] = '0'
headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
headers["Pragma"] = "no-cache"
headers["Expires"] = "0"
return headers
def _get_expires_header(self, max_age: int) -> str:
"""生成 Expires 頭"""
from datetime import datetime, timedelta
expires_time = datetime.utcnow() + timedelta(seconds=max_age)
return expires_time.strftime('%a, %d %b %Y %H:%M:%S GMT')
def get_compression_stats(self) -> Dict[str, any]:
return expires_time.strftime("%a, %d %b %Y %H:%M:%S GMT")
def get_compression_stats(self) -> dict[str, any]:
"""獲取壓縮配置統計"""
return {
'minimum_size': self.minimum_size,
'compression_level': self.compression_level,
'static_cache_max_age': self.static_cache_max_age,
'compressible_types_count': len(self.compressible_types),
'exclude_paths_count': len(self.exclude_paths),
'compressible_types': self.compressible_types,
'exclude_paths': self.exclude_paths
"minimum_size": self.minimum_size,
"compression_level": self.compression_level,
"static_cache_max_age": self.static_cache_max_age,
"compressible_types_count": len(self.compressible_types),
"exclude_paths_count": len(self.exclude_paths),
"compressible_types": self.compressible_types,
"exclude_paths": self.exclude_paths,
}
class CompressionManager:
"""壓縮管理器"""
def __init__(self, config: Optional[CompressionConfig] = None):
def __init__(self, config: CompressionConfig | None = None):
self.config = config or CompressionConfig.from_env()
self._stats = {
'requests_total': 0,
'requests_compressed': 0,
'bytes_original': 0,
'bytes_compressed': 0,
'compression_ratio': 0.0
"requests_total": 0,
"requests_compressed": 0,
"bytes_original": 0,
"bytes_compressed": 0,
"compression_ratio": 0.0,
}
def update_stats(self, original_size: int, compressed_size: int, was_compressed: bool):
def update_stats(
self, original_size: int, compressed_size: int, was_compressed: bool
):
"""更新壓縮統計"""
self._stats['requests_total'] += 1
self._stats['bytes_original'] += original_size
self._stats["requests_total"] += 1
self._stats["bytes_original"] += original_size
if was_compressed:
self._stats['requests_compressed'] += 1
self._stats['bytes_compressed'] += compressed_size
self._stats["requests_compressed"] += 1
self._stats["bytes_compressed"] += compressed_size
else:
self._stats['bytes_compressed'] += original_size
self._stats["bytes_compressed"] += original_size
# 計算壓縮比率
if self._stats['bytes_original'] > 0:
self._stats['compression_ratio'] = (
1 - self._stats['bytes_compressed'] / self._stats['bytes_original']
if self._stats["bytes_original"] > 0:
self._stats["compression_ratio"] = (
1 - self._stats["bytes_compressed"] / self._stats["bytes_original"]
) * 100
def get_stats(self) -> Dict[str, any]:
def get_stats(self) -> dict[str, any]:
"""獲取壓縮統計"""
stats = self._stats.copy()
stats['compression_percentage'] = (
self._stats['requests_compressed'] / max(self._stats['requests_total'], 1) * 100
stats["compression_percentage"] = (
self._stats["requests_compressed"]
/ max(self._stats["requests_total"], 1)
* 100
)
return stats
def reset_stats(self):
"""重置統計"""
self._stats = {
'requests_total': 0,
'requests_compressed': 0,
'bytes_original': 0,
'bytes_compressed': 0,
'compression_ratio': 0.0
"requests_total": 0,
"requests_compressed": 0,
"bytes_original": 0,
"bytes_compressed": 0,
"compression_ratio": 0.0,
}
# 全域壓縮管理器實例
_compression_manager: Optional[CompressionManager] = None
_compression_manager: CompressionManager | None = None
def get_compression_manager() -> CompressionManager:

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
壓縮性能監控工具
================
@ -8,17 +7,15 @@
提供實時性能數據和優化建議
"""
import time
import threading
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import json
@dataclass
class CompressionMetrics:
"""壓縮指標數據類"""
timestamp: datetime
path: str
original_size: int
@ -32,6 +29,7 @@ class CompressionMetrics:
@dataclass
class CompressionSummary:
"""壓縮摘要統計"""
total_requests: int = 0
compressed_requests: int = 0
total_original_bytes: int = 0
@ -40,37 +38,39 @@ class CompressionSummary:
average_response_time: float = 0.0
compression_percentage: float = 0.0
bandwidth_saved: int = 0
top_compressed_paths: List[Tuple[str, float]] = field(default_factory=list)
top_compressed_paths: list[tuple[str, float]] = field(default_factory=list)
class CompressionMonitor:
"""壓縮性能監控器"""
def __init__(self, max_metrics: int = 1000):
self.max_metrics = max_metrics
self.metrics: List[CompressionMetrics] = []
self.metrics: list[CompressionMetrics] = []
self.lock = threading.Lock()
self._start_time = datetime.now()
# 路徑統計
self.path_stats: Dict[str, Dict] = {}
self.path_stats: dict[str, dict] = {}
# 內容類型統計
self.content_type_stats: Dict[str, Dict] = {}
def record_request(self,
path: str,
original_size: int,
compressed_size: int,
response_time: float,
content_type: str = "",
was_compressed: bool = False):
self.content_type_stats: dict[str, dict] = {}
def record_request(
self,
path: str,
original_size: int,
compressed_size: int,
response_time: float,
content_type: str = "",
was_compressed: bool = False,
):
"""記錄請求的壓縮數據"""
compression_ratio = 0.0
if original_size > 0 and was_compressed:
compression_ratio = (1 - compressed_size / original_size) * 100
metric = CompressionMetrics(
timestamp=datetime.now(),
path=path,
@ -79,107 +79,114 @@ class CompressionMonitor:
compression_ratio=compression_ratio,
response_time=response_time,
content_type=content_type,
was_compressed=was_compressed
was_compressed=was_compressed,
)
with self.lock:
self.metrics.append(metric)
# 限制記錄數量
if len(self.metrics) > self.max_metrics:
self.metrics = self.metrics[-self.max_metrics:]
self.metrics = self.metrics[-self.max_metrics :]
# 更新路徑統計
self._update_path_stats(metric)
# 更新內容類型統計
self._update_content_type_stats(metric)
def _update_path_stats(self, metric: CompressionMetrics):
"""更新路徑統計"""
path = metric.path
if path not in self.path_stats:
self.path_stats[path] = {
'requests': 0,
'compressed_requests': 0,
'total_original_bytes': 0,
'total_compressed_bytes': 0,
'total_response_time': 0.0,
'best_compression_ratio': 0.0
"requests": 0,
"compressed_requests": 0,
"total_original_bytes": 0,
"total_compressed_bytes": 0,
"total_response_time": 0.0,
"best_compression_ratio": 0.0,
}
stats = self.path_stats[path]
stats['requests'] += 1
stats['total_original_bytes'] += metric.original_size
stats['total_compressed_bytes'] += metric.compressed_size
stats['total_response_time'] += metric.response_time
stats["requests"] += 1
stats["total_original_bytes"] += metric.original_size
stats["total_compressed_bytes"] += metric.compressed_size
stats["total_response_time"] += metric.response_time
if metric.was_compressed:
stats['compressed_requests'] += 1
stats['best_compression_ratio'] = max(
stats['best_compression_ratio'],
metric.compression_ratio
stats["compressed_requests"] += 1
stats["best_compression_ratio"] = max(
stats["best_compression_ratio"], metric.compression_ratio
)
def _update_content_type_stats(self, metric: CompressionMetrics):
"""更新內容類型統計"""
content_type = metric.content_type or 'unknown'
content_type = metric.content_type or "unknown"
if content_type not in self.content_type_stats:
self.content_type_stats[content_type] = {
'requests': 0,
'compressed_requests': 0,
'total_original_bytes': 0,
'total_compressed_bytes': 0,
'average_compression_ratio': 0.0
"requests": 0,
"compressed_requests": 0,
"total_original_bytes": 0,
"total_compressed_bytes": 0,
"average_compression_ratio": 0.0,
}
stats = self.content_type_stats[content_type]
stats['requests'] += 1
stats['total_original_bytes'] += metric.original_size
stats['total_compressed_bytes'] += metric.compressed_size
stats["requests"] += 1
stats["total_original_bytes"] += metric.original_size
stats["total_compressed_bytes"] += metric.compressed_size
if metric.was_compressed:
stats['compressed_requests'] += 1
stats["compressed_requests"] += 1
# 重新計算平均壓縮比
if stats['total_original_bytes'] > 0:
stats['average_compression_ratio'] = (
1 - stats['total_compressed_bytes'] / stats['total_original_bytes']
if stats["total_original_bytes"] > 0:
stats["average_compression_ratio"] = (
1 - stats["total_compressed_bytes"] / stats["total_original_bytes"]
) * 100
def get_summary(self, time_window: Optional[timedelta] = None) -> CompressionSummary:
def get_summary(self, time_window: timedelta | None = None) -> CompressionSummary:
"""獲取壓縮摘要統計"""
with self.lock:
metrics = self.metrics
# 如果指定時間窗口,過濾數據
if time_window:
cutoff_time = datetime.now() - time_window
metrics = [m for m in metrics if m.timestamp >= cutoff_time]
if not metrics:
return CompressionSummary()
total_requests = len(metrics)
compressed_requests = sum(1 for m in metrics if m.was_compressed)
total_original_bytes = sum(m.original_size for m in metrics)
total_compressed_bytes = sum(m.compressed_size for m in metrics)
total_response_time = sum(m.response_time for m in metrics)
# 計算統計數據
compression_percentage = (compressed_requests / total_requests * 100) if total_requests > 0 else 0
compression_percentage = (
(compressed_requests / total_requests * 100)
if total_requests > 0
else 0
)
average_compression_ratio = 0.0
bandwidth_saved = 0
if total_original_bytes > 0:
average_compression_ratio = (1 - total_compressed_bytes / total_original_bytes) * 100
average_compression_ratio = (
1 - total_compressed_bytes / total_original_bytes
) * 100
bandwidth_saved = total_original_bytes - total_compressed_bytes
average_response_time = total_response_time / total_requests if total_requests > 0 else 0
average_response_time = (
total_response_time / total_requests if total_requests > 0 else 0
)
# 獲取壓縮效果最好的路徑
top_compressed_paths = self._get_top_compressed_paths()
return CompressionSummary(
total_requests=total_requests,
compressed_requests=compressed_requests,
@ -189,39 +196,39 @@ class CompressionMonitor:
average_response_time=average_response_time,
compression_percentage=compression_percentage,
bandwidth_saved=bandwidth_saved,
top_compressed_paths=top_compressed_paths
top_compressed_paths=top_compressed_paths,
)
def _get_top_compressed_paths(self, limit: int = 5) -> List[Tuple[str, float]]:
def _get_top_compressed_paths(self, limit: int = 5) -> list[tuple[str, float]]:
"""獲取壓縮效果最好的路徑"""
path_ratios = []
for path, stats in self.path_stats.items():
if stats['compressed_requests'] > 0 and stats['total_original_bytes'] > 0:
if stats["compressed_requests"] > 0 and stats["total_original_bytes"] > 0:
compression_ratio = (
1 - stats['total_compressed_bytes'] / stats['total_original_bytes']
1 - stats["total_compressed_bytes"] / stats["total_original_bytes"]
) * 100
path_ratios.append((path, compression_ratio))
# 按壓縮比排序
path_ratios.sort(key=lambda x: x[1], reverse=True)
return path_ratios[:limit]
def get_path_stats(self) -> Dict[str, Dict]:
def get_path_stats(self) -> dict[str, dict]:
"""獲取路徑統計"""
with self.lock:
return self.path_stats.copy()
def get_content_type_stats(self) -> Dict[str, Dict]:
def get_content_type_stats(self) -> dict[str, dict]:
"""獲取內容類型統計"""
with self.lock:
return self.content_type_stats.copy()
def get_recent_metrics(self, limit: int = 100) -> List[CompressionMetrics]:
def get_recent_metrics(self, limit: int = 100) -> list[CompressionMetrics]:
"""獲取最近的指標數據"""
with self.lock:
return self.metrics[-limit:] if self.metrics else []
def reset_stats(self):
"""重置統計數據"""
with self.lock:
@ -229,57 +236,74 @@ class CompressionMonitor:
self.path_stats.clear()
self.content_type_stats.clear()
self._start_time = datetime.now()
def export_stats(self) -> Dict:
def export_stats(self) -> dict:
"""導出統計數據為字典格式"""
summary = self.get_summary()
return {
'summary': {
'total_requests': summary.total_requests,
'compressed_requests': summary.compressed_requests,
'compression_percentage': round(summary.compression_percentage, 2),
'average_compression_ratio': round(summary.average_compression_ratio, 2),
'bandwidth_saved_mb': round(summary.bandwidth_saved / (1024 * 1024), 2),
'average_response_time_ms': round(summary.average_response_time * 1000, 2),
'monitoring_duration_hours': round(
"summary": {
"total_requests": summary.total_requests,
"compressed_requests": summary.compressed_requests,
"compression_percentage": round(summary.compression_percentage, 2),
"average_compression_ratio": round(
summary.average_compression_ratio, 2
),
"bandwidth_saved_mb": round(summary.bandwidth_saved / (1024 * 1024), 2),
"average_response_time_ms": round(
summary.average_response_time * 1000, 2
),
"monitoring_duration_hours": round(
(datetime.now() - self._start_time).total_seconds() / 3600, 2
)
),
},
'top_compressed_paths': [
{'path': path, 'compression_ratio': round(ratio, 2)}
"top_compressed_paths": [
{"path": path, "compression_ratio": round(ratio, 2)}
for path, ratio in summary.top_compressed_paths
],
'path_stats': {
"path_stats": {
path: {
'requests': stats['requests'],
'compression_percentage': round(
stats['compressed_requests'] / stats['requests'] * 100, 2
) if stats['requests'] > 0 else 0,
'average_response_time_ms': round(
stats['total_response_time'] / stats['requests'] * 1000, 2
) if stats['requests'] > 0 else 0,
'bandwidth_saved_kb': round(
(stats['total_original_bytes'] - stats['total_compressed_bytes']) / 1024, 2
"requests": stats["requests"],
"compression_percentage": round(
stats["compressed_requests"] / stats["requests"] * 100, 2
)
if stats["requests"] > 0
else 0,
"average_response_time_ms": round(
stats["total_response_time"] / stats["requests"] * 1000, 2
)
if stats["requests"] > 0
else 0,
"bandwidth_saved_kb": round(
(
stats["total_original_bytes"]
- stats["total_compressed_bytes"]
)
/ 1024,
2,
),
}
for path, stats in self.path_stats.items()
},
'content_type_stats': {
"content_type_stats": {
content_type: {
'requests': stats['requests'],
'compression_percentage': round(
stats['compressed_requests'] / stats['requests'] * 100, 2
) if stats['requests'] > 0 else 0,
'average_compression_ratio': round(stats['average_compression_ratio'], 2)
"requests": stats["requests"],
"compression_percentage": round(
stats["compressed_requests"] / stats["requests"] * 100, 2
)
if stats["requests"] > 0
else 0,
"average_compression_ratio": round(
stats["average_compression_ratio"], 2
),
}
for content_type, stats in self.content_type_stats.items()
}
},
}
# 全域監控器實例
_compression_monitor: Optional[CompressionMonitor] = None
_compression_monitor: CompressionMonitor | None = None
def get_compression_monitor() -> CompressionMonitor:

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
網絡工具函數
============
@ -8,28 +7,29 @@
"""
import socket
from typing import Optional
def find_free_port(start_port: int = 8765, max_attempts: int = 100, preferred_port: int = 8765) -> int:
def find_free_port(
start_port: int = 8765, max_attempts: int = 100, preferred_port: int = 8765
) -> int:
"""
尋找可用的端口優先使用偏好端口
Args:
start_port: 起始端口號
max_attempts: 最大嘗試次數
preferred_port: 偏好端口號用於保持設定持久性
Returns:
int: 可用的端口號
Raises:
RuntimeError: 如果找不到可用端口
"""
# 首先嘗試偏好端口(通常是 8765
if is_port_available("127.0.0.1", preferred_port):
return preferred_port
# 如果偏好端口不可用,嘗試其他端口
for i in range(max_attempts):
port = start_port + i
@ -41,18 +41,20 @@ def find_free_port(start_port: int = 8765, max_attempts: int = 100, preferred_po
return port
except OSError:
continue
raise RuntimeError(f"無法在 {start_port}-{start_port + max_attempts - 1} 範圍內找到可用端口")
raise RuntimeError(
f"無法在 {start_port}-{start_port + max_attempts - 1} 範圍內找到可用端口"
)
def is_port_available(host: str, port: int) -> bool:
"""
檢查端口是否可用
Args:
host: 主機地址
port: 端口號
Returns:
bool: 端口是否可用
"""
@ -61,4 +63,4 @@ def is_port_available(host: str, port: int) -> bool:
sock.bind((host, port))
return True
except OSError:
return False
return False

View File

@ -8,58 +8,58 @@
"""
import socket
import subprocess
import platform
import psutil
import time
from typing import Optional, Dict, Any, List
from typing import Any
import psutil
from ...debug import debug_log
class PortManager:
"""端口管理器 - 提供增強的端口管理功能"""
@staticmethod
def find_process_using_port(port: int) -> Optional[Dict[str, Any]]:
def find_process_using_port(port: int) -> dict[str, Any] | None:
"""
查找占用指定端口的進程
Args:
port: 要檢查的端口號
Returns:
Dict[str, Any]: 進程信息字典包含 pid, name, cmdline
None: 如果沒有進程占用該端口
"""
try:
for conn in psutil.net_connections(kind='inet'):
for conn in psutil.net_connections(kind="inet"):
if conn.laddr.port == port and conn.status == psutil.CONN_LISTEN:
try:
process = psutil.Process(conn.pid)
return {
'pid': conn.pid,
'name': process.name(),
'cmdline': ' '.join(process.cmdline()),
'create_time': process.create_time(),
'status': process.status()
"pid": conn.pid,
"name": process.name(),
"cmdline": " ".join(process.cmdline()),
"create_time": process.create_time(),
"status": process.status(),
}
except (psutil.NoSuchProcess, psutil.AccessDenied):
# 進程可能已經結束或無權限訪問
continue
except Exception as e:
debug_log(f"查找端口 {port} 占用進程時發生錯誤: {e}")
return None
@staticmethod
def kill_process_on_port(port: int, force: bool = False) -> bool:
"""
終止占用指定端口的進程
Args:
port: 要清理的端口號
force: 是否強制終止進程
Returns:
bool: 是否成功終止進程
"""
@ -67,25 +67,25 @@ class PortManager:
if not process_info:
debug_log(f"端口 {port} 沒有被任何進程占用")
return True
try:
pid = process_info['pid']
pid = process_info["pid"]
process = psutil.Process(pid)
process_name = process_info['name']
process_name = process_info["name"]
debug_log(f"發現進程 {process_name} (PID: {pid}) 占用端口 {port}")
# 檢查是否是自己的進程(避免誤殺)
if 'mcp-feedback-enhanced' in process_info['cmdline'].lower():
debug_log(f"檢測到 MCP Feedback Enhanced 相關進程,嘗試優雅終止")
if "mcp-feedback-enhanced" in process_info["cmdline"].lower():
debug_log("檢測到 MCP Feedback Enhanced 相關進程,嘗試優雅終止")
if force:
debug_log(f"強制終止進程 {process_name} (PID: {pid})")
process.kill()
else:
debug_log(f"優雅終止進程 {process_name} (PID: {pid})")
process.terminate()
# 等待進程結束
try:
process.wait(timeout=5)
@ -97,17 +97,16 @@ class PortManager:
process.kill()
process.wait(timeout=3)
return True
else:
debug_log(f"強制終止進程 {process_name} (PID: {pid}) 失敗")
return False
debug_log(f"強制終止進程 {process_name} (PID: {pid}) 失敗")
return False
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
debug_log(f"無法終止進程 (PID: {process_info['pid']}): {e}")
return False
except Exception as e:
debug_log(f"終止端口 {port} 占用進程時發生錯誤: {e}")
return False
@staticmethod
def is_port_available(host: str, port: int) -> bool:
"""
@ -130,36 +129,39 @@ class PortManager:
# 使用 psutil 檢查是否有進程在監聽該端口
try:
import psutil
for conn in psutil.net_connections(kind='inet'):
if (conn.laddr.port == port and
conn.laddr.ip in [host, '0.0.0.0', '::'] and
conn.status == psutil.CONN_LISTEN):
for conn in psutil.net_connections(kind="inet"):
if (
conn.laddr.port == port
and conn.laddr.ip in [host, "0.0.0.0", "::"]
and conn.status == psutil.CONN_LISTEN
):
return False
# 沒有找到監聽的進程,可能是臨時占用,認為可用
return True
except Exception:
# 如果 psutil 檢查失敗,保守地認為端口不可用
return False
@staticmethod
def find_free_port_enhanced(
preferred_port: int = 8765,
preferred_port: int = 8765,
auto_cleanup: bool = True,
host: str = "127.0.0.1",
max_attempts: int = 100
max_attempts: int = 100,
) -> int:
"""
增強的端口查找功能
Args:
preferred_port: 偏好端口號
auto_cleanup: 是否自動清理占用端口的進程
host: 主機地址
max_attempts: 最大嘗試次數
Returns:
int: 可用的端口號
Raises:
RuntimeError: 如果找不到可用端口
"""
@ -167,15 +169,17 @@ class PortManager:
if PortManager.is_port_available(host, preferred_port):
debug_log(f"偏好端口 {preferred_port} 可用")
return preferred_port
# 如果偏好端口被占用且啟用自動清理
if auto_cleanup:
debug_log(f"偏好端口 {preferred_port} 被占用,嘗試清理占用進程")
process_info = PortManager.find_process_using_port(preferred_port)
if process_info:
debug_log(f"端口 {preferred_port} 被進程 {process_info['name']} (PID: {process_info['pid']}) 占用")
debug_log(
f"端口 {preferred_port} 被進程 {process_info['name']} (PID: {process_info['pid']}) 占用"
)
# 詢問用戶是否清理(在實際使用中可能需要配置選項)
if PortManager._should_cleanup_process(process_info):
if PortManager.kill_process_on_port(preferred_port):
@ -184,16 +188,16 @@ class PortManager:
if PortManager.is_port_available(host, preferred_port):
debug_log(f"成功清理端口 {preferred_port},現在可用")
return preferred_port
# 如果偏好端口仍不可用,尋找其他端口
debug_log(f"偏好端口 {preferred_port} 不可用,尋找其他可用端口")
for i in range(max_attempts):
port = preferred_port + i + 1
if PortManager.is_port_available(host, port):
debug_log(f"找到可用端口: {port}")
return port
# 如果向上查找失敗,嘗試向下查找
for i in range(1, min(preferred_port - 1024, max_attempts)):
port = preferred_port - i
@ -202,106 +206,116 @@ class PortManager:
if PortManager.is_port_available(host, port):
debug_log(f"找到可用端口: {port}")
return port
raise RuntimeError(
f"無法在 {preferred_port}±{max_attempts} 範圍內找到可用端口。"
f"請檢查是否有過多進程占用端口,或手動指定其他端口。"
)
@staticmethod
def _should_cleanup_process(process_info: Dict[str, Any]) -> bool:
def _should_cleanup_process(process_info: dict[str, Any]) -> bool:
"""
判斷是否應該清理指定進程
Args:
process_info: 進程信息字典
Returns:
bool: 是否應該清理該進程
"""
# 檢查是否是 MCP Feedback Enhanced 相關進程
cmdline = process_info.get('cmdline', '').lower()
process_name = process_info.get('name', '').lower()
cmdline = process_info.get("cmdline", "").lower()
process_name = process_info.get("name", "").lower()
# 如果是自己的進程,允許清理
if any(keyword in cmdline for keyword in ['mcp-feedback-enhanced', 'mcp_feedback_enhanced']):
if any(
keyword in cmdline
for keyword in ["mcp-feedback-enhanced", "mcp_feedback_enhanced"]
):
return True
# 如果是 Python 進程且命令行包含相關關鍵字
if 'python' in process_name and any(keyword in cmdline for keyword in ['uvicorn', 'fastapi']):
if "python" in process_name and any(
keyword in cmdline for keyword in ["uvicorn", "fastapi"]
):
return True
# 其他情況下,為了安全起見,不自動清理
debug_log(f"進程 {process_info['name']} (PID: {process_info['pid']}) 不是 MCP 相關進程,跳過自動清理")
debug_log(
f"進程 {process_info['name']} (PID: {process_info['pid']}) 不是 MCP 相關進程,跳過自動清理"
)
return False
@staticmethod
def get_port_status(port: int, host: str = "127.0.0.1") -> Dict[str, Any]:
def get_port_status(port: int, host: str = "127.0.0.1") -> dict[str, Any]:
"""
獲取端口狀態信息
Args:
port: 端口號
host: 主機地址
Returns:
Dict[str, Any]: 端口狀態信息
"""
status = {
'port': port,
'host': host,
'available': False,
'process': None,
'error': None
"port": port,
"host": host,
"available": False,
"process": None,
"error": None,
}
try:
# 檢查端口是否可用
status['available'] = PortManager.is_port_available(host, port)
status["available"] = PortManager.is_port_available(host, port)
# 如果不可用,查找占用進程
if not status['available']:
status['process'] = PortManager.find_process_using_port(port)
if not status["available"]:
status["process"] = PortManager.find_process_using_port(port)
except Exception as e:
status['error'] = str(e)
status["error"] = str(e)
debug_log(f"獲取端口 {port} 狀態時發生錯誤: {e}")
return status
@staticmethod
def list_listening_ports(start_port: int = 8000, end_port: int = 9000) -> List[Dict[str, Any]]:
def list_listening_ports(
start_port: int = 8000, end_port: int = 9000
) -> list[dict[str, Any]]:
"""
列出指定範圍內正在監聽的端口
Args:
start_port: 起始端口
end_port: 結束端口
Returns:
List[Dict[str, Any]]: 監聽端口列表
"""
listening_ports = []
try:
for conn in psutil.net_connections(kind='inet'):
if (conn.status == psutil.CONN_LISTEN and
start_port <= conn.laddr.port <= end_port):
for conn in psutil.net_connections(kind="inet"):
if (
conn.status == psutil.CONN_LISTEN
and start_port <= conn.laddr.port <= end_port
):
try:
process = psutil.Process(conn.pid)
port_info = {
'port': conn.laddr.port,
'host': conn.laddr.ip,
'pid': conn.pid,
'process_name': process.name(),
'cmdline': ' '.join(process.cmdline())
"port": conn.laddr.port,
"host": conn.laddr.ip,
"pid": conn.pid,
"process_name": process.name(),
"cmdline": " ".join(process.cmdline()),
}
listening_ports.append(port_info)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
except Exception as e:
debug_log(f"列出監聽端口時發生錯誤: {e}")
return listening_ports

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
會話清理管理器
==============
@ -8,12 +7,13 @@
與內存監控系統深度集成提供智能清理決策
"""
import time
import threading
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Callable, Any
from dataclasses import dataclass, field
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Any
from ...debug import web_debug_log as debug_log
from ...utils.error_handler import ErrorHandler, ErrorType
@ -23,6 +23,7 @@ from ..models.feedback_session import CleanupReason, SessionStatus
@dataclass
class CleanupPolicy:
"""清理策略配置"""
max_idle_time: int = 1800 # 最大空閒時間(秒)
max_session_age: int = 7200 # 最大會話年齡(秒)
max_sessions: int = 10 # 最大會話數量
@ -35,6 +36,7 @@ class CleanupPolicy:
@dataclass
class CleanupStats:
"""清理統計數據"""
total_cleanups: int = 0
expired_cleanups: int = 0
memory_pressure_cleanups: int = 0
@ -43,12 +45,13 @@ class CleanupStats:
total_sessions_cleaned: int = 0
total_cleanup_time: float = 0.0
average_cleanup_time: float = 0.0
last_cleanup_time: Optional[datetime] = None
last_cleanup_time: datetime | None = None
cleanup_efficiency: float = 0.0 # 清理效率(清理的會話數/總會話數)
class CleanupTrigger(Enum):
"""清理觸發器類型"""
AUTO = "auto" # 自動清理
MEMORY_PRESSURE = "memory_pressure" # 內存壓力
MANUAL = "manual" # 手動清理
@ -58,11 +61,11 @@ class CleanupTrigger(Enum):
class SessionCleanupManager:
"""會話清理管理器"""
def __init__(self, web_ui_manager, policy: CleanupPolicy = None):
"""
初始化會話清理管理器
Args:
web_ui_manager: WebUIManager 實例
policy: 清理策略配置
@ -70,157 +73,153 @@ class SessionCleanupManager:
self.web_ui_manager = web_ui_manager
self.policy = policy or CleanupPolicy()
self.stats = CleanupStats()
# 清理狀態
self.is_running = False
self.cleanup_thread: Optional[threading.Thread] = None
self.cleanup_thread: threading.Thread | None = None
self._stop_event = threading.Event()
# 回調函數
self.cleanup_callbacks: List[Callable] = []
self.stats_callbacks: List[Callable] = []
self.cleanup_callbacks: list[Callable] = []
self.stats_callbacks: list[Callable] = []
# 清理歷史記錄
self.cleanup_history: List[Dict[str, Any]] = []
self.cleanup_history: list[dict[str, Any]] = []
self.max_history = 100
debug_log("SessionCleanupManager 初始化完成")
def start_auto_cleanup(self) -> bool:
"""啟動自動清理"""
if not self.policy.enable_auto_cleanup:
debug_log("自動清理已禁用")
return False
if self.is_running:
debug_log("自動清理已在運行")
return True
try:
self.is_running = True
self._stop_event.clear()
self.cleanup_thread = threading.Thread(
target=self._auto_cleanup_loop,
name="SessionCleanupManager",
daemon=True
daemon=True,
)
self.cleanup_thread.start()
debug_log(f"自動清理已啟動,間隔 {self.policy.cleanup_interval}")
return True
except Exception as e:
self.is_running = False
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "啟動自動清理"},
error_type=ErrorType.SYSTEM
e, context={"operation": "啟動自動清理"}, error_type=ErrorType.SYSTEM
)
debug_log(f"啟動自動清理失敗 [錯誤ID: {error_id}]: {e}")
return False
def stop_auto_cleanup(self) -> bool:
"""停止自動清理"""
if not self.is_running:
debug_log("自動清理未在運行")
return True
try:
self.is_running = False
self._stop_event.set()
if self.cleanup_thread and self.cleanup_thread.is_alive():
self.cleanup_thread.join(timeout=5)
debug_log("自動清理已停止")
return True
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "停止自動清理"},
error_type=ErrorType.SYSTEM
e, context={"operation": "停止自動清理"}, error_type=ErrorType.SYSTEM
)
debug_log(f"停止自動清理失敗 [錯誤ID: {error_id}]: {e}")
return False
def _auto_cleanup_loop(self):
"""自動清理主循環"""
debug_log("自動清理循環開始")
while not self._stop_event.is_set():
try:
# 執行清理檢查
self._perform_auto_cleanup()
# 等待下次清理
if self._stop_event.wait(self.policy.cleanup_interval):
break
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "自動清理循環"},
error_type=ErrorType.SYSTEM
error_type=ErrorType.SYSTEM,
)
debug_log(f"自動清理循環錯誤 [錯誤ID: {error_id}]: {e}")
# 發生錯誤時等待較短時間後重試
if self._stop_event.wait(30):
break
debug_log("自動清理循環結束")
def _perform_auto_cleanup(self):
"""執行自動清理"""
cleanup_start_time = time.time()
cleaned_sessions = 0
try:
# 1. 檢查會話數量限制
if len(self.web_ui_manager.sessions) > self.policy.max_sessions:
cleaned = self._cleanup_by_capacity()
cleaned_sessions += cleaned
debug_log(f"容量限制清理了 {cleaned} 個會話")
# 2. 清理過期會話
cleaned = self._cleanup_expired_sessions()
cleaned_sessions += cleaned
# 3. 清理空閒會話
cleaned = self._cleanup_idle_sessions()
cleaned_sessions += cleaned
# 4. 更新統計
cleanup_duration = time.time() - cleanup_start_time
self._update_cleanup_stats(
CleanupTrigger.AUTO,
cleaned_sessions,
cleanup_duration
CleanupTrigger.AUTO, cleaned_sessions, cleanup_duration
)
if cleaned_sessions > 0:
debug_log(f"自動清理完成,清理了 {cleaned_sessions} 個會話,耗時: {cleanup_duration:.2f}")
debug_log(
f"自動清理完成,清理了 {cleaned_sessions} 個會話,耗時: {cleanup_duration:.2f}"
)
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "執行自動清理"},
error_type=ErrorType.SYSTEM
e, context={"operation": "執行自動清理"}, error_type=ErrorType.SYSTEM
)
debug_log(f"執行自動清理失敗 [錯誤ID: {error_id}]: {e}")
def trigger_cleanup(self, trigger: CleanupTrigger, force: bool = False) -> int:
"""觸發清理操作"""
cleanup_start_time = time.time()
cleaned_sessions = 0
try:
debug_log(f"觸發清理操作,觸發器: {trigger.value},強制: {force}")
if trigger == CleanupTrigger.MEMORY_PRESSURE:
cleaned_sessions = self.web_ui_manager.cleanup_sessions_by_memory_pressure(force)
cleaned_sessions = (
self.web_ui_manager.cleanup_sessions_by_memory_pressure(force)
)
elif trigger == CleanupTrigger.EXPIRED:
cleaned_sessions = self.web_ui_manager.cleanup_expired_sessions()
elif trigger == CleanupTrigger.CAPACITY:
@ -229,69 +228,83 @@ class SessionCleanupManager:
# 手動清理:組合多種策略
cleaned_sessions += self.web_ui_manager.cleanup_expired_sessions()
if force:
cleaned_sessions += self.web_ui_manager.cleanup_sessions_by_memory_pressure(force)
cleaned_sessions += (
self.web_ui_manager.cleanup_sessions_by_memory_pressure(force)
)
else:
# 自動清理
self._perform_auto_cleanup()
return 0 # 統計已在 _perform_auto_cleanup 中更新
# 更新統計
cleanup_duration = time.time() - cleanup_start_time
self._update_cleanup_stats(trigger, cleaned_sessions, cleanup_duration)
debug_log(f"清理操作完成,清理了 {cleaned_sessions} 個會話,耗時: {cleanup_duration:.2f}")
debug_log(
f"清理操作完成,清理了 {cleaned_sessions} 個會話,耗時: {cleanup_duration:.2f}"
)
return cleaned_sessions
except Exception as e:
error_id = ErrorHandler.log_error_with_context(
e,
context={"operation": "觸發清理", "trigger": trigger.value, "force": force},
error_type=ErrorType.SYSTEM
context={
"operation": "觸發清理",
"trigger": trigger.value,
"force": force,
},
error_type=ErrorType.SYSTEM,
)
debug_log(f"觸發清理操作失敗 [錯誤ID: {error_id}]: {e}")
return 0
def _cleanup_by_capacity(self) -> int:
"""根據容量限制清理會話"""
sessions = self.web_ui_manager.sessions
if len(sessions) <= self.policy.max_sessions:
return 0
# 計算需要清理的會話數量
excess_count = len(sessions) - self.policy.max_sessions
# 按優先級排序會話(優先清理舊的、非活躍的會話)
session_priorities = []
for session_id, session in sessions.items():
# 跳過當前活躍會話(如果啟用保護)
if (self.policy.preserve_active_session and
self.web_ui_manager.current_session and
session.session_id == self.web_ui_manager.current_session.session_id):
if (
self.policy.preserve_active_session
and self.web_ui_manager.current_session
and session.session_id == self.web_ui_manager.current_session.session_id
):
continue
# 計算優先級分數(分數越高越優先清理)
priority_score = 0
# 狀態優先級
if session.status in [SessionStatus.COMPLETED, SessionStatus.ERROR, SessionStatus.TIMEOUT]:
if session.status in [
SessionStatus.COMPLETED,
SessionStatus.ERROR,
SessionStatus.TIMEOUT,
]:
priority_score += 100
elif session.status == SessionStatus.FEEDBACK_SUBMITTED:
priority_score += 50
# 年齡優先級
age = session.get_age()
priority_score += age / 60 # 每分鐘加1分
# 空閒時間優先級
idle_time = session.get_idle_time()
priority_score += idle_time / 30 # 每30秒加1分
session_priorities.append((session_id, session, priority_score))
# 按優先級排序並清理
session_priorities.sort(key=lambda x: x[2], reverse=True)
cleaned_count = 0
for i in range(min(excess_count, len(session_priorities))):
session_id, session, _ = session_priorities[i]
try:
@ -300,7 +313,7 @@ class SessionCleanupManager:
cleaned_count += 1
except Exception as e:
debug_log(f"容量清理會話 {session_id} 失敗: {e}")
return cleaned_count
def _cleanup_expired_sessions(self) -> int:
@ -310,10 +323,7 @@ class SessionCleanupManager:
for session_id, session in self.web_ui_manager.sessions.items():
# 檢查是否過期
if session.is_expired():
expired_sessions.append(session_id)
# 檢查是否超過最大年齡
elif session.get_age() > self.policy.max_session_age:
if session.is_expired() or session.get_age() > self.policy.max_session_age:
expired_sessions.append(session_id)
# 清理過期會話
@ -327,8 +337,10 @@ class SessionCleanupManager:
cleaned_count += 1
# 如果清理的是當前活躍會話,清空當前會話
if (self.web_ui_manager.current_session and
self.web_ui_manager.current_session.session_id == session_id):
if (
self.web_ui_manager.current_session
and self.web_ui_manager.current_session.session_id == session_id
):
self.web_ui_manager.current_session = None
except Exception as e:
@ -342,9 +354,11 @@ class SessionCleanupManager:
for session_id, session in self.web_ui_manager.sessions.items():
# 跳過當前活躍會話(如果啟用保護)
if (self.policy.preserve_active_session and
self.web_ui_manager.current_session and
session.session_id == self.web_ui_manager.current_session.session_id):
if (
self.policy.preserve_active_session
and self.web_ui_manager.current_session
and session.session_id == self.web_ui_manager.current_session.session_id
):
continue
# 檢查是否空閒時間過長
@ -366,7 +380,9 @@ class SessionCleanupManager:
return cleaned_count
def _update_cleanup_stats(self, trigger: CleanupTrigger, cleaned_count: int, duration: float):
def _update_cleanup_stats(
self, trigger: CleanupTrigger, cleaned_count: int, duration: float
):
"""更新清理統計"""
self.stats.total_cleanups += 1
self.stats.total_sessions_cleaned += cleaned_count
@ -375,7 +391,9 @@ class SessionCleanupManager:
# 更新平均清理時間
if self.stats.total_cleanups > 0:
self.stats.average_cleanup_time = self.stats.total_cleanup_time / self.stats.total_cleanups
self.stats.average_cleanup_time = (
self.stats.total_cleanup_time / self.stats.total_cleanups
)
# 更新清理效率
total_sessions = len(self.web_ui_manager.sessions) + cleaned_count
@ -399,14 +417,14 @@ class SessionCleanupManager:
"cleaned_count": cleaned_count,
"duration": duration,
"total_sessions_before": total_sessions,
"total_sessions_after": len(self.web_ui_manager.sessions)
"total_sessions_after": len(self.web_ui_manager.sessions),
}
self.cleanup_history.append(cleanup_record)
# 限制歷史記錄數量
if len(self.cleanup_history) > self.max_history:
self.cleanup_history = self.cleanup_history[-self.max_history:]
self.cleanup_history = self.cleanup_history[-self.max_history :]
# 調用統計回調
for callback in self.stats_callbacks:
@ -415,7 +433,7 @@ class SessionCleanupManager:
except Exception as e:
debug_log(f"統計回調執行失敗: {e}")
def get_cleanup_statistics(self) -> Dict[str, Any]:
def get_cleanup_statistics(self) -> dict[str, Any]:
"""獲取清理統計數據"""
stats_dict = {
"total_cleanups": self.stats.total_cleanups,
@ -427,7 +445,9 @@ class SessionCleanupManager:
"total_cleanup_time": round(self.stats.total_cleanup_time, 2),
"average_cleanup_time": round(self.stats.average_cleanup_time, 2),
"cleanup_efficiency": round(self.stats.cleanup_efficiency, 3),
"last_cleanup_time": self.stats.last_cleanup_time.isoformat() if self.stats.last_cleanup_time else None,
"last_cleanup_time": self.stats.last_cleanup_time.isoformat()
if self.stats.last_cleanup_time
else None,
"is_auto_cleanup_running": self.is_running,
"current_sessions": len(self.web_ui_manager.sessions),
"policy": {
@ -436,13 +456,13 @@ class SessionCleanupManager:
"max_sessions": self.policy.max_sessions,
"cleanup_interval": self.policy.cleanup_interval,
"enable_auto_cleanup": self.policy.enable_auto_cleanup,
"preserve_active_session": self.policy.preserve_active_session
}
"preserve_active_session": self.policy.preserve_active_session,
},
}
return stats_dict
def get_cleanup_history(self, limit: int = 20) -> List[Dict[str, Any]]:
def get_cleanup_history(self, limit: int = 20) -> list[dict[str, Any]]:
"""獲取清理歷史記錄"""
return self.cleanup_history[-limit:] if self.cleanup_history else []
@ -479,9 +499,11 @@ class SessionCleanupManager:
for session_id, session in self.web_ui_manager.sessions.items():
# 是否排除當前活躍會話
if (exclude_current and
self.web_ui_manager.current_session and
session.session_id == self.web_ui_manager.current_session.session_id):
if (
exclude_current
and self.web_ui_manager.current_session
and session.session_id == self.web_ui_manager.current_session.session_id
):
continue
sessions_to_clean.append(session_id)

View File

@ -1,24 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試配置和共用 fixtures
"""
import pytest
import asyncio
import tempfile
import shutil
from pathlib import Path
from typing import Generator, Dict, Any
import os
import shutil
import sys
import tempfile
from collections.abc import Generator
from pathlib import Path
from typing import Any
import pytest
# 添加專案根目錄到 Python 路徑
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from src.mcp_feedback_enhanced.web.main import WebUIManager
from src.mcp_feedback_enhanced.i18n import get_i18n_manager
from src.mcp_feedback_enhanced.web.main import WebUIManager
@pytest.fixture(scope="session")
@ -42,11 +44,11 @@ def test_project_dir(temp_dir: Path) -> Path:
"""創建測試專案目錄"""
project_dir = temp_dir / "test_project"
project_dir.mkdir()
# 創建一些測試文件
(project_dir / "README.md").write_text("# Test Project")
(project_dir / "main.py").write_text("print('Hello World')")
return project_dir
@ -55,7 +57,7 @@ def web_ui_manager() -> Generator[WebUIManager, None, None]:
"""創建 WebUIManager fixture"""
manager = WebUIManager(host="127.0.0.1", port=0) # 使用隨機端口
yield manager
# 清理
if manager.server_thread and manager.server_thread.is_alive():
# 這裡可以添加服務器停止邏輯
@ -69,14 +71,14 @@ def i18n_manager():
@pytest.fixture
def test_config() -> Dict[str, Any]:
def test_config() -> dict[str, Any]:
"""測試配置 fixture"""
return {
"timeout": 30,
"debug": True,
"web_port": 8765,
"test_summary": "測試摘要 - 這是一個自動化測試",
"test_feedback": "這是測試回饋內容"
"test_feedback": "這是測試回饋內容",
}
@ -86,9 +88,9 @@ def setup_test_env():
# 設置測試環境變數
original_debug = os.environ.get("MCP_DEBUG")
os.environ["MCP_DEBUG"] = "true"
yield
# 恢復原始環境
if original_debug is not None:
os.environ["MCP_DEBUG"] = original_debug

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 客戶端模擬器 - 簡化版本
"""
@ -7,34 +6,29 @@ MCP 客戶端模擬器 - 簡化版本
import asyncio
import json
import subprocess
import signal
import os
import time
from typing import Dict, Any, Optional, List
from pathlib import Path
from typing import Any
from .test_utils import TestUtils, PerformanceTimer
from .test_utils import PerformanceTimer
class SimpleMCPClient:
"""簡化的 MCP 客戶端模擬器"""
def __init__(self, timeout: int = 30):
self.timeout = timeout
self.server_process: Optional[subprocess.Popen] = None
self.server_process: subprocess.Popen | None = None
self.stdin = None
self.stdout = None
self.stderr = None
self.initialized = False
async def start_server(self) -> bool:
"""啟動 MCP 服務器"""
try:
# 使用當前專案的 MCP 服務器
cmd = [
"python", "-m", "src.mcp_feedback_enhanced.server"
]
cmd = ["python", "-m", "src.mcp_feedback_enhanced.server"]
self.server_process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
@ -42,30 +36,30 @@ class SimpleMCPClient:
stderr=subprocess.PIPE,
text=True,
bufsize=0,
cwd=Path.cwd()
cwd=Path.cwd(),
)
self.stdin = self.server_process.stdin
self.stdout = self.server_process.stdout
self.stderr = self.server_process.stderr
# 等待服務器啟動
await asyncio.sleep(2)
if self.server_process.poll() is not None:
return False
return True
except Exception as e:
print(f"啟動 MCP 服務器失敗: {e}")
return False
async def initialize(self) -> bool:
"""初始化 MCP 連接"""
if not self.server_process or self.server_process.poll() is not None:
return False
try:
# 發送初始化請求
init_request = {
@ -74,41 +68,30 @@ class SimpleMCPClient:
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {
"roots": {
"listChanged": True
},
"sampling": {}
},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
"capabilities": {"roots": {"listChanged": True}, "sampling": {}},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
}
await self._send_request(init_request)
response = await self._read_response()
if response and "result" in response:
self.initialized = True
return True
except Exception as e:
print(f"MCP 初始化失敗: {e}")
return False
async def call_interactive_feedback(
self,
project_directory: str,
summary: str,
timeout: int = 30
) -> Dict[str, Any]:
self, project_directory: str, summary: str, timeout: int = 30
) -> dict[str, Any]:
"""調用 interactive_feedback 工具"""
if not self.initialized:
return {"error": "MCP 客戶端未初始化"}
try:
request = {
"jsonrpc": "2.0",
@ -119,76 +102,73 @@ class SimpleMCPClient:
"arguments": {
"project_directory": project_directory,
"summary": summary,
"timeout": timeout
}
}
"timeout": timeout,
},
},
}
with PerformanceTimer() as timer:
await self._send_request(request)
response = await self._read_response(timeout=timeout + 5)
if response and "result" in response:
result = response["result"]
result["performance"] = {"duration": timer.duration}
return result
else:
return {"error": "無效的回應格式", "response": response}
except asyncio.TimeoutError:
return {"error": "無效的回應格式", "response": response}
except TimeoutError:
return {"error": "調用超時"}
except Exception as e:
return {"error": f"調用失敗: {str(e)}"}
async def _send_request(self, request: Dict[str, Any]):
return {"error": f"調用失敗: {e!s}"}
async def _send_request(self, request: dict[str, Any]):
"""發送請求"""
if not self.stdin:
raise RuntimeError("stdin 不可用")
request_str = json.dumps(request) + "\n"
self.stdin.write(request_str)
self.stdin.flush()
async def _read_response(self, timeout: int = 30) -> Optional[Dict[str, Any]]:
async def _read_response(self, timeout: int = 30) -> dict[str, Any] | None:
"""讀取回應"""
if not self.stdout:
raise RuntimeError("stdout 不可用")
try:
# 使用 asyncio 超時
response_line = await asyncio.wait_for(
asyncio.to_thread(self.stdout.readline),
timeout=timeout
asyncio.to_thread(self.stdout.readline), timeout=timeout
)
if response_line:
return json.loads(response_line.strip())
return None
except asyncio.TimeoutError:
except TimeoutError:
raise
except json.JSONDecodeError as e:
print(f"JSON 解析錯誤: {e}, 原始數據: {response_line}")
return None
async def cleanup(self):
"""清理資源"""
if self.server_process:
try:
# 嘗試正常終止
self.server_process.terminate()
# 等待進程結束
try:
await asyncio.wait_for(
asyncio.to_thread(self.server_process.wait),
timeout=5
asyncio.to_thread(self.server_process.wait), timeout=5
)
except asyncio.TimeoutError:
except TimeoutError:
# 強制終止
self.server_process.kill()
await asyncio.to_thread(self.server_process.wait)
except Exception as e:
print(f"清理 MCP 服務器失敗: {e}")
finally:
@ -201,20 +181,17 @@ class SimpleMCPClient:
class MCPWorkflowTester:
"""MCP 工作流程測試器"""
def __init__(self, timeout: int = 60):
self.timeout = timeout
self.client = SimpleMCPClient(timeout)
async def test_basic_workflow(self, project_dir: str, summary: str) -> Dict[str, Any]:
async def test_basic_workflow(
self, project_dir: str, summary: str
) -> dict[str, Any]:
"""測試基本工作流程"""
result = {
"success": False,
"steps": {},
"errors": [],
"performance": {}
}
result = {"success": False, "steps": {}, "errors": [], "performance": {}}
with PerformanceTimer() as timer:
try:
# 1. 啟動服務器
@ -223,30 +200,32 @@ class MCPWorkflowTester:
else:
result["errors"].append("服務器啟動失敗")
return result
# 2. 初始化連接
if await self.client.initialize():
result["steps"]["initialized"] = True
else:
result["errors"].append("初始化失敗")
return result
# 3. 調用 interactive_feedback
feedback_result = await self.client.call_interactive_feedback(
project_dir, summary, timeout=10
)
if "error" not in feedback_result:
result["steps"]["interactive_feedback_called"] = True
result["feedback_result"] = feedback_result
result["success"] = True
else:
result["errors"].append(f"interactive_feedback 調用失敗: {feedback_result['error']}")
result["errors"].append(
f"interactive_feedback 調用失敗: {feedback_result['error']}"
)
except Exception as e:
result["errors"].append(f"測試異常: {str(e)}")
result["errors"].append(f"測試異常: {e!s}")
finally:
await self.client.cleanup()
result["performance"]["total_duration"] = timer.duration
return result

View File

@ -1,52 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
測試工具函數
"""
import asyncio
import time
import socket
from typing import Optional, Dict, Any, List
from pathlib import Path
import json
import time
from typing import Any
class TestUtils:
"""測試工具類"""
@staticmethod
def find_free_port(start_port: int = 8000, 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(('127.0.0.1', port))
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})")
raise RuntimeError(
f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})"
)
@staticmethod
async def wait_for_condition(
condition_func,
timeout: float = 10.0,
check_interval: float = 0.1
condition_func, timeout: float = 10.0, check_interval: float = 0.1
) -> bool:
"""等待條件滿足"""
start_time = time.time()
while time.time() - start_time < timeout:
if await condition_func() if asyncio.iscoroutinefunction(condition_func) else condition_func():
if (
await condition_func()
if asyncio.iscoroutinefunction(condition_func)
else condition_func()
):
return True
await asyncio.sleep(check_interval)
return False
@staticmethod
def create_test_session_data(
session_id: str = "test-session-123",
project_directory: str = "/test/project",
summary: str = "測試摘要"
) -> Dict[str, Any]:
summary: str = "測試摘要",
) -> dict[str, Any]:
"""創建測試會話數據"""
return {
"session_id": session_id,
@ -54,32 +55,31 @@ class TestUtils:
"summary": summary,
"status": "waiting",
"created_at": time.time(),
"last_activity": time.time()
"last_activity": time.time(),
}
@staticmethod
def create_test_feedback_data(
feedback: str = "測試回饋",
images: Optional[List[Dict]] = None
) -> Dict[str, Any]:
feedback: str = "測試回饋", images: list[dict] | None = None
) -> dict[str, Any]:
"""創建測試回饋數據"""
return {
"feedback": feedback,
"images": images or [],
"settings": {
"image_size_limit": 1024 * 1024, # 1MB
"enable_base64_detail": True
}
"enable_base64_detail": True,
},
}
@staticmethod
def validate_web_response(response_data: Dict[str, Any]) -> bool:
def validate_web_response(response_data: dict[str, Any]) -> bool:
"""驗證 Web 回應格式"""
required_fields = ["command_logs", "interactive_feedback", "images"]
return all(field in response_data for field in required_fields)
@staticmethod
def validate_session_info(session_info: Dict[str, Any]) -> bool:
def validate_session_info(session_info: dict[str, Any]) -> bool:
"""驗證會話信息格式"""
required_fields = ["session_id", "project_directory", "summary", "status"]
return all(field in session_info for field in required_fields)
@ -87,24 +87,24 @@ class TestUtils:
class MockWebSocketClient:
"""模擬 WebSocket 客戶端"""
def __init__(self):
self.connected = False
self.messages = []
self.responses = []
async def connect(self, url: str) -> bool:
"""模擬連接"""
self.connected = True
return True
async def send_json(self, data: Dict[str, Any]):
async def send_json(self, data: dict[str, Any]):
"""模擬發送 JSON 數據"""
if not self.connected:
raise RuntimeError("WebSocket 未連接")
self.messages.append(data)
async def receive_json(self) -> Dict[str, Any]:
async def receive_json(self) -> dict[str, Any]:
"""模擬接收 JSON 數據"""
if not self.connected:
raise RuntimeError("WebSocket 未連接")
@ -112,11 +112,11 @@ class MockWebSocketClient:
return self.responses.pop(0)
# 返回默認回應
return {"type": "connection_established", "message": "連接成功"}
def add_response(self, response: Dict[str, Any]):
def add_response(self, response: dict[str, Any]):
"""添加模擬回應"""
self.responses.append(response)
async def close(self):
"""關閉連接"""
self.connected = False
@ -124,19 +124,19 @@ class MockWebSocketClient:
class PerformanceTimer:
"""性能計時器"""
def __init__(self):
self.start_time = None
self.end_time = None
def start(self):
"""開始計時"""
self.start_time = time.time()
def stop(self):
"""停止計時"""
self.end_time = time.time()
@property
def duration(self) -> float:
"""獲取持續時間"""
@ -144,10 +144,10 @@ class PerformanceTimer:
return 0.0
end = self.end_time or time.time()
return end - self.start_time
def __enter__(self):
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.stop()

View File

@ -1,97 +1,95 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
I18N 集成測試
"""
import pytest
import asyncio
import os
from pathlib import Path
import pytest
from tests.fixtures.test_data import TestData
class TestI18NWebIntegration:
"""I18N 與 Web UI 集成測試"""
@pytest.mark.asyncio
async def test_i18n_in_web_templates(self, web_ui_manager, i18n_manager, test_project_dir):
async def test_i18n_in_web_templates(
self, web_ui_manager, i18n_manager, test_project_dir
):
"""測試 Web 模板中的 I18N 功能"""
# 創建會話
web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 啟動服務器
web_ui_manager.start_server()
await asyncio.sleep(3)
import aiohttp
base_url = f"http://{web_ui_manager.host}:{web_ui_manager.port}"
# 測試不同語言的頁面渲染
for lang in TestData.SUPPORTED_LANGUAGES:
i18n_manager.set_language(lang)
async with aiohttp.ClientSession() as session:
# 測試主頁
async with session.get(f"{base_url}/") as response:
assert response.status == 200
text = await response.text()
# 頁面應該包含當前語言的內容
assert len(text) > 0
# 檢查是否包含基本的 UI 元素
# 這些元素應該根據語言進行本地化
assert "MCP Feedback" in text
def test_i18n_api_endpoints(self, web_ui_manager, i18n_manager):
"""測試 I18N API 端點"""
import aiohttp
import asyncio
import aiohttp
# 啟動服務器
web_ui_manager.start_server()
async def test_api():
await asyncio.sleep(3)
base_url = f"http://{web_ui_manager.host}:{web_ui_manager.port}"
async with aiohttp.ClientSession() as session:
# 測試語言切換 API如果存在
for lang in TestData.SUPPORTED_LANGUAGES:
# 這裡可以測試語言切換 API
# 例如 POST /api/set-language
pass
asyncio.run(test_api())
class TestI18NMCPIntegration:
"""I18N 與 MCP 集成測試"""
def test_i18n_in_mcp_responses(self, i18n_manager):
"""測試 MCP 回應中的 I18N"""
# 測試不同語言下的錯誤消息
for lang in TestData.SUPPORTED_LANGUAGES:
i18n_manager.set_language(lang)
# 測試常見錯誤消息的本地化
error_keys = [
"error.connection",
"error.timeout",
"error.invalid_input"
]
error_keys = ["error.connection", "error.timeout", "error.invalid_input"]
for key in error_keys:
message = i18n_manager.t(key)
assert isinstance(message, str)
assert len(message) > 0
# 不同語言的消息應該不同(除非回退到同一語言)
if lang != i18n_manager._fallback_language:
# 簡化測試,只檢查翻譯是否存在
@ -102,26 +100,27 @@ class TestI18NMCPIntegration:
class TestI18NFileSystemIntegration:
"""I18N 文件系統集成測試"""
def test_translation_files_exist(self):
"""測試翻譯文件存在"""
# 獲取 I18N 文件目錄
from src.mcp_feedback_enhanced.i18n import I18nManager
manager = I18nManager()
locales_dir = manager._locales_dir
assert locales_dir.exists(), f"翻譯目錄不存在: {locales_dir}"
# 檢查每種支援語言的翻譯文件
for lang in TestData.SUPPORTED_LANGUAGES:
lang_file = locales_dir / f"{lang}.json"
assert lang_file.exists(), f"翻譯文件不存在: {lang_file}"
# 檢查文件內容
import json
try:
with open(lang_file, 'r', encoding='utf-8') as f:
with open(lang_file, encoding="utf-8") as f:
translations = json.load(f)
assert isinstance(translations, dict)
assert len(translations) > 0
@ -129,21 +128,21 @@ class TestI18NFileSystemIntegration:
pytest.fail(f"翻譯文件 {lang_file} JSON 格式錯誤: {e}")
except Exception as e:
pytest.fail(f"讀取翻譯文件 {lang_file} 失敗: {e}")
def test_translation_file_encoding(self):
"""測試翻譯文件編碼"""
from src.mcp_feedback_enhanced.i18n import I18nManager
manager = I18nManager()
locales_dir = manager._locales_dir
for lang in TestData.SUPPORTED_LANGUAGES:
lang_file = locales_dir / f"{lang}.json"
if lang_file.exists():
# 測試 UTF-8 編碼
try:
with open(lang_file, 'r', encoding='utf-8') as f:
with open(lang_file, encoding="utf-8") as f:
content = f.read()
assert len(content) > 0
except UnicodeDecodeError:
@ -152,44 +151,46 @@ class TestI18NFileSystemIntegration:
class TestI18NEnvironmentIntegration:
"""I18N 環境集成測試"""
def test_language_detection_in_different_environments(self):
"""測試不同環境下的語言檢測"""
from src.mcp_feedback_enhanced.i18n import I18nManager
# 保存原始環境變數
original_env = {}
env_vars = ['LANG', 'LANGUAGE', 'LC_ALL', 'LC_MESSAGES']
env_vars = ["LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES"]
for var in env_vars:
original_env[var] = os.environ.get(var)
try:
# 測試不同的環境設置
test_cases = [
{'LANG': 'zh_TW.UTF-8', 'expected': 'zh-TW'},
{'LANG': 'zh_CN.UTF-8', 'expected': 'zh-CN'},
{'LANG': 'en_US.UTF-8', 'expected': 'en'},
{'LANG': 'ja_JP.UTF-8', 'expected': 'en'}, # 不支援的語言應回退
{"LANG": "zh_TW.UTF-8", "expected": "zh-TW"},
{"LANG": "zh_CN.UTF-8", "expected": "zh-CN"},
{"LANG": "en_US.UTF-8", "expected": "en"},
{"LANG": "ja_JP.UTF-8", "expected": "en"}, # 不支援的語言應回退
]
for test_case in test_cases:
# 清理環境變數
for var in env_vars:
os.environ.pop(var, None)
# 設置測試環境
for key, value in test_case.items():
if key != 'expected':
if key != "expected":
os.environ[key] = value
# 創建新的管理器實例
manager = I18nManager()
detected = manager.detect_system_language()
# 驗證檢測結果
expected = test_case['expected']
assert detected == expected, f"環境 {test_case} 檢測到 {detected},預期 {expected}"
expected = test_case["expected"]
assert detected == expected, (
f"環境 {test_case} 檢測到 {detected},預期 {expected}"
)
finally:
# 恢復原始環境變數
for var, value in original_env.items():
@ -197,13 +198,13 @@ class TestI18NEnvironmentIntegration:
os.environ[var] = value
else:
os.environ.pop(var, None)
def test_i18n_with_web_ui_manager(self, web_ui_manager, i18n_manager):
"""測試 I18N 與 WebUIManager 的集成"""
# 驗證 WebUIManager 使用了 I18N 管理器
assert hasattr(web_ui_manager, 'i18n')
assert hasattr(web_ui_manager, "i18n")
assert web_ui_manager.i18n is not None
# 測試語言切換對 WebUIManager 的影響
original_lang = i18n_manager.get_current_language()

View File

@ -1,76 +1,74 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MCP 工作流程集成測試
"""
import pytest
import asyncio
import os
from pathlib import Path
from tests.helpers.mcp_client import SimpleMCPClient, MCPWorkflowTester
import pytest
from tests.fixtures.test_data import TestData
from tests.helpers.mcp_client import MCPWorkflowTester, SimpleMCPClient
from tests.helpers.test_utils import TestUtils
from tests.fixtures.test_data import TestData, TestScenarios
class TestMCPBasicWorkflow:
"""MCP 基本工作流程測試"""
@pytest.mark.asyncio
async def test_mcp_server_startup(self):
"""測試 MCP 服務器啟動"""
client = SimpleMCPClient(timeout=30)
try:
# 測試服務器啟動
success = await client.start_server()
assert success == True, "MCP 服務器啟動失敗"
# 驗證進程存在
assert client.server_process is not None
assert client.server_process.poll() is None # 進程應該還在運行
finally:
await client.cleanup()
@pytest.mark.asyncio
async def test_mcp_initialization(self):
"""測試 MCP 初始化"""
client = SimpleMCPClient(timeout=30)
try:
# 啟動服務器
assert await client.start_server() == True
# 測試初始化
success = await client.initialize()
assert success == True, "MCP 初始化失敗"
assert client.initialized == True
finally:
await client.cleanup()
@pytest.mark.asyncio
async def test_interactive_feedback_call_timeout(self, test_project_dir):
"""測試 interactive_feedback 調用(超時情況)"""
client = SimpleMCPClient(timeout=30)
try:
# 啟動並初始化
assert await client.start_server() == True
assert await client.initialize() == True
# 調用 interactive_feedback設置短超時
result = await client.call_interactive_feedback(
str(test_project_dir),
"測試調用 - 預期超時",
timeout=5 # 5秒超時
timeout=5, # 5秒超時
)
# 驗證結果格式
assert isinstance(result, dict)
# 由於是自動化測試環境,預期會超時或返回默認回應
if "error" in result:
# 超時是預期的行為
@ -78,122 +76,121 @@ class TestMCPBasicWorkflow:
else:
# 或者返回了默認的回應
assert TestUtils.validate_web_response(result)
finally:
await client.cleanup()
class TestMCPWorkflowIntegration:
"""MCP 工作流程集成測試"""
@pytest.mark.asyncio
async def test_complete_workflow(self, test_project_dir):
"""測試完整的 MCP 工作流程"""
tester = MCPWorkflowTester(timeout=60)
result = await tester.test_basic_workflow(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 驗證測試結果
assert isinstance(result, dict)
assert "success" in result
assert "steps" in result
assert "errors" in result
assert "performance" in result
# 檢查關鍵步驟
steps = result["steps"]
assert steps.get("server_started") == True, "服務器啟動失敗"
assert steps.get("initialized") == True, "初始化失敗"
# interactive_feedback 調用可能超時,這在測試環境是正常的
if not steps.get("interactive_feedback_called"):
# 檢查是否是超時錯誤
errors = result["errors"]
timeout_error_found = any("超時" in error or "timeout" in error.lower() for error in errors)
assert timeout_error_found, f"interactive_feedback 調用失敗,但不是超時錯誤: {errors}"
timeout_error_found = any(
"超時" in error or "timeout" in error.lower() for error in errors
)
assert timeout_error_found, (
f"interactive_feedback 調用失敗,但不是超時錯誤: {errors}"
)
# 驗證性能數據
performance = result["performance"]
assert "total_duration" in performance
assert performance["total_duration"] > 0
@pytest.mark.asyncio
async def test_multiple_calls_workflow(self, test_project_dir):
"""測試多次調用工作流程(模擬第二次循環)"""
tester = MCPWorkflowTester(timeout=60)
# 第一次調用
result1 = await tester.test_basic_workflow(
str(test_project_dir),
"第一次 AI 調用 - 完成初始任務"
str(test_project_dir), "第一次 AI 調用 - 完成初始任務"
)
# 第二次調用
result2 = await tester.test_basic_workflow(
str(test_project_dir),
"第二次 AI 調用 - 根據回饋調整"
str(test_project_dir), "第二次 AI 調用 - 根據回饋調整"
)
# 兩次調用都應該成功啟動服務器和初始化
for i, result in enumerate([result1, result2], 1):
assert result["steps"].get("server_started") == True, f"{i}次調用服務器啟動失敗"
assert result["steps"].get("server_started") == True, (
f"{i}次調用服務器啟動失敗"
)
assert result["steps"].get("initialized") == True, f"{i}次調用初始化失敗"
class TestMCPErrorHandling:
"""MCP 錯誤處理測試"""
@pytest.mark.asyncio
async def test_invalid_project_directory(self):
"""測試無效專案目錄處理"""
client = SimpleMCPClient(timeout=30)
try:
assert await client.start_server() == True
assert await client.initialize() == True
# 使用不存在的目錄
result = await client.call_interactive_feedback(
"/non/existent/directory",
"測試無效目錄",
timeout=5
"/non/existent/directory", "測試無效目錄", timeout=5
)
# 應該能處理錯誤而不崩潰
assert isinstance(result, dict)
finally:
await client.cleanup()
@pytest.mark.asyncio
async def test_server_cleanup_on_error(self):
"""測試錯誤時的服務器清理"""
client = SimpleMCPClient(timeout=30)
try:
assert await client.start_server() == True
# 記錄進程 ID
process = client.server_process
assert process is not None
# 模擬錯誤情況(不初始化就調用工具)
result = await client.call_interactive_feedback(
"/test",
"測試錯誤處理",
timeout=5
"/test", "測試錯誤處理", timeout=5
)
# 應該返回錯誤
assert "error" in result
finally:
# 清理應該正常工作
await client.cleanup()
# 驗證進程已被清理
if process:
assert process.poll() is not None # 進程應該已結束
@ -201,57 +198,61 @@ class TestMCPErrorHandling:
class TestMCPPerformance:
"""MCP 性能測試"""
@pytest.mark.asyncio
async def test_startup_performance(self):
"""測試啟動性能"""
from tests.helpers.test_utils import PerformanceTimer
client = SimpleMCPClient(timeout=30)
try:
with PerformanceTimer() as timer:
success = await client.start_server()
assert success == True
# 啟動時間應該在合理範圍內30秒內
assert timer.duration < 30, f"服務器啟動時間過長: {timer.duration:.2f}"
with PerformanceTimer() as timer:
success = await client.initialize()
assert success == True
# 初始化時間應該很快5秒內
assert timer.duration < 5, f"初始化時間過長: {timer.duration:.2f}"
finally:
await client.cleanup()
@pytest.mark.asyncio
async def test_concurrent_initialization(self):
"""測試並發初始化(確保不會衝突)"""
clients = [SimpleMCPClient(timeout=30) for _ in range(2)]
try:
# 並發啟動多個客戶端
startup_tasks = [client.start_server() for client in clients]
startup_results = await asyncio.gather(*startup_tasks, return_exceptions=True)
startup_results = await asyncio.gather(
*startup_tasks, return_exceptions=True
)
# 至少有一個應該成功(其他可能因為端口衝突失敗)
successful_clients = []
for i, (client, result) in enumerate(zip(clients, startup_results)):
for i, (client, result) in enumerate(
zip(clients, startup_results, strict=False)
):
if isinstance(result, bool) and result:
successful_clients.append(client)
elif isinstance(result, Exception):
print(f"客戶端 {i} 啟動失敗(預期): {result}")
assert len(successful_clients) >= 1, "至少應該有一個客戶端成功啟動"
# 測試成功的客戶端初始化
for client in successful_clients:
success = await client.initialize()
assert success == True
finally:
# 清理所有客戶端
cleanup_tasks = [client.cleanup() for client in clients]

View File

@ -1,120 +1,117 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 集成測試
"""
import pytest
import asyncio
import time
from pathlib import Path
from tests.helpers.test_utils import TestUtils, MockWebSocketClient
import pytest
from tests.fixtures.test_data import TestData
from tests.helpers.test_utils import TestUtils
class TestWebUIIntegration:
"""Web UI 集成測試"""
@pytest.mark.asyncio
async def test_web_server_startup_and_routes(self, web_ui_manager):
"""測試 Web 服務器啟動和基本路由"""
# 啟動服務器
web_ui_manager.start_server()
# 等待服務器啟動
await asyncio.sleep(3)
# 驗證服務器正在運行
assert web_ui_manager.server_thread is not None
assert web_ui_manager.server_thread.is_alive()
# 測試基本路由可訪問性
import aiohttp
base_url = f"http://{web_ui_manager.host}:{web_ui_manager.port}"
async with aiohttp.ClientSession() as session:
# 測試主頁
async with session.get(f"{base_url}/") as response:
assert response.status == 200
text = await response.text()
assert "MCP Feedback Enhanced" in text
# 測試靜態文件
async with session.get(f"{base_url}/static/css/style.css") as response:
# 可能返回 200 或 404但不應該是服務器錯誤
assert response.status in [200, 404]
@pytest.mark.asyncio
async def test_session_api_integration(self, web_ui_manager, test_project_dir):
"""測試會話 API 集成"""
import aiohttp
# 創建會話
session_id = web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 啟動服務器
web_ui_manager.start_server()
await asyncio.sleep(3)
base_url = f"http://{web_ui_manager.host}:{web_ui_manager.port}"
async with aiohttp.ClientSession() as session:
# 測試當前會話 API
async with session.get(f"{base_url}/api/current-session") as response:
assert response.status == 200
data = await response.json()
assert data["session_id"] == session_id
assert data["project_directory"] == str(test_project_dir)
assert data["summary"] == TestData.SAMPLE_SESSION["summary"]
@pytest.mark.asyncio
async def test_websocket_connection(self, web_ui_manager, test_project_dir):
"""測試 WebSocket 連接"""
import aiohttp
# 創建會話
web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 啟動服務器
web_ui_manager.start_server()
await asyncio.sleep(3)
ws_url = f"ws://{web_ui_manager.host}:{web_ui_manager.port}/ws"
async with aiohttp.ClientSession() as session:
try:
async with session.ws_connect(ws_url) as ws:
# 應該收到連接確認消息
msg = await asyncio.wait_for(ws.receive(), timeout=5)
assert msg.type == aiohttp.WSMsgType.TEXT
data = msg.json()
assert data["type"] == "connection_established"
# 測試發送心跳
heartbeat_msg = {
"type": "heartbeat",
"tabId": "test-tab-123",
"timestamp": time.time()
"timestamp": time.time(),
}
await ws.send_str(str(heartbeat_msg).replace("'", '"'))
# 應該收到心跳回應
response = await asyncio.wait_for(ws.receive(), timeout=5)
if response.type == aiohttp.WSMsgType.TEXT:
response_data = response.json()
assert response_data["type"] == "heartbeat_response"
except asyncio.TimeoutError:
except TimeoutError:
pytest.fail("WebSocket 連接或通信超時")
except Exception as e:
pytest.fail(f"WebSocket 測試失敗: {e}")
@ -122,192 +119,187 @@ class TestWebUIIntegration:
class TestWebUISessionManagement:
"""Web UI 會話管理集成測試"""
@pytest.mark.asyncio
async def test_session_lifecycle(self, web_ui_manager, test_project_dir):
"""測試會話生命週期"""
# 1. 創建會話
session_id = web_ui_manager.create_session(
str(test_project_dir),
"第一個會話"
)
session_id = web_ui_manager.create_session(str(test_project_dir), "第一個會話")
current_session = web_ui_manager.get_current_session()
assert current_session is not None
assert current_session.session_id == session_id
# 2. 創建第二個會話(模擬第二次 MCP 調用)
session_id_2 = web_ui_manager.create_session(
str(test_project_dir),
"第二個會話"
str(test_project_dir), "第二個會話"
)
# 當前會話應該切換到新會話
current_session = web_ui_manager.get_current_session()
assert current_session.session_id == session_id_2
assert current_session.summary == "第二個會話"
# 3. 測試會話狀態更新
from src.mcp_feedback_enhanced.web.models import SessionStatus
current_session.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已提交回饋")
assert current_session.status == SessionStatus.FEEDBACK_SUBMITTED
@pytest.mark.asyncio
async def test_session_feedback_flow(self, web_ui_manager, test_project_dir):
"""測試會話回饋流程"""
# 創建會話
session_id = web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
session = web_ui_manager.get_current_session()
# 模擬提交回饋
await session.submit_feedback(
TestData.SAMPLE_FEEDBACK["feedback"],
TestData.SAMPLE_FEEDBACK["images"],
TestData.SAMPLE_FEEDBACK["settings"]
TestData.SAMPLE_FEEDBACK["settings"],
)
# 驗證回饋已保存
assert session.feedback_result == TestData.SAMPLE_FEEDBACK["feedback"]
assert session.images == TestData.SAMPLE_FEEDBACK["images"]
assert session.settings == TestData.SAMPLE_FEEDBACK["settings"]
# 驗證狀態已更新
from src.mcp_feedback_enhanced.web.models import SessionStatus
assert session.status == SessionStatus.FEEDBACK_SUBMITTED
@pytest.mark.asyncio
async def test_session_timeout_handling(self, web_ui_manager, test_project_dir):
"""測試會話超時處理"""
# 創建會話,設置短超時
session_id = web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
session = web_ui_manager.get_current_session()
# 測試超時等待
try:
result = await asyncio.wait_for(
session.wait_for_feedback(timeout=1), # 1秒超時
timeout=2 # 外部超時保護
timeout=2, # 外部超時保護
)
# 如果沒有超時,應該返回默認結果
assert TestUtils.validate_web_response(result)
except asyncio.TimeoutError:
except TimeoutError:
# 超時是預期的行為
pass
class TestWebUIErrorHandling:
"""Web UI 錯誤處理集成測試"""
@pytest.mark.asyncio
async def test_no_session_handling(self, web_ui_manager):
"""測試無會話時的處理"""
import aiohttp
# 確保沒有活躍會話
web_ui_manager.clear_current_session()
# 啟動服務器
web_ui_manager.start_server()
await asyncio.sleep(3)
base_url = f"http://{web_ui_manager.host}:{web_ui_manager.port}"
async with aiohttp.ClientSession() as session:
# 測試主頁應該顯示等待頁面
async with session.get(f"{base_url}/") as response:
assert response.status == 200
text = await response.text()
assert "MCP Feedback Enhanced" in text
# 測試當前會話 API 應該返回無會話狀態
async with session.get(f"{base_url}/api/current-session") as response:
assert response.status == 404 # 或其他適當的狀態碼
@pytest.mark.asyncio
async def test_websocket_without_session(self, web_ui_manager):
"""測試無會話時的 WebSocket 連接"""
import aiohttp
# 確保沒有活躍會話
web_ui_manager.clear_current_session()
# 啟動服務器
web_ui_manager.start_server()
await asyncio.sleep(3)
ws_url = f"ws://{web_ui_manager.host}:{web_ui_manager.port}/ws"
async with aiohttp.ClientSession() as session:
try:
async with session.ws_connect(ws_url) as ws:
# 連接應該被拒絕或立即關閉
msg = await asyncio.wait_for(ws.receive(), timeout=5)
if msg.type == aiohttp.WSMsgType.CLOSE:
# 連接被關閉是預期的
assert True
else:
# 如果收到消息,應該是錯誤消息
if msg.type == aiohttp.WSMsgType.TEXT:
data = msg.json()
assert "error" in data or data.get("type") == "error"
# 如果收到消息,應該是錯誤消息
elif msg.type == aiohttp.WSMsgType.TEXT:
data = msg.json()
assert "error" in data or data.get("type") == "error"
except aiohttp.WSServerHandshakeError:
# WebSocket 握手失敗也是預期的
assert True
except asyncio.TimeoutError:
except TimeoutError:
# 超時也可能是預期的行為
assert True
class TestWebUIPerformance:
"""Web UI 性能集成測試"""
@pytest.mark.asyncio
async def test_server_startup_time(self, web_ui_manager):
"""測試服務器啟動時間"""
from tests.helpers.test_utils import PerformanceTimer
with PerformanceTimer() as timer:
web_ui_manager.start_server()
await asyncio.sleep(3) # 等待啟動完成
# 啟動時間應該在合理範圍內
assert timer.duration < 10, f"Web 服務器啟動時間過長: {timer.duration:.2f}"
# 驗證服務器確實在運行
assert web_ui_manager.server_thread is not None
assert web_ui_manager.server_thread.is_alive()
@pytest.mark.asyncio
async def test_multiple_session_performance(self, web_ui_manager, test_project_dir):
"""測試多會話性能"""
from tests.helpers.test_utils import PerformanceTimer
session_ids = []
with PerformanceTimer() as timer:
# 創建多個會話
for i in range(10):
session_id = web_ui_manager.create_session(
str(test_project_dir),
f"測試會話 {i+1}"
str(test_project_dir), f"測試會話 {i + 1}"
)
session_ids.append(session_id)
# 創建會話的時間應該是線性的,不應該有明顯的性能下降
avg_time_per_session = timer.duration / 10
assert avg_time_per_session < 0.1, f"每個會話創建時間過長: {avg_time_per_session:.3f}"
assert avg_time_per_session < 0.1, (
f"每個會話創建時間過長: {avg_time_per_session:.3f}"
)
# 驗證最後一個會話是當前活躍會話
current_session = web_ui_manager.get_current_session()
assert current_session.session_id == session_ids[-1]

View File

@ -8,20 +8,25 @@
- 錯誤上下文記錄
"""
import pytest
import sys
import os
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
# 添加 src 目錄到 Python 路徑
sys.path.insert(0, 'src')
sys.path.insert(0, "src")
from mcp_feedback_enhanced.utils.error_handler import ErrorHandler, ErrorType, ErrorSeverity
from mcp_feedback_enhanced.utils.error_handler import (
ErrorHandler,
ErrorSeverity,
ErrorType,
)
class TestErrorHandler:
"""錯誤處理器測試類"""
def test_classify_error_network(self):
"""測試網絡錯誤分類"""
# 測試 ConnectionError
@ -31,7 +36,7 @@ class TestErrorHandler:
# 測試包含網絡關鍵字的錯誤(不包含 timeout
error = Exception("socket connection failed")
assert ErrorHandler.classify_error(error) == ErrorType.NETWORK
def test_classify_error_file_io(self):
"""測試文件 I/O 錯誤分類"""
# 測試 FileNotFoundError
@ -41,95 +46,99 @@ class TestErrorHandler:
# 測試包含文件關鍵字的錯誤(不包含權限關鍵字)
error = Exception("file not found")
assert ErrorHandler.classify_error(error) == ErrorType.FILE_IO
def test_classify_error_timeout(self):
"""測試超時錯誤分類"""
error = TimeoutError("Operation timed out")
assert ErrorHandler.classify_error(error) == ErrorType.TIMEOUT
error = Exception("timeout occurred")
assert ErrorHandler.classify_error(error) == ErrorType.TIMEOUT
def test_classify_error_permission(self):
"""測試權限錯誤分類"""
error = PermissionError("Access denied")
assert ErrorHandler.classify_error(error) == ErrorType.PERMISSION
error = Exception("access denied")
assert ErrorHandler.classify_error(error) == ErrorType.PERMISSION
def test_classify_error_validation(self):
"""測試驗證錯誤分類"""
error = ValueError("Invalid value")
assert ErrorHandler.classify_error(error) == ErrorType.VALIDATION
error = TypeError("Wrong type")
assert ErrorHandler.classify_error(error) == ErrorType.VALIDATION
def test_classify_error_default_system(self):
"""測試默認系統錯誤分類"""
error = Exception("Some completely unknown issue")
assert ErrorHandler.classify_error(error) == ErrorType.SYSTEM
def test_format_user_error_basic(self):
"""測試基本用戶友好錯誤信息生成"""
error = ConnectionError("Connection failed")
result = ErrorHandler.format_user_error(error)
assert "" in result
assert "網絡連接出現問題" in result or "网络连接出现问题" in result or "Network connection issue" in result
assert (
"網絡連接出現問題" in result
or "网络连接出现问题" in result
or "Network connection issue" in result
)
def test_format_user_error_with_context(self):
"""測試帶上下文的錯誤信息生成"""
error = FileNotFoundError("File not found")
context = {
"operation": "文件讀取",
"file_path": "/path/to/file.txt"
}
context = {"operation": "文件讀取", "file_path": "/path/to/file.txt"}
result = ErrorHandler.format_user_error(error, context=context)
assert "" in result
assert "文件讀取" in result or "文件读取" in result or "文件讀取" in result
assert "/path/to/file.txt" in result
def test_format_user_error_with_technical_details(self):
"""測試包含技術細節的錯誤信息"""
error = ValueError("Invalid input")
result = ErrorHandler.format_user_error(error, include_technical=True)
assert "" in result
assert "ValueError" in result
assert "Invalid input" in result
def test_get_error_solutions(self):
"""測試獲取錯誤解決方案"""
solutions = ErrorHandler.get_error_solutions(ErrorType.NETWORK)
assert isinstance(solutions, list)
assert len(solutions) > 0
# 應該包含網絡相關的解決方案
solutions_text = " ".join(solutions).lower()
assert any(keyword in solutions_text for keyword in ["網絡", "网络", "network", "連接", "连接", "connection"])
assert any(
keyword in solutions_text
for keyword in ["網絡", "网络", "network", "連接", "连接", "connection"]
)
def test_log_error_with_context(self):
"""測試帶上下文的錯誤記錄"""
error = Exception("Test error")
context = {"operation": "測試操作", "user": "test_user"}
error_id = ErrorHandler.log_error_with_context(error, context=context)
assert isinstance(error_id, str)
assert error_id.startswith("ERR_")
assert len(error_id.split("_")) == 3 # ERR_timestamp_id
def test_create_error_response(self):
"""測試創建標準化錯誤響應"""
error = ConnectionError("Network error")
context = {"operation": "網絡請求"}
response = ErrorHandler.create_error_response(error, context=context)
assert isinstance(response, dict)
assert response["success"] is False
assert "error_id" in response
@ -137,18 +146,20 @@ class TestErrorHandler:
assert "message" in response
assert response["error_type"] == ErrorType.NETWORK.value
assert "solutions" in response
def test_create_error_response_for_user(self):
"""測試為用戶界面創建錯誤響應"""
error = FileNotFoundError("File not found")
response = ErrorHandler.create_error_response(error, for_user=True)
assert response["success"] is False
assert "context" not in response # 用戶界面不應包含技術上下文
assert "" in response["message"] # 應該包含用戶友好的格式
@patch('mcp_feedback_enhanced.utils.error_handler.ErrorHandler.get_i18n_error_message')
@patch(
"mcp_feedback_enhanced.utils.error_handler.ErrorHandler.get_i18n_error_message"
)
def test_language_support(self, mock_get_message):
"""測試多語言支持"""
error = ConnectionError("Network error")
@ -167,20 +178,19 @@ class TestErrorHandler:
mock_get_message.return_value = "Network connection issue"
result = ErrorHandler.format_user_error(error)
assert "Network connection issue" in result
def test_error_severity_logging(self):
"""測試錯誤嚴重程度記錄"""
error = Exception("Critical system error")
# 測試高嚴重程度錯誤
error_id = ErrorHandler.log_error_with_context(
error,
severity=ErrorSeverity.CRITICAL
error, severity=ErrorSeverity.CRITICAL
)
assert isinstance(error_id, str)
assert error_id.startswith("ERR_")
def test_get_current_language_fallback(self):
"""測試語言獲取回退機制"""
# 由於 i18n 系統可能會覆蓋環境變數,我們主要測試函數不會拋出異常
@ -190,21 +200,21 @@ class TestErrorHandler:
# 測試語言代碼格式
assert language in ["zh-TW", "zh-CN", "en"] or "-" in language
def test_i18n_integration(self):
"""測試國際化系統集成"""
# 測試當 i18n 系統不可用時的回退
error_type = ErrorType.NETWORK
# 測試獲取錯誤信息
message = ErrorHandler.get_i18n_error_message(error_type)
assert isinstance(message, str)
assert len(message) > 0
# 測試獲取解決方案
solutions = ErrorHandler.get_i18n_error_solutions(error_type)
assert isinstance(solutions, list)
def test_error_context_preservation(self):
"""測試錯誤上下文保存"""
error = Exception("Test error")
@ -212,42 +222,42 @@ class TestErrorHandler:
"operation": "測試操作",
"file_path": "/test/path",
"user_id": "test_user",
"timestamp": "2025-01-05"
"timestamp": "2025-01-05",
}
error_id = ErrorHandler.log_error_with_context(error, context=context)
# 驗證錯誤 ID 格式
assert isinstance(error_id, str)
assert error_id.startswith("ERR_")
# 上下文應該被記錄到調試日誌中(通過 debug_log
# 這裡我們主要驗證函數不會拋出異常
def test_json_rpc_safety(self):
"""測試不影響 JSON RPC 通信"""
# 錯誤處理應該只記錄到 stderr通過 debug_log
# 不應該影響 stdout 或 JSON RPC 響應
error = Exception("Test error for JSON RPC safety")
context = {"operation": "JSON RPC 測試"}
# 這些操作不應該影響 stdout
error_id = ErrorHandler.log_error_with_context(error, context=context)
user_message = ErrorHandler.format_user_error(error)
response = ErrorHandler.create_error_response(error)
# 驗證返回值類型正確
assert isinstance(error_id, str)
assert isinstance(user_message, str)
assert isinstance(response, dict)
# 驗證不會拋出異常
assert error_id.startswith("ERR_")
assert "" in user_message
assert response["success"] is False
if __name__ == '__main__':
if __name__ == "__main__":
# 運行測試
pytest.main([__file__, '-v'])
pytest.main([__file__, "-v"])

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Gzip 壓縮功能測試
================
@ -11,271 +10,287 @@ Gzip 壓縮功能測試
- 性能提升測試
"""
import pytest
import asyncio
import gzip
import json
from unittest.mock import Mock, patch
from fastapi.testclient import TestClient
from unittest.mock import patch
import pytest
from fastapi import FastAPI, Response
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.testclient import TestClient
from src.mcp_feedback_enhanced.web.utils.compression_config import (
CompressionConfig, CompressionManager, get_compression_manager
CompressionConfig,
CompressionManager,
get_compression_manager,
)
from src.mcp_feedback_enhanced.web.utils.compression_monitor import (
CompressionMonitor, get_compression_monitor
CompressionMonitor,
get_compression_monitor,
)
class TestCompressionConfig:
"""測試壓縮配置類"""
def test_default_config(self):
"""測試預設配置"""
config = CompressionConfig()
assert config.minimum_size == 1000
assert config.compression_level == 6
assert config.static_cache_max_age == 3600
assert config.api_cache_max_age == 0
assert 'text/html' in config.compressible_types
assert 'application/json' in config.compressible_types
assert '/ws' in config.exclude_paths
assert "text/html" in config.compressible_types
assert "application/json" in config.compressible_types
assert "/ws" in config.exclude_paths
def test_from_env(self):
"""測試從環境變數創建配置"""
with patch.dict('os.environ', {
'MCP_GZIP_MIN_SIZE': '2000',
'MCP_GZIP_LEVEL': '9',
'MCP_STATIC_CACHE_AGE': '7200'
}):
with patch.dict(
"os.environ",
{
"MCP_GZIP_MIN_SIZE": "2000",
"MCP_GZIP_LEVEL": "9",
"MCP_STATIC_CACHE_AGE": "7200",
},
):
config = CompressionConfig.from_env()
assert config.minimum_size == 2000
assert config.compression_level == 9
assert config.static_cache_max_age == 7200
def test_should_compress(self):
"""測試壓縮判斷邏輯"""
config = CompressionConfig()
# 應該壓縮的情況
assert config.should_compress('text/html', 2000) == True
assert config.should_compress('application/json', 1500) == True
assert config.should_compress("text/html", 2000) == True
assert config.should_compress("application/json", 1500) == True
# 不應該壓縮的情況
assert config.should_compress('text/html', 500) == False # 太小
assert config.should_compress('image/jpeg', 2000) == False # 不支援的類型
assert config.should_compress('', 2000) == False # 無內容類型
assert config.should_compress("text/html", 500) == False # 太小
assert config.should_compress("image/jpeg", 2000) == False # 不支援的類型
assert config.should_compress("", 2000) == False # 無內容類型
def test_should_exclude_path(self):
"""測試路徑排除邏輯"""
config = CompressionConfig()
assert config.should_exclude_path('/ws') == True
assert config.should_exclude_path('/api/ws') == True
assert config.should_exclude_path('/health') == True
assert config.should_exclude_path('/static/css/style.css') == False
assert config.should_exclude_path('/api/feedback') == False
assert config.should_exclude_path("/ws") == True
assert config.should_exclude_path("/api/ws") == True
assert config.should_exclude_path("/health") == True
assert config.should_exclude_path("/static/css/style.css") == False
assert config.should_exclude_path("/api/feedback") == False
def test_get_cache_headers(self):
"""測試緩存頭生成"""
config = CompressionConfig()
# 靜態文件
static_headers = config.get_cache_headers('/static/css/style.css')
assert 'Cache-Control' in static_headers
assert 'public, max-age=3600' in static_headers['Cache-Control']
static_headers = config.get_cache_headers("/static/css/style.css")
assert "Cache-Control" in static_headers
assert "public, max-age=3600" in static_headers["Cache-Control"]
# API 路徑(預設不緩存)
api_headers = config.get_cache_headers('/api/feedback')
assert 'no-cache' in api_headers['Cache-Control']
api_headers = config.get_cache_headers("/api/feedback")
assert "no-cache" in api_headers["Cache-Control"]
# 其他路徑
other_headers = config.get_cache_headers('/feedback')
assert 'no-cache' in other_headers['Cache-Control']
other_headers = config.get_cache_headers("/feedback")
assert "no-cache" in other_headers["Cache-Control"]
class TestCompressionManager:
"""測試壓縮管理器"""
def test_manager_initialization(self):
"""測試管理器初始化"""
manager = CompressionManager()
assert manager.config is not None
assert manager._stats['requests_total'] == 0
assert manager._stats['requests_compressed'] == 0
assert manager._stats["requests_total"] == 0
assert manager._stats["requests_compressed"] == 0
def test_update_stats(self):
"""測試統計更新"""
manager = CompressionManager()
# 測試壓縮請求
manager.update_stats(1000, 600, True)
stats = manager.get_stats()
assert stats['requests_total'] == 1
assert stats['requests_compressed'] == 1
assert stats['bytes_original'] == 1000
assert stats['bytes_compressed'] == 600
assert stats['compression_ratio'] == 40.0 # (1000-600)/1000 * 100
assert stats["requests_total"] == 1
assert stats["requests_compressed"] == 1
assert stats["bytes_original"] == 1000
assert stats["bytes_compressed"] == 600
assert stats["compression_ratio"] == 40.0 # (1000-600)/1000 * 100
# 測試未壓縮請求
manager.update_stats(500, 500, False)
stats = manager.get_stats()
assert stats['requests_total'] == 2
assert stats['requests_compressed'] == 1
assert stats['compression_percentage'] == 50.0 # 1/2 * 100
assert stats["requests_total"] == 2
assert stats["requests_compressed"] == 1
assert stats["compression_percentage"] == 50.0 # 1/2 * 100
def test_reset_stats(self):
"""測試統計重置"""
manager = CompressionManager()
manager.update_stats(1000, 600, True)
manager.reset_stats()
stats = manager.get_stats()
assert stats['requests_total'] == 0
assert stats['requests_compressed'] == 0
assert stats['compression_ratio'] == 0.0
assert stats["requests_total"] == 0
assert stats["requests_compressed"] == 0
assert stats["compression_ratio"] == 0.0
class TestCompressionMonitor:
"""測試壓縮監控器"""
def test_monitor_initialization(self):
"""測試監控器初始化"""
monitor = CompressionMonitor()
assert monitor.max_metrics == 1000
assert len(monitor.metrics) == 0
assert len(monitor.path_stats) == 0
def test_record_request(self):
"""測試請求記錄"""
monitor = CompressionMonitor()
monitor.record_request(
path='/static/css/style.css',
path="/static/css/style.css",
original_size=2000,
compressed_size=1200,
response_time=0.05,
content_type='text/css',
was_compressed=True
content_type="text/css",
was_compressed=True,
)
assert len(monitor.metrics) == 1
metric = monitor.metrics[0]
assert metric.path == '/static/css/style.css'
assert metric.path == "/static/css/style.css"
assert metric.compression_ratio == 40.0 # (2000-1200)/2000 * 100
# 檢查路徑統計
path_stats = monitor.get_path_stats()
assert '/static/css/style.css' in path_stats
assert path_stats['/static/css/style.css']['requests'] == 1
assert path_stats['/static/css/style.css']['compressed_requests'] == 1
assert "/static/css/style.css" in path_stats
assert path_stats["/static/css/style.css"]["requests"] == 1
assert path_stats["/static/css/style.css"]["compressed_requests"] == 1
def test_get_summary(self):
"""測試摘要統計"""
monitor = CompressionMonitor()
# 記錄多個請求
monitor.record_request('/static/css/style.css', 2000, 1200, 0.05, 'text/css', True)
monitor.record_request('/static/js/app.js', 3000, 1800, 0.08, 'application/javascript', True)
monitor.record_request('/api/feedback', 500, 500, 0.02, 'application/json', False)
monitor.record_request(
"/static/css/style.css", 2000, 1200, 0.05, "text/css", True
)
monitor.record_request(
"/static/js/app.js", 3000, 1800, 0.08, "application/javascript", True
)
monitor.record_request(
"/api/feedback", 500, 500, 0.02, "application/json", False
)
summary = monitor.get_summary()
assert summary.total_requests == 3
assert summary.compressed_requests == 2
assert abs(summary.compression_percentage - 66.67) < 0.01 # 2/3 * 100 (約)
assert summary.bandwidth_saved == 2000 # (2000-1200) + (3000-1800) + 0 = 800 + 1200 + 0 = 2000
assert (
summary.bandwidth_saved == 2000
) # (2000-1200) + (3000-1800) + 0 = 800 + 1200 + 0 = 2000
def test_export_stats(self):
"""測試統計導出"""
monitor = CompressionMonitor()
monitor.record_request('/static/css/style.css', 2000, 1200, 0.05, 'text/css', True)
monitor.record_request(
"/static/css/style.css", 2000, 1200, 0.05, "text/css", True
)
exported = monitor.export_stats()
assert 'summary' in exported
assert 'top_compressed_paths' in exported
assert 'path_stats' in exported
assert 'content_type_stats' in exported
assert exported['summary']['total_requests'] == 1
assert exported['summary']['compressed_requests'] == 1
assert "summary" in exported
assert "top_compressed_paths" in exported
assert "path_stats" in exported
assert "content_type_stats" in exported
assert exported["summary"]["total_requests"] == 1
assert exported["summary"]["compressed_requests"] == 1
class TestGzipIntegration:
"""測試 Gzip 壓縮集成"""
def create_test_app(self):
"""創建測試應用"""
app = FastAPI()
# 添加 Gzip 中間件
app.add_middleware(GZipMiddleware, minimum_size=100)
@app.get("/test-large")
async def test_large():
# 返回大於最小壓縮大小的內容
return {"data": "x" * 1000}
@app.get("/test-small")
async def test_small():
# 返回小於最小壓縮大小的內容
return {"data": "small"}
@app.get("/test-html")
async def test_html():
html_content = "<html><body>" + "content " * 100 + "</body></html>"
return Response(content=html_content, media_type="text/html")
return app
def test_gzip_compression_large_content(self):
"""測試大內容的 Gzip 壓縮"""
app = self.create_test_app()
client = TestClient(app)
# 請求壓縮
response = client.get("/test-large", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers.get("content-encoding") == "gzip"
# 驗證內容正確性
data = response.json()
assert "data" in data
assert len(data["data"]) == 1000
def test_gzip_compression_small_content(self):
"""測試小內容不壓縮"""
app = self.create_test_app()
client = TestClient(app)
response = client.get("/test-small", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
# 小內容不應該被壓縮
assert response.headers.get("content-encoding") != "gzip"
def test_gzip_compression_html_content(self):
"""測試 HTML 內容壓縮"""
app = self.create_test_app()
client = TestClient(app)
response = client.get("/test-html", headers={"Accept-Encoding": "gzip"})
assert response.status_code == 200
assert response.headers.get("content-encoding") == "gzip"
assert response.headers.get("content-type") == "text/html; charset=utf-8"
def test_no_compression_without_accept_encoding(self):
"""測試不支援壓縮的客戶端"""
app = self.create_test_app()
@ -291,18 +306,20 @@ class TestGzipIntegration:
class TestWebSocketCompatibility:
"""測試 WebSocket 兼容性"""
def test_websocket_not_compressed(self):
"""測試 WebSocket 連接不受壓縮影響"""
# 這個測試確保 WebSocket 路徑被正確排除
config = CompressionConfig()
# WebSocket 路徑應該被排除
assert config.should_exclude_path('/ws') == True
assert config.should_exclude_path('/api/ws') == True
assert config.should_exclude_path("/ws") == True
assert config.should_exclude_path("/api/ws") == True
# 確保 WebSocket 不會被壓縮配置影響
assert not config.should_compress('application/json', 1000) or config.should_exclude_path('/ws')
assert not config.should_compress(
"application/json", 1000
) or config.should_exclude_path("/ws")
@pytest.mark.asyncio
@ -311,21 +328,21 @@ async def test_compression_performance():
# 創建測試數據
test_data = {"message": "test " * 1000} # 大約 5KB 的 JSON
json_data = json.dumps(test_data)
# 手動壓縮測試
compressed_data = gzip.compress(json_data.encode('utf-8'))
compressed_data = gzip.compress(json_data.encode("utf-8"))
# 驗證壓縮效果
original_size = len(json_data.encode('utf-8'))
original_size = len(json_data.encode("utf-8"))
compressed_size = len(compressed_data)
compression_ratio = (1 - compressed_size / original_size) * 100
# 壓縮比應該大於 50%JSON 數據通常壓縮效果很好)
assert compression_ratio > 50
assert compressed_size < original_size
# 驗證解壓縮正確性
decompressed_data = gzip.decompress(compressed_data).decode('utf-8')
decompressed_data = gzip.decompress(compressed_data).decode("utf-8")
assert decompressed_data == json_data
@ -335,7 +352,7 @@ def test_global_instances():
manager1 = get_compression_manager()
manager2 = get_compression_manager()
assert manager1 is manager2
# 測試壓縮監控器全域實例
monitor1 = get_compression_monitor()
monitor2 = get_compression_monitor()

View File

@ -1,37 +1,36 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
I18N 核心功能測試
"""
import pytest
import os
from pathlib import Path
import pytest
from tests.fixtures.test_data import TestData
class TestI18NManager:
"""I18N 管理器測試"""
def test_i18n_manager_creation(self, i18n_manager):
"""測試 I18N 管理器創建"""
assert i18n_manager is not None
assert hasattr(i18n_manager, '_current_language')
assert hasattr(i18n_manager, '_translations')
assert hasattr(i18n_manager, "_current_language")
assert hasattr(i18n_manager, "_translations")
assert i18n_manager.get_current_language() is not None
def test_supported_languages(self, i18n_manager):
"""測試支援的語言"""
supported_languages = i18n_manager.get_supported_languages()
# 驗證包含預期的語言
for lang in TestData.SUPPORTED_LANGUAGES:
assert lang in supported_languages
# 驗證至少有基本語言支援
assert len(supported_languages) >= 2
def test_language_switching(self, i18n_manager):
"""測試語言切換"""
original_language = i18n_manager.get_current_language()
@ -46,7 +45,7 @@ class TestI18NManager:
# 恢復原始語言
i18n_manager.set_language(original_language)
def test_invalid_language_switching(self, i18n_manager):
"""測試無效語言切換"""
original_language = i18n_manager.get_current_language()
@ -55,7 +54,7 @@ class TestI18NManager:
success = i18n_manager.set_language("invalid-lang")
assert success == False
assert i18n_manager.get_current_language() == original_language
def test_translation_function(self, i18n_manager):
"""測試翻譯函數"""
# 測試基本翻譯
@ -64,20 +63,22 @@ class TestI18NManager:
assert isinstance(translation, str)
assert len(translation) > 0
# 翻譯結果不應該等於 key除非是回退情況
if key in i18n_manager._translations.get(i18n_manager.get_current_language(), {}):
if key in i18n_manager._translations.get(
i18n_manager.get_current_language(), {}
):
assert translation != key
def test_translation_with_parameters(self, i18n_manager):
"""測試帶參數的翻譯"""
# 假設有帶參數的翻譯 key
test_key = "test.message.withParam"
test_params = {"name": "測試用戶", "count": 5}
# 即使 key 不存在,也應該返回合理的結果
translation = i18n_manager.t(test_key, **test_params)
assert isinstance(translation, str)
assert len(translation) > 0
def test_fallback_mechanism(self, i18n_manager):
"""測試回退機制"""
original_language = i18n_manager.get_current_language()
@ -101,45 +102,45 @@ class TestI18NManager:
class TestI18NTranslationCompleteness:
"""I18N 翻譯完整性測試"""
def test_all_languages_have_translations(self, i18n_manager):
"""測試所有語言都有翻譯文件"""
supported_languages = i18n_manager.get_supported_languages()
for lang in supported_languages:
translations = i18n_manager._translations.get(lang, {})
assert len(translations) > 0, f"語言 {lang} 沒有翻譯內容"
def test_key_consistency_across_languages(self, i18n_manager):
"""測試所有語言的 key 一致性"""
supported_languages = i18n_manager.get_supported_languages()
if len(supported_languages) < 2:
pytest.skip("需要至少兩種語言來測試一致性")
# 獲取所有語言的翻譯
all_translations = {}
for lang in supported_languages:
all_translations[lang] = i18n_manager._translations.get(lang, {})
# 獲取所有 key 的聯集
all_keys = set()
for translations in all_translations.values():
all_keys.update(self._get_all_keys(translations))
# 檢查每種語言是否有所有 key
missing_keys_report = {}
for lang in supported_languages:
missing_keys = []
lang_translations = all_translations[lang]
for key in all_keys:
if not self._has_key(lang_translations, key):
missing_keys.append(key)
if missing_keys:
missing_keys_report[lang] = missing_keys
# 如果有缺失的 key生成詳細報告
if missing_keys_report:
report_lines = ["翻譯 key 缺失報告:"]
@ -149,108 +150,106 @@ class TestI18NTranslationCompleteness:
report_lines.append(f" - {key}")
if len(missing_keys) > 5:
report_lines.append(f" ... 還有 {len(missing_keys) - 5}")
# 這裡我們記錄警告而不是失敗測試,因為某些 key 可能是特定語言的
print("\n".join(report_lines))
def test_common_keys_exist(self, i18n_manager):
"""測試常用 key 存在"""
common_keys = [
"common.submit",
"common.cancel",
"common.loading"
]
common_keys = ["common.submit", "common.cancel", "common.loading"]
supported_languages = i18n_manager.get_supported_languages()
for lang in supported_languages:
i18n_manager.set_language(lang)
for key in common_keys:
translation = i18n_manager.t(key)
# 翻譯應該存在且不為空
assert isinstance(translation, str)
assert len(translation.strip()) > 0
def _get_all_keys(self, translations: dict, prefix: str = "") -> set:
"""遞歸獲取所有翻譯 key"""
keys = set()
for key, value in translations.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
# 遞歸處理嵌套字典
keys.update(self._get_all_keys(value, full_key))
else:
# 葉子節點
keys.add(full_key)
return keys
def _has_key(self, translations: dict, key: str) -> bool:
"""檢查翻譯字典是否包含指定 key"""
keys = key.split('.')
keys = key.split(".")
current = translations
for k in keys:
if not isinstance(current, dict) or k not in current:
return False
current = current[k]
return True
class TestI18NEnvironmentDetection:
"""I18N 環境檢測測試"""
def test_language_detection_from_env(self, i18n_manager):
"""測試從環境變數檢測語言"""
original_lang = os.environ.get('LANG')
original_language = os.environ.get('LANGUAGE')
original_lang = os.environ.get("LANG")
original_language = os.environ.get("LANGUAGE")
try:
# 測試設置環境變數
os.environ['LANG'] = 'zh_TW.UTF-8'
os.environ["LANG"] = "zh_TW.UTF-8"
# 重新創建 I18N 管理器來測試環境檢測
from src.mcp_feedback_enhanced.i18n import I18nManager
test_manager = I18nManager()
# 應該檢測到繁體中文
detected_lang = test_manager._detect_language()
assert detected_lang in ['zh-TW', 'zh-CN', 'en'] # 應該是支援的語言之一
assert detected_lang in ["zh-TW", "zh-CN", "en"] # 應該是支援的語言之一
finally:
# 恢復環境變數
if original_lang is not None:
os.environ['LANG'] = original_lang
os.environ["LANG"] = original_lang
else:
os.environ.pop('LANG', None)
os.environ.pop("LANG", None)
if original_language is not None:
os.environ['LANGUAGE'] = original_language
os.environ["LANGUAGE"] = original_language
else:
os.environ.pop('LANGUAGE', None)
os.environ.pop("LANGUAGE", None)
def test_fallback_to_default_language(self, i18n_manager):
"""測試回退到默認語言"""
# 測試當系統語言不支援時的回退行為
original_lang = os.environ.get('LANG')
original_lang = os.environ.get("LANG")
try:
# 設置不支援的語言
os.environ['LANG'] = 'fr_FR.UTF-8' # 法語
os.environ["LANG"] = "fr_FR.UTF-8" # 法語
from src.mcp_feedback_enhanced.i18n import I18nManager
test_manager = I18nManager()
detected_lang = test_manager._detect_language()
# 應該回退到支援的語言
assert detected_lang in TestData.SUPPORTED_LANGUAGES
finally:
if original_lang is not None:
os.environ['LANG'] = original_lang
os.environ["LANG"] = original_lang
else:
os.environ.pop('LANG', None)
os.environ.pop("LANG", None)

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
內存監控系統測試
================
@ -11,21 +10,22 @@
- 統計和分析功能
"""
import pytest
import time
import threading
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
import pytest
from src.mcp_feedback_enhanced.utils.memory_monitor import (
MemoryMonitor, MemorySnapshot, MemoryAlert, MemoryStats,
get_memory_monitor
MemoryAlert,
MemoryMonitor,
MemorySnapshot,
get_memory_monitor,
)
class TestMemorySnapshot:
"""測試內存快照數據類"""
def test_memory_snapshot_creation(self):
"""測試內存快照創建"""
snapshot = MemorySnapshot(
@ -37,9 +37,9 @@ class TestMemorySnapshot:
process_rss=100 * 1024**2, # 100MB
process_vms=200 * 1024**2, # 200MB
process_percent=1.25,
gc_objects=10000
gc_objects=10000,
)
assert snapshot.system_total == 8 * 1024**3
assert snapshot.system_percent == 50.0
assert snapshot.process_rss == 100 * 1024**2
@ -48,7 +48,7 @@ class TestMemorySnapshot:
class TestMemoryAlert:
"""測試內存警告數據類"""
def test_memory_alert_creation(self):
"""測試內存警告創建"""
alert = MemoryAlert(
@ -56,9 +56,9 @@ class TestMemoryAlert:
message="內存使用率較高: 85.0%",
timestamp=datetime.now(),
memory_percent=85.0,
recommended_action="考慮執行輕量級清理"
recommended_action="考慮執行輕量級清理",
)
assert alert.level == "warning"
assert alert.memory_percent == 85.0
assert "85.0%" in alert.message
@ -66,16 +66,16 @@ class TestMemoryAlert:
class TestMemoryMonitor:
"""測試內存監控器"""
def test_monitor_initialization(self):
"""測試監控器初始化"""
monitor = MemoryMonitor(
warning_threshold=0.7,
critical_threshold=0.85,
emergency_threshold=0.95,
monitoring_interval=10
monitoring_interval=10,
)
assert monitor.warning_threshold == 0.7
assert monitor.critical_threshold == 0.85
assert monitor.emergency_threshold == 0.95
@ -83,8 +83,8 @@ class TestMemoryMonitor:
assert not monitor.is_monitoring
assert len(monitor.snapshots) == 0
assert len(monitor.alerts) == 0
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.psutil')
@patch("src.mcp_feedback_enhanced.utils.memory_monitor.psutil")
def test_collect_memory_snapshot(self, mock_psutil):
"""測試內存快照收集"""
# 模擬 psutil 返回值
@ -93,99 +93,95 @@ class TestMemoryMonitor:
mock_virtual_memory.available = 4 * 1024**3
mock_virtual_memory.used = 4 * 1024**3
mock_virtual_memory.percent = 50.0
mock_memory_info = Mock()
mock_memory_info.rss = 100 * 1024**2
mock_memory_info.vms = 200 * 1024**2
mock_process = Mock()
mock_process.memory_info.return_value = mock_memory_info
mock_process.memory_percent.return_value = 1.25
mock_psutil.virtual_memory.return_value = mock_virtual_memory
mock_psutil.Process.return_value = mock_process
monitor = MemoryMonitor()
snapshot = monitor._collect_memory_snapshot()
assert snapshot.system_total == 8 * 1024**3
assert snapshot.system_percent == 50.0
assert snapshot.process_rss == 100 * 1024**2
assert snapshot.process_percent == 1.25
def test_memory_status_classification(self):
"""測試內存狀態分類"""
monitor = MemoryMonitor(
warning_threshold=0.8,
critical_threshold=0.9,
emergency_threshold=0.95
warning_threshold=0.8, critical_threshold=0.9, emergency_threshold=0.95
)
assert monitor._get_memory_status(0.5) == "normal"
assert monitor._get_memory_status(0.85) == "warning"
assert monitor._get_memory_status(0.92) == "critical"
assert monitor._get_memory_status(0.97) == "emergency"
def test_callback_management(self):
"""測試回調函數管理"""
monitor = MemoryMonitor()
cleanup_callback = Mock()
alert_callback = Mock()
# 添加回調
monitor.add_cleanup_callback(cleanup_callback)
monitor.add_alert_callback(alert_callback)
assert cleanup_callback in monitor.cleanup_callbacks
assert alert_callback in monitor.alert_callbacks
# 移除回調
monitor.remove_cleanup_callback(cleanup_callback)
monitor.remove_alert_callback(alert_callback)
assert cleanup_callback not in monitor.cleanup_callbacks
assert alert_callback not in monitor.alert_callbacks
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.gc')
@patch("src.mcp_feedback_enhanced.utils.memory_monitor.gc")
def test_cleanup_triggering(self, mock_gc):
"""測試清理觸發"""
monitor = MemoryMonitor()
cleanup_callback = Mock()
monitor.add_cleanup_callback(cleanup_callback)
mock_gc.collect.return_value = 42
# 測試普通清理
monitor._trigger_cleanup()
assert monitor.cleanup_triggers_count == 1
cleanup_callback.assert_called_once()
mock_gc.collect.assert_called()
# 測試緊急清理
cleanup_callback.reset_mock()
mock_gc.collect.reset_mock()
monitor._trigger_emergency_cleanup()
# 緊急清理會調用多次垃圾回收
assert mock_gc.collect.call_count == 3
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.psutil')
@patch("src.mcp_feedback_enhanced.utils.memory_monitor.psutil")
def test_memory_usage_checking(self, mock_psutil):
"""測試內存使用檢查和警告觸發"""
monitor = MemoryMonitor(
warning_threshold=0.8,
critical_threshold=0.9,
emergency_threshold=0.95
warning_threshold=0.8, critical_threshold=0.9, emergency_threshold=0.95
)
alert_callback = Mock()
cleanup_callback = Mock()
monitor.add_alert_callback(alert_callback)
monitor.add_cleanup_callback(cleanup_callback)
# 模擬不同的內存使用情況
test_cases = [
(75.0, "normal", 0, 0), # 正常情況
@ -193,14 +189,19 @@ class TestMemoryMonitor:
(92.0, "critical", 1, 1), # 危險情況
(97.0, "emergency", 1, 1), # 緊急情況
]
for memory_percent, expected_status, expected_alerts, expected_cleanups in test_cases:
for (
memory_percent,
expected_status,
expected_alerts,
expected_cleanups,
) in test_cases:
# 重置計數器
alert_callback.reset_mock()
cleanup_callback.reset_mock()
monitor.alerts.clear()
monitor.cleanup_triggers_count = 0
# 創建模擬快照
snapshot = MemorySnapshot(
timestamp=datetime.now(),
@ -211,29 +212,29 @@ class TestMemoryMonitor:
process_rss=100 * 1024**2,
process_vms=200 * 1024**2,
process_percent=1.25,
gc_objects=10000
gc_objects=10000,
)
# 檢查內存使用
monitor._check_memory_usage(snapshot)
# 驗證結果
assert monitor._get_memory_status(memory_percent / 100.0) == expected_status
if expected_alerts > 0:
assert len(monitor.alerts) == expected_alerts
assert alert_callback.call_count == expected_alerts
if expected_cleanups > 0:
assert cleanup_callback.call_count == expected_cleanups
def test_memory_trend_analysis(self):
"""測試內存趨勢分析"""
monitor = MemoryMonitor()
# 測試數據不足的情況
assert monitor._analyze_memory_trend() == "insufficient_data"
# 添加穩定趨勢的快照
base_time = datetime.now()
for i in range(10):
@ -246,12 +247,12 @@ class TestMemoryMonitor:
process_rss=100 * 1024**2,
process_vms=200 * 1024**2,
process_percent=1.25,
gc_objects=10000
gc_objects=10000,
)
monitor.snapshots.append(snapshot)
assert monitor._analyze_memory_trend() == "stable"
# 清空並添加遞增趨勢的快照
monitor.snapshots.clear()
for i in range(10):
@ -264,13 +265,13 @@ class TestMemoryMonitor:
process_rss=100 * 1024**2,
process_vms=200 * 1024**2,
process_percent=1.25,
gc_objects=10000
gc_objects=10000,
)
monitor.snapshots.append(snapshot)
assert monitor._analyze_memory_trend() == "increasing"
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.psutil')
@patch("src.mcp_feedback_enhanced.utils.memory_monitor.psutil")
def test_get_current_memory_info(self, mock_psutil):
"""測試獲取當前內存信息"""
# 模擬 psutil 返回值
@ -279,33 +280,33 @@ class TestMemoryMonitor:
mock_virtual_memory.available = 4 * 1024**3
mock_virtual_memory.used = 4 * 1024**3
mock_virtual_memory.percent = 50.0
mock_memory_info = Mock()
mock_memory_info.rss = 100 * 1024**2
mock_memory_info.vms = 200 * 1024**2
mock_process = Mock()
mock_process.memory_info.return_value = mock_memory_info
mock_process.memory_percent.return_value = 1.25
mock_psutil.virtual_memory.return_value = mock_virtual_memory
mock_psutil.Process.return_value = mock_process
monitor = MemoryMonitor()
info = monitor.get_current_memory_info()
assert "system" in info
assert "process" in info
assert info["system"]["total_gb"] == 8.0
assert info["system"]["usage_percent"] == 50.0
assert info["process"]["rss_mb"] == 100.0
assert info["status"] == "normal"
def test_memory_stats_calculation(self):
"""測試內存統計計算"""
monitor = MemoryMonitor()
monitor.start_time = datetime.now() - timedelta(minutes=5)
# 添加一些測試快照
base_time = datetime.now()
for i in range(5):
@ -318,23 +319,25 @@ class TestMemoryMonitor:
process_rss=100 * 1024**2,
process_vms=200 * 1024**2,
process_percent=1.0 + i * 0.2, # 1.0%, 1.2%, 1.4%, 1.6%, 1.8%
gc_objects=10000
gc_objects=10000,
)
monitor.snapshots.append(snapshot)
# 添加一些警告
monitor.alerts.append(MemoryAlert(
level="warning",
message="Test warning",
timestamp=datetime.now(),
memory_percent=85.0,
recommended_action="Test action"
))
monitor.alerts.append(
MemoryAlert(
level="warning",
message="Test warning",
timestamp=datetime.now(),
memory_percent=85.0,
recommended_action="Test action",
)
)
monitor.cleanup_triggers_count = 2
stats = monitor.get_memory_stats()
assert stats.snapshots_count == 5
assert stats.average_system_usage == 60.0 # (50+55+60+65+70)/5
assert stats.peak_system_usage == 70.0
@ -343,34 +346,36 @@ class TestMemoryMonitor:
assert stats.alerts_count == 1
assert stats.cleanup_triggers == 2
assert stats.monitoring_duration > 0
def test_export_memory_data(self):
"""測試內存數據導出"""
monitor = MemoryMonitor()
# 添加一些測試數據
monitor.alerts.append(MemoryAlert(
level="warning",
message="Test warning",
timestamp=datetime.now(),
memory_percent=85.0,
recommended_action="Test action"
))
with patch.object(monitor, 'get_current_memory_info') as mock_info:
monitor.alerts.append(
MemoryAlert(
level="warning",
message="Test warning",
timestamp=datetime.now(),
memory_percent=85.0,
recommended_action="Test action",
)
)
with patch.object(monitor, "get_current_memory_info") as mock_info:
mock_info.return_value = {
"system": {"usage_percent": 75.0},
"status": "warning"
"status": "warning",
}
exported_data = monitor.export_memory_data()
assert "config" in exported_data
assert "current_info" in exported_data
assert "stats" in exported_data
assert "recent_alerts" in exported_data
assert "is_monitoring" in exported_data
assert exported_data["config"]["warning_threshold"] == 0.8
assert len(exported_data["recent_alerts"]) == 1
@ -379,7 +384,7 @@ def test_global_memory_monitor_singleton():
"""測試全域內存監控器單例模式"""
monitor1 = get_memory_monitor()
monitor2 = get_memory_monitor()
assert monitor1 is monitor2
assert isinstance(monitor1, MemoryMonitor)

View File

@ -7,226 +7,226 @@
- 增強端口查找
"""
import pytest
import socket
import time
import threading
import subprocess
import sys
from unittest.mock import patch, MagicMock
import time
from unittest.mock import patch
import pytest
# 添加 src 目錄到 Python 路徑
sys.path.insert(0, 'src')
sys.path.insert(0, "src")
from mcp_feedback_enhanced.web.utils.port_manager import PortManager
class TestPortManager:
"""端口管理器測試類"""
def test_is_port_available_free_port(self):
"""測試檢測空閒端口"""
# 找一個肯定空閒的端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
# 測試該端口是否被檢測為可用
assert PortManager.is_port_available('127.0.0.1', free_port) is True
assert PortManager.is_port_available("127.0.0.1", free_port) is True
def test_is_port_available_occupied_port(self):
"""測試檢測被占用的端口"""
# 創建一個占用端口的 socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 0))
server_socket.bind(("127.0.0.1", 0))
occupied_port = server_socket.getsockname()[1]
server_socket.listen(1)
try:
# 測試該端口是否被檢測為不可用
assert PortManager.is_port_available('127.0.0.1', occupied_port) is False
assert PortManager.is_port_available("127.0.0.1", occupied_port) is False
finally:
server_socket.close()
def test_find_free_port_enhanced_preferred_available(self):
"""測試當偏好端口可用時的行為"""
# 找一個空閒端口作為偏好端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.bind(("127.0.0.1", 0))
preferred_port = s.getsockname()[1]
# 測試是否返回偏好端口
result_port = PortManager.find_free_port_enhanced(
preferred_port=preferred_port,
auto_cleanup=False
preferred_port=preferred_port, auto_cleanup=False
)
assert result_port == preferred_port
def test_find_free_port_enhanced_preferred_occupied(self):
"""測試當偏好端口被占用時的行為"""
# 創建一個占用端口的 socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 0))
server_socket.bind(("127.0.0.1", 0))
occupied_port = server_socket.getsockname()[1]
server_socket.listen(1)
try:
# 測試是否返回其他可用端口
result_port = PortManager.find_free_port_enhanced(
preferred_port=occupied_port,
auto_cleanup=False
preferred_port=occupied_port, auto_cleanup=False
)
assert result_port != occupied_port
assert result_port > occupied_port # 應該向上查找
# 驗證返回的端口確實可用
assert PortManager.is_port_available('127.0.0.1', result_port) is True
assert PortManager.is_port_available("127.0.0.1", result_port) is True
finally:
server_socket.close()
def test_find_process_using_port_no_process(self):
"""測試查找沒有進程占用的端口"""
# 找一個空閒端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
# 測試是否正確返回 None
result = PortManager.find_process_using_port(free_port)
assert result is None
def test_find_process_using_port_with_process(self):
"""測試查找有進程占用的端口"""
# 創建一個簡單的測試服務器
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 0))
server_socket.bind(("127.0.0.1", 0))
test_port = server_socket.getsockname()[1]
server_socket.listen(1)
try:
# 測試是否能找到進程信息
result = PortManager.find_process_using_port(test_port)
if result: # 如果找到了進程(在某些環境下可能找不到)
assert isinstance(result, dict)
assert 'pid' in result
assert 'name' in result
assert 'cmdline' in result
assert isinstance(result['pid'], int)
assert result['pid'] > 0
assert "pid" in result
assert "name" in result
assert "cmdline" in result
assert isinstance(result["pid"], int)
assert result["pid"] > 0
finally:
server_socket.close()
def test_get_port_status_available(self):
"""測試獲取可用端口的狀態"""
# 找一個空閒端口
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('127.0.0.1', 0))
s.bind(("127.0.0.1", 0))
free_port = s.getsockname()[1]
status = PortManager.get_port_status(free_port)
assert status['port'] == free_port
assert status['host'] == '127.0.0.1'
assert status['available'] is True
assert status['process'] is None
assert status['error'] is None
assert status["port"] == free_port
assert status["host"] == "127.0.0.1"
assert status["available"] is True
assert status["process"] is None
assert status["error"] is None
def test_get_port_status_occupied(self):
"""測試獲取被占用端口的狀態"""
# 創建一個占用端口的 socket
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 0))
server_socket.bind(("127.0.0.1", 0))
occupied_port = server_socket.getsockname()[1]
server_socket.listen(1)
try:
status = PortManager.get_port_status(occupied_port)
assert status['port'] == occupied_port
assert status['host'] == '127.0.0.1'
assert status['available'] is False
assert status["port"] == occupied_port
assert status["host"] == "127.0.0.1"
assert status["available"] is False
# process 可能為 None取決於系統權限
assert status['error'] is None
assert status["error"] is None
finally:
server_socket.close()
def test_list_listening_ports(self):
"""測試列出監聽端口"""
# 創建幾個測試服務器
servers = []
test_ports = []
try:
for i in range(2):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('127.0.0.1', 0))
server_socket.bind(("127.0.0.1", 0))
port = server_socket.getsockname()[1]
server_socket.listen(1)
servers.append(server_socket)
test_ports.append(port)
# 測試列出監聽端口
min_port = min(test_ports) - 10
max_port = max(test_ports) + 10
listening_ports = PortManager.list_listening_ports(min_port, max_port)
# 驗證結果
assert isinstance(listening_ports, list)
# 檢查我們的測試端口是否在列表中
found_ports = [p['port'] for p in listening_ports]
found_ports = [p["port"] for p in listening_ports]
for test_port in test_ports:
if test_port in found_ports:
# 找到了我們的端口,驗證信息完整性
port_info = next(p for p in listening_ports if p['port'] == test_port)
assert 'host' in port_info
assert 'pid' in port_info
assert 'process_name' in port_info
assert 'cmdline' in port_info
port_info = next(
p for p in listening_ports if p["port"] == test_port
)
assert "host" in port_info
assert "pid" in port_info
assert "process_name" in port_info
assert "cmdline" in port_info
finally:
# 清理測試服務器
for server in servers:
server.close()
@patch('mcp_feedback_enhanced.web.utils.port_manager.psutil.Process')
@patch("mcp_feedback_enhanced.web.utils.port_manager.psutil.Process")
def test_should_cleanup_process_mcp_process(self, mock_process):
"""測試是否應該清理 MCP 相關進程"""
# 模擬 MCP 相關進程
process_info = {
'pid': 1234,
'name': 'python.exe',
'cmdline': 'python -m mcp-feedback-enhanced test --web',
'create_time': time.time(),
'status': 'running'
"pid": 1234,
"name": "python.exe",
"cmdline": "python -m mcp-feedback-enhanced test --web",
"create_time": time.time(),
"status": "running",
}
result = PortManager._should_cleanup_process(process_info)
assert result is True
@patch('mcp_feedback_enhanced.web.utils.port_manager.psutil.Process')
@patch("mcp_feedback_enhanced.web.utils.port_manager.psutil.Process")
def test_should_cleanup_process_other_process(self, mock_process):
"""測試是否應該清理其他進程"""
# 模擬其他進程
process_info = {
'pid': 5678,
'name': 'chrome.exe',
'cmdline': 'chrome --new-window',
'create_time': time.time(),
'status': 'running'
"pid": 5678,
"name": "chrome.exe",
"cmdline": "chrome --new-window",
"create_time": time.time(),
"status": "running",
}
result = PortManager._should_cleanup_process(process_info)
assert result is False
def test_find_free_port_enhanced_max_attempts(self):
"""測試最大嘗試次數限制"""
# 這個測試比較難實現,因為需要占用大量連續端口
@ -235,7 +235,7 @@ class TestPortManager:
result = PortManager.find_free_port_enhanced(
preferred_port=65000, # 使用高端口減少衝突
auto_cleanup=False,
max_attempts=10
max_attempts=10,
)
assert isinstance(result, int)
assert 65000 <= result <= 65535
@ -244,6 +244,6 @@ class TestPortManager:
pass
if __name__ == '__main__':
if __name__ == "__main__":
# 運行測試
pytest.main([__file__, '-v'])
pytest.main([__file__, "-v"])

View File

@ -8,80 +8,78 @@
- 資源統計和監控
"""
import pytest
import os
import subprocess
import sys
import time
import tempfile
import subprocess
import threading
from pathlib import Path
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
# 添加 src 目錄到 Python 路徑
sys.path.insert(0, 'src')
sys.path.insert(0, "src")
from mcp_feedback_enhanced.utils.resource_manager import (
ResourceManager,
get_resource_manager,
create_temp_file,
ResourceManager,
cleanup_all_resources,
create_temp_dir,
register_process,
cleanup_all_resources
create_temp_file,
get_resource_manager,
)
class TestResourceManager:
"""資源管理器測試類"""
def setup_method(self):
"""每個測試方法前的設置"""
# 重置單例實例
ResourceManager._instance = None
def test_singleton_pattern(self):
"""測試單例模式"""
rm1 = ResourceManager()
rm2 = ResourceManager()
rm3 = get_resource_manager()
assert rm1 is rm2
assert rm2 is rm3
assert id(rm1) == id(rm2) == id(rm3)
def test_create_temp_file(self):
"""測試創建臨時文件"""
rm = get_resource_manager()
# 測試基本創建
temp_file = rm.create_temp_file(suffix=".txt", prefix="test_")
assert isinstance(temp_file, str)
assert os.path.exists(temp_file)
assert temp_file.endswith(".txt")
assert "test_" in os.path.basename(temp_file)
assert temp_file in rm.temp_files
# 清理
os.remove(temp_file)
def test_create_temp_dir(self):
"""測試創建臨時目錄"""
rm = get_resource_manager()
# 測試基本創建
temp_dir = rm.create_temp_dir(suffix="_test", prefix="test_")
assert isinstance(temp_dir, str)
assert os.path.exists(temp_dir)
assert os.path.isdir(temp_dir)
assert temp_dir.endswith("_test")
assert "test_" in os.path.basename(temp_dir)
assert temp_dir in rm.temp_dirs
# 清理
os.rmdir(temp_dir)
def test_convenience_functions(self):
"""測試便捷函數"""
# 測試 create_temp_file 便捷函數
@ -89,93 +87,93 @@ class TestResourceManager:
assert isinstance(temp_file, str)
assert os.path.exists(temp_file)
assert temp_file.endswith(".log")
# 測試 create_temp_dir 便捷函數
temp_dir = create_temp_dir(suffix="_conv", prefix="conv_")
assert isinstance(temp_dir, str)
assert os.path.exists(temp_dir)
assert os.path.isdir(temp_dir)
# 清理
os.remove(temp_file)
os.rmdir(temp_dir)
def test_register_process_with_popen(self):
"""測試註冊 Popen 進程"""
rm = get_resource_manager()
# 創建一個簡單的進程
process = subprocess.Popen(
["python", "-c", "import time; time.sleep(0.1)"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
stderr=subprocess.PIPE,
)
# 註冊進程
pid = rm.register_process(process, description="測試進程")
assert pid == process.pid
assert pid in rm.processes
assert rm.processes[pid]["description"] == "測試進程"
assert rm.processes[pid]["process"] is process
# 等待進程結束
process.wait()
def test_register_process_with_pid(self):
"""測試註冊 PID"""
rm = get_resource_manager()
# 使用當前進程的 PID
current_pid = os.getpid()
# 註冊 PID
registered_pid = rm.register_process(current_pid, description="當前進程")
assert registered_pid == current_pid
assert current_pid in rm.processes
assert rm.processes[current_pid]["description"] == "當前進程"
assert rm.processes[current_pid]["process"] is None
def test_unregister_temp_file(self):
"""測試取消臨時文件追蹤"""
rm = get_resource_manager()
# 創建臨時文件
temp_file = rm.create_temp_file()
assert temp_file in rm.temp_files
# 取消追蹤
result = rm.unregister_temp_file(temp_file)
assert result is True
assert temp_file not in rm.temp_files
# 再次取消追蹤(應該返回 False
result = rm.unregister_temp_file(temp_file)
assert result is False
# 清理
if os.path.exists(temp_file):
os.remove(temp_file)
def test_unregister_process(self):
"""測試取消進程追蹤"""
rm = get_resource_manager()
# 註冊進程
current_pid = os.getpid()
rm.register_process(current_pid, description="測試進程")
assert current_pid in rm.processes
# 取消追蹤
result = rm.unregister_process(current_pid)
assert result is True
assert current_pid not in rm.processes
# 再次取消追蹤(應該返回 False
result = rm.unregister_process(current_pid)
assert result is False
def test_cleanup_temp_files(self):
"""測試清理臨時文件"""
rm = get_resource_manager()
@ -192,7 +190,6 @@ class TestResourceManager:
assert temp_file in rm.temp_files
# 等待一小段時間讓文件有年齡
import time
time.sleep(0.1)
# 執行清理max_age=0 清理所有文件)
@ -202,30 +199,30 @@ class TestResourceManager:
for temp_file in temp_files:
assert not os.path.exists(temp_file)
assert temp_file not in rm.temp_files
def test_cleanup_temp_dirs(self):
"""測試清理臨時目錄"""
rm = get_resource_manager()
# 創建多個臨時目錄
temp_dirs = []
for i in range(2):
temp_dir = rm.create_temp_dir(prefix=f"cleanup_test_{i}_")
temp_dirs.append(temp_dir)
# 確認目錄都存在
for temp_dir in temp_dirs:
assert os.path.exists(temp_dir)
assert temp_dir in rm.temp_dirs
# 執行清理
cleaned_count = rm.cleanup_temp_dirs()
assert cleaned_count == 2
for temp_dir in temp_dirs:
assert not os.path.exists(temp_dir)
assert temp_dir not in rm.temp_dirs
def test_cleanup_all(self):
"""測試全面清理"""
rm = get_resource_manager()
@ -239,7 +236,6 @@ class TestResourceManager:
rm.register_process(current_pid, description="測試進程", auto_cleanup=False)
# 等待一小段時間讓文件有年齡
import time
time.sleep(0.1)
# 執行全面清理
@ -259,19 +255,19 @@ class TestResourceManager:
# 進程不應該被清理auto_cleanup=False
assert current_pid in rm.processes
def test_get_resource_stats(self):
"""測試獲取資源統計"""
rm = get_resource_manager()
# 創建一些資源
temp_file = rm.create_temp_file()
temp_dir = rm.create_temp_dir()
rm.register_process(os.getpid(), description="統計測試")
# 獲取統計
stats = rm.get_resource_stats()
assert isinstance(stats, dict)
assert "current_temp_files" in stats
assert "current_temp_dirs" in stats
@ -279,124 +275,122 @@ class TestResourceManager:
assert "temp_files_created" in stats
assert "temp_dirs_created" in stats
assert "auto_cleanup_enabled" in stats
assert stats["current_temp_files"] >= 1
assert stats["current_temp_dirs"] >= 1
assert stats["current_processes"] >= 1
# 清理
os.remove(temp_file)
os.rmdir(temp_dir)
def test_get_detailed_info(self):
"""測試獲取詳細信息"""
rm = get_resource_manager()
# 創建一些資源
temp_file = rm.create_temp_file(prefix="detail_test_")
rm.register_process(os.getpid(), description="詳細信息測試")
# 獲取詳細信息
info = rm.get_detailed_info()
assert isinstance(info, dict)
assert "temp_files" in info
assert "temp_dirs" in info
assert "processes" in info
assert "stats" in info
assert temp_file in info["temp_files"]
assert os.getpid() in info["processes"]
assert info["processes"][os.getpid()]["description"] == "詳細信息測試"
# 清理
os.remove(temp_file)
def test_configure(self):
"""測試配置功能"""
rm = get_resource_manager()
# 測試配置更新
rm.configure(
auto_cleanup_enabled=False,
cleanup_interval=120,
temp_file_max_age=1800
auto_cleanup_enabled=False, cleanup_interval=120, temp_file_max_age=1800
)
assert rm.auto_cleanup_enabled is False
assert rm.cleanup_interval == 120
assert rm.temp_file_max_age == 1800
# 測試最小值限制
rm.configure(
cleanup_interval=30, # 小於最小值 60
temp_file_max_age=100 # 小於最小值 300
temp_file_max_age=100, # 小於最小值 300
)
assert rm.cleanup_interval == 60 # 應該被限制為最小值
assert rm.temp_file_max_age == 300 # 應該被限制為最小值
def test_cleanup_all_convenience_function(self):
"""測試全面清理便捷函數"""
# 創建一些資源
temp_file = create_temp_file(prefix="conv_cleanup_")
temp_dir = create_temp_dir(prefix="conv_cleanup_")
# 執行清理
results = cleanup_all_resources()
assert isinstance(results, dict)
assert not os.path.exists(temp_file)
assert not os.path.exists(temp_dir)
def test_error_handling(self):
"""測試錯誤處理"""
rm = get_resource_manager()
# 測試創建臨時文件時的錯誤處理
with patch('tempfile.mkstemp', side_effect=OSError("Mock error")):
with patch("tempfile.mkstemp", side_effect=OSError("Mock error")):
with pytest.raises(OSError):
rm.create_temp_file()
# 測試創建臨時目錄時的錯誤處理
with patch('tempfile.mkdtemp', side_effect=OSError("Mock error")):
with patch("tempfile.mkdtemp", side_effect=OSError("Mock error")):
with pytest.raises(OSError):
rm.create_temp_dir()
def test_file_handle_registration(self):
"""測試文件句柄註冊"""
rm = get_resource_manager()
# 創建一個文件句柄
temp_file = rm.create_temp_file()
with open(temp_file, 'w') as f:
with open(temp_file, "w") as f:
f.write("test")
rm.register_file_handle(f)
# 檢查是否註冊成功
assert len(rm.file_handles) > 0
# 清理
os.remove(temp_file)
def test_auto_cleanup_thread(self):
"""測試自動清理線程"""
rm = get_resource_manager()
# 確保自動清理已啟動
assert rm.auto_cleanup_enabled is True
assert rm._cleanup_thread is not None
assert rm._cleanup_thread.is_alive()
# 測試停止自動清理
rm.stop_auto_cleanup()
assert rm._cleanup_thread is None
# 重新啟動
rm.configure(auto_cleanup_enabled=True)
assert rm._cleanup_thread is not None
if __name__ == '__main__':
if __name__ == "__main__":
# 運行測試
pytest.main([__file__, '-v'])
pytest.main([__file__, "-v"])

View File

@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
會話清理優化測試
================
@ -8,50 +7,54 @@
"""
import asyncio
import pytest
import time
import threading
from unittest.mock import Mock, patch, MagicMock
from datetime import datetime, timedelta
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import sys
import time
from unittest.mock import Mock
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.mcp_feedback_enhanced.web.models.feedback_session import (
WebFeedbackSession, SessionStatus, CleanupReason
CleanupReason,
SessionStatus,
WebFeedbackSession,
)
from src.mcp_feedback_enhanced.web.utils.session_cleanup_manager import (
SessionCleanupManager, CleanupPolicy, CleanupTrigger
CleanupPolicy,
CleanupTrigger,
SessionCleanupManager,
)
class TestWebFeedbackSessionCleanup:
"""測試 WebFeedbackSession 清理功能"""
def setup_method(self):
"""測試前設置"""
self.session_id = "test_session_001"
self.project_dir = "/tmp/test_project"
self.summary = "測試會話摘要"
# 創建測試會話
self.session = WebFeedbackSession(
self.session_id,
self.project_dir,
self.session_id,
self.project_dir,
self.summary,
auto_cleanup_delay=60, # 1分鐘自動清理
max_idle_time=30 # 30秒最大空閒時間
max_idle_time=30, # 30秒最大空閒時間
)
def teardown_method(self):
"""測試後清理"""
if hasattr(self, 'session') and self.session:
if hasattr(self, "session") and self.session:
try:
self.session._cleanup_sync_enhanced(CleanupReason.MANUAL)
except:
pass
def test_session_initialization(self):
"""測試會話初始化"""
assert self.session.session_id == self.session_id
@ -62,81 +65,81 @@ class TestWebFeedbackSessionCleanup:
assert self.session.max_idle_time == 30
assert self.session.cleanup_timer is not None
assert len(self.session.cleanup_stats) > 0
def test_is_expired_by_idle_time(self):
"""測試空閒時間過期檢測"""
# 新創建的會話不應該過期
assert not self.session.is_expired()
# 模擬空閒時間過長
self.session.last_activity = time.time() - 40 # 40秒前
assert self.session.is_expired()
def test_is_expired_by_status(self):
"""測試狀態過期檢測"""
# 設置為錯誤狀態
self.session.status = SessionStatus.ERROR
self.session.last_activity = time.time() - 400 # 400秒前
assert self.session.is_expired()
# 設置為已過期狀態
self.session.status = SessionStatus.EXPIRED
assert self.session.is_expired()
def test_get_age_and_idle_time(self):
"""測試年齡和空閒時間計算"""
# 測試年齡
age = self.session.get_age()
assert age >= 0
assert age < 1 # 剛創建應該小於1秒
# 測試空閒時間
idle_time = self.session.get_idle_time()
assert idle_time >= 0
assert idle_time < 1 # 剛創建應該小於1秒
def test_cleanup_timer_scheduling(self):
"""測試清理定時器調度"""
# 檢查定時器是否已設置
assert self.session.cleanup_timer is not None
assert self.session.cleanup_timer.is_alive()
# 測試延長定時器
old_timer = self.session.cleanup_timer
self.session.extend_cleanup_timer(120)
# 應該創建新的定時器
assert self.session.cleanup_timer != old_timer
assert self.session.cleanup_timer.is_alive()
def test_cleanup_callbacks(self):
"""測試清理回調函數"""
callback_called = False
callback_session = None
callback_reason = None
def test_callback(session, reason):
nonlocal callback_called, callback_session, callback_reason
callback_called = True
callback_session = session
callback_reason = reason
# 添加回調
self.session.add_cleanup_callback(test_callback)
assert len(self.session.cleanup_callbacks) == 1
# 執行清理
self.session._cleanup_sync_enhanced(CleanupReason.MANUAL)
# 檢查回調是否被調用
assert callback_called
assert callback_session == self.session
assert callback_reason == CleanupReason.MANUAL
# 移除回調
self.session.remove_cleanup_callback(test_callback)
assert len(self.session.cleanup_callbacks) == 0
def test_cleanup_stats(self):
"""測試清理統計"""
# 初始統計
@ -144,17 +147,17 @@ class TestWebFeedbackSessionCleanup:
assert stats["cleanup_count"] == 0
assert stats["session_id"] == self.session_id
assert stats["is_active"] == True
# 執行清理
self.session._cleanup_sync_enhanced(CleanupReason.EXPIRED)
# 檢查統計更新
stats = self.session.get_cleanup_stats()
assert stats["cleanup_count"] == 1
assert stats["cleanup_reason"] == CleanupReason.EXPIRED.value
assert stats["last_cleanup_time"] is not None
assert stats["cleanup_duration"] >= 0
@pytest.mark.asyncio
async def test_async_cleanup(self):
"""測試異步清理"""
@ -165,27 +168,27 @@ class TestWebFeedbackSessionCleanup:
mock_websocket.close = Mock(return_value=asyncio.Future())
mock_websocket.close.return_value.set_result(None)
mock_websocket.client_state.DISCONNECTED = False
self.session.websocket = mock_websocket
# 執行異步清理
await self.session._cleanup_resources_enhanced(CleanupReason.TIMEOUT)
# 檢查 WebSocket 是否被正確處理
mock_websocket.send_json.assert_called_once()
# 檢查清理統計
stats = self.session.get_cleanup_stats()
assert stats["cleanup_count"] == 1
assert stats["cleanup_reason"] == CleanupReason.TIMEOUT.value
def test_status_update_resets_timer(self):
"""測試狀態更新重置定時器"""
old_timer = self.session.cleanup_timer
# 更新狀態為活躍
self.session.update_status(SessionStatus.ACTIVE, "測試活躍狀態")
# 檢查定時器是否被重置
assert self.session.cleanup_timer != old_timer
assert self.session.cleanup_timer.is_alive()
@ -194,7 +197,7 @@ class TestWebFeedbackSessionCleanup:
class TestSessionCleanupManager:
"""測試 SessionCleanupManager 功能"""
def setup_method(self):
"""測試前設置"""
# 創建模擬的 WebUIManager
@ -202,28 +205,29 @@ class TestSessionCleanupManager:
self.mock_web_ui_manager.sessions = {}
self.mock_web_ui_manager.current_session = None
self.mock_web_ui_manager.cleanup_expired_sessions = Mock(return_value=0)
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure = Mock(return_value=0)
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure = Mock(
return_value=0
)
# 創建清理策略
self.policy = CleanupPolicy(
max_idle_time=30,
max_session_age=300,
max_sessions=5,
cleanup_interval=10,
enable_auto_cleanup=True
enable_auto_cleanup=True,
)
# 創建清理管理器
self.cleanup_manager = SessionCleanupManager(
self.mock_web_ui_manager,
self.policy
self.mock_web_ui_manager, self.policy
)
def teardown_method(self):
"""測試後清理"""
if hasattr(self, 'cleanup_manager'):
if hasattr(self, "cleanup_manager"):
self.cleanup_manager.stop_auto_cleanup()
def test_cleanup_manager_initialization(self):
"""測試清理管理器初始化"""
assert self.cleanup_manager.web_ui_manager == self.mock_web_ui_manager
@ -232,7 +236,7 @@ class TestSessionCleanupManager:
assert self.cleanup_manager.cleanup_thread is None
assert len(self.cleanup_manager.cleanup_callbacks) == 0
assert len(self.cleanup_manager.cleanup_history) == 0
def test_auto_cleanup_start_stop(self):
"""測試自動清理啟動和停止"""
# 啟動自動清理
@ -241,48 +245,52 @@ class TestSessionCleanupManager:
assert self.cleanup_manager.is_running == True
assert self.cleanup_manager.cleanup_thread is not None
assert self.cleanup_manager.cleanup_thread.is_alive()
# 停止自動清理
result = self.cleanup_manager.stop_auto_cleanup()
assert result == True
assert self.cleanup_manager.is_running == False
def test_trigger_cleanup_memory_pressure(self):
"""測試內存壓力清理觸發"""
# 設置模擬返回值
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure.return_value = 3
# 觸發內存壓力清理
cleaned = self.cleanup_manager.trigger_cleanup(CleanupTrigger.MEMORY_PRESSURE, force=True)
cleaned = self.cleanup_manager.trigger_cleanup(
CleanupTrigger.MEMORY_PRESSURE, force=True
)
# 檢查結果
assert cleaned == 3
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure.assert_called_once_with(True)
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure.assert_called_once_with(
True
)
# 檢查統計更新
stats = self.cleanup_manager.get_cleanup_statistics()
assert stats["total_cleanups"] == 1
assert stats["memory_pressure_cleanups"] == 1
assert stats["total_sessions_cleaned"] == 3
def test_trigger_cleanup_expired(self):
"""測試過期清理觸發"""
# 設置模擬返回值
self.mock_web_ui_manager.cleanup_expired_sessions.return_value = 2
# 觸發過期清理
cleaned = self.cleanup_manager.trigger_cleanup(CleanupTrigger.EXPIRED)
# 檢查結果
assert cleaned == 2
self.mock_web_ui_manager.cleanup_expired_sessions.assert_called_once()
# 檢查統計更新
stats = self.cleanup_manager.get_cleanup_statistics()
assert stats["total_cleanups"] == 1
assert stats["expired_cleanups"] == 1
assert stats["total_sessions_cleaned"] == 2
def test_cleanup_statistics(self):
"""測試清理統計功能"""
# 初始統計
@ -290,14 +298,14 @@ class TestSessionCleanupManager:
assert stats["total_cleanups"] == 0
assert stats["total_sessions_cleaned"] == 0
assert stats["is_auto_cleanup_running"] == False
# 執行一些清理操作
self.mock_web_ui_manager.cleanup_expired_sessions.return_value = 1
self.cleanup_manager.trigger_cleanup(CleanupTrigger.EXPIRED)
self.mock_web_ui_manager.cleanup_sessions_by_memory_pressure.return_value = 2
self.cleanup_manager.trigger_cleanup(CleanupTrigger.MEMORY_PRESSURE)
# 檢查統計
stats = self.cleanup_manager.get_cleanup_statistics()
assert stats["total_cleanups"] == 2
@ -305,59 +313,57 @@ class TestSessionCleanupManager:
assert stats["memory_pressure_cleanups"] == 1
assert stats["total_sessions_cleaned"] == 3
assert stats["average_cleanup_time"] >= 0
def test_cleanup_history(self):
"""測試清理歷史記錄"""
# 初始歷史為空
history = self.cleanup_manager.get_cleanup_history()
assert len(history) == 0
# 執行清理操作
self.mock_web_ui_manager.cleanup_expired_sessions.return_value = 1
self.cleanup_manager.trigger_cleanup(CleanupTrigger.EXPIRED)
# 檢查歷史記錄
history = self.cleanup_manager.get_cleanup_history()
assert len(history) == 1
record = history[0]
assert record["trigger"] == CleanupTrigger.EXPIRED.value
assert record["cleaned_count"] == 1
assert "timestamp" in record
assert "duration" in record
def test_policy_update(self):
"""測試策略更新"""
# 更新策略
self.cleanup_manager.update_policy(
max_idle_time=60,
max_sessions=10,
enable_auto_cleanup=False
max_idle_time=60, max_sessions=10, enable_auto_cleanup=False
)
# 檢查策略是否更新
assert self.cleanup_manager.policy.max_idle_time == 60
assert self.cleanup_manager.policy.max_sessions == 10
assert self.cleanup_manager.policy.enable_auto_cleanup == False
def test_stats_reset(self):
"""測試統計重置"""
# 執行一些操作產生統計
self.mock_web_ui_manager.cleanup_expired_sessions.return_value = 1
self.cleanup_manager.trigger_cleanup(CleanupTrigger.EXPIRED)
# 檢查有統計數據
stats = self.cleanup_manager.get_cleanup_statistics()
assert stats["total_cleanups"] > 0
# 重置統計
self.cleanup_manager.reset_stats()
# 檢查統計已重置
stats = self.cleanup_manager.get_cleanup_statistics()
assert stats["total_cleanups"] == 0
assert stats["total_sessions_cleaned"] == 0
history = self.cleanup_manager.get_cleanup_history()
assert len(history) == 0

View File

@ -1,86 +1,78 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Web UI 單元測試
"""
import pytest
import asyncio
import time
from pathlib import Path
import pytest
from tests.fixtures.test_data import TestData
from tests.helpers.test_utils import TestUtils
from tests.fixtures.test_data import TestData, TestScenarios
class TestWebUIManager:
"""Web UI 管理器測試"""
def test_web_ui_manager_creation(self, web_ui_manager):
"""測試 WebUIManager 創建"""
assert web_ui_manager is not None
assert web_ui_manager.host == "127.0.0.1"
assert web_ui_manager.port > 0 # 應該分配了端口
assert web_ui_manager.app is not None
def test_web_ui_manager_session_management(self, web_ui_manager, test_project_dir):
"""測試會話管理"""
# 測試創建會話
session_id = web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
assert session_id is not None
assert len(session_id) > 0
# 測試獲取當前會話
current_session = web_ui_manager.get_current_session()
assert current_session is not None
assert current_session.session_id == session_id
assert current_session.project_directory == str(test_project_dir)
assert current_session.summary == TestData.SAMPLE_SESSION["summary"]
def test_session_switching(self, web_ui_manager, test_project_dir):
"""測試會話切換"""
# 創建第一個會話
session_id_1 = web_ui_manager.create_session(
str(test_project_dir),
"第一個會話"
str(test_project_dir), "第一個會話"
)
# 創建第二個會話
session_id_2 = web_ui_manager.create_session(
str(test_project_dir),
"第二個會話"
str(test_project_dir), "第二個會話"
)
# 驗證當前會話是最新的
current_session = web_ui_manager.get_current_session()
assert current_session.session_id == session_id_2
assert current_session.summary == "第二個會話"
def test_global_tabs_management(self, web_ui_manager):
"""測試全局標籤頁管理"""
# 測試初始狀態
assert web_ui_manager.get_global_active_tabs_count() == 0
# 模擬添加活躍標籤頁
tab_info = {
'timestamp': time.time(),
'last_seen': time.time()
}
web_ui_manager.global_active_tabs['tab-1'] = tab_info
tab_info = {"timestamp": time.time(), "last_seen": time.time()}
web_ui_manager.global_active_tabs["tab-1"] = tab_info
assert web_ui_manager.get_global_active_tabs_count() == 1
# 測試過期標籤頁清理
old_tab_info = {
'timestamp': time.time() - 120, # 2分鐘前
'last_seen': time.time() - 120
"timestamp": time.time() - 120, # 2分鐘前
"last_seen": time.time() - 120,
}
web_ui_manager.global_active_tabs['tab-old'] = old_tab_info
web_ui_manager.global_active_tabs["tab-old"] = old_tab_info
# 獲取計數時應該自動清理過期標籤頁
count = web_ui_manager.get_global_active_tabs_count()
assert count == 1 # 只剩下有效的標籤頁
@ -88,80 +80,78 @@ class TestWebUIManager:
class TestWebFeedbackSession:
"""Web 回饋會話測試"""
def test_session_creation(self, test_project_dir):
"""測試會話創建"""
from src.mcp_feedback_enhanced.web.models import WebFeedbackSession
session = WebFeedbackSession(
"test-session",
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
"test-session", str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
assert session.session_id == "test-session"
assert session.project_directory == str(test_project_dir)
assert session.summary == TestData.SAMPLE_SESSION["summary"]
assert session.websocket is None
assert session.feedback_result is None
assert len(session.images) == 0
def test_session_status_management(self, test_project_dir):
"""測試會話狀態管理"""
from src.mcp_feedback_enhanced.web.models import WebFeedbackSession, SessionStatus
session = WebFeedbackSession(
"test-session",
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
from src.mcp_feedback_enhanced.web.models import (
SessionStatus,
WebFeedbackSession,
)
session = WebFeedbackSession(
"test-session", str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 測試初始狀態
assert session.status == SessionStatus.WAITING
# 測試狀態更新
session.update_status(SessionStatus.FEEDBACK_SUBMITTED, "已提交回饋")
assert session.status == SessionStatus.FEEDBACK_SUBMITTED
assert session.status_message == "已提交回饋"
def test_session_age_and_idle_time(self, test_project_dir):
"""測試會話年齡和空閒時間"""
from src.mcp_feedback_enhanced.web.models import WebFeedbackSession
session = WebFeedbackSession(
"test-session",
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
"test-session", str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 測試年齡計算
age = session.get_age()
assert age >= 0
assert age < 1 # 應該小於1秒
# 測試空閒時間
idle_time = session.get_idle_time()
assert idle_time >= 0
assert idle_time < 1 # 應該小於1秒
@pytest.mark.asyncio
async def test_session_feedback_submission(self, test_project_dir):
"""測試回饋提交"""
from src.mcp_feedback_enhanced.web.models import WebFeedbackSession, SessionStatus
session = WebFeedbackSession(
"test-session",
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
from src.mcp_feedback_enhanced.web.models import (
SessionStatus,
WebFeedbackSession,
)
session = WebFeedbackSession(
"test-session", str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
# 提交回饋
await session.submit_feedback(
TestData.SAMPLE_FEEDBACK["feedback"],
TestData.SAMPLE_FEEDBACK["images"],
TestData.SAMPLE_FEEDBACK["settings"]
TestData.SAMPLE_FEEDBACK["settings"],
)
# 驗證回饋已保存
assert session.feedback_result == TestData.SAMPLE_FEEDBACK["feedback"]
assert session.images == TestData.SAMPLE_FEEDBACK["images"]
@ -171,49 +161,47 @@ class TestWebFeedbackSession:
class TestWebUIRoutes:
"""Web UI 路由測試"""
@pytest.mark.asyncio
async def test_index_route_no_session(self, web_ui_manager):
"""測試主頁路由(無會話)"""
from fastapi.testclient import TestClient
client = TestClient(web_ui_manager.app)
response = client.get("/")
assert response.status_code == 200
assert "MCP Feedback Enhanced" in response.text
@pytest.mark.asyncio
async def test_index_route_with_session(self, web_ui_manager, test_project_dir):
"""測試主頁路由(有會話)"""
from fastapi.testclient import TestClient
# 創建會話
web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
client = TestClient(web_ui_manager.app)
response = client.get("/")
assert response.status_code == 200
assert TestData.SAMPLE_SESSION["summary"] in response.text
@pytest.mark.asyncio
async def test_api_current_session(self, web_ui_manager, test_project_dir):
"""測試當前會話 API"""
from fastapi.testclient import TestClient
# 創建會話
session_id = web_ui_manager.create_session(
str(test_project_dir),
TestData.SAMPLE_SESSION["summary"]
str(test_project_dir), TestData.SAMPLE_SESSION["summary"]
)
client = TestClient(web_ui_manager.app)
response = client.get("/api/current-session")
assert response.status_code == 200
data = response.json()
assert data["session_id"] == session_id
@ -223,36 +211,36 @@ class TestWebUIRoutes:
class TestWebUIUtilities:
"""Web UI 工具函數測試"""
def test_find_free_port(self):
"""測試端口查找"""
port = TestUtils.find_free_port()
assert isinstance(port, int)
assert 8000 <= port <= 8100
def test_validate_web_response(self):
"""測試 Web 回應驗證"""
# 測試有效回應
valid_response = {
"command_logs": "test logs",
"interactive_feedback": "test feedback",
"images": []
"images": [],
}
assert TestUtils.validate_web_response(valid_response) == True
# 測試無效回應
invalid_response = {
"command_logs": "test logs"
# 缺少必要字段
}
assert TestUtils.validate_web_response(invalid_response) == False
def test_validate_session_info(self):
"""測試會話信息驗證"""
# 測試有效會話信息
valid_session = TestData.SAMPLE_SESSION
assert TestUtils.validate_session_info(valid_session) == True
# 測試無效會話信息
invalid_session = {
"session_id": "test"