Merge pull request #42 from Minidoracat/feature/practical-improvements
Feature/practical improvements
201
.github/workflows/publish.yml
vendored
@ -9,9 +9,9 @@ on:
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch # 2.0.0 -> 2.0.1 (bug fixes)
|
||||
- minor # 2.0.0 -> 2.1.0 (new features)
|
||||
- major # 2.0.0 -> 3.0.0 (breaking changes)
|
||||
- patch # 2.0.0 -> 2.0.1 (bug fixes, security patches, documentation updates)
|
||||
- minor # 2.0.0 -> 2.1.0 (new features, enhancements, backward-compatible changes)
|
||||
- major # 2.0.0 -> 3.0.0 (breaking changes, architecture refactoring, API changes)
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@ -70,92 +70,80 @@ jobs:
|
||||
NEW_VERSION="${{ steps.bump_version.outputs.new }}"
|
||||
sed -i "s/__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" src/mcp_feedback_enhanced/__init__.py
|
||||
|
||||
- name: Check Release Notes
|
||||
id: check_notes
|
||||
- name: Extract Release Highlights
|
||||
id: extract_highlights
|
||||
run: |
|
||||
NEW_VERSION="v${{ steps.bump_version.outputs.new }}"
|
||||
RELEASE_DIR="RELEASE_NOTES/${NEW_VERSION}"
|
||||
|
||||
if [ ! -d "$RELEASE_DIR" ]; then
|
||||
echo "❌ Error: Release notes directory not found at $RELEASE_DIR"
|
||||
echo "Please create release notes before publishing!"
|
||||
echo "Required files:"
|
||||
echo " - $RELEASE_DIR/en.md"
|
||||
echo " - $RELEASE_DIR/zh-TW.md"
|
||||
exit 1
|
||||
# Extract highlights from English CHANGELOG
|
||||
if [ -f "RELEASE_NOTES/CHANGELOG.en.md" ]; then
|
||||
# Find the section for the new version and extract highlights
|
||||
awk "/## \[${NEW_VERSION}\]/{flag=1; next} /## \[/{flag=0} flag && /### 🌟 Highlights/{getline; while(getline && !/^###/ && !/^##/) if(/^[^[:space:]]/ || /^- /) print}' RELEASE_NOTES/CHANGELOG.en.md > highlights.txt
|
||||
|
||||
# If no highlights section found, extract from new features
|
||||
if [ ! -s highlights.txt ]; then
|
||||
awk "/## \[${NEW_VERSION}\]/{flag=1; next} /## \[/{flag=0} flag && /### ✨ New Features/{getline; while(getline && !/^###/ && !/^##/) if(/^- /) print}' RELEASE_NOTES/CHANGELOG.en.md | head -4 > highlights.txt
|
||||
fi
|
||||
|
||||
echo "✅ Extracted highlights for $NEW_VERSION"
|
||||
else
|
||||
echo "⚠️ CHANGELOG.en.md not found, using default highlights"
|
||||
echo "- 🚀 New features and improvements" > highlights.txt
|
||||
echo "- 🐛 Bug fixes and optimizations" >> highlights.txt
|
||||
fi
|
||||
|
||||
if [ ! -f "$RELEASE_DIR/en.md" ]; then
|
||||
echo "❌ Error: English release notes not found at $RELEASE_DIR/en.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$RELEASE_DIR/zh-TW.md" ]; then
|
||||
echo "❌ Error: Traditional Chinese release notes not found at $RELEASE_DIR/zh-TW.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$RELEASE_DIR/zh-CN.md" ]; then
|
||||
echo "❌ Error: Simplified Chinese release notes not found at $RELEASE_DIR/zh-CN.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Release notes found for $NEW_VERSION"
|
||||
echo "release_dir=$RELEASE_DIR" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate Release Body
|
||||
id: release_body
|
||||
run: |
|
||||
NEW_VERSION="v${{ steps.bump_version.outputs.new }}"
|
||||
RELEASE_DIR="${{ steps.check_notes.outputs.release_dir }}"
|
||||
|
||||
# Create multi-language release body
|
||||
cat > release_body.md << 'EOF'
|
||||
## 🌐 Multi-Language Release Notes
|
||||
# Get release title from English CHANGELOG
|
||||
RELEASE_TITLE=$(awk "/## \[${NEW_VERSION}\]/{print; exit}" RELEASE_NOTES/CHANGELOG.en.md | sed 's/## \[.*\] - //')
|
||||
if [ -z "$RELEASE_TITLE" ]; then
|
||||
RELEASE_TITLE="Latest Release"
|
||||
fi
|
||||
|
||||
# Create release body with highlights and links
|
||||
cat > release_body.md << EOF
|
||||
# Release ${NEW_VERSION} - ${RELEASE_TITLE}
|
||||
|
||||
## 🌟 Key Highlights
|
||||
EOF
|
||||
|
||||
# Add highlights
|
||||
if [ -s highlights.txt ]; then
|
||||
cat highlights.txt >> release_body.md
|
||||
else
|
||||
echo "- 🚀 New features and improvements" >> release_body.md
|
||||
echo "- 🐛 Bug fixes and optimizations" >> release_body.md
|
||||
fi
|
||||
|
||||
# Add multi-language links section
|
||||
cat >> release_body.md << 'EOF'
|
||||
|
||||
## 🌐 Detailed Release Notes
|
||||
|
||||
### 🇺🇸 English
|
||||
EOF
|
||||
|
||||
# Add English content
|
||||
cat "$RELEASE_DIR/en.md" >> release_body.md
|
||||
|
||||
# Add separator
|
||||
cat >> release_body.md << 'EOF'
|
||||
|
||||
---
|
||||
📖 **[View Complete English Release Notes](https://github.com/Minidoracat/mcp-feedback-enhanced/blob/main/RELEASE_NOTES/CHANGELOG.en.md)**
|
||||
|
||||
### 🇹🇼 繁體中文
|
||||
EOF
|
||||
|
||||
# Add Traditional Chinese content
|
||||
cat "$RELEASE_DIR/zh-TW.md" >> release_body.md
|
||||
|
||||
# Add separator
|
||||
cat >> release_body.md << 'EOF'
|
||||
|
||||
---
|
||||
📖 **[查看完整繁體中文發布說明](https://github.com/Minidoracat/mcp-feedback-enhanced/blob/main/RELEASE_NOTES/CHANGELOG.zh-TW.md)**
|
||||
|
||||
### 🇨🇳 简体中文
|
||||
EOF
|
||||
|
||||
# Add Simplified Chinese content
|
||||
cat "$RELEASE_DIR/zh-CN.md" >> release_body.md
|
||||
|
||||
# Add installation section
|
||||
cat >> release_body.md << 'EOF'
|
||||
📖 **[查看完整简体中文发布说明](https://github.com/Minidoracat/mcp-feedback-enhanced/blob/main/RELEASE_NOTES/CHANGELOG.zh-CN.md)**
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation & Update
|
||||
## 📦 Quick Installation / 快速安裝
|
||||
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test
|
||||
# Latest version / 最新版本
|
||||
uvx mcp-feedback-enhanced@latest
|
||||
|
||||
# Update to this specific version
|
||||
# This specific version / 此特定版本
|
||||
EOF
|
||||
|
||||
echo "uvx mcp-feedback-enhanced@$NEW_VERSION test" >> release_body.md
|
||||
echo "uvx mcp-feedback-enhanced@${NEW_VERSION}" >> release_body.md
|
||||
|
||||
cat >> release_body.md << 'EOF'
|
||||
```
|
||||
@ -166,71 +154,72 @@ jobs:
|
||||
- **Issues**: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
|
||||
---
|
||||
**Release automatically generated from RELEASE_NOTES system** 🤖
|
||||
**Release automatically generated from CHANGELOG system** 🤖
|
||||
EOF
|
||||
|
||||
echo "Release body generated successfully"
|
||||
|
||||
- name: Sync CHANGELOG Files
|
||||
- name: Verify CHANGELOG Files
|
||||
run: |
|
||||
NEW_VERSION="v${{ steps.bump_version.outputs.new }}"
|
||||
RELEASE_DIR="${{ steps.check_notes.outputs.release_dir }}"
|
||||
|
||||
# Function to add version to changelog
|
||||
add_to_changelog() {
|
||||
local lang_file="$1"
|
||||
local changelog_file="$2"
|
||||
local temp_file="changelog_temp.md"
|
||||
# Check if CHANGELOG files exist and contain the new version
|
||||
echo "🔍 Verifying CHANGELOG files contain version ${NEW_VERSION}..."
|
||||
|
||||
# Get the header and separator
|
||||
head -n 5 "$changelog_file" > "$temp_file"
|
||||
MISSING_FILES=""
|
||||
|
||||
# Add the new version content
|
||||
cat "$lang_file" >> "$temp_file"
|
||||
|
||||
# Add separator
|
||||
echo "" >> "$temp_file"
|
||||
echo "---" >> "$temp_file"
|
||||
echo "" >> "$temp_file"
|
||||
|
||||
# Add the rest of the changelog (skip header)
|
||||
tail -n +7 "$changelog_file" >> "$temp_file"
|
||||
|
||||
# Replace the original file
|
||||
mv "$temp_file" "$changelog_file"
|
||||
|
||||
echo "✅ Updated $changelog_file"
|
||||
}
|
||||
|
||||
# Check if CHANGELOG files exist
|
||||
if [ -f "RELEASE_NOTES/CHANGELOG.en.md" ]; then
|
||||
add_to_changelog "$RELEASE_DIR/en.md" "RELEASE_NOTES/CHANGELOG.en.md"
|
||||
if ! grep -q "\[${NEW_VERSION}\]" "RELEASE_NOTES/CHANGELOG.en.md"; then
|
||||
echo "⚠️ Warning: ${NEW_VERSION} not found in CHANGELOG.en.md"
|
||||
MISSING_FILES="${MISSING_FILES} en"
|
||||
else
|
||||
echo "✅ Found ${NEW_VERSION} in CHANGELOG.en.md"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ CHANGELOG.en.md not found, skipping"
|
||||
echo "❌ CHANGELOG.en.md not found"
|
||||
MISSING_FILES="${MISSING_FILES} en"
|
||||
fi
|
||||
|
||||
if [ -f "RELEASE_NOTES/CHANGELOG.zh-TW.md" ]; then
|
||||
add_to_changelog "$RELEASE_DIR/zh-TW.md" "RELEASE_NOTES/CHANGELOG.zh-TW.md"
|
||||
if ! grep -q "\[${NEW_VERSION}\]" "RELEASE_NOTES/CHANGELOG.zh-TW.md"; then
|
||||
echo "⚠️ Warning: ${NEW_VERSION} not found in CHANGELOG.zh-TW.md"
|
||||
MISSING_FILES="${MISSING_FILES} zh-TW"
|
||||
else
|
||||
echo "✅ Found ${NEW_VERSION} in CHANGELOG.zh-TW.md"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ CHANGELOG.zh-TW.md not found, skipping"
|
||||
echo "❌ CHANGELOG.zh-TW.md not found"
|
||||
MISSING_FILES="${MISSING_FILES} zh-TW"
|
||||
fi
|
||||
|
||||
if [ -f "RELEASE_NOTES/CHANGELOG.zh-CN.md" ]; then
|
||||
add_to_changelog "$RELEASE_DIR/zh-CN.md" "RELEASE_NOTES/CHANGELOG.zh-CN.md"
|
||||
if ! grep -q "\[${NEW_VERSION}\]" "RELEASE_NOTES/CHANGELOG.zh-CN.md"; then
|
||||
echo "⚠️ Warning: ${NEW_VERSION} not found in CHANGELOG.zh-CN.md"
|
||||
MISSING_FILES="${MISSING_FILES} zh-CN"
|
||||
else
|
||||
echo "✅ Found ${NEW_VERSION} in CHANGELOG.zh-CN.md"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ CHANGELOG.zh-CN.md not found, skipping"
|
||||
echo "❌ CHANGELOG.zh-CN.md not found"
|
||||
MISSING_FILES="${MISSING_FILES} zh-CN"
|
||||
fi
|
||||
|
||||
echo "📝 CHANGELOG files synchronized"
|
||||
if [ -n "$MISSING_FILES" ]; then
|
||||
echo ""
|
||||
echo "📝 Note: Please ensure CHANGELOG files are updated with version ${NEW_VERSION}"
|
||||
echo "Missing or incomplete files:${MISSING_FILES}"
|
||||
echo "The release will continue, but manual CHANGELOG updates may be needed."
|
||||
else
|
||||
echo "✅ All CHANGELOG files verified successfully"
|
||||
fi
|
||||
|
||||
- name: Commit version bump and changelog updates
|
||||
- name: Commit version bump
|
||||
run: |
|
||||
git add .
|
||||
git commit -m "🔖 Release v${{ steps.bump_version.outputs.new }}
|
||||
|
||||
- Updated version to ${{ steps.bump_version.outputs.new }}
|
||||
- Synchronized CHANGELOG files with release notes
|
||||
- Auto-generated from RELEASE_NOTES system"
|
||||
- Auto-generated release from simplified workflow"
|
||||
git tag "v${{ steps.bump_version.outputs.new }}"
|
||||
|
||||
- name: Build package
|
||||
@ -268,9 +257,11 @@ jobs:
|
||||
echo ""
|
||||
echo "📦 Published to PyPI: https://pypi.org/project/mcp-feedback-enhanced/"
|
||||
echo "🚀 GitHub Release: https://github.com/Minidoracat/mcp-feedback-enhanced/releases/tag/v${{ steps.bump_version.outputs.new }}"
|
||||
echo "📝 CHANGELOG files have been automatically updated"
|
||||
echo "📝 Release notes generated from CHANGELOG files"
|
||||
echo ""
|
||||
echo "✅ Next steps:"
|
||||
echo " - Check the release on GitHub"
|
||||
echo " - Verify the package on PyPI"
|
||||
echo " - Test installation with: uvx mcp-feedback-enhanced@v${{ steps.bump_version.outputs.new }} test"
|
||||
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"
|
71
README.md
@ -8,7 +8,7 @@
|
||||
|
||||
## 🎯 Core Concept
|
||||
|
||||
This is an [MCP server](https://modelcontextprotocol.io/) that establishes **feedback-oriented development workflows**, perfectly adapting to local, **SSH remote development environments**, and **WSL (Windows Subsystem for Linux) environments**. By guiding AI to confirm with users rather than making speculative operations, it can consolidate multiple tool calls into a single feedback-oriented request, dramatically reducing platform costs and improving development efficiency.
|
||||
This is an [MCP server](https://modelcontextprotocol.io/) that establishes **feedback-oriented development workflows**, perfectly adapting to local, **SSH Remote environments** (Cursor SSH Remote, VS Code Remote SSH), and **WSL (Windows Subsystem for Linux) environments**. By guiding AI to confirm with users rather than making speculative operations, it can consolidate multiple tool calls into a single feedback-oriented request, dramatically reducing platform costs and improving development efficiency.
|
||||
|
||||
**Supported Platforms:** [Cursor](https://www.cursor.com) | [Cline](https://cline.bot) | [Windsurf](https://windsurf.com) | [Augment](https://www.augmentcode.com) | [Trae](https://www.trae.ai)
|
||||
|
||||
@ -42,12 +42,19 @@ This is an [MCP server](https://modelcontextprotocol.io/) that establishes **fee
|
||||
- **Smart Detection**: Auto-select based on system language
|
||||
- **Live Switching**: Change language directly within interface
|
||||
|
||||
### ✨ WSL Environment Support (v2.2.5 New Feature)
|
||||
### ✨ WSL Environment Support (v2.2.5)
|
||||
- **Auto Detection**: Intelligently identifies WSL (Windows Subsystem for Linux) environments
|
||||
- **Browser Integration**: Automatically launches Windows browser in WSL environments
|
||||
- **Multiple Launch Methods**: Supports `cmd.exe`, `powershell.exe`, `wslview` and other browser launch methods
|
||||
- **Seamless Experience**: WSL users can directly use Web UI without additional configuration
|
||||
|
||||
### 🌐 SSH Remote Environment Support (v2.3.0 New Feature)
|
||||
- **Smart Detection**: Automatically identifies SSH Remote environments (Cursor SSH Remote, VS Code Remote SSH, etc.)
|
||||
- **Browser Launch Guidance**: Provides clear solutions when browser cannot launch automatically
|
||||
- **Port Forwarding Support**: Complete port forwarding setup guidance and troubleshooting
|
||||
- **MCP Integration Optimization**: Improved integration with MCP system for more stable connection experience
|
||||
- **Detailed Documentation**: [SSH Remote Environment Usage Guide](docs/en/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
## 🖥️ Interface Preview
|
||||
|
||||
### Qt GUI Interface (Refactored Version)
|
||||
@ -134,6 +141,7 @@ For best results, add these rules to your AI assistant:
|
||||
|----------|---------|--------|---------|
|
||||
| `FORCE_WEB` | Force use Web UI | `true`/`false` | `false` |
|
||||
| `MCP_DEBUG` | Debug mode | `true`/`false` | `false` |
|
||||
| `MCP_WEB_PORT` | Web UI port | `1024-65535` | `8765` |
|
||||
|
||||
### Testing Options
|
||||
```bash
|
||||
@ -178,20 +186,38 @@ uvx --with-editable . mcp-feedback-enhanced test --web # Test Web UI (auto co
|
||||
|
||||
📋 **Complete Version History:** [RELEASE_NOTES/CHANGELOG.en.md](RELEASE_NOTES/CHANGELOG.en.md)
|
||||
|
||||
### Latest Version Highlights (v2.2.5)
|
||||
- ✨ **WSL Environment Support**: Added comprehensive support for WSL (Windows Subsystem for Linux) environments
|
||||
- 🌐 **Smart Browser Launching**: Automatically invokes Windows browser in WSL environments with multiple launch methods
|
||||
- 🎯 **Environment Detection Optimization**: Improved remote environment detection logic, WSL no longer misidentified as remote environment
|
||||
- 🧪 **Testing Experience Improvement**: Test mode automatically attempts browser launching for better testing experience
|
||||
### Latest Version Highlights (v2.3.0)
|
||||
- 🌐 **SSH Remote Environment Support**: Solved Cursor SSH Remote browser launch issues with clear usage guidance
|
||||
- 🛡️ **Error Message Improvements**: Provides more user-friendly error messages and solution suggestions when errors occur
|
||||
- 🧹 **Auto-cleanup Features**: Automatically cleans temporary files and expired sessions to keep the system tidy
|
||||
- 📊 **Memory Monitoring**: Monitors memory usage to prevent system resource shortage
|
||||
- 🔧 **Connection Stability**: Improved Web UI connection stability and error handling
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### 🌐 SSH Remote Environment Issues
|
||||
**Q: Browser cannot launch in SSH Remote environment**
|
||||
A: This is normal behavior. SSH Remote environments have no graphical interface, requiring manual opening in local browser. For detailed solutions, see: [SSH Remote Environment Usage Guide](docs/en/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
**Q: Why am I not receiving new MCP feedback?**
|
||||
A: There might be a WebSocket connection issue. **Solution**: Simply refresh the browser page.
|
||||
|
||||
**Q: Why isn't MCP being called?**
|
||||
A: Please confirm the MCP tool status shows green light. **Solution**: Toggle the MCP tool on/off repeatedly, wait a few seconds for system reconnection.
|
||||
|
||||
**Q: Augment cannot start MCP**
|
||||
A: **Solution**: Completely close and restart VS Code or Cursor, then reopen the project.
|
||||
|
||||
### 🔧 General Issues
|
||||
**Q: Getting "Unexpected token 'D'" error**
|
||||
A: Debug output interference. Set `MCP_DEBUG=false` or remove the environment variable.
|
||||
|
||||
**Q: Chinese character garbled text**
|
||||
A: Fixed in v2.0.3. Update to latest version: `uvx mcp-feedback-enhanced@latest`
|
||||
|
||||
**Q: Multi-screen window disappearing or positioning errors**
|
||||
A: Fixed in v2.1.1. Go to "⚙️ Settings" tab, check "Always show window at primary screen center" to resolve. Especially useful for T-shaped screen arrangements and other complex multi-monitor configurations.
|
||||
|
||||
**Q: Image upload fails**
|
||||
A: Check file size (≤1MB) and format (PNG/JPG/GIF/BMP/WebP).
|
||||
|
||||
@ -218,21 +244,11 @@ uv cache clean
|
||||
```
|
||||
For detailed instructions, see: [Cache Management Guide](docs/en/cache-management.md)
|
||||
|
||||
**Q: Gemini Pro 2.5 cannot parse images**
|
||||
A: Known issue. Gemini Pro 2.5 may not correctly parse uploaded image content. Testing shows Claude-4-Sonnet can properly analyze images. Recommend using Claude models for better image understanding capabilities.
|
||||
|
||||
**Q: Multi-screen window positioning issues**
|
||||
A: Fixed in v2.1.1. Go to "⚙️ Settings" tab, check "Always show window at primary screen center" to resolve window positioning issues. Especially useful for T-shaped screen arrangements and other complex multi-monitor configurations.
|
||||
|
||||
**Q: Cannot launch browser in WSL environment**
|
||||
A: v2.2.5 has added WSL environment support. If issues persist:
|
||||
1. Confirm WSL version (WSL 2 recommended)
|
||||
2. Check if Windows browser is properly installed
|
||||
3. Try manual test: run `cmd.exe /c start https://www.google.com` in WSL
|
||||
4. If `wslu` package is installed, you can also try the `wslview` command
|
||||
|
||||
**Q: WSL environment misidentified as remote environment**
|
||||
A: v2.2.5 has fixed this issue. WSL environments are now correctly identified and use Web UI with Windows browser launching, instead of being misidentified as remote environments.
|
||||
**Q: AI models cannot parse images**
|
||||
A: Various AI models (including Gemini Pro 2.5, Claude, etc.) may have instability in image parsing, sometimes correctly identifying and sometimes unable to parse uploaded image content. This is a known limitation of AI visual understanding technology. Recommendations:
|
||||
1. Ensure good image quality (high contrast, clear text)
|
||||
2. Try uploading multiple times, retries usually succeed
|
||||
3. If parsing continues to fail, try adjusting image size or format
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
@ -246,3 +262,14 @@ If you find this useful, please:
|
||||
|
||||
### Design Inspiration
|
||||
**sanshao85** - [mcp-feedback-collector](https://github.com/sanshao85/mcp-feedback-collector)
|
||||
|
||||
### Community Support
|
||||
- **Discord:** [https://discord.gg/Gur2V67](https://discord.gg/Gur2V67)
|
||||
- **Issues:** [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details
|
||||
|
||||
---
|
||||
**🌟 Welcome to Star and share with more developers!**
|
@ -8,7 +8,7 @@
|
||||
|
||||
## 🎯 核心概念
|
||||
|
||||
这是一个 [MCP 服务器](https://modelcontextprotocol.io/),建立**反馈导向的开发工作流程**,完美适配本地、**SSH 远程开发环境**与 **WSL (Windows Subsystem for Linux) 环境**。通过引导 AI 与用户确认而非进行推测性操作,可将多次工具调用合并为单次反馈导向请求,大幅节省平台成本并提升开发效率。
|
||||
这是一个 [MCP 服务器](https://modelcontextprotocol.io/),建立**反馈导向的开发工作流程**,完美适配本地、**SSH Remote 环境**(Cursor SSH Remote、VS Code Remote SSH)与 **WSL (Windows Subsystem for Linux) 环境**。通过引导 AI 与用户确认而非进行推测性操作,可将多次工具调用合并为单次反馈导向请求,大幅节省平台成本并提升开发效率。
|
||||
|
||||
**支持平台:** [Cursor](https://www.cursor.com) | [Cline](https://cline.bot) | [Windsurf](https://windsurf.com) | [Augment](https://www.augmentcode.com) | [Trae](https://www.trae.ai)
|
||||
|
||||
@ -42,12 +42,19 @@
|
||||
- **智能检测**:根据系统语言自动选择
|
||||
- **即时切换**:界面内可直接切换语言
|
||||
|
||||
### ✨ WSL 环境支持(v2.2.5 新功能)
|
||||
### ✨ WSL 环境支持(v2.2.5)
|
||||
- **自动检测**:智能识别 WSL (Windows Subsystem for Linux) 环境
|
||||
- **浏览器整合**:WSL 环境下自动启动 Windows 浏览器
|
||||
- **多种启动方式**:支持 `cmd.exe`、`powershell.exe`、`wslview` 等多种浏览器启动方法
|
||||
- **无缝体验**:WSL 用户可直接使用 Web UI,无需额外配置
|
||||
|
||||
### 🌐 SSH Remote 环境支持(v2.3.0 新功能)
|
||||
- **智能检测**:自动识别 SSH Remote 环境(Cursor SSH Remote、VS Code Remote SSH 等)
|
||||
- **浏览器启动指引**:当无法自动启动浏览器时,提供清晰的解决方案
|
||||
- **端口转发支持**:完整的端口转发设置指引和故障排除
|
||||
- **MCP 整合优化**:改善与 MCP 系统的整合,提供更稳定的连接体验
|
||||
- **详细文档**:[SSH Remote 环境使用指南](docs/zh-CN/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
## 🖥️ 界面预览
|
||||
|
||||
### Qt GUI 界面(重构版)
|
||||
@ -180,25 +187,43 @@ uvx --with-editable . mcp-feedback-enhanced test --web # 测试 Web UI (自
|
||||
|
||||
📋 **完整版本更新记录:** [RELEASE_NOTES/CHANGELOG.zh-CN.md](RELEASE_NOTES/CHANGELOG.zh-CN.md)
|
||||
|
||||
### 最新版本亮点(v2.2.5)
|
||||
- ✨ **WSL 环境支持**: 新增 WSL (Windows Subsystem for Linux) 环境的完整支持
|
||||
- 🌐 **智能浏览器启动**: WSL 环境下自动调用 Windows 浏览器,支持多种启动方式
|
||||
- 🎯 **环境检测优化**: 改进远程环境检测逻辑,WSL 不再被误判为远程环境
|
||||
- 🧪 **测试体验提升**: 测试模式下自动尝试启动浏览器,提供更好的测试体验
|
||||
### 最新版本亮点(v2.3.0)
|
||||
- 🌐 **SSH Remote 环境支持**: 解决 Cursor SSH Remote 无法启动浏览器的问题,提供清晰的使用指引
|
||||
- 🛡️ **错误提示改善**: 当发生错误时,提供更友善的错误信息和解决建议
|
||||
- 🧹 **自动清理功能**: 自动清理临时文件和过期会话,保持系统整洁
|
||||
- 📊 **内存监控**: 监控内存使用情况,防止系统资源不足
|
||||
- 🔧 **连接稳定性**: 改善 Web UI 的连接稳定性和错误处理
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 🌐 SSH Remote 环境问题
|
||||
**Q: SSH Remote 环境下浏览器无法启动**
|
||||
A: 这是正常现象。SSH Remote 环境没有图形界面,需要手动在本地浏览器打开。详细解决方案请参考:[SSH Remote 环境使用指南](docs/zh-CN/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
**Q: 为什么没有接收到 MCP 新的反馈?**
|
||||
A: 可能是 WebSocket 连接问题。**解决方法**:直接重新刷新浏览器页面。
|
||||
|
||||
**Q: 为什么没有调用出 MCP?**
|
||||
A: 请确认 MCP 工具状态为绿灯。**解决方法**:反复开关 MCP 工具,等待几秒让系统重新连接。
|
||||
|
||||
**Q: Augment 无法启动 MCP**
|
||||
A: **解决方法**:完全关闭并重新启动 VS Code 或 Cursor,重新打开项目。
|
||||
|
||||
### 🔧 一般问题
|
||||
**Q: 出现 "Unexpected token 'D'" 错误**
|
||||
A: 调试输出干扰。设置 `MCP_DEBUG=false` 或移除该环境变量。
|
||||
|
||||
**Q: 中文字符乱码**
|
||||
A: 已在 v2.0.3 修复。更新到最新版本:`uvx mcp-feedback-enhanced@latest`
|
||||
|
||||
**Q: 多屏幕环境下窗口消失或定位错误**
|
||||
A: 已在 v2.1.1 修复。进入「⚙️ 设置」标签页,勾选「总是在主屏幕中心显示窗口」即可解决。特别适用于 T 字型屏幕排列等复杂多屏幕配置。
|
||||
|
||||
**Q: 图片上传失败**
|
||||
A: 检查文件大小(≤1MB)和格式(PNG/JPG/GIF/BMP/WebP)。
|
||||
|
||||
**Q: Web UI 无法启动**
|
||||
A: 设置 `FORCE_WEB=true` 或检查火墙设定。
|
||||
A: 设置 `FORCE_WEB=true` 或检查防火墙设置。
|
||||
|
||||
**Q: UV Cache 占用过多磁盘空间**
|
||||
A: 由于频繁使用 `uvx` 命令,cache 可能会累积到数十 GB。建议定期清理:
|
||||
@ -220,21 +245,11 @@ uv cache clean
|
||||
```
|
||||
详细说明请参考:[Cache 管理指南](docs/zh-CN/cache-management.md)
|
||||
|
||||
**Q: Gemini Pro 2.5 无法解析图片**
|
||||
A: 已知问题,Gemini Pro 2.5 可能无法正确解析上传的图片内容。实测 Claude-4-Sonnet 可以正常解析图片。建议使用 Claude 模型获得更好的图片理解能力。
|
||||
|
||||
**Q: 多屏幕视窗定位问题**
|
||||
A: 已在 v2.1.1 修复。进入「⚙️ 设置」标签页,勾选「总是在主屏幕中心显示窗口」即可解决窗口定位问题。特别适用于 T 字型屏幕排列等复杂多屏幕配置。
|
||||
|
||||
**Q: WSL 环境下无法启动浏览器**
|
||||
A: v2.2.5 已新增 WSL 环境支持。如果仍有问题:
|
||||
1. 确认 WSL 版本(建议使用 WSL 2)
|
||||
2. 检查 Windows 浏览器是否正常安装
|
||||
3. 尝试手动测试:在 WSL 中执行 `cmd.exe /c start https://www.google.com`
|
||||
4. 如果安装了 `wslu` 套件,也可尝试 `wslview` 命令
|
||||
|
||||
**Q: WSL 环境被误判为远程环境**
|
||||
A: v2.2.5 已修复此问题。WSL 环境现在会被正确识别并使用 Web UI 配合 Windows 浏览器启动,而不会被误判为远程环境。
|
||||
**Q: AI 模型无法解析图片**
|
||||
A: 各种 AI 模型(包括 Gemini Pro 2.5、Claude 等)在图片解析上可能存在不稳定性,表现为有时能正确识别、有时无法解析上传的图片内容。这是 AI 视觉理解技术的已知限制。建议:
|
||||
1. 确保图片质量良好(高对比度、清晰文字)
|
||||
2. 多尝试几次上传,通常重试可以成功
|
||||
3. 如持续无法解析,可尝试调整图片大小或格式
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
|
@ -42,12 +42,19 @@
|
||||
- **智能偵測**:根據系統語言自動選擇
|
||||
- **即時切換**:介面內可直接切換語言
|
||||
|
||||
### ✨ WSL 環境支援(v2.2.5 新功能)
|
||||
### ✨ WSL 環境支援(v2.2.5)
|
||||
- **自動檢測**:智能識別 WSL (Windows Subsystem for Linux) 環境
|
||||
- **瀏覽器整合**:WSL 環境下自動啟動 Windows 瀏覽器
|
||||
- **多種啟動方式**:支援 `cmd.exe`、`powershell.exe`、`wslview` 等多種瀏覽器啟動方法
|
||||
- **無縫體驗**:WSL 用戶可直接使用 Web UI,無需額外配置
|
||||
|
||||
### 🌐 SSH Remote 環境支援(v2.3.0 新功能)
|
||||
- **智能檢測**:自動識別 SSH Remote 環境(Cursor SSH Remote、VS Code Remote SSH 等)
|
||||
- **瀏覽器啟動指引**:當無法自動啟動瀏覽器時,提供清晰的解決方案
|
||||
- **端口轉發支援**:完整的端口轉發設定指引和故障排除
|
||||
- **MCP 整合優化**:改善與 MCP 系統的整合,提供更穩定的連接體驗
|
||||
- **詳細文檔**:[SSH Remote 環境使用指南](docs/zh-TW/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
## 🖥️ 介面預覽
|
||||
|
||||
### Qt GUI 介面(重構版)
|
||||
@ -180,14 +187,29 @@ uvx --with-editable . mcp-feedback-enhanced test --web # 測試 Web UI (自
|
||||
|
||||
📋 **完整版本更新記錄:** [RELEASE_NOTES/CHANGELOG.zh-TW.md](RELEASE_NOTES/CHANGELOG.zh-TW.md)
|
||||
|
||||
### 最新版本亮點(v2.2.5)
|
||||
- ✨ **WSL 環境支援**: 新增 WSL (Windows Subsystem for Linux) 環境的完整支援
|
||||
- 🌐 **智能瀏覽器啟動**: WSL 環境下自動調用 Windows 瀏覽器,支援多種啟動方式
|
||||
- 🎯 **環境檢測優化**: 改進遠端環境檢測邏輯,WSL 不再被誤判為遠端環境
|
||||
- 🧪 **測試體驗提升**: 測試模式下自動嘗試啟動瀏覽器,提供更好的測試體驗
|
||||
### 最新版本亮點(v2.3.0)
|
||||
- 🌐 **SSH Remote 環境支援**: 解決 Cursor SSH Remote 無法啟動瀏覽器的問題,提供清晰的使用指引
|
||||
- 🛡️ **錯誤提示改善**: 當發生錯誤時,提供更友善的錯誤訊息和解決建議
|
||||
- 🧹 **自動清理功能**: 自動清理臨時文件和過期會話,保持系統整潔
|
||||
- 📊 **記憶體監控**: 監控記憶體使用情況,防止系統資源不足
|
||||
- 🔧 **連線穩定性**: 改善 Web UI 的連線穩定性和錯誤處理
|
||||
|
||||
## 🐛 常見問題
|
||||
|
||||
### 🌐 SSH Remote 環境問題
|
||||
**Q: SSH Remote 環境下瀏覽器無法啟動**
|
||||
A: 這是正常現象。SSH Remote 環境沒有圖形界面,需要手動在本地瀏覽器開啟。詳細解決方案請參考:[SSH Remote 環境使用指南](docs/zh-TW/ssh-remote/browser-launch-issues.md)
|
||||
|
||||
**Q: 為什麼沒有接收到 MCP 新的反饋?**
|
||||
A: 可能是 WebSocket 連接問題。**解決方法**:直接重新整理瀏覽器頁面。
|
||||
|
||||
**Q: 為什麼沒有呼叫出 MCP?**
|
||||
A: 請確認 MCP 工具狀態為綠燈。**解決方法**:反覆開關 MCP 工具,等待幾秒讓系統重新連接。
|
||||
|
||||
**Q: Augment 無法啟動 MCP**
|
||||
A: **解決方法**:完全關閉並重新啟動 VS Code 或 Cursor,重新開啟專案。
|
||||
|
||||
### 🔧 一般問題
|
||||
**Q: 出現 "Unexpected token 'D'" 錯誤**
|
||||
A: 調試輸出干擾。設置 `MCP_DEBUG=false` 或移除該環境變數。
|
||||
|
||||
|
@ -2,38 +2,32 @@
|
||||
|
||||
This document records all version updates for **MCP Feedback Enhanced**.
|
||||
|
||||
## [v2.2.5] - WSL Environment Support & Cross-Platform Enhancement
|
||||
# Release v2.2.5 - WSL Environment Support & Cross-Platform Enhancement
|
||||
## [v2.3.0] - System Stability & Resource Management Enhancement
|
||||
|
||||
## 🌟 Highlights
|
||||
This version introduces comprehensive support for WSL (Windows Subsystem for Linux) environments, enabling WSL users to seamlessly use this tool with automatic Windows browser launching, significantly improving cross-platform development experience.
|
||||
### 🌟 Highlights
|
||||
This version focuses on improving system stability and user experience, particularly solving the browser launch issue in Cursor SSH Remote environments.
|
||||
|
||||
## ✨ New Features
|
||||
- 🐧 **WSL Environment Detection**: Automatically identifies WSL environments and provides specialized support logic
|
||||
- 🌐 **Smart Browser Launching**: Automatically invokes Windows browser in WSL environments with multiple launch methods
|
||||
- 🔧 **Cross-Platform Testing Enhancement**: Test functionality integrates WSL detection for improved test coverage
|
||||
### ✨ New Features
|
||||
- 🌐 **SSH Remote Environment Support**: Solved Cursor SSH Remote browser launch issues with clear usage guidance
|
||||
- 🛡️ **Error Message Improvements**: Provides more user-friendly error messages and solution suggestions when errors occur
|
||||
- 🧹 **Auto-cleanup Features**: Automatically cleans temporary files and expired sessions to keep the system tidy
|
||||
- 📊 **Memory Monitoring**: Monitors memory usage to prevent system resource shortage
|
||||
|
||||
## 🚀 Improvements
|
||||
- 🎯 **Environment Detection Optimization**: Improved remote environment detection logic, WSL no longer misidentified as remote environment
|
||||
- 📊 **System Information Enhancement**: System information tool now displays WSL environment status
|
||||
- 🧪 **Testing Experience Improvement**: Test mode automatically attempts browser launching for better testing experience
|
||||
### 🚀 Improvements
|
||||
- 💾 **Resource Management Optimization**: Better system resource management for improved performance
|
||||
- 🔧 **Enhanced Error Handling**: Provides clearer explanations and solutions when problems occur
|
||||
- 🌐 **Connection Stability**: Improved Web UI connection stability
|
||||
- 🖼️ **Image Upload Optimization**: Enhanced stability of image upload functionality
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reports: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Project Homepage: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
||||
### 🐛 Bug Fixes
|
||||
- 🌐 **Connection Issues**: Fixed WebSocket connection related problems
|
||||
- 🔄 **Session Management**: Fixed session state tracking issues
|
||||
- 🖼️ **Image Processing**: Fixed event handling issues during image upload
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.5] - WSL Environment Support & Cross-Platform Enhancement
|
||||
|
||||
### ✨ New Features
|
||||
- 🐧 **WSL Environment Detection**: Automatically identifies WSL environments and provides specialized support logic
|
||||
- 🌐 **Smart Browser Launching**: Automatically invokes Windows browser in WSL environments with multiple launch methods
|
||||
|
@ -2,56 +2,27 @@
|
||||
|
||||
本文件记录了 **MCP Feedback Enhanced** 的所有版本更新内容。
|
||||
|
||||
## [v2.2.5] - WSL 环境支持与跨平台增强
|
||||
# Release v2.2.5 - WSL 环境支持与跨平台增强
|
||||
## [v2.3.0] - 系统稳定性与资源管理增强
|
||||
|
||||
## 🌟 亮点
|
||||
本版本新增了 WSL (Windows Subsystem for Linux) 环境的完整支持,让 WSL 用户能够无缝使用本工具并自动启动 Windows 浏览器,大幅提升跨平台开发体验。
|
||||
|
||||
## ✨ 新功能
|
||||
- 🐧 **WSL 环境检测**: 自动识别 WSL 环境,提供专门的支持逻辑
|
||||
- 🌐 **智能浏览器启动**: WSL 环境下自动调用 Windows 浏览器,支持多种启动方式
|
||||
- 🔧 **跨平台测试增强**: 测试功能整合 WSL 检测,提升测试覆盖率
|
||||
|
||||
## 🚀 改进功能
|
||||
- 🎯 **环境检测优化**: 改进远程环境检测逻辑,WSL 不再被误判为远程环境
|
||||
- 📊 **系统信息增强**: 系统信息工具新增 WSL 环境状态显示
|
||||
- 🧪 **测试体验提升**: 测试模式下自动尝试启动浏览器,提供更好的测试体验
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 项目首页: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
||||
|
||||
---
|
||||
### 🌟 亮点
|
||||
本版本专注于提升系统稳定性和使用体验,特别解决了 Cursor SSH Remote 环境下无法启动浏览器的问题。
|
||||
|
||||
### ✨ 新功能
|
||||
- 🐧 **WSL 环境检测**: 自动识别 WSL 环境,提供专门的支持逻辑
|
||||
- 🌐 **智能浏览器启动**: WSL 环境下自动调用 Windows 浏览器,支持多种启动方式
|
||||
- 🔧 **跨平台测试增强**: 测试功能整合 WSL 检测,提升测试覆盖率
|
||||
- 🌐 **SSH Remote 环境支持**: 解决 Cursor SSH Remote 无法启动浏览器的问题,提供清晰的使用指引
|
||||
- 🛡️ **错误提示改善**: 当发生错误时,提供更友善的错误信息和解决建议
|
||||
- 🧹 **自动清理功能**: 自动清理临时文件和过期会话,保持系统整洁
|
||||
- 📊 **内存监控**: 监控内存使用情况,防止系统资源不足
|
||||
|
||||
### 🚀 改进功能
|
||||
- 🎯 **环境检测优化**: 改进远程环境检测逻辑,WSL 不再被误判为远程环境
|
||||
- 📊 **系统信息增强**: 系统信息工具新增 WSL 环境状态显示
|
||||
- 🧪 **测试体验提升**: 测试模式下自动尝试启动浏览器,提供更好的测试体验
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.4] - GUI 体验优化与问题修复
|
||||
- 💾 **资源管理优化**: 更好地管理系统资源,提升运行效率
|
||||
- 🔧 **错误处理增强**: 遇到问题时提供更清楚的说明和解决方案
|
||||
- 🌐 **连接稳定性**: 改善 Web UI 的连接稳定性
|
||||
- 🖼️ **图片上传优化**: 改善图片上传功能的稳定性
|
||||
|
||||
### 🐛 问题修复
|
||||
- 🖼️ **图片重复粘贴修复**: 解决 GUI 界面中使用 Ctrl+V 复制粘贴图片时出现重复粘贴的问题
|
||||
- 🌐 **语系切换修复**: 修复图片设定区域在语言切换时文字没有正确翻译的问题
|
||||
- 📝 **字体可读性改善**: 调整图片设定区域的字体大小,提升文字可读性
|
||||
- 🌐 **连接问题**: 修复 WebSocket 连接的相关问题
|
||||
- 🔄 **会话管理**: 修复会话状态跟踪的问题
|
||||
- 🖼️ **图片处理**: 修复图片上传时的事件处理问题
|
||||
|
||||
---
|
||||
|
||||
|
@ -2,38 +2,32 @@
|
||||
|
||||
本文件記錄了 **MCP Feedback Enhanced** 的所有版本更新內容。
|
||||
|
||||
## [v2.2.5] - WSL 環境支援與跨平台增強
|
||||
# Release v2.2.5 - WSL 環境支援與跨平台增強
|
||||
## [v2.3.0] - 系統穩定性與資源管理增強
|
||||
|
||||
## 🌟 亮點
|
||||
本版本新增了 WSL (Windows Subsystem for Linux) 環境的完整支援,讓 WSL 用戶能夠無縫使用本工具並自動啟動 Windows 瀏覽器,大幅提升跨平台開發體驗。
|
||||
### 🌟 亮點
|
||||
本版本專注於提升系統穩定性和使用體驗,特別解決了 Cursor SSH Remote 環境下無法啟動瀏覽器的問題。
|
||||
|
||||
## ✨ 新功能
|
||||
- 🐧 **WSL 環境檢測**: 自動識別 WSL 環境,提供專門的支援邏輯
|
||||
- 🌐 **智能瀏覽器啟動**: WSL 環境下自動調用 Windows 瀏覽器,支援多種啟動方式
|
||||
- 🔧 **跨平台測試增強**: 測試功能整合 WSL 檢測,提升測試覆蓋率
|
||||
### ✨ 新功能
|
||||
- 🌐 **SSH Remote 環境支援**: 解決 Cursor SSH Remote 無法啟動瀏覽器的問題,提供清晰的使用指引
|
||||
- 🛡️ **錯誤提示改善**: 當發生錯誤時,提供更友善的錯誤訊息和解決建議
|
||||
- 🧹 **自動清理功能**: 自動清理臨時文件和過期會話,保持系統整潔
|
||||
- 📊 **記憶體監控**: 監控記憶體使用情況,防止系統資源不足
|
||||
|
||||
## 🚀 改進功能
|
||||
- 🎯 **環境檢測優化**: 改進遠端環境檢測邏輯,WSL 不再被誤判為遠端環境
|
||||
- 📊 **系統資訊增強**: 系統資訊工具新增 WSL 環境狀態顯示
|
||||
- 🧪 **測試體驗提升**: 測試模式下自動嘗試啟動瀏覽器,提供更好的測試體驗
|
||||
### 🚀 改進功能
|
||||
- 💾 **資源管理優化**: 更好地管理系統資源,提升運行效率
|
||||
- 🔧 **錯誤處理增強**: 遇到問題時提供更清楚的說明和解決方案
|
||||
- 🌐 **連線穩定性**: 改善 Web UI 的連線穩定性
|
||||
- 🖼️ **圖片上傳優化**: 改善圖片上傳功能的穩定性
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 專案首頁: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
||||
### 🐛 問題修復
|
||||
- 🌐 **連線問題**: 修復 WebSocket 連線的相關問題
|
||||
- 🔄 **會話管理**: 修復會話狀態追蹤的問題
|
||||
- 🖼️ **圖片處理**: 修復圖片上傳時的事件處理問題
|
||||
|
||||
---
|
||||
|
||||
## [v2.2.5] - WSL 環境支援與跨平台增強
|
||||
|
||||
### ✨ 新功能
|
||||
- 🐧 **WSL 環境檢測**: 自動識別 WSL 環境,提供專門的支援邏輯
|
||||
- 🌐 **智能瀏覽器啟動**: WSL 環境下自動調用 Windows 瀏覽器,支援多種啟動方式
|
||||
|
147
RELEASE_NOTES/SIMPLIFIED_WORKFLOW.md
Normal file
@ -0,0 +1,147 @@
|
||||
# 簡化發布流程 / Simplified Release Workflow
|
||||
|
||||
## 🎯 概述 / Overview
|
||||
|
||||
此專案已採用簡化的發布流程,不再需要建立版本化目錄(如 `v2.3.0/`),而是直接更新 CHANGELOG 文件。
|
||||
|
||||
This project now uses a simplified release workflow that no longer requires creating versioned directories (like `v2.3.0/`), but instead directly updates CHANGELOG files.
|
||||
|
||||
## 📋 新的發布流程 / New Release Process
|
||||
|
||||
### 1. 更新 CHANGELOG 文件 / Update CHANGELOG Files
|
||||
|
||||
在發布前,請手動更新以下三個文件:
|
||||
Before releasing, manually update these three files:
|
||||
|
||||
- `RELEASE_NOTES/CHANGELOG.en.md`
|
||||
- `RELEASE_NOTES/CHANGELOG.zh-TW.md`
|
||||
- `RELEASE_NOTES/CHANGELOG.zh-CN.md`
|
||||
|
||||
### 2. CHANGELOG 格式要求 / CHANGELOG Format Requirements
|
||||
|
||||
每個新版本應該按照以下格式添加到 CHANGELOG 文件的頂部:
|
||||
Each new version should be added to the top of CHANGELOG files in this format:
|
||||
|
||||
```markdown
|
||||
## [v2.3.0] - 版本標題 / Version Title
|
||||
|
||||
### 🌟 亮點 / Highlights
|
||||
本次發佈的主要特色...
|
||||
|
||||
### ✨ 新功能 / New Features
|
||||
- 🆕 **功能名稱**: 功能描述
|
||||
|
||||
### 🐛 錯誤修復 / Bug Fixes
|
||||
- 🔧 **問題修復**: 修復描述
|
||||
|
||||
### 🚀 改進功能 / Improvements
|
||||
- ⚡ **效能優化**: 優化描述
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### 3. 執行發布 / Execute Release
|
||||
|
||||
1. 確保所有 CHANGELOG 文件都已更新
|
||||
Ensure all CHANGELOG files are updated
|
||||
|
||||
2. 前往 GitHub Actions 頁面
|
||||
Go to GitHub Actions page
|
||||
|
||||
3. 執行 "Auto Release to PyPI" workflow
|
||||
Run "Auto Release to PyPI" workflow
|
||||
|
||||
4. 選擇版本類型(patch/minor/major)
|
||||
Select version type (patch/minor/major)
|
||||
|
||||
### 📊 版本類型說明 / Version Type Explanation
|
||||
|
||||
選擇適當的版本類型非常重要,請根據變更內容選擇:
|
||||
Choosing the appropriate version type is important, select based on the changes:
|
||||
|
||||
#### 🔧 Patch (修補版本)
|
||||
- **用途 / Usage**: 錯誤修復、小幅改進、安全修補
|
||||
- **範例 / Example**: `2.3.0 → 2.3.1`
|
||||
- **適用情況 / When to use**:
|
||||
- 🐛 修復 bug / Bug fixes
|
||||
- 🔒 安全性修補 / Security patches
|
||||
- 📝 文檔更新 / Documentation updates
|
||||
- 🎨 小幅 UI 調整 / Minor UI tweaks
|
||||
|
||||
#### ✨ Minor (次要版本)
|
||||
- **用途 / Usage**: 新功能、功能增強、向後相容的變更
|
||||
- **範例 / Example**: `2.3.0 → 2.4.0`
|
||||
- **適用情況 / When to use**:
|
||||
- 🆕 新增功能 / New features
|
||||
- 🚀 功能增強 / Feature enhancements
|
||||
- 🎯 效能改進 / Performance improvements
|
||||
- 🌐 新的語言支援 / New language support
|
||||
|
||||
#### 🚨 Major (主要版本)
|
||||
- **用途 / Usage**: 重大變更、不向後相容的修改、架構重構
|
||||
- **範例 / Example**: `2.3.0 → 3.0.0`
|
||||
- **適用情況 / When to use**:
|
||||
- 💥 破壞性變更 / Breaking changes
|
||||
- 🏗️ 架構重構 / Architecture refactoring
|
||||
- 🔄 API 變更 / API changes
|
||||
- 📦 依賴項重大更新 / Major dependency updates
|
||||
|
||||
#### 🤔 如何選擇 / How to Choose
|
||||
|
||||
**問自己這些問題 / Ask yourself these questions**:
|
||||
|
||||
1. **會破壞現有功能嗎?** / **Will it break existing functionality?**
|
||||
- 是 / Yes → Major
|
||||
- 否 / No → 繼續下一個問題 / Continue to next question
|
||||
|
||||
2. **是否新增了功能?** / **Does it add new functionality?**
|
||||
- 是 / Yes → Minor
|
||||
- 否 / No → 繼續下一個問題 / Continue to next question
|
||||
|
||||
3. **只是修復或小幅改進?** / **Just fixes or minor improvements?**
|
||||
- 是 / Yes → Patch
|
||||
|
||||
## 🔄 自動化流程 / Automated Process
|
||||
|
||||
GitHub workflow 將自動:
|
||||
The GitHub workflow will automatically:
|
||||
|
||||
1. ✅ 版本號碼升級 / Version bump
|
||||
2. ✅ 從 CHANGELOG 提取亮點 / Extract highlights from CHANGELOG
|
||||
3. ✅ 生成多語系 GitHub Release / Generate multi-language GitHub Release
|
||||
4. ✅ 發布到 PyPI / Publish to PyPI
|
||||
5. ✅ 建立 Git 標籤 / Create Git tags
|
||||
|
||||
## 📦 GitHub Release 格式 / GitHub Release Format
|
||||
|
||||
自動生成的 Release 將包含:
|
||||
Auto-generated releases will include:
|
||||
|
||||
- 🌟 版本亮點 / Version highlights
|
||||
- 🌐 多語系 CHANGELOG 連結 / Multi-language CHANGELOG links
|
||||
- 📦 安裝指令 / Installation commands
|
||||
- 🔗 相關連結 / Related links
|
||||
|
||||
## ⚠️ 注意事項 / Important Notes
|
||||
|
||||
1. **不再需要版本目錄**:舊的 `RELEASE_NOTES/v2.x.x/` 目錄結構已棄用
|
||||
**No more version directories**: Old `RELEASE_NOTES/v2.x.x/` directory structure is deprecated
|
||||
|
||||
2. **手動更新 CHANGELOG**:發布前必須手動更新 CHANGELOG 文件
|
||||
**Manual CHANGELOG updates**: CHANGELOG files must be manually updated before release
|
||||
|
||||
3. **格式一致性**:請保持 CHANGELOG 格式的一致性以確保自動提取正常運作
|
||||
**Format consistency**: Maintain CHANGELOG format consistency for proper auto-extraction
|
||||
|
||||
## 🗂️ 舊版本目錄清理 / Old Version Directory Cleanup
|
||||
|
||||
現有的版本目錄(`v2.2.1` 到 `v2.2.5`)可以選擇性保留作為歷史記錄,或者清理以簡化專案結構。
|
||||
|
||||
Existing version directories (`v2.2.1` to `v2.2.5`) can optionally be kept for historical records or cleaned up to simplify project structure.
|
||||
|
||||
## 🚀 優點 / Benefits
|
||||
|
||||
- ✅ 減少維護負擔 / Reduced maintenance burden
|
||||
- ✅ 單一真實來源 / Single source of truth
|
||||
- ✅ 簡化的專案結構 / Simplified project structure
|
||||
- ✅ 自動化的 Release 生成 / Automated release generation
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.1 - Window Optimization & Unified Settings Interface
|
||||
|
||||
## 🌟 Highlights
|
||||
This release primarily addresses GUI window size constraints, implements smart window state saving mechanisms, and optimizes the unified settings interface.
|
||||
|
||||
## 🚀 Improvements
|
||||
- 🖥️ **Window Size Constraint Removal**: Removed GUI main window minimum size limit from 1000×800 to 400×300, allowing users to freely adjust window size for different use cases
|
||||
- 💾 **Real-time Window State Saving**: Implemented real-time saving mechanism for window size and position changes, with debounce delay to avoid excessive I/O operations
|
||||
- ⚙️ **Unified Settings Interface Optimization**: Improved GUI settings page configuration saving logic to avoid setting conflicts, ensuring correct window positioning and size settings
|
||||
- 🎯 **Smart Window Size Saving**: In "Always center display" mode, correctly saves window size (but not position); in "Smart positioning" mode, saves complete window state
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
- 🔧 **Window Size Constraint**: Fixed GUI window unable to resize to small dimensions issue (fixes #10 part one)
|
||||
- 🛡️ **Setting Conflicts**: Fixed potential configuration conflicts during settings save operations
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.1 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reporting: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Issues Addressed: #10 (partially completed)
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.1 - 窗口优化与统一设置接口
|
||||
|
||||
## 🌟 亮点
|
||||
本版本主要解决了 GUI 窗口大小限制问题,实现了窗口状态的智能保存机制,并优化了设置接口的统一性。
|
||||
|
||||
## 🚀 改进功能
|
||||
- 🖥️ **窗口大小限制解除**: 解除 GUI 主窗口最小大小限制,从 1000×800 降至 400×300,让用户可以自由调整窗口大小以符合不同使用场景
|
||||
- 💾 **窗口状态实时保存**: 实现窗口大小与位置的即时保存机制,支持防抖延迟避免过度频繁的 I/O 操作
|
||||
- ⚙️ **统一设置接口优化**: 改进 GUI 设置版面的配置保存逻辑,避免设置冲突,确保窗口定位与大小设置的正确性
|
||||
- 🎯 **智能窗口大小保存**: 「总是在主屏幕中心显示」模式下正确保存窗口大小(但不保存位置),「智能定位」模式下保存完整的窗口状态
|
||||
|
||||
## 🐛 问题修复
|
||||
- 🔧 **窗口大小限制**: 解决 GUI 窗口无法调整至小尺寸的问题 (fixes #10 第一部分)
|
||||
- 🛡️ **设置冲突**: 修复设置保存时可能出现的配置冲突问题
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.1 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 解决问题: #10 (部分完成)
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.1 - 視窗優化與統一設定接口
|
||||
|
||||
## 🌟 亮點
|
||||
本版本主要解決了 GUI 視窗大小限制問題,實現了視窗狀態的智能保存機制,並優化了設定接口的統一性。
|
||||
|
||||
## 🚀 改進功能
|
||||
- 🖥️ **視窗大小限制解除**: 解除 GUI 主視窗最小大小限制,從 1000×800 降至 400×300,讓用戶可以自由調整視窗大小以符合不同使用場景
|
||||
- 💾 **視窗狀態實時保存**: 實現視窗大小與位置的即時保存機制,支援防抖延遲避免過度頻繁的 I/O 操作
|
||||
- ⚙️ **統一設定接口優化**: 改進 GUI 設定版面的配置保存邏輯,避免設定衝突,確保視窗定位與大小設定的正確性
|
||||
- 🎯 **智能視窗大小保存**: 「總是在主螢幕中心顯示」模式下正確保存視窗大小(但不保存位置),「智能定位」模式下保存完整的視窗狀態
|
||||
|
||||
## 🐛 問題修復
|
||||
- 🔧 **視窗大小限制**: 解決 GUI 視窗無法調整至小尺寸的問題 (fixes #10 第一部分)
|
||||
- 🛡️ **設定衝突**: 修復設定保存時可能出現的配置衝突問題
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.1 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 解決問題: #10 (部分完成)
|
@ -1,30 +0,0 @@
|
||||
# Release v2.2.2 - Timeout Auto-cleanup Fix
|
||||
|
||||
## 🌟 Highlights
|
||||
This version fixes a critical resource management issue where GUI/Web UI interfaces were not properly closed when MCP sessions ended due to timeout, causing the interfaces to remain open and unresponsive.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
- 🔄 **Timeout Auto-cleanup**: Fixed GUI/Web UI not automatically closing after MCP session timeout (default 600 seconds)
|
||||
- 🛡️ **Resource Management Optimization**: Improved timeout handling mechanism to ensure proper cleanup and closure of all UI resources on timeout
|
||||
- ⚡ **Enhanced Timeout Detection**: Strengthened timeout detection logic to correctly handle timeout events in various scenarios
|
||||
- 🔧 **Interface Response Improvement**: Enhanced Web UI frontend handling of session timeout events
|
||||
|
||||
## 🚀 Technical Improvements
|
||||
- 📦 **Web Session Management**: Refactored WebFeedbackSession timeout handling logic
|
||||
- 🎯 **QTimer Integration**: Introduced precise QTimer timeout control mechanism in GUI
|
||||
- 🌐 **Frontend Communication Optimization**: Improved timeout message communication between Web UI frontend and backend
|
||||
- 🧹 **Resource Cleanup Mechanism**: Added _cleanup_resources_on_timeout method to ensure thorough cleanup
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.2 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reporting: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Fixed Issue: #5 (GUI/Web UI timeout cleanup)
|
@ -1,30 +0,0 @@
|
||||
# Release v2.2.2 - 超时自动清理修复
|
||||
|
||||
## 🌟 亮点
|
||||
本版本修复了一个重要的资源管理问题:当 MCP session 因超时结束时,GUI/Web UI 界面没有正确关闭,导致界面持续显示而无法正常关闭。
|
||||
|
||||
## 🐛 问题修复
|
||||
- 🔄 **超时自动清理**: 修复 GUI/Web UI 在 MCP session timeout (默认 600 秒) 后没有自动关闭的问题
|
||||
- 🛡️ **资源管理优化**: 改进超时处理机制,确保在超时时正确清理和关闭所有 UI 资源
|
||||
- ⚡ **超时检测增强**: 加强超时检测逻辑,确保在各种情况下都能正确处理超时事件
|
||||
- 🔧 **界面响应改进**: 改善 Web UI 前端对 session timeout 事件的处理响应
|
||||
|
||||
## 🚀 技术改进
|
||||
- 📦 **Web Session 管理**: 重构 WebFeedbackSession 的超时处理逻辑
|
||||
- 🎯 **QTimer 整合**: 在 GUI 中引入精确的 QTimer 超时控制机制
|
||||
- 🌐 **前端通信优化**: 改进 Web UI 前端与后端的超时消息传递
|
||||
- 🧹 **资源清理机制**: 新增 _cleanup_resources_on_timeout 方法确保彻底清理
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.2 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 解决问题: #5 (GUI/Web UI timeout cleanup)
|
@ -1,30 +0,0 @@
|
||||
# Release v2.2.2 - 超時自動清理修復
|
||||
|
||||
## 🌟 亮點
|
||||
本版本修復了一個重要的資源管理問題:當 MCP session 因超時結束時,GUI/Web UI 介面沒有正確關閉,導致介面持續顯示而無法正常關閉。
|
||||
|
||||
## 🐛 問題修復
|
||||
- 🔄 **超時自動清理**: 修復 GUI/Web UI 在 MCP session timeout (預設 600 秒) 後沒有自動關閉的問題
|
||||
- 🛡️ **資源管理優化**: 改進超時處理機制,確保在超時時正確清理和關閉所有 UI 資源
|
||||
- ⚡ **超時檢測增強**: 加強超時檢測邏輯,確保在各種情況下都能正確處理超時事件
|
||||
- 🔧 **介面回應改進**: 改善 Web UI 前端對 session timeout 事件的處理回應
|
||||
|
||||
## 🚀 技術改進
|
||||
- 📦 **Web Session 管理**: 重構 WebFeedbackSession 的超時處理邏輯
|
||||
- 🎯 **QTimer 整合**: 在 GUI 中引入精確的 QTimer 超時控制機制
|
||||
- 🌐 **前端通訊優化**: 改進 Web UI 前端與後端的超時訊息傳遞
|
||||
- 🧹 **資源清理機制**: 新增 _cleanup_resources_on_timeout 方法確保徹底清理
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.2 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 解決問題: #5 (GUI/Web UI timeout cleanup)
|
@ -1,42 +0,0 @@
|
||||
# Release v2.2.3 - Timeout Control & Image Settings Enhancement
|
||||
|
||||
## 🌟 Highlights
|
||||
This version introduces user-controllable timeout settings and flexible image upload configuration options, while improving UV Cache management tools to enhance the overall user experience.
|
||||
|
||||
## ✨ New Features
|
||||
- ⏰ **User Timeout Control**: Added customizable timeout settings with flexible range from 30 seconds to 2 hours
|
||||
- ⏱️ **Countdown Timer**: Real-time countdown timer display at the top of the interface for visual time reminders
|
||||
- 🖼️ **Image Size Limits**: Added image upload size limit settings (unlimited/1MB/3MB/5MB)
|
||||
- 🔧 **Base64 Compatibility Mode**: Added Base64 detail mode to improve image recognition compatibility with AI models
|
||||
- 🧹 **UV Cache Management Tool**: Added `cleanup_cache.py` script to help manage and clean UV cache space
|
||||
|
||||
## 🚀 Improvements
|
||||
- 📚 **Documentation Structure Optimization**: Reorganized documentation directory structure, moved images to `docs/{language}/images/` paths
|
||||
- 📖 **Cache Management Guide**: Added detailed UV Cache management guide with automated cleanup solutions
|
||||
- 🎯 **Smart Compatibility Hints**: Automatically display Base64 compatibility mode suggestions when image upload fails
|
||||
- 🔄 **Settings Sync Mechanism**: Improved image settings synchronization between different interface modes
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
- 🛡️ **Timeout Handling Optimization**: Improved coordination between user-defined timeout and MCP system timeout
|
||||
- 🖥️ **Interface Auto-close**: Fixed interface auto-close and resource cleanup logic after timeout
|
||||
- 📱 **Responsive Layout**: Optimized timeout control component display on small screen devices
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
- 🎛️ **Timeout Control Architecture**: Implemented separated design for frontend countdown timer and backend timeout handling
|
||||
- 📊 **Image Processing Optimization**: Improved image upload size checking and format validation mechanisms
|
||||
- 🗂️ **Settings Persistence**: Enhanced settings saving mechanism to ensure correct saving and loading of user preferences
|
||||
- 🧰 **Tool Script Enhancement**: Added cross-platform cache cleanup tool with support for force cleanup and preview modes
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.3 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reporting: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Related PRs: #22 (Timeout Control Feature), #19 (Image Settings Feature)
|
@ -1,42 +0,0 @@
|
||||
# Release v2.2.3 - 超时控制与图片设置增强
|
||||
|
||||
## 🌟 亮点
|
||||
本版本新增了用户可控制的超时设置功能,以及灵活的图片上传设置选项,同时完善了 UV Cache 管理工具,提升整体使用体验。
|
||||
|
||||
## ✨ 新功能
|
||||
- ⏰ **用户超时控制**: 新增可自定义的超时设置功能,支持 30 秒至 2 小时的弹性设置
|
||||
- ⏱️ **倒数计时器**: 界面顶部显示实时倒数计时器,提供可视化的时间提醒
|
||||
- 🖼️ **图片大小限制**: 新增图片上传大小限制设置(无限制/1MB/3MB/5MB)
|
||||
- 🔧 **Base64 兼容模式**: 新增 Base64 详细模式,提升部分 AI 模型的图片识别兼容性
|
||||
- 🧹 **UV Cache 管理工具**: 新增 `cleanup_cache.py` 脚本,协助管理和清理 UV cache 空间
|
||||
|
||||
## 🚀 改进功能
|
||||
- 📚 **文档结构优化**: 重新整理文档目录结构,将图片移至 `docs/{语言}/images/` 路径
|
||||
- 📖 **Cache 管理指南**: 新增详细的 UV Cache 管理指南,包含自动化清理方案
|
||||
- 🎯 **智能兼容性提示**: 当图片上传失败时自动显示 Base64 兼容模式建议
|
||||
- 🔄 **设置同步机制**: 改进图片设置在不同界面模式间的同步机制
|
||||
|
||||
## 🐛 问题修复
|
||||
- 🛡️ **超时处理优化**: 改进用户自定义超时与 MCP 系统超时的协调机制
|
||||
- 🖥️ **界面自动关闭**: 修复超时后界面自动关闭和资源清理逻辑
|
||||
- 📱 **响应式布局**: 优化超时控制组件在小屏幕设备上的显示效果
|
||||
|
||||
## 🔧 技术改进
|
||||
- 🎛️ **超时控制架构**: 实现前端倒数计时器与后端超时处理的分离设计
|
||||
- 📊 **图片处理优化**: 改进图片上传的大小检查和格式验证机制
|
||||
- 🗂️ **设置持久化**: 增强设置保存机制,确保用户偏好的正确保存和载入
|
||||
- 🧰 **工具脚本增强**: 新增跨平台的 cache 清理工具,支持强制清理和预览模式
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.3 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 相关 PR: #22 (超时控制功能), #19 (图片设置功能)
|
@ -1,42 +0,0 @@
|
||||
# Release v2.2.3 - 超時控制與圖片設定增強
|
||||
|
||||
## 🌟 亮點
|
||||
本版本新增了用戶可控制的超時設定功能,以及靈活的圖片上傳設定選項,同時完善了 UV Cache 管理工具,提升整體使用體驗。
|
||||
|
||||
## ✨ 新功能
|
||||
- ⏰ **用戶超時控制**: 新增可自訂的超時設定功能,支援 30 秒至 2 小時的彈性設定
|
||||
- ⏱️ **倒數計時器**: 介面頂部顯示即時倒數計時器,提供視覺化的時間提醒
|
||||
- 🖼️ **圖片大小限制**: 新增圖片上傳大小限制設定(無限制/1MB/3MB/5MB)
|
||||
- 🔧 **Base64 相容模式**: 新增 Base64 詳細模式,提升部分 AI 模型的圖片識別相容性
|
||||
- 🧹 **UV Cache 管理工具**: 新增 `cleanup_cache.py` 腳本,協助管理和清理 UV cache 空間
|
||||
|
||||
## 🚀 改進功能
|
||||
- 📚 **文檔結構優化**: 重新整理文檔目錄結構,將圖片移至 `docs/{語言}/images/` 路徑
|
||||
- 📖 **Cache 管理指南**: 新增詳細的 UV Cache 管理指南,包含自動化清理方案
|
||||
- 🎯 **智能相容性提示**: 當圖片上傳失敗時自動顯示 Base64 相容模式建議
|
||||
- 🔄 **設定同步機制**: 改進圖片設定在不同介面模式間的同步機制
|
||||
|
||||
## 🐛 問題修復
|
||||
- 🛡️ **超時處理優化**: 改進用戶自訂超時與 MCP 系統超時的協調機制
|
||||
- 🖥️ **介面自動關閉**: 修復超時後介面自動關閉和資源清理邏輯
|
||||
- 📱 **響應式佈局**: 優化超時控制元件在小螢幕設備上的顯示效果
|
||||
|
||||
## 🔧 技術改進
|
||||
- 🎛️ **超時控制架構**: 實現前端倒數計時器與後端超時處理的分離設計
|
||||
- 📊 **圖片處理優化**: 改進圖片上傳的大小檢查和格式驗證機制
|
||||
- 🗂️ **設定持久化**: 增強設定保存機制,確保用戶偏好的正確保存和載入
|
||||
- 🧰 **工具腳本增強**: 新增跨平台的 cache 清理工具,支援強制清理和預覽模式
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.3 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 相關 PR: #22 (超時控制功能), #19 (圖片設定功能)
|
@ -1,23 +0,0 @@
|
||||
# Release v2.2.4 - GUI Experience Optimization & Bug Fixes
|
||||
|
||||
## 🌟 Highlights
|
||||
This version focuses on GUI user experience optimization, fixing image copy-paste duplication issues, reorganizing localization file structure, and improving interface text readability.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
- 🖼️ **Image Duplicate Paste Fix**: Fixed the issue where Ctrl+V image pasting in GUI would create duplicate images
|
||||
- 🌐 **Localization Switch Fix**: Fixed image settings area text not translating correctly when switching languages
|
||||
- 📝 **Font Readability Improvement**: Adjusted font sizes in image settings area for better readability
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.4 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reports: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Project Homepage: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
@ -1,23 +0,0 @@
|
||||
# Release v2.2.4 - GUI 体验优化与问题修复
|
||||
|
||||
## 🌟 亮点
|
||||
本版本专注于 GUI 使用体验的优化,修复了图片复制粘贴的重复问题,重新组织了语系文件结构,并改善了界面文字的可读性。
|
||||
|
||||
## 🐛 问题修复
|
||||
- 🖼️ **图片重复粘贴修复**: 解决 GUI 界面中使用 Ctrl+V 复制粘贴图片时出现重复粘贴的问题
|
||||
- 🌐 **语系切换修复**: 修复图片设定区域在语言切换时文字没有正确翻译的问题
|
||||
- 📝 **字体可读性改善**: 调整图片设定区域的字体大小,提升文字可读性
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.4 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 项目首页: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
@ -1,23 +0,0 @@
|
||||
# Release v2.2.4 - GUI 體驗優化與問題修復
|
||||
|
||||
## 🌟 亮點
|
||||
本版本專注於 GUI 使用體驗的優化,修復了圖片複製貼上的重複問題,重新組織了語系檔案結構,並改善了介面文字的可讀性。
|
||||
|
||||
## 🐛 問題修復
|
||||
- 🖼️ **圖片重複貼上修復**: 解決 GUI 介面中使用 Ctrl+V 複製貼上圖片時出現重複貼上的問題
|
||||
- 🌐 **語系切換修復**: 修復圖片設定區域在語言切換時文字沒有正確翻譯的問題
|
||||
- 📝 **字體可讀性改善**: 調整圖片設定區域的字體大小,提升文字可讀性
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.4 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 專案首頁: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.5 - WSL Environment Support & Cross-Platform Enhancement
|
||||
|
||||
## 🌟 Highlights
|
||||
This version introduces comprehensive support for WSL (Windows Subsystem for Linux) environments, enabling WSL users to seamlessly use this tool with automatic Windows browser launching, significantly improving cross-platform development experience.
|
||||
|
||||
## ✨ New Features
|
||||
- 🐧 **WSL Environment Detection**: Automatically identifies WSL environments and provides specialized support logic
|
||||
- 🌐 **Smart Browser Launching**: Automatically invokes Windows browser in WSL environments with multiple launch methods
|
||||
- 🔧 **Cross-Platform Testing Enhancement**: Test functionality integrates WSL detection for improved test coverage
|
||||
|
||||
## 🚀 Improvements
|
||||
- 🎯 **Environment Detection Optimization**: Improved remote environment detection logic, WSL no longer misidentified as remote environment
|
||||
- 📊 **System Information Enhancement**: System information tool now displays WSL environment status
|
||||
- 🧪 **Testing Experience Improvement**: Test mode automatically attempts browser launching for better testing experience
|
||||
|
||||
## 📦 Installation & Update
|
||||
```bash
|
||||
# Quick test latest version
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# Update to specific version
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 Related Links
|
||||
- Full Documentation: [README.md](../../README.md)
|
||||
- Issue Reports: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- Project Homepage: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.5 - WSL 环境支持与跨平台增强
|
||||
|
||||
## 🌟 亮点
|
||||
本版本新增了 WSL (Windows Subsystem for Linux) 环境的完整支持,让 WSL 用户能够无缝使用本工具并自动启动 Windows 浏览器,大幅提升跨平台开发体验。
|
||||
|
||||
## ✨ 新功能
|
||||
- 🐧 **WSL 环境检测**: 自动识别 WSL 环境,提供专门的支持逻辑
|
||||
- 🌐 **智能浏览器启动**: WSL 环境下自动调用 Windows 浏览器,支持多种启动方式
|
||||
- 🔧 **跨平台测试增强**: 测试功能整合 WSL 检测,提升测试覆盖率
|
||||
|
||||
## 🚀 改进功能
|
||||
- 🎯 **环境检测优化**: 改进远程环境检测逻辑,WSL 不再被误判为远程环境
|
||||
- 📊 **系统信息增强**: 系统信息工具新增 WSL 环境状态显示
|
||||
- 🧪 **测试体验提升**: 测试模式下自动尝试启动浏览器,提供更好的测试体验
|
||||
|
||||
## 📦 安装与更新
|
||||
```bash
|
||||
# 快速测试最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 相关链接
|
||||
- 完整文档: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 问题报告: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 项目首页: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
@ -1,28 +0,0 @@
|
||||
# Release v2.2.5 - WSL 環境支援與跨平台增強
|
||||
|
||||
## 🌟 亮點
|
||||
本版本新增了 WSL (Windows Subsystem for Linux) 環境的完整支援,讓 WSL 用戶能夠無縫使用本工具並自動啟動 Windows 瀏覽器,大幅提升跨平台開發體驗。
|
||||
|
||||
## ✨ 新功能
|
||||
- 🐧 **WSL 環境檢測**: 自動識別 WSL 環境,提供專門的支援邏輯
|
||||
- 🌐 **智能瀏覽器啟動**: WSL 環境下自動調用 Windows 瀏覽器,支援多種啟動方式
|
||||
- 🔧 **跨平台測試增強**: 測試功能整合 WSL 檢測,提升測試覆蓋率
|
||||
|
||||
## 🚀 改進功能
|
||||
- 🎯 **環境檢測優化**: 改進遠端環境檢測邏輯,WSL 不再被誤判為遠端環境
|
||||
- 📊 **系統資訊增強**: 系統資訊工具新增 WSL 環境狀態顯示
|
||||
- 🧪 **測試體驗提升**: 測試模式下自動嘗試啟動瀏覽器,提供更好的測試體驗
|
||||
|
||||
## 📦 安裝與更新
|
||||
```bash
|
||||
# 快速測試最新版本
|
||||
uvx mcp-feedback-enhanced@latest test --gui
|
||||
|
||||
# 更新到特定版本
|
||||
uvx mcp-feedback-enhanced@v2.2.5 test
|
||||
```
|
||||
|
||||
## 🔗 相關連結
|
||||
- 完整文檔: [README.zh-TW.md](../../README.zh-TW.md)
|
||||
- 問題回報: [GitHub Issues](https://github.com/Minidoracat/mcp-feedback-enhanced/issues)
|
||||
- 專案首頁: [GitHub Repository](https://github.com/Minidoracat/mcp-feedback-enhanced)
|
BIN
docs/en/images/ssh-remote-connect-url.png
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
docs/en/images/ssh-remote-debug-port.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
docs/en/images/ssh-remote-port-setting.png
Normal file
After Width: | Height: | Size: 28 KiB |
115
docs/en/ssh-remote/browser-launch-issues.md
Normal file
@ -0,0 +1,115 @@
|
||||
# SSH Remote Environment Browser Launch Issues Solution
|
||||
|
||||
## Problem Description
|
||||
|
||||
When using MCP Feedback Enhanced in SSH Remote environments (such as Cursor SSH Remote, VS Code Remote SSH, etc.), you may encounter the following issues:
|
||||
|
||||
- 🚫 Browser cannot launch automatically
|
||||
- ❌ "Unable to launch browser" error message
|
||||
- 🔗 Web UI cannot open in local browser
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
SSH Remote environment limitations:
|
||||
1. **Display Environment Isolation**: Remote server has no graphical interface environment
|
||||
2. **Network Isolation**: Remote ports cannot be directly accessed locally
|
||||
3. **No Browser Available**: Remote environments typically don't have browsers installed
|
||||
|
||||
## Solution
|
||||
|
||||
### Step 1: Configure Port (Optional)
|
||||
|
||||
MCP Feedback Enhanced uses port **8765** by default, but you can customize the port:
|
||||
|
||||

|
||||
|
||||
### Step 2: Wait for MCP Call
|
||||
|
||||
**Important**: Do not manually start the Web UI. Instead, wait for the AI model to call the MCP tool to automatically start it.
|
||||
|
||||
When the AI model calls the `interactive_feedback` tool, the system will automatically start the Web UI.
|
||||
|
||||
### Step 3: Check Port and Connect
|
||||
|
||||
If the browser doesn't launch automatically, you need to manually connect to the Web UI:
|
||||
|
||||
#### Method 1: Check Port Forwarding
|
||||
Check your SSH Remote environment's port forwarding settings to find the corresponding local port:
|
||||
|
||||

|
||||
|
||||
#### Method 2: Use Debug Mode
|
||||
Enable Debug mode in your IDE, select "Output" → "MCP Log" to see the Web UI URL:
|
||||
|
||||

|
||||
|
||||
### Step 4: Open in Local Browser
|
||||
|
||||
1. Copy the URL (usually `http://localhost:8765` or another port)
|
||||
2. Paste and open in your local browser
|
||||
3. Start using the Web UI for feedback
|
||||
|
||||
## Port Forwarding Setup
|
||||
|
||||
### VS Code Remote SSH
|
||||
1. Press `Ctrl+Shift+P` in VS Code
|
||||
2. Type "Forward a Port"
|
||||
3. Enter the port number (default 8765)
|
||||
4. Access `http://localhost:8765` in your local browser
|
||||
|
||||
### Cursor SSH Remote
|
||||
1. Check Cursor's port forwarding settings
|
||||
2. Manually add port forwarding rule (port 8765)
|
||||
3. Access the forwarded port in your local browser
|
||||
|
||||
## Important Reminders
|
||||
|
||||
### ⚠️ Do Not Start Manually
|
||||
**Do NOT** manually execute commands like `uvx mcp-feedback-enhanced test --web`, as this cannot integrate with the MCP system.
|
||||
|
||||
### ✅ Correct Process
|
||||
1. Wait for AI model to call MCP tool
|
||||
2. System automatically starts Web UI
|
||||
3. Check port forwarding or Debug logs
|
||||
4. Open corresponding URL in local browser
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### Q: Why can't the browser launch automatically in SSH Remote environment?
|
||||
A: SSH Remote environment is headless with no graphical interface, so browsers cannot be launched directly. You need to access through port forwarding in your local browser.
|
||||
|
||||
### Q: How to confirm if Web UI started successfully?
|
||||
A: Check IDE's Debug output or MCP Log. If you see "Web UI started" message, it means successful startup.
|
||||
|
||||
### Q: What if the port is occupied?
|
||||
A: Modify the port number in MCP settings, or wait for the system to automatically select another available port.
|
||||
|
||||
### Q: Can't find port forwarding settings?
|
||||
A: Check your SSH Remote tool documentation, or use Debug mode to view the URL in MCP Log.
|
||||
|
||||
### Q: Why am I not receiving new MCP feedback?
|
||||
A: There might be a WebSocket connection issue. **Solution**: Simply refresh the browser page to re-establish the WebSocket connection.
|
||||
|
||||
### Q: Why isn't MCP being called?
|
||||
A: Please confirm the MCP tool status shows green light (indicating normal operation). **Solution**:
|
||||
- Check the MCP tool status indicator in your IDE
|
||||
- If not green, try toggling the MCP tool on/off repeatedly
|
||||
- Wait a few seconds for the system to reconnect
|
||||
|
||||
### Q: Why can't Augment start MCP?
|
||||
A: Sometimes errors may prevent the MCP tool from showing green status. **Solution**:
|
||||
- Completely close and restart VS Code or Cursor
|
||||
- Reopen the project
|
||||
- Wait for MCP tool to reload and show green light
|
||||
|
||||
## v2.3.0 Improvements
|
||||
|
||||
Improvements for SSH Remote environments in this version:
|
||||
- ✅ Automatic SSH Remote environment detection
|
||||
- ✅ Clear guidance when browser cannot launch
|
||||
- ✅ Display correct access URL
|
||||
- ✅ Improved error messages and solution suggestions
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [Main Documentation](../../README.md)
|
BIN
docs/zh-CN/images/ssh-remote-connect-url.png
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
docs/zh-CN/images/ssh-remote-debug-port.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
docs/zh-CN/images/ssh-remote-port-setting.png
Normal file
After Width: | Height: | Size: 28 KiB |
115
docs/zh-CN/ssh-remote/browser-launch-issues.md
Normal file
@ -0,0 +1,115 @@
|
||||
# SSH Remote 环境浏览器启动问题解决方案
|
||||
|
||||
## 问题描述
|
||||
|
||||
在 SSH Remote 环境(如 Cursor SSH Remote、VS Code Remote SSH 等)中使用 MCP Feedback Enhanced 时,可能会遇到以下问题:
|
||||
|
||||
- 🚫 浏览器无法自动启动
|
||||
- ❌ 显示「无法启动浏览器」错误
|
||||
- 🔗 Web UI 无法在本地浏览器中打开
|
||||
|
||||
## 原因分析
|
||||
|
||||
SSH Remote 环境的限制:
|
||||
1. **显示环境隔离**: 远程服务器没有图形界面环境
|
||||
2. **网络隔离**: 远程端口无法直接在本地访问
|
||||
3. **浏览器不存在**: 远程环境通常没有安装浏览器
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 步骤一:设置端口(可选)
|
||||
|
||||
MCP Feedback Enhanced 默认使用端口 **8765**,您也可以自定义端口:
|
||||
|
||||

|
||||
|
||||
### 步骤二:等待 MCP 调用
|
||||
|
||||
**重要**:不要手动启动 Web UI,而是要等待 AI 模型调用 MCP 工具时自动启动。
|
||||
|
||||
当 AI 模型调用 `interactive_feedback` 工具时,系统会自动启动 Web UI。
|
||||
|
||||
### 步骤三:查看端口并连接
|
||||
|
||||
如果浏览器没有自动启动,您需要手动连接到 Web UI:
|
||||
|
||||
#### 方法一:查看端口转发
|
||||
查看您的 SSH Remote 环境的端口转发设置,找到对应的本地端口:
|
||||
|
||||

|
||||
|
||||
#### 方法二:使用 Debug 模式查看
|
||||
在 IDE 中开启 Debug 模式,选择「输出」→「MCP Log」,可以看到 Web UI 的 URL:
|
||||
|
||||

|
||||
|
||||
### 步骤四:在本地浏览器打开
|
||||
|
||||
1. 复制 URL(通常是 `http://localhost:8765` 或其他端口)
|
||||
2. 在本地浏览器中粘贴并打开
|
||||
3. 开始使用 Web UI 进行反馈
|
||||
|
||||
## 端口转发设置
|
||||
|
||||
### VS Code Remote SSH
|
||||
1. 在 VS Code 中按 `Ctrl+Shift+P`
|
||||
2. 输入 "Forward a Port"
|
||||
3. 输入端口号(默认 8765)
|
||||
4. 在本地浏览器中访问 `http://localhost:8765`
|
||||
|
||||
### Cursor SSH Remote
|
||||
1. 查看 Cursor 的端口转发设置
|
||||
2. 手动添加端口转发规则(端口 8765)
|
||||
3. 在本地浏览器中访问转发的端口
|
||||
|
||||
## 重要提醒
|
||||
|
||||
### ⚠️ 不要手动启动
|
||||
**请勿**手动执行 `uvx mcp-feedback-enhanced test --web` 等指令,这样无法与 MCP 系统整合。
|
||||
|
||||
### ✅ 正确流程
|
||||
1. 等待 AI 模型调用 MCP 工具
|
||||
2. 系统自动启动 Web UI
|
||||
3. 查看端口转发或 Debug 日志
|
||||
4. 在本地浏览器中打开对应 URL
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么在 SSH Remote 环境中无法自动打开浏览器?
|
||||
A: SSH Remote 环境是无头环境(headless),没有图形界面,因此无法直接启动浏览器。需要通过端口转发在本地浏览器中访问。
|
||||
|
||||
### Q: 如何确认 Web UI 是否正常启动?
|
||||
A: 查看 IDE 的 Debug 输出或 MCP Log,如果看到 "Web UI 已启动" 的信息,表示启动成功。
|
||||
|
||||
### Q: 端口被占用怎么办?
|
||||
A: 在 MCP 设置中修改端口号,或者等待系统自动选择其他可用端口。
|
||||
|
||||
### Q: 找不到端口转发设置怎么办?
|
||||
A: 查看您的 SSH Remote 工具文档,或使用 Debug 模式查看 MCP Log 中的 URL。
|
||||
|
||||
### Q: 为什么没有接收到 MCP 新的反馈?
|
||||
A: 可能是 WebSocket 连接有问题。**解决方法**:直接重新刷新浏览器页面,这会重新建立 WebSocket 连接。
|
||||
|
||||
### Q: 为什么没有调用出 MCP?
|
||||
A: 请确认 MCP 工具状态为绿灯(表示正常运作)。**解决方法**:
|
||||
- 检查 IDE 中的 MCP 工具状态指示灯
|
||||
- 如果不是绿灯,尝试反复开关 MCP 工具
|
||||
- 等待几秒钟让系统重新连接
|
||||
|
||||
### Q: 为什么 Augment 无法启动 MCP?
|
||||
A: 有时候可能会有错误导致 MCP 工具没有显示绿灯状态。**解决方法**:
|
||||
- 完全关闭并重新启动 VS Code 或 Cursor
|
||||
- 重新打开项目
|
||||
- 等待 MCP 工具重新加载并显示绿灯
|
||||
|
||||
## v2.3.0 改进
|
||||
|
||||
本版本针对 SSH Remote 环境的改进:
|
||||
- ✅ 自动检测 SSH Remote 环境
|
||||
- ✅ 在无法启动浏览器时提供清晰的指引
|
||||
- ✅ 显示正确的访问 URL
|
||||
- ✅ 改善错误提示和解决建议
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [主要文档](../../README.zh-CN.md)
|
BIN
docs/zh-TW/images/ssh-remote-connect-url.png
Normal file
After Width: | Height: | Size: 117 KiB |
BIN
docs/zh-TW/images/ssh-remote-debug-port.png
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
docs/zh-TW/images/ssh-remote-port-setting.png
Normal file
After Width: | Height: | Size: 28 KiB |
103
docs/zh-TW/ssh-remote/browser-launch-issues.md
Normal file
@ -0,0 +1,103 @@
|
||||
# SSH Remote 環境瀏覽器啟動問題解決方案
|
||||
|
||||
## 問題描述
|
||||
|
||||
在 SSH Remote 環境(如 Cursor SSH Remote、VS Code Remote SSH 、WSL 等)中使用 MCP Feedback Enhanced 時,可能會遇到以下問題:
|
||||
|
||||
- 🚫 瀏覽器無法自動啟動
|
||||
- ❌ 顯示「無法啟動瀏覽器」錯誤
|
||||
- 🔗 Web UI 無法在本地瀏覽器中開啟
|
||||
|
||||
## 原因分析
|
||||
|
||||
SSH Remote 環境的限制:
|
||||
1. **顯示環境隔離**: 遠端伺服器沒有圖形界面環境
|
||||
2. **網路隔離**: 遠端端口無法直接在本地訪問
|
||||
3. **瀏覽器不存在**: 遠端環境通常沒有安裝瀏覽器
|
||||
|
||||
## 解決方案
|
||||
|
||||
### 步驟一:設定端口(可選)
|
||||
|
||||
MCP Feedback Enhanced 預設使用端口 **8765**,您也可以自定義端口:
|
||||
|
||||

|
||||
|
||||
### 步驟二:等待 MCP 呼叫
|
||||
|
||||
**重要**:不要手動啟動 Web UI,而是要等待 AI 模型呼叫 MCP 工具時自動啟動。
|
||||
|
||||
當 AI 模型呼叫 `interactive_feedback` 工具時,系統會自動啟動 Web UI。
|
||||
|
||||
### 步驟三:查看端口並連接
|
||||
|
||||
如果瀏覽器沒有自動啟動,您需要手動連接到 Web UI:
|
||||
|
||||
#### 方法一:查看端口轉發
|
||||
查看您的 SSH Remote 環境的端口轉發設定,找到對應的本地端口:
|
||||
|
||||

|
||||
|
||||
#### 方法二:使用 Debug 模式查看
|
||||
在 IDE 中開啟 Debug 模式,選擇「輸出」→「MCP Log」,可以看到 Web UI 的 URL:
|
||||
|
||||

|
||||
|
||||
### 步驟四:在本地瀏覽器開啟
|
||||
|
||||
1. 複製 URL(通常是 `http://localhost:8765` 或其他端口)
|
||||
2. 在本地瀏覽器中貼上並開啟
|
||||
3. 開始使用 Web UI 進行回饋
|
||||
|
||||
## 端口轉發設定
|
||||
|
||||
### VS Code Remote SSH
|
||||
1. 在 VS Code 中按 `Ctrl+Shift+P`
|
||||
2. 輸入 "Forward a Port"
|
||||
3. 輸入端口號(預設 8765)
|
||||
4. 在本地瀏覽器中訪問 `http://localhost:8765`
|
||||
|
||||
### Cursor SSH Remote
|
||||
1. 查看 Cursor 的端口轉發設定
|
||||
2. 手動添加端口轉發規則(端口 8765)
|
||||
3. 在本地瀏覽器中訪問轉發的端口
|
||||
|
||||
## 重要提醒
|
||||
|
||||
### ⚠️ 不要手動啟動
|
||||
**請勿**手動執行 `uvx mcp-feedback-enhanced test --web` 等指令,這樣無法與 MCP 系統整合。
|
||||
|
||||
### ✅ 正確流程
|
||||
1. 等待 AI 模型呼叫 MCP 工具
|
||||
2. 系統自動啟動 Web UI
|
||||
3. 查看端口轉發或 Debug 日誌
|
||||
4. 在本地瀏覽器中開啟對應 URL
|
||||
|
||||
## 常見問題
|
||||
|
||||
### Q: 為什麼在 SSH Remote 環境中無法自動開啟瀏覽器?
|
||||
A: SSH Remote 環境是無頭環境(headless),沒有圖形界面,因此無法直接啟動瀏覽器。需要通過端口轉發在本地瀏覽器中訪問。
|
||||
|
||||
### Q: 如何確認 Web UI 是否正常啟動?
|
||||
A: 查看 IDE 的 Debug 輸出或 MCP Log,如果看到 "Web UI 已啟動" 的訊息,表示啟動成功。
|
||||
|
||||
### Q: 端口被占用怎麼辦?
|
||||
A: 在 MCP 設定中修改端口號,或者等待系統自動選擇其他可用端口。
|
||||
|
||||
### Q: 找不到端口轉發設定怎麼辦?
|
||||
A: 查看您的 SSH Remote 工具文檔,或使用 Debug 模式查看 MCP Log 中的 URL。
|
||||
|
||||
### Q: 為什麼沒有接收到 MCP 新的反饋?
|
||||
A: 可能是 WebSocket 連接有問題。**解決方法**:直接重新整理瀏覽器頁面,這會重新建立 WebSocket 連接。
|
||||
|
||||
### Q: 為什麼沒有呼叫出 MCP?
|
||||
A: 請確認 MCP 工具狀態為綠燈(表示正常運作)。**解決方法**:
|
||||
- 檢查 IDE 中的 MCP 工具狀態指示燈
|
||||
- 如果不是綠燈,嘗試反覆開關 MCP 工具
|
||||
- 等待幾秒鐘讓系統重新連接
|
||||
|
||||
### Q: 為什麼 Augment 無法啟動 MCP?
|
||||
A: 有時候可能會有錯誤導致 MCP 工具沒有顯示綠燈狀態。**解決方法**:
|
||||
- 完全關閉並重新啟動 VS Code 或 Cursor
|
||||
- 重新開啟專案
|
||||
- 等待 MCP 工具重新載入並顯示綠燈
|
86
issues/WSL環境預設使用WebUI修復.md
Normal file
@ -0,0 +1,86 @@
|
||||
# WSL 環境預設使用 Web UI 修復
|
||||
|
||||
## 任務描述
|
||||
修復 WSL 環境中 MCP 服務器錯誤地偵測為可使用 GUI 的問題。WSL 環境應該預設使用 Web UI,因為大多數 WSL 安裝都是 Linux 環境,沒有桌面應用支援。
|
||||
|
||||
## 問題分析
|
||||
根據 MCP log 顯示:
|
||||
```
|
||||
[SERVER] 偵測到 WSL 環境(通過 /proc/version)
|
||||
[SERVER] WSL 環境不被視為遠端環境
|
||||
[SERVER] 成功載入 PySide6,可使用 GUI
|
||||
[SERVER] GUI 可用: True
|
||||
```
|
||||
|
||||
問題在於 `can_use_gui()` 函數沒有考慮 WSL 環境的特殊性:
|
||||
- WSL 環境不被視為遠端環境(正確)
|
||||
- 但 WSL 環境中即使 PySide6 可以載入,也應該預設使用 Web UI
|
||||
|
||||
## 解決方案
|
||||
採用方案 1:在 `can_use_gui()` 函數中直接檢查 WSL 環境
|
||||
|
||||
### 修改內容
|
||||
1. **文件**:`src\mcp_feedback_enhanced\server.py`
|
||||
2. **函數**:`can_use_gui()` (第 203-230 行)
|
||||
3. **修改邏輯**:
|
||||
- 保持現有的遠端環境檢查
|
||||
- 在遠端環境檢查後,添加 WSL 環境檢查
|
||||
- 如果是 WSL 環境,直接返回 `False`
|
||||
- 保持其餘 PySide6 載入檢查邏輯不變
|
||||
|
||||
### 修改前後對比
|
||||
**修改前**:
|
||||
```python
|
||||
def can_use_gui() -> bool:
|
||||
if is_remote_environment():
|
||||
return False
|
||||
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
debug_log("成功載入 PySide6,可使用 GUI")
|
||||
return True
|
||||
# ...
|
||||
```
|
||||
|
||||
**修改後**:
|
||||
```python
|
||||
def can_use_gui() -> bool:
|
||||
if is_remote_environment():
|
||||
return False
|
||||
|
||||
# WSL 環境預設使用 Web UI
|
||||
if is_wsl_environment():
|
||||
debug_log("WSL 環境偵測到,預設使用 Web UI")
|
||||
return False
|
||||
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
debug_log("成功載入 PySide6,可使用 GUI")
|
||||
return True
|
||||
# ...
|
||||
```
|
||||
|
||||
## 預期結果
|
||||
修改後,WSL 環境的 MCP log 應該顯示:
|
||||
```
|
||||
[SERVER] 偵測到 WSL 環境(通過 /proc/version)
|
||||
[SERVER] WSL 環境不被視為遠端環境
|
||||
[SERVER] WSL 環境偵測到,預設使用 Web UI
|
||||
[SERVER] GUI 可用: False
|
||||
[SERVER] 建議介面: Web UI
|
||||
```
|
||||
|
||||
## 影響範圍
|
||||
- ✅ WSL 環境將預設使用 Web UI
|
||||
- ✅ 不影響其他環境的邏輯
|
||||
- ✅ 保持向後兼容性
|
||||
- ✅ 用戶仍可通過 `FORCE_WEB` 環境變數控制介面選擇
|
||||
|
||||
## 測試建議
|
||||
1. 在 WSL 環境中測試 MCP 服務器啟動
|
||||
2. 驗證日誌顯示正確的環境偵測結果
|
||||
3. 確認使用 Web UI 而非 GUI
|
||||
4. 測試 `FORCE_WEB` 環境變數仍然有效
|
||||
|
||||
## 完成時間
|
||||
2025-06-08 01:45:00
|
@ -192,7 +192,46 @@
|
||||
"confirmClearAll": "Are you sure you want to clear all {count} images?",
|
||||
"confirmClearTitle": "Confirm Clear",
|
||||
"fileSizeExceeded": "Image {filename} size is {size}MB, exceeding 1MB limit!\nRecommend using image editing software to compress before uploading.",
|
||||
"dataSizeExceeded": "Image {filename} data size exceeds 1MB limit!"
|
||||
"dataSizeExceeded": "Image {filename} data size exceeds 1MB limit!",
|
||||
"types": {
|
||||
"network": "Network connection issue",
|
||||
"file_io": "File read/write issue",
|
||||
"process": "Process execution issue",
|
||||
"timeout": "Operation timeout",
|
||||
"user_cancel": "User cancelled the operation",
|
||||
"system": "System issue",
|
||||
"permission": "Insufficient permissions",
|
||||
"validation": "Data validation failed",
|
||||
"dependency": "Dependency issue",
|
||||
"config": "Configuration issue"
|
||||
},
|
||||
"solutions": {
|
||||
"network": [
|
||||
"Check network connection",
|
||||
"Verify firewall settings",
|
||||
"Try restarting the application"
|
||||
],
|
||||
"file_io": [
|
||||
"Check if file exists",
|
||||
"Verify file permissions",
|
||||
"Check available disk space"
|
||||
],
|
||||
"process": [
|
||||
"Check if process is running",
|
||||
"Verify system resources",
|
||||
"Try restarting related services"
|
||||
],
|
||||
"timeout": [
|
||||
"Increase timeout settings",
|
||||
"Check network latency",
|
||||
"Retry the operation later"
|
||||
],
|
||||
"permission": [
|
||||
"Run as administrator",
|
||||
"Check file/directory permissions",
|
||||
"Contact system administrator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"languageSelector": "🌐 Language",
|
||||
"languageNames": {
|
||||
|
@ -172,7 +172,46 @@
|
||||
"confirmClearAll": "确定要清除所有 {count} 张图片吗?",
|
||||
"confirmClearTitle": "确认清除",
|
||||
"fileSizeExceeded": "图片 {filename} 大小为 {size}MB,超过 1MB 限制!\n建议使用图片编辑软件压缩后再上传。",
|
||||
"dataSizeExceeded": "图片 {filename} 数据大小超过 1MB 限制!"
|
||||
"dataSizeExceeded": "图片 {filename} 数据大小超过 1MB 限制!",
|
||||
"types": {
|
||||
"network": "网络连接出现问题",
|
||||
"file_io": "文件读写出现问题",
|
||||
"process": "进程执行出现问题",
|
||||
"timeout": "操作超时",
|
||||
"user_cancel": "用户取消了操作",
|
||||
"system": "系统出现问题",
|
||||
"permission": "权限不足",
|
||||
"validation": "数据验证失败",
|
||||
"dependency": "依赖组件出现问题",
|
||||
"config": "配置出现问题"
|
||||
},
|
||||
"solutions": {
|
||||
"network": [
|
||||
"检查网络连接是否正常",
|
||||
"确认防火墙设置",
|
||||
"尝试重新启动应用程序"
|
||||
],
|
||||
"file_io": [
|
||||
"检查文件是否存在",
|
||||
"确认文件权限",
|
||||
"检查磁盘空间是否足够"
|
||||
],
|
||||
"process": [
|
||||
"检查进程是否正在运行",
|
||||
"确认系统资源是否足够",
|
||||
"尝试重新启动相关服务"
|
||||
],
|
||||
"timeout": [
|
||||
"增加超时时间设置",
|
||||
"检查网络延迟",
|
||||
"稍后重试操作"
|
||||
],
|
||||
"permission": [
|
||||
"以管理员身份运行",
|
||||
"检查文件/目录权限",
|
||||
"联系系统管理员"
|
||||
]
|
||||
}
|
||||
},
|
||||
"aiSummary": "AI 工作摘要",
|
||||
"languageSelector": "🌐 语言选择",
|
||||
|
@ -188,7 +188,46 @@
|
||||
"confirmClearAll": "確定要清除所有 {count} 張圖片嗎?",
|
||||
"confirmClearTitle": "確認清除",
|
||||
"fileSizeExceeded": "圖片 {filename} 大小為 {size}MB,超過 1MB 限制!\n建議使用圖片編輯軟體壓縮後再上傳。",
|
||||
"dataSizeExceeded": "圖片 {filename} 數據大小超過 1MB 限制!"
|
||||
"dataSizeExceeded": "圖片 {filename} 數據大小超過 1MB 限制!",
|
||||
"types": {
|
||||
"network": "網絡連接出現問題",
|
||||
"file_io": "文件讀寫出現問題",
|
||||
"process": "進程執行出現問題",
|
||||
"timeout": "操作超時",
|
||||
"user_cancel": "用戶取消了操作",
|
||||
"system": "系統出現問題",
|
||||
"permission": "權限不足",
|
||||
"validation": "數據驗證失敗",
|
||||
"dependency": "依賴組件出現問題",
|
||||
"config": "配置出現問題"
|
||||
},
|
||||
"solutions": {
|
||||
"network": [
|
||||
"檢查網絡連接是否正常",
|
||||
"確認防火牆設置",
|
||||
"嘗試重新啟動應用程序"
|
||||
],
|
||||
"file_io": [
|
||||
"檢查文件是否存在",
|
||||
"確認文件權限",
|
||||
"檢查磁盤空間是否足夠"
|
||||
],
|
||||
"process": [
|
||||
"檢查進程是否正在運行",
|
||||
"確認系統資源是否足夠",
|
||||
"嘗試重新啟動相關服務"
|
||||
],
|
||||
"timeout": [
|
||||
"增加超時時間設置",
|
||||
"檢查網絡延遲",
|
||||
"稍後重試操作"
|
||||
],
|
||||
"permission": [
|
||||
"以管理員身份運行",
|
||||
"檢查文件/目錄權限",
|
||||
"聯繫系統管理員"
|
||||
]
|
||||
}
|
||||
},
|
||||
"languageNames": {
|
||||
"zhTw": "繁體中文",
|
||||
|
@ -25,6 +25,7 @@ from PySide6.QtWidgets import QSizePolicy
|
||||
# 導入多語系支援
|
||||
from ...i18n import t
|
||||
from ...debug import gui_debug_log as debug_log
|
||||
from ...utils.resource_manager import get_resource_manager, create_temp_file
|
||||
from .image_preview import ImagePreviewWidget
|
||||
|
||||
|
||||
@ -37,6 +38,7 @@ class ImageUploadWidget(QWidget):
|
||||
self.images: Dict[str, Dict[str, str]] = {}
|
||||
self.config_manager = config_manager
|
||||
self._last_paste_time = 0 # 添加最後貼上時間記錄
|
||||
self.resource_manager = get_resource_manager() # 獲取資源管理器
|
||||
self._setup_ui()
|
||||
self.setAcceptDrops(True)
|
||||
# 啟動時清理舊的臨時文件
|
||||
@ -350,20 +352,19 @@ class ImageUploadWidget(QWidget):
|
||||
if mimeData.hasImage():
|
||||
image = clipboard.image()
|
||||
if not image.isNull():
|
||||
# 創建一個唯一的臨時文件名
|
||||
temp_dir = Path.home() / ".cache" / "mcp-feedback-enhanced"
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = int(time.time() * 1000)
|
||||
temp_file = temp_dir / f"clipboard_{timestamp}_{uuid.uuid4().hex[:8]}.png"
|
||||
# 使用資源管理器創建臨時文件
|
||||
temp_file = create_temp_file(
|
||||
suffix=".png",
|
||||
prefix=f"clipboard_{int(time.time() * 1000)}_"
|
||||
)
|
||||
|
||||
# 保存剪貼板圖片
|
||||
if image.save(str(temp_file), "PNG"):
|
||||
if image.save(temp_file, "PNG"):
|
||||
if os.path.getsize(temp_file) > 0:
|
||||
self._add_images([str(temp_file)])
|
||||
self._add_images([temp_file])
|
||||
debug_log(f"從剪貼板成功粘貼圖片: {temp_file}")
|
||||
else:
|
||||
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveEmpty', path=str(temp_file)))
|
||||
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveEmpty', path=temp_file))
|
||||
else:
|
||||
QMessageBox.warning(self, t('errors.warning'), t('errors.imageSaveFailed'))
|
||||
else:
|
||||
|
@ -38,6 +38,12 @@ 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
|
||||
|
||||
# ===== 編碼初始化 =====
|
||||
def init_encoding():
|
||||
"""初始化編碼設置,確保正確處理中文字符"""
|
||||
@ -198,12 +204,20 @@ def can_use_gui() -> bool:
|
||||
"""
|
||||
檢測是否可以使用圖形介面
|
||||
|
||||
WSL 環境預設使用 Web UI,因為大多數 WSL 安裝都是 Linux 環境,
|
||||
沒有桌面應用支援,即使 PySide6 可以載入也應該使用 Web 介面。
|
||||
|
||||
Returns:
|
||||
bool: True 表示可以使用 GUI,False 表示只能使用 Web UI
|
||||
"""
|
||||
if is_remote_environment():
|
||||
return False
|
||||
|
||||
# WSL 環境預設使用 Web UI
|
||||
if is_wsl_environment():
|
||||
debug_log("WSL 環境偵測到,預設使用 Web UI")
|
||||
return False
|
||||
|
||||
try:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
debug_log("成功載入 PySide6,可使用 GUI")
|
||||
@ -228,8 +242,8 @@ def save_feedback_to_file(feedback_data: dict, file_path: str = None) -> str:
|
||||
str: 儲存的文件路徑
|
||||
"""
|
||||
if file_path is None:
|
||||
temp_fd, file_path = tempfile.mkstemp(suffix='.json', prefix='feedback_')
|
||||
os.close(temp_fd)
|
||||
# 使用資源管理器創建臨時文件
|
||||
file_path = create_temp_file(suffix='.json', prefix='feedback_')
|
||||
|
||||
# 確保目錄存在
|
||||
directory = os.path.dirname(file_path)
|
||||
@ -401,9 +415,13 @@ def process_images(images_data: List[dict]) -> List[MCPImage]:
|
||||
debug_log(f"圖片 {i} ({file_name}) 處理成功,格式: {image_format}")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"圖片 {i} 處理失敗: {e}")
|
||||
import traceback
|
||||
debug_log(f"詳細錯誤: {traceback.format_exc()}")
|
||||
# 使用統一錯誤處理(不影響 JSON RPC)
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "圖片處理", "image_index": i},
|
||||
error_type=ErrorType.FILE_IO
|
||||
)
|
||||
debug_log(f"圖片 {i} 處理失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
debug_log(f"共處理 {len(mcp_images)} 張圖片")
|
||||
return mcp_images
|
||||
@ -539,9 +557,18 @@ async def interactive_feedback(
|
||||
return feedback_items
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"回饋收集錯誤: {str(e)}"
|
||||
debug_log(f"錯誤: {error_msg}")
|
||||
return [TextContent(type="text", text=error_msg)]
|
||||
# 使用統一錯誤處理,但不影響 JSON RPC 響應
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "回饋收集", "project_dir": project_directory},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
|
||||
# 生成用戶友好的錯誤信息
|
||||
user_error_msg = ErrorHandler.format_user_error(e, include_technical=False)
|
||||
debug_log(f"回饋收集錯誤 [錯誤ID: {error_id}]: {str(e)}")
|
||||
|
||||
return [TextContent(type="text", text=user_error_msg)]
|
||||
|
||||
|
||||
async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: int) -> dict:
|
||||
@ -565,10 +592,18 @@ async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: in
|
||||
# 傳遞 timeout 參數給 Web UI
|
||||
return await launch_web_feedback_ui(project_dir, summary, timeout)
|
||||
except ImportError as e:
|
||||
debug_log(f"無法導入 Web UI 模組: {e}")
|
||||
# 使用統一錯誤處理
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "Web UI 模組導入", "module": "web"},
|
||||
error_type=ErrorType.DEPENDENCY
|
||||
)
|
||||
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": f"Web UI 模組導入失敗: {str(e)}",
|
||||
"interactive_feedback": user_error_msg,
|
||||
"images": []
|
||||
}
|
||||
except TimeoutError as e:
|
||||
@ -587,19 +622,31 @@ async def launch_web_ui_with_timeout(project_dir: str, summary: str, timeout: in
|
||||
"images": []
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"Web UI 錯誤: {e}"
|
||||
debug_log(f"❌ {error_msg}")
|
||||
# 使用統一錯誤處理
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "Web UI 啟動", "timeout": timeout},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
user_error_msg = ErrorHandler.format_user_error(e, include_technical=False)
|
||||
debug_log(f"❌ Web UI 錯誤 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
# 發生錯誤時也要停止 Web 服務器
|
||||
try:
|
||||
from .web import stop_web_ui
|
||||
stop_web_ui()
|
||||
debug_log("Web UI 服務器已因錯誤而停止")
|
||||
except Exception as stop_error:
|
||||
ErrorHandler.log_error_with_context(
|
||||
stop_error,
|
||||
context={"operation": "Web UI 服務器停止"},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"停止 Web UI 服務器時發生錯誤: {stop_error}")
|
||||
|
||||
return {
|
||||
"command_logs": "",
|
||||
"interactive_feedback": f"錯誤: {str(e)}",
|
||||
"interactive_feedback": user_error_msg,
|
||||
"images": []
|
||||
}
|
||||
|
||||
|
@ -95,11 +95,17 @@ def get_test_summary():
|
||||
|
||||
def find_free_port():
|
||||
"""Find a free port to use for testing"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
try:
|
||||
# 嘗試使用增強的端口管理
|
||||
from .web.utils.port_manager import PortManager
|
||||
return PortManager.find_free_port_enhanced(preferred_port=8765, auto_cleanup=False)
|
||||
except ImportError:
|
||||
# 回退到原始方法
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('', 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
def test_web_ui(keep_running=False):
|
||||
"""Test the Web UI functionality"""
|
||||
|
@ -54,15 +54,26 @@ class TestUtils:
|
||||
|
||||
@staticmethod
|
||||
def find_free_port(start_port: int = 8765, max_attempts: int = 100) -> int:
|
||||
"""尋找可用端口"""
|
||||
for port in range(start_port, start_port + max_attempts):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(('127.0.0.1', port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})")
|
||||
"""尋找可用端口 - 使用增強的端口管理"""
|
||||
try:
|
||||
# 嘗試使用增強的端口管理
|
||||
from ..web.utils.port_manager import PortManager
|
||||
return PortManager.find_free_port_enhanced(
|
||||
preferred_port=start_port,
|
||||
auto_cleanup=False, # 測試時不自動清理
|
||||
host='127.0.0.1',
|
||||
max_attempts=max_attempts
|
||||
)
|
||||
except ImportError:
|
||||
# 回退到原始方法
|
||||
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))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"無法找到可用端口 (嘗試範圍: {start_port}-{start_port + max_attempts})")
|
||||
|
||||
@staticmethod
|
||||
def is_port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
|
27
src/mcp_feedback_enhanced/utils/__init__.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""
|
||||
MCP Feedback Enhanced 工具模組
|
||||
============================
|
||||
|
||||
提供各種工具類和函數,包括錯誤處理、資源管理等。
|
||||
"""
|
||||
|
||||
from .error_handler import ErrorHandler, ErrorType
|
||||
from .resource_manager import (
|
||||
ResourceManager,
|
||||
get_resource_manager,
|
||||
create_temp_file,
|
||||
create_temp_dir,
|
||||
register_process,
|
||||
cleanup_all_resources
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ErrorHandler',
|
||||
'ErrorType',
|
||||
'ResourceManager',
|
||||
'get_resource_manager',
|
||||
'create_temp_file',
|
||||
'create_temp_dir',
|
||||
'register_process',
|
||||
'cleanup_all_resources'
|
||||
]
|
455
src/mcp_feedback_enhanced/utils/error_handler.py
Normal file
@ -0,0 +1,455 @@
|
||||
"""
|
||||
統一錯誤處理框架
|
||||
================
|
||||
|
||||
提供統一的錯誤處理機制,包括:
|
||||
- 錯誤類型分類
|
||||
- 用戶友好錯誤信息
|
||||
- 錯誤上下文記錄
|
||||
- 解決方案建議
|
||||
- 國際化支持
|
||||
|
||||
注意:此模組不會影響 JSON RPC 通信,所有錯誤處理都在應用層進行。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from ..debug import debug_log
|
||||
|
||||
|
||||
class ErrorType(Enum):
|
||||
"""錯誤類型枚舉"""
|
||||
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" # 配置錯誤
|
||||
|
||||
|
||||
class ErrorSeverity(Enum):
|
||||
"""錯誤嚴重程度"""
|
||||
LOW = "low" # 低:不影響核心功能
|
||||
MEDIUM = "medium" # 中:影響部分功能
|
||||
HIGH = "high" # 高:影響核心功能
|
||||
CRITICAL = "critical" # 嚴重:系統無法正常運行
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""統一錯誤處理器"""
|
||||
|
||||
# 錯誤類型到用戶友好信息的映射
|
||||
_ERROR_MESSAGES = {
|
||||
ErrorType.NETWORK: {
|
||||
"zh-TW": "網絡連接出現問題",
|
||||
"zh-CN": "网络连接出现问题",
|
||||
"en": "Network connection issue"
|
||||
},
|
||||
ErrorType.FILE_IO: {
|
||||
"zh-TW": "文件讀寫出現問題",
|
||||
"zh-CN": "文件读写出现问题",
|
||||
"en": "File read/write issue"
|
||||
},
|
||||
ErrorType.PROCESS: {
|
||||
"zh-TW": "進程執行出現問題",
|
||||
"zh-CN": "进程执行出现问题",
|
||||
"en": "Process execution issue"
|
||||
},
|
||||
ErrorType.TIMEOUT: {
|
||||
"zh-TW": "操作超時",
|
||||
"zh-CN": "操作超时",
|
||||
"en": "Operation timeout"
|
||||
},
|
||||
ErrorType.USER_CANCEL: {
|
||||
"zh-TW": "用戶取消了操作",
|
||||
"zh-CN": "用户取消了操作",
|
||||
"en": "User cancelled the operation"
|
||||
},
|
||||
ErrorType.SYSTEM: {
|
||||
"zh-TW": "系統出現問題",
|
||||
"zh-CN": "系统出现问题",
|
||||
"en": "System issue"
|
||||
},
|
||||
ErrorType.PERMISSION: {
|
||||
"zh-TW": "權限不足",
|
||||
"zh-CN": "权限不足",
|
||||
"en": "Insufficient permissions"
|
||||
},
|
||||
ErrorType.VALIDATION: {
|
||||
"zh-TW": "數據驗證失敗",
|
||||
"zh-CN": "数据验证失败",
|
||||
"en": "Data validation failed"
|
||||
},
|
||||
ErrorType.DEPENDENCY: {
|
||||
"zh-TW": "依賴組件出現問題",
|
||||
"zh-CN": "依赖组件出现问题",
|
||||
"en": "Dependency issue"
|
||||
},
|
||||
ErrorType.CONFIGURATION: {
|
||||
"zh-TW": "配置出現問題",
|
||||
"zh-CN": "配置出现问题",
|
||||
"en": "Configuration issue"
|
||||
}
|
||||
}
|
||||
|
||||
# 錯誤解決建議
|
||||
_ERROR_SOLUTIONS = {
|
||||
ErrorType.NETWORK: {
|
||||
"zh-TW": [
|
||||
"檢查網絡連接是否正常",
|
||||
"確認防火牆設置",
|
||||
"嘗試重新啟動應用程序"
|
||||
],
|
||||
"zh-CN": [
|
||||
"检查网络连接是否正常",
|
||||
"确认防火墙设置",
|
||||
"尝试重新启动应用程序"
|
||||
],
|
||||
"en": [
|
||||
"Check network connection",
|
||||
"Verify firewall settings",
|
||||
"Try restarting the application"
|
||||
]
|
||||
},
|
||||
ErrorType.FILE_IO: {
|
||||
"zh-TW": [
|
||||
"檢查文件是否存在",
|
||||
"確認文件權限",
|
||||
"檢查磁盤空間是否足夠"
|
||||
],
|
||||
"zh-CN": [
|
||||
"检查文件是否存在",
|
||||
"确认文件权限",
|
||||
"检查磁盘空间是否足够"
|
||||
],
|
||||
"en": [
|
||||
"Check if file exists",
|
||||
"Verify file permissions",
|
||||
"Check available disk space"
|
||||
]
|
||||
},
|
||||
ErrorType.PROCESS: {
|
||||
"zh-TW": [
|
||||
"檢查進程是否正在運行",
|
||||
"確認系統資源是否足夠",
|
||||
"嘗試重新啟動相關服務"
|
||||
],
|
||||
"zh-CN": [
|
||||
"检查进程是否正在运行",
|
||||
"确认系统资源是否足够",
|
||||
"尝试重新启动相关服务"
|
||||
],
|
||||
"en": [
|
||||
"Check if process is running",
|
||||
"Verify system resources",
|
||||
"Try restarting related services"
|
||||
]
|
||||
},
|
||||
ErrorType.TIMEOUT: {
|
||||
"zh-TW": [
|
||||
"增加超時時間設置",
|
||||
"檢查網絡延遲",
|
||||
"稍後重試操作"
|
||||
],
|
||||
"zh-CN": [
|
||||
"增加超时时间设置",
|
||||
"检查网络延迟",
|
||||
"稍后重试操作"
|
||||
],
|
||||
"en": [
|
||||
"Increase timeout settings",
|
||||
"Check network latency",
|
||||
"Retry the operation later"
|
||||
]
|
||||
},
|
||||
ErrorType.PERMISSION: {
|
||||
"zh-TW": [
|
||||
"以管理員身份運行",
|
||||
"檢查文件/目錄權限",
|
||||
"聯繫系統管理員"
|
||||
],
|
||||
"zh-CN": [
|
||||
"以管理员身份运行",
|
||||
"检查文件/目录权限",
|
||||
"联系系统管理员"
|
||||
],
|
||||
"en": [
|
||||
"Run as administrator",
|
||||
"Check file/directory permissions",
|
||||
"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:
|
||||
# 回退到環境變數或默認語言
|
||||
return os.getenv("MCP_LANGUAGE", "zh-TW")
|
||||
|
||||
@staticmethod
|
||||
def get_i18n_error_message(error_type: ErrorType) -> str:
|
||||
"""從國際化系統獲取錯誤信息"""
|
||||
try:
|
||||
from ..i18n import get_i18n_manager
|
||||
i18n = get_i18n_manager()
|
||||
key = f"errors.types.{error_type.value}"
|
||||
message = i18n.t(key)
|
||||
# 如果返回的是鍵本身,說明沒有找到翻譯,使用回退
|
||||
if message == key:
|
||||
raise Exception("Translation not found")
|
||||
return message
|
||||
except Exception:
|
||||
# 回退到內建映射
|
||||
language = ErrorHandler.get_current_language()
|
||||
error_messages = ErrorHandler._ERROR_MESSAGES.get(error_type, {})
|
||||
return error_messages.get(language, error_messages.get("zh-TW", "發生未知錯誤"))
|
||||
|
||||
@staticmethod
|
||||
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)
|
||||
if isinstance(solutions, list) and len(solutions) > 0:
|
||||
return solutions
|
||||
# 如果沒有找到或為空,使用回退
|
||||
raise Exception("Solutions not found")
|
||||
except Exception:
|
||||
# 回退到內建映射
|
||||
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:
|
||||
return ErrorType.TIMEOUT
|
||||
|
||||
# 權限錯誤(優先檢查,避免被文件錯誤覆蓋)
|
||||
if 'permission' in error_name.lower():
|
||||
return ErrorType.PERMISSION
|
||||
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']):
|
||||
return ErrorType.NETWORK
|
||||
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']): # 使用更精確的匹配
|
||||
return ErrorType.FILE_IO
|
||||
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']):
|
||||
return ErrorType.PROCESS
|
||||
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']):
|
||||
return ErrorType.VALIDATION
|
||||
|
||||
# 配置錯誤
|
||||
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
|
||||
) -> 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"):
|
||||
if language == "en":
|
||||
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)}")
|
||||
else:
|
||||
parts.append(f"技術細節:{type(error).__name__}: {str(error)}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def get_error_solutions(error_type: ErrorType) -> List[str]:
|
||||
"""
|
||||
獲取錯誤解決建議
|
||||
|
||||
Args:
|
||||
error_type: 錯誤類型
|
||||
|
||||
Returns:
|
||||
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
|
||||
) -> 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,
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"error_type": error_type.value,
|
||||
"severity": severity.value,
|
||||
"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
|
||||
}
|
||||
|
||||
# 記錄到調試日誌(不影響 JSON RPC)
|
||||
debug_log(f"錯誤記錄 [{error_id}]: {error_type.value} - {str(error)}")
|
||||
|
||||
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,
|
||||
include_solutions: bool = True,
|
||||
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)
|
||||
}
|
||||
|
||||
# 添加解決建議
|
||||
if include_solutions:
|
||||
solutions = ErrorHandler.get_error_solutions(error_type)
|
||||
response["solutions"] = solutions # 即使為空列表也添加
|
||||
|
||||
# 添加上下文(僅用於調試)
|
||||
if context and not for_user:
|
||||
response["context"] = context
|
||||
|
||||
return response
|
526
src/mcp_feedback_enhanced/utils/memory_monitor.py
Normal file
@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
集成式內存監控系統
|
||||
==================
|
||||
|
||||
提供與資源管理器深度集成的內存監控功能,包括:
|
||||
- 系統和進程內存使用監控
|
||||
- 智能清理觸發機制
|
||||
- 內存洩漏檢測和趨勢分析
|
||||
- 性能優化建議
|
||||
"""
|
||||
|
||||
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 ..debug import debug_log
|
||||
from .error_handler import ErrorHandler, ErrorType
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemorySnapshot:
|
||||
"""內存快照數據類"""
|
||||
timestamp: datetime
|
||||
system_total: int # 系統總內存 (bytes)
|
||||
system_available: int # 系統可用內存 (bytes)
|
||||
system_used: int # 系統已用內存 (bytes)
|
||||
system_percent: float # 系統內存使用率 (%)
|
||||
process_rss: int # 進程常駐內存 (bytes)
|
||||
process_vms: int # 進程虛擬內存 (bytes)
|
||||
process_percent: float # 進程內存使用率 (%)
|
||||
gc_objects: int # Python 垃圾回收對象數量
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryAlert:
|
||||
"""內存警告數據類"""
|
||||
level: str # warning, critical, emergency
|
||||
message: str
|
||||
timestamp: datetime
|
||||
memory_percent: float
|
||||
recommended_action: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryStats:
|
||||
"""內存統計數據類"""
|
||||
monitoring_duration: float # 監控持續時間 (秒)
|
||||
snapshots_count: int # 快照數量
|
||||
average_system_usage: float # 平均系統內存使用率
|
||||
peak_system_usage: float # 峰值系統內存使用率
|
||||
average_process_usage: float # 平均進程內存使用率
|
||||
peak_process_usage: float # 峰值進程內存使用率
|
||||
alerts_count: int # 警告數量
|
||||
cleanup_triggers: int # 清理觸發次數
|
||||
memory_trend: str # 內存趨勢 (stable, increasing, decreasing)
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
初始化內存監控器
|
||||
|
||||
Args:
|
||||
warning_threshold: 警告閾值 (0.0-1.0)
|
||||
critical_threshold: 危險閾值 (0.0-1.0)
|
||||
emergency_threshold: 緊急閾值 (0.0-1.0)
|
||||
monitoring_interval: 監控間隔 (秒)
|
||||
max_snapshots: 最大快照數量
|
||||
"""
|
||||
self.warning_threshold = warning_threshold
|
||||
self.critical_threshold = critical_threshold
|
||||
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._stop_event = threading.Event()
|
||||
|
||||
# 數據存儲
|
||||
self.snapshots: deque = deque(maxlen=max_snapshots)
|
||||
self.alerts: List[MemoryAlert] = []
|
||||
self.max_alerts = 100
|
||||
|
||||
# 回調函數
|
||||
self.cleanup_callbacks: List[Callable] = []
|
||||
self.alert_callbacks: List[Callable[[MemoryAlert], None]] = []
|
||||
|
||||
# 統計數據
|
||||
self.start_time: Optional[datetime] = 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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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,
|
||||
system_available=system_memory.available,
|
||||
system_used=system_memory.used,
|
||||
system_percent=system_memory.percent,
|
||||
process_rss=process_memory.rss,
|
||||
process_vms=process_memory.vms,
|
||||
process_percent=process_percent,
|
||||
gc_objects=gc_objects
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
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(
|
||||
level="emergency",
|
||||
message=f"內存使用率達到緊急水平: {snapshot.system_percent:.1f}%",
|
||||
timestamp=snapshot.timestamp,
|
||||
memory_percent=snapshot.system_percent,
|
||||
recommended_action="立即執行強制清理和垃圾回收"
|
||||
)
|
||||
self._handle_alert(alert)
|
||||
self._trigger_emergency_cleanup()
|
||||
|
||||
# 檢查危險閾值
|
||||
elif usage_percent >= self.critical_threshold:
|
||||
alert = MemoryAlert(
|
||||
level="critical",
|
||||
message=f"內存使用率達到危險水平: {snapshot.system_percent:.1f}%",
|
||||
timestamp=snapshot.timestamp,
|
||||
memory_percent=snapshot.system_percent,
|
||||
recommended_action="執行資源清理和垃圾回收"
|
||||
)
|
||||
self._handle_alert(alert)
|
||||
self._trigger_cleanup()
|
||||
|
||||
# 檢查警告閾值
|
||||
elif usage_percent >= self.warning_threshold:
|
||||
alert = MemoryAlert(
|
||||
level="warning",
|
||||
message=f"內存使用率較高: {snapshot.system_percent:.1f}%",
|
||||
timestamp=snapshot.timestamp,
|
||||
memory_percent=snapshot.system_percent,
|
||||
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:]
|
||||
|
||||
# 調用警告回調
|
||||
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__'):
|
||||
# 嘗試傳遞 force 參數
|
||||
import inspect
|
||||
sig = inspect.signature(callback)
|
||||
if 'force' in sig.parameters:
|
||||
callback(force=True)
|
||||
else:
|
||||
callback()
|
||||
else:
|
||||
callback()
|
||||
except Exception as e:
|
||||
debug_log(f"緊急清理回調執行失敗: {e}")
|
||||
|
||||
|
||||
def add_cleanup_callback(self, callback: Callable):
|
||||
"""添加清理回調函數"""
|
||||
if callback not in self.cleanup_callbacks:
|
||||
self.cleanup_callbacks.append(callback)
|
||||
debug_log("添加清理回調函數")
|
||||
|
||||
def add_alert_callback(self, callback: Callable[[MemoryAlert], None]):
|
||||
"""添加警告回調函數"""
|
||||
if callback not in self.alert_callbacks:
|
||||
self.alert_callbacks.append(callback)
|
||||
debug_log("添加警告回調函數")
|
||||
|
||||
def remove_cleanup_callback(self, callback: Callable):
|
||||
"""移除清理回調函數"""
|
||||
if callback in self.cleanup_callbacks:
|
||||
self.cleanup_callbacks.remove(callback)
|
||||
debug_log("移除清理回調函數")
|
||||
|
||||
def remove_alert_callback(self, callback: Callable[[MemoryAlert], None]):
|
||||
"""移除警告回調函數"""
|
||||
if callback in self.alert_callbacks:
|
||||
self.alert_callbacks.remove(callback)
|
||||
debug_log("移除警告回調函數")
|
||||
|
||||
def get_current_memory_info(self) -> Dict[str, Any]:
|
||||
"""獲取當前內存信息"""
|
||||
try:
|
||||
snapshot = self._collect_memory_snapshot()
|
||||
return {
|
||||
"timestamp": snapshot.timestamp.isoformat(),
|
||||
"system": {
|
||||
"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)
|
||||
},
|
||||
"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)
|
||||
},
|
||||
"gc_objects": snapshot.gc_objects,
|
||||
"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
|
||||
)
|
||||
debug_log(f"獲取內存信息失敗 [錯誤ID: {error_id}]: {e}")
|
||||
return {}
|
||||
|
||||
def get_memory_stats(self) -> MemoryStats:
|
||||
"""獲取內存統計數據"""
|
||||
if not self.snapshots:
|
||||
return MemoryStats(
|
||||
monitoring_duration=0.0,
|
||||
snapshots_count=0,
|
||||
average_system_usage=0.0,
|
||||
peak_system_usage=0.0,
|
||||
average_process_usage=0.0,
|
||||
peak_process_usage=0.0,
|
||||
alerts_count=0,
|
||||
cleanup_triggers=0,
|
||||
memory_trend="unknown"
|
||||
)
|
||||
|
||||
# 計算統計數據
|
||||
system_usages = [s.system_percent for s in self.snapshots]
|
||||
process_usages = [s.process_percent for s in self.snapshots]
|
||||
|
||||
duration = 0.0
|
||||
if self.start_time:
|
||||
duration = (datetime.now() - self.start_time).total_seconds()
|
||||
|
||||
return MemoryStats(
|
||||
monitoring_duration=duration,
|
||||
snapshots_count=len(self.snapshots),
|
||||
average_system_usage=sum(system_usages) / len(system_usages),
|
||||
peak_system_usage=max(system_usages),
|
||||
average_process_usage=sum(process_usages) / len(process_usages),
|
||||
peak_process_usage=max(process_usages),
|
||||
alerts_count=len(self.alerts),
|
||||
cleanup_triggers=self.cleanup_triggers_count,
|
||||
memory_trend=self._analyze_memory_trend()
|
||||
)
|
||||
|
||||
def get_recent_alerts(self, limit: int = 10) -> List[MemoryAlert]:
|
||||
"""獲取最近的警告"""
|
||||
return self.alerts[-limit:] if self.alerts else []
|
||||
|
||||
def _get_memory_status(self, usage_percent: float) -> str:
|
||||
"""獲取內存狀態描述"""
|
||||
if usage_percent >= self.emergency_threshold:
|
||||
return "emergency"
|
||||
elif usage_percent >= self.critical_threshold:
|
||||
return "critical"
|
||||
elif usage_percent >= self.warning_threshold:
|
||||
return "warning"
|
||||
else:
|
||||
return "normal"
|
||||
|
||||
def _analyze_memory_trend(self) -> str:
|
||||
"""分析內存使用趨勢"""
|
||||
if len(self.snapshots) < 10:
|
||||
return "insufficient_data"
|
||||
|
||||
# 取最近的快照進行趨勢分析
|
||||
recent_snapshots = list(self.snapshots)[-10:]
|
||||
usages = [s.system_percent for s in recent_snapshots]
|
||||
|
||||
# 簡單的線性趨勢分析
|
||||
first_half = usages[:5]
|
||||
second_half = usages[5:]
|
||||
|
||||
avg_first = sum(first_half) / len(first_half)
|
||||
avg_second = sum(second_half) / len(second_half)
|
||||
|
||||
diff = avg_second - avg_first
|
||||
|
||||
if abs(diff) < 2.0: # 變化小於 2%
|
||||
return "stable"
|
||||
elif diff > 0:
|
||||
return "increasing"
|
||||
else:
|
||||
return "decreasing"
|
||||
|
||||
def force_cleanup(self):
|
||||
"""手動觸發清理操作"""
|
||||
debug_log("手動觸發內存清理")
|
||||
self._trigger_cleanup()
|
||||
|
||||
def force_emergency_cleanup(self):
|
||||
"""手動觸發緊急清理操作"""
|
||||
debug_log("手動觸發緊急內存清理")
|
||||
self._trigger_emergency_cleanup()
|
||||
|
||||
def reset_stats(self):
|
||||
"""重置統計數據"""
|
||||
self.snapshots.clear()
|
||||
self.alerts.clear()
|
||||
self.cleanup_triggers_count = 0
|
||||
self.start_time = datetime.now() if self.is_monitoring else None
|
||||
debug_log("內存監控統計數據已重置")
|
||||
|
||||
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
|
||||
},
|
||||
"current_info": self.get_current_memory_info(),
|
||||
"stats": self.get_memory_stats().__dict__,
|
||||
"recent_alerts": [
|
||||
{
|
||||
"level": alert.level,
|
||||
"message": alert.message,
|
||||
"timestamp": alert.timestamp.isoformat(),
|
||||
"memory_percent": alert.memory_percent,
|
||||
"recommended_action": alert.recommended_action
|
||||
}
|
||||
for alert in self.get_recent_alerts()
|
||||
],
|
||||
"is_monitoring": self.is_monitoring
|
||||
}
|
||||
|
||||
|
||||
# 全域內存監控器實例
|
||||
_memory_monitor: Optional[MemoryMonitor] = None
|
||||
_monitor_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_memory_monitor() -> MemoryMonitor:
|
||||
"""獲取全域內存監控器實例"""
|
||||
global _memory_monitor
|
||||
if _memory_monitor is None:
|
||||
with _monitor_lock:
|
||||
if _memory_monitor is None:
|
||||
_memory_monitor = MemoryMonitor()
|
||||
return _memory_monitor
|
797
src/mcp_feedback_enhanced/utils/resource_manager.py
Normal file
@ -0,0 +1,797 @@
|
||||
"""
|
||||
統一資源管理器
|
||||
==============
|
||||
|
||||
提供統一的資源管理功能,包括:
|
||||
- 臨時文件和目錄管理
|
||||
- 進程生命週期追蹤
|
||||
- 自動資源清理
|
||||
- 資源使用監控
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import weakref
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, Any, Optional, List, Union
|
||||
from ..debug import debug_log
|
||||
from .error_handler import ErrorHandler, ErrorType
|
||||
|
||||
|
||||
class ResourceType:
|
||||
"""資源類型常量"""
|
||||
TEMP_FILE = "temp_file"
|
||||
TEMP_DIR = "temp_dir"
|
||||
PROCESS = "process"
|
||||
FILE_HANDLE = "file_handle"
|
||||
|
||||
|
||||
class ResourceManager:
|
||||
"""統一資源管理器 - 提供完整的資源生命週期管理"""
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __new__(cls):
|
||||
"""單例模式實現"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""初始化資源管理器"""
|
||||
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.stats = {
|
||||
"temp_files_created": 0,
|
||||
"temp_dirs_created": 0,
|
||||
"processes_registered": 0,
|
||||
"cleanup_runs": 0,
|
||||
"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._stop_cleanup = threading.Event()
|
||||
|
||||
# 註冊退出清理
|
||||
atexit.register(self.cleanup_all)
|
||||
|
||||
# 啟動自動清理
|
||||
self._start_auto_cleanup()
|
||||
|
||||
# 集成內存監控
|
||||
self._setup_memory_monitoring()
|
||||
|
||||
debug_log("ResourceManager 初始化完成")
|
||||
|
||||
def _setup_memory_monitoring(self):
|
||||
"""設置內存監控集成"""
|
||||
try:
|
||||
# 延遲導入避免循環依賴
|
||||
from .memory_monitor import get_memory_monitor
|
||||
|
||||
self.memory_monitor = get_memory_monitor()
|
||||
|
||||
# 註冊清理回調
|
||||
self.memory_monitor.add_cleanup_callback(self._memory_triggered_cleanup)
|
||||
|
||||
# 啟動內存監控
|
||||
if self.memory_monitor.start_monitoring():
|
||||
debug_log("內存監控已集成到資源管理器")
|
||||
else:
|
||||
debug_log("內存監控啟動失敗")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "設置內存監控"},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"設置內存監控失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
def _memory_triggered_cleanup(self, force: bool = False):
|
||||
"""內存監控觸發的清理操作"""
|
||||
debug_log(f"內存監控觸發清理操作 (force={force})")
|
||||
|
||||
try:
|
||||
# 清理臨時文件
|
||||
cleaned_files = self.cleanup_temp_files()
|
||||
|
||||
# 清理臨時目錄
|
||||
cleaned_dirs = self.cleanup_temp_dirs()
|
||||
|
||||
# 清理文件句柄
|
||||
cleaned_handles = self.cleanup_file_handles()
|
||||
|
||||
# 如果是強制清理,也清理進程
|
||||
cleaned_processes = 0
|
||||
if force:
|
||||
cleaned_processes = self.cleanup_processes(force=True)
|
||||
|
||||
debug_log(f"內存觸發清理完成: 文件={cleaned_files}, 目錄={cleaned_dirs}, "
|
||||
f"句柄={cleaned_handles}, 進程={cleaned_processes}")
|
||||
|
||||
# 更新統計
|
||||
self.stats["cleanup_runs"] += 1
|
||||
self.stats["last_cleanup"] = time.time()
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "內存觸發清理", "force": force},
|
||||
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
|
||||
) -> str:
|
||||
"""
|
||||
創建臨時文件並追蹤
|
||||
|
||||
Args:
|
||||
suffix: 文件後綴
|
||||
prefix: 文件前綴
|
||||
dir: 臨時目錄,None 使用系統默認
|
||||
text: 是否為文本模式
|
||||
|
||||
Returns:
|
||||
str: 臨時文件路徑
|
||||
"""
|
||||
try:
|
||||
# 創建臨時文件
|
||||
fd, temp_path = tempfile.mkstemp(
|
||||
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
|
||||
)
|
||||
debug_log(f"創建臨時文件失敗 [錯誤ID: {error_id}]: {e}")
|
||||
raise
|
||||
|
||||
def create_temp_dir(
|
||||
self,
|
||||
suffix: str = "",
|
||||
prefix: str = "mcp_",
|
||||
dir: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
創建臨時目錄並追蹤
|
||||
|
||||
Args:
|
||||
suffix: 目錄後綴
|
||||
prefix: 目錄前綴
|
||||
dir: 父目錄,None 使用系統默認
|
||||
|
||||
Returns:
|
||||
str: 臨時目錄路徑
|
||||
"""
|
||||
try:
|
||||
# 創建臨時目錄
|
||||
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
|
||||
)
|
||||
debug_log(f"創建臨時目錄失敗 [錯誤ID: {error_id}]: {e}")
|
||||
raise
|
||||
|
||||
def register_process(
|
||||
self,
|
||||
process: Union[subprocess.Popen, int],
|
||||
description: str = "",
|
||||
auto_cleanup: bool = True
|
||||
) -> int:
|
||||
"""
|
||||
註冊進程追蹤
|
||||
|
||||
Args:
|
||||
process: 進程對象或 PID
|
||||
description: 進程描述
|
||||
auto_cleanup: 是否自動清理
|
||||
|
||||
Returns:
|
||||
int: 進程 PID
|
||||
"""
|
||||
try:
|
||||
if isinstance(process, subprocess.Popen):
|
||||
pid = process.pid
|
||||
process_obj = process
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
debug_log(f"註冊進程失敗 [錯誤ID: {error_id}]: {e}")
|
||||
raise
|
||||
|
||||
def register_file_handle(self, file_handle: Any) -> None:
|
||||
"""
|
||||
註冊文件句柄追蹤
|
||||
|
||||
Args:
|
||||
file_handle: 文件句柄對象
|
||||
"""
|
||||
try:
|
||||
# 使用弱引用避免循環引用
|
||||
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
|
||||
)
|
||||
debug_log(f"註冊文件句柄失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
def unregister_temp_file(self, file_path: str) -> bool:
|
||||
"""
|
||||
取消臨時文件追蹤
|
||||
|
||||
Args:
|
||||
file_path: 文件路徑
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取消追蹤
|
||||
"""
|
||||
try:
|
||||
if file_path in self.temp_files:
|
||||
self.temp_files.remove(file_path)
|
||||
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
|
||||
)
|
||||
debug_log(f"取消文件追蹤失敗 [錯誤ID: {error_id}]: {e}")
|
||||
return False
|
||||
|
||||
def unregister_process(self, pid: int) -> bool:
|
||||
"""
|
||||
取消進程追蹤
|
||||
|
||||
Args:
|
||||
pid: 進程 PID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取消追蹤
|
||||
"""
|
||||
try:
|
||||
if pid in self.processes:
|
||||
del self.processes[pid]
|
||||
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
|
||||
)
|
||||
debug_log(f"取消進程追蹤失敗 [錯誤ID: {error_id}]: {e}")
|
||||
return False
|
||||
|
||||
def cleanup_temp_files(self, max_age: Optional[int] = 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:
|
||||
os.remove(file_path)
|
||||
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
|
||||
)
|
||||
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:
|
||||
"""
|
||||
清理臨時目錄
|
||||
|
||||
Returns:
|
||||
int: 清理的目錄數量
|
||||
"""
|
||||
cleaned_count = 0
|
||||
dirs_to_remove = set()
|
||||
|
||||
for dir_path in self.temp_dirs.copy():
|
||||
try:
|
||||
if not os.path.exists(dir_path):
|
||||
dirs_to_remove.add(dir_path)
|
||||
continue
|
||||
|
||||
# 嘗試刪除目錄
|
||||
shutil.rmtree(dir_path)
|
||||
dirs_to_remove.add(dir_path)
|
||||
cleaned_count += 1
|
||||
debug_log(f"清理臨時目錄: {dir_path}")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "清理臨時目錄", "dir_path": dir_path},
|
||||
error_type=ErrorType.FILE_IO
|
||||
)
|
||||
debug_log(f"清理臨時目錄失敗 [錯誤ID: {error_id}]: {e}")
|
||||
dirs_to_remove.add(dir_path) # 移除無效追蹤
|
||||
|
||||
# 移除已清理的目錄追蹤
|
||||
self.temp_dirs -= dirs_to_remove
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def cleanup_processes(self, force: bool = False) -> int:
|
||||
"""
|
||||
清理進程
|
||||
|
||||
Args:
|
||||
force: 是否強制終止進程
|
||||
|
||||
Returns:
|
||||
int: 清理的進程數量
|
||||
"""
|
||||
cleaned_count = 0
|
||||
processes_to_remove = []
|
||||
|
||||
for pid, process_info in self.processes.copy().items():
|
||||
try:
|
||||
process_obj = process_info.get("process")
|
||||
auto_cleanup = process_info.get("auto_cleanup", True)
|
||||
|
||||
if not auto_cleanup:
|
||||
continue
|
||||
|
||||
# 檢查進程是否還在運行
|
||||
if process_obj and hasattr(process_obj, 'poll'):
|
||||
if process_obj.poll() is None: # 進程還在運行
|
||||
if force:
|
||||
debug_log(f"強制終止進程: PID {pid}")
|
||||
process_obj.kill()
|
||||
else:
|
||||
debug_log(f"優雅終止進程: PID {pid}")
|
||||
process_obj.terminate()
|
||||
|
||||
# 等待進程結束
|
||||
try:
|
||||
process_obj.wait(timeout=5)
|
||||
cleaned_count += 1
|
||||
except subprocess.TimeoutExpired:
|
||||
if not force:
|
||||
debug_log(f"進程 {pid} 優雅終止超時,強制終止")
|
||||
process_obj.kill()
|
||||
process_obj.wait(timeout=3)
|
||||
cleaned_count += 1
|
||||
|
||||
processes_to_remove.append(pid)
|
||||
else:
|
||||
# 使用 psutil 檢查進程
|
||||
try:
|
||||
import psutil
|
||||
if psutil.pid_exists(pid):
|
||||
proc = psutil.Process(pid)
|
||||
if force:
|
||||
proc.kill()
|
||||
else:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
cleaned_count += 1
|
||||
processes_to_remove.append(pid)
|
||||
except ImportError:
|
||||
debug_log("psutil 不可用,跳過進程檢查")
|
||||
processes_to_remove.append(pid)
|
||||
except Exception as e:
|
||||
debug_log(f"清理進程 {pid} 失敗: {e}")
|
||||
processes_to_remove.append(pid)
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "清理進程", "pid": pid},
|
||||
error_type=ErrorType.PROCESS
|
||||
)
|
||||
debug_log(f"清理進程失敗 [錯誤ID: {error_id}]: {e}")
|
||||
processes_to_remove.append(pid)
|
||||
|
||||
# 移除已清理的進程追蹤
|
||||
for pid in processes_to_remove:
|
||||
self.processes.pop(pid, None)
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def cleanup_file_handles(self) -> int:
|
||||
"""
|
||||
清理文件句柄
|
||||
|
||||
Returns:
|
||||
int: 清理的句柄數量
|
||||
"""
|
||||
cleaned_count = 0
|
||||
handles_to_remove = set()
|
||||
|
||||
for handle_ref in self.file_handles.copy():
|
||||
try:
|
||||
handle = handle_ref()
|
||||
if handle is None:
|
||||
# 弱引用已失效
|
||||
handles_to_remove.add(handle_ref)
|
||||
continue
|
||||
|
||||
# 嘗試關閉文件句柄
|
||||
if hasattr(handle, 'close') and not handle.closed:
|
||||
handle.close()
|
||||
cleaned_count += 1
|
||||
debug_log(f"關閉文件句柄: {type(handle).__name__}")
|
||||
|
||||
handles_to_remove.add(handle_ref)
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "清理文件句柄"},
|
||||
error_type=ErrorType.FILE_IO
|
||||
)
|
||||
debug_log(f"清理文件句柄失敗 [錯誤ID: {error_id}]: {e}")
|
||||
handles_to_remove.add(handle_ref)
|
||||
|
||||
# 移除已清理的句柄追蹤
|
||||
self.file_handles -= handles_to_remove
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def cleanup_all(self, force: bool = False) -> Dict[str, int]:
|
||||
"""
|
||||
清理所有資源
|
||||
|
||||
Args:
|
||||
force: 是否強制清理
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: 清理統計
|
||||
"""
|
||||
debug_log("開始全面資源清理...")
|
||||
|
||||
results = {
|
||||
"temp_files": 0,
|
||||
"temp_dirs": 0,
|
||||
"processes": 0,
|
||||
"file_handles": 0
|
||||
}
|
||||
|
||||
try:
|
||||
# 清理文件句柄
|
||||
results["file_handles"] = self.cleanup_file_handles()
|
||||
|
||||
# 清理進程
|
||||
results["processes"] = self.cleanup_processes(force=force)
|
||||
|
||||
# 清理臨時文件
|
||||
results["temp_files"] = self.cleanup_temp_files(max_age=0) # 清理所有文件
|
||||
|
||||
# 清理臨時目錄
|
||||
results["temp_dirs"] = self.cleanup_temp_dirs()
|
||||
|
||||
# 更新統計
|
||||
self.stats["cleanup_runs"] += 1
|
||||
self.stats["last_cleanup"] = time.time()
|
||||
|
||||
total_cleaned = sum(results.values())
|
||||
debug_log(f"資源清理完成,共清理 {total_cleaned} 個資源: {results}")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "全面資源清理"},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"全面資源清理失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def _start_auto_cleanup(self) -> None:
|
||||
"""啟動自動清理線程"""
|
||||
if not self.auto_cleanup_enabled or self._cleanup_thread:
|
||||
return
|
||||
|
||||
def cleanup_worker():
|
||||
"""清理工作線程"""
|
||||
while not self._stop_cleanup.wait(self.cleanup_interval):
|
||||
try:
|
||||
# 執行定期清理
|
||||
self.cleanup_temp_files()
|
||||
self._check_process_health()
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "自動清理"},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"自動清理失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
self._cleanup_thread = threading.Thread(
|
||||
target=cleanup_worker,
|
||||
name="ResourceManager-AutoCleanup",
|
||||
daemon=True
|
||||
)
|
||||
self._cleanup_thread.start()
|
||||
debug_log("自動清理線程已啟動")
|
||||
|
||||
def _check_process_health(self) -> None:
|
||||
"""檢查進程健康狀態"""
|
||||
current_time = time.time()
|
||||
|
||||
for pid, process_info in self.processes.items():
|
||||
try:
|
||||
process_obj = process_info.get("process")
|
||||
last_check = process_info.get("last_check", current_time)
|
||||
|
||||
# 每分鐘檢查一次
|
||||
if current_time - last_check < 60:
|
||||
continue
|
||||
|
||||
# 更新檢查時間
|
||||
process_info["last_check"] = current_time
|
||||
|
||||
# 檢查進程是否還在運行
|
||||
if process_obj and hasattr(process_obj, 'poll'):
|
||||
if process_obj.poll() is not None:
|
||||
# 進程已結束,移除追蹤
|
||||
debug_log(f"檢測到進程 {pid} 已結束,移除追蹤")
|
||||
self.unregister_process(pid)
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"檢查進程 {pid} 健康狀態失敗: {e}")
|
||||
|
||||
def stop_auto_cleanup(self) -> None:
|
||||
"""停止自動清理"""
|
||||
if self._cleanup_thread:
|
||||
self._stop_cleanup.set()
|
||||
self._cleanup_thread.join(timeout=5)
|
||||
self._cleanup_thread = None
|
||||
debug_log("自動清理線程已停止")
|
||||
|
||||
def get_resource_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
獲取資源統計信息
|
||||
|
||||
Returns:
|
||||
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
|
||||
})
|
||||
|
||||
# 添加內存監控統計
|
||||
try:
|
||||
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
|
||||
})
|
||||
except Exception as e:
|
||||
debug_log(f"獲取內存統計失敗: {e}")
|
||||
|
||||
return current_stats
|
||||
|
||||
def get_detailed_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
獲取詳細資源信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 詳細資源信息
|
||||
"""
|
||||
return {
|
||||
"temp_files": list(self.temp_files),
|
||||
"temp_dirs": list(self.temp_dirs),
|
||||
"processes": {
|
||||
pid: {
|
||||
"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)
|
||||
}
|
||||
for pid, info in self.processes.items()
|
||||
},
|
||||
"file_handles_count": len(self.file_handles),
|
||||
"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
|
||||
) -> None:
|
||||
"""
|
||||
配置資源管理器
|
||||
|
||||
Args:
|
||||
auto_cleanup_enabled: 是否啟用自動清理
|
||||
cleanup_interval: 清理間隔(秒)
|
||||
temp_file_max_age: 臨時文件最大年齡(秒)
|
||||
"""
|
||||
if auto_cleanup_enabled is not None:
|
||||
old_enabled = self.auto_cleanup_enabled
|
||||
self.auto_cleanup_enabled = auto_cleanup_enabled
|
||||
|
||||
if old_enabled and not auto_cleanup_enabled:
|
||||
self.stop_auto_cleanup()
|
||||
elif not old_enabled and auto_cleanup_enabled:
|
||||
self._start_auto_cleanup()
|
||||
elif auto_cleanup_enabled and self._cleanup_thread is None:
|
||||
# 如果啟用了自動清理但線程不存在,重新啟動
|
||||
self._start_auto_cleanup()
|
||||
|
||||
if cleanup_interval is not None:
|
||||
self.cleanup_interval = max(60, cleanup_interval) # 最小1分鐘
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
# 全局資源管理器實例
|
||||
_resource_manager = None
|
||||
|
||||
|
||||
def get_resource_manager() -> ResourceManager:
|
||||
"""
|
||||
獲取全局資源管理器實例
|
||||
|
||||
Returns:
|
||||
ResourceManager: 資源管理器實例
|
||||
"""
|
||||
global _resource_manager
|
||||
if _resource_manager is None:
|
||||
_resource_manager = ResourceManager()
|
||||
return _resource_manager
|
||||
|
||||
|
||||
# 便捷函數
|
||||
def create_temp_file(suffix: str = "", prefix: str = "mcp_", **kwargs) -> str:
|
||||
"""創建臨時文件的便捷函數"""
|
||||
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)
|
||||
|
||||
|
||||
def register_process(process: Union[subprocess.Popen, int], description: str = "", **kwargs) -> int:
|
||||
"""註冊進程的便捷函數"""
|
||||
return get_resource_manager().register_process(process, description=description, **kwargs)
|
||||
|
||||
|
||||
def cleanup_all_resources(force: bool = False) -> Dict[str, int]:
|
||||
"""清理所有資源的便捷函數"""
|
||||
return get_resource_manager().cleanup_all(force=force)
|
@ -10,7 +10,7 @@
|
||||
"commands": "⚡ Commands",
|
||||
"command": "⚡ Commands",
|
||||
"settings": "⚙️ Settings",
|
||||
"combined": "📝 Combined Mode",
|
||||
"combined": "📝 Workspace",
|
||||
"about": "ℹ️ About"
|
||||
},
|
||||
"feedback": {
|
||||
@ -73,7 +73,7 @@
|
||||
"history": "Command History"
|
||||
},
|
||||
"combined": {
|
||||
"description": "Combined mode: AI summary and feedback input are on the same page for easy comparison.",
|
||||
"description": "AI summary and feedback input are on the same page for easy comparison.",
|
||||
"summaryTitle": "📋 AI Work Summary",
|
||||
"feedbackTitle": "💬 Provide Feedback"
|
||||
},
|
||||
@ -86,12 +86,10 @@
|
||||
"interface": "Interface Settings",
|
||||
"layoutMode": "Interface Layout Mode",
|
||||
"layoutModeDesc": "Select how AI summary and feedback input are displayed",
|
||||
"separateMode": "Separate Mode",
|
||||
"separateModeDesc": "AI summary and feedback are in separate tabs",
|
||||
"combinedVertical": "Combined Mode (Vertical Layout)",
|
||||
"combinedVerticalDesc": "AI summary on top, feedback input below, both on the same page",
|
||||
"combinedHorizontal": "Combined Mode (Horizontal Layout)",
|
||||
"combinedHorizontalDesc": "AI summary on left, feedback input on right, expanding summary viewing area",
|
||||
"combinedVertical": "Vertical Layout",
|
||||
"combinedVerticalDesc": "AI summary on top, feedback input below, suitable for standard screens",
|
||||
"combinedHorizontal": "Horizontal Layout",
|
||||
"combinedHorizontalDesc": "AI summary on left, feedback input on right, suitable for widescreen displays",
|
||||
"autoClose": "Auto Close Page",
|
||||
"autoCloseDesc": "Automatically close page after submitting feedback",
|
||||
"theme": "Theme",
|
||||
@ -172,6 +170,15 @@
|
||||
"timeoutDescription": "Due to prolonged inactivity, the session has timed out. The interface will automatically close in 3 seconds.",
|
||||
"closing": "Closing..."
|
||||
},
|
||||
"autoRefresh": {
|
||||
"enable": "Auto Detect",
|
||||
"seconds": "seconds",
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Detecting",
|
||||
"checking": "Checking",
|
||||
"detected": "Detected",
|
||||
"error": "Failed"
|
||||
},
|
||||
|
||||
"dynamic": {
|
||||
"aiSummary": "Test Web UI Functionality\n\n🎯 **Test Items:**\n- Web UI server startup and operation\n- WebSocket real-time communication\n- Feedback submission functionality\n- Image upload and preview\n- Command execution functionality\n- Smart Ctrl+V image pasting\n- Multi-language interface functionality\n\n📋 **Test Steps:**\n1. Test image upload (drag-drop, file selection, clipboard)\n2. Press Ctrl+V in text box to test smart pasting\n3. Try switching languages (Traditional Chinese/Simplified Chinese/English)\n4. Test command execution functionality\n5. Submit feedback and images\n\nPlease test these features and provide feedback!",
|
||||
|
@ -10,7 +10,7 @@
|
||||
"commands": "⚡ 命令",
|
||||
"command": "⚡ 命令",
|
||||
"settings": "⚙️ 设置",
|
||||
"combined": "📝 合并模式",
|
||||
"combined": "📝 工作区",
|
||||
"about": "ℹ️ 关于"
|
||||
},
|
||||
"feedback": {
|
||||
@ -73,7 +73,7 @@
|
||||
"history": "命令历史"
|
||||
},
|
||||
"combined": {
|
||||
"description": "合并模式:AI 摘要和反馈输入在同一页面中,方便对照查看。",
|
||||
"description": "AI 摘要和反馈输入在同一页面中,方便对照查看。",
|
||||
"summaryTitle": "📋 AI 工作摘要",
|
||||
"feedbackTitle": "💬 提供反馈"
|
||||
},
|
||||
@ -86,12 +86,10 @@
|
||||
"interface": "界面设定",
|
||||
"layoutMode": "界面布局模式",
|
||||
"layoutModeDesc": "选择 AI 摘要和反馈输入的显示方式",
|
||||
"separateMode": "分离模式",
|
||||
"separateModeDesc": "AI 摘要和反馈分别在不同页签",
|
||||
"combinedVertical": "合并模式(垂直布局)",
|
||||
"combinedVerticalDesc": "AI 摘要在上,反馈输入在下,摘要和反馈在同一页面",
|
||||
"combinedHorizontal": "合并模式(水平布局)",
|
||||
"combinedHorizontalDesc": "AI 摘要在左,反馈输入在右,增大摘要可视区域",
|
||||
"combinedVertical": "垂直布局",
|
||||
"combinedVerticalDesc": "AI 摘要在上,反馈输入在下,适合标准屏幕使用",
|
||||
"combinedHorizontal": "水平布局",
|
||||
"combinedHorizontalDesc": "AI 摘要在左,反馈输入在右,适合宽屏幕使用",
|
||||
"autoClose": "自动关闭页面",
|
||||
"autoCloseDesc": "提交回馈后自动关闭页面",
|
||||
"theme": "主题",
|
||||
@ -172,6 +170,15 @@
|
||||
"timeoutDescription": "由于长时间无响应,会话已超时。界面将在 3 秒后自动关闭。",
|
||||
"closing": "正在关闭..."
|
||||
},
|
||||
"autoRefresh": {
|
||||
"enable": "自动检测",
|
||||
"seconds": "秒",
|
||||
"disabled": "停用",
|
||||
"enabled": "检测中",
|
||||
"checking": "检查中",
|
||||
"detected": "已检测",
|
||||
"error": "失败"
|
||||
},
|
||||
|
||||
"dynamic": {
|
||||
"aiSummary": "测试 Web UI 功能\n\n🎯 **功能测试项目:**\n- Web UI 服务器启动和运行\n- WebSocket 实时通讯\n- 反馈提交功能\n- 图片上传和预览\n- 命令执行功能\n- 智能 Ctrl+V 图片粘贴\n- 多语言界面功能\n\n📋 **测试步骤:**\n1. 测试图片上传(拖拽、选择文件、剪贴板)\n2. 在文本框内按 Ctrl+V 测试智能粘贴\n3. 尝试切换语言(繁中/简中/英文)\n4. 测试命令执行功能\n5. 提交反馈和图片\n\n请测试这些功能并提供反馈!",
|
||||
|
@ -10,7 +10,7 @@
|
||||
"commands": "⚡ 命令",
|
||||
"command": "⚡ 命令",
|
||||
"settings": "⚙️ 設定",
|
||||
"combined": "📝 合併模式",
|
||||
"combined": "📝 工作區",
|
||||
"about": "ℹ️ 關於"
|
||||
},
|
||||
"feedback": {
|
||||
@ -73,7 +73,7 @@
|
||||
"history": "命令歷史"
|
||||
},
|
||||
"combined": {
|
||||
"description": "合併模式:AI 摘要和回饋輸入在同一頁面中,方便對照查看。",
|
||||
"description": "AI 摘要和回饋輸入在同一頁面中,方便對照查看。",
|
||||
"summaryTitle": "📋 AI 工作摘要",
|
||||
"feedbackTitle": "💬 提供回饋"
|
||||
},
|
||||
@ -86,12 +86,10 @@
|
||||
"interface": "介面設定",
|
||||
"layoutMode": "界面佈局模式",
|
||||
"layoutModeDesc": "選擇 AI 摘要和回饋輸入的顯示方式",
|
||||
"separateMode": "分離模式",
|
||||
"separateModeDesc": "AI 摘要和回饋分別在不同頁籤",
|
||||
"combinedVertical": "合併模式(垂直布局)",
|
||||
"combinedVerticalDesc": "AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面",
|
||||
"combinedHorizontal": "合併模式(水平布局)",
|
||||
"combinedHorizontalDesc": "AI 摘要在左,回饋輸入在右,增大摘要可視區域",
|
||||
"combinedVertical": "垂直佈局",
|
||||
"combinedVerticalDesc": "AI 摘要在上,回饋輸入在下,適合標準螢幕使用",
|
||||
"combinedHorizontal": "水平佈局",
|
||||
"combinedHorizontalDesc": "AI 摘要在左,回饋輸入在右,適合寬螢幕使用",
|
||||
"autoClose": "自動關閉頁面",
|
||||
"autoCloseDesc": "提交回饋後自動關閉頁面",
|
||||
"theme": "主題",
|
||||
@ -172,6 +170,15 @@
|
||||
"timeoutDescription": "由於長時間無回應,會話已超時。介面將在 3 秒後自動關閉。",
|
||||
"closing": "正在關閉..."
|
||||
},
|
||||
"autoRefresh": {
|
||||
"enable": "自動檢測",
|
||||
"seconds": "秒",
|
||||
"disabled": "停用",
|
||||
"enabled": "檢測中",
|
||||
"checking": "檢查中",
|
||||
"detected": "已檢測",
|
||||
"error": "失敗"
|
||||
},
|
||||
|
||||
"dynamic": {
|
||||
"aiSummary": "測試 Web UI 功能\n\n🎯 **功能測試項目:**\n- Web UI 服務器啟動和運行\n- WebSocket 即時通訊\n- 回饋提交功能\n- 圖片上傳和預覽\n- 命令執行功能\n- 智能 Ctrl+V 圖片貼上\n- 多語言介面功能\n\n📋 **測試步驟:**\n1. 測試圖片上傳(拖拽、選擇檔案、剪貼簿)\n2. 在文字框內按 Ctrl+V 測試智能貼上\n3. 嘗試切換語言(繁中/簡中/英文)\n4. 測試命令執行功能\n5. 提交回饋和圖片\n\n請測試這些功能並提供回饋!",
|
||||
|
@ -17,17 +17,23 @@ import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
import uvicorn
|
||||
|
||||
from .models import WebFeedbackSession, FeedbackResult
|
||||
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
|
||||
|
||||
@ -56,10 +62,20 @@ class WebUIManager:
|
||||
else:
|
||||
debug_log(f"未設定 MCP_WEB_PORT 環境變數,使用預設端口 {preferred_port}")
|
||||
|
||||
# 優先使用指定端口,確保 localStorage 的一致性
|
||||
self.port = port or find_free_port(preferred_port=preferred_port)
|
||||
# 使用增強的端口管理,支持自動清理
|
||||
self.port = port or PortManager.find_free_port_enhanced(
|
||||
preferred_port=preferred_port,
|
||||
auto_cleanup=True,
|
||||
host=self.host
|
||||
)
|
||||
self.app = FastAPI(title="MCP Feedback Enhanced")
|
||||
|
||||
# 設置壓縮和緩存中間件
|
||||
self._setup_compression_middleware()
|
||||
|
||||
# 設置內存監控
|
||||
self._setup_memory_monitoring()
|
||||
|
||||
# 重構:使用單一活躍會話而非會話字典
|
||||
self.current_session: Optional[WebFeedbackSession] = None
|
||||
self.sessions: Dict[str, WebFeedbackSession] = {} # 保留用於向後兼容
|
||||
@ -70,6 +86,17 @@ class WebUIManager:
|
||||
# 會話更新通知標記
|
||||
self._pending_session_update = False
|
||||
|
||||
# 會話清理統計
|
||||
self.cleanup_stats = {
|
||||
"total_cleanups": 0,
|
||||
"expired_cleanups": 0,
|
||||
"memory_pressure_cleanups": 0,
|
||||
"manual_cleanups": 0,
|
||||
"last_cleanup_time": None,
|
||||
"total_cleanup_duration": 0.0,
|
||||
"sessions_cleaned": 0
|
||||
}
|
||||
|
||||
self.server_thread = None
|
||||
self.server_process = None
|
||||
self.i18n = get_i18n_manager()
|
||||
@ -83,6 +110,105 @@ class WebUIManager:
|
||||
|
||||
debug_log(f"WebUIManager 初始化完成,將在 {self.host}:{self.port} 啟動")
|
||||
|
||||
def _setup_compression_middleware(self):
|
||||
"""設置壓縮和緩存中間件"""
|
||||
# 獲取壓縮管理器
|
||||
compression_manager = get_compression_manager()
|
||||
config = compression_manager.config
|
||||
|
||||
# 添加 Gzip 壓縮中間件
|
||||
self.app.add_middleware(
|
||||
GZipMiddleware,
|
||||
minimum_size=config.minimum_size
|
||||
)
|
||||
|
||||
# 添加緩存和壓縮統計中間件
|
||||
@self.app.middleware("http")
|
||||
async def compression_and_cache_middleware(request: Request, call_next):
|
||||
"""壓縮和緩存中間件"""
|
||||
response = await call_next(request)
|
||||
|
||||
# 添加緩存頭
|
||||
if not config.should_exclude_path(request.url.path):
|
||||
cache_headers = config.get_cache_headers(request.url.path)
|
||||
for key, value in cache_headers.items():
|
||||
response.headers[key] = value
|
||||
|
||||
# 更新壓縮統計(如果可能)
|
||||
try:
|
||||
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)
|
||||
except (ValueError, TypeError):
|
||||
# 忽略統計錯誤,不影響正常響應
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
debug_log("壓縮和緩存中間件設置完成")
|
||||
|
||||
def _setup_memory_monitoring(self):
|
||||
"""設置內存監控"""
|
||||
try:
|
||||
self.memory_monitor = get_memory_monitor()
|
||||
|
||||
# 添加 Web 應用特定的警告回調
|
||||
def web_memory_alert(alert):
|
||||
debug_log(f"Web UI 內存警告 [{alert.level}]: {alert.message}")
|
||||
|
||||
# 根據警告級別觸發不同的清理策略
|
||||
if alert.level == "critical":
|
||||
# 危險級別:清理過期會話
|
||||
cleaned = self.cleanup_expired_sessions()
|
||||
debug_log(f"內存危險警告觸發,清理了 {cleaned} 個過期會話")
|
||||
elif alert.level == "emergency":
|
||||
# 緊急級別:強制清理會話
|
||||
cleaned = self.cleanup_sessions_by_memory_pressure(force=True)
|
||||
debug_log(f"內存緊急警告觸發,強制清理了 {cleaned} 個會話")
|
||||
|
||||
self.memory_monitor.add_alert_callback(web_memory_alert)
|
||||
|
||||
# 添加會話清理回調到內存監控
|
||||
def session_cleanup_callback(force: bool = False):
|
||||
"""內存監控觸發的會話清理回調"""
|
||||
try:
|
||||
if force:
|
||||
# 強制清理:包括內存壓力清理
|
||||
cleaned = self.cleanup_sessions_by_memory_pressure(force=True)
|
||||
debug_log(f"內存監控強制清理了 {cleaned} 個會話")
|
||||
else:
|
||||
# 常規清理:只清理過期會話
|
||||
cleaned = self.cleanup_expired_sessions()
|
||||
debug_log(f"內存監控清理了 {cleaned} 個過期會話")
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "內存監控會話清理", "force": force},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"內存監控會話清理失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
self.memory_monitor.add_cleanup_callback(session_cleanup_callback)
|
||||
|
||||
# 確保內存監控已啟動(ResourceManager 可能已經啟動了)
|
||||
if not self.memory_monitor.is_monitoring:
|
||||
self.memory_monitor.start_monitoring()
|
||||
|
||||
debug_log("Web UI 內存監控設置完成,已集成會話清理回調")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "設置 Web UI 內存監控"},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"設置 Web UI 內存監控失敗 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
def _setup_static_files(self):
|
||||
"""設置靜態文件服務"""
|
||||
# Web UI 靜態文件
|
||||
@ -135,7 +261,7 @@ class WebUIManager:
|
||||
|
||||
# 處理會話更新通知
|
||||
if old_websocket:
|
||||
# 有舊連接,立即發送會話更新通知
|
||||
# 有舊連接,立即發送會話更新通知並轉移連接
|
||||
self._old_websocket_for_update = old_websocket
|
||||
self._new_session_for_update = session
|
||||
debug_log("已保存舊 WebSocket 連接,準備發送會話更新通知")
|
||||
@ -143,10 +269,13 @@ class WebUIManager:
|
||||
# 立即發送會話更新通知
|
||||
import asyncio
|
||||
try:
|
||||
# 在後台任務中發送通知
|
||||
# 在後台任務中發送通知並轉移連接
|
||||
asyncio.create_task(self._send_immediate_session_update())
|
||||
except Exception as e:
|
||||
debug_log(f"創建會話更新任務失敗: {e}")
|
||||
# 即使任務創建失敗,也要嘗試直接轉移連接
|
||||
session.websocket = old_websocket
|
||||
debug_log("任務創建失敗,直接轉移 WebSocket 連接到新會話")
|
||||
self._pending_session_update = True
|
||||
else:
|
||||
# 沒有舊連接,標記需要發送會話更新通知(當新 WebSocket 連接建立時)
|
||||
@ -262,16 +391,44 @@ class WebUIManager:
|
||||
if e.errno == 10048: # Windows: 位址已在使用中
|
||||
retry_count += 1
|
||||
if retry_count < max_retries:
|
||||
debug_log(f"端口 {self.port} 被占用,嘗試下一個端口")
|
||||
self.port = find_free_port(self.port + 1)
|
||||
debug_log(f"端口 {self.port} 被占用,使用增強端口管理查找新端口")
|
||||
# 使用增強的端口管理查找新端口
|
||||
try:
|
||||
self.port = PortManager.find_free_port_enhanced(
|
||||
preferred_port=self.port + 1,
|
||||
auto_cleanup=False, # 啟動時不自動清理,避免誤殺其他服務
|
||||
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
|
||||
)
|
||||
debug_log(f"無法找到可用端口 [錯誤ID: {error_id}]: {port_error}")
|
||||
break
|
||||
else:
|
||||
debug_log("已達到最大重試次數,無法啟動伺服器")
|
||||
break
|
||||
else:
|
||||
debug_log(f"伺服器啟動錯誤: {e}")
|
||||
# 使用統一錯誤處理
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "伺服器啟動", "host": self.host, "port": self.port},
|
||||
error_type=ErrorType.NETWORK
|
||||
)
|
||||
debug_log(f"伺服器啟動錯誤 [錯誤ID: {error_id}]: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
debug_log(f"伺服器運行錯誤: {e}")
|
||||
# 使用統一錯誤處理
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"operation": "伺服器運行", "host": self.host, "port": self.port},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"伺服器運行錯誤 [錯誤ID: {error_id}]: {e}")
|
||||
break
|
||||
|
||||
# 在新線程中啟動伺服器
|
||||
@ -351,8 +508,21 @@ class WebUIManager:
|
||||
old_websocket = self._old_websocket_for_update
|
||||
new_session = self._new_session_for_update
|
||||
|
||||
# 檢查舊連接是否仍然有效
|
||||
if old_websocket and not old_websocket.client_state.DISCONNECTED:
|
||||
# 改進的連接有效性檢查
|
||||
websocket_valid = False
|
||||
if old_websocket:
|
||||
try:
|
||||
# 檢查 WebSocket 連接狀態
|
||||
if hasattr(old_websocket, 'client_state'):
|
||||
websocket_valid = old_websocket.client_state != old_websocket.client_state.DISCONNECTED
|
||||
else:
|
||||
# 如果沒有 client_state 屬性,嘗試發送測試消息來檢查連接
|
||||
websocket_valid = True
|
||||
except Exception as check_error:
|
||||
debug_log(f"檢查 WebSocket 連接狀態失敗: {check_error}")
|
||||
websocket_valid = False
|
||||
|
||||
if websocket_valid:
|
||||
try:
|
||||
# 發送會話更新通知
|
||||
await old_websocket.send_json({
|
||||
@ -369,11 +539,18 @@ class WebUIManager:
|
||||
# 延遲一小段時間讓前端處理消息
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
# 將 WebSocket 連接轉移到新會話
|
||||
new_session.websocket = old_websocket
|
||||
debug_log("已將 WebSocket 連接轉移到新會話")
|
||||
|
||||
except Exception as send_error:
|
||||
debug_log(f"發送會話更新通知失敗: {send_error}")
|
||||
|
||||
# 安全關閉舊連接
|
||||
await self._safe_close_websocket(old_websocket)
|
||||
# 如果發送失敗,仍然嘗試轉移連接
|
||||
new_session.websocket = old_websocket
|
||||
debug_log("發送失敗但仍轉移 WebSocket 連接到新會話")
|
||||
else:
|
||||
debug_log("舊 WebSocket 連接無效,設置待更新標記")
|
||||
self._pending_session_update = True
|
||||
|
||||
# 清理臨時變數
|
||||
delattr(self, '_old_websocket_for_update')
|
||||
@ -390,29 +567,24 @@ class WebUIManager:
|
||||
self._pending_session_update = True
|
||||
|
||||
async def _safe_close_websocket(self, websocket):
|
||||
"""安全關閉 WebSocket 連接,避免事件循環衝突"""
|
||||
"""安全關閉 WebSocket 連接,避免事件循環衝突 - 僅在連接已轉移後調用"""
|
||||
if not websocket:
|
||||
return
|
||||
|
||||
# 注意:此方法現在主要用於清理,因為連接已經轉移到新會話
|
||||
# 只有在確認連接沒有被新會話使用時才關閉
|
||||
try:
|
||||
# 檢查連接狀態
|
||||
if websocket.client_state.DISCONNECTED:
|
||||
if hasattr(websocket, 'client_state') and websocket.client_state.DISCONNECTED:
|
||||
debug_log("WebSocket 已斷開,跳過關閉操作")
|
||||
return
|
||||
|
||||
# 嘗試正常關閉
|
||||
await asyncio.wait_for(websocket.close(code=1000, reason="會話更新"), timeout=2.0)
|
||||
debug_log("已正常關閉舊 WebSocket 連接")
|
||||
# 由於連接已轉移到新會話,這裡不再主動關閉
|
||||
# 讓新會話管理這個連接的生命週期
|
||||
debug_log("WebSocket 連接已轉移到新會話,跳過關閉操作")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
debug_log("WebSocket 關閉超時,強制斷開")
|
||||
except RuntimeError as e:
|
||||
if "attached to a different loop" in str(e):
|
||||
debug_log(f"WebSocket 事件循環衝突,忽略關閉錯誤: {e}")
|
||||
else:
|
||||
debug_log(f"WebSocket 關閉時發生運行時錯誤: {e}")
|
||||
except Exception as e:
|
||||
debug_log(f"關閉 WebSocket 連接時發生未知錯誤: {e}")
|
||||
debug_log(f"檢查 WebSocket 連接狀態時發生錯誤: {e}")
|
||||
|
||||
async def _check_active_tabs(self) -> bool:
|
||||
"""檢查是否有活躍標籤頁 - 優先檢查全局狀態,回退到 API"""
|
||||
@ -451,12 +623,175 @@ class WebUIManager:
|
||||
"""獲取伺服器 URL"""
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def cleanup_expired_sessions(self) -> int:
|
||||
"""清理過期會話"""
|
||||
cleanup_start_time = time.time()
|
||||
expired_sessions = []
|
||||
|
||||
# 掃描過期會話
|
||||
for session_id, session in self.sessions.items():
|
||||
if session.is_expired():
|
||||
expired_sessions.append(session_id)
|
||||
|
||||
# 批量清理過期會話
|
||||
cleaned_count = 0
|
||||
for session_id in expired_sessions:
|
||||
try:
|
||||
session = self.sessions.get(session_id)
|
||||
if session:
|
||||
# 使用增強清理方法
|
||||
session._cleanup_sync_enhanced(CleanupReason.EXPIRED)
|
||||
del self.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
|
||||
# 如果清理的是當前活躍會話,清空當前會話
|
||||
if self.current_session and self.current_session.session_id == session_id:
|
||||
self.current_session = None
|
||||
debug_log("清空過期的當前活躍會話")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"session_id": session_id, "operation": "清理過期會話"},
|
||||
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
|
||||
})
|
||||
|
||||
if cleaned_count > 0:
|
||||
debug_log(f"清理了 {cleaned_count} 個過期會話,耗時: {cleanup_duration:.2f}秒")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def cleanup_sessions_by_memory_pressure(self, force: bool = False) -> int:
|
||||
"""根據內存壓力清理會話"""
|
||||
cleanup_start_time = time.time()
|
||||
sessions_to_clean = []
|
||||
|
||||
# 根據優先級選擇要清理的會話
|
||||
# 優先級:已完成 > 已提交反饋 > 錯誤狀態 > 空閒時間最長
|
||||
for session_id, session in self.sessions.items():
|
||||
# 跳過當前活躍會話(除非強制清理)
|
||||
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]:
|
||||
sessions_to_clean.append((session_id, session, 1)) # 高優先級
|
||||
elif session.status == SessionStatus.FEEDBACK_SUBMITTED:
|
||||
# 已提交反饋但空閒時間較長的會話
|
||||
if session.get_idle_time() > 300: # 5分鐘空閒
|
||||
sessions_to_clean.append((session_id, session, 2)) # 中優先級
|
||||
elif session.get_idle_time() > 600: # 10分鐘空閒
|
||||
sessions_to_clean.append((session_id, session, 3)) # 低優先級
|
||||
|
||||
# 按優先級排序
|
||||
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))
|
||||
cleaned_count = 0
|
||||
|
||||
for i in range(max_cleanup):
|
||||
session_id, session, priority = sessions_to_clean[i]
|
||||
try:
|
||||
# 使用增強清理方法
|
||||
session._cleanup_sync_enhanced(CleanupReason.MEMORY_PRESSURE)
|
||||
del self.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
|
||||
# 如果清理的是當前活躍會話,清空當前會話
|
||||
if self.current_session and self.current_session.session_id == session_id:
|
||||
self.current_session = None
|
||||
debug_log("因內存壓力清空當前活躍會話")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"session_id": session_id, "operation": "內存壓力清理"},
|
||||
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,
|
||||
"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}秒")
|
||||
|
||||
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 # 將在下面計算
|
||||
})
|
||||
|
||||
# 計算內存使用(如果可能)
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
stats["memory_usage_mb"] = round(process.memory_info().rss / (1024 * 1024), 2)
|
||||
except:
|
||||
pass
|
||||
|
||||
return stats
|
||||
|
||||
def _scan_expired_sessions(self) -> List[str]:
|
||||
"""掃描過期會話ID列表"""
|
||||
expired_sessions = []
|
||||
for session_id, session in self.sessions.items():
|
||||
if session.is_expired():
|
||||
expired_sessions.append(session_id)
|
||||
return expired_sessions
|
||||
|
||||
def stop(self):
|
||||
"""停止 Web UI 服務"""
|
||||
# 清理所有會話
|
||||
cleanup_start_time = time.time()
|
||||
session_count = len(self.sessions)
|
||||
|
||||
for session in list(self.sessions.values()):
|
||||
session.cleanup()
|
||||
try:
|
||||
session._cleanup_sync_enhanced(CleanupReason.SHUTDOWN)
|
||||
except Exception as e:
|
||||
debug_log(f"停止服務時清理會話失敗: {e}")
|
||||
|
||||
self.sessions.clear()
|
||||
self.current_session = None
|
||||
|
||||
# 更新統計
|
||||
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
|
||||
})
|
||||
|
||||
debug_log(f"停止服務時清理了 {session_count} 個會話,耗時: {cleanup_duration:.2f}秒")
|
||||
|
||||
# 停止伺服器(注意:uvicorn 的 graceful shutdown 需要額外處理)
|
||||
if self.server_thread and self.server_thread.is_alive():
|
||||
|
@ -7,10 +7,12 @@ Web UI 資料模型模組
|
||||
定義 Web UI 相關的資料結構和型別。
|
||||
"""
|
||||
|
||||
from .feedback_session import WebFeedbackSession
|
||||
from .feedback_session import WebFeedbackSession, SessionStatus, CleanupReason
|
||||
from .feedback_result import FeedbackResult
|
||||
|
||||
__all__ = [
|
||||
'WebFeedbackSession',
|
||||
'SessionStatus',
|
||||
'CleanupReason',
|
||||
'FeedbackResult'
|
||||
]
|
@ -11,13 +11,17 @@ import asyncio
|
||||
import base64
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
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
|
||||
|
||||
|
||||
class SessionStatus(Enum):
|
||||
@ -28,6 +32,17 @@ class SessionStatus(Enum):
|
||||
COMPLETED = "completed" # 已完成
|
||||
TIMEOUT = "timeout" # 超時
|
||||
ERROR = "error" # 錯誤
|
||||
EXPIRED = "expired" # 已過期
|
||||
|
||||
|
||||
class CleanupReason(Enum):
|
||||
"""清理原因枚舉"""
|
||||
TIMEOUT = "timeout" # 超時清理
|
||||
EXPIRED = "expired" # 過期清理
|
||||
MEMORY_PRESSURE = "memory_pressure" # 內存壓力清理
|
||||
MANUAL = "manual" # 手動清理
|
||||
ERROR = "error" # 錯誤清理
|
||||
SHUTDOWN = "shutdown" # 系統關閉清理
|
||||
|
||||
# 常數定義
|
||||
MAX_IMAGE_SIZE = 1 * 1024 * 1024 # 1MB 圖片大小限制
|
||||
@ -38,7 +53,8 @@ TEMP_DIR = Path.home() / ".cache" / "interactive-feedback-mcp-web"
|
||||
class WebFeedbackSession:
|
||||
"""Web 回饋會話管理"""
|
||||
|
||||
def __init__(self, session_id: str, project_directory: str, summary: str):
|
||||
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
|
||||
@ -54,18 +70,49 @@ class WebFeedbackSession:
|
||||
# 新增:會話狀態管理
|
||||
self.status = SessionStatus.WAITING
|
||||
self.status_message = "等待用戶回饋"
|
||||
self.created_at = asyncio.get_event_loop().time()
|
||||
# 統一使用 time.time() 以避免時間基準不一致
|
||||
self.created_at = time.time()
|
||||
self.last_activity = self.created_at
|
||||
|
||||
# 新增:自動清理配置
|
||||
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_stats = {
|
||||
"cleanup_count": 0,
|
||||
"last_cleanup_time": None,
|
||||
"cleanup_reason": None,
|
||||
"cleanup_duration": 0.0,
|
||||
"memory_freed": 0,
|
||||
"resources_cleaned": 0
|
||||
}
|
||||
|
||||
# 確保臨時目錄存在
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 獲取資源管理器實例
|
||||
self.resource_manager = get_resource_manager()
|
||||
|
||||
# 啟動自動清理定時器
|
||||
self._schedule_auto_cleanup()
|
||||
|
||||
debug_log(f"會話 {self.session_id} 初始化完成,自動清理延遲: {auto_cleanup_delay}秒,最大空閒: {max_idle_time}秒")
|
||||
|
||||
def update_status(self, status: SessionStatus, message: str = None):
|
||||
"""更新會話狀態"""
|
||||
self.status = status
|
||||
if message:
|
||||
self.status_message = message
|
||||
self.last_activity = asyncio.get_event_loop().time()
|
||||
# 統一使用 time.time()
|
||||
self.last_activity = time.time()
|
||||
|
||||
# 如果會話變為活躍狀態,重置清理定時器
|
||||
if status in [SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]:
|
||||
self._schedule_auto_cleanup()
|
||||
|
||||
debug_log(f"會話 {self.session_id} 狀態更新: {status.value} - {self.status_message}")
|
||||
|
||||
def get_status_info(self) -> dict:
|
||||
@ -86,6 +133,117 @@ class WebFeedbackSession:
|
||||
"""檢查會話是否活躍"""
|
||||
return self.status in [SessionStatus.WAITING, SessionStatus.ACTIVE, SessionStatus.FEEDBACK_SUBMITTED]
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""檢查會話是否已過期"""
|
||||
# 統一使用 time.time()
|
||||
current_time = time.time()
|
||||
|
||||
# 檢查是否超過最大空閒時間
|
||||
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}秒")
|
||||
return True
|
||||
|
||||
# 檢查是否處於已過期狀態
|
||||
if self.status == SessionStatus.EXPIRED:
|
||||
return True
|
||||
|
||||
# 檢查是否處於錯誤或超時狀態且超過一定時間
|
||||
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}秒")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_age(self) -> float:
|
||||
"""獲取會話年齡(秒)"""
|
||||
current_time = time.time()
|
||||
return current_time - self.created_at
|
||||
|
||||
def get_idle_time(self) -> float:
|
||||
"""獲取會話空閒時間(秒)"""
|
||||
current_time = time.time()
|
||||
return current_time - self.last_activity
|
||||
|
||||
def _schedule_auto_cleanup(self):
|
||||
"""安排自動清理定時器"""
|
||||
if self.cleanup_timer:
|
||||
self.cleanup_timer.cancel()
|
||||
|
||||
def auto_cleanup():
|
||||
"""自動清理回調"""
|
||||
try:
|
||||
if not self._cleanup_done and self.is_expired():
|
||||
debug_log(f"會話 {self.session_id} 觸發自動清理(過期)")
|
||||
# 使用異步方式執行清理
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(self._cleanup_resources_enhanced(CleanupReason.EXPIRED))
|
||||
except RuntimeError:
|
||||
# 如果沒有事件循環,使用同步清理
|
||||
self._cleanup_sync_enhanced(CleanupReason.EXPIRED)
|
||||
else:
|
||||
# 如果還沒過期,重新安排定時器
|
||||
self._schedule_auto_cleanup()
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={"session_id": self.session_id, "operation": "自動清理"},
|
||||
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}秒後觸發")
|
||||
|
||||
def extend_cleanup_timer(self, additional_time: int = None):
|
||||
"""延長清理定時器"""
|
||||
if additional_time is None:
|
||||
additional_time = self.auto_cleanup_delay
|
||||
|
||||
if self.cleanup_timer:
|
||||
self.cleanup_timer.cancel()
|
||||
|
||||
self.cleanup_timer = threading.Timer(additional_time, lambda: None)
|
||||
self.cleanup_timer.daemon = True
|
||||
self.cleanup_timer.start()
|
||||
|
||||
debug_log(f"會話 {self.session_id} 清理定時器已延長 {additional_time} 秒")
|
||||
|
||||
def add_cleanup_callback(self, callback: Callable):
|
||||
"""添加清理回調函數"""
|
||||
if callback not in self.cleanup_callbacks:
|
||||
self.cleanup_callbacks.append(callback)
|
||||
debug_log(f"會話 {self.session_id} 添加清理回調函數")
|
||||
|
||||
def remove_cleanup_callback(self, callback: Callable):
|
||||
"""移除清理回調函數"""
|
||||
if callback in self.cleanup_callbacks:
|
||||
self.cleanup_callbacks.remove(callback)
|
||||
debug_log(f"會話 {self.session_id} 移除清理回調函數")
|
||||
|
||||
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)
|
||||
})
|
||||
return stats
|
||||
|
||||
async def wait_for_feedback(self, timeout: int = 600) -> dict:
|
||||
"""
|
||||
等待用戶回饋,包含圖片,支援超時自動清理
|
||||
@ -249,6 +407,13 @@ class WebFeedbackSession:
|
||||
universal_newlines=True
|
||||
)
|
||||
|
||||
# 註冊進程到資源管理器
|
||||
register_process(
|
||||
self.process,
|
||||
description=f"WebFeedbackSession-{self.session_id}-command",
|
||||
auto_cleanup=True
|
||||
)
|
||||
|
||||
# 在背景線程中讀取輸出
|
||||
async def read_output():
|
||||
loop = asyncio.get_event_loop()
|
||||
@ -282,6 +447,9 @@ class WebFeedbackSession:
|
||||
if self.process:
|
||||
exit_code = self.process.wait()
|
||||
|
||||
# 從資源管理器取消註冊進程
|
||||
self.resource_manager.unregister_process(self.process.pid)
|
||||
|
||||
# 發送命令完成信號
|
||||
if self.websocket:
|
||||
try:
|
||||
@ -307,33 +475,72 @@ class WebFeedbackSession:
|
||||
pass
|
||||
|
||||
async def _cleanup_resources_on_timeout(self):
|
||||
"""超時時清理所有資源"""
|
||||
"""超時時清理所有資源(保持向後兼容)"""
|
||||
await self._cleanup_resources_enhanced(CleanupReason.TIMEOUT)
|
||||
|
||||
async def _cleanup_resources_enhanced(self, reason: CleanupReason):
|
||||
"""增強的資源清理方法"""
|
||||
if self._cleanup_done:
|
||||
return # 避免重複清理
|
||||
|
||||
cleanup_start_time = time.time()
|
||||
self._cleanup_done = True
|
||||
debug_log(f"開始清理會話 {self.session_id} 的資源...")
|
||||
|
||||
debug_log(f"開始清理會話 {self.session_id} 的資源,原因: {reason.value}")
|
||||
|
||||
# 更新清理統計
|
||||
self.cleanup_stats["cleanup_count"] += 1
|
||||
self.cleanup_stats["cleanup_reason"] = reason.value
|
||||
self.cleanup_stats["last_cleanup_time"] = datetime.now().isoformat()
|
||||
|
||||
resources_cleaned = 0
|
||||
memory_before = 0
|
||||
|
||||
try:
|
||||
# 1. 關閉 WebSocket 連接
|
||||
# 記錄清理前的內存使用(如果可能)
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
memory_before = process.memory_info().rss
|
||||
except:
|
||||
pass
|
||||
|
||||
# 1. 取消自動清理定時器
|
||||
if self.cleanup_timer:
|
||||
self.cleanup_timer.cancel()
|
||||
self.cleanup_timer = None
|
||||
resources_cleaned += 1
|
||||
|
||||
# 2. 關閉 WebSocket 連接
|
||||
if self.websocket:
|
||||
try:
|
||||
# 先通知前端超時
|
||||
# 根據清理原因發送不同的通知消息
|
||||
message_map = {
|
||||
CleanupReason.TIMEOUT: "會話已超時,介面將自動關閉",
|
||||
CleanupReason.EXPIRED: "會話已過期,介面將自動關閉",
|
||||
CleanupReason.MEMORY_PRESSURE: "系統內存不足,會話將被清理",
|
||||
CleanupReason.MANUAL: "會話已被手動清理",
|
||||
CleanupReason.ERROR: "會話發生錯誤,將被清理",
|
||||
CleanupReason.SHUTDOWN: "系統正在關閉,會話將被清理"
|
||||
}
|
||||
|
||||
await self.websocket.send_json({
|
||||
"type": "session_timeout",
|
||||
"message": "會話已超時,介面將自動關閉"
|
||||
"type": "session_cleanup",
|
||||
"reason": reason.value,
|
||||
"message": message_map.get(reason, "會話將被清理")
|
||||
})
|
||||
await asyncio.sleep(0.1) # 給前端一點時間處理消息
|
||||
|
||||
# 安全關閉 WebSocket
|
||||
await self._safe_close_websocket()
|
||||
debug_log(f"會話 {self.session_id} WebSocket 已關閉")
|
||||
resources_cleaned += 1
|
||||
except Exception as e:
|
||||
debug_log(f"關閉 WebSocket 時發生錯誤: {e}")
|
||||
finally:
|
||||
self.websocket = None
|
||||
|
||||
# 2. 終止正在運行的命令進程
|
||||
# 3. 終止正在運行的命令進程
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
@ -343,67 +550,213 @@ class WebFeedbackSession:
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
debug_log(f"會話 {self.session_id} 命令進程已強制終止")
|
||||
resources_cleaned += 1
|
||||
except Exception as e:
|
||||
debug_log(f"終止命令進程時發生錯誤: {e}")
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
# 3. 設置完成事件(防止其他地方還在等待)
|
||||
# 4. 設置完成事件(防止其他地方還在等待)
|
||||
self.feedback_completed.set()
|
||||
|
||||
# 4. 清理臨時數據
|
||||
# 5. 清理臨時數據
|
||||
logs_count = len(self.command_logs)
|
||||
images_count = len(self.images)
|
||||
|
||||
self.command_logs.clear()
|
||||
self.images.clear()
|
||||
self.settings.clear()
|
||||
|
||||
debug_log(f"會話 {self.session_id} 資源清理完成")
|
||||
if logs_count > 0 or images_count > 0:
|
||||
resources_cleaned += logs_count + images_count
|
||||
debug_log(f"清理了 {logs_count} 條日誌和 {images_count} 張圖片")
|
||||
|
||||
# 6. 更新會話狀態
|
||||
if reason == CleanupReason.EXPIRED:
|
||||
self.status = SessionStatus.EXPIRED
|
||||
elif reason == CleanupReason.TIMEOUT:
|
||||
self.status = SessionStatus.TIMEOUT
|
||||
elif reason == CleanupReason.ERROR:
|
||||
self.status = SessionStatus.ERROR
|
||||
else:
|
||||
self.status = SessionStatus.COMPLETED
|
||||
|
||||
# 7. 調用清理回調函數
|
||||
for callback in self.cleanup_callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(callback):
|
||||
await callback(self, reason)
|
||||
else:
|
||||
callback(self, reason)
|
||||
except Exception as e:
|
||||
debug_log(f"清理回調執行失敗: {e}")
|
||||
|
||||
# 8. 計算清理效果
|
||||
cleanup_duration = time.time() - cleanup_start_time
|
||||
memory_after = 0
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
memory_after = process.memory_info().rss
|
||||
except:
|
||||
pass
|
||||
|
||||
memory_freed = max(0, memory_before - memory_after)
|
||||
|
||||
# 更新清理統計
|
||||
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}字節")
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤: {e}")
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={
|
||||
"session_id": self.session_id,
|
||||
"cleanup_reason": reason.value,
|
||||
"operation": "增強資源清理"
|
||||
},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
# 即使發生錯誤也要更新統計
|
||||
self.cleanup_stats["cleanup_duration"] = time.time() - cleanup_start_time
|
||||
|
||||
def _cleanup_sync(self):
|
||||
"""同步清理會話資源(但保留 WebSocket 連接)"""
|
||||
if self._cleanup_done:
|
||||
"""同步清理會話資源(但保留 WebSocket 連接)- 保持向後兼容"""
|
||||
self._cleanup_sync_enhanced(CleanupReason.MANUAL, preserve_websocket=True)
|
||||
|
||||
def _cleanup_sync_enhanced(self, reason: CleanupReason, preserve_websocket: bool = False):
|
||||
"""增強的同步清理會話資源"""
|
||||
if self._cleanup_done and not preserve_websocket:
|
||||
return
|
||||
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源(保留 WebSocket)...")
|
||||
cleanup_start_time = time.time()
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源,原因: {reason.value},保留WebSocket: {preserve_websocket}")
|
||||
|
||||
# 只清理進程,不清理 WebSocket 連接
|
||||
if self.process:
|
||||
# 更新清理統計
|
||||
self.cleanup_stats["cleanup_count"] += 1
|
||||
self.cleanup_stats["cleanup_reason"] = reason.value
|
||||
self.cleanup_stats["last_cleanup_time"] = datetime.now().isoformat()
|
||||
|
||||
resources_cleaned = 0
|
||||
memory_before = 0
|
||||
|
||||
try:
|
||||
# 記錄清理前的內存使用
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
memory_before = process.memory_info().rss
|
||||
except:
|
||||
try:
|
||||
self.process.kill()
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
pass
|
||||
|
||||
# 清理臨時數據
|
||||
self.command_logs.clear()
|
||||
# 注意:不設置 _cleanup_done = True,因為還需要清理 WebSocket
|
||||
# 1. 取消自動清理定時器
|
||||
if self.cleanup_timer:
|
||||
self.cleanup_timer.cancel()
|
||||
self.cleanup_timer = None
|
||||
resources_cleaned += 1
|
||||
|
||||
# 2. 清理進程
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
debug_log(f"會話 {self.session_id} 命令進程已正常終止")
|
||||
resources_cleaned += 1
|
||||
except:
|
||||
try:
|
||||
self.process.kill()
|
||||
debug_log(f"會話 {self.session_id} 命令進程已強制終止")
|
||||
resources_cleaned += 1
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
# 3. 清理臨時數據
|
||||
logs_count = len(self.command_logs)
|
||||
images_count = len(self.images)
|
||||
|
||||
self.command_logs.clear()
|
||||
if not preserve_websocket:
|
||||
self.images.clear()
|
||||
self.settings.clear()
|
||||
resources_cleaned += images_count
|
||||
|
||||
resources_cleaned += logs_count
|
||||
|
||||
# 4. 設置完成事件
|
||||
if not preserve_websocket:
|
||||
self.feedback_completed.set()
|
||||
|
||||
# 5. 更新狀態
|
||||
if not preserve_websocket:
|
||||
if reason == CleanupReason.EXPIRED:
|
||||
self.status = SessionStatus.EXPIRED
|
||||
elif reason == CleanupReason.TIMEOUT:
|
||||
self.status = SessionStatus.TIMEOUT
|
||||
elif reason == CleanupReason.ERROR:
|
||||
self.status = SessionStatus.ERROR
|
||||
else:
|
||||
self.status = SessionStatus.COMPLETED
|
||||
|
||||
self._cleanup_done = True
|
||||
|
||||
# 6. 調用清理回調函數(同步版本)
|
||||
for callback in self.cleanup_callbacks:
|
||||
try:
|
||||
if not asyncio.iscoroutinefunction(callback):
|
||||
callback(self, reason)
|
||||
except Exception as e:
|
||||
debug_log(f"同步清理回調執行失敗: {e}")
|
||||
|
||||
# 7. 計算清理效果
|
||||
cleanup_duration = time.time() - cleanup_start_time
|
||||
memory_after = 0
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
memory_after = process.memory_info().rss
|
||||
except:
|
||||
pass
|
||||
|
||||
memory_freed = max(0, memory_before - memory_after)
|
||||
|
||||
# 更新清理統計
|
||||
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}字節")
|
||||
|
||||
except Exception as e:
|
||||
error_id = ErrorHandler.log_error_with_context(
|
||||
e,
|
||||
context={
|
||||
"session_id": self.session_id,
|
||||
"cleanup_reason": reason.value,
|
||||
"preserve_websocket": preserve_websocket,
|
||||
"operation": "同步資源清理"
|
||||
},
|
||||
error_type=ErrorType.SYSTEM
|
||||
)
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源時發生錯誤 [錯誤ID: {error_id}]: {e}")
|
||||
|
||||
# 即使發生錯誤也要更新統計
|
||||
self.cleanup_stats["cleanup_duration"] = time.time() - cleanup_start_time
|
||||
|
||||
def cleanup(self):
|
||||
"""同步清理會話資源(保持向後兼容)"""
|
||||
if self._cleanup_done:
|
||||
return
|
||||
|
||||
self._cleanup_done = True
|
||||
debug_log(f"同步清理會話 {self.session_id} 資源...")
|
||||
|
||||
if self.process:
|
||||
try:
|
||||
self.process.terminate()
|
||||
self.process.wait(timeout=5)
|
||||
except:
|
||||
try:
|
||||
self.process.kill()
|
||||
except:
|
||||
pass
|
||||
self.process = None
|
||||
|
||||
# 設置完成事件
|
||||
self.feedback_completed.set()
|
||||
self._cleanup_sync_enhanced(CleanupReason.MANUAL)
|
||||
|
||||
async def _safe_close_websocket(self):
|
||||
"""安全關閉 WebSocket 連接,避免事件循環衝突"""
|
||||
|
@ -33,15 +33,15 @@ def load_user_layout_settings() -> str:
|
||||
if settings_file.exists():
|
||||
with open(settings_file, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
layout_mode = settings.get('layoutMode', 'separate')
|
||||
layout_mode = settings.get('layoutMode', 'combined-vertical')
|
||||
debug_log(f"從設定檔案載入佈局模式: {layout_mode}")
|
||||
return layout_mode
|
||||
else:
|
||||
debug_log("設定檔案不存在,使用預設佈局模式: separate")
|
||||
return 'separate'
|
||||
debug_log("設定檔案不存在,使用預設佈局模式: combined-vertical")
|
||||
return 'combined-vertical'
|
||||
except Exception as e:
|
||||
debug_log(f"載入佈局設定失敗: {e},使用預設佈局模式: separate")
|
||||
return 'separate'
|
||||
debug_log(f"載入佈局設定失敗: {e},使用預設佈局模式: combined-vertical")
|
||||
return 'combined-vertical'
|
||||
|
||||
|
||||
def setup_routes(manager: 'WebUIManager'):
|
||||
@ -140,6 +140,7 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
)
|
||||
|
||||
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(),
|
||||
@ -157,9 +158,13 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
session.websocket = websocket
|
||||
|
||||
debug_log(f"WebSocket 連接建立: 當前活躍會話")
|
||||
# 檢查會話是否已有 WebSocket 連接
|
||||
if session.websocket and session.websocket != websocket:
|
||||
debug_log("會話已有 WebSocket 連接,替換為新連接")
|
||||
|
||||
session.websocket = websocket
|
||||
debug_log(f"WebSocket 連接建立: 當前活躍會話 {session.session_id}")
|
||||
|
||||
# 發送連接成功消息
|
||||
try:
|
||||
@ -197,7 +202,14 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
await handle_websocket_message(manager, session, message)
|
||||
|
||||
# 重新獲取當前會話,以防會話已切換
|
||||
current_session = manager.get_current_session()
|
||||
if current_session and current_session.websocket == websocket:
|
||||
await handle_websocket_message(manager, current_session, message)
|
||||
else:
|
||||
debug_log("會話已切換或 WebSocket 連接不匹配,忽略消息")
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
debug_log(f"WebSocket 連接正常斷開")
|
||||
@ -207,8 +219,9 @@ def setup_routes(manager: 'WebUIManager'):
|
||||
debug_log(f"WebSocket 錯誤: {e}")
|
||||
finally:
|
||||
# 安全清理 WebSocket 連接
|
||||
if session.websocket == websocket:
|
||||
session.websocket = None
|
||||
current_session = manager.get_current_session()
|
||||
if current_session and current_session.websocket == websocket:
|
||||
current_session.websocket = None
|
||||
debug_log("已清理會話中的 WebSocket 連接")
|
||||
|
||||
@manager.app.post("/api/save-settings")
|
||||
|
@ -1266,3 +1266,127 @@ body {
|
||||
.compatibility-hint-btn:hover {
|
||||
background: #f57c00;
|
||||
}
|
||||
|
||||
/* 自動刷新控制項樣式 */
|
||||
.section-header-with-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* AI 工作摘要標題樣式 */
|
||||
h3.combined-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auto-refresh-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.auto-refresh-controls:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
.auto-refresh-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auto-refresh-toggle input[type="checkbox"]:checked {
|
||||
background: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.auto-refresh-toggle input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-refresh-interval {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.auto-refresh-interval input[type="number"] {
|
||||
width: 50px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auto-refresh-interval input[type="number"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.interval-unit {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-refresh-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 4px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.auto-refresh-status .status-indicator {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auto-refresh-status .status-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
@ -170,6 +170,13 @@ class FeedbackApp {
|
||||
this.heartbeatInterval = null;
|
||||
this.heartbeatFrequency = 30000; // 30秒 WebSocket 心跳
|
||||
|
||||
// 新增:WebSocket 連接狀態管理
|
||||
this.connectionReady = false;
|
||||
this.pendingSubmission = null;
|
||||
this.connectionCheckInterval = null;
|
||||
this.sessionUpdatePending = false;
|
||||
this.reconnectDelay = 1000; // 重連延遲,會逐漸增加
|
||||
|
||||
// UI 狀態
|
||||
this.currentTab = 'feedback';
|
||||
|
||||
@ -185,11 +192,17 @@ class FeedbackApp {
|
||||
|
||||
// 設定
|
||||
this.autoClose = false;
|
||||
this.layoutMode = 'separate';
|
||||
this.layoutMode = 'combined-vertical';
|
||||
|
||||
// 語言設定
|
||||
this.currentLanguage = 'zh-TW';
|
||||
|
||||
// 自動刷新設定
|
||||
this.autoRefreshEnabled = false;
|
||||
this.autoRefreshInterval = 5; // 默認5秒
|
||||
this.autoRefreshTimer = null;
|
||||
this.lastKnownSessionId = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
@ -223,6 +236,9 @@ class FeedbackApp {
|
||||
// 確保狀態指示器使用正確的翻譯(在國際化系統載入後)
|
||||
this.updateStatusIndicators();
|
||||
|
||||
// 初始化自動刷新功能
|
||||
this.initAutoRefresh();
|
||||
|
||||
// 設置頁面關閉時的清理
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.tabManager) {
|
||||
@ -231,6 +247,9 @@ class FeedbackApp {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('MCP Feedback Enhanced 應用程式初始化完成');
|
||||
@ -259,6 +278,12 @@ class FeedbackApp {
|
||||
this.commandOutput = document.getElementById('commandOutput');
|
||||
this.runCommandBtn = document.getElementById('runCommandBtn');
|
||||
|
||||
// 自動刷新相關元素
|
||||
this.autoRefreshCheckbox = document.getElementById('autoRefreshEnabled');
|
||||
this.autoRefreshIntervalInput = document.getElementById('autoRefreshInterval');
|
||||
this.refreshStatusIndicator = document.getElementById('refreshStatusIndicator');
|
||||
this.refreshStatusText = document.getElementById('refreshStatusText');
|
||||
|
||||
// 動態初始化圖片相關元素
|
||||
this.initImageElements();
|
||||
}
|
||||
@ -409,15 +434,28 @@ class FeedbackApp {
|
||||
* 設置圖片事件監聽器
|
||||
*/
|
||||
setupImageEventListeners() {
|
||||
console.log(`🖼️ 設置圖片事件監聽器 - imageInput: ${this.imageInput?.id}, imageUploadArea: ${this.imageUploadArea?.id}`);
|
||||
|
||||
// 文件選擇事件
|
||||
this.imageChangeHandler = (e) => {
|
||||
console.log(`📁 文件選擇事件觸發 - input: ${e.target.id}, files: ${e.target.files.length}`);
|
||||
this.handleFileSelect(e.target.files);
|
||||
};
|
||||
this.imageInput.addEventListener('change', this.imageChangeHandler);
|
||||
|
||||
// 點擊上傳區域
|
||||
this.imageClickHandler = () => {
|
||||
this.imageInput.click();
|
||||
// 點擊上傳區域 - 使用更安全的方式確保只觸發對應的 input
|
||||
this.imageClickHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 確保我們觸發的是正確的 input 元素
|
||||
const targetInput = this.imageInput;
|
||||
if (targetInput) {
|
||||
console.log(`🖱️ 點擊上傳區域 - 觸發 input: ${targetInput.id}`);
|
||||
targetInput.click();
|
||||
} else {
|
||||
console.warn('⚠️ 沒有找到對應的 input 元素');
|
||||
}
|
||||
};
|
||||
this.imageUploadArea.addEventListener('click', this.imageClickHandler);
|
||||
|
||||
@ -451,8 +489,10 @@ class FeedbackApp {
|
||||
// 重新初始化圖片元素(確保使用最新的佈局模式)
|
||||
this.initImageElements();
|
||||
|
||||
console.log(`🔍 檢查圖片元素 - imageUploadArea: ${this.imageUploadArea?.id || 'null'}, imageInput: ${this.imageInput?.id || 'null'}`);
|
||||
|
||||
if (!this.imageUploadArea || !this.imageInput) {
|
||||
console.warn('⚠️ 圖片處理初始化失敗 - 缺少必要元素');
|
||||
console.warn(`⚠️ 圖片處理初始化失敗 - imageUploadArea: ${!!this.imageUploadArea}, imageInput: ${!!this.imageInput}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -486,6 +526,7 @@ class FeedbackApp {
|
||||
* 移除舊的圖片事件監聽器
|
||||
*/
|
||||
removeImageEventListeners() {
|
||||
// 移除當前主要元素的事件監聽器
|
||||
if (this.imageInput && this.imageChangeHandler) {
|
||||
this.imageInput.removeEventListener('change', this.imageChangeHandler);
|
||||
}
|
||||
@ -503,6 +544,32 @@ class FeedbackApp {
|
||||
this.imageUploadArea.removeEventListener('drop', this.imageDropHandler);
|
||||
}
|
||||
}
|
||||
|
||||
// 額外清理:移除所有可能的圖片上傳區域的 click 事件監聽器
|
||||
const allImageUploadAreas = [
|
||||
document.getElementById('feedbackImageUploadArea'),
|
||||
document.getElementById('combinedImageUploadArea')
|
||||
].filter(area => area);
|
||||
|
||||
allImageUploadAreas.forEach(area => {
|
||||
if (area && this.imageClickHandler) {
|
||||
area.removeEventListener('click', this.imageClickHandler);
|
||||
console.log(`🧹 已移除 ${area.id} 的 click 事件監聽器`);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理所有可能的 input 元素的 change 事件監聽器
|
||||
const allImageInputs = [
|
||||
document.getElementById('feedbackImageInput'),
|
||||
document.getElementById('combinedImageInput')
|
||||
].filter(input => input);
|
||||
|
||||
allImageInputs.forEach(input => {
|
||||
if (input && this.imageChangeHandler) {
|
||||
input.removeEventListener('change', this.imageChangeHandler);
|
||||
console.log(`🧹 已移除 ${input.id} 的 change 事件監聽器`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -797,11 +864,11 @@ class FeedbackApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否可以提交回饋
|
||||
* 檢查是否可以提交回饋(舊版本,保持兼容性)
|
||||
*/
|
||||
canSubmitFeedback() {
|
||||
const canSubmit = this.feedbackState === 'waiting_for_feedback' && this.isConnected;
|
||||
console.log(`🔍 檢查提交權限: feedbackState=${this.feedbackState}, isConnected=${this.isConnected}, canSubmit=${canSubmit}`);
|
||||
const canSubmit = this.feedbackState === 'waiting_for_feedback' && this.isConnected && this.connectionReady;
|
||||
console.log(`🔍 檢查提交權限: feedbackState=${this.feedbackState}, isConnected=${this.isConnected}, connectionReady=${this.connectionReady}, canSubmit=${canSubmit}`);
|
||||
return canSubmit;
|
||||
}
|
||||
|
||||
@ -965,11 +1032,13 @@ class FeedbackApp {
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.connectionReady = false; // 等待連接確認
|
||||
this.updateConnectionStatus('connected', '已連接');
|
||||
console.log('WebSocket 連接已建立');
|
||||
|
||||
// 重置重連計數器
|
||||
// 重置重連計數器和延遲
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = 1000;
|
||||
|
||||
// 開始 WebSocket 心跳
|
||||
this.startWebSocketHeartbeat();
|
||||
@ -982,6 +1051,23 @@ class FeedbackApp {
|
||||
console.log('🔄 WebSocket 重連後重置處理狀態');
|
||||
this.setFeedbackState('waiting_for_feedback');
|
||||
}
|
||||
|
||||
// 如果有待處理的會話更新,處理它
|
||||
if (this.sessionUpdatePending) {
|
||||
console.log('🔄 處理待處理的會話更新');
|
||||
this.sessionUpdatePending = false;
|
||||
}
|
||||
|
||||
// 如果有待提交的回饋,處理它
|
||||
if (this.pendingSubmission) {
|
||||
console.log('🔄 處理待提交的回饋');
|
||||
setTimeout(() => {
|
||||
if (this.connectionReady && this.pendingSubmission) {
|
||||
this.submitFeedbackInternal(this.pendingSubmission);
|
||||
this.pendingSubmission = null;
|
||||
}
|
||||
}, 500); // 等待連接完全就緒
|
||||
}
|
||||
};
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
@ -995,6 +1081,7 @@ class FeedbackApp {
|
||||
|
||||
this.websocket.onclose = (event) => {
|
||||
this.isConnected = false;
|
||||
this.connectionReady = false;
|
||||
console.log('WebSocket 連接已關閉, code:', event.code, 'reason:', event.reason);
|
||||
|
||||
// 停止心跳
|
||||
@ -1012,15 +1099,23 @@ class FeedbackApp {
|
||||
} else {
|
||||
this.updateConnectionStatus('disconnected', '已斷開');
|
||||
|
||||
// 會話更新導致的正常關閉,立即重連
|
||||
if (event.code === 1000 && event.reason === '會話更新') {
|
||||
console.log('🔄 會話更新導致的連接關閉,立即重連...');
|
||||
this.sessionUpdatePending = true;
|
||||
setTimeout(() => {
|
||||
this.setupWebSocket();
|
||||
}, 200); // 短延遲後重連
|
||||
}
|
||||
// 只有在非正常關閉時才重連
|
||||
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
else if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(3000 * this.reconnectAttempts, 15000); // 最大延遲15秒
|
||||
console.log(`${delay/1000}秒後嘗試重連... (第${this.reconnectAttempts}次)`);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 15000); // 指數退避,最大15秒
|
||||
console.log(`${this.reconnectDelay / 1000}秒後嘗試重連... (第${this.reconnectAttempts}次)`);
|
||||
setTimeout(() => {
|
||||
console.log(`🔄 開始重連 WebSocket... (第${this.reconnectAttempts}次)`);
|
||||
this.setupWebSocket();
|
||||
}, delay);
|
||||
}, this.reconnectDelay);
|
||||
} else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.log('❌ 達到最大重連次數,停止重連');
|
||||
this.showMessage('WebSocket 連接失敗,請刷新頁面重試', 'error');
|
||||
@ -1078,6 +1173,18 @@ class FeedbackApp {
|
||||
switch (data.type) {
|
||||
case 'connection_established':
|
||||
console.log('WebSocket 連接確認');
|
||||
this.connectionReady = true;
|
||||
|
||||
// 如果有待提交的回饋,現在可以提交了
|
||||
if (this.pendingSubmission) {
|
||||
console.log('🔄 連接就緒,提交待處理的回饋');
|
||||
setTimeout(() => {
|
||||
if (this.pendingSubmission) {
|
||||
this.submitFeedbackInternal(this.pendingSubmission);
|
||||
this.pendingSubmission = null;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
break;
|
||||
case 'heartbeat_response':
|
||||
// 心跳回應,更新標籤頁活躍狀態
|
||||
@ -1149,8 +1256,11 @@ class FeedbackApp {
|
||||
document.title = `MCP Feedback - ${projectName}`;
|
||||
}
|
||||
|
||||
// 使用局部更新替代整頁刷新
|
||||
this.refreshPageContent();
|
||||
// 確保 WebSocket 連接就緒
|
||||
this.ensureWebSocketReady(() => {
|
||||
// 使用局部更新替代整頁刷新
|
||||
this.refreshPageContent();
|
||||
});
|
||||
} else {
|
||||
// 如果沒有會話信息,仍然重置狀態
|
||||
console.log('⚠️ 會話更新沒有包含會話信息,僅重置狀態');
|
||||
@ -1160,6 +1270,51 @@ class FeedbackApp {
|
||||
console.log('✅ 會話更新處理完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 確保 WebSocket 連接就緒
|
||||
*/
|
||||
ensureWebSocketReady(callback, maxWaitTime = 5000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
const checkConnection = () => {
|
||||
if (this.isConnected && this.connectionReady) {
|
||||
console.log('✅ WebSocket 連接已就緒');
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed >= maxWaitTime) {
|
||||
console.log('⚠️ WebSocket 連接等待超時,強制執行回調');
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果連接斷開,嘗試重連
|
||||
if (!this.isConnected) {
|
||||
console.log('🔄 WebSocket 未連接,嘗試重連...');
|
||||
this.setupWebSocket();
|
||||
}
|
||||
|
||||
// 繼續等待
|
||||
setTimeout(checkConnection, 200);
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查是否可以提交回饋
|
||||
*/
|
||||
canSubmitFeedback() {
|
||||
const canSubmit = this.isConnected &&
|
||||
this.connectionReady &&
|
||||
this.feedbackState === 'waiting_for_feedback';
|
||||
|
||||
console.log(`🔍 檢查提交權限: isConnected=${this.isConnected}, connectionReady=${this.connectionReady}, feedbackState=${this.feedbackState}, canSubmit=${canSubmit}`);
|
||||
return canSubmit;
|
||||
}
|
||||
|
||||
async refreshPageContent() {
|
||||
console.log('🔄 局部更新頁面內容...');
|
||||
|
||||
@ -1200,16 +1355,22 @@ class FeedbackApp {
|
||||
const sessionData = await response.json();
|
||||
console.log('📥 獲取到最新會話資料:', sessionData);
|
||||
|
||||
// 2. 更新 AI 摘要內容
|
||||
// 2. 重置回饋狀態為等待新回饋(使用新的會話 ID)
|
||||
if (sessionData.session_id) {
|
||||
this.setFeedbackState('waiting_for_feedback', sessionData.session_id);
|
||||
console.log('🔄 已重置回饋狀態為等待新回饋');
|
||||
}
|
||||
|
||||
// 3. 更新 AI 摘要內容
|
||||
this.updateAISummaryContent(sessionData.summary);
|
||||
|
||||
// 3. 重置回饋表單
|
||||
// 4. 重置回饋表單
|
||||
this.resetFeedbackForm();
|
||||
|
||||
// 4. 更新狀態指示器
|
||||
// 5. 更新狀態指示器
|
||||
this.updateStatusIndicators();
|
||||
|
||||
// 5. 更新頁面標題
|
||||
// 6. 更新頁面標題
|
||||
if (sessionData.project_directory) {
|
||||
const projectName = sessionData.project_directory.split(/[/\\]/).pop();
|
||||
document.title = `MCP Feedback - ${projectName}`;
|
||||
@ -1618,22 +1779,46 @@ class FeedbackApp {
|
||||
|
||||
// 檢查是否可以提交回饋
|
||||
if (!this.canSubmitFeedback()) {
|
||||
console.log('⚠️ 無法提交回饋 - 當前狀態:', this.feedbackState, '連接狀態:', this.isConnected);
|
||||
console.log('⚠️ 無法提交回饋 - 當前狀態:', this.feedbackState, '連接狀態:', this.isConnected, '連接就緒:', this.connectionReady);
|
||||
|
||||
if (this.feedbackState === 'feedback_submitted') {
|
||||
this.showMessage('回饋已提交,請等待下次 MCP 調用', 'warning');
|
||||
} else if (this.feedbackState === 'processing') {
|
||||
this.showMessage('正在處理中,請稍候', 'warning');
|
||||
} else if (!this.isConnected) {
|
||||
this.showMessage('WebSocket 未連接,正在嘗試重連...', 'error');
|
||||
// 嘗試重新建立連接
|
||||
this.setupWebSocket();
|
||||
} else if (!this.isConnected || !this.connectionReady) {
|
||||
// 收集回饋數據,等待連接就緒後提交
|
||||
const feedbackData = this.collectFeedbackData();
|
||||
if (feedbackData) {
|
||||
this.pendingSubmission = feedbackData;
|
||||
this.showMessage('WebSocket 連接中,回饋將在連接就緒後自動提交...', 'info');
|
||||
|
||||
// 確保 WebSocket 連接
|
||||
this.ensureWebSocketReady(() => {
|
||||
if (this.pendingSubmission) {
|
||||
this.submitFeedbackInternal(this.pendingSubmission);
|
||||
this.pendingSubmission = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.showMessage(`當前狀態不允許提交: ${this.feedbackState}`, 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集回饋數據並提交
|
||||
const feedbackData = this.collectFeedbackData();
|
||||
if (!feedbackData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitFeedbackInternal(feedbackData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集回饋數據
|
||||
*/
|
||||
collectFeedbackData() {
|
||||
// 根據當前佈局模式獲取回饋內容
|
||||
let feedback = '';
|
||||
if (this.layoutMode.startsWith('combined')) {
|
||||
@ -1646,9 +1831,25 @@ class FeedbackApp {
|
||||
|
||||
if (!feedback && this.images.length === 0) {
|
||||
this.showMessage('請提供回饋文字或上傳圖片', 'warning');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
feedback: feedback,
|
||||
images: [...this.images], // 創建副本
|
||||
settings: {
|
||||
image_size_limit: this.imageSizeLimit,
|
||||
enable_base64_detail: this.enableBase64Detail
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 內部提交回饋方法
|
||||
*/
|
||||
submitFeedbackInternal(feedbackData) {
|
||||
console.log('📤 內部提交回饋...');
|
||||
|
||||
// 設置處理狀態
|
||||
this.setFeedbackState('processing');
|
||||
|
||||
@ -1656,12 +1857,9 @@ class FeedbackApp {
|
||||
// 發送回饋
|
||||
this.websocket.send(JSON.stringify({
|
||||
type: 'submit_feedback',
|
||||
feedback: feedback,
|
||||
images: this.images,
|
||||
settings: {
|
||||
image_size_limit: this.imageSizeLimit,
|
||||
enable_base64_detail: this.enableBase64Detail
|
||||
}
|
||||
feedback: feedbackData.feedback,
|
||||
images: feedbackData.images,
|
||||
settings: feedbackData.settings
|
||||
}));
|
||||
|
||||
// 清空表單
|
||||
@ -1801,6 +1999,8 @@ class FeedbackApp {
|
||||
this.currentLanguage = settings.language || 'zh-TW';
|
||||
this.imageSizeLimit = settings.imageSizeLimit || 0;
|
||||
this.enableBase64Detail = settings.enableBase64Detail || false;
|
||||
this.autoRefreshEnabled = settings.autoRefreshEnabled || false;
|
||||
this.autoRefreshInterval = settings.autoRefreshInterval || 5;
|
||||
|
||||
// 處理 activeTab 設定
|
||||
if (settings.activeTab) {
|
||||
@ -1846,6 +2046,8 @@ class FeedbackApp {
|
||||
language: this.currentLanguage,
|
||||
imageSizeLimit: this.imageSizeLimit,
|
||||
enableBase64Detail: this.enableBase64Detail,
|
||||
autoRefreshEnabled: this.autoRefreshEnabled,
|
||||
autoRefreshInterval: this.autoRefreshInterval,
|
||||
activeTab: this.currentTab
|
||||
};
|
||||
|
||||
@ -1904,6 +2106,15 @@ class FeedbackApp {
|
||||
if (this.enableBase64DetailCheckbox) {
|
||||
this.enableBase64DetailCheckbox.checked = this.enableBase64Detail;
|
||||
}
|
||||
|
||||
// 應用自動刷新設定
|
||||
if (this.autoRefreshCheckbox) {
|
||||
this.autoRefreshCheckbox.checked = this.autoRefreshEnabled;
|
||||
}
|
||||
|
||||
if (this.autoRefreshIntervalInput) {
|
||||
this.autoRefreshIntervalInput.value = this.autoRefreshInterval;
|
||||
}
|
||||
}
|
||||
|
||||
applyLayoutMode() {
|
||||
@ -1927,18 +2138,11 @@ class FeedbackApp {
|
||||
// 同步合併佈局和分頁中的內容
|
||||
this.syncCombinedLayoutContent();
|
||||
|
||||
// 如果是合併模式,確保內容同步
|
||||
if (this.layoutMode.startsWith('combined')) {
|
||||
this.setupCombinedModeSync();
|
||||
// 如果當前頁籤不是合併模式,則切換到合併模式頁籤
|
||||
if (this.currentTab !== 'combined') {
|
||||
this.currentTab = 'combined';
|
||||
}
|
||||
} else {
|
||||
// 分離模式時,如果當前頁籤是合併模式,則切換到回饋頁籤
|
||||
if (this.currentTab === 'combined') {
|
||||
this.currentTab = 'feedback';
|
||||
}
|
||||
// 確保合併模式內容同步
|
||||
this.setupCombinedModeSync();
|
||||
// 如果當前頁籤不是合併模式,則切換到合併模式頁籤
|
||||
if (this.currentTab !== 'combined') {
|
||||
this.currentTab = 'combined';
|
||||
}
|
||||
}
|
||||
|
||||
@ -1947,17 +2151,10 @@ class FeedbackApp {
|
||||
const feedbackTab = document.querySelector('.tab-button[data-tab="feedback"]');
|
||||
const summaryTab = document.querySelector('.tab-button[data-tab="summary"]');
|
||||
|
||||
if (this.layoutMode.startsWith('combined')) {
|
||||
// 合併模式:顯示合併模式頁籤,隱藏回饋和AI摘要頁籤
|
||||
if (combinedTab) combinedTab.style.display = 'inline-block';
|
||||
if (feedbackTab) feedbackTab.style.display = 'none';
|
||||
if (summaryTab) summaryTab.style.display = 'none';
|
||||
} else {
|
||||
// 分離模式:隱藏合併模式頁籤,顯示回饋和AI摘要頁籤
|
||||
if (combinedTab) combinedTab.style.display = 'none';
|
||||
if (feedbackTab) feedbackTab.style.display = 'inline-block';
|
||||
if (summaryTab) summaryTab.style.display = 'inline-block';
|
||||
}
|
||||
// 只使用合併模式:顯示合併模式頁籤,隱藏回饋和AI摘要頁籤
|
||||
if (combinedTab) combinedTab.style.display = 'inline-block';
|
||||
if (feedbackTab) feedbackTab.style.display = 'none';
|
||||
if (summaryTab) summaryTab.style.display = 'none';
|
||||
}
|
||||
|
||||
syncCombinedLayoutContent() {
|
||||
@ -2012,27 +2209,6 @@ class FeedbackApp {
|
||||
}
|
||||
|
||||
setupCombinedModeSync() {
|
||||
// 設置文字輸入的雙向同步
|
||||
const feedbackText = document.getElementById('feedbackText');
|
||||
const combinedFeedbackText = document.getElementById('combinedFeedbackText');
|
||||
|
||||
if (feedbackText && combinedFeedbackText) {
|
||||
// 移除舊的事件監聽器(如果存在)
|
||||
feedbackText.removeEventListener('input', this.syncToCombinetText);
|
||||
combinedFeedbackText.removeEventListener('input', this.syncToSeparateText);
|
||||
|
||||
// 添加新的事件監聽器
|
||||
this.syncToCombinetText = (e) => {
|
||||
combinedFeedbackText.value = e.target.value;
|
||||
};
|
||||
this.syncToSeparateText = (e) => {
|
||||
feedbackText.value = e.target.value;
|
||||
};
|
||||
|
||||
feedbackText.addEventListener('input', this.syncToCombinetText);
|
||||
combinedFeedbackText.addEventListener('input', this.syncToSeparateText);
|
||||
}
|
||||
|
||||
// 設置圖片設定的同步
|
||||
this.setupImageSettingsSync();
|
||||
|
||||
@ -2077,45 +2253,20 @@ class FeedbackApp {
|
||||
|
||||
setupImageUploadSync() {
|
||||
// 設置合併模式的圖片上傳功能
|
||||
const combinedImageInput = document.getElementById('combinedImageInput');
|
||||
const combinedImageUploadArea = document.getElementById('combinedImageUploadArea');
|
||||
|
||||
if (combinedImageInput && combinedImageUploadArea) {
|
||||
// 簡化的圖片上傳同步 - 只需要基本的事件監聽器
|
||||
combinedImageInput.addEventListener('change', (e) => {
|
||||
this.handleFileSelect(e.target.files);
|
||||
});
|
||||
|
||||
combinedImageUploadArea.addEventListener('click', () => {
|
||||
combinedImageInput.click();
|
||||
});
|
||||
|
||||
// 拖放事件
|
||||
combinedImageUploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
combinedImageUploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
combinedImageUploadArea.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
combinedImageUploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
combinedImageUploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
combinedImageUploadArea.classList.remove('dragover');
|
||||
this.handleFileSelect(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
// 注意:所有事件監聽器現在由 setupImageEventListeners() 統一處理
|
||||
// 這個函數保留用於未來可能的同步邏輯,但不再設置重複的事件監聽器
|
||||
console.log('🔄 setupImageUploadSync: 事件監聽器由 setupImageEventListeners() 統一處理');
|
||||
}
|
||||
|
||||
resetSettings() {
|
||||
localStorage.removeItem('mcp-feedback-settings');
|
||||
this.layoutMode = 'separate';
|
||||
this.layoutMode = 'combined-vertical';
|
||||
this.autoClose = false;
|
||||
this.currentLanguage = 'zh-TW';
|
||||
this.imageSizeLimit = 0;
|
||||
this.enableBase64Detail = false;
|
||||
this.autoRefreshEnabled = false;
|
||||
this.autoRefreshInterval = 5;
|
||||
this.applySettings();
|
||||
this.saveSettings();
|
||||
}
|
||||
@ -2169,6 +2320,203 @@ class FeedbackApp {
|
||||
// 不需要手動複製,updateStatusIndicator() 會處理所有狀態指示器
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化自動刷新功能
|
||||
*/
|
||||
initAutoRefresh() {
|
||||
console.log('🔄 初始化自動刷新功能...');
|
||||
|
||||
// 檢查必要元素是否存在
|
||||
if (!this.autoRefreshCheckbox || !this.autoRefreshIntervalInput) {
|
||||
console.warn('⚠️ 自動刷新元素不存在,跳過初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
// 設置開關事件監聽器
|
||||
this.autoRefreshCheckbox.addEventListener('change', (e) => {
|
||||
this.autoRefreshEnabled = e.target.checked;
|
||||
this.handleAutoRefreshToggle();
|
||||
this.saveSettings();
|
||||
});
|
||||
|
||||
// 設置間隔輸入事件監聽器
|
||||
this.autoRefreshIntervalInput.addEventListener('change', (e) => {
|
||||
const newInterval = parseInt(e.target.value);
|
||||
if (newInterval >= 5 && newInterval <= 300) {
|
||||
this.autoRefreshInterval = newInterval;
|
||||
this.saveSettings();
|
||||
|
||||
// 如果自動刷新已啟用,重新啟動定時器
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.stopAutoRefresh();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 從設定中恢復狀態
|
||||
this.autoRefreshCheckbox.checked = this.autoRefreshEnabled;
|
||||
this.autoRefreshIntervalInput.value = this.autoRefreshInterval;
|
||||
|
||||
// 延遲更新狀態指示器,確保 i18n 已完全載入
|
||||
setTimeout(() => {
|
||||
this.updateAutoRefreshStatus();
|
||||
|
||||
// 如果自動刷新已啟用,啟動自動檢測
|
||||
if (this.autoRefreshEnabled) {
|
||||
console.log('🔄 自動刷新已啟用,啟動自動檢測...');
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
console.log('✅ 自動刷新功能初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理自動刷新開關切換
|
||||
*/
|
||||
handleAutoRefreshToggle() {
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.startAutoRefresh();
|
||||
} else {
|
||||
this.stopAutoRefresh();
|
||||
}
|
||||
this.updateAutoRefreshStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 啟動自動刷新
|
||||
*/
|
||||
startAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
}
|
||||
|
||||
// 記錄當前會話 ID
|
||||
this.lastKnownSessionId = this.currentSessionId;
|
||||
|
||||
this.autoRefreshTimer = setInterval(() => {
|
||||
this.checkForSessionUpdate();
|
||||
}, this.autoRefreshInterval * 1000);
|
||||
|
||||
console.log(`🔄 自動刷新已啟動,間隔: ${this.autoRefreshInterval}秒`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止自動刷新
|
||||
*/
|
||||
stopAutoRefresh() {
|
||||
if (this.autoRefreshTimer) {
|
||||
clearInterval(this.autoRefreshTimer);
|
||||
this.autoRefreshTimer = null;
|
||||
}
|
||||
console.log('⏸️ 自動刷新已停止');
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢查會話更新
|
||||
*/
|
||||
async checkForSessionUpdate() {
|
||||
try {
|
||||
this.updateAutoRefreshStatus('checking');
|
||||
|
||||
const response = await fetch('/api/current-session');
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 請求失敗: ${response.status}`);
|
||||
}
|
||||
|
||||
const sessionData = await response.json();
|
||||
|
||||
// 檢查會話 ID 是否變化
|
||||
if (sessionData.session_id && sessionData.session_id !== this.lastKnownSessionId) {
|
||||
console.log(`🔄 檢測到新會話: ${this.lastKnownSessionId} -> ${sessionData.session_id}`);
|
||||
|
||||
// 更新記錄的會話 ID
|
||||
this.lastKnownSessionId = sessionData.session_id;
|
||||
this.currentSessionId = sessionData.session_id;
|
||||
|
||||
// 觸發局部刷新
|
||||
await this.updatePageContentPartially();
|
||||
|
||||
this.updateAutoRefreshStatus('detected');
|
||||
|
||||
// 短暫顯示檢測成功狀態,然後恢復為檢測中
|
||||
setTimeout(() => {
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.updateAutoRefreshStatus('enabled');
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
this.updateAutoRefreshStatus('enabled');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 自動刷新檢測失敗:', error);
|
||||
this.updateAutoRefreshStatus('error');
|
||||
|
||||
// 短暫顯示錯誤狀態,然後恢復
|
||||
setTimeout(() => {
|
||||
if (this.autoRefreshEnabled) {
|
||||
this.updateAutoRefreshStatus('enabled');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新自動刷新狀態指示器
|
||||
*/
|
||||
updateAutoRefreshStatus(status = null) {
|
||||
console.log(`🔧 updateAutoRefreshStatus 被調用,status: ${status}`);
|
||||
console.log(`🔧 refreshStatusIndicator: ${this.refreshStatusIndicator ? 'found' : 'null'}`);
|
||||
console.log(`🔧 refreshStatusText: ${this.refreshStatusText ? 'found' : 'null'}`);
|
||||
|
||||
if (!this.refreshStatusIndicator || !this.refreshStatusText) {
|
||||
console.log(`⚠️ 自動檢測狀態元素未找到,跳過更新`);
|
||||
return;
|
||||
}
|
||||
|
||||
let indicator = '⏸️';
|
||||
let textKey = 'autoRefresh.disabled';
|
||||
|
||||
if (status === null) {
|
||||
status = this.autoRefreshEnabled ? 'enabled' : 'disabled';
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'enabled':
|
||||
indicator = '🔄';
|
||||
textKey = 'autoRefresh.enabled';
|
||||
break;
|
||||
case 'checking':
|
||||
indicator = '🔍';
|
||||
textKey = 'autoRefresh.checking';
|
||||
break;
|
||||
case 'detected':
|
||||
indicator = '✅';
|
||||
textKey = 'autoRefresh.detected';
|
||||
break;
|
||||
case 'error':
|
||||
indicator = '❌';
|
||||
textKey = 'autoRefresh.error';
|
||||
break;
|
||||
case 'disabled':
|
||||
default:
|
||||
indicator = '⏸️';
|
||||
textKey = 'autoRefresh.disabled';
|
||||
break;
|
||||
}
|
||||
|
||||
this.refreshStatusIndicator.textContent = indicator;
|
||||
|
||||
// 使用多語系翻譯
|
||||
|
||||
const translatedText = window.i18nManager.t(textKey);
|
||||
console.log(`🔄 自動檢測狀態翻譯: ${textKey} -> ${translatedText} (語言: ${window.i18nManager.currentLanguage})`);
|
||||
this.refreshStatusText.textContent = translatedText;
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
@ -178,6 +178,10 @@ class I18nManager {
|
||||
if (window.feedbackApp) {
|
||||
window.feedbackApp.updateUIState();
|
||||
window.feedbackApp.updateStatusIndicator();
|
||||
// 更新自動檢測狀態文字
|
||||
if (window.feedbackApp.updateAutoRefreshStatus) {
|
||||
window.feedbackApp.updateAutoRefreshStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,18 +23,8 @@
|
||||
|
||||
/* 頁面特定的佈局模式樣式 */
|
||||
|
||||
/* 佈局模式樣式 */
|
||||
/* 預設分離模式 - 顯示回饋和AI摘要頁籤,隱藏合併模式頁籤 */
|
||||
body.layout-separate .tab-button[data-tab="combined"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.layout-separate .tab-button[data-tab="feedback"],
|
||||
body.layout-separate .tab-button[data-tab="summary"] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 合併模式 - 顯示合併模式頁籤,隱藏回饋和AI摘要頁籤 */
|
||||
/* 佈局模式樣式 - 工作區模式 */
|
||||
/* 工作區模式 - 顯示工作區頁籤,隱藏回饋和AI摘要頁籤 */
|
||||
body.layout-combined-vertical .tab-button[data-tab="combined"],
|
||||
body.layout-combined-horizontal .tab-button[data-tab="combined"] {
|
||||
display: inline-block;
|
||||
@ -62,7 +52,7 @@
|
||||
|
||||
|
||||
|
||||
/* 合併模式分頁的水平佈局樣式 */
|
||||
/* 工作區分頁的水平佈局樣式 */
|
||||
#tab-combined.active.combined-horizontal .combined-content {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
@ -99,7 +89,7 @@
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 合併模式分頁的垂直佈局樣式 */
|
||||
/* 工作區分頁的垂直佈局樣式 */
|
||||
#tab-combined.active.combined-vertical .combined-content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
@ -144,7 +134,7 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 合併模式基礎樣式 */
|
||||
/* 工作區基礎樣式 */
|
||||
.combined-section {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -395,9 +385,9 @@
|
||||
<!-- 分頁導航 -->
|
||||
<div class="tabs">
|
||||
<div class="tab-buttons">
|
||||
<!-- 合併模式分頁 - 移到最左邊第一個 -->
|
||||
<!-- 工作區分頁 - 移到最左邊第一個 -->
|
||||
<button class="tab-button hidden" data-tab="combined" data-i18n="tabs.combined">
|
||||
📝 合併模式
|
||||
📝 工作區
|
||||
</button>
|
||||
<button class="tab-button active" data-tab="feedback" data-i18n="tabs.feedback">
|
||||
💬 回饋
|
||||
@ -499,16 +489,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合併模式分頁 - 移動到此位置 -->
|
||||
<!-- 工作區分頁 - 移動到此位置 -->
|
||||
<div id="tab-combined" class="tab-content">
|
||||
<div class="section-description" style="margin-bottom: 12px; padding: 8px 12px; font-size: 13px;" data-i18n="combined.description">
|
||||
合併模式:AI 摘要和回饋輸入在同一頁面中,方便對照查看。
|
||||
AI 摘要和回饋輸入在同一頁面中,方便對照查看。
|
||||
</div>
|
||||
|
||||
<div class="combined-content">
|
||||
<!-- AI 摘要區域 -->
|
||||
<div class="combined-section">
|
||||
<h3 class="combined-section-title" data-i18n="combined.summaryTitle">📋 AI 工作摘要</h3>
|
||||
<div class="section-header-with-controls">
|
||||
<h3 class="combined-section-title" data-i18n="combined.summaryTitle">📋 AI 工作摘要</h3>
|
||||
<div class="auto-refresh-controls">
|
||||
<label class="auto-refresh-toggle">
|
||||
<input type="checkbox" id="autoRefreshEnabled" />
|
||||
<span class="toggle-label" data-i18n="autoRefresh.enable"></span>
|
||||
</label>
|
||||
<div class="auto-refresh-interval">
|
||||
<input type="number" id="autoRefreshInterval" min="5" max="300" value="5" />
|
||||
<span class="interval-unit" data-i18n="autoRefresh.seconds"></span>
|
||||
</div>
|
||||
<div class="auto-refresh-status" id="autoRefreshStatus">
|
||||
<span class="status-indicator" id="refreshStatusIndicator">⏸️</span>
|
||||
<span class="status-text" id="refreshStatusText" data-i18n="autoRefresh.disabled"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="combined-summary">
|
||||
<div id="combinedSummaryContent" class="text-input" style="min-height: 200px; 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 }}</div>
|
||||
</div>
|
||||
@ -570,23 +576,16 @@
|
||||
</div>
|
||||
<div class="layout-mode-selector">
|
||||
<div class="layout-option">
|
||||
<input type="radio" id="separateMode" name="layoutMode" value="separate" checked>
|
||||
<label for="separateMode">
|
||||
<div class="layout-option-title" data-i18n="settings.separateMode">分離模式</div>
|
||||
<div class="layout-option-desc" data-i18n="settings.separateModeDesc">AI 摘要和回饋分別在不同頁籤</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="layout-option">
|
||||
<input type="radio" id="combinedVertical" name="layoutMode" value="combined-vertical">
|
||||
<input type="radio" id="combinedVertical" name="layoutMode" value="combined-vertical" checked>
|
||||
<label for="combinedVertical">
|
||||
<div class="layout-option-title" data-i18n="settings.combinedVertical">合併模式(垂直布局)</div>
|
||||
<div class="layout-option-title" data-i18n="settings.combinedVertical">垂直布局</div>
|
||||
<div class="layout-option-desc" data-i18n="settings.combinedVerticalDesc">AI 摘要在上,回饋輸入在下,摘要和回饋在同一頁面</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="layout-option">
|
||||
<input type="radio" id="combinedHorizontal" name="layoutMode" value="combined-horizontal">
|
||||
<label for="combinedHorizontal">
|
||||
<div class="layout-option-title" data-i18n="settings.combinedHorizontal">合併模式(水平布局)</div>
|
||||
<div class="layout-option-title" data-i18n="settings.combinedHorizontal">水平布局</div>
|
||||
<div class="layout-option-desc" data-i18n="settings.combinedHorizontalDesc">AI 摘要在左,回饋輸入在右,增大摘要可視區域</div>
|
||||
</label>
|
||||
</div>
|
||||
|
187
src/mcp_feedback_enhanced/web/utils/compression_config.py
Normal file
@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
壓縮配置管理器
|
||||
==============
|
||||
|
||||
管理 Web UI 的 Gzip 壓縮配置和靜態文件緩存策略。
|
||||
支援可配置的壓縮參數和性能優化選項。
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# 排除的路徑
|
||||
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'
|
||||
]
|
||||
|
||||
if self.exclude_paths is None:
|
||||
self.exclude_paths = [
|
||||
'/ws', # WebSocket 連接
|
||||
'/api/ws', # WebSocket API
|
||||
'/health', # 健康檢查
|
||||
]
|
||||
|
||||
@classmethod
|
||||
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'))
|
||||
)
|
||||
|
||||
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]:
|
||||
"""獲取緩存頭"""
|
||||
headers = {}
|
||||
|
||||
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:
|
||||
# API 緩存(如果啟用)
|
||||
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'
|
||||
|
||||
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 {
|
||||
'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):
|
||||
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
|
||||
}
|
||||
|
||||
def update_stats(self, original_size: int, compressed_size: int, was_compressed: bool):
|
||||
"""更新壓縮統計"""
|
||||
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
|
||||
else:
|
||||
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']
|
||||
) * 100
|
||||
|
||||
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
|
||||
)
|
||||
return stats
|
||||
|
||||
def reset_stats(self):
|
||||
"""重置統計"""
|
||||
self._stats = {
|
||||
'requests_total': 0,
|
||||
'requests_compressed': 0,
|
||||
'bytes_original': 0,
|
||||
'bytes_compressed': 0,
|
||||
'compression_ratio': 0.0
|
||||
}
|
||||
|
||||
|
||||
# 全域壓縮管理器實例
|
||||
_compression_manager: Optional[CompressionManager] = None
|
||||
|
||||
|
||||
def get_compression_manager() -> CompressionManager:
|
||||
"""獲取全域壓縮管理器實例"""
|
||||
global _compression_manager
|
||||
if _compression_manager is None:
|
||||
_compression_manager = CompressionManager()
|
||||
return _compression_manager
|
290
src/mcp_feedback_enhanced/web/utils/compression_monitor.py
Normal file
@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
壓縮性能監控工具
|
||||
================
|
||||
|
||||
監控 Gzip 壓縮的性能效果,包括壓縮比率、響應時間和文件大小統計。
|
||||
提供實時性能數據和優化建議。
|
||||
"""
|
||||
|
||||
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
|
||||
compressed_size: int
|
||||
compression_ratio: float
|
||||
response_time: float
|
||||
content_type: str
|
||||
was_compressed: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressionSummary:
|
||||
"""壓縮摘要統計"""
|
||||
total_requests: int = 0
|
||||
compressed_requests: int = 0
|
||||
total_original_bytes: int = 0
|
||||
total_compressed_bytes: int = 0
|
||||
average_compression_ratio: float = 0.0
|
||||
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)
|
||||
|
||||
|
||||
class CompressionMonitor:
|
||||
"""壓縮性能監控器"""
|
||||
|
||||
def __init__(self, max_metrics: int = 1000):
|
||||
self.max_metrics = max_metrics
|
||||
self.metrics: List[CompressionMetrics] = []
|
||||
self.lock = threading.Lock()
|
||||
self._start_time = datetime.now()
|
||||
|
||||
# 路徑統計
|
||||
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):
|
||||
"""記錄請求的壓縮數據"""
|
||||
|
||||
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,
|
||||
original_size=original_size,
|
||||
compressed_size=compressed_size,
|
||||
compression_ratio=compression_ratio,
|
||||
response_time=response_time,
|
||||
content_type=content_type,
|
||||
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._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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if metric.was_compressed:
|
||||
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'
|
||||
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
|
||||
}
|
||||
|
||||
stats = self.content_type_stats[content_type]
|
||||
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
|
||||
|
||||
# 重新計算平均壓縮比
|
||||
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:
|
||||
"""獲取壓縮摘要統計"""
|
||||
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
|
||||
average_compression_ratio = 0.0
|
||||
bandwidth_saved = 0
|
||||
|
||||
if total_original_bytes > 0:
|
||||
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
|
||||
|
||||
# 獲取壓縮效果最好的路徑
|
||||
top_compressed_paths = self._get_top_compressed_paths()
|
||||
|
||||
return CompressionSummary(
|
||||
total_requests=total_requests,
|
||||
compressed_requests=compressed_requests,
|
||||
total_original_bytes=total_original_bytes,
|
||||
total_compressed_bytes=total_compressed_bytes,
|
||||
average_compression_ratio=average_compression_ratio,
|
||||
average_response_time=average_response_time,
|
||||
compression_percentage=compression_percentage,
|
||||
bandwidth_saved=bandwidth_saved,
|
||||
top_compressed_paths=top_compressed_paths
|
||||
)
|
||||
|
||||
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:
|
||||
compression_ratio = (
|
||||
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]:
|
||||
"""獲取路徑統計"""
|
||||
with self.lock:
|
||||
return self.path_stats.copy()
|
||||
|
||||
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]:
|
||||
"""獲取最近的指標數據"""
|
||||
with self.lock:
|
||||
return self.metrics[-limit:] if self.metrics else []
|
||||
|
||||
def reset_stats(self):
|
||||
"""重置統計數據"""
|
||||
with self.lock:
|
||||
self.metrics.clear()
|
||||
self.path_stats.clear()
|
||||
self.content_type_stats.clear()
|
||||
self._start_time = datetime.now()
|
||||
|
||||
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(
|
||||
(datetime.now() - self._start_time).total_seconds() / 3600, 2
|
||||
)
|
||||
},
|
||||
'top_compressed_paths': [
|
||||
{'path': path, 'compression_ratio': round(ratio, 2)}
|
||||
for path, ratio in summary.top_compressed_paths
|
||||
],
|
||||
'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
|
||||
)
|
||||
}
|
||||
for path, stats in self.path_stats.items()
|
||||
},
|
||||
'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)
|
||||
}
|
||||
for content_type, stats in self.content_type_stats.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 全域監控器實例
|
||||
_compression_monitor: Optional[CompressionMonitor] = None
|
||||
|
||||
|
||||
def get_compression_monitor() -> CompressionMonitor:
|
||||
"""獲取全域壓縮監控器實例"""
|
||||
global _compression_monitor
|
||||
if _compression_monitor is None:
|
||||
_compression_monitor = CompressionMonitor()
|
||||
return _compression_monitor
|
307
src/mcp_feedback_enhanced/web/utils/port_manager.py
Normal file
@ -0,0 +1,307 @@
|
||||
"""
|
||||
端口管理工具模組
|
||||
|
||||
提供增強的端口管理功能,包括:
|
||||
- 智能端口查找
|
||||
- 進程檢測和清理
|
||||
- 端口衝突解決
|
||||
"""
|
||||
|
||||
import socket
|
||||
import subprocess
|
||||
import platform
|
||||
import psutil
|
||||
import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from ...debug import debug_log
|
||||
|
||||
|
||||
class PortManager:
|
||||
"""端口管理器 - 提供增強的端口管理功能"""
|
||||
|
||||
@staticmethod
|
||||
def find_process_using_port(port: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
查找占用指定端口的進程
|
||||
|
||||
Args:
|
||||
port: 要檢查的端口號
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 進程信息字典,包含 pid, name, cmdline 等
|
||||
None: 如果沒有進程占用該端口
|
||||
"""
|
||||
try:
|
||||
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()
|
||||
}
|
||||
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: 是否成功終止進程
|
||||
"""
|
||||
process_info = PortManager.find_process_using_port(port)
|
||||
if not process_info:
|
||||
debug_log(f"端口 {port} 沒有被任何進程占用")
|
||||
return True
|
||||
|
||||
try:
|
||||
pid = process_info['pid']
|
||||
process = psutil.Process(pid)
|
||||
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 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)
|
||||
debug_log(f"成功終止進程 {process_name} (PID: {pid})")
|
||||
return True
|
||||
except psutil.TimeoutExpired:
|
||||
if not force:
|
||||
debug_log(f"優雅終止超時,強制終止進程 {process_name} (PID: {pid})")
|
||||
process.kill()
|
||||
process.wait(timeout=3)
|
||||
return True
|
||||
else:
|
||||
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:
|
||||
"""
|
||||
檢查端口是否可用
|
||||
|
||||
Args:
|
||||
host: 主機地址
|
||||
port: 端口號
|
||||
|
||||
Returns:
|
||||
bool: 端口是否可用
|
||||
"""
|
||||
try:
|
||||
# 首先嘗試不使用 SO_REUSEADDR 來檢測端口
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((host, port))
|
||||
return True
|
||||
except OSError:
|
||||
# 如果綁定失敗,再檢查是否真的有進程在監聽
|
||||
# 使用 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):
|
||||
return False
|
||||
# 沒有找到監聽的進程,可能是臨時占用,認為可用
|
||||
return True
|
||||
except Exception:
|
||||
# 如果 psutil 檢查失敗,保守地認為端口不可用
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def find_free_port_enhanced(
|
||||
preferred_port: int = 8765,
|
||||
auto_cleanup: bool = True,
|
||||
host: str = "127.0.0.1",
|
||||
max_attempts: int = 100
|
||||
) -> int:
|
||||
"""
|
||||
增強的端口查找功能
|
||||
|
||||
Args:
|
||||
preferred_port: 偏好端口號
|
||||
auto_cleanup: 是否自動清理占用端口的進程
|
||||
host: 主機地址
|
||||
max_attempts: 最大嘗試次數
|
||||
|
||||
Returns:
|
||||
int: 可用的端口號
|
||||
|
||||
Raises:
|
||||
RuntimeError: 如果找不到可用端口
|
||||
"""
|
||||
# 首先嘗試偏好端口
|
||||
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']}) 占用")
|
||||
|
||||
# 詢問用戶是否清理(在實際使用中可能需要配置選項)
|
||||
if PortManager._should_cleanup_process(process_info):
|
||||
if PortManager.kill_process_on_port(preferred_port):
|
||||
# 等待一下讓端口釋放
|
||||
time.sleep(1)
|
||||
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
|
||||
if port < 1024: # 避免使用系統保留端口
|
||||
break
|
||||
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:
|
||||
"""
|
||||
判斷是否應該清理指定進程
|
||||
|
||||
Args:
|
||||
process_info: 進程信息字典
|
||||
|
||||
Returns:
|
||||
bool: 是否應該清理該進程
|
||||
"""
|
||||
# 檢查是否是 MCP Feedback Enhanced 相關進程
|
||||
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']):
|
||||
return True
|
||||
|
||||
# 如果是 Python 進程且命令行包含相關關鍵字
|
||||
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 相關進程,跳過自動清理")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
}
|
||||
|
||||
try:
|
||||
# 檢查端口是否可用
|
||||
status['available'] = PortManager.is_port_available(host, port)
|
||||
|
||||
# 如果不可用,查找占用進程
|
||||
if not status['available']:
|
||||
status['process'] = PortManager.find_process_using_port(port)
|
||||
|
||||
except Exception as 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]]:
|
||||
"""
|
||||
列出指定範圍內正在監聽的端口
|
||||
|
||||
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):
|
||||
|
||||
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())
|
||||
}
|
||||
listening_ports.append(port_info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"列出監聽端口時發生錯誤: {e}")
|
||||
|
||||
return listening_ports
|
504
src/mcp_feedback_enhanced/web/utils/session_cleanup_manager.py
Normal file
@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
會話清理管理器
|
||||
==============
|
||||
|
||||
統一管理 Web 會話的清理策略、統計和性能監控。
|
||||
與內存監控系統深度集成,提供智能清理決策。
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from ...debug import web_debug_log as debug_log
|
||||
from ...utils.error_handler import ErrorHandler, ErrorType
|
||||
from ..models.feedback_session import CleanupReason, SessionStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupPolicy:
|
||||
"""清理策略配置"""
|
||||
max_idle_time: int = 1800 # 最大空閒時間(秒)
|
||||
max_session_age: int = 7200 # 最大會話年齡(秒)
|
||||
max_sessions: int = 10 # 最大會話數量
|
||||
cleanup_interval: int = 300 # 清理間隔(秒)
|
||||
memory_pressure_threshold: float = 0.8 # 內存壓力閾值
|
||||
enable_auto_cleanup: bool = True # 啟用自動清理
|
||||
preserve_active_session: bool = True # 保護活躍會話
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupStats:
|
||||
"""清理統計數據"""
|
||||
total_cleanups: int = 0
|
||||
expired_cleanups: int = 0
|
||||
memory_pressure_cleanups: int = 0
|
||||
manual_cleanups: int = 0
|
||||
auto_cleanups: int = 0
|
||||
total_sessions_cleaned: int = 0
|
||||
total_cleanup_time: float = 0.0
|
||||
average_cleanup_time: float = 0.0
|
||||
last_cleanup_time: Optional[datetime] = None
|
||||
cleanup_efficiency: float = 0.0 # 清理效率(清理的會話數/總會話數)
|
||||
|
||||
|
||||
class CleanupTrigger(Enum):
|
||||
"""清理觸發器類型"""
|
||||
AUTO = "auto" # 自動清理
|
||||
MEMORY_PRESSURE = "memory_pressure" # 內存壓力
|
||||
MANUAL = "manual" # 手動清理
|
||||
EXPIRED = "expired" # 過期清理
|
||||
CAPACITY = "capacity" # 容量限制
|
||||
|
||||
|
||||
class SessionCleanupManager:
|
||||
"""會話清理管理器"""
|
||||
|
||||
def __init__(self, web_ui_manager, policy: CleanupPolicy = None):
|
||||
"""
|
||||
初始化會話清理管理器
|
||||
|
||||
Args:
|
||||
web_ui_manager: WebUIManager 實例
|
||||
policy: 清理策略配置
|
||||
"""
|
||||
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._stop_event = threading.Event()
|
||||
|
||||
# 回調函數
|
||||
self.cleanup_callbacks: List[Callable] = []
|
||||
self.stats_callbacks: List[Callable] = []
|
||||
|
||||
# 清理歷史記錄
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
if cleaned_sessions > 0:
|
||||
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
|
||||
)
|
||||
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)
|
||||
elif trigger == CleanupTrigger.EXPIRED:
|
||||
cleaned_sessions = self.web_ui_manager.cleanup_expired_sessions()
|
||||
elif trigger == CleanupTrigger.CAPACITY:
|
||||
cleaned_sessions = self._cleanup_by_capacity()
|
||||
elif trigger == CleanupTrigger.MANUAL:
|
||||
# 手動清理:組合多種策略
|
||||
cleaned_sessions += self.web_ui_manager.cleanup_expired_sessions()
|
||||
if 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}秒")
|
||||
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
|
||||
)
|
||||
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):
|
||||
continue
|
||||
|
||||
# 計算優先級分數(分數越高越優先清理)
|
||||
priority_score = 0
|
||||
|
||||
# 狀態優先級
|
||||
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:
|
||||
session._cleanup_sync_enhanced(CleanupReason.MANUAL)
|
||||
del self.web_ui_manager.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
except Exception as e:
|
||||
debug_log(f"容量清理會話 {session_id} 失敗: {e}")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def _cleanup_expired_sessions(self) -> int:
|
||||
"""清理過期會話"""
|
||||
expired_sessions = []
|
||||
current_time = time.time()
|
||||
|
||||
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:
|
||||
expired_sessions.append(session_id)
|
||||
|
||||
# 清理過期會話
|
||||
cleaned_count = 0
|
||||
for session_id in expired_sessions:
|
||||
try:
|
||||
session = self.web_ui_manager.sessions.get(session_id)
|
||||
if session:
|
||||
session._cleanup_sync_enhanced(CleanupReason.EXPIRED)
|
||||
del self.web_ui_manager.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
|
||||
# 如果清理的是當前活躍會話,清空當前會話
|
||||
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:
|
||||
debug_log(f"清理過期會話 {session_id} 失敗: {e}")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def _cleanup_idle_sessions(self) -> int:
|
||||
"""清理空閒會話"""
|
||||
idle_sessions = []
|
||||
|
||||
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):
|
||||
continue
|
||||
|
||||
# 檢查是否空閒時間過長
|
||||
if session.get_idle_time() > self.policy.max_idle_time:
|
||||
idle_sessions.append(session_id)
|
||||
|
||||
# 清理空閒會話
|
||||
cleaned_count = 0
|
||||
for session_id in idle_sessions:
|
||||
try:
|
||||
session = self.web_ui_manager.sessions.get(session_id)
|
||||
if session:
|
||||
session._cleanup_sync_enhanced(CleanupReason.EXPIRED)
|
||||
del self.web_ui_manager.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"清理空閒會話 {session_id} 失敗: {e}")
|
||||
|
||||
return cleaned_count
|
||||
|
||||
def _update_cleanup_stats(self, trigger: CleanupTrigger, cleaned_count: int, duration: float):
|
||||
"""更新清理統計"""
|
||||
self.stats.total_cleanups += 1
|
||||
self.stats.total_sessions_cleaned += cleaned_count
|
||||
self.stats.total_cleanup_time += duration
|
||||
self.stats.last_cleanup_time = datetime.now()
|
||||
|
||||
# 更新平均清理時間
|
||||
if self.stats.total_cleanups > 0:
|
||||
self.stats.average_cleanup_time = self.stats.total_cleanup_time / self.stats.total_cleanups
|
||||
|
||||
# 更新清理效率
|
||||
total_sessions = len(self.web_ui_manager.sessions) + cleaned_count
|
||||
if total_sessions > 0:
|
||||
self.stats.cleanup_efficiency = cleaned_count / total_sessions
|
||||
|
||||
# 根據觸發器類型更新統計
|
||||
if trigger == CleanupTrigger.AUTO:
|
||||
self.stats.auto_cleanups += 1
|
||||
elif trigger == CleanupTrigger.MEMORY_PRESSURE:
|
||||
self.stats.memory_pressure_cleanups += 1
|
||||
elif trigger == CleanupTrigger.EXPIRED:
|
||||
self.stats.expired_cleanups += 1
|
||||
elif trigger == CleanupTrigger.MANUAL:
|
||||
self.stats.manual_cleanups += 1
|
||||
|
||||
# 記錄清理歷史
|
||||
cleanup_record = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"trigger": trigger.value,
|
||||
"cleaned_count": cleaned_count,
|
||||
"duration": duration,
|
||||
"total_sessions_before": total_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:]
|
||||
|
||||
# 調用統計回調
|
||||
for callback in self.stats_callbacks:
|
||||
try:
|
||||
callback(self.stats, cleanup_record)
|
||||
except Exception as e:
|
||||
debug_log(f"統計回調執行失敗: {e}")
|
||||
|
||||
def get_cleanup_statistics(self) -> Dict[str, Any]:
|
||||
"""獲取清理統計數據"""
|
||||
stats_dict = {
|
||||
"total_cleanups": self.stats.total_cleanups,
|
||||
"expired_cleanups": self.stats.expired_cleanups,
|
||||
"memory_pressure_cleanups": self.stats.memory_pressure_cleanups,
|
||||
"manual_cleanups": self.stats.manual_cleanups,
|
||||
"auto_cleanups": self.stats.auto_cleanups,
|
||||
"total_sessions_cleaned": self.stats.total_sessions_cleaned,
|
||||
"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,
|
||||
"is_auto_cleanup_running": self.is_running,
|
||||
"current_sessions": len(self.web_ui_manager.sessions),
|
||||
"policy": {
|
||||
"max_idle_time": self.policy.max_idle_time,
|
||||
"max_session_age": self.policy.max_session_age,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
return stats_dict
|
||||
|
||||
def get_cleanup_history(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""獲取清理歷史記錄"""
|
||||
return self.cleanup_history[-limit:] if self.cleanup_history else []
|
||||
|
||||
def add_cleanup_callback(self, callback: Callable):
|
||||
"""添加清理回調函數"""
|
||||
if callback not in self.cleanup_callbacks:
|
||||
self.cleanup_callbacks.append(callback)
|
||||
debug_log("添加清理回調函數")
|
||||
|
||||
def add_stats_callback(self, callback: Callable):
|
||||
"""添加統計回調函數"""
|
||||
if callback not in self.stats_callbacks:
|
||||
self.stats_callbacks.append(callback)
|
||||
debug_log("添加統計回調函數")
|
||||
|
||||
def update_policy(self, **kwargs):
|
||||
"""更新清理策略"""
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self.policy, key):
|
||||
setattr(self.policy, key, value)
|
||||
debug_log(f"更新清理策略 {key} = {value}")
|
||||
else:
|
||||
debug_log(f"未知的策略參數: {key}")
|
||||
|
||||
def reset_stats(self):
|
||||
"""重置統計數據"""
|
||||
self.stats = CleanupStats()
|
||||
self.cleanup_history.clear()
|
||||
debug_log("清理統計數據已重置")
|
||||
|
||||
def force_cleanup_all(self, exclude_current: bool = True) -> int:
|
||||
"""強制清理所有會話"""
|
||||
sessions_to_clean = []
|
||||
|
||||
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):
|
||||
continue
|
||||
sessions_to_clean.append(session_id)
|
||||
|
||||
# 清理會話
|
||||
cleaned_count = 0
|
||||
for session_id in sessions_to_clean:
|
||||
try:
|
||||
session = self.web_ui_manager.sessions.get(session_id)
|
||||
if session:
|
||||
session._cleanup_sync_enhanced(CleanupReason.MANUAL)
|
||||
del self.web_ui_manager.sessions[session_id]
|
||||
cleaned_count += 1
|
||||
except Exception as e:
|
||||
debug_log(f"強制清理會話 {session_id} 失敗: {e}")
|
||||
|
||||
# 更新統計
|
||||
self._update_cleanup_stats(CleanupTrigger.MANUAL, cleaned_count, 0.0)
|
||||
|
||||
debug_log(f"強制清理完成,清理了 {cleaned_count} 個會話")
|
||||
return cleaned_count
|
253
tests/test_error_handler.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""
|
||||
錯誤處理框架測試模組
|
||||
|
||||
測試 ErrorHandler 類的各項功能,包括:
|
||||
- 錯誤類型自動分類
|
||||
- 用戶友好錯誤信息生成
|
||||
- 國際化支持
|
||||
- 錯誤上下文記錄
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# 添加 src 目錄到 Python 路徑
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
from mcp_feedback_enhanced.utils.error_handler import ErrorHandler, ErrorType, ErrorSeverity
|
||||
|
||||
|
||||
class TestErrorHandler:
|
||||
"""錯誤處理器測試類"""
|
||||
|
||||
def test_classify_error_network(self):
|
||||
"""測試網絡錯誤分類"""
|
||||
# 測試 ConnectionError
|
||||
error = ConnectionError("Connection failed")
|
||||
assert ErrorHandler.classify_error(error) == ErrorType.NETWORK
|
||||
|
||||
# 測試包含網絡關鍵字的錯誤(不包含 timeout)
|
||||
error = Exception("socket connection failed")
|
||||
assert ErrorHandler.classify_error(error) == ErrorType.NETWORK
|
||||
|
||||
def test_classify_error_file_io(self):
|
||||
"""測試文件 I/O 錯誤分類"""
|
||||
# 測試 FileNotFoundError
|
||||
error = FileNotFoundError("No such file or directory")
|
||||
assert ErrorHandler.classify_error(error) == ErrorType.FILE_IO
|
||||
|
||||
# 測試包含文件關鍵字的錯誤(不包含權限關鍵字)
|
||||
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
|
||||
|
||||
def test_format_user_error_with_context(self):
|
||||
"""測試帶上下文的錯誤信息生成"""
|
||||
error = FileNotFoundError("File not found")
|
||||
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"])
|
||||
|
||||
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
|
||||
assert "error_type" in response
|
||||
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')
|
||||
def test_language_support(self, mock_get_message):
|
||||
"""測試多語言支持"""
|
||||
error = ConnectionError("Network error")
|
||||
|
||||
# 測試繁體中文
|
||||
mock_get_message.return_value = "網絡連接出現問題"
|
||||
result = ErrorHandler.format_user_error(error)
|
||||
assert "網絡連接出現問題" in result
|
||||
|
||||
# 測試簡體中文
|
||||
mock_get_message.return_value = "网络连接出现问题"
|
||||
result = ErrorHandler.format_user_error(error)
|
||||
assert "网络连接出现问题" in result
|
||||
|
||||
# 測試英文
|
||||
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
|
||||
)
|
||||
|
||||
assert isinstance(error_id, str)
|
||||
assert error_id.startswith("ERR_")
|
||||
|
||||
def test_get_current_language_fallback(self):
|
||||
"""測試語言獲取回退機制"""
|
||||
# 由於 i18n 系統可能會覆蓋環境變數,我們主要測試函數不會拋出異常
|
||||
language = ErrorHandler.get_current_language()
|
||||
assert isinstance(language, str)
|
||||
assert len(language) > 0
|
||||
|
||||
# 測試語言代碼格式
|
||||
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")
|
||||
context = {
|
||||
"operation": "測試操作",
|
||||
"file_path": "/test/path",
|
||||
"user_id": "test_user",
|
||||
"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__':
|
||||
# 運行測試
|
||||
pytest.main([__file__, '-v'])
|
346
tests/test_gzip_compression.py
Normal file
@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Gzip 壓縮功能測試
|
||||
================
|
||||
|
||||
測試 FastAPI Gzip 壓縮中間件的功能,包括:
|
||||
- 壓縮效果驗證
|
||||
- WebSocket 兼容性
|
||||
- 靜態文件緩存
|
||||
- 性能提升測試
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from fastapi import FastAPI, Response
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from src.mcp_feedback_enhanced.web.utils.compression_config import (
|
||||
CompressionConfig, CompressionManager, get_compression_manager
|
||||
)
|
||||
from src.mcp_feedback_enhanced.web.utils.compression_monitor import (
|
||||
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
|
||||
|
||||
def test_from_env(self):
|
||||
"""測試從環境變數創建配置"""
|
||||
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', 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
|
||||
|
||||
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']
|
||||
|
||||
# API 路徑(預設不緩存)
|
||||
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']
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 測試未壓縮請求
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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',
|
||||
original_size=2000,
|
||||
compressed_size=1200,
|
||||
response_time=0.05,
|
||||
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.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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
def test_export_stats(self):
|
||||
"""測試統計導出"""
|
||||
monitor = CompressionMonitor()
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
client = TestClient(app)
|
||||
|
||||
# FastAPI 的 TestClient 預設會添加 Accept-Encoding,所以我們測試明確拒絕壓縮
|
||||
response = client.get("/test-large", headers={"Accept-Encoding": "identity"})
|
||||
|
||||
assert response.status_code == 200
|
||||
# 當明確要求不壓縮時,應該不會有 gzip 編碼
|
||||
# 注意:某些情況下 FastAPI 仍可能壓縮,這是正常行為
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 確保 WebSocket 不會被壓縮配置影響
|
||||
assert not config.should_compress('application/json', 1000) or config.should_exclude_path('/ws')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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'))
|
||||
|
||||
# 驗證壓縮效果
|
||||
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')
|
||||
assert decompressed_data == json_data
|
||||
|
||||
|
||||
def test_global_instances():
|
||||
"""測試全域實例"""
|
||||
# 測試壓縮管理器全域實例
|
||||
manager1 = get_compression_manager()
|
||||
manager2 = get_compression_manager()
|
||||
assert manager1 is manager2
|
||||
|
||||
# 測試壓縮監控器全域實例
|
||||
monitor1 = get_compression_monitor()
|
||||
monitor2 = get_compression_monitor()
|
||||
assert monitor1 is monitor2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
388
tests/test_memory_monitor.py
Normal file
@ -0,0 +1,388 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
內存監控系統測試
|
||||
================
|
||||
|
||||
測試集成式內存監控系統的功能,包括:
|
||||
- 內存監控準確性
|
||||
- 警告機制
|
||||
- 清理觸發
|
||||
- 統計和分析功能
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import threading
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.mcp_feedback_enhanced.utils.memory_monitor import (
|
||||
MemoryMonitor, MemorySnapshot, MemoryAlert, MemoryStats,
|
||||
get_memory_monitor
|
||||
)
|
||||
|
||||
|
||||
class TestMemorySnapshot:
|
||||
"""測試內存快照數據類"""
|
||||
|
||||
def test_memory_snapshot_creation(self):
|
||||
"""測試內存快照創建"""
|
||||
snapshot = MemorySnapshot(
|
||||
timestamp=datetime.now(),
|
||||
system_total=8 * 1024**3, # 8GB
|
||||
system_available=4 * 1024**3, # 4GB
|
||||
system_used=4 * 1024**3, # 4GB
|
||||
system_percent=50.0,
|
||||
process_rss=100 * 1024**2, # 100MB
|
||||
process_vms=200 * 1024**2, # 200MB
|
||||
process_percent=1.25,
|
||||
gc_objects=10000
|
||||
)
|
||||
|
||||
assert snapshot.system_total == 8 * 1024**3
|
||||
assert snapshot.system_percent == 50.0
|
||||
assert snapshot.process_rss == 100 * 1024**2
|
||||
assert snapshot.gc_objects == 10000
|
||||
|
||||
|
||||
class TestMemoryAlert:
|
||||
"""測試內存警告數據類"""
|
||||
|
||||
def test_memory_alert_creation(self):
|
||||
"""測試內存警告創建"""
|
||||
alert = MemoryAlert(
|
||||
level="warning",
|
||||
message="內存使用率較高: 85.0%",
|
||||
timestamp=datetime.now(),
|
||||
memory_percent=85.0,
|
||||
recommended_action="考慮執行輕量級清理"
|
||||
)
|
||||
|
||||
assert alert.level == "warning"
|
||||
assert alert.memory_percent == 85.0
|
||||
assert "85.0%" in alert.message
|
||||
|
||||
|
||||
class TestMemoryMonitor:
|
||||
"""測試內存監控器"""
|
||||
|
||||
def test_monitor_initialization(self):
|
||||
"""測試監控器初始化"""
|
||||
monitor = MemoryMonitor(
|
||||
warning_threshold=0.7,
|
||||
critical_threshold=0.85,
|
||||
emergency_threshold=0.95,
|
||||
monitoring_interval=10
|
||||
)
|
||||
|
||||
assert monitor.warning_threshold == 0.7
|
||||
assert monitor.critical_threshold == 0.85
|
||||
assert monitor.emergency_threshold == 0.95
|
||||
assert monitor.monitoring_interval == 10
|
||||
assert not monitor.is_monitoring
|
||||
assert len(monitor.snapshots) == 0
|
||||
assert len(monitor.alerts) == 0
|
||||
|
||||
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.psutil')
|
||||
def test_collect_memory_snapshot(self, mock_psutil):
|
||||
"""測試內存快照收集"""
|
||||
# 模擬 psutil 返回值
|
||||
mock_virtual_memory = Mock()
|
||||
mock_virtual_memory.total = 8 * 1024**3
|
||||
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
|
||||
)
|
||||
|
||||
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')
|
||||
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')
|
||||
def test_memory_usage_checking(self, mock_psutil):
|
||||
"""測試內存使用檢查和警告觸發"""
|
||||
monitor = MemoryMonitor(
|
||||
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), # 正常情況
|
||||
(85.0, "warning", 1, 0), # 警告情況
|
||||
(92.0, "critical", 1, 1), # 危險情況
|
||||
(97.0, "emergency", 1, 1), # 緊急情況
|
||||
]
|
||||
|
||||
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(),
|
||||
system_total=8 * 1024**3,
|
||||
system_available=int(8 * 1024**3 * (100 - memory_percent) / 100),
|
||||
system_used=int(8 * 1024**3 * memory_percent / 100),
|
||||
system_percent=memory_percent,
|
||||
process_rss=100 * 1024**2,
|
||||
process_vms=200 * 1024**2,
|
||||
process_percent=1.25,
|
||||
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):
|
||||
snapshot = MemorySnapshot(
|
||||
timestamp=base_time + timedelta(seconds=i * 30),
|
||||
system_total=8 * 1024**3,
|
||||
system_available=4 * 1024**3,
|
||||
system_used=4 * 1024**3,
|
||||
system_percent=50.0 + (i % 2), # 輕微波動
|
||||
process_rss=100 * 1024**2,
|
||||
process_vms=200 * 1024**2,
|
||||
process_percent=1.25,
|
||||
gc_objects=10000
|
||||
)
|
||||
monitor.snapshots.append(snapshot)
|
||||
|
||||
assert monitor._analyze_memory_trend() == "stable"
|
||||
|
||||
# 清空並添加遞增趨勢的快照
|
||||
monitor.snapshots.clear()
|
||||
for i in range(10):
|
||||
snapshot = MemorySnapshot(
|
||||
timestamp=base_time + timedelta(seconds=i * 30),
|
||||
system_total=8 * 1024**3,
|
||||
system_available=4 * 1024**3,
|
||||
system_used=4 * 1024**3,
|
||||
system_percent=50.0 + i * 2, # 遞增趨勢
|
||||
process_rss=100 * 1024**2,
|
||||
process_vms=200 * 1024**2,
|
||||
process_percent=1.25,
|
||||
gc_objects=10000
|
||||
)
|
||||
monitor.snapshots.append(snapshot)
|
||||
|
||||
assert monitor._analyze_memory_trend() == "increasing"
|
||||
|
||||
@patch('src.mcp_feedback_enhanced.utils.memory_monitor.psutil')
|
||||
def test_get_current_memory_info(self, mock_psutil):
|
||||
"""測試獲取當前內存信息"""
|
||||
# 模擬 psutil 返回值
|
||||
mock_virtual_memory = Mock()
|
||||
mock_virtual_memory.total = 8 * 1024**3
|
||||
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):
|
||||
snapshot = MemorySnapshot(
|
||||
timestamp=base_time + timedelta(seconds=i * 30),
|
||||
system_total=8 * 1024**3,
|
||||
system_available=4 * 1024**3,
|
||||
system_used=4 * 1024**3,
|
||||
system_percent=50.0 + i * 5, # 50%, 55%, 60%, 65%, 70%
|
||||
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
|
||||
)
|
||||
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.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
|
||||
assert stats.average_process_usage == 1.4 # (1.0+1.2+1.4+1.6+1.8)/5
|
||||
assert stats.peak_process_usage == 1.8
|
||||
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:
|
||||
mock_info.return_value = {
|
||||
"system": {"usage_percent": 75.0},
|
||||
"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
|
||||
|
||||
|
||||
def test_global_memory_monitor_singleton():
|
||||
"""測試全域內存監控器單例模式"""
|
||||
monitor1 = get_memory_monitor()
|
||||
monitor2 = get_memory_monitor()
|
||||
|
||||
assert monitor1 is monitor2
|
||||
assert isinstance(monitor1, MemoryMonitor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
249
tests/test_port_manager.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""
|
||||
端口管理器測試模組
|
||||
|
||||
測試 PortManager 類的各項功能,包括:
|
||||
- 端口可用性檢測
|
||||
- 進程查找和清理
|
||||
- 增強端口查找
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import socket
|
||||
import time
|
||||
import threading
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# 添加 src 目錄到 Python 路徑
|
||||
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))
|
||||
free_port = s.getsockname()[1]
|
||||
|
||||
# 測試該端口是否被檢測為可用
|
||||
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))
|
||||
occupied_port = server_socket.getsockname()[1]
|
||||
server_socket.listen(1)
|
||||
|
||||
try:
|
||||
# 測試該端口是否被檢測為不可用
|
||||
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))
|
||||
preferred_port = s.getsockname()[1]
|
||||
|
||||
# 測試是否返回偏好端口
|
||||
result_port = PortManager.find_free_port_enhanced(
|
||||
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))
|
||||
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
|
||||
)
|
||||
assert result_port != occupied_port
|
||||
assert result_port > occupied_port # 應該向上查找
|
||||
|
||||
# 驗證返回的端口確實可用
|
||||
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))
|
||||
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))
|
||||
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
|
||||
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))
|
||||
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
|
||||
|
||||
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))
|
||||
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
|
||||
# process 可能為 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))
|
||||
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]
|
||||
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
|
||||
|
||||
finally:
|
||||
# 清理測試服務器
|
||||
for server in servers:
|
||||
server.close()
|
||||
|
||||
@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'
|
||||
}
|
||||
|
||||
result = PortManager._should_cleanup_process(process_info)
|
||||
assert result is True
|
||||
|
||||
@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'
|
||||
}
|
||||
|
||||
result = PortManager._should_cleanup_process(process_info)
|
||||
assert result is False
|
||||
|
||||
def test_find_free_port_enhanced_max_attempts(self):
|
||||
"""測試最大嘗試次數限制"""
|
||||
# 這個測試比較難實現,因為需要占用大量連續端口
|
||||
# 我們只測試參數是否正確傳遞
|
||||
try:
|
||||
result = PortManager.find_free_port_enhanced(
|
||||
preferred_port=65000, # 使用高端口減少衝突
|
||||
auto_cleanup=False,
|
||||
max_attempts=10
|
||||
)
|
||||
assert isinstance(result, int)
|
||||
assert 65000 <= result <= 65535
|
||||
except RuntimeError:
|
||||
# 如果真的找不到端口,這也是正常的
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 運行測試
|
||||
pytest.main([__file__, '-v'])
|
394
tests/test_resource_manager.py
Normal file
@ -0,0 +1,394 @@
|
||||
"""
|
||||
資源管理器測試模組
|
||||
|
||||
測試 ResourceManager 類的各項功能,包括:
|
||||
- 臨時文件和目錄管理
|
||||
- 進程註冊和清理
|
||||
- 自動清理機制
|
||||
- 資源統計和監控
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# 添加 src 目錄到 Python 路徑
|
||||
sys.path.insert(0, 'src')
|
||||
|
||||
from mcp_feedback_enhanced.utils.resource_manager import (
|
||||
ResourceManager,
|
||||
get_resource_manager,
|
||||
create_temp_file,
|
||||
create_temp_dir,
|
||||
register_process,
|
||||
cleanup_all_resources
|
||||
)
|
||||
|
||||
|
||||
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 便捷函數
|
||||
temp_file = create_temp_file(suffix=".log", prefix="conv_")
|
||||
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
|
||||
)
|
||||
|
||||
# 註冊進程
|
||||
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()
|
||||
|
||||
# 創建多個臨時文件
|
||||
temp_files = []
|
||||
for i in range(3):
|
||||
temp_file = rm.create_temp_file(prefix=f"cleanup_test_{i}_")
|
||||
temp_files.append(temp_file)
|
||||
|
||||
# 確認文件都存在
|
||||
for temp_file in temp_files:
|
||||
assert os.path.exists(temp_file)
|
||||
assert temp_file in rm.temp_files
|
||||
|
||||
# 執行清理(max_age=0 清理所有文件)
|
||||
cleaned_count = rm.cleanup_temp_files(max_age=0)
|
||||
|
||||
assert cleaned_count == 3
|
||||
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()
|
||||
|
||||
# 創建各種資源
|
||||
temp_file = rm.create_temp_file(prefix="cleanup_all_")
|
||||
temp_dir = rm.create_temp_dir(prefix="cleanup_all_")
|
||||
|
||||
# 註冊進程
|
||||
current_pid = os.getpid()
|
||||
rm.register_process(current_pid, description="測試進程", auto_cleanup=False)
|
||||
|
||||
# 執行全面清理
|
||||
results = rm.cleanup_all()
|
||||
|
||||
assert isinstance(results, dict)
|
||||
assert "temp_files" in results
|
||||
assert "temp_dirs" in results
|
||||
assert "processes" in results
|
||||
assert "file_handles" in results
|
||||
|
||||
# 檢查文件和目錄是否被清理
|
||||
assert not os.path.exists(temp_file)
|
||||
assert not os.path.exists(temp_dir)
|
||||
assert temp_file not in rm.temp_files
|
||||
assert temp_dir not in rm.temp_dirs
|
||||
|
||||
# 進程不應該被清理(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
|
||||
assert "current_processes" in stats
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 pytest.raises(OSError):
|
||||
rm.create_temp_file()
|
||||
|
||||
# 測試創建臨時目錄時的錯誤處理
|
||||
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:
|
||||
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__':
|
||||
# 運行測試
|
||||
pytest.main([__file__, '-v'])
|
366
tests/test_session_cleanup.py
Normal file
@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
會話清理優化測試
|
||||
================
|
||||
|
||||
測試 WebFeedbackSession 和 SessionCleanupManager 的清理功能。
|
||||
"""
|
||||
|
||||
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__), '..'))
|
||||
|
||||
from src.mcp_feedback_enhanced.web.models.feedback_session import (
|
||||
WebFeedbackSession, SessionStatus, CleanupReason
|
||||
)
|
||||
from src.mcp_feedback_enhanced.web.utils.session_cleanup_manager import (
|
||||
SessionCleanupManager, CleanupPolicy, CleanupTrigger
|
||||
)
|
||||
|
||||
|
||||
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.summary,
|
||||
auto_cleanup_delay=60, # 1分鐘自動清理
|
||||
max_idle_time=30 # 30秒最大空閒時間
|
||||
)
|
||||
|
||||
def teardown_method(self):
|
||||
"""測試後清理"""
|
||||
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
|
||||
assert self.session.project_directory == self.project_dir
|
||||
assert self.session.summary == self.summary
|
||||
assert self.session.status == SessionStatus.WAITING
|
||||
assert self.session.auto_cleanup_delay == 60
|
||||
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):
|
||||
"""測試清理統計"""
|
||||
# 初始統計
|
||||
stats = self.session.get_cleanup_stats()
|
||||
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):
|
||||
"""測試異步清理"""
|
||||
# 模擬 WebSocket 連接
|
||||
mock_websocket = Mock()
|
||||
mock_websocket.send_json = Mock(return_value=asyncio.Future())
|
||||
mock_websocket.send_json.return_value.set_result(None)
|
||||
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()
|
||||
assert self.session.status == SessionStatus.ACTIVE
|
||||
|
||||
|
||||
class TestSessionCleanupManager:
|
||||
"""測試 SessionCleanupManager 功能"""
|
||||
|
||||
def setup_method(self):
|
||||
"""測試前設置"""
|
||||
# 創建模擬的 WebUIManager
|
||||
self.mock_web_ui_manager = Mock()
|
||||
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.policy = CleanupPolicy(
|
||||
max_idle_time=30,
|
||||
max_session_age=300,
|
||||
max_sessions=5,
|
||||
cleanup_interval=10,
|
||||
enable_auto_cleanup=True
|
||||
)
|
||||
|
||||
# 創建清理管理器
|
||||
self.cleanup_manager = SessionCleanupManager(
|
||||
self.mock_web_ui_manager,
|
||||
self.policy
|
||||
)
|
||||
|
||||
def teardown_method(self):
|
||||
"""測試後清理"""
|
||||
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
|
||||
assert self.cleanup_manager.policy == self.policy
|
||||
assert not self.cleanup_manager.is_running
|
||||
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):
|
||||
"""測試自動清理啟動和停止"""
|
||||
# 啟動自動清理
|
||||
result = self.cleanup_manager.start_auto_cleanup()
|
||||
assert result == True
|
||||
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)
|
||||
|
||||
# 檢查結果
|
||||
assert cleaned == 3
|
||||
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):
|
||||
"""測試清理統計功能"""
|
||||
# 初始統計
|
||||
stats = self.cleanup_manager.get_cleanup_statistics()
|
||||
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
|
||||
assert stats["expired_cleanups"] == 1
|
||||
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
|
||||
)
|
||||
|
||||
# 檢查策略是否更新
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|