diff --git a/.github/cursor-example.jpg b/.github/cursor-example.jpg deleted file mode 100644 index d73586e..0000000 Binary files a/.github/cursor-example.jpg and /dev/null differ diff --git a/.github/interactive_feedback_1.jpg b/.github/interactive_feedback_1.jpg deleted file mode 100644 index c4c2acd..0000000 Binary files a/.github/interactive_feedback_1.jpg and /dev/null differ diff --git a/.github/interactive_feedback_2.jpg b/.github/interactive_feedback_2.jpg deleted file mode 100644 index 60b0a41..0000000 Binary files a/.github/interactive_feedback_2.jpg and /dev/null differ diff --git a/.gitignore b/.gitignore index 6f1b1d9..c60d3ce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,42 @@ venv*/ # Logs *.log +data/*.log + +# Temporary files +Temp/ +temp/ +tmp/ +*.tmp + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# Local configuration +.env +.env.* #Others .DS_Store +uv.lock +# 不提交的文件 +二次开发建议.md +todolist.md +todo.md .cursor/rules/ +custom_http_transport_mcp.md +config.json +# Private individual user cursor rules +.cursor/rules/_*.mdc +.cursor/ +docs/ +issues/ +xnote/ +todo.md + + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..43f9068 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: +- repo: https://github.com/psf/black + rev: 24.8.0 # You can pin to a specific version + hooks: + - id: black + language_version: python3.11 # Specify your python version \ No newline at end of file diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c07333..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.11 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd0d708 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,410 @@ +# 更新日志 (Changelog) + +## [v2.5.10.3] - 2025-01-17 + +### 🔧 重要修复 (Critical Fix) +- **修复设置页面单选按钮分组逻辑错误**:解决了显示模式和提交方式选项错误地彼此互斥的问题 +- **使用QButtonGroup明确分组**:将显示模式组和提交方式组设为独立的互斥组 +- **恢复正确的用户体验**:用户现在可以同时选择一个显示模式(简化/完整)和一个提交方式(Enter/Ctrl+Enter) +- **支持所有4种有效组合**:简化+Enter、简化+Ctrl+Enter、完整+Enter、完整+Ctrl+Enter +- **完整的测试验证**:通过自动化测试确保修复正确性和功能完整性 + +## [v2.5.10.2] - 2025-01-15 + +### 📸 示例图片修复 (Example Images Fix) +- **修复README.md示例图片链接**:更正了示例图片的链接地址,确保图片能正常显示 +- **使用正确的链接格式**:采用i.postimg.cc的正确链接格式,提升图片加载稳定性 + +## [v2.5.10.1] - 2025-01-15 + +### 📸 示例图片更新 (Example Images Update) +- **更新README.md示例图片**:更新了示例图片的链接地址,优化了图片显示效果 +- **改进视觉展示**:使用更清晰的图片链接,提升用户浏览体验 + +## [v2.5.10] - 2025-01-15 + +### 📚 文档重大更新 (Documentation Major Update) +- **推荐安装方式调整**:将开发模式安装提升为推荐方式,确保用户获得最佳稳定性 +- **完善三大核心文档**: + - **README.md**:重新排序安装方式,优先展示开发模式配置 + - **安装与配置指南.md**:重新组织目录结构,详细对比各种安装方式优劣 + - **功能说明.md**:更新快速开始部分,同步最新配置示例 +- **配置示例标准化**:统一所有文档中的开发模式配置格式 +- **优势说明增强**:详细解释开发模式的稳定性优势和PyPI安装可能遇到的问题 + +### 🎨 UI视觉增强 (UI Visual Enhancements) +- **重大改进:选项控件选中状态视觉效果**:完全重新设计单选按钮和复选框的选中状态显示 +- **主题协调的选中颜色**: + - **深色主题**:选中状态使用协调的灰色背景 (#555555),与深色界面完美融合 + - **浅色主题**:选中状态使用深灰色背景 (#6B6B6B),提供良好的对比度 +- **增强的悬停效果**:改进鼠标悬停时的视觉反馈,使用稍亮的灰色调 +- **改进的边框设计**:将边框宽度从1px增加到2px,提供更清晰的边界定义 +- **完善的禁用状态**:为禁用控件提供专门的灰色样式,确保可访问性 + +### 🔧 技术改进 (Technical Improvements) +- **增强的样式系统**:创建新的 `apply_enhanced_control_styling` 函数,统一管理所有控件样式 +- **主题感知优化**:改进 `apply_theme_aware_styling` 函数,支持更复杂的样式配置 +- **设置对话框样式应用**:为所有设置对话框(主设置、音频设置、优化设置)添加自动样式应用 +- **SVG图标增强**:为复选框勾选标记添加描边效果,提高在不同背景下的可见性 + +## [v2.5.9.13] - 2025-01-15 + +### 🚀 重大修复 (Major Fixes) +- **修复uv安装用户预定义选项问题**:默认启用自定义选项功能,解决uv安装用户看不到预定义选项的问题 +- **改进音频文件兼容性**:优化音频文件在uv安装环境下的路径解析和资源加载 +- **完善配置文件位置说明**:明确不同安装方式下配置文件的存储位置 + +### 🔧 技术改进 (Technical Improvements) +- **配置管理优化**:将 `enable_custom_options` 默认值改为 `true` +- **音频资源加载增强**:改进 `importlib.resources` 的使用,增加临时文件回退机制 +- **设置界面改进**:在音频设置页面显示默认音频文件状态 +- **文档完善**:添加uv安装用户常见问题解决方案 + +### 📋 配置变更 (Configuration Changes) +- **默认配置更新**:`enable_custom_options` 从 `false` 改为 `true` +- **配置模板更新**:同步更新 `config.template.json` +- **版本说明**:在配置模板中添加版本变更说明 + +### 🐛 问题解决 (Bug Fixes) +- 修复uv安装用户只能看到通用AI选项("继续"、"取消"等)的问题 +- 修复音频设置页面显示空白自定义音频路径的问题 +- 修复默认音频文件在uv安装环境下无法正确加载的问题 + +## [v2.5.9.11] - 2025-01-14 + +### 🔧 强力修复 (Powerful Fix) +- **内联代码样式终极解决方案**:采用增强的内联样式完全绕过CSS优先级问题 +- **HTML处理优化**:修复了双分号问题,确保样式语法正确 +- **CSS规则简化**:移除了复杂的属性选择器,专注于class选择器 +- **内联样式增强**:直接在HTML中嵌入完整的代码样式,包括: + - 蓝色文字颜色 (#4A90E2) + - 灰色背景高亮 + - 等宽字体设置 + - 圆角边框和内边距 + +### 🎯 彻底解决 (Complete Resolution) +- **uv环境兼容性**:确保在所有安装环境中内联代码都正确显示 +- **样式独立性**:不再依赖外部CSS规则,使用自包含的内联样式 +- **视觉一致性**:所有代码文字(文件名、方法名、选择器等)统一显示效果 + +### 📋 技术改进 (Technical) +- 修复了样式字符串处理中的双分号问题 +- 简化了CSS选择器,提高兼容性 +- 增强了HTML处理的健壮性 + +## [v2.5.9.10] - 2025-01-14 + +### 🔧 关键修复 (Critical Fix) +- **内联代码样式优先级问题修复**:彻底解决了uv安装环境中内联代码文字无法正确显示蓝色的问题 +- **CSS选择器特异性增强**:增加了多种引号格式的支持,确保CSS规则能够覆盖QTextDocument生成的内联样式 + - 新增支持:`font-family:'Courier New'` 和 `font-family:"Courier New"` + - 使用 `!important` 确保代码颜色优先级最高 +- **HTML处理逻辑改进**:强制移除冲突的颜色样式,确保代码文字始终显示为蓝色 + - 改进了正则表达式以处理更多HTML格式变体 + - 增强了颜色样式的替换逻辑 + +### 🎯 问题解决 (Issue Resolution) +- **内联代码显示**:文件名、类名、方法名等内联代码现在在所有环境中都正确显示蓝色 +- **环境一致性**:确保uv安装用户和源代码用户获得完全相同的内联代码显示效果 +- **样式稳定性**:消除了CSS优先级冲突,提高了样式应用的可靠性 + +### 📋 技术改进 (Technical) +- 增强了正则表达式的兼容性,支持更多HTML格式 +- 优化了样式处理流程,减少了样式冲突的可能性 +- 提高了代码样式渲染的稳定性和一致性 + +## [v2.5.9.9] - 2025-01-14 + +### 🎨 重要样式修复 (Critical Style Fix) +- **代码样式回退问题修复**:彻底解决了uv安装用户GUI窗口中代码文本样式回退的问题 +- **样式冲突消除**:移除了dark_theme.qss中重复且冲突的代码样式定义 + - 删除了导致代码文本颜色回退到默认样式的重复定义 + - 确保代码文本始终显示为预期的蓝色 (#4A90E2) +- **QTextEdit代码样式支持**:新增对QTextEdit组件的代码样式支持 + - 内联代码和代码块在所有UI组件中统一显示蓝色 + - 深色和浅色主题都完全支持 + +### 🔧 代码质量优化 (Code Quality) +- **消除冗余代码**:移除了约15行重复的CSS样式定义 +- **优化样式结构**:合并了相似样式规则,提高加载效率 +- **增强维护性**:统一了样式管理,便于后续维护 + +### 🎯 用户体验改进 (UX Improvements) +- **统一显示效果**:确保uv安装用户和本地开发用户获得完全一致的代码样式 +- **稳定性提升**:消除了样式冲突,提高了显示一致性 +- **兼容性保证**:所有现有功能和样式保持不变 + +## [v2.5.9.8] - 2025-01-14 + +### 🔧 重要修复 (Critical Fix) +- **uv环境Markdown渲染问题修复**:解决了uv安装用户GUI窗口显示模式异常的问题 +- **多重导入策略**:实现了text_formatter模块的多重导入策略,确保在不同环境中都能正确导入 + - 策略1:相对导入(开发环境) + - 策略2:绝对导入(uv安装环境) + - 策略3:动态导入(兼容性回退) +- **改进的回退检测**:优化了Markdown检测的回退逻辑,大幅减少误判 + - 更严格的检测条件,避免普通AI回复被误识别为Markdown + - 成对标记验证,确保格式标记的完整性 + - 行首标记检测,提高检测准确性 + +### 🎯 用户体验改进 (UX Improvements) +- **统一体验**:确保uv安装用户和源代码用户获得完全一致的显示效果 +- **稳定性增强**:添加了全面的异常处理,确保在任何情况下都能正常显示文本 +- **调试支持**:添加了调试模式,便于问题诊断和排查 + +### 🔧 技术改进 (Technical) +- 增强了错误处理机制,确保Markdown转换失败时能优雅回退 +- 优化了导入逻辑,提高了模块加载的成功率 +- 添加了详细的调试信息,便于开发和维护 + +## [v2.5.9.7] - 2025-01-13 + +### ✨ 新功能 (Features) +- **Markdown渲染**:GUI窗口现在支持自动检测和渲染Markdown格式文本 +- **智能文本显示**:自动识别Markdown内容并正确渲染,隐藏语法符号 +- **主题兼容**:Markdown渲染完美兼容现有的深色/浅色主题系统 + +### 🎨 界面优化 (UI/UX) +- **文本格式化**:标题、粗体、斜体、代码块等Markdown元素正确显示 +- **视觉层次**:通过字体大小和样式区分文本层次,而非颜色 +- **阅读体验**:消除原始Markdown语法符号的干扰,提升专业感 + +### 🔧 技术改进 (Technical) +- 使用PySide6原生QTextDocument.setMarkdown()方法 +- 保持向后兼容,普通文本正常显示 +- 最小化代码改动,不影响现有功能 + +## [v2.5.9.6] - 2025-01-13 + +### 🔧 修复 (Fixed) +- **依赖兼容性**:移除playsound依赖,解决Windows环境下构建失败问题 +- **音频播放**:使用原生音频播放方案替代playsound,提升跨平台兼容性 + - Windows: 使用winsound和PowerShell + - macOS: 使用afplay + - Linux: 使用系统原生音频播放器和系统提示音 + +### 📚 文档 (Documentation) +- 更新安装指南,添加playsound构建失败的故障排除说明 +- 更新版本信息到v2.5.9.6 + +### ⚡ 性能优化 (Performance) +- 减少包体积,移除不必要的可选依赖 +- 提升音频播放稳定性和响应速度 + +## [v2.5.9.3] - 2025-01-12 + +### ✨ 新增功能 +#### 提交方式设置功能 +- **功能描述**:在设置页面新增提交方式选择选项,支持两种提交模式 +- **提交模式**: + - Enter键直接提交:按Enter键直接提交反馈 + - Ctrl+Enter组合键提交:按Ctrl+Enter(Windows/Linux)或Cmd+Enter(macOS)提交反馈 +- **跨平台兼容**: + - Windows/Linux系统:显示"Ctrl+Enter组合键提交" + - macOS系统:显示"⌘+Enter组合键提交" + - 自动检测操作系统并显示相应的快捷键说明 +- **用户体验**: + - 设置变更立即生效,无需重启应用 + - 动态更新输入框占位符文本,显示当前提交方式的快捷键提示 + - 完整的中英文双语支持 + +### 🔧 技术改进 +#### 代码结构优化 +- **新增模块**:创建 `platform_utils.py` 工具模块,提供跨平台兼容性支持 +- **性能优化**: + - 消除重复的模块导入逻辑 + - 缓存配置获取函数,减少重复调用开销 + - 移除未使用的冗余代码和函数 +- **配置管理**: + - 在默认配置中添加 `submit_method` 字段 + - 增强配置验证,确保提交方式设置的有效性 + - 支持配置的持久化保存和加载 + +### 📋 界面优化 +#### 设置窗口布局改进 +- **窗口尺寸**:设置窗口高度从650px增加到700px,为新功能提供充足空间 +- **布局优化**: + - 提交方式选项位于"精简模式"/"完整模式"下方,"启用自定义选择"上方 + - 优化语言选择区域布局,避免文字被挤压 + - 改用水平布局替代网格布局,提供更好的视觉效果 +- **响应式设计**:确保在不同分辨率下都有良好的显示效果 + +### 🌐 国际化支持 +#### 多语言适配 +- **文本本地化**:所有新增的界面文本都支持中英文双语 +- **平台适配**:根据操作系统自动显示对应的快捷键文本 +- **动态更新**:语言切换时同步更新所有相关文本 + +--- + +## [v2.5.9.1] - 2025-01-12 + +### 🔧 重要修复 +#### uvx环境配置文件路径问题修复 +- **问题描述**:修复了uvx运行环境下配置文件路径错误的问题 +- **解决方案**: + - 智能检测uvx运行环境,自动使用用户主目录配置路径 + - 优化配置文件路径计算逻辑,避免在临时缓存目录中查找配置 + - 自动创建默认配置文件,提升首次使用体验 + - 改进错误消息,明确显示运行环境和配置文件路径 +- **技术改进**: + - 检测uvx环境特征(路径包含"uv"和"cache"或"archive") + - 在uvx环境中直接使用 `~/.interactive-feedback/config.json` + - 首次运行时自动创建默认配置文件 +- **用户体验**:解决了uvx用户看到配置文件路径错误的困扰 + +--- + +## [v2.5.9] - 2025-01-12 + +### 🎨 提示文字显示效果优化 +- **工具提示优化**:创建专门的工具提示格式化工具类,支持长文本自动换行 +- **样式统一**:在明暗主题中设置统一的字体、边距、圆角边框和阴影效果 +- **动态提示**:优化工具提示显示延迟,添加加载状态动态提示 +- **占位符优化**:根据用户配置和语言动态更新占位符文本,支持跨平台 +- **文本渲染**:启用抗锯齿、字母间距和单词间距调整,提升可读性 +- **语法高亮**:为文件引用添加蓝色高亮显示 +- **反馈消息**:优化成功状态显示和加载状态提示,智能自动隐藏 +- **格式化工具**:创建Markdown格式处理工具,支持撤销的文本替换 + +--- + +## [v2.5.8.3] - 2025-01-12 + +### 🔧 重要修复 +#### MCP服务连续调用问题修复 +- **问题描述**:修复了MCP服务连续调用两次的问题,原因是严格的参数验证逻辑导致第一次调用失败 +- **解决方案**:实现智能回退机制,当主要参数为空时自动使用备用参数 +- **技术改进**: + - 完整模式:优先使用 `full_response`,如果为空则回退到 `message` + - 精简模式:优先使用 `message`,如果为空则回退到 `full_response` + - 只有当两个参数都为空时才返回错误 + - 保持实时模式检测,支持动态切换 +- **代码优化**: + - 提取公共参数验证函数,减少重复代码 + - 使用元组解包简化参数优先级逻辑 + - 更新工具文档,添加智能回退说明 +- **用户体验**:避免了不必要的错误提示,提升了工具的稳定性和可靠性 + +--- + +## [当前版本] - 项目现状 + +### 🎯 项目概述 +interactive-feedback-mcp 是一个专业的AI助手交互式反馈工具,提供图形化界面用于AI助手与用户之间的高效沟通。 + +### 🌟 核心功能 +- **交互式反馈窗口**:支持文本输入、预定义选项选择、快捷键操作 +- **多媒体处理**:图片粘贴/拖拽、文件引用、窗口截图功能 +- **常用语管理**:预设管理、hover预览、快速插入 +- **原生终端集成**:支持PowerShell、Git Bash、Command Prompt +- **文本优化功能**:多AI提供商支持、一键优化、自定义增强 +- **界面布局**:垂直/水平布局、可拖拽分割器、主题切换 +- **显示模式**:简单模式/完整模式、实时切换 +- **音频提示**:窗口弹出提示音、自定义音频支持 + +### 🏗️ 技术架构 +- **基于PySide6**:现代化的Qt图形界面框架 +- **MCP协议**:标准的模型上下文协议集成 +- **模块化设计**:清晰的代码结构和组件分离 +- **主题系统**:统一的颜色管理和样式应用 +- **配置管理**:灵活的配置文件和UI设置 + +### 📚 文档体系 +- **README.md**:项目简介和使用技巧 +- **功能说明.md**:详细的功能描述和操作指南 +- **安装与配置指南.md**:完整的安装配置说明 +- **CHANGELOG.md**:项目发展历程记录 + +--- + +## [主要版本历程] + +### v4.x - 界面优化与文档重构期 +#### UI界面稳定性提升 +- 解决了UI窗口布局闪烁问题,优化初始化流程 +- 实现主题一致性,所有UI元素统一使用主题感知颜色系统 +- 调整按钮布局逻辑,提升用户操作体验 +- 移除不协调的hover效果,保持视觉一致性 + +#### 代码质量改进 +- 清理约50行冗余代码,统一配置读取和样式应用逻辑 +- 优化初始化流程,减少定时器冲突,提升UI响应性 +- 改进窗口显示和样式更新时序,提供更稳定的用户体验 + +#### 文档体系重构 +- 重构README.md,突出使用技巧,更新AI助手工作流规则 +- 精简功能说明.md,移除时间性描述,突出核心功能 +- 优化安装与配置指南.md,移除重复内容,保持文档简洁 + +### v3.x - 功能扩展与体验优化期 +#### 文本优化功能 +- 集成多AI提供商支持(OpenAI、Gemini、DeepSeek、火山引擎) +- 实现一键优化和自定义增强功能 +- 添加API密钥管理和加载动画 +- 支持撤销功能和自动光标定位 + +#### 多媒体功能增强 +- 添加窗口截图功能,支持矩形选择和智能窗口管理 +- 实现音频提示功能,支持自定义音频文件 +- 优化图片处理流程,提供更好的预览和管理体验 + +#### 界面布局系统 +- 实现垂直/水平双布局模式 +- 添加可拖拽分割器,支持双击重置 +- 实现显示模式配置(简单模式/完整模式) +- 优化设置页面,统一UI风格 + +### v2.x - 核心功能完善期 +#### 文件处理系统 +- 实现文件拖拽功能,支持蓝色文件引用显示 +- 添加文件选择按钮,支持多文件选择 +- 智能处理图片文件和普通文件 +- 实现智能光标定位和重复检测 + +#### 常用语系统 +- 实现常用语管理功能,支持添加、编辑、删除和排序 +- 添加hover预览功能,支持无限滚动 +- 实现流畅的鼠标交互和主题适配 +- 优化管理界面布局和用户体验 + +#### 终端集成 +- 集成原生终端功能,支持PowerShell、Git Bash、Command Prompt +- 实现直接输入模式和完整键盘支持 +- 添加ANSI转义序列支持,正确显示彩色输出 +- 实现智能提示符识别和窗口管理 + +### v1.x - 基础功能建立期 +#### 核心反馈系统 +- 建立基础的交互式反馈窗口 +- 实现文本输入和预定义选项选择 +- 添加快捷键支持(Enter、Shift+Enter、Ctrl+V) +- 集成MCP协议,实现与AI助手的通信 + +#### 图片处理功能 +- 实现图片粘贴和拖拽功能 +- 添加图片预览和管理功能 +- 支持多种图片格式和尺寸调整 +- 实现图片与文本的混合处理 + +#### 基础UI框架 +- 建立基于PySide6的图形界面框架 +- 实现深色主题UI +- 添加基础的窗口控制功能 +- 建立配置管理系统 + +--- + +## [发展里程碑] + +### 🎯 项目成就 +- **功能完整性**:从基础反馈工具发展为功能完整的AI助手交互平台 +- **用户体验**:持续优化界面和交互,提供专业级的用户体验 +- **技术架构**:建立了稳定、可扩展的技术架构 +- **文档体系**:完善的文档体系,便于用户使用和开发者参与 + +### 🔮 技术演进 +- **界面技术**:从基础Qt界面发展为主题感知的现代化UI +- **功能集成**:从单一反馈功能扩展为多媒体、终端、AI优化的综合平台 +- **代码质量**:持续重构和优化,保持高质量的代码标准 +- **用户导向**:始终以用户体验为中心,不断改进和完善功能 diff --git a/LICENSE b/LICENSE index edbad56..91ebd78 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2024 Fábio Ferreira +Copyright (c) 2025 Pau Oliva Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4e80f3c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,23 @@ +include README.md +include LICENSE +include CHANGELOG.md +include pyproject.toml +recursive-include src *.py +recursive-include src *.qrc +recursive-include src *.ui +recursive-include src/feedback_ui/images *.png *.jpg *.jpeg *.gif *.svg +recursive-include src/feedback_ui/resources *.qrc *.py *.wav *.mp3 *.ogg +recursive-include src/feedback_ui/resources/sounds *.wav *.mp3 *.ogg *.flac *.aac +recursive-include src/feedback_ui/resources/translations *.qm *.ts +recursive-include src/feedback_ui/styles *.qss + +# 确保编译的资源文件被包含(uv安装兼容性) +include src/feedback_ui/resources_rc.py + +global-exclude *.pyc +global-exclude config.json +global-exclude __pycache__ +global-exclude *.egg-info +global-exclude .git* +global-exclude *.bak +global-exclude *~ diff --git a/README.md b/README.md index 963ed6f..f019da1 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,469 @@ -# Interactive Feedback MCP +# ![Interactive Feedback MCP](./1a7ef-zmno1-001.png) Interactive Feedback MCP -Developed by Fábio Ferreira ([@fabiomlferreira](https://x.com/fabiomlferreira)). -Check out [dotcursorrules.com](https://dotcursorrules.com/) for more AI development enhancements. +**不好意思,之前很多功能下载源代码正常,但是uv安装会有问题** -Simple [MCP Server](https://modelcontextprotocol.io/) to enable a human-in-the-loop workflow in AI-assisted development tools like [Cursor](https://www.cursor.com). This server allows you to run commands, view their output, and provide textual feedback directly to the AI. It is also compatible with [Cline](https://cline.bot) and [Windsurf](https://windsurf.com). +一个简单的 [MCP Server](https://modelcontextprotocol.io/),用于在AI辅助开发工具(如 [Cursor](https://www.cursor.com)、[Cline](https://cline.bot) 、 [Windsurf](https://windsurf.com))和[Augment]插件中实现人机协作工作流。该服务器允许您轻松地直接向AI代理提供反馈,让AI与您之间更好地协作。 -![Interactive Feedback UI - Main View](https://github.com/noopstudios/interactive-feedback-mcp/blob/main/.github/interactive_feedback_1.jpg?raw=true) -![Interactive Feedback UI - Command Section Open](https://github.com/noopstudios/interactive-feedback-mcp/blob/main/.github/interactive_feedback_2.jpg) +**详细信息请参阅:** +* [功能说明.md](./功能说明.md) - 了解本服务提供的各项功能。 +* [安装与配置指南.md](./安装与配置指南.md) - 获取详细的安装和设置步骤。 -## Prompt Engineering +**注意:** 此服务器设计为与MCP客户端(例如Cursor、VS Code)在本地一同运行,因为它需要直接访问用户的操作系统以显示UI和执行键盘/鼠标操作。 -For the best results, add the following to your custom prompt in your AI assistant, you should add it on a rule or directly in the prompt (e.g., Cursor): +## 🖼️ 示例 -> Whenever you want to ask a question, always call the MCP `interactive_feedback`. -> Whenever you’re about to complete a user request, call the MCP `interactive_feedback` instead of simply ending the process. -> If the feedback is empty you can end the request and don't call the mcp in loop. +![Interactive Feedback Example](https://i.postimg.cc/xCfym6HP/1.png) +![Interactive Feedback Example](https://i.postimg.cc/FKQxrrH8/3.png) +![Interactive Feedback Example](https://i.postimg.cc/zfSF3sLB/2.png) +*(请注意,示例图片可能未反映最新的UI调整,但核心交互流程保持不变)* -This will ensure your AI assistant uses this MCP server to request user feedback before marking the task as completed. +## 💡 为何使用此工具? -## 💡 Why Use This? -By guiding the assistant to check in with the user instead of branching out into speculative, high-cost tool calls, this module can drastically reduce the number of premium requests (e.g., OpenAI tool invocations) on platforms like Cursor. In some cases, it helps consolidate what would be up to 25 tool calls into a single, feedback-aware request — saving resources and improving performance. +1.在像Cursor这样的环境中,您发送给LLM的每个提示都被视为一个独立的请求——每个请求都会计入您的每月限额(例如,500个高级请求)。当您迭代模糊指令或纠正被误解的输出时,这会变得效率低下,因为每次后续澄清都会触发一个全新的请求。 -## Configuration +此MCP服务器引入了一种变通方法:它允许模型在最终确定响应之前暂停并请求澄清。模型不会直接完成请求,而是触发一个工具调用 (`interactive_feedback`),打开一个交互式反馈窗口。然后,您可以提供更多细节或要求更改——模型会继续会话,所有这些都在单个请求内完成。 -This MCP server uses Qt's `QSettings` to store configuration on a per-project basis. This includes: -* The command to run. -* Whether to execute the command automatically on the next startup for that project (see "Execute automatically on next run" checkbox). -* The visibility state (shown/hidden) of the command section (this is saved immediately when toggled). -* Window geometry and state (general UI preferences). +从本质上讲,这只是巧妙地利用工具调用来推迟请求的完成。由于工具调用不计为单独的高级交互,因此您可以在不消耗额外请求的情况下循环执行多个反馈周期。 -These settings are typically stored in platform-specific locations (e.g., registry on Windows, plist files on macOS, configuration files in `~/.config` or `~/.local/share` on Linux) under an organization name "FabioFerreira" and application name "InteractiveFeedbackMCP", with a unique group for each project directory. +简而言明,这有助于您的AI助手在猜测之前请求澄清,而不会浪费另一个请求。这意味着更少的错误答案、更好的性能和更少的API使用浪费。 -The "Save Configuration" button in the UI primarily saves the current command typed into the command input field and the state of the "Execute automatically on next run" checkbox for the active project. The visibility of the command section is saved automatically when you toggle it. General window size and position are saved when the application closes. +2.一定程度上可替代原有的IDA对话栏,直接使用MCP服务与AI对话 -## Installation (Cursor) +- **💰 减少高级API调用:** 避免浪费昂贵的API调用来基于猜测生成代码。 +- **✅ 更少错误:** 行动前的澄清意味着更少的错误代码和时间浪费。 +- **⏱️ 更快周期:** 快速确认胜过调试错误的猜测。 +- **🎮 更好协作:** 将单向指令转变为对话,让您保持控制。 -![Instalation on Cursor](https://github.com/noopstudios/interactive-feedback-mcp/blob/main/.github/cursor-example.jpg?raw=true) +## 🌟 核心功能与使用技巧 -1. **Prerequisites:** - * Python 3.11 or newer. - * [uv](https://github.com/astral-sh/uv) (Python package manager). Install it with: +### 处理图片 +- **粘贴:** 在反馈窗口的文本输入框中按 `Ctrl+V` (或 `Cmd+V`) 粘贴图片。您可以同时粘贴多张图片和文本。 +- **拖拽:** 直接从文件管理器拖拽图片文件到文本输入框中。 +- **选择:** 点击"选择文件"按钮,通过文件对话框选择图片文件。 +- **图片预览:** 添加的图片会在输入框下方显示可点击的缩略图预览。点击缩略图可以移除对应的图片。 + +### 文件引用 +- **拖拽文件:** 将任意文件从文件管理器拖拽到文本输入框,会生成蓝色的文件引用(如 `@文件名.txt`)。 +- **选择文件:** 点击"选择文件"按钮选择多个文件,支持图片和普通文件的混合选择。 +- **智能处理:** 系统自动识别图片文件和普通文件,分别进行相应的处理和显示。 + +### 常用语 +- **hover预览:** 鼠标悬停在"常用语"按钮上可快速预览所有常用语,支持滚动查看。 +- **快速插入:** 在预览窗口中点击常用语可直接插入到输入框,无需打开管理对话框。 +- **管理:** 点击"常用语"按钮打开管理对话框,可以添加、编辑、删除和排序常用语。 + + + +### 文本优化和增强 +- **一键优化:** 点击"优化"按钮将口语化输入转换为结构化指令,提高AI理解准确性。 +- **自定义增强:** 点击"增强"按钮,可使用自定义提示词对文本进行特定处理。 +- **API配置:** 在设置页面配置OpenAI、Gemini、DeepSeek等AI提供商的API密钥。 +- **撤销功能:** 优化后可使用Ctrl+Z撤销,恢复原始文本内容。 + +### 窗口截图 +- **矩形截图:** 点击截图按钮后,UI窗口自动最小化,可进行矩形区域选择截图。 +- **自动集成:** 截图完成后自动添加到输入内容中,与图片功能无缝集成。 +- **实时预览:** 截图选择过程中提供实时的选择区域预览效果。 + +### 界面布局 +- **布局切换:** 在设置页面可以选择垂直布局(上下分布)或水平布局(左右分布)。 +- **分割器拖拽:** 拖拽分割器手柄可以调整各区域的大小,双击分割器可重置为默认比例。 +- **状态保存:** 布局选择和分割器位置会自动保存,下次启动时恢复。 + +### 显示模式配置 +- **简单模式:** 显示AI处理后的简洁问题,适合快速交互。 +- **完整模式:** 显示AI的原始完整回复内容,适合详细查看。 +- **动态切换:** 可在设置页面实时切换显示模式,立即生效。 + +## 🛠️ 工具 + +此服务器通过模型上下文协议 (MCP) 公开以下工具: + +### `interactive_feedback` +- **功能:** 向用户发起交互式会话,显示提示信息,提供可选选项,并收集用户的文本、图片和文件引用反馈。支持多种交互方式包括文本输入、图片粘贴/拖拽、文件拖拽/选择等。 +- **参数:** + - `message` (str, 可选): 简单模式下显示的简洁问题或提示 + - `full_response` (str, 可选): 完整模式下显示的AI原始完整回复内容 + - `predefined_options` (List[str], 可选): 一个字符串列表,每个字符串代表一个用户可以选择的预定义选项。如果提供,这些选项会显示为复选框。 +- **智能回退机制:** + - **简单模式**:优先使用 `message` 参数,如果为空则自动回退到 `full_response` + - **完整模式**:优先使用 `full_response` 参数,如果为空则自动回退到 `message` + - **实时模式检测**:每次调用都读取最新的用户模式配置,支持动态切换 + - **错误处理**:只有当两个参数都为空时才返回错误,避免不必要的调用失败 +- **用户交互方式:** + - **文本输入**:在主输入框中输入反馈文本 + - **图片处理**:通过Ctrl+V粘贴或拖拽图片文件 + - **文件引用**:通过拖拽文件或点击"选择文件"按钮添加文件引用 + - **常用语**:通过hover预览或管理对话框快速插入预设短语 + + - **布局调整**:通过拖拽分割器调整界面布局 + - **文本优化**:通过优化和增强按钮处理输入文本 + - **窗口截图**:通过截图按钮进行矩形选择截图 +- **返回给AI助手的数据格式:** + 该工具会返回一个包含结构化反馈内容的元组 (Tuple)。元组中的每个元素可以是字符串 (文本反馈或文件引用信息) 或 `fastmcp.Image` 对象 (图片反馈)。 + 具体来说,从UI收集到的数据会转换成以下 `content` 项列表,并由 MCP 服务器进一步处理成 FastMCP兼容的元组: + ```json + // UI返回给MCP服务器的原始JSON结构示例 + { + "content": [ + {"type": "text", "text": "用户的文本反馈..."}, + {"type": "image", "data": "base64_encoded_image_data", "mimeType": "image/jpeg"}, + {"type": "file_reference", "display_name": "@example.txt", "path": "/path/to/local/example.txt"} + // ... 可能有更多项 + ] + } + ``` + * **文本内容** (`type: "text"`):包含用户输入的文本和/或选中的预定义选项组合文本。 + * **图片内容** (`type: "image"`):包含 Base64 编码后的图片数据和图片的 MIME 类型 (如 `image/jpeg`)。这些在 MCP 服务器中会被转换为 `fastmcp.Image` 对象。 + * **文件引用** (`type: "file_reference"`):包含用户拖拽或选择的文件的显示名 (如 `@filename.txt`) 和其在用户本地的完整路径。这些信息通常会作为文本字符串传递给AI助手。 + + **注意:** + * 即便没有任何用户输入(例如用户直接关闭反馈窗口),工具也会返回一个表示"无反馈"的特定消息,如 `("[User provided no feedback]",)`。 + +### `optimize_user_input` +- **功能:** 使用配置的LLM API来优化或增强用户输入的文本,将口语化、可能存在歧义的输入转化为更结构化、更清晰、更便于AI模型理解的文本。 +- **参数:** + - `original_text` (str): **必须参数**。用户的原始输入文本 + - `mode` (str): **必须参数**。优化模式: + - `'optimize'`: 一键优化,使用预设的通用优化指令 + - `'reinforce'`: 提示词强化,使用用户自定义的强化指令 + - `reinforcement_prompt` (str, 可选): 在 'reinforce' 模式下用户的自定义指令 +- **支持的AI提供商:** + - **OpenAI**: GPT-4o-mini 等模型 + - **Google Gemini**: Gemini-2.0-flash 等模型 + - **DeepSeek**: DeepSeek-chat 等模型 + - **火山引擎**: DeepSeek-v3 等模型 +- **返回:** 优化后的文本内容或错误信息 + +## 📦 安装 + +### 方式一:开发安装(推荐) + +**推荐使用开发模式安装,以获得最佳的稳定性和功能完整性。** + +开发模式安装提供: +- ✅ **完整的功能支持和最佳稳定性** +- ✅ **实时的代码更新和bug修复** +- ✅ **完整的资源文件和配置支持** +- ✅ **更好的调试和问题排查能力** +- ✅ **避免PyPI安装可能遇到的资源文件缺失问题** + +**为什么推荐开发模式?** +- PyPI安装可能存在资源文件缺失、配置问题等 +- 开发模式确保所有功能完整可用 +- 可以及时获得最新的功能改进和bug修复 + +1. **先决条件:** + * Python 3.11 或更新版本 + * [uv](https://github.com/astral-sh/uv) (推荐的Python包管理工具) * Windows: `pip install uv` - * Linux/Mac: `curl -LsSf https://astral.sh/uv/install.sh | sh` -2. **Get the code:** - * Clone this repository: - `git clone https://github.com/noopstudios/interactive-feedback-mcp.git` - * Or download the source code. -3. **Navigate to the directory:** - * `cd path/to/interactive-feedback-mcp` -4. **Install dependencies:** - * `uv sync` (this creates a virtual environment and installs packages) -5. **Run the MCP Server:** - * `uv run server.py` -6. **Configure in Cursor:** - * Cursor typically allows specifying custom MCP servers in its settings. You'll need to point Cursor to this running server. The exact mechanism might vary, so consult Cursor's documentation for adding custom MCPs. - * **Manual Configuration (e.g., via `mcp.json`)** - **Remember to change the `/Users/fabioferreira/Dev/scripts/interactive-feedback-mcp` path to the actual path where you cloned the repository on your system.** - - ```json - { - "mcpServers": { - "interactive-feedback-mcp": { - "command": "uv", - "args": [ - "--directory", - "/Users/fabioferreira/Dev/scripts/interactive-feedback-mcp", - "run", - "server.py" - ], - "timeout": 600, - "autoApprove": [ - "interactive_feedback" - ] - } - } - } - ``` - * You might use a server identifier like `interactive-feedback-mcp` when configuring it in Cursor. - -### For Cline / Windsurf - -Similar setup principles apply. You would configure the server command (e.g., `uv run server.py` with the correct `--directory` argument pointing to the project directory) in the respective tool's MCP settings, using `interactive-feedback-mcp` as the server identifier. - -## Development - -To run the server in development mode with a web interface for testing: - -```sh -uv run fastmcp dev server.py + * Linux/macOS: `curl -LsSf https://astral.sh/uv/install.sh | sh` + +2. **获取代码:** + ```bash + git clone https://github.com/pawaovo/interactive-feedback-mcp.git + cd interactive-feedback-mcp + ``` + +3. **安装依赖:** + ```bash + uv pip install -e . + ``` + 或使用现代 uv 语法: + ```bash + uv sync + ``` + +**当前版本:** v2.5.10 - 文档重大更新,推荐开发模式安装;修复UI控件选中状态视觉效果 + +### 方式二:PyPI安装(备选) + +**使用uvx:** +```bash +# 直接运行,无需安装 +uvx interactive-feedback@latest + +# 如果首次安装失败,可以预安装: +uv tool install interactive-feedback@latest +``` + +**使用pip:** +```bash +pip install interactive-feedback +``` + +**注意:** PyPI安装可能存在资源文件缺失或配置问题,推荐使用开发模式安装。 + +## ⚙️ 配置 + +### 方式一:开发模式配置(推荐) + +将以下配置添加到您的 `claude_desktop_config.json` (Claude Desktop) 或 `mcp_servers.json` (Cursor, 通常在 `.cursor-ai/mcp_servers.json` 或用户配置目录中): + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "/path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +**请将 `/path/to/interactive-feedback-mcp` 替换为您实际的项目路径。** + +### 方式二:uvx配置(备选) + +如果您选择使用uvx安装,可以使用以下配置: + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "interactive-feedback@latest" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +### 推荐配置方式:开发模式 + UI 设置 + +**MCP JSON 中配置开发模式,API key 通过 UI 设置页面管理:** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "/path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} ``` -This will open a web interface and allow you to interact with the MCP tools for testing. +**开发模式优势:** +- ✅ **最佳稳定性**:完整的功能支持和资源文件 +- ✅ **实时更新**:可以获得最新的代码修复 +- ✅ **完整功能**:避免PyPI安装可能遇到的问题 +- ✅ **灵活配置**:API key 通过 UI 界面管理 +- ✅ **多提供商**:支持多个 AI 提供商配置和切换 +- ✅ **用户友好**:直观的图形界面配置 + +**优势:** +- ✅ **零安装**:无需手动安装任何依赖 +- ✅ **自动更新**:总是使用最新版本 +- ✅ **灵活配置**:API key 通过 UI 界面管理 +- ✅ **多提供商**:支持多个 AI 提供商配置和切换 +- ✅ **用户友好**:直观的图形界面配置 + +**使用步骤:** +1. 在 MCP JSON 中添加上述配置 +2. 重启 AI 助手 +3. 在 UI 设置页面中配置 API key +4. 开始使用所有功能 + +### 方式二:使用pip安装后配置 + +如果您使用pip安装,配置如下: + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "interactive-feedback", + "timeout": 600, + "autoApprove": [ + "interactive_feedback" + ] + } + } +} +``` -## Available tools +### 方式三:开发模式配置 -Here's an example of how the AI assistant would call the `interactive_feedback` tool: +如果您克隆了仓库进行开发,配置如下: -```xml - - interactive-feedback-mcp - interactive_feedback - - { - "project_directory": "/path/to/your/project", - "summary": "I've implemented the changes you requested and refactored the main module." +**重要提示:** 将 `/path/to/interactive-feedback-mcp` 替换为您在系统上克隆或解压本仓库的 **实际绝对路径**。 +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback" + ] } - - + } +} ``` -## Acknowledgements & Contact +**关于 `command` 和 `args` 的说明:** +- 如果 `uv` 在您的系统路径中,并且您希望 `uv` 管理虚拟环境和运行脚本,可以使用 `"command": "uv", "args": ["run", "interactive-feedback"]`。 +- 如果您更倾向于直接使用系统Python(并已在全局或项目虚拟环境中安装了依赖),可以使用 `"command": "interactive-feedback"` (需要先安装包)。 +- **`cwd` (Current Working Directory):** 强烈建议设置 `cwd` 为此项目的根目录,以确保脚本能正确找到其依赖文件。 + +2. 将以下自定义规则添加到您的AI助手中 (例如,在 Cursor 的设置 -> Rules -> User Rules): + + ```text + Always respond in Chinese-simplified + 你是 IDE 的 AI 编程助手,遵循核心工作流(研究 -> 构思 -> 计划 -> 执行 -> 优化 -> 评审)用中文协助用户,面向专业程序员,交互应简洁专业,避免不必要解释。 + + [沟通守则] + 1. 响应以模式标签 `[模式:X]` 开始,初始为 `[模式:研究]`。 + 2. 核心工作流严格按 `研究 -> 构思 -> 计划 -> 执行 -> 优化 -> 评审` 顺序流转,用户可指令跳转。 + + [核心工作流详解] + 1. `[模式:研究]`:理解需求。 + 2. `[模式:构思]`:提供至少两种可行方案及评估(例如:`方案 1:描述`)。 + 3. `[模式:计划]`:将选定方案细化为详尽、有序、可执行的步骤清单(含原子操作:文件、函数 / 类、逻辑概要;预期结果;新库用 `Context7` 查询)。不写完整代码。完成后用 `interactive-feedback` 请求用户批准。 + 4. `[模式:执行]`:必须用户批准方可执行。严格按计划编码执行。计划简要(含上下文和计划)存入 `./issues/ 任务名.md`。关键步骤后及完成时用 `interactive-feedback` 反馈。 + 5. `[模式:优化]`:在`[模式:执行]完成后,必须自动进行本模式[模式:优化],自动检查并分析本次任务已实现(仅本次对话产生的相关代码),在[模式:执行]下产生的相关代码。聚焦冗余、低效、垃圾代码,提出具体优化建议(含优化理由与预期收益),用户确认后执行相关优化功能。 + 6. `[模式:评审]`:对照计划评估执行结果,报告问题与建议。完成后用 `mcp-feedback-enhanced` 请求用户确认。 + + [快速模式] + `[模式:快速]`:跳过核心工作流,快速响应。完成后用 `interactive-feedback` 请求用户确认。 + + [主动反馈与 MCP 服务] + # MCP interactive-feedback 规则 + 1. 在任何流程、任务、对话进行时,无论是询问、回复、或完成阶段性任务,皆必须调用 MCP interactive-feedback。 + 2. 每当收到用户反馈,若反馈内容非空,必须再次调用 MCP interactive-feedback,并根据反馈内容调整行为。 + 3. 仅当用户明确表示「结束」或「不再需要交互」时,才可停止调用 MCP interactive-feedback,流程才算结束。 + 4. 除非收到结束指令,否则所有步骤都必须重复调用 MCP interactive-feedback。 + 5. 完成任务前,必须使用 MCP interactive-feedback 工具向用户询问反馈。 + * **MCP 服务 **: + * `interactive-feedback`: 用户反馈。 + * `Context7`: 查询最新库文档 / 示例。 + * `DeepWiki`: 查询相关 GitHub 仓库的文档 / 示例。 + * 优先使用 MCP 服务。 + ``` + + 这将确保您的AI助手遵循专业的编程工作流,并在适当时机使用此MCP服务器进行交互式反馈。 + +## 🔧 故障排除 + +如果在安装或配置过程中遇到问题,请参考以下解决方案: + +### uvx安装故障排除 + +**问题1**:首次uvx安装失败,通常由于PySide6等大包下载超时。 + +**解决方案**: +1. **预安装工具**: + ```bash + uv tool install interactive-feedback@latest + ``` + +2. **修改MCP配置**(预安装后): + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + +**配置方式区别**: +- `@latest`:临时运行,每次都下载最新版本 +- 不带版本号:使用已安装的工具,启动更快 + +**问题2**:MCP配置中使用 `"command": "uvx"` 时出现"命令未找到"错误。 + +**解决方案**: + +1. **检查uvx安装位置**: + ```bash + # Windows + where uvx + + # Linux/macOS + which uvx + ``` + +2. **使用完整路径**: + + 将MCP配置中的 `"uvx"` 替换为完整路径,例如: + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "D:/python/Scripts/uv.exe", + "args": ["interactive-feedback@latest"], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + +### MCP配置问题 + +**问题**:AI助手无法识别或启动服务。 + +**解决方案**: + +1. **验证JSON格式**:确保配置文件语法正确 +2. **检查文件位置**:确认 `mcp_servers.json` 在正确目录 +3. **重启AI助手**:修改配置后重启应用程序 +4. **询问AI助手**:将配置文件内容提供给AI,请求配置建议 + +**示例**:在Cursor中询问:"我在配置MCP服务时遇到问题,请帮我检查这个配置:[粘贴您的配置]" + +详细的故障排除指南请参阅 [安装与配置指南.md](./安装与配置指南.md#故障排除)。 + + + +## 🙏 致谢 + +- 原始概念和初步开发由 Fábio Ferreira ([@fabiomlferreira](https://x.com/fabiomlferreira)) 完成。 +- 由 pawa ([@pawaovo](https://github.com/pawaovo)) 进行了功能增强,并借鉴了 [interactive-feedback-mcp](https://github.com/noopstudios/interactive-feedback-mcp) 项目中的一些想法。 +- 当前版本由 pawaovo 维护和进一步开发。 + +## 📄 许可证 -If you find this Interactive Feedback MCP useful, the best way to show appreciation is by following Fábio Ferreira on [X @fabiomlferreira](https://x.com/fabiomlferreira). +此项目使用 MIT 许可证。详情请参阅 `LICENSE` 文件。 -For any questions, suggestions, or if you just want to share how you're using it, feel free to reach out on X! -Also, check out [dotcursorrules.com](https://dotcursorrules.com/) for more resources on enhancing your AI-assisted development workflow. \ No newline at end of file diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..701a5a0 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,29 @@ +Stack trace: +Frame Function Args +0007FFFFB750 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFFA650) msys-2.0.dll+0x1FE8E +0007FFFFB750 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFBA28) msys-2.0.dll+0x67F9 +0007FFFFB750 000210046832 (000210286019, 0007FFFFB608, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFB750 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 +0007FFFFB750 000210068E24 (0007FFFFB760, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 +0007FFFFBA30 00021006A225 (0007FFFFB760, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 +End of stack trace +Loaded modules: +000100400000 bash.exe +7FFFB4850000 ntdll.dll +7FFFB3DC0000 KERNEL32.DLL +7FFFB1CB0000 KERNELBASE.dll +7FFFB43B0000 USER32.dll +7FFFB1BD0000 win32u.dll +7FFFB3D80000 GDI32.dll +7FFFB23B0000 gdi32full.dll +7FFFB1940000 msvcp_win.dll +7FFFB2290000 ucrtbase.dll +000210040000 msys-2.0.dll +7FFFB4120000 advapi32.dll +7FFFB34D0000 msvcrt.dll +7FFFB41E0000 sechost.dll +7FFFB1C80000 bcrypt.dll +7FFFB37C0000 RPCRT4.dll +7FFFB1040000 CRYPTBASE.DLL +7FFFB1C00000 bcryptPrimitives.dll +7FFFB40E0000 IMM32.DLL diff --git a/config.json b/config.json new file mode 100644 index 0000000..08e2601 --- /dev/null +++ b/config.json @@ -0,0 +1,53 @@ +{ + "display_mode": "full", + "enable_custom_options": true, + "submit_method": "ctrl_enter", + "fallback_options": [ + "继续吧,按你的计划来", + "仔细一点,不要出错,不要影响到其他内容", + "我们先讨论一下,给我你的方案和建议,不要直接修改代码", + "null", + "null" + ], + "expression_optimizer": { + "enabled": true, + "active_provider": "gemini", + "prompts": { + "optimize": "你是一个专业的文本优化助手。请将用户的输入文本改写为结构化、逻辑清晰的指令。只需要输出优化后的文本,不要包含任何技术参数、函数定义或元数据信息。", + "reinforce": "你是一个指令执行助手。请严格按照用户提供的'强化指令',对用户提供的'原始文本'进行处理和改写。只输出改写结果,不要包含任何技术信息。" + }, + "performance": { + "timeout_seconds": 30, + "max_retries": 3, + "retry_delay_seconds": 1, + "max_concurrent_requests": 2, + "cache_enabled": true, + "cache_ttl_minutes": 10 + }, + "providers": { + "openai": { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini" + }, + "gemini": { + "api_key": "", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", + "model": "gemini-2.0-flash" + }, + "deepseek": { + "api_key": "", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat" + }, + "volcengine": { + "api_key": "", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "model": "deepseek-v3-250324" + } + } + }, + "version": "3.2", + "created_at": "2025-06-10T12:51:16.371417Z", + "updated_at": "2025-07-17T14:55:54.019797Z" +} \ No newline at end of file diff --git a/config.template.json b/config.template.json new file mode 100644 index 0000000..17c220d --- /dev/null +++ b/config.template.json @@ -0,0 +1,56 @@ +{ + "_comment": "这是配置文件模板,用于 GitHub 提交和用户参考。API key 请通过 UI 设置页面配置,不要在此文件中填写。", + "_note": "This is a config template for GitHub submission and user reference. Please configure API keys through the UI settings page, not in this file.", + "_version_note": "v2.5.9.13+ 默认启用自定义选项功能,解决uv安装用户看不到预定义选项的问题", + "display_mode": "full", + "enable_custom_options": true, + "submit_method": "enter", + "fallback_options": [ + "好的,我明白了", + "请继续", + "需要更多信息", + "返回上一步", + "暂停,让我思考一下" + ], + "expression_optimizer": { + "enabled": true, + "active_provider": "openai", + "prompts": { + "optimize": "你是一个专业的文本优化助手。请将用户的输入文本改写为结构化、逻辑清晰的指令。只需要输出优化后的文本,不要包含任何技术参数、函数定义或元数据信息。", + "reinforce": "你是一个指令执行助手。请严格按照用户提供的'强化指令',对用户提供的'原始文本'进行处理和改写。只输出改写结果,不要包含任何技术信息。" + }, + "performance": { + "timeout_seconds": 30, + "max_retries": 3, + "retry_delay_seconds": 1, + "max_concurrent_requests": 2, + "cache_enabled": true, + "cache_ttl_minutes": 10 + }, + "providers": { + "openai": { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini" + }, + "gemini": { + "api_key": "", + "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", + "model": "gemini-2.0-flash" + }, + "deepseek": { + "api_key": "", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat" + }, + "volcengine": { + "api_key": "", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "model": "deepseek-v3-250324" + } + } + }, + "version": "3.2", + "created_at": "2025-01-01T00:00:00.000000Z", + "updated_at": "2025-01-01T00:00:00.000000Z" +} diff --git a/feedback_ui.py b/feedback_ui.py deleted file mode 100644 index c71e951..0000000 --- a/feedback_ui.py +++ /dev/null @@ -1,581 +0,0 @@ -# Interactive Feedback MCP UI -# Developed by Fábio Ferreira (https://x.com/fabiomlferreira) -# Inspired by/related to dotcursorrules.com (https://dotcursorrules.com/) -import os -import sys -import json -import psutil -import argparse -import subprocess -import threading -import hashlib -from typing import Optional, TypedDict - -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit, QGroupBox -) -from PySide6.QtCore import Qt, Signal, QObject, QTimer, QSettings -from PySide6.QtGui import QTextCursor, QIcon, QKeyEvent, QFont, QFontDatabase, QPalette, QColor - -class FeedbackResult(TypedDict): - command_logs: str - interactive_feedback: str - -class FeedbackConfig(TypedDict): - run_command: str - execute_automatically: bool - -def set_dark_title_bar(widget: QWidget, dark_title_bar: bool) -> None: - # Ensure we're on Windows - if sys.platform != "win32": - return - - from ctypes import windll, c_uint32, byref - - # Get Windows build number - build_number = sys.getwindowsversion().build - if build_number < 17763: # Windows 10 1809 minimum - return - - # Check if the widget's property already matches the setting - dark_prop = widget.property("DarkTitleBar") - if dark_prop is not None and dark_prop == dark_title_bar: - return - - # Set the property (True if dark_title_bar != 0, False otherwise) - widget.setProperty("DarkTitleBar", dark_title_bar) - - # Load dwmapi.dll and call DwmSetWindowAttribute - dwmapi = windll.dwmapi - hwnd = widget.winId() # Get the window handle - attribute = 20 if build_number >= 18985 else 19 # Use newer attribute for newer builds - c_dark_title_bar = c_uint32(dark_title_bar) # Convert to C-compatible uint32 - dwmapi.DwmSetWindowAttribute(hwnd, attribute, byref(c_dark_title_bar), 4) - - # HACK: Create a 1x1 pixel frameless window to force redraw - temp_widget = QWidget(None, Qt.FramelessWindowHint) - temp_widget.resize(1, 1) - temp_widget.move(widget.pos()) - temp_widget.show() - temp_widget.deleteLater() # Safe deletion in Qt event loop - -def get_dark_mode_palette(app: QApplication): - darkPalette = app.palette() - darkPalette.setColor(QPalette.Window, QColor(53, 53, 53)) - darkPalette.setColor(QPalette.WindowText, Qt.white) - darkPalette.setColor(QPalette.Disabled, QPalette.WindowText, QColor(127, 127, 127)) - darkPalette.setColor(QPalette.Base, QColor(42, 42, 42)) - darkPalette.setColor(QPalette.AlternateBase, QColor(66, 66, 66)) - darkPalette.setColor(QPalette.ToolTipBase, QColor(53, 53, 53)) - darkPalette.setColor(QPalette.ToolTipText, Qt.white) - darkPalette.setColor(QPalette.Text, Qt.white) - darkPalette.setColor(QPalette.Disabled, QPalette.Text, QColor(127, 127, 127)) - darkPalette.setColor(QPalette.Dark, QColor(35, 35, 35)) - darkPalette.setColor(QPalette.Shadow, QColor(20, 20, 20)) - darkPalette.setColor(QPalette.Button, QColor(53, 53, 53)) - darkPalette.setColor(QPalette.ButtonText, Qt.white) - darkPalette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(127, 127, 127)) - darkPalette.setColor(QPalette.BrightText, Qt.red) - darkPalette.setColor(QPalette.Link, QColor(42, 130, 218)) - darkPalette.setColor(QPalette.Highlight, QColor(42, 130, 218)) - darkPalette.setColor(QPalette.Disabled, QPalette.Highlight, QColor(80, 80, 80)) - darkPalette.setColor(QPalette.HighlightedText, Qt.white) - darkPalette.setColor(QPalette.Disabled, QPalette.HighlightedText, QColor(127, 127, 127)) - darkPalette.setColor(QPalette.PlaceholderText, QColor(127, 127, 127)) - return darkPalette - -def kill_tree(process: subprocess.Popen): - killed: list[psutil.Process] = [] - parent = psutil.Process(process.pid) - for proc in parent.children(recursive=True): - try: - proc.kill() - killed.append(proc) - except psutil.Error: - pass - try: - parent.kill() - except psutil.Error: - pass - killed.append(parent) - - # Terminate any remaining processes - for proc in killed: - try: - if proc.is_running(): - proc.terminate() - except psutil.Error: - pass - -def get_user_environment() -> dict[str, str]: - if sys.platform != "win32": - return os.environ.copy() - - import ctypes - from ctypes import wintypes - - # Load required DLLs - advapi32 = ctypes.WinDLL("advapi32") - userenv = ctypes.WinDLL("userenv") - kernel32 = ctypes.WinDLL("kernel32") - - # Constants - TOKEN_QUERY = 0x0008 - - # Function prototypes - OpenProcessToken = advapi32.OpenProcessToken - OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] - OpenProcessToken.restype = wintypes.BOOL - - CreateEnvironmentBlock = userenv.CreateEnvironmentBlock - CreateEnvironmentBlock.argtypes = [ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.BOOL] - CreateEnvironmentBlock.restype = wintypes.BOOL - - DestroyEnvironmentBlock = userenv.DestroyEnvironmentBlock - DestroyEnvironmentBlock.argtypes = [wintypes.LPVOID] - DestroyEnvironmentBlock.restype = wintypes.BOOL - - GetCurrentProcess = kernel32.GetCurrentProcess - GetCurrentProcess.argtypes = [] - GetCurrentProcess.restype = wintypes.HANDLE - - CloseHandle = kernel32.CloseHandle - CloseHandle.argtypes = [wintypes.HANDLE] - CloseHandle.restype = wintypes.BOOL - - # Get process token - token = wintypes.HANDLE() - if not OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, ctypes.byref(token)): - raise RuntimeError("Failed to open process token") - - try: - # Create environment block - environment = ctypes.c_void_p() - if not CreateEnvironmentBlock(ctypes.byref(environment), token, False): - raise RuntimeError("Failed to create environment block") - - try: - # Convert environment block to list of strings - result = {} - env_ptr = ctypes.cast(environment, ctypes.POINTER(ctypes.c_wchar)) - offset = 0 - - while True: - # Get string at current offset - current_string = "" - while env_ptr[offset] != "\0": - current_string += env_ptr[offset] - offset += 1 - - # Skip null terminator - offset += 1 - - # Break if we hit double null terminator - if not current_string: - break - - equal_index = current_string.index("=") - if equal_index == -1: - continue - - key = current_string[:equal_index] - value = current_string[equal_index + 1:] - result[key] = value - - return result - - finally: - DestroyEnvironmentBlock(environment) - - finally: - CloseHandle(token) - -class FeedbackTextEdit(QTextEdit): - def __init__(self, parent=None): - super().__init__(parent) - - def keyPressEvent(self, event: QKeyEvent): - if event.key() == Qt.Key_Return and event.modifiers() == Qt.ControlModifier: - # Find the parent FeedbackUI instance and call submit - parent = self.parent() - while parent and not isinstance(parent, FeedbackUI): - parent = parent.parent() - if parent: - parent._submit_feedback() - else: - super().keyPressEvent(event) - -class LogSignals(QObject): - append_log = Signal(str) - -class FeedbackUI(QMainWindow): - def __init__(self, project_directory: str, prompt: str): - super().__init__() - self.project_directory = project_directory - self.prompt = prompt - - self.process: Optional[subprocess.Popen] = None - self.log_buffer = [] - self.feedback_result = None - self.log_signals = LogSignals() - self.log_signals.append_log.connect(self._append_log) - - self.setWindowTitle("Interactive Feedback MCP") - script_dir = os.path.dirname(os.path.abspath(__file__)) - icon_path = os.path.join(script_dir, "images", "feedback.png") - self.setWindowIcon(QIcon(icon_path)) - self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) - - self.settings = QSettings("InteractiveFeedbackMCP", "InteractiveFeedbackMCP") - - # Load general UI settings for the main window (geometry, state) - self.settings.beginGroup("MainWindow_General") - geometry = self.settings.value("geometry") - if geometry: - self.restoreGeometry(geometry) - else: - self.resize(800, 600) - screen = QApplication.primaryScreen().geometry() - x = (screen.width() - 800) // 2 - y = (screen.height() - 600) // 2 - self.move(x, y) - state = self.settings.value("windowState") - if state: - self.restoreState(state) - self.settings.endGroup() # End "MainWindow_General" group - - # Load project-specific settings (command, auto-execute, command section visibility) - self.project_group_name = get_project_settings_group(self.project_directory) - self.settings.beginGroup(self.project_group_name) - loaded_run_command = self.settings.value("run_command", "", type=str) - loaded_execute_auto = self.settings.value("execute_automatically", False, type=bool) - command_section_visible = self.settings.value("commandSectionVisible", False, type=bool) - self.settings.endGroup() # End project-specific group - - self.config: FeedbackConfig = { - "run_command": loaded_run_command, - "execute_automatically": loaded_execute_auto - } - - self._create_ui() # self.config is used here to set initial values - - # Set command section visibility AFTER _create_ui has created relevant widgets - self.command_group.setVisible(command_section_visible) - if command_section_visible: - self.toggle_command_button.setText("Hide Command Section") - else: - self.toggle_command_button.setText("Show Command Section") - - set_dark_title_bar(self, True) - - if self.config.get("execute_automatically", False): - self._run_command() - - def _format_windows_path(self, path: str) -> str: - if sys.platform == "win32": - # Convert forward slashes to backslashes - path = path.replace("/", "\\") - # Capitalize drive letter if path starts with x:\ - if len(path) >= 2 and path[1] == ":" and path[0].isalpha(): - path = path[0].upper() + path[1:] - return path - - def _create_ui(self): - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - - # Toggle Command Section Button - self.toggle_command_button = QPushButton("Show Command Section") - self.toggle_command_button.clicked.connect(self._toggle_command_section) - layout.addWidget(self.toggle_command_button) - - # Command section - self.command_group = QGroupBox("Command") - command_layout = QVBoxLayout(self.command_group) - - # Working directory label - formatted_path = self._format_windows_path(self.project_directory) - working_dir_label = QLabel(f"Working directory: {formatted_path}") - command_layout.addWidget(working_dir_label) - - # Command input row - command_input_layout = QHBoxLayout() - self.command_entry = QLineEdit() - self.command_entry.setText(self.config["run_command"]) - self.command_entry.returnPressed.connect(self._run_command) - self.command_entry.textChanged.connect(self._update_config) - self.run_button = QPushButton("&Run") - self.run_button.clicked.connect(self._run_command) - - command_input_layout.addWidget(self.command_entry) - command_input_layout.addWidget(self.run_button) - command_layout.addLayout(command_input_layout) - - # Auto-execute and save config row - auto_layout = QHBoxLayout() - self.auto_check = QCheckBox("Execute automatically on next run") - self.auto_check.setChecked(self.config.get("execute_automatically", False)) - self.auto_check.stateChanged.connect(self._update_config) - - save_button = QPushButton("&Save Configuration") - save_button.clicked.connect(self._save_config) - - auto_layout.addWidget(self.auto_check) - auto_layout.addStretch() - auto_layout.addWidget(save_button) - command_layout.addLayout(auto_layout) - - # Console section (now part of command_group) - console_group = QGroupBox("Console") - console_layout_internal = QVBoxLayout(console_group) - console_group.setMinimumHeight(200) - - # Log text area - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - font = QFont(QFontDatabase.systemFont(QFontDatabase.FixedFont)) - font.setPointSize(9) - self.log_text.setFont(font) - console_layout_internal.addWidget(self.log_text) - - # Clear button - button_layout = QHBoxLayout() - self.clear_button = QPushButton("&Clear") - self.clear_button.clicked.connect(self.clear_logs) - button_layout.addStretch() - button_layout.addWidget(self.clear_button) - console_layout_internal.addLayout(button_layout) - - command_layout.addWidget(console_group) - - self.command_group.setVisible(False) - layout.addWidget(self.command_group) - - # Feedback section with adjusted height - self.feedback_group = QGroupBox("Feedback") - feedback_layout = QVBoxLayout(self.feedback_group) - - # Short description label (from self.prompt) - self.description_label = QLabel(self.prompt) - self.description_label.setWordWrap(True) - feedback_layout.addWidget(self.description_label) - - self.feedback_text = FeedbackTextEdit() - font_metrics = self.feedback_text.fontMetrics() - row_height = font_metrics.height() - # Calculate height for 5 lines + some padding for margins - padding = self.feedback_text.contentsMargins().top() + self.feedback_text.contentsMargins().bottom() + 5 # 5 is extra vertical padding - self.feedback_text.setMinimumHeight(5 * row_height + padding) - - self.feedback_text.setPlaceholderText("Enter your feedback here (Ctrl+Enter to submit)") - submit_button = QPushButton("&Send Feedback (Ctrl+Enter)") - submit_button.clicked.connect(self._submit_feedback) - - feedback_layout.addWidget(self.feedback_text) - feedback_layout.addWidget(submit_button) - - # Set minimum height for feedback_group to accommodate its contents - # This will be based on the description label and the 5-line feedback_text - self.feedback_group.setMinimumHeight(self.description_label.sizeHint().height() + self.feedback_text.minimumHeight() + submit_button.sizeHint().height() + feedback_layout.spacing() * 2 + feedback_layout.contentsMargins().top() + feedback_layout.contentsMargins().bottom() + 10) # 10 for extra padding - - # Add widgets in a specific order - layout.addWidget(self.feedback_group) - - # Credits/Contact Label - contact_label = QLabel('Need to improve? Contact Fábio Ferreira on X.com or visit dotcursorrules.com') - contact_label.setOpenExternalLinks(True) - contact_label.setAlignment(Qt.AlignCenter) - # Optionally, make font a bit smaller and less prominent - # contact_label_font = contact_label.font() - # contact_label_font.setPointSize(contact_label_font.pointSize() - 1) - # contact_label.setFont(contact_label_font) - contact_label.setStyleSheet("font-size: 9pt; color: #cccccc;") # Light gray for dark theme - layout.addWidget(contact_label) - - def _toggle_command_section(self): - is_visible = self.command_group.isVisible() - self.command_group.setVisible(not is_visible) - if not is_visible: - self.toggle_command_button.setText("Hide Command Section") - else: - self.toggle_command_button.setText("Show Command Section") - - # Immediately save the visibility state for this project - self.settings.beginGroup(self.project_group_name) - self.settings.setValue("commandSectionVisible", self.command_group.isVisible()) - self.settings.endGroup() - - # Adjust window height only - new_height = self.centralWidget().sizeHint().height() - if self.command_group.isVisible() and self.command_group.layout().sizeHint().height() > 0 : - # if command group became visible and has content, ensure enough height - min_content_height = self.command_group.layout().sizeHint().height() + self.feedback_group.minimumHeight() + self.toggle_command_button.height() + layout().spacing() * 2 - new_height = max(new_height, min_content_height) - - current_width = self.width() - self.resize(current_width, new_height) - - def _update_config(self): - self.config["run_command"] = self.command_entry.text() - self.config["execute_automatically"] = self.auto_check.isChecked() - - def _append_log(self, text: str): - self.log_buffer.append(text) - self.log_text.append(text.rstrip()) - cursor = self.log_text.textCursor() - cursor.movePosition(QTextCursor.End) - self.log_text.setTextCursor(cursor) - - def _check_process_status(self): - if self.process and self.process.poll() is not None: - # Process has terminated - exit_code = self.process.poll() - self._append_log(f"\nProcess exited with code {exit_code}\n") - self.run_button.setText("&Run") - self.process = None - self.activateWindow() - self.feedback_text.setFocus() - - def _run_command(self): - if self.process: - kill_tree(self.process) - self.process = None - self.run_button.setText("&Run") - return - - # Clear the log buffer but keep UI logs visible - self.log_buffer = [] - - command = self.command_entry.text() - if not command: - self._append_log("Please enter a command to run\n") - return - - self._append_log(f"$ {command}\n") - self.run_button.setText("Sto&p") - - try: - self.process = subprocess.Popen( - command, - shell=True, - cwd=self.project_directory, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env=get_user_environment(), - text=True, - bufsize=1, - encoding="utf-8", - errors="ignore", - close_fds=True, - ) - - def read_output(pipe): - for line in iter(pipe.readline, ""): - self.log_signals.append_log.emit(line) - - threading.Thread( - target=read_output, - args=(self.process.stdout,), - daemon=True - ).start() - - threading.Thread( - target=read_output, - args=(self.process.stderr,), - daemon=True - ).start() - - # Start process status checking - self.status_timer = QTimer() - self.status_timer.timeout.connect(self._check_process_status) - self.status_timer.start(100) # Check every 100ms - - except Exception as e: - self._append_log(f"Error running command: {str(e)}\n") - self.run_button.setText("&Run") - - def _submit_feedback(self): - self.feedback_result = FeedbackResult( - logs="".join(self.log_buffer), - interactive_feedback=self.feedback_text.toPlainText().strip(), - ) - self.close() - - def clear_logs(self): - self.log_buffer = [] - self.log_text.clear() - - def _save_config(self): - # Save run_command and execute_automatically to QSettings under project group - self.settings.beginGroup(self.project_group_name) - self.settings.setValue("run_command", self.config["run_command"]) - self.settings.setValue("execute_automatically", self.config["execute_automatically"]) - self.settings.endGroup() - self._append_log("Configuration saved for this project.\n") - - def closeEvent(self, event): - # Save general UI settings for the main window (geometry, state) - self.settings.beginGroup("MainWindow_General") - self.settings.setValue("geometry", self.saveGeometry()) - self.settings.setValue("windowState", self.saveState()) - self.settings.endGroup() - - # Save project-specific command section visibility (this is now slightly redundant due to immediate save in toggle, but harmless) - self.settings.beginGroup(self.project_group_name) - self.settings.setValue("commandSectionVisible", self.command_group.isVisible()) - self.settings.endGroup() - - if self.process: - kill_tree(self.process) - super().closeEvent(event) - - def run(self) -> FeedbackResult: - self.show() - QApplication.instance().exec() - - if self.process: - kill_tree(self.process) - - if not self.feedback_result: - return FeedbackResult(logs="".join(self.log_buffer), interactive_feedback="") - - return self.feedback_result - -def get_project_settings_group(project_dir: str) -> str: - # Create a safe, unique group name from the project directory path - # Using only the last component + hash of full path to keep it somewhat readable but unique - basename = os.path.basename(os.path.normpath(project_dir)) - full_hash = hashlib.md5(project_dir.encode('utf-8')).hexdigest()[:8] - return f"{basename}_{full_hash}" - -def feedback_ui(project_directory: str, prompt: str, output_file: Optional[str] = None) -> Optional[FeedbackResult]: - app = QApplication.instance() or QApplication() - app.setPalette(get_dark_mode_palette(app)) - app.setStyle("Fusion") - ui = FeedbackUI(project_directory, prompt) - result = ui.run() - - if output_file and result: - # Ensure the directory exists - os.makedirs(os.path.dirname(output_file) if os.path.dirname(output_file) else ".", exist_ok=True) - # Save the result to the output file - with open(output_file, "w") as f: - json.dump(result, f) - return None - - return result - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Run the feedback UI") - parser.add_argument("--project-directory", default=os.getcwd(), help="The project directory to run the command in") - parser.add_argument("--prompt", default="I implemented the changes you requested.", help="The prompt to show to the user") - parser.add_argument("--output-file", help="Path to save the feedback result as JSON") - args = parser.parse_args() - - result = feedback_ui(args.project_directory, args.prompt, args.output_file) - if result: - print(f"\nLogs collected: \n{result['logs']}") - print(f"\nFeedback received:\n{result['interactive_feedback']}") - sys.exit(0) diff --git a/images/attribution.txt b/images/attribution.txt deleted file mode 100644 index 25b552d..0000000 --- a/images/attribution.txt +++ /dev/null @@ -1 +0,0 @@ -Feedback icons created by Freepik - Flaticon diff --git a/images/feedback.png b/images/feedback.png deleted file mode 100644 index f4070a7..0000000 Binary files a/images/feedback.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index e8752ed..77bc212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,69 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + [project] -name = "interactive-feedback-mcp" -version = "0.1.0" -description = "MCP server for interactive user feedback and command execution in AI-assisted development, by Fábio Ferreira." +name = "interactive-feedback" +version = "2.5.10.3" +authors = [ + { name="Fábio Ferreira" }, + { name="Pau Oliva" }, + { name="pawa", email="pawaovo@example.com" }, +] +description = "Enhanced MCP server for interactive user feedback with rich file drag-drop, smart canned responses preview, and optimized UI experience in AI-assisted development." readme = "README.md" +license = "MIT" requires-python = ">=3.11" +keywords = ["mcp", "ai", "feedback", "interactive", "cursor", "claude"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development", + "Topic :: Communications", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] dependencies = [ "fastmcp>=2.0.0", "psutil>=7.0.0", - "pyside6>=6.8.2.1", + "PySide6-Essentials>=6.8.2.1", + "pyperclip>=1.8.2", + "Pillow>=9.0.0", + 'pywin32>=228; sys_platform == "win32"', + "openai>=1.0.0", +] + +[project.optional-dependencies] +audio = [ + # playsound removed due to build issues - using native audio backends instead +] +dev = [ + "black", + "pre-commit", + "build", + "twine", ] + +[project.urls] +Homepage = "https://github.com/pawaovo/interactive-feedback-mcp" +Repository = "https://github.com/pawaovo/interactive-feedback-mcp" +Issues = "https://github.com/pawaovo/interactive-feedback-mcp/issues" +Documentation = "https://github.com/pawaovo/interactive-feedback-mcp#readme" + +[project.scripts] +interactive-feedback = "interactive_feedback_server.cli:main" +feedback-server = "interactive_feedback_server.cli:main" +feedback-ui = "feedback_ui.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"feedback_ui.resources" = ["*.qrc", "*.py"] +"feedback_ui.resources.sounds" = ["*.wav", "*.mp3", "*.ogg", "*.flac", "*.aac"] +"feedback_ui.resources.translations" = ["*.qm", "*.ts"] +"feedback_ui.styles" = ["*.qss"] +"feedback_ui.images" = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg"] diff --git a/server.py b/server.py deleted file mode 100644 index f0070f0..0000000 --- a/server.py +++ /dev/null @@ -1,73 +0,0 @@ -# Interactive Feedback MCP -# Developed by Fábio Ferreira (https://x.com/fabiomlferreira) -# Inspired by/related to dotcursorrules.com (https://dotcursorrules.com/) -import os -import sys -import json -import tempfile -import subprocess - -from typing import Annotated, Dict - -from fastmcp import FastMCP -from pydantic import Field - -# The log_level is necessary for Cline to work: https://github.com/jlowin/fastmcp/issues/81 -mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR") - -def launch_feedback_ui(project_directory: str, summary: str) -> dict[str, str]: - # Create a temporary file for the feedback result - with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: - output_file = tmp.name - - try: - # Get the path to feedback_ui.py relative to this script - script_dir = os.path.dirname(os.path.abspath(__file__)) - feedback_ui_path = os.path.join(script_dir, "feedback_ui.py") - - # Run feedback_ui.py as a separate process - # NOTE: There appears to be a bug in uv, so we need - # to pass a bunch of special flags to make this work - args = [ - sys.executable, - "-u", - feedback_ui_path, - "--project-directory", project_directory, - "--prompt", summary, - "--output-file", output_file - ] - result = subprocess.run( - args, - check=False, - shell=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - close_fds=True - ) - if result.returncode != 0: - raise Exception(f"Failed to launch feedback UI: {result.returncode}") - - # Read the result from the temporary file - with open(output_file, 'r') as f: - result = json.load(f) - os.unlink(output_file) - return result - except Exception as e: - if os.path.exists(output_file): - os.unlink(output_file) - raise e - -def first_line(text: str) -> str: - return text.split("\n")[0].strip() - -@mcp.tool() -def interactive_feedback( - project_directory: Annotated[str, Field(description="Full path to the project directory")], - summary: Annotated[str, Field(description="Short, one-line summary of the changes")], -) -> Dict[str, str]: - """Request interactive feedback for a given project directory and summary""" - return launch_feedback_ui(first_line(project_directory), first_line(summary)) - -if __name__ == "__main__": - mcp.run(transport="stdio") diff --git a/src/feedback_ui/__init__.py b/src/feedback_ui/__init__.py new file mode 100644 index 0000000..f14c9ee --- /dev/null +++ b/src/feedback_ui/__init__.py @@ -0,0 +1,21 @@ +# feedback_ui/__init__.py +# This file makes the 'feedback_ui' directory a Python package. +# 这个文件使得 'feedback_ui' 目录成为一个 Python 包。 + +# You can make key classes or functions available directly when importing the package: +# 如果希望在导入 feedback_ui 包时可以直接访问某些核心类或函数,可以在这里导入它们: +# For example: +# from .main_window import FeedbackUI +# from .utils.constants import FeedbackResult, ContentItem + +# This allows imports like: +# from feedback_ui import FeedbackUI +# +# Instead of: +# from feedback_ui.main_window import FeedbackUI + +# For now, let's keep it minimal. Users of the package will import from submodules. +# 目前,我们保持最小化。包的使用者将从子模块导入。 +__version__ = "2.5.5" # (可选) 包版本 (Optional: package version) + +# print(f"反馈UI包已加载 (Feedback UI package loaded) - version {__version__}") diff --git a/src/feedback_ui/cli.py b/src/feedback_ui/cli.py new file mode 100644 index 0000000..61b92b5 --- /dev/null +++ b/src/feedback_ui/cli.py @@ -0,0 +1,262 @@ +# cli.py (Application Entry Point / 应用程序入口点) +import sys +import os +import json +import argparse +from typing import Optional, List + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QTranslator, QLocale + +# --- 从 feedback_ui 包导入 (Imports from the feedback_ui package) --- +# Note: Changed to relative imports as this is now part of the package +from .main_window import FeedbackUI +from .utils.style_manager import apply_theme +from .utils.settings_manager import SettingsManager +from .utils.constants import FeedbackResult + +# Import the compiled resources +# This should work as long as it's in the same package directory +from . import resources_rc + +# (可选) 设置高DPI缩放,如果需要 (Optional: Set High DPI scaling if needed) +# QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) +# QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) + + +def start_feedback_tool( + prompt: str, + predefined_options: Optional[List[str]] = None, + output_file_path: Optional[str] = None, +) -> Optional[FeedbackResult]: + """ + Initializes and runs the Feedback UI application. + 初始化并运行反馈UI应用程序。 + + Args: + prompt (str): The main question or prompt for the user. + (用户的主要问题或提示。) + predefined_options (Optional[List[str]]): A list of predefined choices for the user. + (为用户预定义选项的列表。) + output_file_path (Optional[str]): Path to save the feedback result as JSON. If None, result is returned. + (将反馈结果保存为JSON的路径。如果为None,则返回结果。) + + Returns: + Optional[FeedbackResult]: The feedback collected from the user, or None if UI was quit unexpectedly. + (从用户收集的反馈,如果UI意外退出则为None。) + """ + app = QApplication.instance() # Check if an instance already exists + if not app: # Create one if not + app = QApplication(sys.argv) + + # 应用全局样式和调色板 (Apply global styles and palette) + settings = SettingsManager() + initial_theme = settings.get_current_theme() + apply_theme(app, initial_theme) + app.setQuitOnLastWindowClosed(True) # Ensure app exits when main window closes + + # 创建并设置全局翻译器 + translator = setup_translator(settings.get_current_language()) + if translator: + app.installTranslator(translator) + + if predefined_options is None: + predefined_options = [] + + ui_window = FeedbackUI(prompt, predefined_options) + collected_result = ( + ui_window.run_ui_and_get_result() + ) # This will block until UI closes + + if output_file_path and collected_result: + # 确保输出目录存在 (Ensure output directory exists) + output_dir = os.path.dirname(output_file_path) + if output_dir and not os.path.exists(output_dir): + try: + os.makedirs(output_dir, exist_ok=True) + except OSError as e: + print(f"错误: 无法创建输出目录 '{output_dir}': {e}", file=sys.stderr) + print( + f"(Error: Could not create output directory '{output_dir}': {e})", + file=sys.stderr, + ) + # Decide if to proceed without saving or raise error + + try: + with open(output_file_path, "w", encoding="utf-8") as f: + # ensure_ascii=False for proper non-ASCII char handling (like Chinese) + # indent=2 for pretty printing + json.dump(collected_result, f, ensure_ascii=False, indent=2) + print(f"反馈结果已保存到: {output_file_path}") + print(f"(Feedback result saved to: {output_file_path})") + # If saving to file, the server script usually doesn't need the direct result back + # return None + except IOError as e: + print(f"错误: 无法写入输出文件 '{output_file_path}': {e}", file=sys.stderr) + print( + f"(Error: Could not write to output file '{output_file_path}': {e})", + file=sys.stderr, + ) + # Fall through to return result if saving failed, so it's not lost + + return collected_result + + +def setup_translator(lang_code: str) -> Optional[QTranslator]: + """ + 设置应用程序的翻译器 + Setup the application translator based on language code + """ + if not lang_code or lang_code == "zh_CN": # 默认中文不需要翻译 + print("应用程序使用默认中文语言") + return None + + translator = QTranslator() + + # 尝试从Qt资源系统加载翻译文件 + # Try to load translation file from Qt resource system + if translator.load(f":/translations/{lang_code}.qm"): + print(f"应用程序成功加载 {lang_code} 语言翻译") + return translator + else: + print(f"警告:无法从资源系统加载 {lang_code} 翻译文件。将使用默认语言。") + print( + f"Warning: Could not load {lang_code} translation from resource system. Using default language." + ) + return None + + +def main(): + """Main function to run the command-line interface.""" + parser = argparse.ArgumentParser( + description="运行交互式反馈UI (Run Interactive Feedback UI)" + ) + parser.add_argument( + "--prompt", + default="我已根据您的要求实施了更改。(I have implemented the changes you requested.)", + help="向用户显示的提示信息 (The prompt to show to the user)", + ) + parser.add_argument( + "--predefined-options", + default="", + help="用 '|||' 分隔的预定义选项列表 (Pipe-separated list of predefined options, e.g., \"Opt1|||Opt2\")", + ) + parser.add_argument( + "--output-file", + help="将反馈结果保存为JSON的文件路径 (Path to save the feedback result as JSON)", + ) + # --debug flag from original script seems unused internally for UI, but kept for interface consistency + parser.add_argument( + "--debug", + action="store_true", + help="启用调试模式 (Enable debug mode - currently no specific UI effect)", + ) + # --full-ui flag for demo purposes + parser.add_argument( + "--full-ui", + action="store_true", + default=False, + help="显示包含所有功能的完整UI界面 (演示目的) (Show full UI with all features for demo)", + ) + args = parser.parse_args() + + # Process predefined options with V3.2 three-layer fallback logic + options_list: List[str] = [] + if args.predefined_options: + options_list = [ + opt.strip() for opt in args.predefined_options.split("|||") if opt.strip() + ] + elif args.full_ui: # Demo options if --full-ui is used and no options provided + options_list = [ + "这是一个很棒的功能! (This is a great feature!)", + "我发现了一个小问题... (I found a small issue...)", + "可以考虑增加... (Could you consider adding...)", + ] + + # V3.2 简化的三层回退逻辑 - uv安装兼容版本 + try: + # 简化导入策略:优先使用标准包导入 + config = None + resolve_final_options = None + + # 策略1:标准包导入(uv安装模式) + try: + from interactive_feedback_server.utils.rule_engine import ( + resolve_final_options, + ) + from interactive_feedback_server.utils.config_manager import get_config + + config = get_config() + print("使用标准包导入模式", file=sys.stderr) + except ImportError as e1: + print(f"标准包导入失败: {e1}", file=sys.stderr) + + # 策略2:开发模式导入 + try: + current_file = os.path.abspath(__file__) + feedback_ui_dir = os.path.dirname(current_file) + src_dir = os.path.dirname(feedback_ui_dir) + project_root = os.path.dirname(src_dir) + + if project_root not in sys.path: + sys.path.insert(0, project_root) + + from src.interactive_feedback_server.utils.rule_engine import ( + resolve_final_options, + ) + from src.interactive_feedback_server.utils.config_manager import ( + get_config, + ) + + config = get_config() + print("使用开发模式导入", file=sys.stderr) + except ImportError as e2: + print(f"开发模式导入失败: {e2}", file=sys.stderr) + # 导入失败,使用基础选项 + resolve_final_options = None + config = None + + # 如果成功导入,使用规则引擎 + if resolve_final_options and config: + ai_options_for_engine = options_list if options_list else None + final_options = resolve_final_options( + ai_options=ai_options_for_engine, + text=args.prompt, + config=config, + ) + if final_options: + options_list = final_options + print( + f"规则引擎处理完成,最终选项数量: {len(options_list)}", + file=sys.stderr, + ) + else: + print("规则引擎不可用,使用基础选项", file=sys.stderr) + + except Exception as e: + print(f"选项处理失败: {e}", file=sys.stderr) + + # 最终保底选项 + if not options_list: + options_list = ["继续", "取消", "需要帮助"] + print("使用保底选项", file=sys.stderr) + + final_result = start_feedback_tool(args.prompt, options_list, args.output_file) + + # If not saving to a file, print the result to stdout for the calling process (e.g., server.py) + if final_result and not args.output_file: + # Standard way to output JSON for inter-process communication is compact + # Pretty print for direct human reading if needed, but server might expect compact + # json.dump(final_result, sys.stdout, ensure_ascii=False) # Compact JSON to stdout + + # For demonstration or direct script run, pretty print: + pretty_result = json.dumps(final_result, indent=2, ensure_ascii=False) + print("\n--- 反馈UI结果 (Feedback UI Result) ---") + print(pretty_result) + print("--- 结束结果 (End Result) ---\n") + + sys.exit(0) # Successful exit + + +if __name__ == "__main__": + main() diff --git a/src/feedback_ui/dialogs/__init__.py b/src/feedback_ui/dialogs/__init__.py new file mode 100644 index 0000000..995c1bb --- /dev/null +++ b/src/feedback_ui/dialogs/__init__.py @@ -0,0 +1,3 @@ +# feedback_ui/dialogs/__init__.py +# This file makes the 'dialogs' directory a Python package. +# 这个文件使得 'dialogs' 目录成为一个 Python 包。 diff --git a/src/feedback_ui/dialogs/draggable_list_widget.py b/src/feedback_ui/dialogs/draggable_list_widget.py new file mode 100644 index 0000000..4231338 --- /dev/null +++ b/src/feedback_ui/dialogs/draggable_list_widget.py @@ -0,0 +1,121 @@ +# feedback_ui/dialogs/draggable_list_widget.py +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import ( + QDropEvent, + QKeyEvent, + QMouseEvent, + QShowEvent, +) # Added missing imports +from PySide6.QtWidgets import QLabel, QListWidget, QWidget + + +class DraggableListWidget(QListWidget): + """ + A QListWidget that supports internal drag-and-drop to reorder items. + It also emits a signal when an item is double-clicked. + + 一个支持内部拖放以重新排序项目的 QListWidget。 + 它还在项目被双击时发出信号。 + """ + + drag_completed = Signal() # Emitted after a drag-and-drop operation is completed + # 拖放操作完成后发出 + item_double_clicked = Signal( + str + ) # Emitted with the text of the double-clicked item + # 发出双击项目的文本 + + def __init__(self, parent: QWidget = None): + super().__init__(parent) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDragDropMode( + QListWidget.DragDropMode.InternalMove + ) # Items can be moved within the list + self.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setAlternatingRowColors(True) # Improves readability + self.setCurrentRow(-1) # No item selected by default + self.setIconSize(QSize(32, 32)) # Default icon size, can be overridden + + self._drag_start_position = None + + def showEvent(self, event: QShowEvent): # Corrected type hint + """Clears selection when the widget is shown.""" + super().showEvent(event) + self.clearSelection() + self.setCurrentItem(None) + + def mouseDoubleClickEvent(self, event: QMouseEvent): # Corrected type hint + """Handles double-click events on list items.""" + item = self.itemAt(event.position().toPoint()) # event.pos() is QPointF + if item: + item_widget = self.itemWidget( + item + ) # Assuming custom widgets are set for items + if item_widget: + # Attempt to find a QLabel within the item_widget to get its text + # 尝试在 item_widget 中找到 QLabel 以获取其文本 + # This assumes a specific structure for item widgets. + # 这假定项目小部件具有特定结构。 + text_label = item_widget.findChild(QLabel) + if text_label: + self.item_double_clicked.emit(text_label.text()) + event.accept() + return + super().mouseDoubleClickEvent(event) + + def mousePressEvent(self, event: QMouseEvent): # Corrected type hint + """Stores the starting position of a potential drag operation.""" + if event.button() == Qt.MouseButton.LeftButton: + self._drag_start_position = event.position().toPoint() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): # Corrected type hint + """ + Initiates a drag operation if the mouse moves beyond a certain threshold + while the left button is pressed. Qt's default drag initiation handles this + when setDragEnabled(True) is used. This method can be simplified or removed + if default behavior is sufficient. + + 如果鼠标在按下左键的情况下移动超过某个阈值,则启动拖动操作。 + 当使用 setDragEnabled(True) 时,Qt 的默认拖动启动会处理此问题。 + 如果默认行为足够,则可以简化或删除此方法。 + """ + # Qt's default drag handling with setDragEnabled(True) is usually sufficient. + # This explicit check might be redundant unless custom drag data is needed. + # if not (event.buttons() & Qt.MouseButton.LeftButton): + # return super().mouseMoveEvent(event) + # if not self._drag_start_position: + # return super().mouseMoveEvent(event) + + # manhattan_length = (event.position().toPoint() - self._drag_start_position).manhattanLength() + # if manhattan_length < QApplication.startDragDistance(): + # return super().mouseMoveEvent(event) + + # If we reach here, a drag should start. Qt handles this internally. + # Calling super() is important for the default drag to begin. + super().mouseMoveEvent(event) + + def dropEvent(self, event: QDropEvent): # Corrected type hint + """Handles the drop event, clears selection, and emits drag_completed signal.""" + super().dropEvent(event) # Allow Qt to handle the internal move + # Clear selection after the drop to avoid a lingering selected item + # QTimer.singleShot(0, self.clearSelection) # Clear selection in the next event loop cycle + self.setCurrentRow(-1) # More direct way to clear selection focus + self.drag_completed.emit() + event.acceptProposedAction() + + def keyPressEvent(self, event: QKeyEvent): # Added keyPressEvent + """Handle key presses, e.g., Enter to trigger double click action.""" + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + current_item = self.currentItem() + if current_item: + item_widget = self.itemWidget(current_item) + if item_widget: + text_label = item_widget.findChild(QLabel) + if text_label: + self.item_double_clicked.emit(text_label.text()) + event.accept() + return + super().keyPressEvent(event) diff --git a/src/feedback_ui/dialogs/manage_canned_responses_dialog.py b/src/feedback_ui/dialogs/manage_canned_responses_dialog.py new file mode 100644 index 0000000..7864f99 --- /dev/null +++ b/src/feedback_ui/dialogs/manage_canned_responses_dialog.py @@ -0,0 +1,272 @@ +# feedback_ui/dialogs/manage_canned_responses_dialog.py +from PySide6.QtCore import QEvent, QObject, Qt # Added QObject, QEvent +from PySide6.QtWidgets import ( + QDialog, + QGroupBox, + QHBoxLayout, + QLineEdit, + QListWidget, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from ..utils.settings_manager import SettingsManager # Relative import + + +class ManageCannedResponsesDialog(QDialog): + """ + Dialog for managing a list of canned text responses. + Allows adding, updating, deleting, and clearing responses. + + 用于管理常用文本回复列表的对话框。 + 允许添加、更新、删除和清空回复。 + """ + + def __init__(self, parent: QObject = None): # parent should be QWidget for dialogs + super().__init__(parent) + self.setWindowTitle(self.tr("管理常用语")) + self.resize(500, 500) + self.setMinimumSize(400, 400) + self.setWindowModality( + Qt.WindowModality.ApplicationModal + ) # Ensures it blocks parent window + + self.settings_manager = SettingsManager(self) # Can be passed or instantiated + + self._create_ui() + self._load_responses_from_settings() + + def _create_ui(self): + """Creates the UI elements for the dialog.""" + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(18, 18, 18, 18) + main_layout.setSpacing(18) + + # 移除描述标签,直接显示列表 + self.responses_list_widget = QListWidget() + self.responses_list_widget.setAlternatingRowColors(True) + self.responses_list_widget.itemClicked.connect(self._on_list_item_selected) + + # 设置列表的最小高度,确保能显示5个项目 + # 假设每个项目高度约30px,加上边距和滚动条空间 + self.responses_list_widget.setMinimumHeight(180) # 5 * 30 + 30 (边距) + + main_layout.addWidget(self.responses_list_widget) + + # --- Edit Group --- + # 移除分组框标题,简化界面 + edit_group = QGroupBox() # 不设置标题 + edit_layout = QVBoxLayout(edit_group) + edit_layout.setContentsMargins(12, 15, 12, 15) + edit_layout.setSpacing(12) + + self.input_field = QLineEdit() + self.input_field.setPlaceholderText(self.tr("输入新的常用语或编辑选中的项目")) + self.input_field.returnPressed.connect( + self._add_or_update_response + ) # Add/Update on Enter + edit_layout.addWidget(self.input_field) + + buttons_layout = QHBoxLayout() + buttons_layout.setSpacing(12) + + self.add_button = QPushButton(self.tr("添加")) + self.add_button.clicked.connect(self._add_new_response) + self.add_button.setObjectName("secondary_button") + buttons_layout.addWidget(self.add_button) + + self.update_button = QPushButton(self.tr("更新")) + self.update_button.clicked.connect(self._update_selected_response) + self.update_button.setEnabled(False) # Disabled until an item is selected + self.update_button.setObjectName("secondary_button") + buttons_layout.addWidget(self.update_button) + + self.delete_button = QPushButton(self.tr("删除")) + self.delete_button.clicked.connect(self._delete_selected_response) + self.delete_button.setEnabled(False) # Disabled until an item is selected + self.delete_button.setObjectName( + "secondary_button" + ) # Could have a more destructive style + buttons_layout.addWidget(self.delete_button) + + self.clear_all_button = QPushButton(self.tr("清空全部")) + self.clear_all_button.clicked.connect(self._clear_all_responses) + self.clear_all_button.setObjectName("secondary_button") + buttons_layout.addWidget(self.clear_all_button) + + edit_layout.addLayout(buttons_layout) + main_layout.addWidget(edit_group) + + # --- Dialog Buttons --- + dialog_buttons_layout = QHBoxLayout() + dialog_buttons_layout.addStretch(1) # Push button to the right + + self.close_dialog_button = QPushButton(self.tr("关闭")) + self.close_dialog_button.clicked.connect( + self.accept + ) # accept() closes dialog and signals acceptance + self.close_dialog_button.setObjectName("secondary_button") + dialog_buttons_layout.addWidget(self.close_dialog_button) + main_layout.addLayout(dialog_buttons_layout) + + def _load_responses_from_settings(self): + """Loads canned responses from settings and populates the list widget.""" + responses = self.settings_manager.get_canned_responses() + self.responses_list_widget.clear() + if responses: + for response_text in responses: + self.responses_list_widget.addItem(response_text) + self._update_button_states() + + def _save_responses_to_settings(self): + """Saves the current list of responses to settings.""" + responses = [] + for i in range(self.responses_list_widget.count()): + item = self.responses_list_widget.item(i) + if item: # Should always be an item + responses.append(item.text()) + self.settings_manager.set_canned_responses(responses) + + def _on_list_item_selected(self, item): # item is QListWidgetItem + """Handles selection of an item in the list.""" + if item: + self.input_field.setText(item.text()) + else: # Should not happen with itemClicked if list is not empty + self.input_field.clear() + self._update_button_states() + + def _add_or_update_response(self): + """Adds a new response or updates the selected one when Enter is pressed in input field.""" + if self.responses_list_widget.currentItem() and self.update_button.isEnabled(): + self._update_selected_response() + else: + self._add_new_response() + + def _add_new_response(self): + """Adds a new response from the input field to the list.""" + text = self.input_field.text().strip() + if not text: + QMessageBox.warning(self, self.tr("输入无效"), self.tr("常用语不能为空。")) + return + + # Check for duplicates + items = self.responses_list_widget.findItems(text, Qt.MatchFlag.MatchExactly) + if items: + QMessageBox.warning(self, self.tr("重复项"), self.tr("此常用语已存在。")) + return + + self.responses_list_widget.addItem(text) + self._save_responses_to_settings() + self.input_field.clear() + self.responses_list_widget.setCurrentRow( + self.responses_list_widget.count() - 1 + ) # Select new item + self._update_button_states() + + def _update_selected_response(self): + """Updates the currently selected response with text from the input field.""" + current_item = self.responses_list_widget.currentItem() + if not current_item: + return # Should not happen if update_button is enabled + + new_text = self.input_field.text().strip() + if not new_text: + QMessageBox.warning(self, self.tr("输入无效"), self.tr("常用语不能为空。")) + return + + # Check for duplicates (excluding the current item itself) + for i in range(self.responses_list_widget.count()): + item = self.responses_list_widget.item(i) + if item != current_item and item.text() == new_text: + QMessageBox.warning( + self, self.tr("重复项"), self.tr("此常用语已存在。") + ) + return + + current_item.setText(new_text) + self._save_responses_to_settings() + # self.input_field.clear() # Keep text for further editing if desired + # self.responses_list_widget.clearSelection() # Keep item selected + self._update_button_states() # Update button state might be needed if text becomes empty + + def _delete_selected_response(self): + """Deletes the currently selected response from the list.""" + current_row = ( + self.responses_list_widget.currentRow() + ) # More reliable than currentItem sometimes + if current_row >= 0: + reply = QMessageBox.question( + self, + self.tr("确认删除"), + self.tr("确定要删除此常用语吗?"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, # Default button + ) + if reply == QMessageBox.StandardButton.Yes: + self.responses_list_widget.takeItem(current_row) # Remove item + self._save_responses_to_settings() + self.input_field.clear() + self._update_button_states() + + def _clear_all_responses(self): + """Clears all responses from the list after confirmation.""" + if self.responses_list_widget.count() > 0: + reply = QMessageBox.question( + self, + self.tr("确认清空"), + self.tr("确定要清空所有常用语吗?此操作不可撤销。"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.responses_list_widget.clear() + self._save_responses_to_settings() + self.input_field.clear() + self._update_button_states() + + def _update_button_states(self): + """Updates the enabled state of edit/delete buttons based on selection.""" + has_selection = self.responses_list_widget.currentItem() is not None + self.update_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + self.clear_all_button.setEnabled(self.responses_list_widget.count() > 0) + + # Override accept to ensure settings are saved if dialog is closed via "Close" button + def accept(self): + self._save_responses_to_settings() # Ensure saving before closing + super().accept() + + # Override reject for Esc key or window close button (if not explicitly handled) + def reject(self): + self._save_responses_to_settings() # Also save on reject + super().reject() + + def changeEvent(self, event: QEvent): + """处理语言变化事件""" + if event.type() == QEvent.Type.LanguageChange: + self.retranslateUi() + super().changeEvent(event) + + def retranslateUi(self): + """更新界面上的所有文本""" + self.setWindowTitle(self.tr("管理常用语")) + + # 由于移除了描述标签和分组框标题,直接更新输入框和按钮 + # 更新输入框 + if hasattr(self, "input_field") and self.input_field: + self.input_field.setPlaceholderText( + self.tr("输入新的常用语或编辑选中的项目") + ) + + # 更新按钮 + if hasattr(self, "add_button") and self.add_button: + self.add_button.setText(self.tr("添加")) + if hasattr(self, "update_button") and self.update_button: + self.update_button.setText(self.tr("更新")) + if hasattr(self, "delete_button") and self.delete_button: + self.delete_button.setText(self.tr("删除")) + if hasattr(self, "clear_all_button") and self.clear_all_button: + self.clear_all_button.setText(self.tr("清空全部")) + if hasattr(self, "close_dialog_button") and self.close_dialog_button: + self.close_dialog_button.setText(self.tr("关闭")) diff --git a/src/feedback_ui/dialogs/select_canned_response_dialog.py b/src/feedback_ui/dialogs/select_canned_response_dialog.py new file mode 100644 index 0000000..7c2431d --- /dev/null +++ b/src/feedback_ui/dialogs/select_canned_response_dialog.py @@ -0,0 +1,313 @@ +# feedback_ui/dialogs/select_canned_response_dialog.py + +from PySide6.QtCore import QEvent, QObject, QSize, Qt +from PySide6.QtGui import QFontMetrics, QTextCursor +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidgetItem, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) +from shiboken6 import isValid # 替换sip + +from ..utils.settings_manager import SettingsManager # Relative import +from .draggable_list_widget import DraggableListWidget # Import the custom list widget + +# Forward declaration for type hinting parent window +# FeedbackUI 类型的前向声明 +FeedbackUI = "FeedbackUI" + + +class SelectCannedResponseDialog(QDialog): + """ + Dialog for selecting a canned response, managing the list (add/delete/reorder), + and inserting the selected response into the parent's text edit. + + 用于选择常用回复、管理列表(添加/删除/重新排序)并将所选回复插入父窗口文本编辑器的对话框。 + """ + + def __init__( + self, responses: list[str], parent_window: QObject + ): # parent_window is FeedbackUI + super().__init__(parent_window) # Set parent for modality and context + self.setWindowTitle(self.tr("常用语管理")) + self.resize(500, 500) # 增加高度 + self.setMinimumSize(450, 450) # 增加最小高度 + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.parent_feedback_ui = parent_window # Store reference to the main UI + self.initial_responses = responses[:] # Store a copy of initial responses + self.settings_manager = SettingsManager(self) + + # 双语文本映射 + self.texts = { + "title": {"zh_CN": "常用语管理", "en_US": "Manage Canned Responses"}, + "list_title": {"zh_CN": "常用语列表", "en_US": "Canned Responses List"}, + "hint": { + "zh_CN": "双击插入文本,点击删除按钮移除,拖拽调整顺序。", + "en_US": "Double-click to insert, click delete button, drag to reorder.", + }, + "input_label": { + "zh_CN": "输入新的常用语:", + "en_US": "Enter new canned response:", + }, + "input_placeholder": { + "zh_CN": "输入新的常用语", + "en_US": "Enter new canned response", + }, + "save_button": {"zh_CN": "保存", "en_US": "Save"}, + "close_button": {"zh_CN": "关闭", "en_US": "Close"}, + "delete_button": {"zh_CN": "删除", "en_US": "Delete"}, + "invalid_input": {"zh_CN": "输入无效", "en_US": "Invalid Input"}, + "empty_input_message": { + "zh_CN": "常用语不能为空。", + "en_US": "Canned response cannot be empty.", + }, + "duplicate_title": {"zh_CN": "重复项", "en_US": "Duplicate Item"}, + "duplicate_message": { + "zh_CN": "此常用语已存在。", + "en_US": "This canned response already exists.", + }, + } + + self._create_ui() + self._load_responses_to_list_widget(self.initial_responses) + + # 初始更新文本 + self._update_texts() + + def _create_ui(self): + """Creates the UI elements for the dialog.""" + layout = QVBoxLayout(self) + layout.setSpacing(16) + layout.setContentsMargins(18, 18, 18, 18) + + # 移除标题和提示标签,简化界面 + + self.responses_list_widget = DraggableListWidget(self) + self.responses_list_widget.item_double_clicked.connect( + self._on_list_item_double_clicked + ) + self.responses_list_widget.drag_completed.connect( + self._save_responses_from_list_widget + ) + + # 设置列表的最小高度,确保能显示更多项目 + self.responses_list_widget.setMinimumHeight(250) # 增加列表高度 + + layout.addWidget( + self.responses_list_widget, 1 + ) # Give list widget stretch factor + + # 输入框单独一行,移除标签 + self.input_field = QLineEdit() + # 稍后设置占位符文本 + self.input_field.returnPressed.connect(self._add_new_response_from_input) + layout.addWidget(self.input_field) + + # 底部按钮区域 - 左侧保存,右侧关闭 + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + # 保存按钮(左侧) + self.add_button = QPushButton("") # 稍后设置文本 + self.add_button.clicked.connect(self._add_new_response_from_input) + self.add_button.setObjectName("secondary_button") + button_layout.addWidget(self.add_button) + + # 弹性空间 + button_layout.addStretch() + + # 关闭按钮(右侧) + close_button = QPushButton("") # 稍后设置文本 + close_button.setObjectName("secondary_button") + close_button.clicked.connect(self.accept) # Accept will save and close + button_layout.addWidget(close_button) + + layout.addLayout(button_layout) + + self.close_button = close_button + + def _load_responses_to_list_widget(self, responses: list[str]): + """Populates the list widget with given responses.""" + self.responses_list_widget.clear() + for response_text in responses: + if isinstance(response_text, str) and response_text.strip(): + self._add_item_to_gui_list(response_text) + self.responses_list_widget.setCurrentRow(-1) # No selection + + def _add_item_to_gui_list(self, text: str): + """Adds a single response item (with custom widget) to the DraggableListWidget.""" + item = QListWidgetItem() # Create the item itself + + # Create a custom widget for the item + item_widget = QWidget() + item_layout = QHBoxLayout(item_widget) + item_layout.setContentsMargins(6, 3, 6, 3) + item_layout.setSpacing(8) + + text_label = QLabel(text) + text_label.setWordWrap( + False + ) # Ensure it doesn't wrap to keep item height consistent + text_label.setMaximumWidth( + 350 + ) # Prevent very long text from expanding too much + item_layout.addWidget(text_label, 1) # Label takes available space + + current_language = self.settings_manager.get_current_language() + delete_button = QPushButton(self.texts["delete_button"][current_language]) + delete_button.setFixedSize(40, 25) # Make delete button compact + delete_button.setObjectName( + "delete_canned_item_button" + ) # For specific styling via QSS + # Use lambda to pass the item (or its text) to the delete function + delete_button.clicked.connect( + lambda _, item_to_delete=item: self._delete_response_item(item_to_delete) + ) + item_layout.addWidget(delete_button) + + item_widget.setLayout(item_layout) # Set layout on the custom widget + + # Calculate item height based on content + font_metrics = QFontMetrics(text_label.font()) + text_height = font_metrics.height() + button_height = delete_button.sizeHint().height() + item_height = ( + max(text_height, button_height) + + item_layout.contentsMargins().top() + + item_layout.contentsMargins().bottom() + + 6 + ) # Add some padding + + item.setSizeHint( + QSize(0, item_height) + ) # Width will be managed by list, set height + + self.responses_list_widget.addItem(item) # Add the QListWidgetItem + self.responses_list_widget.setItemWidget( + item, item_widget + ) # Set custom widget for the item + + # 保存按钮引用以便语言切换时更新 + if not hasattr(self, "delete_buttons"): + self.delete_buttons = [] + self.delete_buttons.append(delete_button) + + def _add_new_response_from_input(self): + """Adds a new response from the input field to the list and settings.""" + # 立即获取输入文本 + text_to_add = self.input_field.text().strip() + + # 如果输入为空,静默返回,不显示警告 + if not text_to_add: + return + + current_language = self.settings_manager.get_current_language() + + # Check for duplicates in the current list items + for i in range(self.responses_list_widget.count()): + item = self.responses_list_widget.item(i) + widget = self.responses_list_widget.itemWidget(item) + if widget: + label = widget.findChild(QLabel) + if label and label.text() == text_to_add: + QMessageBox.warning( + self, + self.texts["duplicate_title"][current_language], + self.texts["duplicate_message"][current_language], + ) + return + + self._add_item_to_gui_list(text_to_add) + self._save_responses_from_list_widget() # Save immediately + self.input_field.clear() + + def _delete_response_item(self, item_to_delete: QListWidgetItem): + """Deletes the specified response item from the list and settings.""" + row = self.responses_list_widget.row(item_to_delete) + if row >= 0: + self.responses_list_widget.takeItem(row) # Remove from GUI list + self._save_responses_from_list_widget() # Update settings + + def _on_list_item_double_clicked(self, text_of_item: str): + """Handles double-click on a list item to insert text into parent.""" + if ( + text_of_item + and self.parent_feedback_ui + and hasattr(self.parent_feedback_ui, "text_input") + ): + # 隐藏任何现有的预览窗口 + if hasattr(self.parent_feedback_ui, "_hide_canned_responses_preview"): + self.parent_feedback_ui._hide_canned_responses_preview() + + # Access the text_input QTextEdit widget on the parent FeedbackUI + feedback_text_widget = self.parent_feedback_ui.text_input + if feedback_text_widget: + feedback_text_widget.insertPlainText(text_of_item) + # Optionally, set focus back to the text edit and move cursor + feedback_text_widget.setFocus() + cursor = feedback_text_widget.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + feedback_text_widget.setTextCursor(cursor) + + # self.selected_response = text_of_item # Not strictly needed if action is direct + self.accept() # Close the dialog after insertion + + def _save_responses_from_list_widget(self): + """Saves the current order and content of responses from the list widget to settings.""" + current_responses_in_list = [] + for i in range(self.responses_list_widget.count()): + item = self.responses_list_widget.item(i) + widget = self.responses_list_widget.itemWidget(item) + if widget: + label = widget.findChild(QLabel) + if label: + current_responses_in_list.append(label.text()) + self.settings_manager.set_canned_responses(current_responses_in_list) + + # Override accept and reject to ensure current list state is saved + def accept(self): + self._save_responses_from_list_widget() + super().accept() + + def reject(self): + self._save_responses_from_list_widget() # Also save if rejected (e.g., Esc pressed) + super().reject() + + def changeEvent(self, event: QEvent): + """处理语言变化事件""" + if event.type() == QEvent.Type.LanguageChange: + self._update_texts() + super().changeEvent(event) + + def _update_texts(self): + """根据当前语言设置更新所有文本""" + current_language = self.settings_manager.get_current_language() + + # 更新窗口标题 + self.setWindowTitle(self.texts["title"][current_language]) + + # 由于移除了标题和提示标签,直接更新输入框占位符 + if hasattr(self, "input_field"): + self.input_field.setPlaceholderText( + self.texts["input_placeholder"][current_language] + ) + + # 更新按钮文本 + if hasattr(self, "add_button"): + self.add_button.setText(self.texts["save_button"][current_language]) + + if hasattr(self, "close_button"): + self.close_button.setText(self.texts["close_button"][current_language]) + + # 更新删除按钮 + if hasattr(self, "delete_buttons"): + for button in self.delete_buttons: + if button and isValid(button): + button.setText(self.texts["delete_button"][current_language]) diff --git a/src/feedback_ui/dialogs/settings_dialog.py b/src/feedback_ui/dialogs/settings_dialog.py new file mode 100644 index 0000000..2e3e97f --- /dev/null +++ b/src/feedback_ui/dialogs/settings_dialog.py @@ -0,0 +1,1731 @@ +from PySide6.QtCore import QCoreApplication, QEvent, QTranslator +from PySide6.QtWidgets import ( + QApplication, + QButtonGroup, + QDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QSpinBox, + QVBoxLayout, + QWidget, + QGridLayout, + QSlider, + QFileDialog, +) +from PySide6.QtCore import Qt + +from ..utils.settings_manager import SettingsManager +from ..utils.style_manager import apply_theme + + +def _setup_project_path(): + """设置项目路径到sys.path,避免重复代码""" + import sys + import os + + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + return project_root + + +class ConfigManager: + """配置管理工具类 - 减少重复代码""" + + @staticmethod + def get_optimizer_config(): + """安全获取优化器配置""" + try: + # 兼容包安装模式和开发模式的导入 + try: + from interactive_feedback_server.utils import get_config + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import get_config + + config = get_config() + if "expression_optimizer" not in config: + config["expression_optimizer"] = { + "enabled": False, + "active_provider": "openai", + "providers": {}, + "prompts": {}, + } + return config + except Exception as e: + print(f"获取配置失败: {e}") + return None + + @staticmethod + def save_config(config, operation_name="配置保存"): + """安全保存配置""" + try: + # 兼容包安装模式和开发模式的导入 + try: + from interactive_feedback_server.utils import save_config + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import save_config + + save_config(config) + return True + except Exception as e: + print(f"{operation_name}失败: {e}") + return False + + +class AudioSettingsDialog(QDialog): + """音频设置弹窗""" + + def __init__(self, settings_manager, parent=None): + super().__init__(parent) + self.settings_manager = settings_manager + self.setWindowTitle("音频设置") + self.setModal(True) + self.resize(400, 300) + + # 初始化音频管理器 + try: + from ..utils.audio_manager import get_audio_manager + + self._audio_manager = get_audio_manager() + except Exception: + self._audio_manager = None + + self._setup_ui() + self._apply_enhanced_styling() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # 启用提示音开关 + from ..utils.ui_factory import create_toggle_radio_button + + current_audio_enabled = self.settings_manager.get_audio_enabled() + self.enable_audio_radio = create_toggle_radio_button( + "启用提示音", current_audio_enabled, self._on_audio_enabled_changed + ) + layout.addWidget(self.enable_audio_radio) + + # 音量控制 + volume_layout = QHBoxLayout() + volume_label = QLabel("音量:") + + self.audio_volume_slider = QSlider() + from PySide6.QtCore import Qt + + self.audio_volume_slider.setOrientation(Qt.Orientation.Horizontal) + self.audio_volume_slider.setRange(0, 100) + current_volume = int(self.settings_manager.get_audio_volume() * 100) + self.audio_volume_slider.setValue(current_volume) + self.audio_volume_slider.valueChanged.connect(self._on_audio_volume_changed) + + self.audio_volume_value = QLabel(f"{current_volume}%") + self.audio_volume_value.setFixedWidth(45) # 增加宽度以完全显示"100%" + self.audio_volume_value.setAlignment(Qt.AlignmentFlag.AlignCenter) # 居中对齐 + + volume_layout.addWidget(volume_label) + volume_layout.addWidget(self.audio_volume_slider) + volume_layout.addWidget(self.audio_volume_value) + layout.addLayout(volume_layout) + + # 自定义音频文件 + file_layout = QVBoxLayout() + + # 默认音频文件状态显示 + default_info_layout = QHBoxLayout() + default_label = QLabel("默认音频文件:") + self.default_status_label = QLabel() + self._update_default_audio_status() + + default_info_layout.addWidget(default_label) + default_info_layout.addWidget(self.default_status_label) + default_info_layout.addStretch() + file_layout.addLayout(default_info_layout) + + # 自定义音频文件输入 + custom_layout = QHBoxLayout() + custom_label = QLabel("自定义音频文件:") + + self.custom_sound_edit = QLineEdit() + current_sound_path = self.settings_manager.get_notification_sound_path() + if current_sound_path: + self.custom_sound_edit.setText(current_sound_path) + else: + self.custom_sound_edit.setPlaceholderText("留空使用默认音频文件") + self.custom_sound_edit.textChanged.connect(self._on_custom_sound_changed) + + browse_button = QPushButton("浏览...") + browse_button.clicked.connect(self._browse_sound_file) + + test_button = QPushButton("测试") + test_button.clicked.connect(self._test_sound) + + custom_layout.addWidget(custom_label) + custom_layout.addWidget(self.custom_sound_edit) + custom_layout.addWidget(browse_button) + custom_layout.addWidget(test_button) + file_layout.addLayout(custom_layout) + layout.addLayout(file_layout) + + # 按钮 + button_layout = QHBoxLayout() + ok_button = QPushButton("确定") + ok_button.clicked.connect(self.accept) + cancel_button = QPushButton("取消") + cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(ok_button) + button_layout.addStretch() + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + def _apply_enhanced_styling(self): + """应用增强的控件样式""" + try: + from ..utils.ui_factory import apply_enhanced_control_styling + + current_theme = self.settings_manager.get_current_theme() + apply_enhanced_control_styling(self, current_theme) + except Exception as e: + print(f"应用增强样式失败: {e}") + + def _update_default_audio_status(self): + """更新默认音频文件状态显示""" + if self._audio_manager: + # 获取默认音频文件路径 + default_path = self._audio_manager._get_default_notification_sound() + if default_path: + if default_path.startswith(":/"): + self.default_status_label.setText("✓ 内置音频文件") + self.default_status_label.setStyleSheet("color: green;") + else: + import os + + if os.path.exists(default_path): + self.default_status_label.setText( + f"✓ {os.path.basename(default_path)}" + ) + self.default_status_label.setStyleSheet("color: green;") + else: + self.default_status_label.setText("✗ 文件不存在") + self.default_status_label.setStyleSheet("color: red;") + else: + self.default_status_label.setText("✗ 未找到默认音频") + self.default_status_label.setStyleSheet("color: orange;") + else: + self.default_status_label.setText("✗ 音频管理器未初始化") + self.default_status_label.setStyleSheet("color: red;") + + def _on_audio_enabled_changed(self, enabled): + self.settings_manager.set_audio_enabled(enabled) + + def _on_audio_volume_changed(self, value): + volume = value / 100.0 + self.settings_manager.set_audio_volume(volume) + self.audio_volume_value.setText(f"{value}%") + + def _on_custom_sound_changed(self, path): + self.settings_manager.set_notification_sound_path(path.strip()) + + def _browse_sound_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, + "选择音频文件", + "", + "音频文件 (*.wav *.mp3 *.ogg *.flac *.aac);;WAV文件 (*.wav);;MP3文件 (*.mp3);;所有文件 (*.*)", + ) + if file_path: + self.custom_sound_edit.setText(file_path) + + def _test_sound(self): + """测试音频播放""" + if self._audio_manager: + # 获取自定义音频文件路径 + custom_path = self.custom_sound_edit.text().strip() + # 播放音频 - 修复方法名 + success = self._audio_manager.play_notification_sound( + custom_path if custom_path else None + ) + if not success: + from PySide6.QtWidgets import QMessageBox + + QMessageBox.warning( + self, "音频测试", "音频播放失败,请检查文件路径和格式" + ) + + +class OptimizationSettingsDialog(QDialog): + """输入表达优化设置弹窗""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("输入表达优化") + self.setModal(True) + self.resize(500, 400) + self._setup_ui() + self._apply_enhanced_styling() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # 获取当前优化配置 + config = ConfigManager.get_optimizer_config() + optimizer_config = ( + config.get("expression_optimizer", {}) + if config + else { + "enabled": False, + "active_provider": "openai", + "providers": {}, + "prompts": {}, + } + ) + + # 启用优化功能开关 + from ..utils.ui_factory import create_toggle_radio_button + + self.enable_optimization_radio = create_toggle_radio_button( + "启用优化功能", + optimizer_config.get("enabled", False), + self._on_optimization_toggled, + ) + layout.addWidget(self.enable_optimization_radio) + + # LLM提供商选择 + provider_group = QGroupBox("LLM提供商") + provider_layout = QHBoxLayout() + + self.openai_radio = QRadioButton("OpenAI") + self.gemini_radio = QRadioButton("Google Gemini") + self.deepseek_radio = QRadioButton("DeepSeek") + self.huoshan_radio = QRadioButton("火山引擎") + + # 设置当前选中的提供商 + active_provider = optimizer_config.get("active_provider", "openai") + if active_provider == "openai": + self.openai_radio.setChecked(True) + elif active_provider == "gemini": + self.gemini_radio.setChecked(True) + elif active_provider == "deepseek": + self.deepseek_radio.setChecked(True) + elif active_provider == "volcengine": + self.huoshan_radio.setChecked(True) + + # 连接信号 + self.openai_radio.toggled.connect( + lambda checked: self._on_provider_changed("openai", checked) + ) + self.gemini_radio.toggled.connect( + lambda checked: self._on_provider_changed("gemini", checked) + ) + self.deepseek_radio.toggled.connect( + lambda checked: self._on_provider_changed("deepseek", checked) + ) + self.huoshan_radio.toggled.connect( + lambda checked: self._on_provider_changed("volcengine", checked) + ) + + provider_layout.addWidget(self.openai_radio) + provider_layout.addWidget(self.gemini_radio) + provider_layout.addWidget(self.deepseek_radio) + provider_layout.addWidget(self.huoshan_radio) + provider_group.setLayout(provider_layout) + layout.addWidget(provider_group) + + # API密钥输入 + api_layout = QHBoxLayout() + api_label = QLabel("API密钥:") + self.api_key_edit = QLineEdit() + self.api_key_edit.setEchoMode(QLineEdit.EchoMode.Password) + self.api_key_edit.setPlaceholderText("请输入API密钥") + self.api_key_edit.textChanged.connect(self._on_api_key_changed) + + test_button = QPushButton("测试连接") + test_button.clicked.connect(self._test_api_connection) + + # 加载当前API密钥 + current_provider_config = optimizer_config.get("providers", {}).get( + active_provider, {} + ) + current_api_key = current_provider_config.get("api_key", "") + if current_api_key and not current_api_key.startswith("YOUR_"): + self.api_key_edit.setText(current_api_key) + + api_layout.addWidget(api_label) + api_layout.addWidget(self.api_key_edit) + api_layout.addWidget(test_button) + layout.addLayout(api_layout) + + # 提示词自定义区域 + prompts_group = QGroupBox("提示词设置") + prompts_layout = QVBoxLayout() + + # 获取当前提示词配置 + current_prompts = optimizer_config.get("prompts", {}) + + # 优化提示词设置 + optimize_layout = QHBoxLayout() + optimize_label = QLabel("优化提示词:") + optimize_label.setFixedWidth(80) + + self.optimize_prompt_edit = QLineEdit() + self.optimize_prompt_edit.setPlaceholderText("输入自定义优化提示词...") + optimize_prompt = current_prompts.get("optimize", "") + if optimize_prompt: + self.optimize_prompt_edit.setText(optimize_prompt) + self.optimize_prompt_edit.textChanged.connect(self._on_optimize_prompt_changed) + + optimize_layout.addWidget(optimize_label) + optimize_layout.addWidget(self.optimize_prompt_edit) + prompts_layout.addLayout(optimize_layout) + + # 增强提示词设置 + reinforce_layout = QHBoxLayout() + reinforce_label = QLabel("增强提示词:") + reinforce_label.setFixedWidth(80) + + self.reinforce_prompt_edit = QLineEdit() + self.reinforce_prompt_edit.setPlaceholderText("输入自定义增强提示词...") + reinforce_prompt = current_prompts.get("reinforce", "") + if reinforce_prompt: + self.reinforce_prompt_edit.setText(reinforce_prompt) + self.reinforce_prompt_edit.textChanged.connect( + self._on_reinforce_prompt_changed + ) + + reinforce_layout.addWidget(reinforce_label) + reinforce_layout.addWidget(self.reinforce_prompt_edit) + prompts_layout.addLayout(reinforce_layout) + + prompts_group.setLayout(prompts_layout) + layout.addWidget(prompts_group) + + # 按钮 + button_layout = QHBoxLayout() + ok_button = QPushButton("确定") + ok_button.clicked.connect(self.accept) + cancel_button = QPushButton("取消") + cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(ok_button) + button_layout.addStretch() + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + def _apply_enhanced_styling(self): + """应用增强的控件样式""" + try: + from ..utils.ui_factory import apply_enhanced_control_styling + from ..utils.settings_manager import SettingsManager + + settings_manager = SettingsManager() + current_theme = settings_manager.get_current_theme() + apply_enhanced_control_styling(self, current_theme) + except Exception as e: + print(f"应用增强样式失败: {e}") + + def _on_optimization_toggled(self, checked): + """优化功能开关切换处理""" + config = ConfigManager.get_optimizer_config() + if not config: + return + + config["expression_optimizer"]["enabled"] = checked + + if ConfigManager.save_config(config, "优化功能开关"): + # 通知主窗口更新按钮可见性 + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app: + for widget in app.topLevelWidgets(): + if widget.__class__.__name__ == "FeedbackUI": + if hasattr(widget, "_update_optimization_buttons_visibility"): + widget._update_optimization_buttons_visibility() + break + + def _on_provider_changed(self, provider, checked): + """提供商切换处理""" + if not checked: + return + + config = ConfigManager.get_optimizer_config() + if not config: + return + + config["expression_optimizer"]["active_provider"] = provider + + if ConfigManager.save_config(config, "提供商切换"): + # 更新API密钥输入框 + provider_config = ( + config["expression_optimizer"].get("providers", {}).get(provider, {}) + ) + current_api_key = provider_config.get("api_key", "") + + if current_api_key and not current_api_key.startswith("YOUR_"): + self.api_key_edit.setText(current_api_key) + else: + self.api_key_edit.setText("") + + def _on_api_key_changed(self): + """API密钥变更处理""" + config = ConfigManager.get_optimizer_config() + if not config: + return + + # 获取当前选中的提供商 + active_provider = config["expression_optimizer"].get( + "active_provider", "openai" + ) + + # 确保providers字段存在 + if "providers" not in config["expression_optimizer"]: + config["expression_optimizer"]["providers"] = {} + + # 确保当前提供商配置存在 + if active_provider not in config["expression_optimizer"]["providers"]: + config["expression_optimizer"]["providers"][active_provider] = {} + + # 更新API密钥 + config["expression_optimizer"]["providers"][active_provider][ + "api_key" + ] = self.api_key_edit.text().strip() + ConfigManager.save_config(config, "API密钥保存") + + def _test_api_connection(self): + """API连接测试""" + from PySide6.QtWidgets import QMessageBox, QProgressDialog + + # 显示进度对话框 + progress = QProgressDialog("正在测试连接...", "取消", 0, 0, self) + progress.setWindowTitle("API连接测试") + progress.setModal(True) + progress.show() + + try: + config = ConfigManager.get_optimizer_config() + if not config: + progress.close() + QMessageBox.warning(self, "测试结果", "无法获取配置信息") + return + + # 兼容包安装模式和开发模式的导入 + try: + from interactive_feedback_server.llm.factory import get_llm_provider + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.llm.factory import get_llm_provider + + optimizer_config = config.get("expression_optimizer", {}) + + # 获取provider并测试 + provider, message = get_llm_provider(optimizer_config) + + if provider: + # 测试配置验证 + is_valid, validation_message = provider.validate_config() + + progress.close() + + if is_valid: + QMessageBox.information(self, "测试结果", "✅ 连接成功!") + else: + QMessageBox.warning( + self, "测试结果", f"❌ 连接失败: {validation_message}" + ) + else: + progress.close() + QMessageBox.warning(self, "测试结果", f"❌ 连接失败: {message}") + + except Exception as e: + progress.close() + QMessageBox.critical(self, "测试错误", f"❌ 测试失败: {str(e)}") + + def _on_optimize_prompt_changed(self): + """优化提示词改变处理""" + self._save_prompt_config("optimize", self.optimize_prompt_edit.text()) + + def _on_reinforce_prompt_changed(self): + """增强提示词改变处理""" + self._save_prompt_config("reinforce", self.reinforce_prompt_edit.text()) + + def _save_prompt_config(self, prompt_type: str, value: str): + """保存提示词配置""" + config = ConfigManager.get_optimizer_config() + if not config: + return + + # 确保prompts字段存在 + if "prompts" not in config["expression_optimizer"]: + config["expression_optimizer"]["prompts"] = {} + + # 更新提示词 + config["expression_optimizer"]["prompts"][prompt_type] = value.strip() + ConfigManager.save_config(config, f"{prompt_type}提示词保存") + + # 已删除终端设置对话框类 + + # 已删除终端项组件类 - 第一部分 + + def _load_current_path(self): + """加载当前路径""" + detected_path = self.terminal_manager.get_terminal_command(self.terminal_type) + custom_path = self.settings_manager.get_terminal_path(self.terminal_type) + path_text = custom_path if custom_path else detected_path + self.path_edit.setText(path_text) + self.path_edit.setCursorPosition(0) + + def _apply_theme_style(self): + """应用主题样式""" + current_theme = self.settings_manager.get_current_theme() + if current_theme == "dark": + self.path_edit.setStyleSheet( + "QLineEdit { background-color: #2d2d2d; color: #ffffff; border: 1px solid #555555; padding: 4px; }" + ) + else: + self.path_edit.setStyleSheet( + "QLineEdit { background-color: #ffffff; color: #000000; border: 1px solid #cccccc; padding: 4px; }" + ) + + def _on_radio_changed(self, checked): + """单选按钮状态改变""" + if checked: + self.settings_manager.set_default_terminal_type(self.terminal_type) + + def _on_path_changed(self, text): + """路径改变时的处理""" + self.settings_manager.set_terminal_path(self.terminal_type, text.strip()) + self.path_edit.setCursorPosition(0) # 保持光标在开头 + + def _browse_path(self): + """浏览文件路径""" + from PySide6.QtWidgets import QFileDialog + import os + + current_path = self.path_edit.text().strip() + start_dir = ( + os.path.dirname(current_path) + if current_path and os.path.exists(current_path) + else "" + ) + + file_path, _ = QFileDialog.getOpenFileName( + self, + f"选择 {self.terminal_info['display_name']} 路径", + start_dir, + "可执行文件 (*.exe);;所有文件 (*.*)", + ) + + if file_path: + self.path_edit.setText(file_path) + self.settings_manager.set_terminal_path(self.terminal_type, file_path) + + def get_radio_button(self): + """获取单选按钮,用于按钮组管理""" + return self.radio + + def set_checked(self, checked): + """设置选中状态""" + self.radio.setChecked(checked) + + def update_texts(self, texts, current_lang): + """更新文本""" + if "browse_button" in texts: + self.browse_button.setText(texts["browse_button"][current_lang]) + + # 更新终端名称 + terminal_name_key = f"{self.terminal_type}_name" + if terminal_name_key in texts: + self.radio.setText(texts[terminal_name_key][current_lang]) + + +class SettingsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(self.tr("设置")) + + # Mac系统兼容性设置 + self.setModal(True) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + # 调整窗口大小,确保有足够空间显示所有内容(V4.3 增加高度以容纳提交方式选项) + # V2.5.9.4 修复:进一步增加高度以解决uv安装用户的UI压缩问题 + self.resize(700, 800) + self.setMinimumSize(650, 750) + + self.settings_manager = SettingsManager(self) + self.layout = QVBoxLayout(self) + + # 保存当前翻译器的引用 + self.translator = QTranslator() + # 记录当前语言状态,方便切换时判断 + self.current_language = self.settings_manager.get_current_language() + + # V4.3 优化:缓存配置工具模块,避免重复导入 + self._config_utils = None + self._init_config_utils() + + # 双语文本映射 + self.texts = { + "title": {"zh_CN": "设置", "en_US": "Settings"}, + # 重新组织的设置组 + "theme_layout_group": {"zh_CN": "主题布局", "en_US": "Theme & Layout"}, + "dark_mode": {"zh_CN": "深色模式", "en_US": "Dark Mode"}, + "light_mode": {"zh_CN": "浅色模式", "en_US": "Light Mode"}, + "vertical_layout": {"zh_CN": "上下布局", "en_US": "Vertical Layout"}, + "horizontal_layout": {"zh_CN": "左右布局", "en_US": "Horizontal Layout"}, + "language_font_group": {"zh_CN": "语言字体", "en_US": "Language & Font"}, + "chinese": {"zh_CN": "中文", "en_US": "Chinese"}, + "english": {"zh_CN": "English", "en_US": "English"}, + "prompt_font_size": { + "zh_CN": "提示区文字大小", + "en_US": "Prompt Text Size", + }, + "options_font_size": { + "zh_CN": "选项区文字大小", + "en_US": "Options Text Size", + }, + "input_font_size": {"zh_CN": "输入框文字大小", "en_US": "Input Font Size"}, + # 更多设置相关文本 + "more_settings_group": {"zh_CN": "更多设置", "en_US": "More Settings"}, + "audio_settings_button": {"zh_CN": "音频", "en_US": "Audio"}, + "optimization_settings_button": { + "zh_CN": "输入表达优化", + "en_US": "Input Optimization", + }, + "terminal_settings_button": {"zh_CN": "终端", "en_US": "Terminal"}, + # V3.2 新增:交互模式设置 + "interaction_group": {"zh_CN": "交互模式", "en_US": "Interaction Mode"}, + "simple_mode": {"zh_CN": "精简模式", "en_US": "Simple Mode"}, + "full_mode": {"zh_CN": "完整模式", "en_US": "Full Mode"}, + "simple_mode_desc": { + "zh_CN": "仅显示AI提供的选项", + "en_US": "Show only AI-provided options", + }, + "full_mode_desc": { + "zh_CN": "智能生成选项 + 用户自定义后备", + "en_US": "Smart option generation + custom fallback", + }, + # V4.3 新增:提交方式设置 + "submit_method_group": {"zh_CN": "提交方式", "en_US": "Submit Method"}, + "submit_enter": { + "zh_CN": "Enter键直接提交", + "en_US": "Enter key to submit", + }, + "submit_ctrl_enter": {"zh_CN": "", "en_US": ""}, # 动态设置,基于操作系统 + # V4.0 简化:自定义选项开关 + "enable_custom_options": { + "zh_CN": "启用自定义选项", + "en_US": "Enable Custom Options", + }, + "fallback_options_group": { + "zh_CN": "自定义后备选项", + "en_US": "Custom Fallback Options", + }, + "fallback_options_desc": { + "zh_CN": "当AI未提供选项且无法自动生成时显示的选项:", + "en_US": "Options shown when AI provides none and auto-generation fails:", + }, + "option_label": {"zh_CN": "选项", "en_US": "Option"}, + "expand_options": {"zh_CN": "展开选项设置", "en_US": "Expand Options"}, + "collapse_options": {"zh_CN": "收起选项设置", "en_US": "Collapse Options"}, + } + + self._setup_ui() + + # 初始更新文本 + self._update_texts() + + # 应用增强样式 + self._apply_enhanced_styling() + + def _init_config_utils(self): + """V4.3 优化:初始化配置工具模块,避免重复导入""" + try: + try: + from interactive_feedback_server.utils import ( + get_config, + save_config, + handle_config_error, + ) + + self._config_utils = { + "get_config": get_config, + "save_config": save_config, + "handle_config_error": handle_config_error, + } + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import ( + get_config, + save_config, + handle_config_error, + ) + + self._config_utils = { + "get_config": get_config, + "save_config": save_config, + "handle_config_error": handle_config_error, + } + except Exception as e: + print(f"初始化配置工具失败: {e}") + self._config_utils = None + + def _setup_ui(self): + self._setup_theme_layout_group() # 整合主题和布局 + self._setup_language_font_group() # 整合语言和字体 + self._setup_interaction_group() # V3.2 新增 + self._setup_more_settings_group() # 新增:更多设置 + + # 添加 OK 和 Cancel 按钮 - 自定义布局实现左右对称 + button_container = QWidget() + button_layout = QHBoxLayout(button_container) + button_layout.setContentsMargins(0, 10, 0, 0) # 顶部留一些间距 + + # 创建确定按钮(左对齐) + self.ok_button = QPushButton("") # 稍后设置文本 + self.ok_button.setDefault(True) + self.ok_button.clicked.connect(self.accept) + + # 创建取消按钮(右对齐) + self.cancel_button = QPushButton("") # 稍后设置文本 + self.cancel_button.clicked.connect(self.reject) + + # 布局:确定按钮左对齐,中间弹性空间,取消按钮右对齐 + button_layout.addWidget(self.ok_button) + button_layout.addStretch() # 弹性空间 + button_layout.addWidget(self.cancel_button) + + self.layout.addWidget(button_container) + + def _setup_theme_layout_group(self): + """整合主题和布局设置 - 优化左右对齐""" + self.theme_layout_group = QGroupBox("") # 稍后设置文本 + grid_layout = QGridLayout() + + # 设置列宽比例,确保左右对齐 + grid_layout.setColumnStretch(0, 1) # 左列 + grid_layout.setColumnStretch(1, 1) # 右列 + + # 获取当前设置 + current_theme = self.settings_manager.get_current_theme() + from ..utils.constants import LAYOUT_HORIZONTAL, LAYOUT_VERTICAL + + current_layout = self.settings_manager.get_layout_direction() + + # 第一行:主题设置 + self.dark_theme_radio = QRadioButton("") # 稍后设置文本 + self.light_theme_radio = QRadioButton("") # 稍后设置文本 + + if current_theme == "dark": + self.dark_theme_radio.setChecked(True) + else: + self.light_theme_radio.setChecked(True) + + # 第二行:布局设置 + self.vertical_layout_radio = QRadioButton("") # 稍后设置文本 + self.horizontal_layout_radio = QRadioButton("") # 稍后设置文本 + + if current_layout == LAYOUT_HORIZONTAL: + self.horizontal_layout_radio.setChecked(True) + else: + self.vertical_layout_radio.setChecked(True) + + # 网格布局:左上(深色) 右上(浅色) 左下(上下) 右下(左右) + # 使用右对齐让右侧按钮更好地利用空间 + grid_layout.addWidget(self.dark_theme_radio, 0, 0, Qt.AlignmentFlag.AlignLeft) + grid_layout.addWidget(self.light_theme_radio, 0, 1, Qt.AlignmentFlag.AlignRight) + grid_layout.addWidget( + self.vertical_layout_radio, 1, 0, Qt.AlignmentFlag.AlignLeft + ) + grid_layout.addWidget( + self.horizontal_layout_radio, 1, 1, Qt.AlignmentFlag.AlignRight + ) + + # 连接信号 + self.dark_theme_radio.toggled.connect( + lambda checked: self.switch_theme("dark", checked) + ) + self.light_theme_radio.toggled.connect( + lambda checked: self.switch_theme("light", checked) + ) + self.vertical_layout_radio.toggled.connect( + lambda checked: self.switch_layout(LAYOUT_VERTICAL, checked) + ) + self.horizontal_layout_radio.toggled.connect( + lambda checked: self.switch_layout(LAYOUT_HORIZONTAL, checked) + ) + + self.theme_layout_group.setLayout(grid_layout) + self.layout.addWidget(self.theme_layout_group) + + def _setup_language_font_group(self): + """整合语言和字体设置""" + self.language_font_group = QGroupBox("") # 稍后设置文本 + layout = QVBoxLayout() + + # 第一行:语言设置 - 使用水平布局确保足够空间 + lang_layout = QHBoxLayout() + lang_layout.setSpacing(20) # 增加间距 + + self.chinese_radio = QRadioButton("") # 稍后设置文本 + self.english_radio = QRadioButton("") # 稍后设置文本 + + current_lang = self.settings_manager.get_current_language() + if current_lang == "zh_CN": + self.chinese_radio.setChecked(True) + else: + self.english_radio.setChecked(True) + + # 连接语言切换信号 + self.chinese_radio.toggled.connect( + lambda checked: self.switch_language_radio("zh_CN", checked) + ) + self.english_radio.toggled.connect( + lambda checked: self.switch_language_radio("en_US", checked) + ) + + # 添加到水平布局,左右分布 + lang_layout.addWidget(self.chinese_radio) + lang_layout.addStretch() # 弹性空间 + lang_layout.addWidget(self.english_radio) + + # 创建容器widget来包装布局 + lang_widget = QWidget() + lang_widget.setLayout(lang_layout) + layout.addWidget(lang_widget) + + # 字体大小设置 - 更紧凑的布局 + font_sizes = [ + ( + "prompt_font_size", + self.settings_manager.get_prompt_font_size(), + 12, + 24, + self.update_prompt_font_size, + ), + ( + "options_font_size", + self.settings_manager.get_options_font_size(), + 10, + 20, + self.update_options_font_size, + ), + ( + "input_font_size", + self.settings_manager.get_input_font_size(), + 10, + 20, + self.update_input_font_size, + ), + ] + + self.font_labels = {} + self.font_spinners = {} + + for key, current_value, min_val, max_val, callback in font_sizes: + font_widget = QWidget() + font_layout = QHBoxLayout(font_widget) + font_layout.setContentsMargins(0, 0, 0, 0) + + # 创建标签 + label = QLabel("") + self._apply_font_label_theme_style(label) + label.setMinimumWidth(120) + self.font_labels[key] = label + + # 创建数值选择器 + spinner = QSpinBox() + spinner.setRange(min_val, max_val) + spinner.setValue(current_value) + spinner.valueChanged.connect(callback) + spinner.setFixedSize(90, 32) + self._apply_font_spinner_theme_style(spinner) + self.font_spinners[key] = spinner + + font_layout.addWidget(label) + font_layout.addStretch() + font_layout.addWidget(spinner) + layout.addWidget(font_widget) + + self.language_font_group.setLayout(layout) + self.layout.addWidget(self.language_font_group) + + def _get_font_control_styles(self): + """获取字体控件的主题样式配置""" + is_dark = self.settings_manager.get_current_theme() == "dark" + + # 主题颜色配置 + colors = { + "bg": "#2d2d2d" if is_dark else "white", + "text": "#ffffff" if is_dark else "#000000", + "border": "#555555" if is_dark else "#cccccc", + "button_bg": "#404040" if is_dark else "#f8f8f8", + "button_hover": "#505050" if is_dark else "#e8e8e8", + "button_pressed": "#606060" if is_dark else "#d8d8d8", + "label_text": "#ffffff" if is_dark else "#333333", + "focus": "#0078d4", + } + + return { + "spinner": f""" + QSpinBox {{ + font-size: 11pt; padding: 3px 6px; border-radius: 4px; + border: 1px solid {colors['border']}; + background-color: {colors['bg']}; color: {colors['text']}; + selection-background-color: {colors['focus']}; + }} + QSpinBox:focus {{ border: 2px solid {colors['focus']}; }} + QSpinBox::up-button, QSpinBox::down-button {{ + subcontrol-origin: border; width: 22px; height: 14px; + border-left: 1px solid {colors['border']}; + background-color: {colors['button_bg']}; + }} + QSpinBox::up-button {{ + subcontrol-position: top right; border-bottom: 1px solid {colors['border']}; + border-top-right-radius: 4px; + }} + QSpinBox::down-button {{ + subcontrol-position: bottom right; border-top: 1px solid {colors['border']}; + border-bottom-right-radius: 4px; + }} + QSpinBox::up-button:hover, QSpinBox::down-button:hover {{ + background-color: {colors['button_hover']}; + }} + QSpinBox::up-button:pressed, QSpinBox::down-button:pressed {{ + background-color: {colors['button_pressed']}; + }} + """, + "label": f""" + font-size: 11pt; font-weight: 500; padding: 2px 0px; + color: {colors['label_text']}; + """, + } + + def _apply_font_spinner_theme_style(self, spinner): + """为字体选择器应用主题感知的样式""" + styles = self._get_font_control_styles() + spinner.setStyleSheet(styles["spinner"]) + + def _apply_font_label_theme_style(self, label): + """为字体标签应用主题感知的样式""" + styles = self._get_font_control_styles() + label.setStyleSheet(styles["label"]) + + def _update_font_spinners_theme(self): + """更新所有字体控件的主题样式""" + if hasattr(self, "font_spinners"): + for spinner in self.font_spinners.values(): + self._apply_font_spinner_theme_style(spinner) + if hasattr(self, "font_labels"): + for label in self.font_labels.values(): + self._apply_font_label_theme_style(label) + + def _setup_more_settings_group(self): + """设置更多设置组 - 包含三个按钮""" + self.more_settings_group = QGroupBox("更多设置") + layout = QHBoxLayout() + + # 音频设置按钮 + self.audio_settings_button = QPushButton("音频") + self.audio_settings_button.clicked.connect(self._open_audio_settings) + + # 输入表达优化设置按钮 + self.optimization_settings_button = QPushButton("输入表达优化") + self.optimization_settings_button.clicked.connect( + self._open_optimization_settings + ) + + # 已删除终端设置按钮 + + layout.addWidget(self.audio_settings_button) + layout.addWidget(self.optimization_settings_button) + # 已删除终端设置按钮的添加 + + self.more_settings_group.setLayout(layout) + self.layout.addWidget(self.more_settings_group) + + def _open_audio_settings(self): + """打开音频设置弹窗""" + dialog = AudioSettingsDialog(self.settings_manager, self) + dialog.exec() + + def _open_optimization_settings(self): + """打开输入表达优化设置弹窗""" + dialog = OptimizationSettingsDialog(self) + dialog.exec() + + # 已删除终端设置方法 + + def _setup_interaction_group(self): + """V3.2 新增:设置交互模式配置区域 - 简洁布局""" + self.interaction_group = QGroupBox("") # 稍后设置文本 + interaction_layout = QVBoxLayout() + + # 获取当前配置和UI工厂 - 合并导入 + try: + from interactive_feedback_server.utils import safe_get_config + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import safe_get_config + from ..utils.ui_factory import create_radio_button_pair + + config, current_mode = safe_get_config() + + checked_index = 1 if current_mode == "full" else 0 + self.simple_mode_radio, self.full_mode_radio, mode_layout = ( + create_radio_button_pair( + "", + "", # 文本稍后设置 + checked_index=checked_index, + callback1=lambda checked: self._on_display_mode_changed( + "simple", checked + ), + callback2=lambda checked: self._on_display_mode_changed( + "full", checked + ), + ) + ) + + # 修改布局以实现更好的对齐 + mode_layout.takeAt(0) # 移除第一个按钮 + mode_layout.takeAt(0) # 移除第二个按钮 + + # 重新添加按钮,设置对齐 + mode_layout.addWidget(self.simple_mode_radio, 0, Qt.AlignmentFlag.AlignLeft) + mode_layout.addWidget(self.full_mode_radio, 0, Qt.AlignmentFlag.AlignRight) + + # 创建显示模式按钮组,确保只有这两个按钮互斥 + self.display_mode_group = QButtonGroup(self) + self.display_mode_group.addButton(self.simple_mode_radio) + self.display_mode_group.addButton(self.full_mode_radio) + + interaction_layout.addLayout(mode_layout) + + # 第二行:提交方式设置 - V4.3 新增 + self._setup_submit_method_options(interaction_layout, config) + + # 第三行:功能开关 - 左右布局 + self._setup_feature_toggles(interaction_layout, config) + + # 第四行:自定义后备选项 - 简洁设计 + self._setup_simple_fallback_options(interaction_layout, config) + + self.interaction_group.setLayout(interaction_layout) + self.layout.addWidget(self.interaction_group) + + def _setup_submit_method_options(self, parent_layout, config): + """V4.3 新增:设置提交方式选项""" + from ..utils.ui_factory import create_radio_button_pair + from ..utils.platform_utils import get_submit_method_options + + # 获取当前提交方式设置 + current_submit_method = config.get("submit_method", "enter") + + # 获取平台相关的选项文本 + submit_options = get_submit_method_options() + + checked_index = 1 if current_submit_method == "ctrl_enter" else 0 + self.submit_enter_radio, self.submit_ctrl_enter_radio, submit_layout = ( + create_radio_button_pair( + "", # 文本稍后设置 + "", # 文本稍后设置 + checked_index=checked_index, + callback1=lambda checked: self._on_submit_method_changed( + "enter", checked + ), + callback2=lambda checked: self._on_submit_method_changed( + "ctrl_enter", checked + ), + ) + ) + + # 修改布局以实现更好的对齐 + submit_layout.takeAt(0) # 移除第一个按钮 + submit_layout.takeAt(0) # 移除第二个按钮 + + # 重新添加按钮,设置对齐 + submit_layout.addWidget(self.submit_enter_radio, 0, Qt.AlignmentFlag.AlignLeft) + submit_layout.addWidget( + self.submit_ctrl_enter_radio, 0, Qt.AlignmentFlag.AlignRight + ) + + # 创建提交方式按钮组,确保只有这两个按钮互斥 + self.submit_method_group = QButtonGroup(self) + self.submit_method_group.addButton(self.submit_enter_radio) + self.submit_method_group.addButton(self.submit_ctrl_enter_radio) + + parent_layout.addLayout(submit_layout) + + def _on_submit_method_changed(self, method: str, checked: bool): + """提交方式改变时的处理""" + if checked: + try: + # V4.3 优化:使用缓存的配置工具 + if self._config_utils: + config = self._config_utils["get_config"]() + config["submit_method"] = method + self._config_utils["save_config"](config) + + # 通知主窗口更新占位符文本 + self._notify_submit_method_changed(method) + else: + print("配置工具未初始化,无法保存提交方式设置") + + except Exception as e: + if self._config_utils: + self._config_utils["handle_config_error"]("保存提交方式设置", e) + else: + print(f"保存提交方式设置失败: {e}") + + def _notify_submit_method_changed(self, method: str): + """通知主窗口提交方式已更改""" + try: + # 尝试获取主窗口并更新占位符文本 + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app: + for widget in app.topLevelWidgets(): + if hasattr(widget, "text_input") and hasattr( + widget, "_update_placeholder_text" + ): + widget._update_placeholder_text() + except Exception as e: + print(f"通知主窗口更新占位符文本失败: {e}") + + def _setup_feature_toggles(self, parent_layout, config): + """V4.0 简化:设置自定义选项开关""" + # 获取功能状态和UI工厂 + try: + from interactive_feedback_server.utils import get_custom_options_enabled + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import get_custom_options_enabled + from ..utils.ui_factory import create_toggle_radio_button + + custom_options_enabled = get_custom_options_enabled(config) + + # 记录初始状态,用于关闭时保存 + self._custom_options_enabled = custom_options_enabled + + toggles_layout = QHBoxLayout() + + self.enable_custom_options_radio = create_toggle_radio_button( + "", custom_options_enabled, self._on_custom_options_toggled + ) + + toggles_layout.addWidget(self.enable_custom_options_radio) + + parent_layout.addLayout(toggles_layout) + + # V4.0 移除:_on_rule_engine_toggled 函数已删除 + + def _on_custom_options_toggled(self, checked: bool): + """自定义选项开关切换处理 - 简化版本""" + # 只记录状态,在关闭时统一保存 + self._custom_options_enabled = checked + + def _setup_simple_fallback_options(self, parent_layout, config): + """设置可折叠的后备选项区域 - 简洁设计""" + # 创建展开/收起按钮 - 简洁样式 + self.fallback_toggle_button = QPushButton("") # 稍后设置文本 + self.fallback_toggle_button.setCheckable(True) + self.fallback_toggle_button.setChecked(False) # 默认收起 + self.fallback_toggle_button.clicked.connect(self._toggle_fallback_options) + + # 简洁的按钮样式 + self.fallback_toggle_button.setStyleSheet( + """ + QPushButton { + text-align: left; + padding: 4px 8px; + border: none; + background-color: transparent; + font-size: 10pt; + color: gray; + } + QPushButton:hover { + background-color: rgba(128, 128, 128, 0.1); + } + """ + ) + + parent_layout.addWidget(self.fallback_toggle_button) + + # 获取当前选项 - 使用过滤后的有效选项 + try: + from interactive_feedback_server.utils import get_fallback_options + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import get_fallback_options + + current_options = get_fallback_options(config) + + # 创建可折叠的选项容器 + self.fallback_options_container = QWidget() + self.fallback_options_container.setVisible(False) # 默认隐藏 + options_layout = QVBoxLayout(self.fallback_options_container) + options_layout.setContentsMargins(15, 5, 0, 5) # 左侧缩进 + options_layout.setSpacing(3) # 紧凑间距 + + # 移除复杂的状态指示器,采用简单的关闭时保存方案 + + self.fallback_option_edits = [] + self.fallback_option_labels = [] + + for i in range(5): + option_layout = QHBoxLayout() + option_layout.setContentsMargins(0, 0, 0, 0) + + # 选项标签 - 更小的字体 + option_label = QLabel("") # 稍后设置文本 + option_label.setFixedWidth(50) + option_label.setStyleSheet("font-size: 9pt;") # 小字体 + self.fallback_option_labels.append(option_label) + + # 选项输入框 - 更紧凑 + option_edit = QLineEdit() + option_edit.setMaxLength(50) + option_edit.setStyleSheet("font-size: 10pt; padding: 2px;") # 紧凑样式 + if i < len(current_options): + option_edit.setText(current_options[i]) + + # 移除实时保存信号,改为关闭时统一保存 + self.fallback_option_edits.append(option_edit) + + option_layout.addWidget(option_label) + option_layout.addWidget(option_edit) + options_layout.addLayout(option_layout) + + parent_layout.addWidget(self.fallback_options_container) + + def _toggle_fallback_options(self): + """切换后备选项区域的显示/隐藏 - 简洁优化版本""" + from PySide6.QtCore import QTimer + + is_expanded = self.fallback_toggle_button.isChecked() + current_width = self.width() + + # 更新按钮文本 + current_lang = self.current_language + button_text = ( + f"▼ {self.texts['collapse_options'][current_lang]}" + if is_expanded + else f"▶ {self.texts['expand_options'][current_lang]}" + ) + self.fallback_toggle_button.setText(button_text) + + # 设置容器可见性并调整窗口大小 + self.fallback_options_container.setVisible(is_expanded) + + # 延迟调整窗口大小,避免闪动 + QTimer.singleShot( + 10, lambda: self._adjust_window_size(current_width, is_expanded) + ) + + def _adjust_window_size(self, target_width, is_expanded): + """调整窗口大小以适应内容变化""" + # 激活布局计算并获取合适的高度 + self.layout.activate() + target_height = self.sizeHint().height() + + # 调整窗口大小,保持宽度不变 + self.resize(target_width, target_height) + + # 设置合理的最小高度 + min_height = target_height if is_expanded else 600 + self.setMinimumHeight(min_height) + + def _on_display_mode_changed(self, mode: str, checked: bool): + """V3.2 新增:显示模式改变时的处理""" + if checked: + try: + # V4.3 优化:使用缓存的配置工具 + if self._config_utils: + config = self._config_utils["get_config"]() + config["display_mode"] = mode + self._config_utils["save_config"](config) + else: + print("配置工具未初始化,无法保存显示模式设置") + except Exception as e: + if self._config_utils: + self._config_utils["handle_config_error"]("保存显示模式", e) + else: + print(f"保存显示模式失败: {e}") + + def _save_fallback_options(self): + """保存后备选项 - 使用null占位符标记空选项""" + try: + try: + from interactive_feedback_server.utils import get_config, save_config + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import ( + get_config, + save_config, + ) + + # 收集所有选项,空选项用"null"占位符 + options = [] + for edit in self.fallback_option_edits: + text = edit.text().strip() + if text: + options.append(text) + else: + options.append("null") # 空选项用null占位符 + + # 确保有5个选项(保持配置结构完整) + while len(options) < 5: + options.append("null") + + # 保存配置 + config = get_config() + config["fallback_options"] = options[:5] # 保存5个选项 + save_config(config) + + except Exception as e: + print(f"保存后备选项失败: {e}") + + def switch_theme(self, theme_name: str, checked: bool): + # The 'checked' boolean comes directly from the toggled signal. + # We only act when a radio button is checked, not when it's unchecked. + if checked: + self.settings_manager.set_current_theme(theme_name) + app_instance = QApplication.instance() + if app_instance: + apply_theme(app_instance, theme_name) + + # 更新字体选择器的主题样式 + self._update_font_spinners_theme() + + # 通知主窗口更新分割器样式以匹配新主题 + for widget in app_instance.topLevelWidgets(): + if widget.__class__.__name__ == "FeedbackUI": + if hasattr(widget, "update_font_sizes"): + widget.update_font_sizes() + break + + def switch_layout(self, layout_direction: str, checked: bool): + """切换界面布局方向""" + if checked: + self.settings_manager.set_layout_direction(layout_direction) + + # 通知主窗口重新创建布局 + app_instance = QApplication.instance() + if app_instance: + for widget in app_instance.topLevelWidgets(): + if widget.__class__.__name__ == "FeedbackUI": + if hasattr(widget, "_recreate_layout"): + widget._recreate_layout() + break + + def switch_language_radio(self, language_code: str, checked: bool): + """ + 通过单选按钮切换语言设置 + """ + if checked: + self.switch_language_internal(language_code) + + def switch_language_internal(self, selected_lang: str): + """ + 内部语言切换逻辑 + """ + # 如果语言没有变化,则不需要处理 + if selected_lang == self.current_language: + return + + # 保存设置 + self.settings_manager.set_current_language(selected_lang) + old_language = self.current_language + self.current_language = selected_lang # 更新当前语言记录 + + # 应用翻译 + app = QApplication.instance() + if app: + # 1. 移除旧翻译器 + app.removeTranslator(self.translator) + + # 2. 准备新翻译器 + self.translator = QTranslator(self) + + # 3. 根据语言选择加载/移除翻译器 + if selected_lang == "zh_CN": + # 中文是默认语言,不需要翻译器 + print("设置对话框:切换到中文") + elif selected_lang == "en_US": + # 英文需要加载翻译 + if self.translator.load(f":/translations/{selected_lang}.qm"): + app.installTranslator(self.translator) + print("设置对话框:加载英文翻译") + else: + print("设置对话框:无法加载英文翻译") + + # 4. 处理特殊情况:英文->中文 + if old_language == "en_US" and selected_lang == "zh_CN": + self._handle_english_to_chinese_switch(app) + else: + # 5. 标准更新流程 + self._handle_standard_language_switch(app) + + # 6. 更新自身的文本 + self._update_texts() + + def _handle_standard_language_switch(self, app): + """处理标准的语言切换流程""" + # 1. 等待事件处理 + app.processEvents() + + # 2. 发送语言变更事件 + QCoreApplication.sendEvent(app, QEvent(QEvent.Type.LanguageChange)) + + # 3. 更新所有窗口 + for widget in app.topLevelWidgets(): + if widget is not self: + # 发送语言变更事件 + QCoreApplication.sendEvent(widget, QEvent(QEvent.Type.LanguageChange)) + + # 如果是FeedbackUI,直接调用其更新方法 + if widget.__class__.__name__ == "FeedbackUI": + if hasattr(widget, "_update_displayed_texts"): + widget._update_displayed_texts() + # 如果有retranslateUi方法,尝试调用 + elif hasattr(widget, "retranslateUi"): + try: + widget.retranslateUi() + except Exception as e: + print(f"更新窗口 {type(widget).__name__} 失败: {str(e)}") + + def _handle_english_to_chinese_switch(self, app): + """专门处理从英文到中文的切换""" + # 1. 处理事件队列 + app.processEvents() + + # 2. 发送语言变更事件给应用程序 + QCoreApplication.sendEvent(app, QEvent(QEvent.Type.LanguageChange)) + + # 3. 查找并特别处理主窗口 + for widget in app.topLevelWidgets(): + if widget.__class__.__name__ == "FeedbackUI": + # 直接调用主窗口的按钮文本更新方法 + if hasattr(widget, "_update_button_texts"): + widget._update_button_texts("zh_CN") + # 更新其他文本 + if hasattr(widget, "_update_displayed_texts"): + widget._update_displayed_texts() + print("设置对话框:已强制更新主窗口按钮文本") + else: + # 对其他窗口发送语言变更事件 + QCoreApplication.sendEvent(widget, QEvent(QEvent.Type.LanguageChange)) + + def _update_texts(self): + """根据当前语言设置更新所有文本""" + current_lang = self.current_language + + # 更新窗口标题 + self.setWindowTitle(self.texts["title"][current_lang]) + + # 更新整合后的主题布局组 + if hasattr(self, "theme_layout_group"): + self.theme_layout_group.setTitle( + self.texts["theme_layout_group"][current_lang] + ) + + if hasattr(self, "dark_theme_radio"): + self.dark_theme_radio.setText(self.texts["dark_mode"][current_lang]) + + if hasattr(self, "light_theme_radio"): + self.light_theme_radio.setText(self.texts["light_mode"][current_lang]) + + if hasattr(self, "vertical_layout_radio"): + self.vertical_layout_radio.setText( + self.texts["vertical_layout"][current_lang] + ) + + if hasattr(self, "horizontal_layout_radio"): + self.horizontal_layout_radio.setText( + self.texts["horizontal_layout"][current_lang] + ) + + # 更新整合后的语言字体组 + if hasattr(self, "language_font_group"): + self.language_font_group.setTitle( + self.texts["language_font_group"][current_lang] + ) + + if hasattr(self, "chinese_radio"): + self.chinese_radio.setText(self.texts["chinese"][current_lang]) + + if hasattr(self, "english_radio"): + self.english_radio.setText(self.texts["english"][current_lang]) + + # 更新字体标签 + if hasattr(self, "font_labels"): + for key, label in self.font_labels.items(): + if key in self.texts: + label.setText(self.texts[key][current_lang]) + + # 更新更多设置组 + if hasattr(self, "more_settings_group"): + self.more_settings_group.setTitle( + self.texts["more_settings_group"][current_lang] + ) + + if hasattr(self, "audio_settings_button"): + self.audio_settings_button.setText( + self.texts["audio_settings_button"][current_lang] + ) + + if hasattr(self, "optimization_settings_button"): + self.optimization_settings_button.setText( + self.texts["optimization_settings_button"][current_lang] + ) + + if hasattr(self, "terminal_settings_button"): + self.terminal_settings_button.setText( + self.texts["terminal_settings_button"][current_lang] + ) + + # V3.2 新增:更新交互模式设置文本 + if hasattr(self, "interaction_group"): + self.interaction_group.setTitle( + self.texts["interaction_group"][current_lang] + ) + + if hasattr(self, "simple_mode_radio"): + self.simple_mode_radio.setText(self.texts["simple_mode"][current_lang]) + + if hasattr(self, "full_mode_radio"): + self.full_mode_radio.setText(self.texts["full_mode"][current_lang]) + + # V4.0 简化:更新自定义选项开关文本 + if hasattr(self, "enable_custom_options_radio"): + self.enable_custom_options_radio.setText( + self.texts["enable_custom_options"][current_lang] + ) + + # V4.3 新增:更新提交方式选项文本 + if hasattr(self, "submit_enter_radio"): + self.submit_enter_radio.setText(self.texts["submit_enter"][current_lang]) + + if hasattr(self, "submit_ctrl_enter_radio"): + # 动态获取平台相关的文本 + from ..utils.platform_utils import get_submit_method_options + + submit_options = get_submit_method_options() + self.submit_ctrl_enter_radio.setText( + submit_options["ctrl_enter"][current_lang] + ) + + # 更新可折叠按钮文本 + if hasattr(self, "fallback_toggle_button"): + is_expanded = self.fallback_toggle_button.isChecked() + if is_expanded: + self.fallback_toggle_button.setText( + f"▼ {self.texts['collapse_options'][current_lang]}" + ) + else: + self.fallback_toggle_button.setText( + f"▶ {self.texts['expand_options'][current_lang]}" + ) + + # 更新后备选项标签 + if hasattr(self, "fallback_option_labels"): + for i, label in enumerate(self.fallback_option_labels): + label.setText(f"{self.texts['option_label'][current_lang]} {i+1}:") + + # 更新按钮文本 + if hasattr(self, "ok_button"): + if current_lang == "zh_CN": + self.ok_button.setText("确定") + else: + self.ok_button.setText("OK") + + if hasattr(self, "cancel_button"): + if current_lang == "zh_CN": + self.cancel_button.setText("取消") + else: + self.cancel_button.setText("Cancel") + + def changeEvent(self, event: QEvent): + """处理语言变化事件""" + if event.type() == QEvent.Type.LanguageChange: + self._update_texts() + super().changeEvent(event) + + def accept(self): + """关闭设置页面时统一保存所有配置""" + try: + # 保存自定义选项开关状态 + if hasattr(self, "_custom_options_enabled"): + try: + from interactive_feedback_server.utils import ( + set_custom_options_enabled, + ) + except ImportError: + _setup_project_path() + from src.interactive_feedback_server.utils import ( + set_custom_options_enabled, + ) + + set_custom_options_enabled(self._custom_options_enabled) + + # 保存后备选项(过滤空选项) + if hasattr(self, "fallback_option_edits"): + self._save_fallback_options() + + except Exception as e: + print(f"保存设置失败: {e}") + + super().accept() + + def _update_font_size(self, font_type: str, size: int): + """统一的字体大小更新方法""" + # 设置字体大小 + if font_type == "prompt": + self.settings_manager.set_prompt_font_size(size) + elif font_type == "options": + self.settings_manager.set_options_font_size(size) + elif font_type == "input": + self.settings_manager.set_input_font_size(size) + + # 应用到主窗口 + app = QApplication.instance() + if app: + for widget in app.topLevelWidgets(): + if widget.__class__.__name__ == "FeedbackUI" and hasattr( + widget, "update_font_sizes" + ): + widget.update_font_sizes() + break + + def update_prompt_font_size(self, size: int): + """更新提示区字体大小""" + self._update_font_size("prompt", size) + + def update_options_font_size(self, size: int): + """更新选项区字体大小""" + self._update_font_size("options", size) + + def update_input_font_size(self, size: int): + """更新输入框字体大小""" + self._update_font_size("input", size) + + def _apply_enhanced_styling(self): + """应用增强的控件样式""" + try: + from ..utils.ui_factory import apply_enhanced_control_styling + + current_theme = self.settings_manager.get_current_theme() + apply_enhanced_control_styling(self, current_theme) + except Exception as e: + print(f"应用增强样式失败: {e}") + + def reject(self): + super().reject() diff --git a/src/feedback_ui/images/1.png b/src/feedback_ui/images/1.png new file mode 100644 index 0000000..3544075 Binary files /dev/null and b/src/feedback_ui/images/1.png differ diff --git a/src/feedback_ui/images/feedback.png b/src/feedback_ui/images/feedback.png new file mode 100644 index 0000000..685a6ba Binary files /dev/null and b/src/feedback_ui/images/feedback.png differ diff --git a/src/feedback_ui/main_window.py b/src/feedback_ui/main_window.py new file mode 100644 index 0000000..49e78e9 --- /dev/null +++ b/src/feedback_ui/main_window.py @@ -0,0 +1,2470 @@ +# feedback_ui/main_window.py +import os +import re # 正则表达式 (Regular expressions) +import subprocess +import sys + +from PySide6.QtCore import QEvent, QObject, Qt, QTimer +from PySide6.QtGui import QIcon, QPixmap, QTextCursor +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QScrollArea, + QSizePolicy, + QSplitter, + QVBoxLayout, + QWidget, +) + +from .dialogs.select_canned_response_dialog import SelectCannedResponseDialog +from .dialogs.settings_dialog import SettingsDialog + +# --- 从子模块导入 (Imports from submodules) --- +from .utils.constants import ( + ContentItem, + FeedbackResult, + LAYOUT_HORIZONTAL, + MIN_LEFT_AREA_WIDTH, + MIN_LOWER_AREA_HEIGHT, + MIN_RIGHT_AREA_WIDTH, + MIN_UPPER_AREA_HEIGHT, + SCREENSHOT_WINDOW_MINIMIZE_DELAY, + SCREENSHOT_FOCUS_DELAY, +) +from .utils.image_processor import get_image_items_from_widgets +from .utils.settings_manager import SettingsManager +from .utils.ui_helpers import set_selection_colors + +from .widgets.feedback_text_edit import FeedbackTextEdit +from .widgets.image_preview import ImagePreviewWidget +from .widgets.selectable_label import SelectableLabel +from .widgets.screenshot_window import ScreenshotWindow + + +class FeedbackUI(QMainWindow): + """ + Main window for the Interactive Feedback MCP application. + 交互式反馈MCP应用程序的主窗口。 + """ + + def __init__( + self, + prompt: str, + predefined_options: list[str] | None = None, + parent: QWidget | None = None, + ): + super().__init__(parent) + self.prompt = prompt + self.predefined_options = predefined_options or [] + self.output_result = FeedbackResult( + content=[] + ) # 初始化为空结果 (Initialize with empty result) + + # --- 内部状态 (Internal State) --- + self.image_widgets: dict[int, ImagePreviewWidget] = {} # image_id: widget + self.option_checkboxes: list[QCheckBox] = ( + [] + ) # Initialize here to prevent AttributeError + self.next_image_id = 0 + self.canned_responses: list[str] = [] + self.dropped_file_references: dict[str, str] = {} # display_name: file_path + self.disable_auto_minimize = False + self.window_pinned = False + + # 按钮文本的双语映射 + self.button_texts = { + "submit_button": {"zh_CN": "提交", "en_US": "Submit"}, + "canned_responses_button": {"zh_CN": "常用语", "en_US": "Canned Responses"}, + "select_file_button": {"zh_CN": "选择文件", "en_US": "Select Files"}, + "screenshot_button": {"zh_CN": "窗口截图", "en_US": "Screenshot"}, + "open_terminal_button": {"zh_CN": "启用终端", "en_US": "Open Terminal"}, + "pin_window_button": {"zh_CN": "固定窗口", "en_US": "Pin Window"}, + "settings_button": {"zh_CN": "设置", "en_US": "Settings"}, + # V4.0 新增:优化按钮 + "optimize_button": {"zh_CN": "优化", "en_US": "Optimize"}, + "enhance_button": {"zh_CN": "增强", "en_US": "Enhance"}, + } + + self.settings_manager = SettingsManager(self) + + # 初始化音频管理器 + self._setup_audio_manager() + + self._setup_window() + self._load_settings() + + self._create_ui_layout() + self._connect_signals() + + self._apply_pin_state_on_load() + + # 初始化时更新界面文本显示 + self._update_displayed_texts() + + # 立即执行初始化,避免窗口显示后的布局变化 + self._perform_delayed_initialization() + + # 为主窗口安装事件过滤器,以实现点击背景聚焦输入框的功能 + self.installEventFilter(self) + + # 添加窗口大小变化监听,用于动态调整选项间距 + self._setup_resize_monitoring() + + # V4.1 新增:创建加载覆盖层 + self._setup_loading_overlay() + + def _perform_delayed_initialization(self): + """合并的延迟初始化操作,减少布局闪烁""" + try: + # 首先应用字体设置,避免后续样式变化 + self._apply_initial_font_settings() + + # 设置分割器样式,确保在窗口显示后应用 + self._ensure_splitter_visibility() + except Exception as e: + print(f"DEBUG: 延迟初始化时出错: {e}", file=sys.stderr) + + def _apply_initial_font_settings(self): + """应用初始字体设置,避免布局闪烁""" + try: + app = QApplication.instance() + if app: + from .utils.style_manager import apply_theme + + current_theme = self.settings_manager.get_current_theme() + apply_theme(app, current_theme) + + # 直接应用所有样式更新 + self._apply_all_style_updates() + + except Exception as e: + print(f"DEBUG: 应用初始字体设置时出错: {e}", file=sys.stderr) + + def _apply_all_style_updates(self): + """统一应用所有样式更新的方法""" + current_theme = self.settings_manager.get_current_theme() + + # 重新应用分割器样式,确保颜色与主题一致 + if hasattr(self, "main_splitter"): + self._force_splitter_style() + + # 更新输入框字体大小,与提示文字保持一致 + if hasattr(self, "text_input") and self.text_input: + self.text_input.update_font_size() + + # 更新复选框样式,确保主题切换时颜色正确 + self._update_all_checkbox_styles() + + # 更新优化按钮样式,确保主题切换时颜色正确 + self._update_optimization_buttons_styles() + + # V4.1 新增:更新加载覆盖层主题 + if hasattr(self, "loading_overlay"): + is_dark_theme = current_theme == "dark" + self.loading_overlay.set_theme(is_dark_theme) + + def _setup_audio_manager(self): + """设置音频管理器""" + try: + from .utils.audio_manager import get_audio_manager + + self.audio_manager = get_audio_manager() + + if self.audio_manager: + # 从设置中加载音频配置 + enabled = self.settings_manager.get_audio_enabled() + volume = self.settings_manager.get_audio_volume() + + self.audio_manager.set_enabled(enabled) + self.audio_manager.set_volume(volume) + + except Exception as e: + print(f"设置音频管理器时出错: {e}", file=sys.stderr) + self.audio_manager = None + + def _setup_loading_overlay(self): + """V4.1 新增:设置加载覆盖层""" + from .widgets.loading_overlay import LoadingOverlay + + self.loading_overlay = LoadingOverlay(self) + + # 根据当前主题设置样式 + current_theme = self.settings_manager.get_current_theme() + is_dark_theme = current_theme == "dark" + self.loading_overlay.set_theme(is_dark_theme) + + def _setup_window(self): + """Sets up basic window properties like title, size.""" + self.setWindowTitle("交互式反馈 MCP (Interactive Feedback MCP)") + self.setMinimumWidth(1000) + self.setMinimumHeight(700) + self.setWindowFlags(Qt.WindowType.Window) + + # 设置窗口图标 + self._setup_window_icon() + + def _setup_window_icon(self): + """设置窗口图标""" + # 获取图标文件路径 + script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + icon_path = os.path.join(script_dir, "feedback_ui", "images", "feedback.png") + + # 尝试加载图标,如果不存在则创建一个空目录确保后续程序正确运行 + try: + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + else: + # 如果图标文件不存在,确保images目录存在 + images_dir = os.path.join(script_dir, "feedback_ui", "images") + if not os.path.exists(images_dir): + os.makedirs(images_dir, exist_ok=True) + print(f"警告: 图标文件不存在: {icon_path}") + except Exception as e: + print(f"警告: 无法加载图标文件: {e}") + + def _load_settings(self): + """从设置中加载保存的窗口状态和几何形状""" + + # 设置默认大小 + default_width, default_height = 1000, 750 + + # 尝试恢复保存的窗口几何信息(位置和大小) + saved_geometry = self.settings_manager.get_main_window_geometry() + if saved_geometry: + # 使用Qt标准方法恢复几何信息 + if not self.restoreGeometry(saved_geometry): + # 如果恢复失败,使用默认设置 + self._set_default_window_geometry(default_width, default_height) + else: + # 没有保存的几何信息,使用默认设置 + self._set_default_window_geometry(default_width, default_height) + + # 恢复窗口状态(工具栏、停靠窗口等) + state = self.settings_manager.get_main_window_state() + if state: + self.restoreState(state) + + self.window_pinned = self.settings_manager.get_main_window_pinned() + self._load_canned_responses_from_settings() + + def _set_default_window_geometry(self, width: int, height: int): + """设置默认的窗口几何信息""" + # 设置默认大小 + self.resize(width, height) + + # 获取屏幕大小并居中显示 + screen = QApplication.primaryScreen().geometry() + screen_width, screen_height = screen.width(), screen.height() + + # 计算居中位置 + default_x = (screen_width - width) // 2 + default_y = (screen_height - height) // 2 + + # 确保窗口在屏幕范围内 + default_x = max(0, min(default_x, screen_width - width)) + default_y = max(0, min(default_y, screen_height - height)) + + self.move(default_x, default_y) + + def _create_ui_layout(self): + """根据设置创建对应的UI布局""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # 获取布局方向设置 + layout_direction = self.settings_manager.get_layout_direction() + + if layout_direction == LAYOUT_HORIZONTAL: + self._create_horizontal_layout(central_widget) + else: + self._create_vertical_layout(central_widget) + + def _create_vertical_layout(self, central_widget: QWidget): + """创建上下布局(当前布局)""" + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(20, 5, 20, 10) + main_layout.setSpacing(15) + + # 创建垂直分割器 + self.main_splitter = QSplitter(Qt.Orientation.Vertical) + self.main_splitter.setObjectName("mainSplitter") + self.main_splitter.setChildrenCollapsible(False) + + # 上部区域和下部区域 + self.upper_area = self._create_upper_area() + self.lower_area = self._create_lower_area() + + self.main_splitter.addWidget(self.upper_area) + self.main_splitter.addWidget(self.lower_area) + + self._setup_vertical_splitter_properties() + main_layout.addWidget(self.main_splitter) + + # 强制设置分割器样式 + self._force_splitter_style() + + # 底部按钮和GitHub链接 + self._setup_bottom_bar(main_layout) + self._create_submit_button(main_layout) + self._create_github_link_area(main_layout) + + self._update_submit_button_text_status() + + def _create_horizontal_layout(self, central_widget: QWidget): + """创建左右布局(混合布局)""" + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(20, 5, 20, 10) + main_layout.setSpacing(15) + + # 创建上部分割区域 + upper_splitter_area = self._create_upper_splitter_area() + main_layout.addWidget(upper_splitter_area, 1) # 占据主要空间 + + # 创建底部按钮区域(横跨全宽) + self._setup_bottom_bar(main_layout) + self._create_submit_button(main_layout) + self._create_github_link_area(main_layout) + + self._update_submit_button_text_status() + + def _create_submit_button(self, parent_layout: QVBoxLayout): + """创建提交按钮""" + current_language = self.settings_manager.get_current_language() + self.submit_button = QPushButton( + self.button_texts["submit_button"][current_language] + ) + self.submit_button.setObjectName("submit_button") + self.submit_button.setMinimumHeight(42) + parent_layout.addWidget(self.submit_button) + + def _recreate_layout(self): + """重新创建布局(用于布局方向切换)""" + # 保存当前的文本内容和选项状态 + current_text = "" + selected_options = [] + + if hasattr(self, "text_input") and self.text_input: + current_text = self.text_input.toPlainText() + + if hasattr(self, "option_checkboxes"): + for i, checkbox in enumerate(self.option_checkboxes): + if checkbox.isChecked() and i < len(self.predefined_options): + selected_options.append(i) + + # 重新创建UI布局 + self._create_ui_layout() + + # 恢复文本内容和选项状态 + if current_text and hasattr(self, "text_input"): + self.text_input.setPlainText(current_text) + + if selected_options and hasattr(self, "option_checkboxes"): + for i in selected_options: + if i < len(self.option_checkboxes): + self.option_checkboxes[i].setChecked(True) + + # 重新连接信号 + self._connect_signals() + + # 应用主题和字体设置 + self.update_font_sizes() + + # 设置焦点 + self._set_initial_focus() + + def _create_upper_splitter_area(self) -> QWidget: + """创建上部分割区域(左右布局专用)""" + splitter_container = QWidget() + splitter_layout = QVBoxLayout(splitter_container) + splitter_layout.setContentsMargins(0, 0, 0, 0) + + # 创建水平分割器 + self.main_splitter = QSplitter(Qt.Orientation.Horizontal) + self.main_splitter.setObjectName("mainSplitter") + self.main_splitter.setChildrenCollapsible(False) + + # 左侧:提示文字区域 + self.left_area = self._create_left_area() + self.main_splitter.addWidget(self.left_area) + + # 右侧:选项+输入框区域 + self.right_area = self._create_right_area() + self.main_splitter.addWidget(self.right_area) + + self._setup_horizontal_splitter_properties() + splitter_layout.addWidget(self.main_splitter) + + # 强制设置分割器样式 + self._force_splitter_style() + + return splitter_container + + def _create_left_area(self) -> QWidget: + """创建左侧区域(提示文字 + 选项)""" + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(15, 15, 15, 15) + left_layout.setSpacing(10) + + # 添加提示文字区域,在左右布局中给予更多空间 + self._create_description_area(left_layout) + + # 在左右布局中,将选项区域添加到左侧 + if self.predefined_options: + self._create_options_checkboxes(left_layout) + + return left_widget + + def _create_right_area(self) -> QWidget: + """创建右侧区域(仅输入框)""" + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(15, 15, 15, 15) + right_layout.setSpacing(10) + + # 在左右布局中,右侧只包含输入框区域 + # 选项区域已移动到左侧 + self._create_input_submission_area(right_layout) + + return right_widget + + def _create_upper_area(self) -> QWidget: + """创建上部区域容器(提示文字 + 选项)""" + upper_widget = QWidget() + upper_layout = QVBoxLayout(upper_widget) + upper_layout.setContentsMargins(15, 5, 15, 15) + upper_layout.setSpacing(10) + + # 添加现有的描述区域 + self._create_description_area(upper_layout) + + # 添加选项复选框(如果有) + if self.predefined_options: + self._create_options_checkboxes(upper_layout) + + return upper_widget + + def _create_lower_area(self) -> QWidget: + """创建下部区域容器(输入框)""" + lower_widget = QWidget() + lower_layout = QVBoxLayout(lower_widget) + lower_layout.setContentsMargins(15, 5, 15, 15) + lower_layout.setSpacing(10) + + # 添加输入提交区域 + self._create_input_submission_area(lower_layout) + + return lower_widget + + def _setup_vertical_splitter_properties(self): + """配置垂直分割器属性""" + self.main_splitter.setHandleWidth(6) + self.upper_area.setMinimumHeight(MIN_UPPER_AREA_HEIGHT) + self.lower_area.setMinimumHeight(MIN_LOWER_AREA_HEIGHT) + + saved_sizes = self.settings_manager.get_splitter_sizes() + self.main_splitter.setSizes(saved_sizes) + + self.main_splitter.splitterMoved.connect(self._on_vertical_splitter_moved) + self._setup_splitter_double_click() + + def _setup_horizontal_splitter_properties(self): + """配置水平分割器属性""" + self.main_splitter.setHandleWidth(6) + self.left_area.setMinimumWidth(MIN_LEFT_AREA_WIDTH) + self.right_area.setMinimumWidth(MIN_RIGHT_AREA_WIDTH) + + saved_sizes = self.settings_manager.get_horizontal_splitter_sizes() + self.main_splitter.setSizes(saved_sizes) + + self.main_splitter.splitterMoved.connect(self._on_horizontal_splitter_moved) + self._setup_splitter_double_click() + + def _force_splitter_style(self): + """强制设置分割器样式,确保可见性""" + # 获取当前主题的分割器颜色配置 + from .utils.theme_colors import ThemeColors + + current_theme = self.settings_manager.get_current_theme() + colors = ThemeColors.get_splitter_colors(current_theme) + + base_color = colors["base_color"] + hover_color = colors["hover_color"] + pressed_color = colors["pressed_color"] + + # 精致的分割线样式:细线,与UI风格一致 + splitter_style = f""" + QSplitter::handle:vertical {{ + background-color: {base_color} !important; + border: none !important; + border-radius: 2px; + height: 6px !important; + min-height: 6px !important; + max-height: 6px !important; + margin: 2px 4px; + }} + QSplitter::handle:vertical:hover {{ + background-color: {hover_color} !important; + }} + QSplitter::handle:vertical:pressed {{ + background-color: {pressed_color} !important; + }} + QSplitter::handle:horizontal {{ + width: 6px !important; + min-width: 6px !important; + max-width: 6px !important; + background-color: {base_color} !important; + border: none !important; + border-radius: 2px; + margin: 4px 2px; + }} + QSplitter::handle:horizontal:hover {{ + background-color: {hover_color} !important; + }} + QSplitter::handle:horizontal:pressed {{ + background-color: {pressed_color} !important; + }} + """ + self.main_splitter.setStyleSheet(splitter_style) + + # 设置精致的手柄宽度 + self.main_splitter.setHandleWidth(6) + + # 确保分割器手柄可见 + layout_direction = self.settings_manager.get_layout_direction() + for i in range(self.main_splitter.count() - 1): + handle = self.main_splitter.handle(i + 1) + if handle: + handle.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + + # 根据布局方向设置不同的尺寸属性 + if layout_direction == LAYOUT_HORIZONTAL: + # 水平分割器(左右布局):设置宽度 + handle.setMinimumWidth(6) + handle.setMaximumWidth(6) + # 设置与主题一致的背景色,保持与横向分割线相同的margin比例 + handle.setStyleSheet( + f"background-color: {base_color}; border: none; border-radius: 2px; margin: 2px 0px;" + ) + else: + # 垂直分割器(上下布局):设置高度 + handle.setMinimumHeight(6) + handle.setMaximumHeight(6) + # 设置与主题一致的背景色 + handle.setStyleSheet( + f"background-color: {base_color}; border: none; border-radius: 2px; margin: 2px 4px;" + ) + + def _ensure_splitter_visibility(self): + """确保分割器在窗口显示后可见""" + if hasattr(self, "main_splitter"): + # 重新应用样式 + self._force_splitter_style() + + # 强制刷新分割器 + self.main_splitter.update() + + def _setup_splitter_double_click(self): + """设置分割器双击重置功能""" + # 获取分割器手柄并设置双击事件 + handle = self.main_splitter.handle(1) + if handle: + handle.mouseDoubleClickEvent = self._reset_splitter_to_default + + def _reset_splitter_to_default(self, event): + """双击分割器手柄时重置为默认比例""" + layout_direction = self.settings_manager.get_layout_direction() + + if layout_direction == LAYOUT_HORIZONTAL: + from .utils.constants import DEFAULT_HORIZONTAL_SPLITTER_RATIO + + self.main_splitter.setSizes(DEFAULT_HORIZONTAL_SPLITTER_RATIO) + self._on_horizontal_splitter_moved(0, 0) + else: + from .utils.constants import DEFAULT_SPLITTER_RATIO + + self.main_splitter.setSizes(DEFAULT_SPLITTER_RATIO) + self._on_vertical_splitter_moved(0, 0) + + def _on_vertical_splitter_moved(self, pos: int, index: int): + """垂直分割器移动时保存状态""" + sizes = self.main_splitter.sizes() + self.settings_manager.set_splitter_sizes(sizes) + self.settings_manager.set_splitter_state(self.main_splitter.saveState()) + + # 延迟更新选项间距,因为分割器移动可能影响可用空间 + QTimer.singleShot(100, self._update_option_spacing) + + def _on_horizontal_splitter_moved(self, pos: int, index: int): + """水平分割器移动时保存状态""" + sizes = self.main_splitter.sizes() + self.settings_manager.set_horizontal_splitter_sizes(sizes) + self.settings_manager.set_horizontal_splitter_state( + self.main_splitter.saveState() + ) + + # 延迟更新选项间距,因为分割器移动可能影响可用空间 + QTimer.singleShot(100, self._update_option_spacing) + + def _create_description_area(self, parent_layout: QVBoxLayout): + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # 在左右布局模式下不限制高度,让其充分利用可用空间 + # 修复:在上下布局中也移除高度限制,允许描述区域随分割器拖拽正常扩展 + layout_direction = self.settings_manager.get_layout_direction() + if layout_direction == LAYOUT_HORIZONTAL: + # 左右布局:不限制高度,让其充分利用可用空间 + pass + else: + # 上下布局:移除高度限制,允许描述区域正常扩展 + pass + + desc_widget_container = QWidget() + desc_layout = QVBoxLayout(desc_widget_container) + desc_layout.setContentsMargins(15, 5, 15, 15) + + self.description_label = SelectableLabel(self.prompt, self) + self.description_label.setProperty("class", "prompt-label") + self.description_label.setWordWrap(True) + # 在左右布局模式下,确保文字从顶部开始对齐 + if layout_direction == LAYOUT_HORIZONTAL: + self.description_label.setAlignment( + Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft + ) + desc_layout.addWidget(self.description_label) + + self.status_label = SelectableLabel("", self) + self.status_label.setWordWrap(True) + self.status_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.status_label.setVisible(False) + desc_layout.addWidget(self.status_label) + + # 在左右布局模式下,添加弹性空间确保内容顶部对齐 + if layout_direction == LAYOUT_HORIZONTAL: + desc_layout.addStretch() + + scroll_area.setWidget(desc_widget_container) + parent_layout.addWidget(scroll_area) + + def _create_options_checkboxes(self, parent_layout: QVBoxLayout): + self.option_checkboxes: list[QCheckBox] = [] + self.options_frame = QFrame() + + # 动态调整:设置选项框架的大小策略为可扩展,允许动态调整高度 + self.options_frame.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + + self.options_layout = QVBoxLayout(self.options_frame) + # 使用负边距补偿复选框宽度(~20px)和间距(5px),实现与提示文字的精确对齐 + self.options_layout.setContentsMargins(-10, 0, 0, 0) + + # 动态间距:初始设置为默认间距,后续会根据可用空间动态调整 + from .utils.constants import DEFAULT_OPTION_SPACING + + self.current_option_spacing = DEFAULT_OPTION_SPACING + self.options_layout.setSpacing(self.current_option_spacing) + + for i, option_text in enumerate(self.predefined_options): + # 创建一个水平容器用于放置复选框和可选择的标签 + option_container = QWidget() + option_container_layout = QHBoxLayout(option_container) + option_container_layout.setContentsMargins(0, 0, 0, 0) + option_container_layout.setSpacing(5) + + # 创建无文本的复选框 + checkbox = QCheckBox("", self) + checkbox.setObjectName(f"optionCheckbox_{i}") + + # 应用主题样式,确保覆盖系统默认蓝色 + self._apply_checkbox_theme_style(checkbox) + + # 创建可选择文本的标签 + label = SelectableLabel(option_text, self) + label.setProperty("class", "option-label") + label.setWordWrap(True) + + # 连接标签的点击信号到复选框的切换方法 + label.clicked.connect(checkbox.toggle) + + # 将复选框和标签添加到水平容器 + option_container_layout.addWidget(checkbox) + option_container_layout.addWidget(label, 1) # 标签使用剩余的空间 + + # 将复选框添加到列表,保持与原有逻辑兼容 + self.option_checkboxes.append(checkbox) + + # 将整个容器添加到选项布局 + self.options_layout.addWidget(option_container) + + parent_layout.addWidget(self.options_frame) + + # 延迟初始化动态间距计算,确保所有选项都已创建 + QTimer.singleShot(200, self._setup_dynamic_option_spacing) + + def _apply_checkbox_theme_style(self, checkbox: QCheckBox): + """为复选框应用主题相关的样式,确保覆盖系统默认蓝色""" + from .utils.ui_factory import apply_theme_aware_styling + + current_theme = self.settings_manager.get_current_theme() + + # 使用增强的主题感知样式 + apply_theme_aware_styling(checkbox, current_theme) + + def _update_all_checkbox_styles(self): + """更新所有复选框的样式(主题切换时调用)""" + if hasattr(self, "option_checkboxes"): + for checkbox in self.option_checkboxes: + self._apply_checkbox_theme_style(checkbox) + + def _update_optimization_buttons_styles(self): + """更新优化按钮的样式(主题切换时调用)""" + if hasattr(self, "optimize_button"): + self._apply_optimization_button_style(self.optimize_button) + if hasattr(self, "enhance_button"): + self._apply_optimization_button_style(self.enhance_button) + + def _update_optimization_buttons_visibility(self): + """更新优化按钮的可见性(设置变更时调用)""" + if hasattr(self, "optimize_button") and hasattr(self, "enhance_button"): + enabled = self._get_optimization_enabled_status() + self.optimize_button.setVisible(enabled) + self.enhance_button.setVisible(enabled) + # 重新应用样式以确保布局正确 + if enabled: + self._apply_optimization_button_style(self.optimize_button) + self._apply_optimization_button_style(self.enhance_button) + + def _setup_dynamic_option_spacing(self): + """设置动态选项间距功能""" + # 立即执行,因为已经延迟调用了这个方法 + self._update_option_spacing() + + def _calculate_dynamic_option_spacing(self) -> int: + """计算动态选项间距""" + from .utils.constants import ( + DEFAULT_OPTION_SPACING, + MAX_OPTION_SPACING, + MIN_OPTION_SPACING, + ) + + try: + # 获取当前布局方向 + layout_direction = self.settings_manager.get_layout_direction() + + # 获取容器和内容信息 + container_height = 0 + content_height = 0 + + if layout_direction == "horizontal": + # 水平布局:检查左侧区域的可用空间 + if hasattr(self, "left_area") and hasattr(self, "description_label"): + container_height = self.left_area.height() + content_height = self._get_description_content_height() + else: + return DEFAULT_OPTION_SPACING + else: + # 垂直布局:检查上部区域的可用空间 + if hasattr(self, "upper_area") and hasattr(self, "description_label"): + container_height = self.upper_area.height() + content_height = self._get_description_content_height() + else: + return DEFAULT_OPTION_SPACING + + # 计算选项区域的基础高度需求 + option_count = ( + len(self.predefined_options) if self.predefined_options else 0 + ) + if option_count == 0: + return DEFAULT_OPTION_SPACING + + # 估算每个选项的基础高度(复选框 + 文本) + base_option_height = 30 # 调整为更准确的选项高度 + base_options_height = option_count * base_option_height + + # 计算选项间距的总高度(选项数量-1个间距) + total_spacing_height = max(0, option_count - 1) * DEFAULT_OPTION_SPACING + + # 计算可用的额外空间 + available_space = ( + container_height + - content_height + - base_options_height + - total_spacing_height + - 80 + ) # 增加边距缓冲 + + if available_space > 50: # 只有当可用空间足够大时才增加间距 + # 计算可以增加的间距,使用更保守的算法 + extra_spacing_per_gap = min( + available_space // max(1, option_count + 1), 16 + ) # 限制最大额外间距 + new_spacing = min( + DEFAULT_OPTION_SPACING + extra_spacing_per_gap, MAX_OPTION_SPACING + ) + return max(new_spacing, MIN_OPTION_SPACING) + else: + return DEFAULT_OPTION_SPACING + + except Exception as e: + print(f"DEBUG: 计算动态间距时出错: {e}", file=sys.stderr) + return DEFAULT_OPTION_SPACING + + def _get_description_content_height(self) -> int: + """获取描述文字的实际内容高度""" + try: + if hasattr(self, "description_label"): + # 获取文本的实际渲染高度 + font_metrics = self.description_label.fontMetrics() + text = self.description_label.text() + + # 计算文本在当前宽度下的高度 + available_width = self.description_label.width() - 20 # 减去边距 + if available_width > 0: + text_rect = font_metrics.boundingRect( + 0, 0, available_width, 0, Qt.TextFlag.TextWordWrap, text + ) + return text_rect.height() + 40 # 加上一些边距 + return 100 # 默认高度 + except Exception as e: + print(f"DEBUG: 获取描述内容高度时出错: {e}", file=sys.stderr) + return 100 + + def _update_option_spacing(self): + """更新选项间距""" + try: + if hasattr(self, "options_layout") and hasattr(self, "predefined_options"): + new_spacing = self._calculate_dynamic_option_spacing() + if new_spacing != self.current_option_spacing: + self.current_option_spacing = new_spacing + self.options_layout.setSpacing(new_spacing) + except Exception as e: + print(f"DEBUG: 更新选项间距时出错: {e}", file=sys.stderr) + + def _setup_resize_monitoring(self): + """设置窗口大小变化监听""" + # 创建定时器,用于延迟处理窗口大小变化 + self.resize_timer = QTimer() + self.resize_timer.setSingleShot(True) + self.resize_timer.timeout.connect(self._on_window_resized) + + def resizeEvent(self, event): + """窗口大小变化事件""" + super().resizeEvent(event) + # 延迟更新选项间距,避免频繁计算 + if hasattr(self, "resize_timer"): + self.resize_timer.start(300) # 300ms延迟,避免与初始化定时器冲突 + + def _on_window_resized(self): + """窗口大小变化后的处理""" + # 重新计算选项间距 + self._update_option_spacing() + + def _create_input_submission_area(self, parent_layout: QVBoxLayout): + self.text_input = FeedbackTextEdit(self) + # 动态设置占位符文本 + self._update_placeholder_text() + + # 连接焦点事件来动态控制placeholder显示 + self.text_input.focusInEvent = self._on_text_input_focus_in + self.text_input.focusOutEvent = self._on_text_input_focus_out + + # QTextEdit should expand vertically, so we give it a stretch factor + parent_layout.addWidget(self.text_input, 1) + + def _setup_bottom_bar(self, parent_layout: QVBoxLayout): + """Creates the bottom bar with canned responses, pin, and settings buttons.""" + bottom_bar_widget = QWidget() + bottom_layout = QHBoxLayout(bottom_bar_widget) + bottom_layout.setContentsMargins(0, 3, 0, 3) + bottom_layout.setSpacing(10) + + current_language = self.settings_manager.get_current_language() + + # 使用语言相关的文本 + self.canned_responses_button = QPushButton( + self.button_texts["canned_responses_button"][current_language] + ) + self.canned_responses_button.setObjectName("secondary_button") + + # 为常用语按钮添加hover事件处理 + self.canned_responses_button.enterEvent = self._on_canned_responses_button_enter + self.canned_responses_button.leaveEvent = self._on_canned_responses_button_leave + + # 初始化hover预览窗口变量 + self.canned_responses_preview_window = None + + bottom_layout.addWidget(self.canned_responses_button) + + # 选择文件按钮 + self.select_file_button = QPushButton( + self.button_texts["select_file_button"][current_language] + ) + self.select_file_button.setObjectName("secondary_button") + bottom_layout.addWidget(self.select_file_button) + + # 截图按钮 + self.screenshot_button = QPushButton( + self.button_texts["screenshot_button"][current_language] + ) + self.screenshot_button.setObjectName("secondary_button") + bottom_layout.addWidget(self.screenshot_button) + + self.pin_window_button = QPushButton( + self.button_texts["pin_window_button"][current_language] + ) + self.pin_window_button.setCheckable(True) + self.pin_window_button.setObjectName("secondary_button") + bottom_layout.addWidget(self.pin_window_button) + + # --- Settings Button (设置按钮) --- + self.settings_button = QPushButton( + self.button_texts["settings_button"][current_language] + ) + self.settings_button.setObjectName("secondary_button") + bottom_layout.addWidget(self.settings_button) + + # V4.0 新增:优化按钮 + self._create_optimization_buttons(bottom_layout, current_language) + + # 智能空间分配:减少右侧空白,但保持一定的弹性空间 + bottom_layout.addStretch(1) # 添加适度的弹性空间 + + parent_layout.addWidget(bottom_bar_widget) + + def _create_optimization_buttons(self, layout, current_language): + """V4.0 新增:创建优化按钮""" + # 优化按钮 + self.optimize_button = QPushButton( + self.button_texts["optimize_button"][current_language] + ) + self.optimize_button.setObjectName("optimization_button") + # 应用主题感知的样式 + self._apply_optimization_button_style(self.optimize_button) + layout.addWidget(self.optimize_button) + + # 增强按钮 + self.enhance_button = QPushButton( + self.button_texts["enhance_button"][current_language] + ) + self.enhance_button.setObjectName("optimization_button") + # 应用主题感知的样式 + self._apply_optimization_button_style(self.enhance_button) + layout.addWidget(self.enhance_button) + + # 初始化时立即设置正确的可见性,避免后续布局变化 + self._set_initial_optimization_buttons_visibility() + + def _apply_optimization_button_style(self, button: QPushButton): + """为优化按钮应用主题感知的样式""" + from .utils.theme_colors import ThemeColors + + current_theme = self.settings_manager.get_current_theme() + colors = ThemeColors.get_optimization_button_colors(current_theme) + + button_style = f""" + QPushButton#optimization_button {{ + min-width: 95px; + max-width: 110px; + min-height: 42px; + max-height: 42px; + border-radius: 21px; + background-color: {colors['bg_color']}; + color: {colors['text_color']}; + border: 2px solid {colors['border_color']}; + font-size: 12px; + font-weight: bold; + padding: 0px 16px; + margin: 0px 3px; + }} + QPushButton#optimization_button:hover {{ + background-color: {colors['hover_bg']}; + border-color: {colors['hover_border']}; + }} + QPushButton#optimization_button:pressed {{ + background-color: {colors['pressed_bg']}; + border-color: {colors['pressed_border']}; + }} + """ + button.setStyleSheet(button_style) + + def _get_optimization_enabled_status(self) -> bool: + """获取优化功能启用状态的统一方法""" + try: + # 检查优化功能是否启用 + import sys + import os + + # 添加项目根目录到路径 + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + # 兼容包安装模式和开发模式的导入 + try: + from interactive_feedback_server.utils import get_config + except ImportError: + from src.interactive_feedback_server.utils import get_config + + config = get_config() + optimizer_config = config.get("expression_optimizer", {}) + return optimizer_config.get("enabled", False) + + except Exception as e: + print(f"DEBUG: 获取优化功能状态失败: {e}", file=sys.stderr) + return False + + def _set_initial_optimization_buttons_visibility(self): + """初始化时设置优化按钮的可见性,避免后续布局变化""" + enabled = self._get_optimization_enabled_status() + self.optimize_button.setVisible(enabled) + self.enhance_button.setVisible(enabled) + + def _create_github_link_area(self, parent_layout: QVBoxLayout): + """Creates the GitHub link at the bottom.""" + github_container = QWidget() + github_layout = QHBoxLayout(github_container) + github_layout.setContentsMargins(0, 5, 0, 0) + + # 重构:使用可点击的纯文本标签而不是HTML链接 + github_label = QLabel("GitHub") + github_label.setCursor(Qt.CursorShape.PointingHandCursor) + + # 启用文本选择功能 + github_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + # 设置灰色文字颜色,与主题协调 + github_label.setStyleSheet( + "font-size: 10pt; color: #666666; text-decoration: underline;" + ) + + # 连接点击事件 + github_label.mousePressEvent = lambda event: self._open_github_link() + + # 设置选择文本时的高亮颜色为灰色 + set_selection_colors(github_label) + + github_layout.addStretch() + github_layout.addWidget(github_label) + github_layout.addStretch() + parent_layout.addWidget(github_container) + + def _open_github_link(self): + """打开GitHub链接""" + import webbrowser + + webbrowser.open("https://github.com/pawaovo/interactive-feedback-mcp") + + def _connect_signals(self): + self.text_input.textChanged.connect(self._update_submit_button_text_status) + self.canned_responses_button.clicked.connect(self._show_canned_responses_dialog) + self.select_file_button.clicked.connect(self._open_file_dialog) + self.screenshot_button.clicked.connect(self._take_screenshot) + self.pin_window_button.toggled.connect(self._toggle_pin_window_action) + self.settings_button.clicked.connect(self.open_settings_dialog) + # V4.0 新增:连接优化按钮事件 + self.optimize_button.clicked.connect(self._optimize_text) + self.enhance_button.clicked.connect(self._reinforce_text) + self.submit_button.clicked.connect(self._prepare_and_submit_feedback) + + def event(self, event: QEvent) -> bool: + if event.type() == QEvent.Type.WindowDeactivate: + if ( + not self.window_pinned + and self.isVisible() + and not self.isMinimized() + and not self.disable_auto_minimize + ): + QTimer.singleShot(100, self.showMinimized) + return super().event(event) + + def closeEvent(self, event: QEvent): + """窗口关闭事件 - 保存状态并清理资源""" + try: + # 清理预览窗口资源 + self._cleanup_preview_resources() + + # 保存分割器状态 + if hasattr(self, "main_splitter"): + sizes = self.main_splitter.sizes() + self.settings_manager.set_splitter_sizes(sizes) + self.settings_manager.set_splitter_state(self.main_splitter.saveState()) + + # 保存窗口几何和状态(使用Qt标准方法) + self.settings_manager.set_main_window_geometry(self.saveGeometry()) + self.settings_manager.set_main_window_state(self.saveState()) + self.settings_manager.set_main_window_pinned(self.window_pinned) + + # 确保在用户直接关闭窗口时也返回空结果 + # 此处不需要检查 self.output_result 是否已设置,因为在 __init__ 中已初始化为空结果 + # 如果没有显式通过 _prepare_and_submit_feedback 设置结果,则保持初始的空结果 + + except Exception as e: + print(f"DEBUG: 窗口关闭时清理资源出错: {e}", file=sys.stderr) + finally: + super().closeEvent(event) + + def _load_canned_responses_from_settings(self): + self.canned_responses = self.settings_manager.get_canned_responses() + + def _update_submit_button_text_status(self): + has_text = bool(self.text_input.toPlainText().strip()) + has_images = bool(self.image_widgets) + + has_options_selected = any(cb.isChecked() for cb in self.option_checkboxes) + + # 修改:按钮应始终可点击,即使没有内容,以支持提交空反馈 + # self.submit_button.setEnabled(has_text or has_images or has_options_selected) + self.submit_button.setEnabled(True) + + def _show_canned_responses_dialog(self): + # 立即设置自动最小化保护,确保在任何操作之前就有保护 + self.disable_auto_minimize = True + + # 禁用预览功能,防止对话框触发预览窗口 + self._preview_disabled = True + # 安全隐藏任何现有的预览窗口 + if self.canned_responses_preview_window: + self._safe_close_preview_window() + + dialog = SelectCannedResponseDialog(self.canned_responses, self) + dialog.exec() + + self.disable_auto_minimize = False + # 延迟重新启用预览功能,确保双击操作完全完成且鼠标事件处理完毕 + QTimer.singleShot(500, self._re_enable_preview) + # After the dialog closes, settings are updated internally by the dialog. + # We just need to reload them here. + self._load_canned_responses_from_settings() + + def _re_enable_preview(self): + """重新启用预览功能""" + self._preview_disabled = False + + def _open_file_dialog(self): + """打开文件选择对话框,允许用户选择多个文件""" + # 禁用自动最小化,防止对话框导致窗口最小化 + self.disable_auto_minimize = True + + try: + file_paths, _ = QFileDialog.getOpenFileNames( + self, + "选择文件 (Select Files)", + "", # 默认目录 + "所有文件 (All Files) (*.*)", + ) + + if file_paths: # 用户选择了文件 + self._process_selected_files(file_paths) + + except Exception as e: + print(f"ERROR: 文件选择对话框出错: {e}", file=sys.stderr) + finally: + # 恢复自动最小化功能 + self.disable_auto_minimize = False + + def _process_selected_files(self, file_paths: list[str]): + """处理用户选择的文件列表""" + from .utils.constants import SUPPORTED_IMAGE_EXTENSIONS + + for file_path in file_paths: + try: + if not os.path.isfile(file_path): + continue + + file_name = os.path.basename(file_path) + file_ext = os.path.splitext(file_path)[1].lower() + + # 判断是否为图片文件 + if file_ext in SUPPORTED_IMAGE_EXTENSIONS: + self._process_selected_image(file_path) + else: + self._process_selected_file(file_path, file_name) + + except Exception as e: + print(f"ERROR: 处理文件失败 {file_path}: {e}", file=sys.stderr) + + def _process_selected_image(self, file_path: str): + """处理选择的图片文件""" + try: + pixmap = QPixmap(file_path) + if not pixmap.isNull() and pixmap.width() > 0: + self.add_image_preview(pixmap) + else: + print(f"WARNING: 无法加载图片: {file_path}", file=sys.stderr) + except Exception as e: + print(f"ERROR: 加载图片失败 {file_path}: {e}", file=sys.stderr) + + def _process_selected_file(self, file_path: str, file_name: str): + """处理选择的普通文件""" + try: + # 复用现有的文件引用插入逻辑 + self.text_input._insert_file_reference_text(self, file_path, file_name) + + # 设置焦点到输入框 + self.text_input.setFocus() + + except Exception as e: + print(f"ERROR: 插入文件引用失败 {file_path}: {e}", file=sys.stderr) + + def _get_project_path(self) -> str: + """获取项目路径,优先使用当前工作目录""" + try: + # 首先尝试获取当前工作目录 + current_path = os.getcwd() + if os.path.exists(current_path): + return current_path + except Exception: + pass + + # 如果获取失败,使用用户主目录 + try: + return os.path.expanduser("~") + except Exception: + # 最后的回退选项 + return "C:\\" if os.name == "nt" else "/" + + def open_settings_dialog(self): + """Opens the settings dialog with Mac compatibility.""" + self.disable_auto_minimize = True + + try: + dialog = SettingsDialog(self) + + # Mac系统兼容性:确保对话框正确显示 + dialog.show() + dialog.raise_() + dialog.activateWindow() + + # 执行对话框 + result = dialog.exec() + + except Exception as e: + print(f"ERROR: 设置对话框打开失败: {e}", file=sys.stderr) + import traceback + + print(f"ERROR: 详细错误信息: {traceback.format_exc()}", file=sys.stderr) + finally: + self.disable_auto_minimize = False + + def _apply_window_flags(self): + """应用窗口标志 - 统一的窗口标志设置方法""" + if self.window_pinned: + # 固定窗口:添加置顶标志,保留所有标准窗口功能 + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.WindowMinimizeButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowStaysOnTopHint + ) + else: + # 标准窗口:使用标准窗口标志,确保所有按钮功能正常 + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowSystemMenuHint + | Qt.WindowType.WindowMinimizeButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + | Qt.WindowType.WindowCloseButtonHint + ) + + def _apply_pin_state_on_load(self): + # 从设置中加载固定窗口状态,但不改变按钮样式 + self.pin_window_button.setChecked(self.window_pinned) + + # 应用窗口标志(使用统一的方法) + self._apply_window_flags() + + # 设置按钮样式 + if self.window_pinned: + self.pin_window_button.setObjectName("pin_window_active") + else: + self.pin_window_button.setObjectName("secondary_button") + + # 只应用样式到固定窗口按钮,避免影响其他按钮 + self.pin_window_button.style().unpolish(self.pin_window_button) + self.pin_window_button.style().polish(self.pin_window_button) + self.pin_window_button.update() + + def _toggle_pin_window_action(self): + # 获取按钮当前的勾选状态 + self.window_pinned = self.pin_window_button.isChecked() + self.settings_manager.set_main_window_pinned(self.window_pinned) + + # 保存当前窗口几何信息 + current_geometry = self.saveGeometry() + + # 应用窗口标志(使用统一的方法) + self._apply_window_flags() + + # 设置按钮样式 + if self.window_pinned: + self.pin_window_button.setObjectName("pin_window_active") + else: + self.pin_window_button.setObjectName("secondary_button") + + # 只应用样式变化到固定窗口按钮,避免影响其他按钮 + self.pin_window_button.style().unpolish(self.pin_window_button) + self.pin_window_button.style().polish(self.pin_window_button) + self.pin_window_button.update() + + # 重新显示窗口并恢复几何信息(因为改变了窗口标志) + self.show() + self.restoreGeometry(current_geometry) + + def add_image_preview(self, pixmap: QPixmap) -> int | None: + if pixmap and not pixmap.isNull(): + image_id = self.next_image_id + self.next_image_id += 1 + + image_widget = ImagePreviewWidget( + pixmap, image_id, self.text_input.images_container + ) + image_widget.image_deleted.connect(self._remove_image_widget) + + self.text_input.images_layout.addWidget(image_widget) + self.image_widgets[image_id] = image_widget + + self.text_input.show_images_container(True) + self._update_submit_button_text_status() + return image_id + return None + + def _remove_image_widget(self, image_id: int): + if image_id in self.image_widgets: + widget_to_remove = self.image_widgets.pop(image_id) + self.text_input.images_layout.removeWidget(widget_to_remove) + widget_to_remove.deleteLater() + + if not self.image_widgets: + self.text_input.show_images_container(False) + self._update_submit_button_text_status() + + def _prepare_and_submit_feedback(self): + final_content_list: list[ContentItem] = [] + feedback_plain_text = self.text_input.toPlainText().strip() + + # 获取选中的选项 + selected_options = [] + for i, checkbox in enumerate(self.option_checkboxes): + if checkbox.isChecked() and i < len(self.predefined_options): + # 使用预定义选项列表中的文本 + selected_options.append(self.predefined_options[i]) + + combined_text_parts = [] + if selected_options: + combined_text_parts.append("; ".join(selected_options)) + if feedback_plain_text: + combined_text_parts.append(feedback_plain_text) + + final_text = "\n".join(combined_text_parts).strip() + # 允许提交空内容,即使 final_text 为空 + if final_text: + final_content_list.append({"type": "text", "text": final_text}) + + image_items = get_image_items_from_widgets(self.image_widgets) + final_content_list.extend(image_items) + + # 处理文件引用(恢复之前移除的代码) + current_text_content_for_refs = self.text_input.toPlainText() + file_references = { + k: v + for k, v in self.dropped_file_references.items() + if k in current_text_content_for_refs + } + + # 将文件引用添加到final_content_list中,确保AI收到完整路径信息 + for display_name, file_path in file_references.items(): + file_reference_item: ContentItem = { + "type": "file_reference", + "display_name": display_name, + "path": file_path, + "text": None, + "data": None, + "mimeType": None, + } + final_content_list.append(file_reference_item) + + # 不管 final_content_list 是否为空,都设置结果并关闭窗口 + self.output_result = FeedbackResult(content=final_content_list) + + # 保存窗口几何和状态信息,确保即使通过提交反馈关闭窗口时也能保存这些信息 + # 使用Qt标准方法保存完整的几何信息 + self.settings_manager.set_main_window_geometry(self.saveGeometry()) + self.settings_manager.set_main_window_state(self.saveState()) + + self.close() + + def _cleanup_preview_resources(self): + """清理预览窗口相关资源""" + # 停止计时器 + self._stop_hide_timer() + if hasattr(self, "_hide_timer"): + self._hide_timer = None + + # 安全关闭预览窗口 + if self.canned_responses_preview_window: + self._safe_close_preview_window() + + def run_ui_and_get_result(self) -> FeedbackResult: + # 延迟显示窗口,确保所有初始化完成 + QTimer.singleShot(10, self._show_window_when_ready) + + app_instance = QApplication.instance() + if app_instance: + app_instance.exec() + + # 直接返回 self.output_result,它在 __init__ 中已初始化为空结果 + # 如果用户有提交内容,它已在 _prepare_and_submit_feedback 中被更新 + return self.output_result + + def _show_window_when_ready(self): + """在窗口完全准备好后显示""" + self.show() + self.activateWindow() + + # 延迟设置焦点,确保窗口完全显示 + QTimer.singleShot(50, self._set_initial_focus) + + # 播放提示音 + self._play_notification_sound() + + def _play_notification_sound(self): + """播放提示音""" + try: + if hasattr(self, "audio_manager") and self.audio_manager: + # 获取自定义音频文件路径 + custom_sound_path = self.settings_manager.get_notification_sound_path() + + # 播放提示音 + self.audio_manager.play_notification_sound( + custom_sound_path if custom_sound_path else None + ) + + except Exception as e: + print(f"播放提示音时出错: {e}", file=sys.stderr) + + def _set_initial_focus(self): + """Sets initial focus to the feedback text edit.""" + if hasattr(self, "text_input") and self.text_input: + self.text_input.setFocus(Qt.FocusReason.OtherFocusReason) + cursor = self.text_input.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.text_input.setTextCursor(cursor) + self.text_input.ensureCursorVisible() + + # --- 截图功能 (Screenshot Functions) --- + def _take_screenshot(self): + """开始截图流程""" + try: + # 保存当前窗口状态 + self._save_window_state_for_screenshot() + + # 最小化主窗口(即使在固定状态下) + self._minimize_for_screenshot() + + # 增加延迟时间确保窗口完全最小化,减少闪烁 + QTimer.singleShot( + SCREENSHOT_WINDOW_MINIMIZE_DELAY, self._show_screenshot_window + ) + + except Exception as e: + print(f"ERROR: 截图流程启动失败: {e}", file=sys.stderr) + self._restore_window_after_screenshot() + + def _save_window_state_for_screenshot(self): + """保存窗口状态用于截图后恢复""" + self._screenshot_window_geometry = self.saveGeometry() + self._screenshot_window_state = self.saveState() + self._screenshot_was_pinned = self.window_pinned + self._screenshot_was_visible = self.isVisible() + + def _minimize_for_screenshot(self): + """为截图最小化窗口""" + # 临时禁用自动最小化,避免干扰 + self.disable_auto_minimize = True + + # 最小化窗口 + self.showMinimized() + + def _show_screenshot_window(self): + """显示截图窗口""" + try: + # 创建截图窗口 + self.screenshot_window = ScreenshotWindow(self) + + # 连接信号 + self.screenshot_window.screenshot_taken.connect(self._on_screenshot_taken) + self.screenshot_window.screenshot_cancelled.connect( + self._on_screenshot_cancelled + ) + + print("DEBUG: 截图窗口已显示", file=sys.stderr) + + except Exception as e: + print(f"ERROR: 显示截图窗口失败: {e}", file=sys.stderr) + self._restore_window_after_screenshot() + + def _on_screenshot_taken(self, pixmap): + """截图完成回调""" + try: + # 恢复主窗口 + self._restore_window_after_screenshot() + + # 将截图添加到输入框 + if pixmap and not pixmap.isNull(): + self.add_image_preview(pixmap) + + except Exception as e: + print(f"ERROR: 处理截图失败: {e}", file=sys.stderr) + self._restore_window_after_screenshot() + + def _on_screenshot_cancelled(self): + """截图取消回调""" + self._restore_window_after_screenshot() + + def _restore_window_after_screenshot(self): + """截图后恢复窗口状态""" + try: + # 重新启用自动最小化 + self.disable_auto_minimize = False + + # 恢复窗口显示 + if ( + hasattr(self, "_screenshot_was_visible") + and self._screenshot_was_visible + ): + # 先显示窗口 + self.show() + + # 恢复窗口几何信息 + if hasattr(self, "_screenshot_window_geometry"): + self.restoreGeometry(self._screenshot_window_geometry) + + # 恢复窗口状态 + if hasattr(self, "_screenshot_window_state"): + self.restoreState(self._screenshot_window_state) + + # 强制激活窗口并置顶 + self.setWindowState( + self.windowState() & ~Qt.WindowState.WindowMinimized + | Qt.WindowState.WindowActive + ) + self.activateWindow() + self.raise_() + + # 延迟设置焦点,确保窗口完全恢复 + QTimer.singleShot( + SCREENSHOT_FOCUS_DELAY, self._set_focus_after_screenshot + ) + + # 清理临时变量 + self._cleanup_screenshot_variables() + + except Exception as e: + print(f"ERROR: 恢复窗口状态失败: {e}", file=sys.stderr) + # 确保重新启用自动最小化 + self.disable_auto_minimize = False + + def _set_focus_after_screenshot(self): + """截图后设置焦点""" + try: + # 再次确保窗口激活 + self.activateWindow() + self.raise_() + + # 设置焦点到输入框 + if hasattr(self, "text_input"): + self.text_input.setFocus() + + except Exception as e: + print(f"ERROR: 设置焦点失败: {e}", file=sys.stderr) + + def _cleanup_screenshot_variables(self): + """清理截图相关的临时变量""" + attrs_to_remove = [ + "_screenshot_window_geometry", + "_screenshot_window_state", + "_screenshot_was_pinned", + "_screenshot_was_visible", + ] + + for attr in attrs_to_remove: + if hasattr(self, attr): + delattr(self, attr) + + # 清理截图窗口引用 + if hasattr(self, "screenshot_window"): + self.screenshot_window = None + + def changeEvent(self, event: QEvent): + """处理语言变化事件,更新界面文本""" + if event.type() == QEvent.Type.LanguageChange: + print("FeedbackUI: 接收到语言变化事件,更新UI文本") + # 更新所有文本 + self._update_displayed_texts() + super().changeEvent(event) + + def _update_displayed_texts(self): + """根据当前语言设置更新显示的文本内容""" + current_lang = self.settings_manager.get_current_language() + + # 更新提示文字 + if self.description_label: + self.description_label.setText( + self._filter_text_by_language(self.prompt, current_lang) + ) + + # 更新选项复选框的关联标签 + for i, checkbox in enumerate(self.option_checkboxes): + if i < len(self.predefined_options): + # 找到复选框所在的容器 + option_container = checkbox.parent() + if option_container: + # 找到容器中的SelectableLabel + for child in option_container.children(): + if isinstance(child, SelectableLabel): + # 更新标签文本 + child.setText( + self._filter_text_by_language( + self.predefined_options[i], current_lang + ) + ) + break + + # 更新按钮文本 + self._update_button_texts(current_lang) + + def _update_button_texts(self, language_code): + """根据当前语言更新所有按钮的文本""" + # 更新提交按钮 + if hasattr(self, "submit_button") and self.submit_button: + self.submit_button.setText( + self.button_texts["submit_button"].get(language_code, "提交") + ) + + # 更新底部按钮 + if hasattr(self, "canned_responses_button") and self.canned_responses_button: + self.canned_responses_button.setText( + self.button_texts["canned_responses_button"].get( + language_code, "常用语" + ) + ) + + if hasattr(self, "select_file_button") and self.select_file_button: + self.select_file_button.setText( + self.button_texts["select_file_button"].get(language_code, "选择文件") + ) + + if hasattr(self, "screenshot_button") and self.screenshot_button: + self.screenshot_button.setText( + self.button_texts["screenshot_button"].get(language_code, "窗口截图") + ) + + if hasattr(self, "pin_window_button") and self.pin_window_button: + # 保存当前按钮的样式类名 + current_object_name = self.pin_window_button.objectName() + self.pin_window_button.setText( + self.button_texts["pin_window_button"].get(language_code, "固定窗口") + ) + # 单独刷新固定窗口按钮的样式,避免影响其他按钮 + self.pin_window_button.style().unpolish(self.pin_window_button) + self.pin_window_button.style().polish(self.pin_window_button) + self.pin_window_button.update() + + if hasattr(self, "settings_button") and self.settings_button: + self.settings_button.setText( + self.button_texts["settings_button"].get(language_code, "设置") + ) + + # 单独为提交按钮、常用语按钮和设置按钮刷新样式 + for btn in [ + self.submit_button, + self.canned_responses_button, + self.settings_button, + ]: + if btn: + btn.style().unpolish(btn) + btn.style().polish(btn) + btn.update() + + def _filter_text_by_language(self, text: str, lang_code: str) -> str: + """ + 从双语文本中提取指定语言的部分 + 支持的格式: + - "中文 (English)" 或 "中文(English)" + - "中文 - English" 或类似分隔符 + """ + if not text or not isinstance(text, str): + return text + + # 如果是中文模式 + if lang_code == "zh_CN": + # 格式1:标准括号格式 "中文 (English)" 或 "中文(English)" + match = re.match(r"^(.*?)[\s]*[\((].*?[\))](\s*|$)", text) + if match: + return match.group(1).strip() + + # 格式2:中英文之间有破折号或其他分隔符 "中文 - English" + match = re.match(r"^(.*?)[\s]*[-—–][\s]*[A-Za-z].*?$", text) + if match: + return match.group(1).strip() + + # 如果都不匹配,可能是纯中文,直接返回 + return text + + # 如果是英文模式 + elif lang_code == "en_US": + # 格式1:标准括号格式,提取括号内的英文 + match = re.search(r"[\((](.*?)[\))]", text) + if match: + return match.group(1).strip() + + # 格式2:中英文之间有破折号或其他分隔符 "中文 - English" + match = re.search(r"[-—–][\s]*(.*?)$", text) + if match and re.search(r"[A-Za-z]", match.group(1)): + return match.group(1).strip() + + # 如果上述格式都不匹配,检查是否包含英文单词 + if re.search(r"[A-Za-z]{2,}", text): # 至少包含2个连续英文字母 + return text + + # 可能是纯中文,那就返回原文本 + return text + + # 默认返回原文本 + return text + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + """ + 事件过滤器,用于实现无论点击窗口哪个区域,都自动保持文本输入框的活跃状态。 + Event filter to keep the text input active regardless of where the user clicks. + """ + if event.type() == QEvent.Type.MouseButtonPress: + # 对于任何鼠标点击,都激活输入框 + # For any mouse click, activate the text input + + # 如果文本输入框当前没有焦点,则设置焦点并移动光标到末尾 + if not self.text_input.hasFocus(): + self.text_input.setFocus() + cursor = self.text_input.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.text_input.setTextCursor(cursor) + + # 重要:不消耗事件,让它继续传递,确保被点击的控件(如按钮)能正常响应 + # Important: Don't consume the event, let it pass through to ensure clicked controls (like buttons) respond normally + + # 将事件传递给父类处理,保持所有控件的原有功能 + return super().eventFilter(obj, event) + + def _on_text_input_focus_in(self, event): + """输入框获得焦点时的处理 - 隐藏placeholder text""" + # 调用原始的focusInEvent + FeedbackTextEdit.focusInEvent(self.text_input, event) + + # 如果输入框为空,临时清除placeholder text以避免显示 + if not self.text_input.toPlainText().strip(): + self.text_input.setPlaceholderText("") + + def _on_text_input_focus_out(self, event): + """输入框失去焦点时的处理 - 恢复placeholder text""" + # 调用原始的focusOutEvent + FeedbackTextEdit.focusOutEvent(self.text_input, event) + + # 如果输入框为空,恢复placeholder text + if not self.text_input.toPlainText().strip(): + self._update_placeholder_text() + + def _on_canned_responses_button_enter(self, event): + """常用语按钮鼠标进入事件 - 显示常用语预览""" + # 调用原始的enterEvent + QPushButton.enterEvent(self.canned_responses_button, event) + + # 如果有常用语且没有禁用预览,显示预览窗口 + if self.canned_responses and not getattr(self, "_preview_disabled", False): + self._show_canned_responses_preview() + + def _on_canned_responses_button_leave(self, event): + """常用语按钮鼠标离开事件 - 延迟隐藏常用语预览""" + # 调用原始的leaveEvent + QPushButton.leaveEvent(self.canned_responses_button, event) + + # 延迟隐藏预览窗口,给用户时间移动到预览窗口 + QTimer.singleShot(200, self._delayed_hide_preview) + + def _on_preview_window_enter(self, event): + """预览窗口鼠标进入事件 - 取消隐藏计时器""" + # 取消任何延迟隐藏计时器 + self._stop_hide_timer() + + def _on_preview_window_leave(self, event): + """预览窗口鼠标离开事件 - 延迟隐藏预览窗口""" + # 使用延迟隐藏而不是立即隐藏,避免事件处理中的竞态条件 + self._start_hide_timer(100) # 100ms延迟,给事件处理足够时间 + + def _delayed_hide_preview(self): + """延迟隐藏预览窗口 - 检查鼠标是否在预览窗口内""" + if ( + self.canned_responses_preview_window + and self.canned_responses_preview_window.isVisible() + ): + # 获取鼠标位置 + from PySide6.QtGui import QCursor + + mouse_pos = QCursor.pos() + + # 检查鼠标是否在预览窗口内 + preview_rect = self.canned_responses_preview_window.geometry() + if not preview_rect.contains(mouse_pos): + # 鼠标不在预览窗口内,安全隐藏窗口 + self._safe_hide_preview() + + def _start_hide_timer(self, delay_ms: int): + """启动隐藏计时器""" + self._stop_hide_timer() # 先停止现有计时器 + + if not hasattr(self, "_hide_timer"): + self._hide_timer = QTimer() + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(self._safe_hide_preview) + + self._hide_timer.start(delay_ms) + + def _stop_hide_timer(self): + """停止隐藏计时器""" + if hasattr(self, "_hide_timer") and self._hide_timer: + self._hide_timer.stop() + + def _safe_hide_preview(self): + """安全隐藏预览窗口 - 避免事件处理中的竞态条件""" + # 使用QTimer.singleShot确保在事件循环的下一次迭代中执行 + QTimer.singleShot(0, self._hide_canned_responses_preview) + + def _show_canned_responses_preview(self): + """显示常用语预览窗口""" + if not self.canned_responses: + return + + # 预先设置自动最小化保护,防止预览窗口交互导致窗口最小化 + self.disable_auto_minimize = True + + # 如果预览窗口已存在,先安全关闭 + if self.canned_responses_preview_window: + self._safe_close_preview_window() + + # 创建预览窗口 + self.canned_responses_preview_window = QWidget() + self.canned_responses_preview_window.setWindowFlags( + Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint + ) + self.canned_responses_preview_window.setAttribute( + Qt.WidgetAttribute.WA_ShowWithoutActivating + ) + + # 使用更安全的事件处理方式 + self._setup_preview_window_events() + + # 主布局 - 直接使用VBoxLayout,不使用滚动区域 + main_layout = QVBoxLayout(self.canned_responses_preview_window) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.setSpacing(1) # 减少间距,与终端预览窗口保持一致 + + # 获取当前主题的颜色配置 + from .utils.theme_colors import ThemeColors + + current_theme = self.settings_manager.get_current_theme() + colors = ThemeColors.get_preview_colors(current_theme) + + bg_color = colors["bg_color"] + border_color = colors["border_color"] + text_color = colors["text_color"] + item_bg = colors["item_bg"] + item_border = colors["item_border"] + item_hover_bg = colors["item_hover_bg"] + item_hover_border = colors["item_hover_border"] + + # 添加所有常用语项目 + for i, response in enumerate(self.canned_responses): + response_label = QLabel(response) + + # 设置固定高度和文本省略模式 + response_label.setFixedHeight(40) # 调整到40px以获得更好的文字显示效果 + response_label.setWordWrap(False) # 禁用自动换行 + + # 使用Qt原生的文本省略功能 + response_label.setTextFormat(Qt.TextFormat.PlainText) + + # 设置文本省略模式为末尾省略 + font_metrics = response_label.fontMetrics() + available_width = 260 - 20 # 预览窗口宽度减去padding + elided_text = font_metrics.elidedText( + response, Qt.TextElideMode.ElideRight, available_width + ) + response_label.setText(elided_text) + + response_label.setStyleSheet( + f""" + QLabel {{ + padding: 4px 10px; + border-radius: 6px; + background-color: {item_bg}; + color: {text_color}; + border: 1px solid {item_border}; + margin: 1px 0px; + }} + QLabel:hover {{ + background-color: {item_hover_bg}; + border-color: {item_hover_border}; + color: white; + }} + """ + ) + response_label.setCursor(Qt.CursorShape.PointingHandCursor) + + # 为每个标签添加点击事件 + response_label.mousePressEvent = ( + lambda event, text=response: self._on_preview_item_clicked(text) + ) + + main_layout.addWidget(response_label) + + # 设置窗口样式(包含阴影效果) + self.canned_responses_preview_window.setStyleSheet( + f""" + QWidget {{ + background-color: {bg_color}; + border: 1px solid {border_color}; + border-radius: 10px; + }} + """ + ) + + # 计算位置(在按钮上方显示) + button_pos = self.canned_responses_button.mapToGlobal( + self.canned_responses_button.rect().topLeft() + ) + preview_width = 280 # 减少宽度,使预览窗口更紧凑 + + # 根据实际常用语数量动态计算高度,不限制最大数量 + # 每个项目40px高度 + 间距1px + 上下边距16px + item_height = 40 + spacing = 1 + padding = 16 + + # 计算总高度:项目高度 + 间距 + 边距 + if len(self.canned_responses) > 0: + preview_height = ( + len(self.canned_responses) * item_height # 所有项目的高度 + + max(0, len(self.canned_responses) - 1) * spacing # 项目间距 + + padding # 上下边距 + ) + else: + preview_height = 50 # 最小高度,防止空列表时窗口过小 + + # 在按钮上方显示 + x = button_pos.x() + y = button_pos.y() - preview_height - 10 + + self.canned_responses_preview_window.setGeometry( + x, y, preview_width, preview_height + ) + self.canned_responses_preview_window.show() + + def _setup_preview_window_events(self): + """设置预览窗口的事件处理 - 使用更安全的方式""" + if not self.canned_responses_preview_window: + return + + # 创建一个事件过滤器类来处理事件 + class PreviewEventFilter(QObject): + def __init__(self, parent_window): + super().__init__() + self.parent_window = parent_window + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Enter: + self.parent_window._on_preview_window_enter(event) + elif event.type() == QEvent.Type.Leave: + self.parent_window._on_preview_window_leave(event) + return False + + # 创建并安装事件过滤器 + self._preview_event_filter = PreviewEventFilter(self) + self.canned_responses_preview_window.installEventFilter( + self._preview_event_filter + ) + + def _safe_close_preview_window(self): + """安全关闭预览窗口""" + if self.canned_responses_preview_window: + # 停止计时器 + self._stop_hide_timer() + + # 移除事件过滤器 + if hasattr(self, "_preview_event_filter"): + self.canned_responses_preview_window.removeEventFilter( + self._preview_event_filter + ) + self._preview_event_filter = None + + # 关闭窗口 + self.canned_responses_preview_window.close() + self.canned_responses_preview_window = None + + def _hide_canned_responses_preview(self): + """隐藏常用语预览窗口""" + try: + self._safe_close_preview_window() + except Exception as e: + print(f"DEBUG: 隐藏预览窗口时出错: {e}", file=sys.stderr) + finally: + # 确保恢复自动最小化功能 + self.disable_auto_minimize = False + + def _on_preview_item_clicked(self, text): + """预览项目被点击时插入到输入框""" + if self.text_input: + self.text_input.insertPlainText(text) + self.text_input.setFocus() + + # 移动光标到末尾 + cursor = self.text_input.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.text_input.setTextCursor(cursor) + + # 隐藏预览窗口(会自动恢复disable_auto_minimize) + self._hide_canned_responses_preview() + + def update_font_sizes(self): + """ + 通过重新应用当前主题来更新UI中的字体大小。 + style_manager会处理动态字体大小的注入。 + """ + app = QApplication.instance() + if app: + from .utils.style_manager import apply_theme + + current_theme = self.settings_manager.get_current_theme() + apply_theme(app, current_theme) + + # 使用单个定时器统一处理所有样式更新,避免布局闪烁 + QTimer.singleShot(50, self._update_all_styles_after_theme_change) + + def _update_all_styles_after_theme_change(self): + """主题切换后统一更新所有样式,避免多个定时器导致的布局闪烁""" + try: + self._apply_all_style_updates() + except Exception as e: + print(f"DEBUG: 主题切换后样式更新时出错: {e}", file=sys.stderr) + + # V4.0 新增:输入表达优化功能 + def _optimize_text(self): + """一键优化当前输入文本""" + current_text = self.text_input.toPlainText().strip() + if not current_text: + self._show_optimization_message("请先输入要优化的文本") + return + + self._perform_optimization(current_text, "optimize") + + def _reinforce_text(self): + """提示词强化当前输入文本""" + current_text = self.text_input.toPlainText().strip() + if not current_text: + self._show_optimization_message("请先输入要强化的文本") + return + + # 弹出对话框获取强化指令 + from PySide6.QtWidgets import QInputDialog + + self.disable_auto_minimize = True + try: + reinforcement_prompt, ok = QInputDialog.getText( + self, + "提示词强化", + "请输入强化指令(例如:用更专业的语气重写):", + text="", + ) + + if ok and reinforcement_prompt.strip(): + self._perform_optimization( + current_text, "reinforce", reinforcement_prompt.strip() + ) + elif ok: + self._show_optimization_message("强化指令不能为空") + + finally: + self.disable_auto_minimize = False + + def _perform_optimization( + self, text: str, mode: str, reinforcement_prompt: str = None + ): + """执行优化操作 - V4.1 异步加载效果""" + # V4.1 新增:立即显示加载覆盖层 + loading_message = ( + "🔄 正在优化文本,请稍候..." + if mode == "optimize" + else "🔄 正在增强文本,请稍候..." + ) + self.loading_overlay.show_loading(loading_message) + + # 显示加载状态 + self._set_optimization_loading_state(True) + + # V4.1 修复:使用QTimer异步执行优化,避免阻塞UI + QTimer.singleShot( + 50, + lambda: self._execute_optimization_async(text, mode, reinforcement_prompt), + ) + + def _execute_optimization_async( + self, text: str, mode: str, reinforcement_prompt: str = None + ): + """异步执行优化操作 - V4.1 新增""" + try: + # 调用后端MCP工具 + import sys + import os + + # 添加项目根目录到路径 + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + # 兼容包安装模式和开发模式的导入 + try: + from interactive_feedback_server.cli import ( + _optimize_user_input_internal, + ) + except ImportError: + from src.interactive_feedback_server.cli import ( + _optimize_user_input_internal, + ) + + if mode == "reinforce" and reinforcement_prompt: + result = _optimize_user_input_internal(text, mode, reinforcement_prompt) + else: + result = _optimize_user_input_internal(text, mode) + + # V4.1 智能切换:根据结果类型选择不同的反馈方式 + if self._is_optimization_error(result): + # 错误时:隐藏loading,显示详细的错误对话框 + self.loading_overlay.hide_loading() + self._show_optimization_message(result) + else: + # 成功时:只更新文本,不显示弹窗(用户能直接看到变化) + clean_result = result + is_cached = False + + if result.startswith("[CACHED] "): + clean_result = result[9:] # 移除 "[CACHED] " 前缀 + is_cached = True + + # 验证优化结果的质量 + if self._validate_optimization_result(clean_result, text): + # 成功:使用支持撤销的文本替换方法 + self.text_input.replace_text_with_undo_support(clean_result) + # V4.1 新增:激活输入框焦点,让用户可以直接输入 + QTimer.singleShot(100, self.text_input.activate_input_focus) + # V4.1 智能反馈:显示简短的成功状态,然后自动消失 + success_msg = "✅ 优化完成!" + (" (缓存)" if is_cached else "") + self.loading_overlay.show_success(success_msg, 500) + return # 提前返回,避免执行finally中的hide_loading + else: + # 质量警告:仍然应用文本,使用支持撤销的方法 + self.text_input.replace_text_with_undo_support(clean_result) + # V4.1 新增:激活输入框焦点 + QTimer.singleShot(100, self.text_input.activate_input_focus) + self.loading_overlay.hide_loading() + self._show_optimization_message( + "⚠️ 优化完成,但结果可能需要手动调整", success=True + ) + + except Exception as e: + error_msg = f"优化过程中发生错误: {str(e)}" + self._show_optimization_message(error_msg) + # 异常时隐藏loading overlay + self.loading_overlay.hide_loading() + finally: + # V4.1 修改:只重置按钮状态,loading overlay由具体逻辑控制 + self._set_optimization_loading_state(False) + + def _is_optimization_error(self, result: str) -> bool: + """ + 检测优化结果是否为错误 - V4.1 新增 + Detect if optimization result is an error - V4.1 New + """ + if not result or not isinstance(result, str): + return True + + # 检查明显的错误标识 + error_indicators = [ + "[ERROR", + "[错误", + "[失败", + "[系统错误]", + "[配置错误]", + "[优化失败]", + "不可用", + "异常", + "Exception", + ] + + return any(indicator in result for indicator in error_indicators) + + def _validate_optimization_result(self, result: str, original: str) -> bool: + """ + 验证优化结果的基本质量 - V4.1 新增 + Validate basic quality of optimization result - V4.1 New + """ + if not result or not isinstance(result, str): + return False + + result = result.strip() + original = original.strip() + + # 基本长度检查 + if len(result) < 2: + return False + + # 检查是否过短(相对于原文) + if len(result) < len(original) * 0.3: + return False + + # 检查是否过长(可能包含了不必要的内容) + if len(result) > len(original) * 3: + return False + + # 检查是否包含明显的技术内容 + technical_indicators = [ + "function", + "def ", + "class ", + "import ", + "from ", + "Args:", + "Returns:", + "Parameters:", + "Type:", + ] + + if any(indicator in result for indicator in technical_indicators): + return False + + return True + + def _set_optimization_loading_state(self, loading: bool): + """设置优化按钮的加载状态 - V4.1 增强视觉反馈""" + # V4.1 更新:改进加载状态的视觉反馈 + if hasattr(self, "optimize_button") and hasattr(self, "enhance_button"): + self.optimize_button.setEnabled(not loading) + self.enhance_button.setEnabled(not loading) + + if loading: + # 改变按钮样式以显示加载状态 + self.optimize_button.setStyleSheet( + self.optimize_button.styleSheet() + "QPushButton { opacity: 0.6; }" + ) + self.enhance_button.setStyleSheet( + self.enhance_button.styleSheet() + "QPushButton { opacity: 0.6; }" + ) + else: + # 恢复正常状态 + + # 恢复按钮样式 + original_style = self.optimize_button.styleSheet().replace( + "QPushButton { opacity: 0.6; }", "" + ) + self.optimize_button.setStyleSheet(original_style) + original_style = self.enhance_button.styleSheet().replace( + "QPushButton { opacity: 0.6; }", "" + ) + self.enhance_button.setStyleSheet(original_style) + + # 同时禁用/启用输入框,防止用户在优化过程中修改文本 + if hasattr(self, "text_input"): + self.text_input.setEnabled(not loading) + + if hasattr(self.text_input, "reinforce_button"): + self.text_input.reinforce_button.setEnabled(not loading) + + def _convert_error_to_user_friendly(self, error_message: str) -> str: + """ + 将技术性错误消息转换为用户友好的提示 - V4.1 新增 + Convert technical error messages to user-friendly prompts - V4.1 New + """ + if not error_message: + return "优化过程中出现未知问题,请稍后重试" + + # 处理常见的技术错误 + if "[ERROR:AUTH]" in error_message or "API密钥无效" in error_message: + return "API密钥配置有误,请在设置中检查并更新您的API密钥" + + if "[ERROR:RATE]" in error_message or "频率过高" in error_message: + return "请求过于频繁,请稍等片刻后再试" + + if "[ERROR:TIMEOUT]" in error_message or "超时" in error_message: + return "网络连接超时,请检查网络连接后重试" + + if "[配置错误]" in error_message or "导入失败" in error_message: + return "系统配置异常,请检查设置或重启应用" + + if ( + "[ERROR:MODEL]" in error_message + or "模型" in error_message + and "不存在" in error_message + ): + return "所选AI模型不可用,请在设置中选择其他模型" + + if "[ERROR:SAFETY]" in error_message or "安全过滤" in error_message: + return "输入内容被安全过滤器拦截,请修改后重试" + + # 处理优化失败的情况 + if "[优化失败]" in error_message: + return "文本优化失败,请检查网络连接和API配置" + + # 如果是其他错误,提供通用的友好提示 + if error_message.startswith("[") and any( + keyword in error_message for keyword in ["错误", "失败", "异常"] + ): + return "优化过程中遇到问题,请稍后重试或检查设置" + + # 返回原始消息(如果不是错误消息) + return error_message + + def _show_optimization_message(self, message: str, success: bool = False): + """显示优化结果消息 - V4.1 增强用户体验""" + from PySide6.QtWidgets import QMessageBox + + self.disable_auto_minimize = True + try: + # 转换错误消息为用户友好格式 + if not success: + message = self._convert_error_to_user_friendly(message) + + msg_box = QMessageBox(self) + msg_box.setWindowTitle("输入表达优化") + msg_box.setText(message) + + if success: + msg_box.setIcon(QMessageBox.Icon.Information) + # 成功时自动关闭对话框(2秒后) + QTimer.singleShot(2000, msg_box.accept) + else: + msg_box.setIcon(QMessageBox.Icon.Warning) + + msg_box.exec() + finally: + self.disable_auto_minimize = False + + def _update_displayed_texts(self): + """更新界面显示的文本(包括优化按钮)""" + current_language = self.settings_manager.get_current_language() + + # 更新现有按钮文本 + if hasattr(self, "submit_button"): + self.submit_button.setText( + self.button_texts["submit_button"][current_language] + ) + + if hasattr(self, "canned_responses_button"): + self.canned_responses_button.setText( + self.button_texts["canned_responses_button"][current_language] + ) + + if hasattr(self, "select_file_button"): + self.select_file_button.setText( + self.button_texts["select_file_button"][current_language] + ) + + if hasattr(self, "screenshot_button"): + self.screenshot_button.setText( + self.button_texts["screenshot_button"][current_language] + ) + + if hasattr(self, "open_terminal_button"): + self.open_terminal_button.setText( + self.button_texts["open_terminal_button"][current_language] + ) + + if hasattr(self, "pin_window_button"): + self.pin_window_button.setText( + self.button_texts["pin_window_button"][current_language] + ) + + if hasattr(self, "settings_button"): + self.settings_button.setText( + self.button_texts["settings_button"][current_language] + ) + + # V4.0 新增:更新优化按钮文本 + if hasattr(self, "optimize_button"): + self.optimize_button.setText( + self.button_texts["optimize_button"][current_language] + ) + + if hasattr(self, "enhance_button"): + self.enhance_button.setText( + self.button_texts["enhance_button"][current_language] + ) + + # V4.3 新增:更新占位符文本 + self._update_placeholder_text() + + def _update_placeholder_text(self): + """V4.3 新增:根据当前提交方式和语言设置更新占位符文本""" + try: + # 获取当前提交方式设置 + try: + from interactive_feedback_server.utils import get_config + except ImportError: + # 开发模式导入 + import sys + import os + + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + from src.interactive_feedback_server.utils import get_config + + config = get_config() + submit_method = config.get("submit_method", "enter") + + # 获取当前语言 + current_language = self.settings_manager.get_current_language() + + # 使用平台工具获取占位符文本 + from .utils.platform_utils import get_placeholder_text + + placeholder_text = get_placeholder_text(submit_method, current_language) + + # 设置占位符文本 + if hasattr(self, "text_input"): + self.text_input.setPlaceholderText(placeholder_text) + + except Exception as e: + # 如果获取失败,使用默认文本 + print(f"更新占位符文本失败: {e}") + default_text = "在此输入反馈... (可拖拽文件和图片到输入框,Enter提交反馈,Shift+Enter换行,Ctrl+V复制剪切板信息)" + if hasattr(self, "text_input"): + self.text_input.setPlaceholderText(default_text) diff --git a/src/feedback_ui/resources/resources.qrc b/src/feedback_ui/resources/resources.qrc new file mode 100644 index 0000000..36a1f36 --- /dev/null +++ b/src/feedback_ui/resources/resources.qrc @@ -0,0 +1,10 @@ + + + + ../styles/dark_theme.qss + ../styles/light_theme.qss + translations/en_US.qm + sounds/notification.wav + + + \ No newline at end of file diff --git a/src/feedback_ui/resources/sounds/notification.wav b/src/feedback_ui/resources/sounds/notification.wav new file mode 100644 index 0000000..7bae684 Binary files /dev/null and b/src/feedback_ui/resources/sounds/notification.wav differ diff --git a/src/feedback_ui/resources/translations/en_US.qm b/src/feedback_ui/resources/translations/en_US.qm new file mode 100644 index 0000000..017e203 Binary files /dev/null and b/src/feedback_ui/resources/translations/en_US.qm differ diff --git a/src/feedback_ui/resources/translations/en_US.ts b/src/feedback_ui/resources/translations/en_US.ts new file mode 100644 index 0000000..35281dc --- /dev/null +++ b/src/feedback_ui/resources/translations/en_US.ts @@ -0,0 +1,245 @@ + + + + + FeedbackUI + + + 常用语 + Canned Responses + + + + 选择或管理常用语 + Select or manage canned responses + + + + 固定窗口 + Pin Window + + + + 设置 + Settings + + + + 打开设置面板 + Open settings panel + + + + ManageCannedResponsesDialog + + + 管理常用语 + Manage Canned Responses + + + + 管理您的常用反馈短语。点击列表项进行编辑,编辑完成后点击更新按钮。 + Manage your canned feedback phrases. Click a list item to edit, then click the Update button. + + + + 编辑常用语 + Edit Canned Response + + + + 输入新的常用语或编辑选中的项目 + Enter new or edit selected response + + + + 添加 + Add + + + + 更新 + Update + + + + 删除 + Delete + + + + 清空全部 + Clear All + + + + 关闭 + Close + + + + + 输入无效 + Invalid Input + + + + + 常用语不能为空。 + Canned response cannot be empty. + + + + + 重复项 + Duplicate Item + + + + + 此常用语已存在。 + This canned response already exists. + + + + 成功添加常用语 + Successfully added + + + + 确认删除 + Confirm Delete + + + + 确定要删除此常用语吗? + Are you sure you want to delete this canned response? + + + + 确认清空 + Confirm Clear All + + + + 确定要清空所有常用语吗?此操作不可撤销。 + Are you sure you want to clear all canned responses? This action cannot be undone. + + + + SelectCannedResponseDialog + + + 常用语管理 + Manage Canned Responses + + + + 常用语列表 + Canned Responses List + + + + 显示快捷图标 + Show Shortcut Icons + + + + 双击插入文本,点击删除按钮移除,拖拽调整顺序。 + Double-click to insert, click delete button, drag to reorder. + + + + 输入新的常用语 + Enter new canned response + + + + 保存 + Save + + + + 关闭 + Close + + + + 删除 + Delete + + + + 删除此常用语 + Delete this canned response + + + + 输入无效 + Invalid Input + + + + 常用语不能为空。 + Canned response cannot be empty. + + + + 重复项 + Duplicate Item + + + + 此常用语已存在。 + This canned response already exists. + + + + SettingsDialog + + + 设置 + Settings + + + + 外观主题 + Theme + + + + 深色模式 + Dark Mode + + + + 浅色模式 + Light Mode + + + + 语言 + Language + + + + 中文 + Chinese + + + + English + English + + + + 设置已保存 + Settings Saved + + + + 语言更改将在您下次启动应用时生效。 + Language changes will take effect the next time you start the application. + + + diff --git a/src/feedback_ui/resources_rc.py b/src/feedback_ui/resources_rc.py new file mode 100644 index 0000000..9a7a595 --- /dev/null +++ b/src/feedback_ui/resources_rc.py @@ -0,0 +1,3375 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 6.9.1 +# WARNING! All changes made in this file will be lost! + +from PySide6 import QtCore + +qt_resource_data = b"\ +\x00\x00\x9cl\ +R\ +IFFd\x9c\x00\x00WAVEfmt \x10\ +\x00\x00\x00\x01\x00\x02\x00\x22V\x00\x00\x88X\x01\x00\x04\ +\x00\x10\x00data@\x9c\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\ +\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\ +\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\ +\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\ +\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\ +\x00\x01\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\ +\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x01\ +\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x02\x00\x02\x00\xff\ +\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xfe\xff\xfe\xff\x00\ +\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\ +\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\ +\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\xff\xff\xff\xff\xff\ +\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\ +\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x03\ +\x00\x03\x00\x02\x00\x02\x00\x00\x00\x00\x00\xff\xff\xff\xff\x02\ +\x00\x02\x00\x02\x00\x02\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\ +\xff\xff\xff\xff\xff\xff\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xff\ +\xff\xff\xff\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\ +\x00\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\ +\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\ +\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x04\x00\x04\x00\x01\ +\x00\x01\x00\x02\x00\x02\x00\x06\x00\x06\x00\x01\x00\x01\x00\x02\ +\x00\x02\x00\x02\x00\x02\x00\x03\x00\x03\x00\xff\xff\xff\xff\x00\ +\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x03\x00\x03\x00\xff\ +\xff\xff\xff\x02\x00\x02\x00\x03\x00\x03\x00\x00\x00\x00\x00\x02\ +\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\xff\ +\xff\xff\xff\xfd\xff\xfd\xff\x00\x00\x00\x00\x00\x00\x00\x00\xfe\ +\xff\xfe\xff\xff\xff\xff\xff\x02\x00\x02\x00\x04\x00\x04\x00\x01\ +\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x05\x00\x05\x00\x02\ +\x00\x02\x00\xff\xff\xff\xff\x00\x00\x00\x00\x02\x00\x02\x00\x00\ +\x00\x00\x00\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfc\xff\xfc\xff\x01\ +\x00\x01\x00\x02\x00\x02\x00\x03\x00\x03\x00\x00\x00\x00\x00\x03\ +\x00\x03\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\xfd\ +\xff\xfd\xff\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xfc\ +\xff\xfc\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x03\ +\x00\x03\x00\x01\x00\x01\x00\x02\x00\x02\x00\xfd\xff\xfd\xff\x02\ +\x00\x02\x00\xfe\xff\xfe\xff\xfb\xff\xfb\xff\xff\xff\xff\xff\xfb\ +\xff\xfb\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xfc\ +\xff\xfc\xff\xfc\xff\xfc\xff\x05\x00\x05\x00\x02\x00\x02\x00\x03\ +\x00\x03\x00\x00\x00\x00\x00\x02\x00\x02\x00\x04\x00\x04\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\xff\ +\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\ +\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\ +\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\xfb\xff\xfb\xff\x00\ +\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\ +\x00\x00\x00\xfb\xff\xfb\xff\x01\x00\x01\x00\xfe\xff\xfe\xff\x02\ +\x00\x02\x00\x01\x00\x01\x00\xff\xff\xff\xff\x04\x00\x04\x00\x01\ +\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x03\ +\x00\x03\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\ +\xff\xff\xff\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\x01\x00\x03\ +\x00\x03\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\xfe\ +\xff\xfe\xff\xff\xff\xff\xff\xfb\xff\xfb\xff\xfe\xff\xfe\xff\xfc\ +\xff\xfc\xff\x02\x00\x02\x00\x00\x00\x00\x00\xfc\xff\xfc\xff\xfe\ +\xff\xfe\xff\x02\x00\x02\x00\xfe\xff\xfe\xff\x03\x00\x03\x00\x03\ +\x00\x03\x00\x02\x00\x02\x00\x02\x00\x02\x00\xfb\xff\xfb\xff\xfa\ +\xff\xfa\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\x01\ +\x00\x01\x00\xfb\xff\xfb\xff\x00\x00\x00\x00\x02\x00\x02\x00\xfe\ +\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\ +\x00\x02\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\x00\ +\x00\x00\x00\x03\x00\x03\x00\xff\xff\xff\xff\xfd\xff\xfd\xff\x01\ +\x00\x01\x00\x02\x00\x02\x00\x04\x00\x04\x00\x04\x00\x04\x00\xff\ +\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x01\ +\x00\x01\x00\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xfc\ +\xff\xfc\xff\x00\x00\x00\x00\xfc\xff\xfc\xff\xfc\xff\xfc\xff\x00\ +\x00\x00\x00\xfd\xff\xfd\xff\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\ +\xff\xff\xff\x02\x00\x02\x00\x06\x00\x06\x00\x02\x00\x02\x00\xfd\ +\xff\xfd\xff\x01\x00\x01\x00\x03\x00\x03\x00\x01\x00\x01\x00\x06\ +\x00\x06\x00\x01\x00\x01\x00\xfa\xff\xfa\xff\x00\x00\x00\x00\x04\ +\x00\x04\x00\x01\x00\x01\x00\x02\x00\x02\x00\xff\xff\xff\xff\x08\ +\x00\x08\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\x01\x00\x01\x00\x00\ +\x00\x00\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x01\ +\x00\x01\x00\x05\x00\x05\x00\x09\x00\x09\x00\x05\x00\x05\x00\x04\ +\x00\x04\x00\x03\x00\x03\x00\xff\xff\xff\xff\x00\x00\x00\x00\x03\ +\x00\x03\x00\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfd\ +\xff\xfd\xff\x00\x00\x00\x00\xfd\xff\xfd\xff\xff\xff\xff\xff\x02\ +\x00\x02\x00\x02\x00\x02\x00\x06\x00\x06\x00\x03\x00\x03\x00\x00\ +\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\xff\xff\xff\xff\xfd\ +\xff\xfd\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\x01\x00\x01\x00\xfe\ +\xff\xfe\xff\xfc\xff\xfc\xff\xff\xff\xff\xff\x01\x00\x01\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x01\ +\x00\x01\x00\x02\x00\x02\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\ +\xff\xff\xff\xfc\xff\xfc\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfc\ +\xff\xfc\xff\x00\x00\x00\x00\x10\x00\x10\x004\x004\x00S\ +\x00S\x00j\x00j\x00e\x00e\x00d\x00d\x00_\ +\x00_\x00^\x00^\x00\x5c\x00\x5c\x00V\x00V\x00X\ +\x00X\x00Y\x00Y\x00T\x00T\x00L\x00L\x00P\ +\x00P\x00S\x00S\x00S\x00S\x00S\x00S\x00Q\ +\x00Q\x00I\x00I\x00L\x00L\x00F\x00F\x00J\ +\x00J\x00D\x00D\x00H\x00H\x00H\x00H\x00K\ +\x00K\x00G\x00G\x00@\x00@\x00E\x00E\x00C\ +\x00C\x00:\x00:\x008\x008\x00D\x00D\x00F\ +\x00F\x00>\x00>\x008\x008\x006\x006\x00.\ +\x00.\x00)\x00)\x000\x000\x009\x009\x00(\ +\x00(\x00/\x00/\x00\x22\x00\x22\x00%\x00%\x00\x22\ +\x00\x22\x00\x1e\x00\x1e\x00)\x00)\x00\x22\x00\x22\x00!\ +\x00!\x00&\x00&\x00%\x00%\x00$\x00$\x00&\ +\x00&\x00\x1f\x00\x1f\x00$\x00$\x00!\x00!\x00$\ +\x00$\x00\x1e\x00\x1e\x00\x1b\x00\x1b\x00\x19\x00\x19\x00\x15\ +\x00\x15\x00\x10\x00\x10\x00\x1d\x00\x1d\x00\x17\x00\x17\x00\x19\ +\x00\x19\x00\x14\x00\x14\x00\x12\x00\x12\x00\x17\x00\x17\x00\x1e\ +\x00\x1e\x00$\x00$\x00!\x00!\x00%\x00%\x00)\ +\x00)\x00#\x00#\x00\x22\x00\x22\x00&\x00&\x00/\ +\x00/\x00/\x00/\x00$\x00$\x00/\x00/\x00&\ +\x00&\x00\x1e\x00\x1e\x00&\x00&\x001\x001\x003\ +\x003\x00,\x00,\x00*\x00*\x00\x1e\x00\x1e\x00\x1b\ +\x00\x1b\x00\x1b\x00\x1b\x00!\x00!\x00\x19\x00\x19\x00\x19\ +\x00\x19\x00\x12\x00\x12\x00\x17\x00\x17\x00\x17\x00\x17\x00\x16\ +\x00\x16\x00\x0d\x00\x0d\x00\xfe\xff\xfe\xff\x01\x00\x01\x00\x05\ +\x00\x05\x00\x01\x00\x01\x00\x01\x00\x01\x00\x0c\x00\x0c\x00\x09\ +\x00\x09\x00\x01\x00\x01\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\xfe\ +\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\xf9\xff\xf9\xff\xf9\ +\xff\xf9\xff\xf9\xff\xf9\xff\xef\xff\xef\xff\xef\xff\xef\xff\xe7\ +\xff\xe7\xff\xf2\xff\xf2\xff\xe6\xff\xe6\xff\xde\xff\xde\xff\xeb\ +\xff\xeb\xff\xef\xff\xef\xff\xe9\xff\xe9\xff\xe3\xff\xe3\xff\xea\ +\xff\xea\xff\xe0\xff\xe0\xff\xed\xff\xed\xff\xee\xff\xee\xff\xf5\ +\xff\xf5\xff\x02\x00\x02\x00\x01\x00\x01\x00\xf6\xff\xf6\xff\xfd\ +\xff\xfd\xff\x06\x00\x06\x00\x06\x00\x06\x00\xff\xff\xff\xff\x11\ +\x00\x11\x00\x17\x00\x17\x00\x15\x00\x15\x00\x0f\x00\x0f\x00\x18\ +\x00\x18\x00\x22\x00\x22\x002\x002\x00\x1f\x00\x1f\x00!\ +\x00!\x00)\x00)\x00&\x00&\x00\x16\x00\x16\x00\x14\ +\x00\x14\x00\x1a\x00\x1a\x00\x18\x00\x18\x00\x08\x00\x08\x00\xf9\ +\xff\xf9\xff\xf3\xff\xf3\xff\xfc\xff\xfc\xff\x00\x00\x00\x00\xf9\ +\xff\xf9\xff\xfa\xff\xfa\xff\xf7\xff\xf7\xff\xff\xff\xff\xff\x07\ +\x00\x07\x00\x00\x00\x00\x00\x02\x00\x02\x00\x05\x00\x05\x00\xff\ +\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x08\x00\x08\x00\x10\ +\x00\x10\x00\x11\x00\x11\x00\x05\x00\x05\x00\x01\x00\x01\x00\xfa\ +\xff\xfa\xff\xfb\xff\xfb\xff\xf9\xff\xf9\xff\xfb\xff\xfb\xff\xf6\ +\xff\xf6\xff\xda\xff\xda\xff\xbe\xff\xbe\xff\xb0\xff\xb0\xff\xbc\ +\xff\xbc\xff\xb9\xff\xb9\xff\xbc\xff\xbc\xff\xa9\xff\xa9\xff\xa3\ +\xff\xa3\xff\xa0\xff\xa0\xff\x9a\xff\x9a\xff\xbc\xff\xbc\xff\xcc\ +\xff\xcc\xff\xd2\xff\xd2\xff\xbc\xff\xbc\xff\xa9\xff\xa9\xff\xc6\ +\xff\xc6\xff\xe2\xff\xe2\xff\xf0\xff\xf0\xff\xeb\xff\xeb\xff\xec\ +\xff\xec\xff\xf3\xff\xf3\xff\xf6\xff\xf6\xff\xf7\xff\xf7\xff\xfe\ +\xff\xfe\xff\xfd\xff\xfd\xff\x0f\x00\x0f\x00\x08\x00\x08\x00\x05\ +\x00\x05\x00\x06\x00\x06\x00\x10\x00\x10\x00\x05\x00\x05\x00\x07\ +\x00\x07\x00\x03\x00\x03\x00\x04\x00\x04\x00\x01\x00\x01\x00\x0b\ +\x00\x0b\x00\x12\x00\x12\x00\x1d\x00\x1d\x00\x05\x00\x05\x00\xfd\ +\xff\xfd\xff\x09\x00\x09\x00\x1c\x00\x1c\x00#\x00#\x00=\ +\x00=\x00&\x00&\x00\x18\x00\x18\x00\x09\x00\x09\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x05\x00\x05\x00\x10\x00\x10\x00\x09\ +\x00\x09\x00\x01\x00\x01\x00\x10\x00\x10\x00#\x00#\x001\ +\x001\x00(\x00(\x00\x1b\x00\x1b\x00\x0f\x00\x0f\x00\xf8\ +\xff\xf8\xff\xe4\xff\xe4\xff\xe1\xff\xe1\xff\xd5\xff\xd5\xff\xcc\ +\xff\xcc\xff\xb2\xff\xb2\xff\xa4\xff\xa4\xff\xab\xff\xab\xff\x97\ +\xff\x97\xff\xa3\xff\xa3\xff\x9d\xff\x9d\xff\x9a\xff\x9a\xff\xae\ +\xff\xae\xff\x91\xff\x91\xff\x8d\xff\x8d\xff\x8c\xff\x8c\xff\x97\ +\xff\x97\xff\x88\xff\x88\xff\x93\xff\x93\xff\x8a\xff\x8a\xff\x97\ +\xff\x97\xff\xb5\xff\xb5\xff\xb8\xff\xb8\xff\xc0\xff\xc0\xff\xce\ +\xff\xce\xff\xd5\xff\xd5\xff\xbb\xff\xbb\xff\xc6\xff\xc6\xff\xd6\ +\xff\xd6\xff\xdf\xff\xdf\xff\xed\xff\xed\xff\x01\x00\x01\x00\x11\ +\x00\x11\x00$\x00$\x00(\x00(\x00&\x00&\x00-\ +\x00-\x007\x007\x00+\x00+\x00$\x00$\x00#\ +\x00#\x007\x007\x00/\x00/\x00\x14\x00\x14\x00%\ +\x00%\x00;\x00;\x00I\x00I\x00N\x00N\x000\ +\x000\x00'\x00'\x00.\x00.\x00)\x00)\x00!\ +\x00!\x00\x1a\x00\x1a\x00\x15\x00\x15\x00\x18\x00\x18\x00\x01\ +\x00\x01\x00\xeb\xff\xeb\xff\xe3\xff\xe3\xff\xee\xff\xee\xff\xf5\ +\xff\xf5\xff\xff\xff\xff\xff\x03\x00\x03\x00\xfd\xff\xfd\xff\xf4\ +\xff\xf4\xff\xe5\xff\xe5\xff\xca\xff\xca\xff\xdf\xff\xdf\xff\xe4\ +\xff\xe4\xff\xd9\xff\xd9\xff\xc3\xff\xc3\xff\xbe\xff\xbe\xff\xc6\ +\xff\xc6\xff\xb8\xff\xb8\xff\xa9\xff\xa9\xff\xbe\xff\xbe\xff\xce\ +\xff\xce\xff\xd4\xff\xd4\xff\xce\xff\xce\xff\xd0\xff\xd0\xff\xdd\ +\xff\xdd\xff\xdb\xff\xdb\xff\xc4\xff\xc4\xff\xb2\xff\xb2\xff\xae\ +\xff\xae\xff\xbb\xff\xbb\xff\xb7\xff\xb7\xff\xac\xff\xac\xff\xbb\ +\xff\xbb\xff\xd8\xff\xd8\xff\xdb\xff\xdb\xff\xd2\xff\xd2\xff\xca\ +\xff\xca\xff\xd3\xff\xd3\xff\xdb\xff\xdb\xff\xca\xff\xca\xff\xcc\ +\xff\xcc\xff\xd7\xff\xd7\xff\xde\xff\xde\xff\xe5\xff\xe5\xff\xd0\ +\xff\xd0\xff\xd7\xff\xd7\xff\xdf\xff\xdf\xff\xe6\xff\xe6\xff\xe4\ +\xff\xe4\xff\x00\x00\x00\x00\x0c\x00\x0c\x00\x08\x00\x08\x00\x0a\ +\x00\x0a\x00\x0e\x00\x0e\x00\x1d\x00\x1d\x00!\x00!\x00'\ +\x00'\x00(\x00(\x00G\x00G\x00P\x00P\x00T\ +\x00T\x00G\x00G\x00:\x00:\x00M\x00M\x00W\ +\x00W\x00Z\x00Z\x00N\x00N\x00H\x00H\x00K\ +\x00K\x00E\x00E\x00U\x00U\x00_\x00_\x00Y\ +\x00Y\x00M\x00M\x00;\x00;\x00%\x00%\x00#\ +\x00#\x00$\x00$\x000\x000\x00\x10\x00\x10\x00\xfe\ +\xff\xfe\xff\xf9\xff\xf9\xff\xe3\xff\xe3\xff\xed\xff\xed\xff\xd3\ +\xff\xd3\xff\xb6\xff\xb6\xff\xa2\xff\xa2\xff\x98\xff\x98\xff\xaa\ +\xff\xaa\xff\xaa\xff\xaa\xff\xa9\xff\xa9\xff\x91\xff\x91\xff|\ +\xff|\xffp\xffp\xff\x80\xff\x80\xff\x7f\xff\x7f\xffe\ +\xffe\xffR\xffR\xffK\xffK\xff`\xff`\xffy\ +\xffy\xff\x8f\xff\x8f\xff\xa8\xff\xa8\xff\xc3\xff\xc3\xff\xd7\ +\xff\xd7\xff\xd4\xff\xd4\xff\xea\xff\xea\xff\x04\x00\x04\x00\x0c\ +\x00\x0c\x00\x01\x00\x01\x00\xf9\xff\xf9\xff\xf9\xff\xf9\xff\xfc\ +\xff\xfc\xff\xfb\xff\xfb\xff\x03\x00\x03\x00\x01\x00\x01\x00\x1b\ +\x00\x1b\x00&\x00&\x00,\x00,\x003\x003\x00<\ +\x00<\x000\x000\x00\x22\x00\x22\x00#\x00#\x00+\ +\x00+\x00E\x00E\x00D\x00D\x00<\x00<\x00-\ +\x00-\x00&\x00&\x00=\x00=\x00E\x00E\x00O\ +\x00O\x00N\x00N\x00:\x00:\x00&\x00&\x00\x16\ +\x00\x16\x00\x0f\x00\x0f\x00\x10\x00\x10\x00\x0f\x00\x0f\x00\x0b\ +\x00\x0b\x00\x04\x00\x04\x00\xfc\xff\xfc\xff\xfc\xff\xfc\xff\x09\ +\x00\x09\x00\x00\x00\x00\x00\xff\xff\xff\xff\xee\xff\xee\xff\xd7\ +\xff\xd7\xff\xbe\xff\xbe\xff\xba\xff\xba\xff\xc1\xff\xc1\xff\xb4\ +\xff\xb4\xff\xab\xff\xab\xff\x8e\xff\x8e\xff\x8a\xff\x8a\xff\x90\ +\xff\x90\xff\x9e\xff\x9e\xff\xb0\xff\xb0\xff\xb0\xff\xb0\xff\xad\ +\xff\xad\xff\xa2\xff\xa2\xff\xa9\xff\xa9\xff\xba\xff\xba\xff\xcc\ +\xff\xcc\xff\xdc\xff\xdc\xff\xdc\xff\xdc\xff\xda\xff\xda\xff\xe0\ +\xff\xe0\xff\xdd\xff\xdd\xff\xe8\xff\xe8\xff\xea\xff\xea\xff\xee\ +\xff\xee\xff\xf6\xff\xf6\xff\xe6\xff\xe6\xff\xea\xff\xea\xff\x02\ +\x00\x02\x00\xfc\xff\xfc\xff\xff\xff\xff\xff\xfb\xff\xfb\xff\xf6\ +\xff\xf6\xff\x04\x00\x04\x00\x06\x00\x06\x00\x0c\x00\x0c\x00\x0f\ +\x00\x0f\x00\x0a\x00\x0a\x00\xfe\xff\xfe\xff\xf8\xff\xf8\xff\x08\ +\x00\x08\x00\x16\x00\x16\x00\x22\x00\x22\x00'\x00'\x00\x0f\ +\x00\x0f\x00\x05\x00\x05\x00\xfd\xff\xfd\xff\xf9\xff\xf9\xff\xff\ +\xff\xff\xff\x0e\x00\x0e\x00\x15\x00\x15\x00\x0b\x00\x0b\x00\x0e\ +\x00\x0e\x00\x18\x00\x18\x007\x007\x001\x001\x001\ +\x001\x00\x1e\x00\x1e\x00\x10\x00\x10\x00\xf7\xff\xf7\xff\xf6\ +\xff\xf6\xff\xeb\xff\xeb\xff\xe3\xff\xe3\xff\xd0\xff\xd0\xff\xb1\ +\xff\xb1\xff\xa5\xff\xa5\xff\xa0\xff\xa0\xff\x98\xff\x98\xff\xa8\ +\xff\xa8\xff\x98\xff\x98\xff\xab\xff\xab\xff\x9c\xff\x9c\xff\x87\ +\xff\x87\xff\x8b\xff\x8b\xff\x9b\xff\x9b\xff\x98\xff\x98\xff\x91\ +\xff\x91\xff\x98\xff\x98\xff\x98\xff\x98\xff\xb1\xff\xb1\xff\xbd\ +\xff\xbd\xff\xc1\xff\xc1\xff\xc7\xff\xc7\xff\xd8\xff\xd8\xff\xd0\ +\xff\xd0\xff\xb8\xff\xb8\xff\xce\xff\xce\xff\xdd\xff\xdd\xff\xe2\ +\xff\xe2\xff\xed\xff\xed\xff\x03\x00\x03\x00\x10\x00\x10\x00*\ +\x00*\x00-\x00-\x00-\x00-\x005\x005\x008\ +\x008\x00'\x00'\x00%\x00%\x00%\x00%\x00-\ +\x00-\x00\x16\x00\x16\x00\x10\x00\x10\x002\x002\x005\ +\x005\x00N\x00N\x00F\x00F\x00,\x00,\x00(\ +\x00(\x000\x000\x00'\x00'\x00#\x00#\x00\x13\ +\x00\x13\x00\x14\x00\x14\x00\x08\x00\x08\x00\xec\xff\xec\xff\xe3\ +\xff\xe3\xff\xea\xff\xea\xff\xf0\xff\xf0\xff\xf3\xff\xf3\xff\xfe\ +\xff\xfe\xff\xff\xff\xff\xff\xf3\xff\xf3\xff\xee\xff\xee\xff\xc6\ +\xff\xc6\xff\xc2\xff\xc2\xff\xd1\xff\xd1\xff\xca\xff\xca\xff\xba\ +\xff\xba\xff\xaf\xff\xaf\xff\xbb\xff\xbb\xff\xad\xff\xad\xff\x93\ +\xff\x93\xff\x98\xff\x98\xff\xb0\xff\xb0\xff\xcb\xff\xcb\xff\xcd\ +\xff\xcd\xff\xc8\xff\xc8\xff\xcb\xff\xcb\xff\xd1\xff\xd1\xff\xba\ +\xff\xba\xff\xa0\xff\xa0\xff\x91\xff\x91\xff\xa2\xff\xa2\xff\xa3\ +\xff\xa3\xff\x98\xff\x98\xff\xa0\xff\xa0\xff\xbb\xff\xbb\xff\xd6\ +\xff\xd6\xff\xd1\xff\xd1\xff\xc9\xff\xc9\xff\xc2\xff\xc2\xff\xd0\ +\xff\xd0\xff\xbf\xff\xbf\xff\xba\xff\xba\xff\xc0\xff\xc0\xff\xc7\ +\xff\xc7\xff\xd5\xff\xd5\xff\xbb\xff\xbb\xff\xb1\xff\xb1\xff\xc9\ +\xff\xc9\xff\xd1\xff\xd1\xff\xd1\xff\xd1\xff\xda\xff\xda\xff\xfe\ +\xff\xfe\xff\x03\x00\x03\x00\x0f\x00\x0f\x00\x0e\x00\x0e\x00\x12\ +\x00\x12\x00\x1e\x00\x1e\x00%\x00%\x00-\x00-\x00P\ +\x00P\x00j\x00j\x00y\x00y\x00u\x00u\x00R\ +\x00R\x00Q\x00Q\x00u\x00u\x00\x88\x00\x88\x00~\ +\x00~\x00g\x00g\x00[\x00[\x00^\x00^\x00e\ +\x00e\x00~\x00~\x00\x8c\x00\x8c\x00p\x00p\x00V\ +\x00V\x003\x003\x00=\x00=\x00;\x00;\x00P\ +\x00P\x00,\x00,\x00\xff\xff\xff\xff\xf1\xff\xf1\xff\xdd\ +\xff\xdd\xff\xda\xff\xda\xff\xd8\xff\xd8\xff\xa2\xff\xa2\xff\x82\ +\xff\x82\xffg\xffg\xffd\xffd\xff\x86\xff\x86\xff\x81\ +\xff\x81\xffp\xffp\xff9\xff9\xff\x1c\xff\x1c\xff\x1a\ +\xff\x1a\xff-\xff-\xff\x13\xff\x13\xff\xfe\xfe\xfe\xfe\xd3\ +\xfe\xd3\xfe\xe3\xfe\xe3\xfe\xf7\xfe\xf7\xfe!\xff!\xffI\ +\xffI\xffm\xffm\xff\x8e\xff\x8e\xff\xa9\xff\xa9\xff\xb4\ +\xff\xb4\xff\xce\xff\xce\xff\xea\xff\xea\xff\xea\xff\xea\xff\xdc\ +\xff\xdc\xff\xd9\xff\xd9\xff\xd2\xff\xd2\xff\xcb\xff\xcb\xff\xd6\ +\xff\xd6\xff\xdf\xff\xdf\xff\xe8\xff\xe8\xff\x07\x00\x07\x008\ +\x008\x00Y\x00Y\x00c\x00c\x00d\x00d\x00]\ +\x00]\x00`\x00`\x00z\x00z\x00\xa1\x00\xa1\x00\xc6\ +\x00\xc6\x00\xb6\x00\xb6\x00\x96\x00\x96\x00\x95\x00\x95\x00\xa3\ +\x00\xa3\x00\xbe\x00\xbe\x00\xc4\x00\xc4\x00\xca\x00\xca\x00\xc0\ +\x00\xc0\x00\x9b\x00\x9b\x00w\x00w\x00I\x00I\x00\x1f\ +\x00\x1f\x00%\x00%\x00\x18\x00\x18\x00\x12\x00\x12\x00\x0c\ +\x00\x0c\x00\x1a\x00\x1a\x00'\x00'\x00=\x00=\x00#\ +\x00#\x00\x09\x00\x09\x00\x0b\x00\x0b\x00\x0e\x00\x0e\x00\xf4\ +\xff\xf4\xff\xc3\xff\xc3\xff\xb4\xff\xb4\xff\x94\xff\x94\xffq\ +\xffq\xffP\xffP\xffZ\xffZ\xfft\xfft\xff|\ +\xff|\xffd\xffd\xffZ\xffZ\xffq\xffq\xffx\ +\xffx\xffj\xffj\xff_\xff_\xff\x7f\xff\x7f\xff\xa6\ +\xff\xa6\xff\xb4\xff\xb4\xff\x8f\xff\x8f\xff\x9d\xff\x9d\xff\xa7\ +\xff\xa7\xff\x8e\xff\x8e\xffu\xffu\xff\x96\xff\x96\xff\xb3\ +\xff\xb3\xff\x9c\xff\x9c\xffl\xffl\xff\x83\xff\x83\xff\xb0\ +\xff\xb0\xff\xdb\xff\xdb\xff\xd6\xff\xd6\xff\xdd\xff\xdd\xff\x01\ +\x00\x01\x00\x06\x00\x06\x00\xef\xff\xef\xff\xdb\xff\xdb\xff\xf6\ +\xff\xf6\xff\x07\x00\x07\x00\x08\x00\x08\x00\x0e\x00\x0e\x00\x03\ +\x00\x03\x00\x09\x00\x09\x00\xf1\xff\xf1\xff\xed\xff\xed\xff\x0b\ +\x00\x0b\x007\x007\x00J\x00J\x00L\x00L\x00i\ +\x00i\x00\x8b\x00\x8b\x00\xb6\x00\xb6\x00\xcb\x00\xcb\x00\xdf\ +\x00\xdf\x00\x05\x01\x05\x01\x17\x01\x17\x01\x07\x01\x07\x01\xef\ +\x00\xef\x00\xee\x00\xee\x00\xff\x00\xff\x00\xfb\x00\xfb\x00\xc7\ +\x00\xc7\x00}\x00}\x003\x003\x00\x0d\x00\x0d\x00\xe8\ +\xff\xe8\xff\xa1\xff\xa1\xff\x82\xff\x82\xff\x84\xff\x84\xff\x89\ +\xff\x89\xff\x82\xff\x82\xff\x96\xff\x96\xff\xc8\xff\xc8\xff\xe6\ +\xff\xe6\xff\xde\xff\xde\xff\xda\xff\xda\xff\xf2\xff\xf2\xff\xf8\ +\xff\xf8\xff\xd5\xff\xd5\xff\x8d\xff\x8d\xffn\xffn\xff\x5c\ +\xff\x5c\xff\x19\xff\x19\xff\xcb\xfe\xcb\xfe\xaf\xfe\xaf\xfe\xce\ +\xfe\xce\xfe\xe0\xfe\xe0\xfe\xf9\xfe\xf9\xfe\x05\xff\x05\xff\x17\ +\xff\x17\xff\x18\xff\x18\xff%\xff%\xff*\xff*\xffj\ +\xffj\xff\x94\xff\x94\xff\x82\xff\x82\xff\x90\xff\x90\xff\x9c\ +\xff\x9c\xff\xb9\xff\xb9\xff\xed\xff\xed\xff\x03\x00\x03\x00\x17\ +\x00\x17\x00\x12\x00\x12\x00\x13\x00\x13\x00!\x00!\x00C\ +\x00C\x00[\x00[\x00B\x00B\x00#\x00#\x00\x1f\ +\x00\x1f\x00@\x00@\x00b\x00b\x00\x8f\x00\x8f\x00\x87\ +\x00\x87\x00\x7f\x00\x7f\x00m\x00m\x00\x82\x00\x82\x00\xac\ +\x00\xac\x00\xcf\x00\xcf\x00\xc0\x00\xc0\x00\x83\x00\x83\x00j\ +\x00j\x00_\x00_\x00]\x00]\x00M\x00M\x00:\ +\x00:\x00M\x00M\x00Q\x00Q\x00G\x00G\x00\x5c\ +\x00\x5c\x00x\x00x\x00h\x00h\x00[\x00[\x00M\ +\x00M\x00Q\x00Q\x00i\x00i\x00U\x00U\x00.\ +\x00.\x00\xfe\xff\xfe\xff\xe2\xff\xe2\xff\xb4\xff\xb4\xff\xb4\ +\xff\xb4\xff\xcb\xff\xcb\xff\xbe\xff\xbe\xff\xa4\xff\xa4\xffy\ +\xffy\xffF\xffF\xff\x22\xff\x22\xff$\xff$\xff2\ +\xff2\xffB\xffB\xffB\xffB\xff\x10\xff\x10\xff\xd2\ +\xfe\xd2\xfe\xde\xfe\xde\xfe\xea\xfe\xea\xfe\x10\xff\x10\xff-\ +\xff-\xff/\xff/\xff'\xff'\xffC\xffC\xffj\ +\xffj\xff\xcf\xff\xcf\xff\x12\x00\x12\x00\x18\x00\x18\x00\x03\ +\x00\x03\x00\xfc\xff\xfc\xff,\x00,\x00o\x00o\x00\xb9\ +\x00\xb9\x00\xfe\x00\xfe\x00\x1f\x01\x1f\x01\x14\x01\x14\x01\x00\ +\x01\x00\x01\xf5\x00\xf5\x00\x1c\x01\x1c\x01\x17\x01\x17\x01\xe5\ +\x00\xe5\x00\xa9\x00\xa9\x00\x9f\x00\x9f\x00\x9d\x00\x9d\x00\x9a\ +\x00\x9a\x00\xb3\x00\xb3\x00\xd7\x00\xd7\x00\xd7\x00\xd7\x00\xa7\ +\x00\xa7\x00v\x00v\x00\xa3\x00\xa3\x00\xc3\x00\xc3\x00w\ +\x00w\x00&\x00&\x00\x17\x00\x17\x00\x11\x00\x11\x00\xfe\ +\xff\xfe\xff\xdf\xff\xdf\xff\xd8\xff\xd8\xff\xcb\xff\xcb\xff\xac\ +\xff\xac\xff\xa3\xff\xa3\xff\xfb\xff\xfb\xff<\x00<\x00<\ +\x00<\x00\x0d\x00\x0d\x00\x03\x00\x03\x00\xef\xff\xef\xff]\ +\xff]\xff\xfb\xfe\xfb\xfe\xe5\xfe\xe5\xfe\xd2\xfe\xd2\xfe\xa5\ +\xfe\xa5\xfes\xfes\xfen\xfen\xfe\x84\xfe\x84\xfeb\ +\xfeb\xfeo\xfeo\xfe\xca\xfe\xca\xfe*\xff*\xffB\ +\xffB\xff\xf5\xfe\xf5\xfe\xd5\xfe\xd5\xfe\xf5\xfe\xf5\xfe\x19\ +\xff\x19\xff:\xff:\xffY\xffY\xff_\xff_\xff<\ +\xff<\xff%\xff%\xffh\xffh\xff\xe4\xff\xe4\xff(\ +\x00(\x00\x10\x00\x10\x00\xfd\xff\xfd\xff\x17\x00\x17\x00+\ +\x00+\x00\x1c\x00\x1c\x00!\x00!\x00,\x00,\x00J\ +\x00J\x00U\x00U\x00j\x00j\x00\xd1\x00\xd1\x00:\ +\x01:\x01F\x01F\x01U\x01U\x01\x9d\x01\x9d\x01\xd5\ +\x01\xd5\x01\xcc\x01\xcc\x01\xe4\x01\xe4\x01\xd7\x01\xd7\x01\xce\ +\x01\xce\x01\x8c\x01\x8c\x01B\x01B\x01B\x01B\x01B\ +\x01B\x01\xfa\x00\xfa\x00\xa5\x00\xa5\x00\xa3\x00\xa3\x00\xcc\ +\x00\xcc\x00\xc0\x00\xc0\x00\xa0\x00\xa0\x00\xa4\x00\xa4\x00\xae\ +\x00\xae\x00\xaa\x00\xaa\x00b\x00b\x000\x000\x000\ +\x000\x00\x09\x00\x09\x00\x9f\xff\x9f\xffA\xffA\xff(\ +\xff(\xff\x1c\xff\x1c\xff\x03\xff\x03\xff\xc0\xfe\xc0\xfe\xa6\ +\xfe\xa6\xfe\x96\xfe\x96\xfep\xfep\xfel\xfel\xfe\xb4\ +\xfe\xb4\xfe\xb9\xfe\xb9\xfe{\xfe{\xfeO\xfeO\xfeT\ +\xfeT\xfed\xfed\xfec\xfec\xfe[\xfe[\xfe\x88\ +\xfe\x88\xfe\xca\xfe\xca\xfe\xdb\xfe\xdb\xfe\xd8\xfe\xd8\xfe\x22\ +\xff\x22\xffr\xffr\xff\x86\xff\x86\xff\x83\xff\x83\xffk\ +\xffk\xffg\xffg\xff\x84\xff\x84\xff\x81\xff\x81\xff\xa8\ +\xff\xa8\xff\xe0\xff\xe0\xff\xe2\xff\xe2\xff\x0f\x00\x0f\x00h\ +\x00h\x00\xc5\x00\xc5\x00\xe5\x00\xe5\x00\xde\x00\xde\x00\xd7\ +\x00\xd7\x00\x1a\x01\x1a\x01T\x01T\x01R\x01R\x01U\ +\x01U\x01V\x01V\x01P\x01P\x01\x16\x01\x16\x01\x0d\ +\x01\x0d\x01.\x01.\x01k\x01k\x01l\x01l\x01B\ +\x01B\x01N\x01N\x01l\x01l\x01[\x01[\x01)\ +\x01)\x01'\x01'\x01I\x01I\x01(\x01(\x01\xf2\ +\x00\xf2\x00\xc6\x00\xc6\x00\xc5\x00\xc5\x00\x87\x00\x87\x008\ +\x008\x00\x1f\x00\x1f\x00<\x00<\x00!\x00!\x00\xda\ +\xff\xda\xff\x99\xff\x99\xff{\xff{\xffq\xffq\xff\x1d\ +\xff\x1d\xff\x10\xff\x10\xffE\xffE\xffJ\xffJ\xff\x05\ +\xff\x05\xff\xa2\xfe\xa2\xfey\xfey\xfez\xfez\xfeW\ +\xfeW\xfe3\xfe3\xfeF\xfeF\xfeW\xfeW\xfet\ +\xfet\xfex\xfex\xfe\x9c\xfe\x9c\xfe\xdb\xfe\xdb\xfe\xa3\ +\xfe\xa3\xfeY\xfeY\xfeK\xfeK\xfe\x90\xfe\x90\xfe\xbb\ +\xfe\xbb\xfe\xcf\xfe\xcf\xfe\xec\xfe\xec\xfe$\xff$\xff3\ +\xff3\xffC\xffC\xff\xa9\xff\xa9\xff\x1d\x00\x1d\x00p\ +\x00p\x00r\x00r\x00~\x00~\x00\x89\x00\x89\x00v\ +\x00v\x003\x003\x00B\x00B\x00\x99\x00\x99\x00\xdf\ +\x00\xdf\x00\xe6\x00\xe6\x00\x0e\x01\x0e\x01a\x01a\x01\x91\ +\x01\x91\x01\xa8\x01\xa8\x01\xcf\x01\xcf\x01\x17\x02\x17\x02o\ +\x02o\x02u\x02u\x02V\x02V\x02\xfc\x01\xfc\x01\xcf\ +\x01\xcf\x01\x8c\x01\x8c\x01P\x01P\x01&\x01&\x01\xf7\ +\x00\xf7\x00\xe6\x00\xe6\x00\xdd\x00\xdd\x00\xa4\x00\xa4\x00\x88\ +\x00\x88\x00\x8f\x00\x8f\x00\xa1\x00\xa1\x00\x7f\x00\x7f\x00\x1e\ +\x00\x1e\x00\xcf\xff\xcf\xffs\xffs\xff\x19\xff\x19\xff\xc2\ +\xfe\xc2\xfe\xa2\xfe\xa2\xfe\xb8\xfe\xb8\xfe\xe6\xfe\xe6\xfe\xd7\ +\xfe\xd7\xfe\xa9\xfe\xa9\xfex\xfex\xfet\xfet\xfe|\ +\xfe|\xfef\xfef\xfe[\xfe[\xfem\xfem\xfeG\ +\xfeG\xfe\xed\xfd\xed\xfd\xd4\xfd\xd4\xfd'\xfe'\xfet\ +\xfet\xfex\xfex\xfe|\xfe|\xfe\xb1\xfe\xb1\xfe\xf9\ +\xfe\xf9\xfe>\xff>\xffY\xffY\xff\xb3\xff\xb3\xff\xf7\ +\xff\xf7\xff\x07\x00\x07\x00\x14\x00\x14\x00F\x00F\x00\x98\ +\x00\x98\x00\xc5\x00\xc5\x00\xa4\x00\xa4\x00j\x00j\x00\xaa\ +\x00\xaa\x00\x13\x01\x13\x01.\x01.\x01\x12\x01\x12\x01\xf5\ +\x00\xf5\x00\xed\x00\xed\x00\xf1\x00\xf1\x003\x013\x01\x9d\ +\x01\x9d\x01\xcf\x01\xcf\x01\xc4\x01\xc4\x01\xe3\x01\xe3\x01\xdc\ +\x01\xdc\x01\xed\x01\xed\x01!\x02!\x02F\x02F\x027\ +\x027\x02\x14\x02\x14\x02\xd4\x01\xd4\x01f\x01f\x01B\ +\x01B\x015\x015\x01\x12\x01\x12\x01\xf5\x00\xf5\x00\xad\ +\x00\xad\x00e\x00e\x00K\x00K\x00\x13\x00\x13\x00\xba\ +\xff\xba\xffJ\xffJ\xff\x0a\xff\x0a\xff\xf0\xfe\xf0\xfe\xf1\ +\xfe\xf1\xfe\x0a\xff\x0a\xff\xd9\xfe\xd9\xfe\xca\xfe\xca\xfe\xba\ +\xfe\xba\xfe\xa2\xfe\xa2\xfe\x92\xfe\x92\xfeb\xfeb\xfe$\ +\xfe$\xfe\xf3\xfd\xf3\xfd\xde\xfd\xde\xfd\xaf\xfd\xaf\xfd\x8d\ +\xfd\x8d\xfd\x89\xfd\x89\xfd\x8f\xfd\x8f\xfd\xaa\xfd\xaa\xfd\xd7\ +\xfd\xd7\xfd\x04\xfe\x04\xfe(\xfe(\xfeH\xfeH\xfeT\ +\xfeT\xfe\x90\xfe\x90\xfe\xc7\xfe\xc7\xfe\xe8\xfe\xe8\xfe\xe3\ +\xfe\xe3\xfe\xe5\xfe\xe5\xfe\x12\xff\x12\xff~\xff~\xff\xf4\ +\xff\xf4\xff}\x00}\x00\x07\x01\x07\x013\x013\x01.\ +\x01.\x01f\x01f\x01\xbd\x01\xbd\x01\xd4\x01\xd4\x01\xe1\ +\x01\xe1\x01\xdf\x01\xdf\x01\xed\x01\xed\x01\x1e\x02\x1e\x02p\ +\x02p\x02\xe2\x02\xe2\x02,\x03,\x03\xfd\x02\xfd\x02\xbd\ +\x02\xbd\x02\x0d\x03\x0d\x03R\x03R\x03K\x03K\x03\x01\ +\x03\x01\x03\x9c\x02\x9c\x02e\x02e\x02\x14\x02\x14\x02\xc9\ +\x01\xc9\x01\x85\x01\x85\x01~\x01~\x01Q\x01Q\x01\xef\ +\x00\xef\x00\xa7\x00\xa7\x00s\x00s\x006\x006\x00\xa9\ +\xff\xa9\xff\xd6\xfe\xd6\xfeQ\xfeQ\xfe\x1a\xfe\x1a\xfe\xfe\ +\xfd\xfe\xfd\x05\xfe\x05\xfe'\xfe'\xfe\xf2\xfd\xf2\xfd}\ +\xfd}\xfd,\xfd,\xfd\x0e\xfd\x0e\xfd1\xfd1\xfdO\ +\xfdO\xfd\x04\xfd\x04\xfd\xaf\xfc\xaf\xfc\x91\xfc\x91\xfcm\ +\xfcm\xfct\xfct\xfc\xb7\xfc\xb7\xfc\xf7\xfc\xf7\xfc\xfe\ +\xfc\xfe\xfc#\xfd#\xfdh\xfdh\xfd\xf1\xfd\xf1\xfdc\ +\xfec\xfez\xfez\xfe\x9c\xfe\x9c\xfe\xed\xfe\xed\xfeY\ +\xffY\xff\x91\xff\x91\xff\xd8\xff\xd8\xff<\x00<\x00x\ +\x00x\x00\xa4\x00\xa4\x00\xe9\x00\xe9\x00q\x01q\x01\xcd\ +\x01\xcd\x01\xd1\x01\xd1\x01\xe8\x01\xe8\x01/\x02/\x02c\ +\x02c\x02f\x02f\x02z\x02z\x02\x9e\x02\x9e\x02\xc3\ +\x02\xc3\x02\xee\x02\xee\x02.\x03.\x03\x99\x03\x99\x03\xf3\ +\x03\xf3\x03\xd7\x03\xd7\x03\xa5\x03\xa5\x03j\x03j\x03D\ +\x03D\x03\x11\x03\x11\x03=\x03=\x039\x039\x03\xef\ +\x02\xef\x02u\x02u\x02\x0f\x02\x0f\x02\xbe\x01\xbe\x01n\ +\x01n\x01\x12\x01\x12\x01\x98\x00\x98\x00\x19\x00\x19\x00\x8e\ +\xff\x8e\xff\x22\xff\x22\xff\xbe\xfe\xbe\xfex\xfex\xfe;\ +\xfe;\xfe\xb2\xfd\xb2\xfdu\xfdu\xfdv\xfdv\xfd\x90\ +\xfd\x90\xfdC\xfdC\xfd\xfe\xfc\xfe\xfc\xc8\xfc\xc8\xfc\xbb\ +\xfc\xbb\xfc\x0b\xfd\x0b\xfd\x12\xfd\x12\xfd\xd9\xfc\xd9\xfc\xe8\ +\xfc\xe8\xfc\xcc\xfc\xcc\xfc\xda\xfc\xda\xfc\x1a\xfd\x1a\xfdB\ +\xfdB\xfd7\xfd7\xfd<\xfd<\xfdY\xfdY\xfd\x81\ +\xfd\x81\xfd\x9d\xfd\x9d\xfdt\xfdt\xfd\x9b\xfd\x9b\xfd\x86\ +\xfe\x86\xfe\x19\xff\x19\xffB\xffB\xff\x99\xff\x99\xff\x10\ +\x00\x10\x00\x8d\x00\x8d\x00\xdb\x00\xdb\x00\x17\x01\x17\x01e\ +\x01e\x01\xcf\x01\xcf\x01\xbf\x01\xbf\x01\x02\x02\x02\x02\xa1\ +\x02\xa1\x02-\x03-\x03#\x03#\x03\x18\x03\x18\x03B\ +\x03B\x03_\x03_\x03&\x03&\x03\xc6\x02\xc6\x02\xd9\ +\x02\xd9\x02\xe4\x02\xe4\x02o\x02o\x02\xe5\x01\xe5\x01\x09\ +\x02\x09\x02R\x02R\x02f\x02f\x022\x022\x02\x04\ +\x02\x04\x02\x0f\x02\x0f\x02\xd1\x01\xd1\x01h\x01h\x01i\ +\x01i\x01S\x01S\x01\xc8\x00\xc8\x00%\x00%\x00\xc9\ +\xff\xc9\xffu\xffu\xff\x09\xff\x09\xff\x9e\xfe\x9e\xfe\x9b\ +\xfe\x9b\xfe\xec\xfe\xec\xfe\xec\xfe\xec\xfez\xfez\xfe<\ +\xfe<\xfeL\xfeL\xfe!\xfe!\xfe\x9e\xfd\x9e\xfd\x80\ +\xfd\x80\xfd\x9c\xfd\x9c\xfd\xd8\xfd\xd8\xfd\xcc\xfd\xcc\xfdg\ +\xfdg\xfdM\xfdM\xfdJ\xfdJ\xfdR\xfdR\xfd\x92\ +\xfd\x92\xfd\xea\xfd\xea\xfd\x14\xfe\x14\xfe\xe1\xfd\xe1\xfd\xb8\ +\xfd\xb8\xfd'\xfe'\xfe\xc7\xfe\xc7\xfe\x00\xff\x00\xff9\ +\xff9\xff\xbb\xff\xbb\xffq\x00q\x00\xe0\x00\xe0\x00\x0e\ +\x01\x0e\x01\x11\x01\x11\x010\x010\x01\xfd\x00\xfd\x00\x98\ +\x00\x98\x00\xec\x00\xec\x00\xb0\x01\xb0\x01\xc4\x01\xc4\x01\x8a\ +\x01\x8a\x01\xa1\x01\xa1\x01\xf0\x01\xf0\x01y\x02y\x02\xc2\ +\x02\xc2\x02\xc6\x02\xc6\x02\xf2\x02\xf2\x02\xab\x02\xab\x02/\ +\x02/\x02R\x02R\x02\xce\x02\xce\x02\xac\x02\xac\x02-\ +\x02-\x02\xd3\x01\xd3\x01\xef\x01\xef\x01/\x02/\x02\xc1\ +\x01\xc1\x01S\x01S\x01,\x01,\x01\x05\x01\x05\x01\xc0\ +\x00\xc0\x00\x8a\x00\x8a\x00B\x00B\x00\xf6\xff\xf6\xffx\ +\xffx\xff\xf4\xfe\xf4\xfe\xfc\xfe\xfc\xfe6\xff6\xff\xfd\ +\xfe\xfd\xfe^\xfe^\xfe\xd8\xfd\xd8\xfdp\xfdp\xfd\x91\ +\xfd\x91\xfd\x91\xfd\x91\xfd\x8b\xfd\x8b\xfdc\xfdc\xfd\xd8\ +\xfc\xd8\xfcY\xfcY\xfc\x81\xfc\x81\xfc\xe7\xfc\xe7\xfc\x15\ +\xfd\x15\xfd\x13\xfd\x13\xfd\xc6\xfc\xc6\xfc\xd5\xfc\xd5\xfc\xfe\ +\xfc\xfe\xfc5\xfd5\xfd\xb8\xfd\xb8\xfdZ\xfeZ\xfey\ +\xfey\xfe\x8c\xfe\x8c\xfe\xf4\xfe\xf4\xfe\x80\xff\x80\xff\xf7\ +\xff\xf7\xff7\x007\x00\x93\x00\x93\x00\xfa\x00\xfa\x00A\ +\x01A\x01:\x01:\x01\x8b\x01\x8b\x01\xde\x01\xde\x01\xd2\ +\x01\xd2\x01\xce\x01\xce\x01\xfe\x01\xfe\x01M\x02M\x02\xa1\ +\x02\xa1\x02\xce\x02\xce\x02\x11\x03\x11\x03`\x03`\x03o\ +\x03o\x03O\x03O\x03g\x03g\x03\x88\x03\x88\x032\ +\x032\x03\xba\x02\xba\x02^\x02^\x02:\x02:\x02!\ +\x02!\x02\x01\x02\x01\x02\xb8\x01\xb8\x01~\x01~\x01\x15\ +\x01\x15\x01\x9c\x00\x9c\x00h\x00h\x002\x002\x00\xd1\ +\xff\xd1\xff7\xff7\xff\xd3\xfe\xd3\xfe\xb4\xfe\xb4\xfe\xaf\ +\xfe\xaf\xfes\xfes\xfe$\xfe$\xfe\xfa\xfd\xfa\xfd\xde\ +\xfd\xde\xfd\xad\xfd\xad\xfd\xb2\xfd\xb2\xfd\xaa\xfd\xaa\xfdN\ +\xfdN\xfd\xfe\xfc\xfe\xfc\xd7\xfc\xd7\xfc\xeb\xfc\xeb\xfc\x00\ +\xfd\x00\xfd\xe2\xfc\xe2\xfc\xbf\xfc\xbf\xfc\xea\xfc\xea\xfc-\ +\xfd-\xfdc\xfdc\xfd\xc1\xfd\xc1\xfd\xf7\xfd\xf7\xfd\x04\ +\xfe\x04\xfe\x0e\xfe\x0e\xfe;\xfe;\xfe\x82\xfe\x82\xfe\xdc\ +\xfe\xdc\xfe1\xff1\xff\x89\xff\x89\xff\xf5\xff\xf5\xffE\ +\x00E\x00\x9f\x00\x9f\x002\x012\x01\x84\x01\x84\x01\xae\ +\x01\xae\x01\xc7\x01\xc7\x01\xe8\x01\xe8\x01\x07\x02\x07\x02\x1c\ +\x02\x1c\x020\x020\x02K\x02K\x02\x85\x02\x85\x02\xd1\ +\x02\xd1\x02\x0b\x03\x0b\x03`\x03`\x03\x84\x03\x84\x03v\ +\x03v\x03T\x03T\x036\x036\x03$\x03$\x032\ +\x032\x036\x036\x03\x03\x03\x03\x03\xbd\x02\xbd\x02[\ +\x02[\x02\xff\x01\xff\x01\xab\x01\xab\x01Y\x01Y\x01\x1a\ +\x01\x1a\x01\xb3\x00\xb3\x00\x1a\x00\x1a\x00\xb8\xff\xb8\xff|\ +\xff|\xff:\xff:\xff\x05\xff\x05\xff\x96\xfe\x96\xfe\x1f\ +\xfe\x1f\xfe\xe8\xfd\xe8\xfd\xcf\xfd\xcf\xfd\xca\xfd\xca\xfd\x91\ +\xfd\x91\xfdn\xfdn\xfd\x19\xfd\x19\xfd \xfd \xfdD\ +\xfdD\xfd\x1d\xfd\x1d\xfd\x18\xfd\x18\xfd\x0e\xfd\x0e\xfd\x08\ +\xfd\x08\xfd;\xfd;\xfd]\xfd]\xfdg\xfdg\xfdN\ +\xfdN\xfdU\xfdU\xfdp\xfdp\xfd\x85\xfd\x85\xfdx\ +\xfdx\xfd^\xfd^\xfd\xe4\xfd\xe4\xfd\xb8\xfe\xb8\xfe\x05\ +\xff\x05\xff1\xff1\xff\x8b\xff\x8b\xff\x14\x00\x14\x00\x95\ +\x00\x95\x00\xd8\x00\xd8\x00 \x01 \x01{\x01{\x01\x84\ +\x01\x84\x01\x88\x01\x88\x01\x15\x02\x15\x02\xd0\x02\xd0\x02\x16\ +\x03\x16\x03\xf5\x02\xf5\x02\x0b\x03\x0b\x035\x035\x033\ +\x033\x03\xce\x02\xce\x02\xa7\x02\xa7\x02\xdc\x02\xdc\x02\x90\ +\x02\x90\x02\xde\x01\xde\x01\xa9\x01\xa9\x01\xfd\x01\xfd\x011\ +\x021\x027\x027\x02\xf7\x01\xf7\x01\xee\x01\xee\x01\xde\ +\x01\xde\x01w\x01w\x01[\x01[\x01k\x01k\x01'\ +\x01'\x01d\x00d\x00\xe8\xff\xe8\xff\xa3\xff\xa3\xffO\ +\xffO\xff\xcf\xfe\xcf\xfe\x9d\xfe\x9d\xfe\xf5\xfe\xf5\xfe9\ +\xff9\xff\xdd\xfe\xdd\xfe{\xfe{\xfe`\xfe`\xfe\x5c\ +\xfe\x5c\xfe\xf7\xfd\xf7\xfd\xaa\xfd\xaa\xfd\xb1\xfd\xb1\xfd\xd1\ +\xfd\xd1\xfd\xfe\xfd\xfe\xfd\xa7\xfd\xa7\xfd_\xfd_\xfdh\ +\xfdh\xfdx\xfdx\xfd\xa2\xfd\xa2\xfd\xf2\xfd\xf2\xfd,\ +\xfe,\xfe\x1b\xfe\x1b\xfe\xd3\xfd\xd3\xfd\xc7\xfd\xc7\xfdU\ +\xfeU\xfe\xb7\xfe\xb7\xfe\xe1\xfe\xe1\xfe@\xff@\xff\x06\ +\x00\x06\x00\xa7\x00\xa7\x00\xe6\x00\xe6\x00\xd6\x00\xd6\x00\xfa\ +\x00\xfa\x00\x19\x01\x19\x01\xb1\x00\xb1\x00{\x00{\x00+\ +\x01+\x01\xbe\x01\xbe\x01\x88\x01\x88\x01^\x01^\x01\xa1\ +\x01\xa1\x01 \x02 \x02\x91\x02\x91\x02\x80\x02\x80\x02\xa8\ +\x02\xa8\x02\xce\x02\xce\x02b\x02b\x02:\x02:\x02\xb0\ +\x02\xb0\x02\xe8\x02\xe8\x02\x87\x02\x87\x02\x0d\x02\x0d\x02\xe9\ +\x01\xe9\x01e\x02e\x02c\x02c\x02\xc5\x01\xc5\x01y\ +\x01y\x01^\x01^\x017\x017\x01\x06\x01\x06\x01\xdc\ +\x00\xdc\x00\x92\x00\x92\x00#\x00#\x00i\xffi\xff\xfe\ +\xfe\xfe\xfeB\xffB\xffg\xffg\xff\xc5\xfe\xc5\xfe\x07\ +\xfe\x07\xfer\xfdr\xfdN\xfdN\xfd\xa4\xfd\xa4\xfd\xd3\ +\xfd\xd3\xfd\xd2\xfd\xd2\xfd(\xfd(\xfdo\xfco\xfc\x17\ +\xfc\x17\xfc\x96\xfc\x96\xfc\xe4\xfc\xe4\xfc\xec\xfc\xec\xfc\x9e\ +\xfc\x9e\xfcA\xfcA\xfcI\xfcI\xfcp\xfcp\xfc\xfa\ +\xfc\xfa\xfc\xd3\xfd\xd3\xfdS\xfeS\xfe>\xfe>\xfe\x83\ +\xfe\x83\xfe\xf4\xfe\xf4\xfev\xffv\xff\xb9\xff\xb9\xff\x01\ +\x00\x01\x00{\x00{\x00\xfc\x00\xfc\x00\x05\x01\x05\x01\x04\ +\x01\x04\x01\xa3\x01\xa3\x01\xc6\x01\xc6\x01\x9e\x01\x9e\x01\x98\ +\x01\x98\x01\xda\x01\xda\x01\xff\x01\xff\x01;\x02;\x02\xbf\ +\x02\xbf\x02n\x03n\x03\x91\x03\x91\x03\x1f\x03\x1f\x03\x0a\ +\x03\x0a\x03\xcd\x03\xcd\x03\xf9\x03\xf9\x03F\x03F\x03\xa8\ +\x02\xa8\x02\x87\x02\x87\x02\x8c\x02\x8c\x02E\x02E\x02\x06\ +\x02\x06\x02\xf1\x01\xf1\x01\xcc\x01\xcc\x01\xf7\x00\xf7\x00\xa8\ +\x00\xa8\x00\xfb\x00\xfb\x00$\x01$\x01{\x00{\x00\xd5\ +\xff\xd5\xff\xa7\xff\xa7\xffv\xffv\xff\x03\xff\x03\xff\xa3\ +\xfe\xa3\xfe\xa6\xfe\xa6\xfe\xb5\xfe\xb5\xfel\xfel\xfeM\ +\xfeM\xfe}\xfe}\xfep\xfep\xfe\xf5\xfd\xf5\xfdt\ +\xfdt\xfdw\xfdw\xfd\xb9\xfd\xb9\xfdl\xfdl\xfd\xce\ +\xfc\xce\xfc\xc2\xfc\xc2\xfc(\xfd(\xfd\x19\xfd\x19\xfd\x0c\ +\xfd\x0c\xfdA\xfdA\xfd7\xfd7\xfd\xf0\xfc\xf0\xfc\xa1\ +\xfc\xa1\xfc\xd8\xfc\xd8\xfcp\xfdp\xfd\xe5\xfd\xe5\xfd\xfd\ +\xfd\xfd\xfds\xfes\xfe\x1c\xff\x1c\xff\x95\xff\x95\xff\x18\ +\x00\x18\x00\xa0\x00\xa0\x00\x1c\x01\x1c\x01(\x01(\x01\xbd\ +\x00\xbd\x00\xcb\x00\xcb\x00d\x01d\x01\xb8\x01\xb8\x01\xa1\ +\x01\xa1\x01\xad\x01\xad\x01\x12\x02\x12\x02t\x02t\x02\x9a\ +\x02\x9a\x02\xb9\x02\xb9\x02\x1e\x03\x1e\x03\x0e\x03\x0e\x03\xb4\ +\x02\xb4\x02\xb3\x02\xb3\x02\xf7\x02\xf7\x02 \x03 \x03\xe8\ +\x02\xe8\x02\xf0\x02\xf0\x02\xde\x02\xde\x02\x90\x02\x90\x02\xff\ +\x01\xff\x01\xd8\x01\xd8\x01.\x02.\x02h\x02h\x02\xc9\ +\x01\xc9\x01O\x01O\x01L\x01L\x01\x81\x01\x81\x01S\ +\x01S\x01\xc8\x00\xc8\x00\x83\x00\x83\x00Y\x00Y\x00\xcd\ +\xff\xcd\xff\x1e\xff\x1e\xffN\xffN\xffz\xffz\xff\xd6\ +\xfe\xd6\xfe\x1b\xfe\x1b\xfe\x13\xfe\x13\xfe?\xfe?\xfe\x05\ +\xfe\x05\xfey\xfdy\xfdD\xfdD\xfdU\xfdU\xfd\xf0\ +\xfc\xf0\xfc\x8f\xfc\x8f\xfc\xba\xfc\xba\xfc\x07\xfd\x07\xfd\xde\ +\xfc\xde\xfc\xbb\xfc\xbb\xfc\xbe\xfc\xbe\xfc,\xfd,\xfdV\ +\xfdV\xfd\x15\xfd\x15\xfdQ\xfdQ\xfd\xfe\xfd\xfe\xfd\xe4\ +\xfd\xe4\xfd\xbb\xfd\xbb\xfd,\xfe,\xfe\xcf\xfe\xcf\xfeN\ +\xffN\xffS\xffS\xff\x82\xff\x82\xffP\x00P\x00\x0e\ +\x01\x0e\x01&\x01&\x01\xc0\x01\xc0\x01\x94\x02\x94\x02\xc0\ +\x02\xc0\x02\xc3\x02\xc3\x02\xdb\x02\xdb\x021\x031\x03o\ +\x03o\x03E\x03E\x03\x0e\x03\x0e\x03P\x03P\x03,\ +\x03,\x03\xc3\x02\xc3\x02\x1f\x03\x1f\x03\xb2\x03\xb2\x03\xea\ +\x03\xea\x03\x95\x03\x95\x03{\x03{\x03\x9b\x03\x9b\x03\x7f\ +\x03\x7f\x03\xc4\x02\xc4\x028\x028\x02\x09\x02\x09\x02B\ +\x01B\x01f\x00f\x00:\x00:\x00\x16\x00\x16\x00\xaf\ +\xff\xaf\xff\x1e\xff\x1e\xff\xae\xfe\xae\xfe\xb8\xfe\xb8\xfe|\ +\xfe|\xfe\xda\xfd\xda\xfdt\xfdt\xfdF\xfdF\xfd\xe0\ +\xfc\xe0\xfc\x85\xfc\x85\xfcZ\xfcZ\xfc\x1e\xfc\x1e\xfc\x18\ +\xfc\x18\xfc\xe5\xfb\xe5\xfb\xa2\xfb\xa2\xfb\xd9\xfb\xd9\xfb\xfb\ +\xfb\xfb\xfb\xec\xfb\xec\xfb\x0a\xfc\x0a\xfc\x0f\xfc\x0f\xfc\x14\ +\xfc\x14\xfc8\xfc8\xfcU\xfcU\xfc\xae\xfc\xae\xfc\xeb\ +\xfc\xeb\xfc\xcd\xfc\xcd\xfc\xf2\xfc\xf2\xfcx\xfdx\xfd\xe6\ +\xfd\xe6\xfdo\xfeo\xfe:\xff:\xff\xed\xff\xed\xff0\ +\x000\x00\xaa\x00\xaa\x00T\x01T\x01\xdb\x01\xdb\x015\ +\x025\x02\xa0\x02\xa0\x02\x92\x03\x92\x03c\x04c\x04^\ +\x04^\x04/\x04/\x042\x042\x04R\x04R\x04\xe7\ +\x04\xe7\x04Q\x05Q\x05u\x05u\x05;\x05;\x05\xfc\ +\x04\xfc\x04\xef\x04\xef\x04L\x05L\x05\x92\x05\x92\x05\x91\ +\x05\x91\x05R\x05R\x05P\x04P\x04\xca\x03\xca\x03\xd2\ +\x03\xd2\x03\x96\x03\x96\x03\xed\x02\xed\x02}\x02}\x02\xbc\ +\x01\xbc\x01+\x01+\x01\xaa\x00\xaa\x00\x0e\x00\x0e\x00u\ +\xffu\xff\xbb\xfe\xbb\xfe\xb3\xfd\xb3\xfd3\xfd3\xfd\x18\ +\xfd\x18\xfd\xa4\xfc\xa4\xfcE\xfcE\xfc\xbd\xfb\xbd\xfb\x1f\ +\xfb\x1f\xfb\x81\xfa\x81\xfa1\xfa1\xfa6\xfa6\xfa\xc0\ +\xfa\xc0\xfa\xe3\xfa\xe3\xfa|\xfa|\xfa/\xfa/\xfa7\ +\xfa7\xfaA\xfaA\xfa\x80\xfa\x80\xfa\xa5\xfa\xa5\xfa\xaf\ +\xfa\xaf\xfa\x0f\xfb\x0f\xfbb\xfbb\xfb\xba\xfb\xba\xfbc\ +\xfcc\xfc\x0e\xfd\x0e\xfdY\xfdY\xfd\xaf\xfd\xaf\xfd\x05\ +\xfe\x05\xfen\xfen\xfe\xfa\xfe\xfa\xfeT\xffT\xff\xc0\ +\xff\xc0\xff\x97\x00\x97\x00@\x01@\x01\xed\x01\xed\x01\xac\ +\x02\xac\x02?\x03?\x03\x10\x04\x10\x04\x80\x04\x80\x04\xb5\ +\x04\xb5\x04G\x05G\x05\xd5\x05\xd5\x05\xcf\x05\xcf\x05\xfa\ +\x05\xfa\x058\x068\x06J\x06J\x06?\x06?\x06\x0c\ +\x06\x0c\x06\xfd\x05\xfd\x05F\x06F\x06\x22\x06\x22\x06\xab\ +\x05\xab\x05\x8d\x05\x8d\x05v\x05v\x05\x0b\x05\x0b\x05\xa8\ +\x04\xa8\x04e\x04e\x04\xf5\x03\xf5\x03\xb4\x03\xb4\x03H\ +\x03H\x03\xbd\x02\xbd\x02D\x02D\x02\x88\x01\x88\x01|\ +\x00|\x00\xf6\xff\xf6\xffb\xffb\xff1\xfe1\xfe\x02\ +\xfd\x02\xfd\x9c\xfb\x9c\xfb\xdf\xfa\xdf\xfaK\xfaK\xfa\xcd\ +\xf9\xcd\xf9\xc2\xf9\xc2\xf9\x1c\xfa\x1c\xfa\xac\xf9\xac\xf9<\ +\xf9<\xf9|\xf9|\xf9\xc3\xf9\xc3\xf9\xce\xf9\xce\xf9\xae\ +\xf9\xae\xf9~\xf9~\xf9\x8c\xf9\x8c\xf9\xa6\xf9\xa6\xf9\xc0\ +\xf9\xc0\xf9q\xfaq\xfa\xcd\xfa\xcd\xfa\xe3\xfa\xe3\xfa\x98\ +\xfb\x98\xfb\x9c\xfc\x9c\xfcx\xfdx\xfd\xf9\xfd\xf9\xfd!\ +\xfe!\xfex\xfex\xfe\x07\xff\x07\xff!\xff!\xffJ\ +\xffJ\xff+\x00+\x00)\x01)\x01\xc2\x01\xc2\x01l\ +\x02l\x02D\x03D\x03;\x04;\x04\xc0\x04\xc0\x04\x1d\ +\x05\x1d\x05\xdc\x05\xdc\x05\x8c\x06\x8c\x06j\x06j\x06`\ +\x06`\x06\xdc\x06\xdc\x06\xf1\x06\xf1\x06\x92\x06\x92\x06\xcb\ +\x05\xcb\x05L\x05L\x05\xf1\x04\xf1\x04V\x04V\x04\x7f\ +\x03\x7f\x03\x7f\x03\x7f\x03{\x03{\x03\x0e\x03\x0e\x03\xd0\ +\x02\xd0\x02\x9d\x02\x9d\x02\xfd\x01\xfd\x01\xf0\x00\xf0\x00\xf8\ +\xff\xf8\xff\x94\xff\x94\xffv\xffv\xffx\xfex\xfe\x9e\ +\xfd\x9e\xfdl\xfdl\xfdb\xfdb\xfd\x1d\xfd\x1d\xfd@\ +\xfd@\xfd@\xfd@\xfd\x13\xfd\x13\xfd~\xfc~\xfc\x05\ +\xfc\x05\xfc\xe6\xfb\xe6\xfb\xd6\xfb\xd6\xfbx\xfbx\xfb%\ +\xfb%\xfb\xf9\xfa\xf9\xfa\xa0\xfa\xa0\xfa\x94\xfa\x94\xfa\xc9\ +\xfa\xc9\xfa\x1b\xfb\x1b\xfb\x10\xfb\x10\xfb\x0e\xfb\x0e\xfb3\ +\xfb3\xfb\xcf\xfb\xcf\xfb]\xfc]\xfc\xa3\xfc\xa3\xfc[\ +\xfd[\xfd.\xfe.\xfe\xde\xfe\xde\xfe\x8d\xff\x8d\xff\x81\ +\x00\x81\x00R\x01R\x01\x9c\x01\x9c\x01\xdb\x01\xdb\x01\x8e\ +\x02\x8e\x02D\x03D\x03G\x03G\x03\x83\x03\x83\x03\x94\ +\x04\x94\x04\xa2\x05\xa2\x05\x13\x06\x13\x06v\x06v\x06\xc8\ +\x06\xc8\x06\xf6\x06\xf6\x06\x0b\x07\x0b\x07J\x07J\x07\xb9\ +\x07\xb9\x07s\x07s\x07\x06\x06\x06\x06\x0e\x05\x0e\x05\xd9\ +\x04\xd9\x04\xc6\x04\xc6\x04k\x04k\x04\xe5\x03\xe5\x03\xa8\ +\x03\xa8\x03d\x03d\x03q\x02q\x025\x015\x01\xb2\ +\x00\xb2\x00\x9f\x00\x9f\x00V\x00V\x00\xd2\xff\xd2\xffE\ +\xffE\xff\xa1\xfe\xa1\xfe\xee\xfd\xee\xfdH\xfdH\xfd=\ +\xfd=\xfd\x0c\xfd\x0c\xfd4\xfc4\xfc\x88\xfb\x88\xfb\x91\ +\xfb\x91\xfbk\xfbk\xfbA\xfbA\xfb,\xfb,\xfb\x89\ +\xfa\x89\xfa\x17\xfa\x17\xfa\x9c\xf9\x9c\xf9/\xf9/\xf9N\ +\xf9N\xf9\x83\xf9\x83\xf9L\xf9L\xf9-\xf9-\xf9\xea\ +\xf9\xea\xf9\xec\xfa\xec\xfa\x84\xfb\x84\xfb\x86\xfb\x86\xfb\xd7\ +\xfb\xd7\xfb\x88\xfc\x88\xfc\xbe\xfc\xbe\xfc\xe6\xfc\xe6\xfc\xbe\ +\xfd\xbe\xfd-\xfe-\xfe\x02\xfe\x02\xfe=\xfe=\xfe\xe8\ +\xfe\xe8\xfe\x96\xff\x96\xff\xe1\xff\xe1\xff\x10\x00\x10\x00\xb7\ +\x00\xb7\x00\xc3\x01\xc3\x01\xde\x02\xde\x02\x9b\x04\x9b\x04\xe7\ +\x05\xe7\x05d\x06d\x06\xb3\x06\xb3\x06\x12\x07\x12\x07?\ +\x07?\x07\xa0\x07\xa0\x073\x083\x08L\x08L\x08\x04\ +\x08\x04\x08Y\x07Y\x07\x00\x07\x00\x07\xe1\x06\xe1\x06\x88\ +\x06\x88\x06C\x06C\x06\x90\x06\x90\x06;\x06;\x067\ +\x057\x05\x0e\x05\x0e\x05\xe0\x04\xe0\x04C\x04C\x04\x9e\ +\x03\x9e\x03}\x02}\x02B\x01B\x01\x95\x00\x95\x00\xfa\ +\xff\xfa\xffE\xffE\xff\xf4\xfd\xf4\xfd\xbf\xfc\xbf\xfc\xb7\ +\xfb\xb7\xfb,\xfb,\xfb\xde\xfa\xde\xfa|\xfa|\xfa\x8e\ +\xf9\x8e\xf9w\xf8w\xf8\x07\xf8\x07\xf8)\xf8)\xf8o\ +\xf8o\xf8^\xf8^\xf8f\xf8f\xf8m\xf8m\xf8\x85\ +\xf8\x85\xf8&\xf9&\xf9\x90\xf9\x90\xf9F\xf9F\xf9\xaf\ +\xf8\xaf\xf8\xac\xf8\xac\xf8\xbd\xf9\xbd\xf9\x85\xfa\x85\xfa$\ +\xfb$\xfb\xe7\xfb\xe7\xfbu\xfcu\xfc\xab\xfc\xab\xfc,\ +\xfd,\xfdo\xfeo\xfe\xb5\xff\xb5\xff\xb7\x00\xb7\x00W\ +\x01W\x01?\x02?\x02\xe3\x02\xe3\x02\x88\x03\x88\x03u\ +\x04u\x04u\x05u\x05\x82\x06\x82\x06\x0d\x07\x0d\x07N\ +\x07N\x07\x8a\x07\x8a\x07\x03\x08\x03\x08\xd0\x07\xd0\x07\x97\ +\x07\x97\x07\xcc\x07\xcc\x07k\x08k\x08c\x08c\x08\x02\ +\x08\x02\x08P\x07P\x07\xc2\x06\xc2\x06\xe2\x05\xe2\x05\xf8\ +\x04\xf8\x04\xa3\x04\xa3\x04\xc9\x04\xc9\x04g\x04g\x04U\ +\x03U\x03H\x02H\x02u\x01u\x01/\x01/\x01y\ +\x00y\x002\x002\x00\xb8\xff\xb8\xff>\xff>\xff\x7f\ +\xfe\x7f\xfe\xe8\xfd\xe8\xfd1\xfd1\xfdV\xfcV\xfc\x95\ +\xfb\x95\xfb\xca\xfa\xca\xfah\xfah\xfa/\xfa/\xfag\ +\xfag\xfaN\xfaN\xfa\xec\xf9\xec\xf9\x1e\xf9\x1e\xf9\xd8\ +\xf8\xd8\xf8\xdf\xf8\xdf\xf82\xf92\xf9-\xf9-\xf9\x05\ +\xf9\x05\xf9\xad\xf8\xad\xf8t\xf8t\xf8\xb3\xf8\xb3\xf8\x91\ +\xf9\x91\xf9\x1e\xfb\x1e\xfb\xb1\xfb\xb1\xfb+\xfc+\xfc\xc4\ +\xfc\xc4\xfc\xcf\xfd\xcf\xfd\xaf\xfe\xaf\xfe\xa9\xff\xa9\xff\x22\ +\x00\x22\x00r\x00r\x00\x8b\x00\x8b\x00b\x00b\x00\xba\ +\x00\xba\x00{\x01{\x01\xf5\x01\xf5\x01\x9d\x02\x9d\x02\x98\ +\x03\x98\x03S\x04S\x04\x1b\x05\x1b\x05\x99\x05\x99\x05E\ +\x06E\x06\x19\x07\x19\x07\x92\x07\x92\x07j\x07j\x07J\ +\x07J\x07>\x07>\x07\xbe\x06\xbe\x06\x92\x06\x92\x06\xc5\ +\x06\xc5\x06\xf7\x06\xf7\x06\x97\x06\x97\x06\xdf\x05\xdf\x05\x07\ +\x05\x07\x05\xf8\x04\xf8\x04\xdc\x04\xdc\x04_\x04_\x04\xcc\ +\x03\xcc\x03'\x03'\x03\xf9\x01\xf9\x01\xbf\x00\xbf\x00\xaa\ +\xff\xaa\xffP\xffP\xff \xff \xffQ\xfeQ\xfe\xdd\ +\xfd\xdd\xfd\x91\xfd\x91\xfd\xee\xfc\xee\xfc\xfc\xfb\xfc\xfb\xb0\ +\xfb\xb0\xfb\xcc\xfb\xcc\xfb\x7f\xfb\x7f\xfb\x8c\xfa\x8c\xfa\x1d\ +\xfa\x1d\xfa\xbf\xf9\xbf\xf9X\xf9X\xf9\xc3\xf8\xc3\xf8\x8e\ +\xf8\x8e\xf8\xf3\xf8\xf3\xf8U\xf9U\xf9M\xf9M\xf9\x95\ +\xf9\x95\xf9A\xfaA\xfa\xaa\xfa\xaa\xfa\xe3\xfa\xe3\xfa\xf1\ +\xfa\xf1\xfa\xb3\xfb\xb3\xfb&\xfc&\xfc\x82\xfc\x82\xfc3\ +\xfd3\xfdv\xfev\xfe?\xff?\xffU\xffU\xff\xb1\ +\xff\xb1\xff\xa7\x00\xa7\x00\x8d\x01\x8d\x015\x025\x02P\ +\x03P\x03\x1a\x04\x1a\x04\x1b\x04\x1b\x04\xa2\x03\xa2\x03o\ +\x04o\x04\x9d\x05\x9d\x05\xfb\x05\xfb\x05\xbe\x05\xbe\x05\x86\ +\x05\x86\x05\xc0\x05\xc0\x05\xd5\x05\xd5\x05\xdb\x05\xdb\x05Q\ +\x06Q\x06\xcb\x06\xcb\x06v\x06v\x06%\x06%\x06\x03\ +\x07\x03\x07\x0e\x08\x0e\x08\xcd\x07\xcd\x07\x1a\x06\x1a\x06\xcc\ +\x04\xcc\x04M\x04M\x04\xb5\x03\xb5\x03\x8d\x02\x8d\x02\xc5\ +\x01\xc5\x01\xab\x01\xab\x01>\x01>\x01(\x00(\x00\x04\ +\x00\x04\x00\xa4\x00\xa4\x00\x1f\x00\x1f\x001\xfe1\xfe\xe4\ +\xfc\xe4\xfc\xec\xfc\xec\xfc)\xfc)\xfc\x89\xfa\x89\xfa\x17\ +\xf9\x17\xf9\xa0\xf8\xa0\xf8\xd6\xf7\xd6\xf7\xd4\xf6\xd4\xf6\xb3\ +\xf6\xb3\xf6\xd0\xf7\xd0\xf7o\xf8o\xf8\x18\xf8\x18\xf8\xfa\ +\xf7\xfa\xf7\xcc\xf8\xcc\xf8\x8a\xf9\x8a\xf9g\xf9g\xf9\xe6\ +\xf8\xe6\xf8\xb1\xf8\xb1\xf8\x98\xf8\x98\xf8\xb4\xf8\xb4\xf8\xa4\ +\xf9\xa4\xf9(\xfb(\xfb\x10\xfc\x10\xfc\x17\xfc\x17\xfc\x90\ +\xfc\x90\xfc\xb0\xfd\xb0\xfd\xe7\xfe\xe7\xfe\xff\xff\xff\xff'\ +\x01'\x01;\x02;\x02b\x02b\x02\x91\x02\x91\x02\xaf\ +\x03\xaf\x03@\x05@\x05\x05\x06\x05\x06\xfc\x05\xfc\x05]\ +\x06]\x06\x5c\x07\x5c\x07\x0c\x08\x0c\x08L\x08L\x08\xb0\ +\x08\xb0\x08\x1f\x09\x1f\x09\x18\x09\x18\x09@\x09@\x09\xcd\ +\x09\xcd\x09-\x0a-\x0a4\x0a4\x0a\xa7\x09\xa7\x09\xc5\ +\x08\xc5\x08\xfc\x07\xfc\x07x\x07x\x07\xf6\x06\xf6\x06`\ +\x06`\x06-\x05-\x05\xae\x03\xae\x03\xb4\x02\xb4\x02\xf8\ +\x01\xf8\x01\x9b\x01\x9b\x01\x12\x01\x12\x01\xd4\xff\xd4\xff\x96\ +\xfe\x96\xfe\xa8\xfd\xa8\xfd\xa9\xfc\xa9\xfc\xe7\xfb\xe7\xfb\x10\ +\xfb\x10\xfb:\xfa:\xfap\xf9p\xf9\xda\xf8\xda\xf8[\ +\xf8[\xf8+\xf8+\xf8\x17\xf8\x17\xf8C\xf8C\xf88\ +\xf88\xf8\x04\xf8\x04\xf8\xc3\xf7\xc3\xf7\xd8\xf7\xd8\xf7\xe8\ +\xf7\xe8\xf7\xfb\xf7\xfb\xf7\x89\xf8\x89\xf8\xfc\xf8\xfc\xf8a\ +\xf9a\xf9\x01\xfa\x01\xfaX\xfaX\xfa\x90\xfa\x90\xfa\xc7\ +\xfa\xc7\xfa7\xfb7\xfb0\xfc0\xfc8\xfd8\xfd%\ +\xfe%\xfe\x0e\xff\x0e\xffm\xffm\xff\xe1\xff\xe1\xff\xf1\ +\x00\xf1\x00\x84\x02\x84\x02\x95\x03\x95\x03\x22\x04\x22\x04\x92\ +\x04\x92\x044\x054\x05\xca\x05\xca\x05\x16\x06\x16\x066\ +\x066\x06\x83\x06\x83\x06\xb1\x06\xb1\x06\x9b\x06\x9b\x06\x9c\ +\x06\x9c\x06\x07\x07\x07\x07\x1b\x07\x1b\x07\xe8\x06\xe8\x06\xb4\ +\x06\xb4\x06u\x06u\x065\x065\x062\x062\x06\x0e\ +\x06\x0e\x06|\x05|\x05\x88\x04\x88\x04\xd5\x03\xd5\x03|\ +\x03|\x03\xe2\x02\xe2\x021\x021\x02\x99\x01\x99\x01C\ +\x01C\x01\x9b\x00\x9b\x00\xe4\xff\xe4\xff-\xff-\xff\xac\ +\xfe\xac\xfe\xf3\xfd\xf3\xfd4\xfd4\xfd\xc1\xfc\xc1\xfcb\ +\xfcb\xfc&\xfc&\xfc\x9d\xfb\x9d\xfb\xc8\xfa\xc8\xfa\x9b\ +\xf9\x9b\xf9\xba\xf8\xba\xf8\xae\xf8\xae\xf8\xa1\xf8\xa1\xf8v\ +\xf8v\xf8$\xf8$\xf8\xd3\xf7\xd3\xf7\xc5\xf7\xc5\xf7\x0c\ +\xf8\x0c\xf8\xe8\xf8\xe8\xf8\xc5\xf9\xc5\xf9[\xfa[\xfa\xcd\ +\xfa\xcd\xfaq\xfbq\xfbA\xfcA\xfc\xf5\xfc\xf5\xfc\x81\ +\xfd\x81\xfd\x14\xfe\x14\xfe\xdb\xfe\xdb\xfe6\xff6\xffm\ +\xffm\xffC\x00C\x004\x014\x01\xf0\x01\xf0\x01\xac\ +\x02\xac\x02\x81\x03\x81\x03\x92\x04\x92\x04\x8b\x05\x8b\x05\x1f\ +\x06\x1f\x06\xb4\x06\xb4\x06M\x07M\x07\xc2\x07\xc2\x07\xea\ +\x07\xea\x07b\x08b\x08t\x08t\x08J\x08J\x08\xf8\ +\x07\xf8\x07\x97\x07\x97\x07`\x07`\x07\x08\x07\x08\x07X\ +\x06X\x06\xd9\x05\xd9\x05\xa5\x05\xa5\x05\x05\x05\x05\x05\x1f\ +\x04\x1f\x04\x84\x03\x84\x03\xf8\x02\xf8\x02;\x02;\x02\xbf\ +\x00\xbf\x00\x92\xff\x92\xffO\xffO\xff\xb5\xfe\xb5\xfe\xe0\ +\xfd\xe0\xfd\x96\xfd\x96\xfd_\xfd_\xfdd\xfcd\xfc{\ +\xfb{\xfbS\xfbS\xfb\x87\xfb\x87\xfb\xea\xfa\xea\xfa\x16\ +\xfa\x16\xfa\xa6\xf9\xa6\xf9[\xf9[\xf9\xdc\xf8\xdc\xf8M\ +\xf8M\xf8m\xf8m\xf8\xd2\xf8\xd2\xf8\xfb\xf8\xfb\xf8\xc4\ +\xf8\xc4\xf8/\xf9/\xf9\xe3\xf9\xe3\xf9\x8c\xfa\x8c\xfa\x96\ +\xfa\x96\xfa\x0d\xfb\x0d\xfb\xcb\xfb\xcb\xfb)\xfc)\xfc\x9c\ +\xfc\x9c\xfc\x95\xfd\x95\xfd\xaf\xfe\xaf\xfe\xec\xfe\xec\xfe\xfd\ +\xfe\xfd\xfe\xc9\xff\xc9\xff\xed\x00\xed\x00\x94\x01\x94\x01\x7f\ +\x02\x7f\x02\x8f\x03\x8f\x03\x00\x04\x00\x04\xb0\x03\xb0\x03\xd4\ +\x03\xd4\x03\x1e\x05\x1e\x05\xe2\x05\xe2\x05\xf0\x05\xf0\x05\x94\ +\x05\x94\x05\xaf\x05\xaf\x05\xd2\x05\xd2\x05\xbb\x05\xbb\x05\x0c\ +\x06\x0c\x06\xef\x06\xef\x06\x1e\x07\x1e\x07}\x06}\x06\x98\ +\x06\x98\x06\x8c\x07\x8c\x07\xfe\x07\xfe\x07\xfe\x06\xfe\x06\x89\ +\x05\x89\x05\xbe\x04\xbe\x04\x19\x04\x19\x04'\x03'\x03[\ +\x02[\x02\x03\x02\x03\x02\xa5\x01\xa5\x01\xed\x00\xed\x00?\ +\x00?\x00\x89\x00\x89\x00\x9f\x00\x9f\x00f\xfff\xff\x86\ +\xfd\x86\xfd\x02\xfd\x02\xfd\xa3\xfc\xa3\xfcS\xfbS\xfb\xab\ +\xf9\xab\xf9\xd2\xf8\xd2\xf8g\xf8g\xf8k\xf7k\xf7\xa4\ +\xf6\xa4\xf6\x1f\xf7\x1f\xf7%\xf8%\xf8\x09\xf8\x09\xf8\xcc\ +\xf7\xcc\xf7W\xf8W\xf8>\xf9>\xf9\x89\xf9\x89\xf9\xff\ +\xf8\xff\xf8\xab\xf8\xab\xf8t\xf8t\xf8E\xf8E\xf8\xe9\ +\xf8\xe9\xf8}\xfa}\xfa\xb4\xfb\xb4\xfb\xe5\xfb\xe5\xfb\x17\ +\xfc\x17\xfc\x18\xfd\x18\xfdB\xfeB\xfe<\xff<\xffA\ +\x00A\x00\xb4\x01\xb4\x01]\x02]\x02R\x02R\x02\x0f\ +\x03\x0f\x03\xa7\x04\xa7\x04\xc0\x05\xc0\x05\xe4\x05\xe4\x05\x1d\ +\x06\x1d\x06\x00\x07\x00\x07\xf6\x07\xf6\x07U\x08U\x08\xaa\ +\x08\xaa\x08\x17\x09\x17\x09V\x09V\x09^\x09^\x09\xcc\ +\x09\xcc\x09;\x0a;\x0as\x0as\x0a<\x0a<\x0a`\ +\x09`\x09\xc8\x08\xc8\x08\x1b\x08\x1b\x08\x82\x07\x82\x07\xc7\ +\x06\xc7\x06*\x06*\x06\xc4\x04\xc4\x04\x80\x03\x80\x03\x85\ +\x02\x85\x02\x06\x02\x06\x02\xcf\x01\xcf\x01\xee\x00\xee\x00n\ +\xffn\xffP\xfeP\xfeW\xfdW\xfd\x88\xfc\x88\xfc\xd2\ +\xfb\xd2\xfb\xd2\xfa\xd2\xfa\xf0\xf9\xf0\xf9E\xf9E\xf9\xb4\ +\xf8\xb4\xf85\xf85\xf8\x0b\xf8\x0b\xf8\xe2\xf7\xe2\xf7\xc5\ +\xf7\xc5\xf7\xa9\xf7\xa9\xf7n\xf7n\xf7;\xf7;\xf7{\ +\xf7{\xf7\xc0\xf7\xc0\xf7\x0a\xf8\x0a\xf8Z\xf8Z\xf8j\ +\xf8j\xf8\x1a\xf9\x1a\xf9\xb2\xf9\xb2\xf9\x07\xfa\x07\xfa%\ +\xfa%\xfaM\xfaM\xfa\xf1\xfa\xf1\xfa\xec\xfb\xec\xfb\xbd\ +\xfc\xbd\xfc\x9d\xfd\x9d\xfd9\xfe9\xfe\x8a\xfe\x8a\xfeB\ +\xffB\xff\x94\x00\x94\x000\x020\x02\xde\x02\xde\x02:\ +\x03:\x03\xce\x03\xce\x03\xe6\x04\xe6\x04m\x05m\x05\x9c\ +\x05\x9c\x05\x01\x06\x01\x06q\x06q\x06J\x06J\x06\x0e\ +\x06\x0e\x06\x95\x06\x95\x06%\x07%\x070\x070\x07H\ +\x07H\x07\x5c\x07\x5c\x07C\x07C\x07\x1b\x07\x1b\x07\x0a\ +\x07\x0a\x07\x01\x07\x01\x07v\x06v\x06e\x05e\x05\xe4\ +\x04\xe4\x04\x82\x04\x82\x04\x9b\x03\x9b\x03\xb7\x02\xb7\x02u\ +\x02u\x02!\x02!\x02|\x01|\x01\xe0\x00\xe0\x00c\ +\x00c\x00\xc9\xff\xc9\xff\xef\xfe\xef\xfef\xfef\xfe\xf3\ +\xfd\xf3\xfdD\xfdD\xfdo\xfco\xfc\xa8\xfb\xa8\xfb\xb0\ +\xfa\xb0\xfa1\xf91\xf9l\xf8l\xf8?\xf8?\xf8\xdc\ +\xf7\xdc\xf7j\xf7j\xf7.\xf7.\xf7#\xf7#\xf7G\ +\xf7G\xf7\xb6\xf7\xb6\xf7:\xf8:\xf8\xe4\xf8\xe4\xf8r\ +\xf9r\xf9\xea\xf9\xea\xf9\x9e\xfa\x9e\xfa\x01\xfb\x01\xfbQ\ +\xfbQ\xfb\xbd\xfb\xbd\xfb\xd9\xfc\xd9\xfc\xc8\xfd\xc8\xfd`\ +\xfe`\xfeb\xffb\xffe\x00e\x003\x013\x01\x1b\ +\x02\x1b\x02\xfc\x02\xfc\x02\xe4\x03\xe4\x03F\x05F\x05-\ +\x06-\x06\xd8\x06\xd8\x06,\x07,\x07\xb7\x07\xb7\x07L\ +\x08L\x08\x22\x09\x22\x09\x11\x0a\x11\x0a\x8c\x0a\x8c\x0a\x5c\ +\x0a\x5c\x0a\x22\x09\x22\x09_\x08_\x08B\x08B\x08\x13\ +\x08\x13\x08S\x07S\x07\xe1\x06\xe1\x06;\x06;\x065\ +\x055\x05\xee\x03\xee\x03\x81\x03\x81\x03\xf5\x03\xf5\x03j\ +\x03j\x03$\x01$\x01\xa0\xff\xa0\xff\x18\xff\x18\xff\xd1\ +\xfd\xd1\xfd\xb3\xfc\xb3\xfc\xbb\xfc\xbb\xfc\xbc\xfc\xbc\xfcL\ +\xfbL\xfb\xe1\xf9\xe1\xf9\xe7\xf9\xe7\xf9\xa3\xfa\xa3\xfaY\ +\xfaY\xfaC\xf9C\xf9\xcd\xf8\xcd\xf8\xc3\xf8\xc3\xf8\x0b\ +\xf8\x0b\xf8$\xf7$\xf7\x01\xf7\x01\xf7y\xf7y\xf7L\ +\xf7L\xf7\xca\xf6\xca\xf69\xf79\xf7\xa4\xf8\xa4\xf8\x18\ +\xf9\x18\xf9A\xf9A\xf9I\xfaI\xfah\xfbh\xfb\xcc\ +\xfb\xcc\xfb\x07\xfc\x07\xfc\x08\xfd\x08\xfd\x1b\xfe\x1b\xfe\x03\ +\xfe\x03\xfe\x9c\xfd\x9c\xfd0\xff0\xff\x1f\x01\x1f\x01*\ +\x02*\x02\xd2\x02\xd2\x02\x8d\x03\x8d\x033\x043\x04\xd4\ +\x04\xd4\x04\x7f\x05\x7f\x05\x89\x06\x89\x06,\x07,\x07\xdb\ +\x06\xdb\x06\xbf\x06\xbf\x06U\x07U\x07\x84\x07\x84\x07\xbd\ +\x07\xbd\x07#\x08#\x08\xd5\x08\xd5\x08G\x09G\x09C\ +\x08C\x08B\x07B\x07v\x07v\x07\x19\x08\x19\x08Q\ +\x07Q\x07e\x06e\x06\x84\x05\x84\x05\x0a\x05\x0a\x05\xaf\ +\x04\xaf\x04\x92\x04\x92\x04\xa2\x04\xa2\x04\x0b\x04\x0b\x04\x99\ +\x02\x99\x02\xdb\x01\xdb\x01\x11\x02\x11\x02j\x01j\x01\x91\ +\xff\x91\xff|\xfd|\xfdf\xfcf\xfcD\xfbD\xfb\x0d\ +\xfa\x0d\xfa\xcb\xf9\xcb\xf9\x17\xfa\x17\xfa\xe8\xf8\xe8\xf8\x5c\ +\xf7\x5c\xf7\xfb\xf6\xfb\xf6\x15\xf7\x15\xf7\xbb\xf6\xbb\xf6\x17\ +\xf6\x17\xf6m\xf5m\xf5\xc4\xf4\xc4\xf4\x9c\xf4\x9c\xf4\xc6\ +\xf4\xc6\xf4\x08\xf5\x08\xf5\xba\xf5\xba\xf5\x15\xf6\x15\xf6\x8b\ +\xf6\x8b\xf6z\xf7z\xf7\x15\xf9\x15\xf9\x90\xfa\x90\xfa\xd8\ +\xfa\xd8\xfaq\xfbq\xfb\xa3\xfc\xa3\xfc\x98\xfd\x98\xfdq\ +\xfeq\xfe\xf1\xff\xf1\xff\xaa\x01\xaa\x01\xaf\x02\xaf\x02\xd0\ +\x02\xd0\x02\x8f\x03\x8f\x03}\x05}\x05\xe7\x06\xe7\x06|\ +\x07|\x07a\x08a\x08Q\x09Q\x09\x7f\x09\x7f\x09\x11\ +\x09\x11\x09T\x09T\x09\x1b\x0a\x1b\x0a\x9b\x0a\x9b\x0a.\ +\x0a.\x0a1\x0a1\x0a\xf8\x0a\xf8\x0aD\x0bD\x0bl\ +\x0al\x0a}\x09}\x09\x02\x09\x02\x09\x9a\x08\x9a\x08\x9c\ +\x07\x9c\x07m\x06m\x06\xea\x05\xea\x05\x02\x05\x02\x05]\ +\x03]\x03l\x02l\x02'\x02'\x02O\x01O\x01\x14\ +\x00\x14\x00 \xff \xff\xbc\xfe\xbc\xfe\xdc\xfd\xdc\xfd/\ +\xfc/\xfc\x22\xfb\x22\xfbW\xfaW\xfa\x13\xf9\x13\xf9\x1d\ +\xf7\x1d\xf7\xd4\xf5\xd4\xf5\x88\xf5\x88\xf5\x87\xf4\x87\xf4\xb0\ +\xf3\xb0\xf3\xd1\xf3\xd1\xf3Y\xf4Y\xf4>\xf4>\xf4\x8c\ +\xf4\x8c\xf4\x00\xf6\x00\xf6x\xf7x\xf7\xa9\xf7\xa9\xf7\xe4\ +\xf7\xe4\xf7$\xf9$\xf9M\xfaM\xfa\x8d\xfa\x8d\xfa\xad\ +\xfa\xad\xfa\xf4\xfb\xf4\xfb\xf3\xfc\xf3\xfc\xeb\xfc\xeb\xfc\xb6\ +\xfd\xb6\xfd5\x005\x00\x99\x02\x99\x024\x034\x03\x1b\ +\x03\x1b\x03W\x04W\x04\xbd\x05\xbd\x05m\x05m\x05\x9e\ +\x05\x9e\x05\x95\x06\x95\x06\xc3\x06\xc3\x06\x90\x05\x90\x05\x13\ +\x05\x13\x05\xe0\x06\xe0\x06\x17\x08\x17\x08\x14\x08\x14\x08K\ +\x08K\x08\x95\x09\x95\x09K\x0aK\x0a\xd9\x09\xd9\x09\x85\ +\x09\x85\x09-\x0a-\x0a`\x09`\x09*\x07*\x07\x8e\ +\x06\x8e\x06\x8d\x07\x8d\x07\x85\x07\x85\x07z\x06z\x061\ +\x061\x06\x8e\x06\x8e\x06\xce\x05\xce\x05\xf6\x03\xf6\x03\x08\ +\x03\x08\x03\xad\x02\xad\x02\x8e\x00\x8e\x00\xeb\xfd\xeb\xfd\x87\ +\xfc\x87\xfc\x01\xfc\x01\xfc\xef\xfa\xef\xfa\x16\xf9\x16\xf9\x9a\ +\xf8\x9a\xf8\xbc\xf8\xbc\xf8\x95\xf8\x95\xf8\xe7\xf7\xe7\xf7O\ +\xf8O\xf8\xdd\xf7\xdd\xf7\x89\xf6\x89\xf6\xff\xf4\xff\xf4\xe5\ +\xf4\xe5\xf4\x0b\xf5\x0b\xf5I\xf4I\xf4O\xf3O\xf3f\ +\xf3f\xf3\xb6\xf3\xb6\xf3\x1f\xf4\x1f\xf4e\xf5e\xf5\x1c\ +\xf7\x1c\xf7\xf1\xf7\xf1\xf7A\xf8A\xf8\x80\xf9\x80\xf9\x0c\ +\xfb\x0c\xfb\x15\xfc\x15\xfc\x10\xfc\x10\xfc\xd1\xfc\xd1\xfc]\ +\xfe]\xfe\xf5\xff\xf5\xff=\x01=\x01\x14\x03\x14\x03T\ +\x04T\x04\xec\x04\xec\x04\x8c\x05\x8c\x05[\x07[\x07\x16\ +\x09\x16\x09\x9b\x09\x9b\x09{\x09{\x09=\x09=\x09\xbc\ +\x09\xbc\x09\x01\x0a\x01\x0aq\x0aq\x0a\x17\x0b\x17\x0b\xab\ +\x0b\xab\x0bR\x0bR\x0b\xf6\x0a\xf6\x0a\x99\x0a\x99\x0a\x86\ +\x0a\x86\x0aG\x0aG\x0a\xfa\x09\xfa\x09L\x09L\x09\xa1\ +\x07\xa1\x07`\x06`\x06\xf4\x05\xf4\x05%\x06%\x06\x14\ +\x05\x14\x05\x09\x03\x09\x03\x8a\x01\x8a\x018\x018\x01\xf1\ +\x00\xf1\x00D\x00D\x00\x0e\xff\x0e\xff\xba\xfd\xba\xfd\xdd\ +\xfb\xdd\xfb4\xfa4\xfa\xe9\xf8\xe9\xf8\x17\xf8\x17\xf8\xb4\ +\xf6\xb4\xf6(\xf5(\xf5}\xf3}\xf3\x80\xf2\x80\xf2g\ +\xf2g\xf2\xce\xf2\xce\xf2\x90\xf3\x90\xf3S\xf4S\xf4\xe3\ +\xf4\xe3\xf4\x9a\xf5\x9a\xf5\xaa\xf6\xaa\xf6X\xf7X\xf7\xa4\ +\xf7\xa4\xf7\x00\xf7\x00\xf7\x00\xf7\x00\xf7K\xf8K\xf8$\ +\xfa$\xfa\x88\xfb\x88\xfb\x5c\xfc\x5c\xfc\x0e\xfd\x0e\xfd4\ +\xfe4\xfeW\xffW\xff?\x01?\x01\xa4\x02\xa4\x02\xc8\ +\x02\xc8\x02\x8a\x02\x8a\x02\x11\x03\x11\x03\xd0\x04\xd0\x04<\ +\x06<\x06U\x07U\x07A\x08A\x08\xac\x08\xac\x08h\ +\x08h\x08\xc1\x08\xc1\x08[\x09[\x09\xf7\x09\xf7\x09\xbc\ +\x09\xbc\x09\xc3\x08\xc3\x08\xb8\x07\xb8\x07\xe8\x07\xe8\x07\xdb\ +\x08\xdb\x08\xb4\x09\xb4\x09%\x0a%\x0a\xb3\x09\xb3\x09X\ +\x08X\x084\x074\x07\xce\x06\xce\x06W\x06W\x06\xad\ +\x05\xad\x05\xb7\x03\xb7\x03\xd3\x01\xd3\x01\x02\x01\x02\x01B\ +\x01B\x01\xd9\x00\xd9\x00!\x00!\x000\xff0\xff\xc2\ +\xfe\xc2\xfeV\xfeV\xfe\xd0\xfd\xd0\xfdy\xfdy\xfd\xc9\ +\xfc\xc9\xfc\x83\xfb\x83\xfb \xfa \xfa)\xf9)\xf9\x0c\ +\xf8\x0c\xf8#\xf7#\xf7\x06\xf6\x06\xf65\xf55\xf5\x02\ +\xf4\x02\xf4\xd1\xf2\xd1\xf2%\xf2%\xf2\xf2\xf1\xf2\xf1\x03\ +\xf2\x03\xf2<\xf2<\xf2\xa8\xf2\xa8\xf2.\xf3.\xf3\xcf\ +\xf3\xcf\xf3\xba\xf4\xba\xf4*\xf6*\xf6\xd1\xf7\xd1\xf7\x5c\ +\xf9\x5c\xf9y\xfby\xfbh\xfeh\xfen\x01n\x01I\ +\x03I\x03\x5c\x04\x5c\x04\xfc\x05\xfc\x05\xb4\x07\xb4\x07\x83\ +\x08\x83\x08\x86\x09\x86\x09\xab\x0a\xab\x0a\xe7\x0a\xe7\x0a\x0a\ +\x0a\x0a\x0a\x8b\x09\x8b\x09\x91\x0a\x91\x0a{\x0b{\x0b9\ +\x0b9\x0b\xdd\x0a\xdd\x0a,\x0b,\x0b\xbc\x0a\xbc\x0a\x0b\ +\x0a\x0b\x0aD\x0aD\x0a\xcf\x0a\xcf\x0a\xef\x09\xef\x09:\ +\x08:\x08\x0e\x07\x0e\x07v\x07v\x07\xc2\x07\xc2\x07\xd3\ +\x07\xd3\x07\xe9\x07\xe9\x073\x073\x07\xcf\x05\xcf\x05\x01\ +\x04\x01\x04\x09\x03\x09\x037\x027\x02\xf5\x00\xf5\x004\ +\xfe4\xfe\xcb\xfb\xcb\xfbg\xfag\xfa\xe4\xf9\xe4\xf9\xd3\ +\xf9\xd3\xf9\x02\xfa\x02\xfa\xb0\xfa\xb0\xfa\xc5\xf9\xc5\xf9\xaa\ +\xf7\xaa\xf7\xde\xf6\xde\xf6`\xf7`\xf7\xe0\xf6\xe0\xf65\ +\xf55\xf5\x1e\xf4\x1e\xf4\x0a\xf4\x0a\xf4\xc5\xf3\xc5\xf3$\ +\xf3$\xf3W\xf3W\xf3\xce\xf4\xce\xf4\xbd\xf5\xbd\xf5\x96\ +\xf6\x96\xf66\xf86\xf8\x9b\xf9\x9b\xf9\xc8\xf9\xc8\xf9f\ +\xf9f\xf9\xb5\xf9\xb5\xf9\xf5\xfa\xf5\xfaQ\xfbQ\xfb\x16\ +\xfb\x16\xfb\x1a\xfc\x1a\xfc\xfe\xfd\xfe\xfdN\xffN\xff\x85\ +\x00\x85\x00\x84\x02\x84\x02\xd3\x04\xd3\x04\x7f\x06\x7f\x06\xab\ +\x07\xab\x07\x9a\x08\x9a\x081\x091\x09>\x09>\x09f\ +\x09f\x09%\x0a%\x0a\x89\x0a\x89\x0a\xd2\x0a\xd2\x0a\x82\ +\x0b\x82\x0b\x9d\x0c\x9d\x0c\xac\x0c\xac\x0c\x8b\x0c\x8b\x0c\xba\ +\x0c\xba\x0cY\x0dY\x0d6\x0d6\x0du\x0cu\x0c\xbe\ +\x0b\xbe\x0b\xb7\x0a\xb7\x0a\x83\x08\x83\x08O\x06O\x06\xe5\ +\x04\xe5\x04B\x04B\x04\x9a\x03\x9a\x03\x81\x02\x81\x02.\ +\x01.\x01\xef\xff\xef\xff\xca\xfe\xca\xfep\xfdp\xfdo\ +\xfco\xfc\xf8\xfa\xf8\xfa5\xf95\xf9Q\xf7Q\xf7+\ +\xf6+\xf6I\xf5I\xf5\xb0\xf4\xb0\xf4\x03\xf4\x03\xf4\x94\ +\xf3\x94\xf3\xa4\xf2\xa4\xf2\xc0\xf1\xc0\xf1\xac\xf1\xac\xf1\xe6\ +\xf1\xe6\xf1\xe7\xf1\xe7\xf1Z\xf2Z\xf22\xf32\xf3/\ +\xf4/\xf4\x19\xf5\x19\xf5G\xf5G\xf5\xa1\xf5\xa1\xf5H\ +\xf6H\xf6~\xf7~\xf7\xa8\xf8\xa8\xf8\x0e\xfa\x0e\xfa7\ +\xfb7\xfbI\xfcI\xfcr\xfdr\xfd\xef\xfe\xef\xfe\x94\ +\x00\x94\x006\x026\x02B\x03B\x03)\x04)\x04\x00\ +\x05\x00\x05\xfd\x05\xfd\x05\x8b\x07\x8b\x07\x8c\x08\x8c\x088\ +\x098\x09\x1a\x0a\x1a\x0aI\x0bI\x0b\x04\x0c\x04\x0c3\ +\x0d3\x0d\xf7\x0d\xf7\x0dL\x0eL\x0e\xb5\x0d\xb5\x0d\xcd\ +\x0c\xcd\x0c\xc0\x0c\xc0\x0cS\x0dS\x0d\x8a\x0d\x8a\x0d\xdc\ +\x0c\xdc\x0c\xba\x0b\xba\x0br\x0ar\x0ao\x09o\x09_\ +\x08_\x08V\x08V\x08\x8b\x08\x8b\x08Z\x07Z\x07\x89\ +\x05\x89\x05r\x04r\x04\xf7\x03\xf7\x03\x14\x03\x14\x03\xc4\ +\x01\xc4\x01\xf1\xff\xf1\xff^\xfe^\xfe\xaa\xfc\xaa\xfc\x89\ +\xfa\x89\xfa\xfe\xf8\xfe\xf8\x07\xf8\x07\xf8\x14\xf7\x14\xf7w\ +\xf5w\xf5\xbc\xf3\xbc\xf3b\xf2b\xf2\xad\xf1\xad\xf1\xf5\ +\xf0\xf5\xf0\xc0\xf0\xc0\xf0n\xf0n\xf0\xc4\xef\xc4\xefd\ +\xefd\xef\xff\xef\xff\xef\x00\xf1\x00\xf19\xf29\xf2A\ +\xf3A\xf3\xd8\xf3\xd8\xf3\x96\xf4\x96\xf4L\xf5L\xf5\xe7\ +\xf6\xe7\xf6\xb0\xf8\xb0\xf8\x14\xfa\x14\xfa\x13\xfb\x13\xfb\xbe\ +\xfb\xbe\xfbe\xfce\xfc\xf2\xfd\xf2\xfd\xf5\xff\xf5\xff\xea\ +\x01\xea\x01\xad\x03\xad\x03\xef\x04\xef\x04\xb2\x05\xb2\x05\xbb\ +\x06\xbb\x06\x5c\x08\x5c\x08\xb1\x09\xb1\x09Z\x0aZ\x0a\x12\ +\x0b\x12\x0b[\x0c[\x0cE\x0dE\x0dh\x0dh\x0d\xf4\ +\x0d\xf4\x0d'\x0f'\x0f\xc9\x0f\xc9\x0f\xe0\x0f\xe0\x0fA\ +\x10A\x10u\x10u\x10P\x10P\x10\xa2\x10\xa2\x10\xb8\ +\x10\xb8\x10\xe7\x0f\xe7\x0fI\x0eI\x0e{\x0c{\x0c,\ +\x0b,\x0bd\x09d\x09\xb0\x06\xb0\x06h\x04h\x04\xa1\ +\x02\xa1\x02\x82\x00\x82\x00#\xfe#\xfeS\xfcS\xfc\x98\ +\xfb\x98\xfb\xa1\xfa\xa1\xfap\xf8p\xf8P\xf6P\xf6\x0d\ +\xf5\x0d\xf5\x94\xf3\x94\xf3\xe1\xf1\xe1\xf13\xf13\xf1\xde\ +\xf0\xde\xf0\x1a\xf0\x1a\xf0,\xef,\xef\xb1\xee\xb1\xee\xd8\ +\xee\xd8\xee\xd3\xee\xd3\xee\xdd\xee\xdd\xee\xa3\xef\xa3\xef*\ +\xf0*\xf0\x09\xf0\x09\xf07\xf07\xf0\xd4\xf0\xd4\xf0\xe6\ +\xf1\xe6\xf1f\xf3f\xf3\xbe\xf4\xbe\xf4>\xf6>\xf6\x88\ +\xf7\x88\xf72\xf82\xf8\xeb\xf9\xeb\xf9\xcf\xfc\xcf\xfc\xf4\ +\xfe\xf4\xfem\x00m\x00|\x01|\x01\x8c\x02\x8c\x02\x14\ +\x04\x14\x04\xac\x05\xac\x05\xbc\x07\xbc\x07\xe9\x09\xe9\x09r\ +\x0br\x0by\x0cy\x0c\xc5\x0d\xc5\x0d\x8d\x0e\x8d\x0e\x12\ +\x10\x12\x10\xaf\x11\xaf\x11\x8b\x12\x8b\x12\x0f\x13\x0f\x13:\ +\x13:\x13\xde\x12\xde\x12\x17\x13\x17\x13{\x13{\x13\xd9\ +\x12\xd9\x12 \x12 \x12\xcf\x11\xcf\x11\x7f\x11\x7f\x118\ +\x108\x10\xe7\x0d\xe7\x0d\x12\x0c\x12\x0cV\x0bV\x0bu\ +\x09u\x09\xbd\x06\xbd\x06D\x04D\x04\xf9\x01\xf9\x01l\ +\xffl\xff\xdd\xfc\xdd\xfc\x8d\xfb\x8d\xfb\x81\xfb\x81\xfbH\ +\xfaH\xfa\x9a\xf7\x9a\xf7\x9a\xf5\x9a\xf5\xe2\xf3\xe2\xf3\xd2\ +\xf1\xd2\xf1\xd1\xef\xd1\xef\x94\xee\x94\xee\xaa\xed\xaa\xed\xd8\ +\xeb\xd8\xeb\x84\xe9\x84\xe9\xfb\xe8\xfb\xe8\xbd\xe9\xbd\xe9\xe6\ +\xe9\xe6\xe9\xe7\xe9\xe7\xe9\x86\xea\x86\xea\x90\xeb\x90\xeb\x9f\ +\xec\x9f\xec\xb3\xed\xb3\xed#\xef#\xefT\xf1T\xf1\xf3\ +\xf2\xf3\xf2|\xf4|\xf4+\xf7+\xf7\x04\xfa\x04\xfa\x06\ +\xfc\x06\xfct\xfdt\xfd\x16\xff\x16\xff\x87\x01\x87\x01$\ +\x04$\x04/\x06/\x06\x86\x08\x86\x08e\x0ae\x0a\xbc\ +\x0a\xbc\x0a'\x0b'\x0b\xc5\x0c\xc5\x0c\xd9\x0e\xd9\x0ep\ +\x10p\x10\xf5\x10\xf5\x10Y\x11Y\x11\xe4\x11\xe4\x11\x18\ +\x12\x18\x12\x07\x13\x07\x13\xc5\x14\xc5\x14Q\x15Q\x15 \ +\x14 \x14:\x13:\x13\xf9\x12\xf9\x12\x8b\x12\x8b\x126\ +\x116\x11\xd3\x0f\xd3\x0f\xd7\x0e\xd7\x0e\xf6\x0c\xf6\x0c\x83\ +\x0a\x83\x0a\xd6\x08\xd6\x08\x8d\x07\x8d\x07\x00\x05\x00\x058\ +\x028\x02\xee\xff\xee\xff\x0f\xfe\x0f\xfe\x95\xfb\x95\xfbE\ +\xf9E\xf9\x14\xf8\x14\xf8,\xf7,\xf7\x1a\xf5\x1a\xf5\xe5\ +\xf2\xe5\xf2\xd2\xf1\xd2\xf1\xec\xf0\xec\xf0\x86\xef\x86\xef\xe6\ +\xed\xe6\xed-\xed-\xed\xb7\xec\xb7\xec?\xec?\xec'\ +\xec'\xec\xdd\xec\xdd\xec.\xed.\xed\xd5\xec\xd5\xec\xe3\ +\xec\xe3\xec\x1c\xee\x1c\xee\x9d\xef\x9d\xef\x99\xf0\x99\xf0\xb2\ +\xf1\xb2\xf1\x8c\xf3\x8c\xf3\x1b\xf5\x1b\xf5\x13\xf6\x13\xf6\xb3\ +\xf7\xb3\xf75\xfa5\xfam\xfcm\xfc\xd7\xfd\xd7\xfd\xa7\ +\xff\xa7\xff!\x02!\x02m\x04m\x04\x09\x06\x09\x06\xbc\ +\x07\xbc\x07\xd8\x09\xd8\x09\xb6\x0b\xb6\x0b\xa5\x0c\xa5\x0c\xe3\ +\x0d\xe3\x0dZ\x0fZ\x0fH\x10H\x10\xb5\x10\xb5\x10\x02\ +\x11\x02\x11h\x11h\x11\xfc\x11\xfc\x11L\x12L\x12\x14\ +\x12\x14\x12\xa4\x11\xa4\x11\xb5\x10\xb5\x10\xa8\x0f\xa8\x0f\x9b\ +\x0e\x9b\x0e\xe8\x0d\xe8\x0d\x89\x0d\x89\x0d\xc5\x0c\xc5\x0c\x09\ +\x0b\x09\x0b\x83\x09\x83\x09?\x08?\x08\xf3\x06\xf3\x06\xd6\ +\x05\xd6\x05}\x04}\x04\x9c\x02\x9c\x02\xaa\x00\xaa\x00\x9b\ +\xfe\x9b\xfe\x94\xfc\x94\xfc\xf4\xfa\xf4\xfah\xf9h\xf9\xbb\ +\xf7\xbb\xf7\xdd\xf5\xdd\xf5\xf3\xf3\xf3\xf3\x0f\xf2\x0f\xf2\xa0\ +\xf0\xa0\xf0\xbf\xef\xbf\xef(\xef(\xefb\xeeb\xee\x8e\ +\xed\x8e\xed{\xed{\xed\xcb\xed\xcb\xed\x1f\xee\x1f\xee\xf3\ +\xee\xf3\xee\xcb\xef\xcb\xef\x88\xf0\x88\xf0$\xf1$\xf1+\ +\xf2+\xf2\xd1\xf3\xd1\xf3l\xf5l\xf5\xc8\xf6\xc8\xf6#\ +\xf8#\xf8J\xf9J\xf9\x9f\xfa\x9f\xfas\xfcs\xfc\xd0\ +\xfe\xd0\xfe\xf8\x00\xf8\x00\xbf\x02\xbf\x02*\x04*\x04x\ +\x05x\x05\x13\x07\x13\x07\x8a\x08\x8a\x08\xdd\x09\xdd\x09\xfa\ +\x0a\xfa\x0a\x5c\x0c\x5c\x0cO\x0dO\x0d\x04\x0e\x04\x0e\xbb\ +\x0e\xbb\x0e\xd7\x0f\xd7\x0f\xd7\x10\xd7\x100\x110\x11x\ +\x11x\x11\xe6\x11\xe6\x11\xc8\x11\xc8\x11\x8b\x11\x8b\x11\xbc\ +\x11\xbc\x117\x117\x11\xf8\x0f\xf8\x0fS\x0eS\x0e\xea\ +\x0c\xea\x0c\xa6\x0b\xa6\x0b\xa0\x09\xa0\x09\x0c\x07\x0c\x07\xf2\ +\x04\xf2\x04\xf9\x02\xf9\x02\xbb\x00\xbb\x00\xe9\xfe\xe9\xfe~\ +\xfd~\xfd\x84\xfc\x84\xfc\xb2\xfa\xb2\xfa~\xf8~\xf8\xdb\ +\xf6\xdb\xf6\xb2\xf5\xb2\xf5\xed\xf3\xed\xf3d\xf2d\xf2\xcd\ +\xf1\xcd\xf1\x01\xf1\x01\xf1\x03\xf0\x03\xf0\xf9\xee\xf9\xee\xad\ +\xee\xad\xeet\xeet\xee\x07\xee\x07\xeeU\xeeU\xee#\ +\xef#\xefG\xefG\xef\x1e\xef\x1e\xef\xa0\xef\xa0\xef{\ +\xf0{\xf0\xb2\xf1\xb2\xf1\x11\xf3\x11\xf3\x86\xf4\x86\xf4\x1f\ +\xf6\x1f\xf6\x15\xf7\x15\xf75\xf85\xf8\xa0\xfa\xa0\xfab\ +\xfdb\xfd \xff \xffr\x00r\x00\x9b\x01\x9b\x01R\ +\x03R\x03\xe1\x04\xe1\x04\x94\x06\x94\x06\xc2\x08\xc2\x08\xba\ +\x0a\xba\x0a\xfc\x0b\xfc\x0b\x04\x0d\x04\x0d%\x0e%\x0e`\ +\x0f`\x0fK\x11K\x11+\x12+\x12\xc9\x12\xc9\x12J\ +\x13J\x13%\x13%\x13\xc1\x12\xc1\x12K\x13K\x13U\ +\x13U\x13H\x12H\x12\xa9\x11\xa9\x11\x92\x11\x92\x11,\ +\x11,\x11-\x0f-\x0f\xa4\x0c\xa4\x0c@\x0b@\x0b,\ +\x0a,\x0a\xad\x07\xad\x07\x10\x05\x10\x05\xb8\x02\xb8\x02x\ +\x00x\x00\x1e\xfe\x1e\xfe\xf2\xfb\xf2\xfb5\xfb5\xfb\xf4\ +\xfa\xf4\xfa\xf6\xf8\xf6\xf87\xf67\xf6t\xf4t\xf4\x9e\ +\xf2\x9e\xf2\x8c\xf0\x8c\xf0\xb9\xee\xb9\xee\xc5\xed\xc5\xedw\ +\xecw\xec\x02\xea\x02\xeag\xe8g\xe8\xf1\xe8\xf1\xe8x\ +\xe9x\xe9)\xe9)\xe9@\xe9@\xe9B\xeaB\xeau\ +\xebu\xeb\x80\xec\x80\xec\x97\xed\x97\xedu\xefu\xefs\ +\xf1s\xf1\x17\xf3\x17\xf3\x5c\xf5\x5c\xf5\x91\xf8\x91\xf81\ +\xfb1\xfb\xe6\xfc\xe6\xfcU\xfeU\xfeb\x00b\x00\xf6\ +\x02\xf6\x028\x058\x05g\x07g\x07\xe4\x09\xe4\x09\x03\ +\x0b\x03\x0b\x0e\x0b\x0e\x0b5\x0c5\x0c|\x0e|\x0e\xa9\ +\x10\xa9\x10\xbc\x11\xbc\x11\x08\x12\x08\x12\xd7\x12\xd7\x12T\ +\x13T\x13\xc9\x13\xc9\x134\x154\x15\xe6\x16\xe6\x16_\ +\x16_\x16%\x15%\x15\x05\x15\x05\x15Z\x15Z\x15\xf8\ +\x14\xf8\x14\xad\x13\xad\x13\x7f\x12\x7f\x12\xce\x10\xce\x10b\ +\x0eb\x0e\x11\x0c\x11\x0c\xe0\x0a\xe0\x0a\xc1\x08\xc1\x08\x9c\ +\x05\x9c\x05o\x02o\x02\x00\x00\x00\x00\xe7\xfd\xe7\xfdA\ +\xfbA\xfb6\xf96\xf9`\xf8`\xf8\xca\xf6\xca\xf6\x9e\ +\xf3\x9e\xf3\x89\xf1\x89\xf1\xc8\xf0\xc8\xf0\xa6\xef\xa6\xefO\ +\xedO\xedV\xebV\xeb^\xea^\xea\xad\xe9\xad\xe9\xeb\ +\xe8\xeb\xe8\xea\xe8\xea\xe8\x9c\xe9\x9c\xe90\xe90\xe9>\ +\xe8>\xe8\xa4\xe8\xa4\xe8a\xeaa\xeaj\xebj\xeb\x0d\ +\xec\x0d\xec\x92\xed\x92\xed\xd1\xef\xd1\xef-\xf1-\xf1)\ +\xf2)\xf2\x85\xf4\x85\xf4\x87\xf7\x87\xf7\xaf\xf9\xaf\xf9\x85\ +\xfb\x85\xfb\xbb\xfe\xbb\xfe\x1a\x02\x1a\x02^\x04^\x042\ +\x062\x06\xe5\x08\xe5\x08\xd6\x0b\xd6\x0bb\x0db\x0d_\ +\x0e_\x0e&\x10&\x10\xc8\x11\xc8\x11\x9e\x12\x9e\x12\xa7\ +\x13\xa7\x13\xf2\x14\xf2\x14\xfd\x15\xfd\x15\x7f\x16\x7f\x16c\ +\x16c\x16\x84\x16\x84\x16:\x16:\x16S\x15S\x15\x8f\ +\x14\x8f\x14\xf1\x13\xf1\x13\x19\x13\x19\x13@\x12@\x12\xba\ +\x10\xba\x10\xf3\x0e\xf3\x0e\xa9\x0d\xa9\x0d\xdd\x0b\xdd\x0b#\ +\x0a#\x0a\xfe\x08\xfe\x08\x8d\x07\x8d\x07\x17\x05\x17\x05\xa1\ +\x02\xa1\x02\xae\x00\xae\x00\xc7\xfe\xc7\xfe\xa2\xfc\xa2\xfc\x22\ +\xfa\x22\xfa\xdf\xf7\xdf\xf7\xb8\xf5\xb8\xf5\x1e\xf3\x1e\xf3\x8c\ +\xf0\x8c\xf0\x04\xef\x04\xef\xb1\xed\xb1\xed<\xec<\xec\xcc\ +\xea\xcc\xea'\xea'\xea\xd3\xe9\xd3\xe9\xfb\xe8\xfb\xe8\xb1\ +\xe8\xb1\xe8[\xe9[\xe9\xe2\xe9\xe2\xe9-\xea-\xea\xfb\ +\xea\xfb\xea\xfd\xeb\xfd\xeb\x1e\xed\x1e\xed\x22\xee\x22\xee\x0d\ +\xf0\x0d\xf0\xa2\xf2\xa2\xf2\xbe\xf4\xbe\xf4\x1d\xf6\x1d\xf6\xa2\ +\xf8\xa2\xf8`\xfb`\xfb\x8f\xfd\x8f\xfd\xb7\xff\xb7\xff\x07\ +\x02\x07\x02_\x04_\x04a\x06a\x06\x93\x08\x93\x08\xe0\ +\x0a\xe0\x0a\xef\x0c\xef\x0c?\x0e?\x0e>\x0f>\x0f\xdf\ +\x10\xdf\x10{\x12{\x12~\x13~\x13\xc4\x14\xc4\x14\xab\ +\x15\xab\x15@\x16@\x16C\x16C\x16\xbf\x15\xbf\x15\xa8\ +\x15\xa8\x15\x8c\x15\x8c\x15\x9f\x14\x9f\x14X\x13X\x13!\ +\x12!\x12\x03\x11\x03\x11\xf6\x0f\xf6\x0fY\x0eY\x0eJ\ +\x0cJ\x0c~\x0a~\x0a>\x08>\x08\x13\x06\x13\x06\x8a\ +\x04\x8a\x04\xe6\x02\xe6\x02,\x01,\x01g\xffg\xff|\ +\xfd|\xfd\x22\xfc\x22\xfc\xa1\xfa\xa1\xfa\xa5\xf8\xa5\xf8\xcc\ +\xf6\xcc\xf6\xb6\xf4\xb6\xf4q\xf2q\xf2\xfe\xf0\xfe\xf0\x88\ +\xef\x88\xef\x0a\xee\x0a\xee\x80\xec\x80\xec\xec\xea\xec\xea\xf6\ +\xe9\xf6\xe9\x95\xe9\x95\xe9\xb7\xe8\xb7\xe8z\xe8z\xe8\xef\ +\xe8\xef\xe8\xdc\xe8\xdc\xe8\xf4\xe8\xf4\xe8\xae\xe9\xae\xe9\xc1\ +\xea\xc1\xea3\xec3\xec\x96\xed\x96\xed\xe9\xee\xe9\xee\xdf\ +\xf0\xdf\xf0&\xf3&\xf3=\xf5=\xf5\xbe\xf7\xbe\xf7s\ +\xfas\xfa\xbf\xfc\xbf\xfcd\xffd\xff\x95\x02\x95\x02\xda\ +\x05\xda\x051\x081\x08\xae\x09\xae\x09X\x0bX\x0bT\ +\x0dT\x0d\xdc\x0e\xdc\x0eL\x10L\x10\xc6\x11\xc6\x11Q\ +\x12Q\x12\xe1\x12\xe1\x12\x92\x13\x92\x13V\x15V\x15\xb6\ +\x17\xb6\x17\xbd\x18\xbd\x18\xb7\x18\xb7\x18\x10\x19\x10\x19\x09\ +\x19\x09\x19\x93\x18\x93\x18\xc5\x17\xc5\x17\xad\x16\xad\x16c\ +\x16c\x16\x85\x15\x85\x15]\x13]\x13\xc0\x11\xc0\x11\x1b\ +\x10\x1b\x10S\x0dS\x0d\xef\x0a\xef\x0aD\x09D\x09\xa2\ +\x07\xa2\x07\xec\x04\xec\x04\xbc\x01\xbc\x01\x1d\xff\x1d\xff\xba\ +\xfc\xba\xfc\x1f\xf9\x1f\xf9\x1c\xf6\x1c\xf6\x11\xf4\x11\xf4X\ +\xf1X\xf1)\xee)\xeeJ\xebJ\xeb\x93\xe9\x93\xe9\xe2\ +\xe8\xe2\xe8\x19\xe7\x19\xe7\xdf\xe4\xdf\xe4\x16\xe4\x16\xe4v\ +\xe3v\xe3\xcf\xe2\xcf\xe2\xae\xe2\xae\xe2u\xe3u\xe34\ +\xe44\xe4\x94\xe4\x94\xe4\x11\xe5\x11\xe5\xe0\xe6\xe0\xe6#\ +\xe9#\xe9\xb5\xea\xb5\xea{\xec{\xec%\xef%\xef\xaf\ +\xf1\xaf\xf18\xf48\xf48\xf78\xf7\x9e\xfa\x9e\xfa\x09\ +\xfe\x09\xfet\x00t\x00\x01\x03\x01\x03\x97\x06\x97\x06\x1c\ +\x0a\x1c\x0a\xed\x0c\xed\x0c\xc2\x0f\xc2\x0f:\x11:\x11\xe8\ +\x11\xe8\x11\xe7\x12\xe7\x12\x98\x14\x98\x14\x92\x16\x92\x16\x19\ +\x17\x19\x17\x95\x16\x95\x16\xfe\x16\xfe\x16K\x18K\x18\xdc\ +\x18\xdc\x18e\x19e\x19\x01\x1a\x01\x1a\x1b\x1a\x1b\x1a\x22\ +\x19\x22\x19\xc0\x17\xc0\x17W\x17W\x17e\x17e\x17\x91\ +\x15\x91\x15b\x12b\x12p\x10p\x10\xb9\x0e\xb9\x0e\xff\ +\x0b\xff\x0bd\x09d\x09\xe7\x06\xe7\x06j\x04j\x04\xbb\ +\x00\xbb\x00\xf7\xfc\xf7\xfc\xe5\xfa\xe5\xfa\xad\xf9\xad\xf9\x0e\ +\xf7\x0e\xf7\xfb\xf3\xfb\xf3c\xf1c\xf1\xd5\xee\xd5\xee\x83\ +\xec\x83\xec\xe5\xea\xe5\xea\x5c\xea\x5c\xea\x91\xe9\x91\xe9\x99\ +\xe7\x99\xe7\xc1\xe5\xc1\xe5\xac\xe5\xac\xe5\xee\xe5\xee\xe5\xc6\ +\xe5\xc6\xe5\xe3\xe5\xe3\xe5\x9a\xe6\x9a\xe6+\xe7+\xe7\x94\ +\xe7\x94\xe7h\xe9h\xe9~\xec~\xec\x1b\xee\x1b\xee\xdb\ +\xee\xdb\xee\xc5\xf0\xc5\xf0b\xf3b\xf3%\xf6%\xf6\xce\ +\xf8\xce\xf8\xbf\xfb\xbf\xfb]\xff]\xffN\x02N\x02?\ +\x05?\x05\xb7\x08\xb7\x08F\x0bF\x0b\xa9\x0c\xa9\x0c}\ +\x0e}\x0eg\x11g\x11;\x14;\x14\xf5\x14\xf5\x14\x82\ +\x15\x82\x15\xb1\x16\xb1\x16,\x17,\x17\xd8\x17\xd8\x17\xd0\ +\x19\xd0\x19\xaa\x1b\xaa\x1b\x8d\x1b\x8d\x1b\xfd\x19\xfd\x19\xea\ +\x19\xea\x19L\x1aL\x1aA\x19A\x19&\x17&\x17\x86\ +\x15\x86\x15\x88\x13\x88\x13\x18\x11\x18\x11\x95\x0f\x95\x0f\xf7\ +\x0e\xf7\x0e\xa9\x0c\xa9\x0c\x8c\x08\x8c\x08\x17\x05\x17\x05<\ +\x03<\x03J\x01J\x01\xf4\xfd\xf4\xfd\xce\xfa\xce\xfa\x1c\ +\xf8\x1c\xf8\x18\xf4\x18\xf4%\xf1%\xf1\x18\xf0\x18\xf0\xf7\ +\xee\xf7\xee\xf7\xeb\xf7\xeb\xbb\xe8\xbb\xe8(\xe7(\xe7\x01\ +\xe6\x01\xe6m\xe4m\xe45\xe35\xe3\xe1\xe1\xe1\xe1S\ +\xe0S\xe0\x8f\xdf\x8f\xdf\x83\xe0\x83\xe0Q\xe2Q\xe25\ +\xe35\xe3\xb7\xe3\xb7\xe3q\xe5q\xe5\xad\xe7\xad\xe7$\ +\xea$\xea\x5c\xed\x5c\xed\x86\xf0\x86\xf0\xbc\xf2\xbc\xf2\x98\ +\xf4\x98\xf4\x95\xf7\x95\xf7\xbc\xfb\xbc\xfbE\xffE\xff\xfd\ +\x01\xfd\x01i\x05i\x05\xf7\x08\xf7\x08\xf7\x0b\xf7\x0b\x9f\ +\x0e\x9f\x0eI\x11I\x11\xb6\x13\xb6\x13\xc5\x15\xc5\x15\xb7\ +\x17\xb7\x17\x9f\x1a\x9f\x1a\xe1\x1c\xe1\x1ce\x1ee\x1e\xcf\ +\x1f\xcf\x1f\xfd \xfd c!c!D!D!\xf9\ + \xf9 $ $ 5\x1e5\x1e\x91\x1c\x91\x1c\xc6\ +\x1b\xc6\x1b\x0c\x1a\x0c\x1a\xf8\x16\xf8\x16R\x14R\x14\x9f\ +\x11\x9f\x11u\x0eu\x0e@\x0b@\x0b\x8c\x08\x8c\x08|\ +\x05|\x05\xb7\x00\xb7\x00K\xfcK\xfc\x1d\xfa\x1d\xfa~\ +\xf7~\xf7\x1b\xf4\x1b\xf4_\xf1_\xf1A\xefA\xef\x02\ +\xec\x02\xecP\xe8P\xe8:\xe5:\xe5=\xe3=\xe3\xeb\ +\xe0\xeb\xe0U\xdeU\xde\x92\xdc\x92\xdc\xde\xdb\xde\xdb\xd5\ +\xda\xd5\xdaX\xdaX\xda\xb8\xdb\xb8\xdb\xc7\xdd\xc7\xdd\x14\ +\xdf\x14\xdf~\xe0~\xe0\x8b\xe2\x8b\xe2\x1c\xe5\x1c\xe5\x82\ +\xe7\x82\xe7%\xeb%\xeb\xec\xee\xec\xee\xa8\xf1\xa8\xf1\xb7\ +\xf4\xb7\xf46\xf96\xf9\xc3\xfd\xc3\xfd\xca\x01\xca\x01\xed\ +\x05\xed\x05/\x0a/\x0a\xc2\x0d\xc2\x0d=\x10=\x10_\ +\x13_\x13\xb3\x16\xb3\x16\xc4\x18\xc4\x18d\x1ad\x1a,\ +\x1c,\x1cG\x1eG\x1e\x1b \x1b \xd3 \xd3 \x13\ +\x22\x13\x22\x0e#\x0e#B\x22B\x22^!^!\x01\ +!\x01!\x83\x1f\x83\x1f\x9e\x1c\x9e\x1cH\x1aH\x1a\xae\ +\x18\xae\x18X\x16X\x16+\x13+\x13\xbc\x10\xbc\x10\x09\ +\x0f\x09\x0f/\x0c/\x0ci\x09i\x09)\x07)\x07>\ +\x04>\x04n\x00n\x00R\xfcR\xfcT\xf8T\xf8i\ +\xf4i\xf4j\xf0j\xf0e\xede\xedB\xebB\xeb\x8e\ +\xe8\x8e\xe8\xe4\xe5\xe4\xe5\xe3\xe3\xe3\xe3a\xe2a\xe2;\ +\xe1;\xe1\xe8\xdf\xe8\xdfM\xdfM\xdf\xaf\xde\xaf\xde\x83\ +\xdd\x83\xdd\xa2\xdd\xa2\xdd(\xdf(\xdfY\xe0Y\xe0_\ +\xe1_\xe1\x97\xe3\x97\xe3g\xe6g\xe6\xe4\xe8\xe4\xe8 \ +\xeb \xeb\xa5\xee\xa5\xee\xcd\xf2\xcd\xf2\xb2\xf5\xb2\xf5d\ +\xf8d\xf8C\xfcC\xfcG\x00G\x00\xac\x03\xac\x03\xbe\ +\x06\xbe\x06\x80\x0a\x80\x0a\xc9\x0d\xc9\x0d\xa7\x0f\xa7\x0f*\ +\x12*\x12\x8b\x15\x8b\x158\x188\x188\x1a8\x1a\x18\ +\x1c\x18\x1c\xbd\x1d\xbd\x1d\xae\x1e\xae\x1eB\x1fB\x1f\xff\ +\x1f\xff\x1f\x85!\x85!-\x22-\x22\xdd!\xdd!#\ +!#!\x99 \x99 \xe9\x1e\xe9\x1el\x1cl\x1c\x16\ +\x1b\x16\x1b\xaf\x19\xaf\x19\xd3\x15\xd3\x15\xa0\x11\xa0\x11\x8f\ +\x0e\x8f\x0e\x13\x0b\x13\x0b\xf5\x06\xf5\x06|\x03|\x03\xf6\ +\x00\xf6\x00r\xfer\xfe2\xfa2\xfa\x89\xf6\x89\xf6c\ +\xf3c\xf3\xd8\xef\xd8\xef\x80\xec\x80\xec'\xea'\xea\xbb\ +\xe7\xbb\xe7\x88\xe4\x88\xe4b\xe1b\xe1\xce\xdf\xce\xdf \ +\xdf \xdf\xb8\xdd\xb8\xdd\xfc\xdc\xfc\xdc\x99\xdd\x99\xdd;\ +\xdd;\xddf\xdcf\xdc\x0a\xdd\x0a\xdd\xe6\xde\xe6\xdek\ +\xe0k\xe0\xe0\xe1\xe0\xe1\xa1\xe4\xa1\xe4$\xe8$\xe8\xc7\ +\xea\xc7\xea`\xee`\xee+\xf3+\xf3\xd6\xf6\xd6\xf6'\ +\xfa'\xfa\xb7\xfd\xb7\xfd\xc5\x01\xc5\x01\x05\x05\x05\x05\xbe\ +\x07\xbe\x07G\x0bG\x0b\xec\x0e\xec\x0ee\x11e\x11\x12\ +\x14\x12\x14J\x17J\x17\x1f\x1a\x1f\x1a\xcb\x1b\xcb\x1bL\ +\x1dL\x1d}\x1f}\x1f\xcb \xcb O O L\ + L \xb6 \xb6 \x06 \x06 \x09\x1f\x09\x1f\x0b\ +\x1f\x0b\x1fQ\x1fQ\x1f\x14\x1e\x14\x1e\xa9\x1b\xa9\x1b\xa4\ +\x1a\xa4\x1a,\x19,\x19\xf7\x15\xf7\x15\x14\x13\x14\x13\xab\ +\x10\xab\x10\xbf\x0c\xbf\x0c\xd9\x07\xd9\x07\xe5\x03\xe5\x03\xb7\ +\x01\xb7\x01)\xff)\xff\xe9\xfa\xe9\xfa\x1e\xf7\x1e\xf7\x10\ +\xf4\x10\xf4\xbd\xef\xbd\xef\x88\xeb\x88\xeb\xd7\xe8\xd7\xe8w\ +\xe6w\xe6J\xe3J\xe3\xa0\xe0\xa0\xe0\x86\xdf\x86\xdf3\ +\xde3\xde\xe5\xdb\xe5\xdbs\xdbs\xdb\xb7\xdc\xb7\xdc\xe5\ +\xdc\xe5\xdc\x5c\xdc\x5c\xdc\x0f\xdd\x0f\xdd\x9e\xde\x9e\xdev\ +\xdfv\xdf~\xe0~\xe0=\xe4=\xe4B\xe8B\xe8N\ +\xeaN\xea\xdd\xec\xdd\xecJ\xf1J\xf1o\xf5o\xf5\xdf\ +\xf8\xdf\xf8\xf5\xfc\xf5\xfc\xcf\x01\xcf\x01\xd0\x04\xd0\x04R\ +\x06R\x066\x096\x09<\x0d<\x0d\xc2\x10\xc2\x10\x07\ +\x14\x07\x14\xa9\x17\xa9\x17\xbc\x1a\xbc\x1a\xa4\x1c\xa4\x1ct\ +\x1et\x1e\xf0 \xf0 ]\x22]\x22\xfb!\xfb!;\ +\x22;\x22\xc3\x22\xc3\x22\xbf!\xbf!- - \xb7\ +\x1f\xb7\x1f\xda\x1e\xda\x1eb\x1db\x1dG\x1cG\x1c2\ +\x1b2\x1b\x03\x19\x03\x19r\x15r\x15#\x12#\x12\xd1\ +\x0f\xd1\x0f\x93\x0c\x93\x0c\xa1\x08\xa1\x08P\x05P\x05\x95\ +\x01\x95\x01\xea\xfc\xea\xfc\xa3\xf8\xa3\xf8\x1b\xf6\x1b\xf60\ +\xf40\xf4w\xf1w\xf1\x98\xee\x98\xee\x07\xec\x07\xec\x17\ +\xe9\x17\xe9\x16\xe6\x16\xe6\x84\xe3\x84\xe3\xb7\xe1\xb7\xe1\xe3\ +\xdf\xe3\xdf\xdf\xdd\xdf\xdd=\xdc=\xdcf\xdbf\xdb\xfa\ +\xda\xfa\xda\x0d\xdb\x0d\xdb\xcc\xdb\xcc\xdbs\xdcs\xdc\xe0\ +\xdc\xe0\xdc2\xde2\xde\xd3\xe0\xd3\xe0q\xe4q\xe4m\ +\xe7m\xe7o\xeao\xea\xed\xed\xed\xed\x03\xf1\x03\xf1\xdf\ +\xf3\xdf\xf3\xcd\xf7\xcd\xf7x\xfcx\xfc\xf0\x00\xf0\x00\xa0\ +\x04\xa0\x04D\x08D\x08#\x0c#\x0c\xeb\x0f\xeb\x0f\xaf\ +\x13\xaf\x13\x88\x17\x88\x17\x0f\x1a\x0f\x1aB\x1cB\x1c\x00\ +\x1f\x00\x1f\x09\x22\x09\x22\xbb$\xbb$''''0\ +)0)\x98*\x98*\x12+\x12+d+d+R\ ++R+\xd0)\xd0)d'd'\x16%\x16%\x96\ +\x22\x96\x22! ! \x14\x1d\x14\x1d\x1a\x19\x1a\x19\x14\ +\x14\x14\x14\xaf\x0e\xaf\x0e\xdf\x09\xdf\x09\xcc\x05\xcc\x05F\ +\x01F\x01I\xfcI\xfcV\xf7V\xf7\x96\xf2\x96\xf2\xa9\ +\xee\xa9\xee6\xeb6\xeb_\xe7_\xe7\xca\xe3\xca\xe3 \ +\xe1 \xe1e\xdfe\xdf\x8e\xde\x8e\xde\xab\xdc\xab\xdc/\ +\xda/\xdaQ\xd8Q\xd82\xd72\xd7\x01\xd6\x01\xd6a\ +\xd5a\xd5\xd1\xd5\xd1\xd5\x98\xd6\x98\xd6\xcf\xd6\xcf\xd6\x1d\ +\xd8\x1d\xd8\xd7\xdb\xd7\xdbG\xe0G\xe0\x92\xe3\x92\xe3\xa0\ +\xe6\xa0\xe6\xbf\xe9\xbf\xe93\xed3\xed\x98\xf0\x98\xf0\x03\ +\xf5\x03\xf5\xd9\xf9\xd9\xf9o\xfdo\xfd\xd3\xff\xd3\xffJ\ +\x03J\x03C\x08C\x08V\x0dV\x0d\xad\x11\xad\x11\x07\ +\x16\x07\x16!\x1a!\x1a\x81\x1d\x81\x1d2!2!\xdf\ +$\xdf$\x80&\x80&\x0f'\x0f'V(V(;\ +*;*\x1d+\x1d+\x00+\x00+\x13+\x13+\xbd\ +*\xbd*\x97)\x97)<'<'h#h#\xf6\ +\x1e\xf6\x1e\xed\x1a\xed\x1a>\x16>\x16\xb3\x10\xb3\x10\x08\ +\x0b\x08\x0b\x8c\x06\x8c\x06/\x02/\x02\x03\xfe\x03\xfe1\ +\xfa1\xfaa\xf5a\xf5\xba\xef\xba\xef\x94\xea\x94\xeaF\ +\xe6F\xe6\x14\xe2\x14\xe2\x17\xde\x17\xde\xe6\xda\xe6\xda\xca\ +\xd7\xca\xd7f\xd5f\xd5\xad\xd4\xad\xd4\xc5\xd4\xc5\xd4i\ +\xd5i\xd5\x14\xd6\x14\xd6\x0b\xd7\x0b\xd7\xfd\xd7\xfd\xd7\xd5\ +\xd8\xd5\xd8\x8c\xda\x8c\xdaB\xddB\xddY\xe0Y\xe0\x22\ +\xe4\x22\xe4\xc8\xe8\xc8\xe8\xe3\xed\xe3\xedC\xf3C\xf3|\ +\xf8|\xf8\x7f\xfd\x7f\xfd'\x02'\x02\xd6\x06\xd6\x06\xfa\ +\x0a\xfa\x0ao\x0eo\x0e\xd3\x11\xd3\x11R\x16R\x165\ +\x1b5\x1b\xa1\x1f\xa1\x1f\xdd#\xdd#e'e'K\ +*K*\xce,\xce,\x8c.\x8c._/_/\x87\ +/\x87/././\x8d-\x8d- + +\xc6\ +(\xc6(Q&Q&\x1a#\x1a#\xcf\x1e\xcf\x1e\xad\ +\x1a\xad\x1at\x16t\x16\xa6\x11\xa6\x11\x85\x0c\x85\x0c\xf0\ +\x06\xf0\x06a\x01a\x01\xcb\xfb\xcb\xfbF\xf6F\xf6(\ +\xf1(\xf1o\xeco\xec{\xe8{\xe8\xd3\xe4\xd3\xe4\xe2\ +\xe0\xe2\xe0Z\xddZ\xddG\xdaG\xdax\xd7x\xd7+\ +\xd5+\xd5\xc0\xd3\xc0\xd3\x9f\xd2\x9f\xd2\xa7\xd1\xa7\xd1F\ +\xd1F\xd1\xeb\xd1\xeb\xd1\x84\xd3\x84\xd3\xa9\xd5\xa9\xd5=\ +\xd8=\xd8\xea\xda\xea\xda\x0a\xde\x0a\xde\xcf\xe1\xcf\xe1\x15\ +\xe6\x15\xe6\xb0\xea\xb0\xeaG\xefG\xef\xaf\xf3\xaf\xf3\xa0\ +\xf7\xa0\xf7\xff\xfb\xff\xfb9\x019\x01p\x06p\x06\xb8\ +\x0b\xb8\x0b\xc9\x10\xc9\x10\xee\x15\xee\x15\xd4\x1a\xd4\x1ax\ +\x1fx\x1fY#Y#\xee%\xee%\x87'\x87'\x86\ +)\x86)6+6+\xd4+\xd4+\x0a,\x0a,\xad\ +,\xad,\xff,\xff,\x05,\x05,\xad*\xad*\xf2\ +(\xf2(\x88&\x88&\xa4#\xa4#\x0a \x0a \x94\ +\x1b\x94\x1b\xae\x16\xae\x16F\x12F\x12&\x0e&\x0e\xfb\ +\x08\xfb\x08A\x03A\x03\x85\xfd\x85\xfd\x19\xf8\x19\xf8\xb0\ +\xf2\xb0\xf2\xca\xed\xca\xed\x1a\xe9\x1a\xe9\x01\xe4\x01\xe4I\ +\xdfI\xdf\xd0\xdb\xd0\xdb\xf0\xd8\xf0\xd8=\xd6=\xd6r\ +\xd4r\xd4D\xd3D\xd3\xf7\xd1\xf7\xd1\xcb\xd0\xcb\xd0\xa2\ +\xd0\xa2\xd0\xc0\xd0\xc0\xd0\xcb\xd1\xcb\xd1\xde\xd3\xde\xd3Q\ +\xd6Q\xd6k\xd9k\xd9\xcb\xdd\xcb\xdd\x00\xe3\x00\xe35\ +\xe85\xe82\xed2\xedg\xf2g\xf2;\xf7;\xf7\x05\ +\xfc\x05\xfc\xac\x00\xac\x004\x054\x05\x06\x0a\x06\x0a\x85\ +\x0f\x85\x0f,\x15,\x15\xa3\x1a\xa3\x1a, , $\ +%$%<)<)\x0e-\x0e-Q0Q0\xbc\ +2\xbc2?4?4\x9d4\x9d4\xa63\xa63\xb3\ +2\xb32\xf21\xf21\xa30\xa30\xad-\xad-\x15\ +*\x15*\xbe&\xbe&\xc0\x22\xc0\x22\xdb\x1d\xdb\x1dm\ +\x18m\x18\xfe\x11\xfe\x11S\x0bS\x0b*\x05*\x05\xd7\ +\xfe\xd7\xfe\xb5\xf8\xb5\xf8\xea\xf2\xea\xf2x\xedx\xed\xed\ +\xe7\xed\xe7\xd0\xe2\xd0\xe2\xae\xde\xae\xden\xdan\xdag\ +\xd6g\xd6\xd5\xd3\xd5\xd3\x86\xd1\x86\xd1\xd1\xce\xd1\xce\x1b\ +\xcd\x1b\xcd>\xcc>\xcci\xcbi\xcb\x07\xcb\x07\xcb\x8c\ +\xcc\x8c\xcc\xba\xce\xba\xce\x15\xd1\x15\xd1\x97\xd4\x97\xd4\xdd\ +\xd8\xdd\xd8>\xdd>\xdd\xf7\xe1\xf7\xe1y\xe7y\xe7\x01\ +\xed\x01\xed7\xf17\xf1\xfc\xf5\xfc\xf5\x8d\xfb\x8d\xfb\x12\ +\x01\x12\x01\xf4\x06\xf4\x06\x13\x0d\x13\x0d]\x13]\x13\xec\ +\x18\xec\x18\x03\x1e\x03\x1e\xcc\x22\xcc\x22\xea%\xea%\xca\ +'\xca'\xab)\xab)\xfd*\xfd*c+c+\x99\ ++\x99+\x8e,\x8e,\xde,\xde,\xd1,\xd1,\xf9\ +-\xf9-\x9a.\x9a.\x00.\x00.\xc1,\xc1,\xe8\ +*\xe8*\x91'\x91'\x07#\x07#\x89\x1f\x89\x1f\xcc\ +\x1a\xcc\x1a]\x13]\x13\x22\x0d\x22\x0d\xa1\x08\xa1\x08\xaf\ +\x03\xaf\x03d\xfed\xfeT\xfaT\xfa\x1f\xf5\x1f\xf5\x15\ +\xee\x15\xee\xed\xe8\xed\xe8\x83\xe5\x83\xe5$\xe0$\xe0\xb4\ +\xda\xb4\xda\x9a\xd6\x9a\xd6\xb5\xd2\xb5\xd2\xb6\xce\xb6\xce\xf8\ +\xcc\xf8\xcc\xcd\xcc\xcd\xcc\xef\xca\xef\xca\xcf\xca\xcf\xca\x95\ +\xcc\x95\xcc\xb7\xcd\xb7\xcdp\xcfp\xcf\x84\xd2\x84\xd2\xcc\ +\xd5\xcc\xd5\xf5\xd8\xf5\xd8U\xdeU\xde\x12\xe4\x12\xe4\x1d\ +\xe8\x1d\xe8X\xedX\xed\x92\xf4\x92\xf4T\xfbT\xfb[\ +\x00[\x003\x063\x06\xb3\x0b\xb3\x0b\xdb\x10\xdb\x10\xea\ +\x17\xea\x17$\x1e$\x1e\xf9!\xf9!\x8d&\x8d&\xa7\ +,\xa7,\xd60\xd60\xbb2\xbb2;5;5\xfe\ +5\xfe5\xe15\xe15Q7Q7U7U7\x02\ +5\x025\x902\x902\x081\x081\x05-\x05-\x05\ +(\x05(\x88$\x88$\x11\x1e\x11\x1e@\x16@\x16\xb0\ +\x10\xb0\x10\xb2\x0b\xb2\x0b\xdd\x04\xdd\x04F\xfdF\xfd\xf0\ +\xf6\xf0\xf6\xcc\xef\xcc\xef\xd6\xe8\xd6\xe8\xe4\xe3\xe4\xe3V\ +\xdeV\xdeN\xd8N\xd8\x06\xd4\x06\xd4\x0b\xd1\x0b\xd1S\ +\xcdS\xcd\x83\xca\x83\xca\xfa\xc8\xfa\xc8{\xc6{\xc6u\ +\xc5u\xc5K\xc6K\xc6\xc4\xc6\xc4\xc6a\xc7a\xc7\x10\ +\xca\x10\xcaV\xceV\xce!\xd2!\xd2\x14\xd7\x14\xd7\xb4\ +\xdc\xb4\xdc\xf6\xe1\xf6\xe1\x93\xe8\x93\xe8\x99\xf0\x99\xf0\xd9\ +\xf8\xd9\xf8w\xffw\xffB\x06B\x06\xa6\x0d\xa6\x0dK\ +\x13K\x13N\x19N\x19H\x1fH\x1fH$H$V\ +(V(\xaa+\xaa+\xac.\xac.y1y1\x13\ +5\x135\x127\x127\x027\x027\xb17\xb17\xe2\ +7\xe27\xc66\xc66\xbe4\xbe4\x142\x142\xe8\ +-\xe8-5)5)\x94%\x94%\xb8 \xb8 \xf2\ +\x19\xf2\x19w\x13w\x13\xf7\x0d\xf7\x0d[\x08[\x08\x0d\ +\x02\x0d\x02\xaa\xfb\xaa\xfb\xfe\xf3\xfe\xf38\xec8\xec\xd2\ +\xe6\xd2\xe6#\xe2#\xe2\xbe\xdc\xbe\xdc \xd8 \xd8\xec\ +\xd4\xec\xd4\x8f\xd1\x8f\xd1\xc9\xce\xc9\xce\xb5\xcd\xb5\xcd\xb4\ +\xcb\xb4\xcb\x0a\xc9\x0a\xc9C\xc8C\xc8\xb1\xc8\xb1\xc8|\ +\xc9|\xc9\xae\xcb\xae\xcbW\xceW\xce\xc9\xd0\xc9\xd0\xfa\ +\xd4\xfa\xd4s\xdbs\xdb\xb8\xe1\xb8\xe1\xca\xe6\xca\xe6\x9b\ +\xec\x9b\xec\x1f\xf4\x1f\xf4\xfe\xfb\xfe\xfb3\x033\x03\xf8\ +\x09\xf8\x097\x0f7\x0f\xd3\x13\xd3\x136\x196\x19\xab\ +\x1e\xab\x1e+#+#\xaa&\xaa&r*r*O\ +.O.\x001\x001s3s3\xae5\xae50\ +707`7`7A7A7I6I6d\ +3d3Z1Z1\xf7.\xf7.h*h*B\ +%B%\xdb\x1f\xdb\x1f\xe7\x19\xe7\x19P\x13P\x13\xa1\ +\x0d\xa1\x0d\xf3\x06\xf3\x06\x89\xfe\x89\xfe\xba\xf7\xba\xf7\x17\ +\xf2\x17\xf2\xad\xeb\xad\xeb\x82\xe4\x82\xe4\xc5\xde\xc5\xde@\ +\xd9@\xd9\xe3\xd3\xe3\xd3\x9b\xd0\x9b\xd0\xd9\xcc\xd9\xcc\xdd\ +\xc8\xdd\xc8\x22\xc7\x22\xc7\xec\xc6\xec\xc66\xc66\xc6\x8c\ +\xc5\x8c\xc5\x94\xc6\x94\xc6\x8f\xc8\x8f\xc8\xd1\xcb\xd1\xcb\x9d\ +\xd0\x9d\xd09\xd59\xd5=\xd9=\xd9\xd9\xdd\xd9\xdd\x90\ +\xe4\x90\xe4O\xebO\xebT\xf1T\xf1n\xf8n\xf8\xef\ +\xfe\xef\xfe\xc2\x04\xc2\x04\x8d\x0b\x8d\x0bK\x12K\x12t\ +\x17t\x17q\x1bq\x1b\xfc\x1f\xfc\x1f\x05$\x05$\x81\ +'\x81'T*T*u,u,\xa4.\xa4.\xfc\ +0\xfc0\x8e3\x8e3D4D4\x1d3\x1d3\xf5\ +1\xf51_0_0].].\xca+\xca+O\ +(O(\x93#\x93#\xd4\x1e\xd4\x1e\xae\x1a\xae\x1a\x91\ +\x15\x91\x15\xd7\x0f\xd7\x0f#\x0a#\x0a\x8f\x03\x8f\x03p\ +\xfcp\xfc\xca\xf5\xca\xf5Y\xf0Y\xf0\x85\xea\x85\xea6\ +\xe56\xe5-\xe1-\xe1i\xdci\xdc\xf0\xd7\xf0\xd7\xea\ +\xd4\xea\xd48\xd28\xd2\x8c\xcf\x8c\xcf\xbc\xcd\xbc\xcdi\ +\xcci\xcc\x8a\xcb\x8a\xcb\x85\xcc\x85\xcc\xdd\xce\xdd\xceH\ +\xd1H\xd1\xa3\xd3\xa3\xd3C\xd7C\xd7\x22\xdc\x22\xdc\x09\ +\xe1\x09\xe1%\xe6%\xe6\xf4\xea\xf4\xea\x01\xf0\x01\xf0\x1d\ +\xf6\x1d\xf6c\xfcc\xfc\x8c\x02\x8c\x02\xad\x08\xad\x08e\ +\x0ee\x0e\xaf\x13\xaf\x13.\x19.\x19\x02\x1f\x02\x1f\xb4\ +#\xb4#\xd9'\xd9'?+?+\x16.\x16.\xc0\ +0\xc00\x033\x033\x883\x883\x9a2\x9a2:\ +2:2\x0e1\x0e1^/^/\x7f-\x7f-*\ +***\x84%\x84%\x10!\x10!\xa8\x1c\xa8\x1c=\ +\x16=\x16\x97\x0f\x97\x0f\xc0\x09\xc0\x09\xc8\x03\xc8\x03\x87\ +\xfd\x87\xfdd\xf7d\xf7.\xf1.\xf1\x94\xea\x94\xea+\ +\xe5+\xe5\x0a\xe0\x0a\xe0\xaa\xda\xaa\xdar\xd6r\xd6\x1c\ +\xd3\x1c\xd3\x89\xd0\x89\xd0I\xceI\xcew\xccw\xccp\ +\xcbp\xcb\x93\xcb\x93\xcb\xe5\xcc\xe5\xcc\x9c\xce\x9c\xce+\ +\xd1+\xd1C\xd4C\xd4\xff\xd7\xff\xd7\xd7\xdc\xd7\xdch\ +\xe2h\xe2r\xe7r\xe7E\xecE\xec\x0c\xf2\x0c\xf2\x18\ +\xf8\x18\xf8\xd5\xfd\xd5\xfd\xc3\x03\xc3\x03k\x09k\x09\xf7\ +\x0e\xf7\x0e\xfa\x14\xfa\x14E\x1aE\x1an\x1en\x1e!\ +\x22!\x22\xe9%\xe9%+)+)9,9,\xc5\ +.\xc5.\x92/\x92/\x0c0\x0c0\xbc0\xbc0k\ +0k0\x1c/\x1c/7-7-\xc7*\xc7*\xbf\ +'\xbf'y$y$! ! \x01\x1b\x01\x1b\x06\ +\x16\x06\x16\x94\x10\x94\x10\x09\x0a\x09\x0aZ\x03Z\x03\x88\ +\xfd\x88\xfd\xaa\xf7\xaa\xf7x\xf1x\xf1Q\xecQ\xecU\ +\xe7U\xe7\x1b\xe2\x1b\xe2\xd5\xdd\xd5\xddP\xdaP\xdau\ +\xd6u\xd6\x08\xd3\x08\xd3\xbf\xd0\xbf\xd0\x98\xce\x98\xce\xa4\ +\xcd\xa4\xcdF\xceF\xceE\xcfE\xcf\x93\xd0\x93\xd0\xe3\ +\xd2\xe3\xd2\x03\xd6\x03\xd6d\xd9d\xd9@\xdd@\xdd\xfe\ +\xe0\xfe\xe0\xf7\xe4\xf7\xe4\x1f\xea\x1f\xea\xcc\xef\xcc\xefX\ +\xf5X\xf5O\xfbO\xfbg\x01g\x019\x079\x07\xe1\ +\x0c\xe1\x0c\x05\x13\x05\x13\xfe\x18\xfe\x18/\x1e/\x1e\xf4\ +\x22\xf4\x22\xc1&\xc1&\xef)\xef)----\xc5\ +/\xc5/\xad0\xad0i1i1\xd81\xd81(\ +1(1\xa30\xa30\xc3/\xc3/\xf7,\xf7,G\ +)G)\xb1%\xb1%2!2!\xdb\x1b\xdb\x1b\xb4\ +\x16\xb4\x16\xca\x10\xca\x10\xd5\x0a\xd5\x0a\x14\x05\x14\x05+\ +\xff+\xff\xb8\xf8\xb8\xf8v\xf2v\xf2\xbe\xec\xbe\xec\xc5\ +\xe6\xc5\xe6\xad\xe1\xad\xe1@\xdd@\xdd!\xd9!\xd9\xeb\ +\xd5\xeb\xd5)\xd3)\xd3u\xd0u\xd0c\xcec\xce\xc0\ +\xcd\xc0\xcd\x94\xcd\x94\xcd\x07\xce\x07\xce\xbf\xcf\xbf\xcf\x9c\ +\xd1\x9c\xd1+\xd4+\xd4\x88\xd8\x88\xd8+\xdd+\xdd\x7f\ +\xe0\x7f\xe0\xd3\xe4\xd3\xe4n\xean\xea\x89\xef\x89\xef\xfc\ +\xf4\xfc\xf4\xf4\xfa\xf4\xfa\x8c\x00\x8c\x00\xca\x06\xca\x06\xd1\ +\x0d\xd1\x0d-\x13-\x13w\x17w\x17J\x1cJ\x1c\xcf\ + \xcf \x99$\x99$>(>(\xa9*\xa9*0\ +,0,k.k.\x100\x1006060\xac\ +/\xac/\xd2.\xd2.j-j-\xbf+\xbf+\xc3\ +(\xc3(E$E$\x85\x1f\x85\x1f%\x1b%\x1b!\ +\x16!\x16\x12\x10\x12\x10\xfa\x09\xfa\x09\xf8\x03\xf8\x03\xef\ +\xfd\xef\xfd\x05\xf8\x05\xf8_\xf2_\xf2\xec\xec\xec\xec\xd1\ +\xe7\xd1\xe7o\xe3o\xe3\x10\xdf\x10\xdfi\xdai\xda\xda\ +\xd6\xda\xd6\x0b\xd4\x0b\xd4\xe5\xd1\xe5\xd1\xf5\xd0\xf5\xd0\xb7\ +\xd0\xb7\xd0\x9f\xd0\x9f\xd0\xa9\xd1\xa9\xd1\xf7\xd3\xf7\xd35\ +\xd65\xd6r\xd8r\xd8-\xdb-\xdb\x10\xde\x10\xde\xf5\ +\xe1\xf5\xe1\xa7\xe6\xa7\xe6\xd2\xea\xd2\xea\x10\xef\x10\xef\xbf\ +\xf4\xbf\xf4\xca\xfa\xca\xfa\x8b\x00\x8b\x00\xdc\x06\xdc\x06\x02\ +\x0d\x02\x0d\xc9\x12\xc9\x12\xb4\x18\xb4\x18\x1c\x1e\x1c\x1e\xc6\ +!\xc6!\xed$\xed$\x06(\x06(\x82*\x82*\xd5\ +,\xd5,\xf5.\xf5.\x86/\x86/\x17/\x17/\x1a\ +/\x1a/|.|.A,A,))))`\ +%`%b!b!\xbd\x1d\xbd\x1d*\x19*\x19V\ +\x13V\x13-\x0d-\x0dm\x07m\x07}\x01}\x01\xe2\ +\xfb\xe2\xfb\xe6\xf5\xe6\xf5\xc8\xef\xc8\xef\xcf\xea\xcf\xea\x8a\ +\xe6\x8a\xe6^\xe2^\xe2\xaa\xde\xaa\xde_\xdb_\xdbF\ +\xd8F\xd8\xf6\xd5\xf6\xd5\xf0\xd4\xf0\xd4\x83\xd4\x83\xd4l\ +\xd4l\xd4\xe3\xd4\xe3\xd4\x98\xd5\x98\xd5\x80\xd6\x80\xd6`\ +\xd8`\xd8\x0c\xdb\x0c\xdb\xe6\xdd\xe6\xddr\xe1r\xe1\x0c\ +\xe5\x0c\xe5\xf6\xe8\xf6\xe8\xcb\xed\xcb\xeda\xf3a\xf3\xb4\ +\xf8\xb4\xf8\xcc\xfd\xcc\xfd\xfa\x02\xfa\x02\x11\x08\x11\x08\xf0\ +\x0c\xf0\x0c\x8d\x11\x8d\x11\x09\x16\x09\x16\xd1\x19\xd1\x19]\ +\x1d]\x1d\x8f \x8f \xae#\xae#\xc8&\xc8&\x0b\ +)\x0b)\x1f*\x1f*\xe6*\xe6*6+6+4\ ++4+\xa2*\xa2*\xcc(\xcc(\x14&\x14&\xa1\ +#\xa1#\x0f!\x0f!g\x1dg\x1d\xf3\x18\xf3\x18\xf2\ +\x13\xf2\x13\x0d\x0f\x0d\x0f%\x0a%\x0a\x01\x05\x01\x05p\ +\xffp\xffV\xf9V\xf9\xd2\xf3\xd2\xf3\xcb\xee\xcb\xee\x94\ +\xe9\x94\xe9\xa4\xe4\xa4\xe4\x97\xe0\x97\xe0\xb8\xdd\xb8\xdd\x87\ +\xdb\x87\xdbI\xd9I\xd9\xf2\xd6\xf2\xd6\x13\xd5\x13\xd5\xb8\ +\xd4\xb8\xd4Z\xd5Z\xd5o\xd5o\xd5m\xd5m\xd5\x13\ +\xd7\x13\xd7\xa9\xd9\xa9\xd9\xae\xdc\xae\xdc\xb9\xdf\xb9\xdfr\ +\xe2r\xe2\xba\xe5\xba\xe5\x95\xea\x95\xea\xee\xef\xee\xefn\ +\xf4n\xf4\x17\xf9\x17\xf9V\xfeV\xfe\x1d\x04\x1d\x04\x89\ +\x0a\x89\x0aA\x10A\x10\xf3\x14\xf3\x14\x9b\x19\x9b\x19r\ +\x1er\x1e7\x227\x22\xbe$\xbe$\xcf&\xcf&B\ +(B(Y)Y)\xfe)\xfe)X)X)<\ +(<(\xa5'\xa5'\xa6&\xa6&O$O$\x95\ +!\x95!k\x1ek\x1e\xe2\x1a\xe2\x1aY\x17Y\x170\ +\x130\x13\xdc\x0d\xdc\x0d\xeb\x08\xeb\x08\x84\x04\x84\x04\x9b\ +\xff\x9b\xffU\xfaU\xfa\x96\xf4\x96\xf4\x01\xef\x01\xef)\ +\xea)\xea\x03\xe6\x03\xe6\xdf\xe1\xdf\xe1a\xdea\xdeX\ +\xdcX\xdc\xf3\xda\xf3\xda}\xd9}\xd9\xff\xd7\xff\xd7\xe0\ +\xd6\xe0\xd6f\xd6f\xd69\xd79\xd7\x91\xd8\x91\xd8\xf3\ +\xd9\xf3\xd9J\xdcJ\xdc\xa3\xdf\xa3\xdf\xa5\xe3\xa5\xe3\xd9\ +\xe7\xd9\xe7\xea\xeb\xea\xeb'\xf0'\xf0>\xf5>\xf5~\ +\xfa~\xfaU\xffU\xff\x14\x04\x14\x04t\x09t\x09\xce\ +\x0e\xce\x0e\xf3\x13\xf3\x13\x9d\x18\x9d\x18F\x1cF\x1c\x8e\ +\x1f\x8e\x1f\xe3\x22\xe3\x22T%T%\xa7&\xa7&\xaa\ +'\xaa'\x85(\x85(\xfd(\xfd(\x13)\x13)\x0c\ +(\x0c(H&H&\x8a$\x8a$\x93\x22\x93\x22i\ + i \x00\x1d\x00\x1d\x0c\x18\x0c\x18\x08\x13\x08\x13\x8c\ +\x0e\x8c\x0e\x0e\x09\x0e\x09\xf1\x02\xf1\x02f\xfdf\xfd\xdb\ +\xf7\xdb\xf7\xa2\xf2\xa2\xf2o\xeeo\xee\x18\xea\x18\xea\x87\ +\xe5\x87\xe5#\xe2#\xe2#\xdf#\xdf\x16\xdc\x16\xdc\xae\ +\xd9\xae\xd9\xba\xd7\xba\xd7D\xd6D\xd6\x1d\xd6\x1d\xd6\xd4\ +\xd6\xd4\xd6\xa2\xd7\xa2\xd7U\xd9U\xd9\x9d\xdb\x9d\xdb$\ +\xde$\xde~\xe1~\xe1^\xe5^\xe5\x17\xe9\x17\xe9R\ +\xedR\xed\xeb\xf1\xeb\xf1}\xf6}\xf6\x89\xfb\x89\xfb\xa2\ +\x00\xa2\x00\xef\x05\xef\x05l\x0bl\x0b\xbb\x10\xbb\x10f\ +\x15f\x15{\x19{\x19x\x1dx\x1d\xce \xce {\ +#{#e%e%\xcf&\xcf&\xd5'\xd5'\xc0\ +(\xc0(\xed(\xed(\xf1'\xf1'{&{&\x9a\ +$\x9a$\x16\x22\x16\x22\x0c\x1f\x0c\x1fa\x1ba\x1b\xcb\ +\x16\xcb\x16k\x12k\x12\x16\x0e\x16\x0e!\x09!\x09%\ +\x04%\x04\xfe\xfe\xfe\xfe\xcb\xf9\xcb\xf9\x09\xf5\x09\xf5I\ +\xf0I\xf0;\xeb;\xeb\xd7\xe6\xd7\xe6m\xe3m\xe3,\ +\xe0,\xe0a\xdda\xdd%\xdb%\xdbQ\xd9Q\xd9@\ +\xd8@\xd8#\xd8#\xd8\x16\xd8\x16\xd8m\xd8m\xd8\xb8\ +\xd9\xb8\xd9\xd0\xdb\xd0\xdb\xab\xde\xab\xde\xfa\xe1\xfa\xe1\x9a\ +\xe5\x9a\xe5^\xe9^\xe9>\xee>\xee;\xf3;\xf3\x08\ +\xf8\x08\xf84\xfd4\xfd^\x02^\x02R\x07R\x07x\ +\x0cx\x0c\x0a\x11\x0a\x11\xe2\x14\xe2\x14\xd1\x18\xd1\x18g\ +\x1cg\x1cE\x1fE\x1f\x95!\x95!P#P#\x7f\ +$\x7f$\xa8%\xa8%\xad&\xad&\xad&\xad&\x12\ +&\x12&\x0c%\x0c%\x93#\x93#\xa4!\xa4!\xa3\ +\x1e\xa3\x1e\xd0\x1a\xd0\x1a\xdf\x16\xdf\x16\xb8\x12\xb8\x12\xe2\ +\x0d\xe2\x0d\xf3\x08\xf3\x08\xd0\x03\xd0\x03\xa2\xfe\xa2\xfe\xf7\ +\xf9\xf7\xf9p\xf5p\xf5c\xf0c\xf0\xcc\xeb\xcc\xeb\x0e\ +\xe8\x0e\xe8V\xe4V\xe4\xf7\xe0\xf7\xe0\x22\xde\x22\xde\x99\ +\xdb\x99\xdb\x03\xda\x03\xda\x83\xd9\x83\xd94\xd94\xd94\ +\xd94\xd9\x15\xda\x15\xdaZ\xdbZ\xdb!\xdd!\xdd\x8c\ +\xdf\x8c\xdf/\xe2/\xe2\x17\xe5\x17\xe5\xd7\xe8\xd7\xe8\xde\ +\xec\xde\xec\x0f\xf1\x0f\xf1\xe8\xf5\xe8\xf5\xcc\xfa\xcc\xfa\xda\ +\xff\xda\xff\x00\x05\x00\x05\x0f\x0a\x0f\x0a\xa0\x0e\xa0\x0e[\ +\x13[\x13\xcd\x17\xcd\x17b\x1bb\x1b\x95\x1e\x95\x1e\x88\ +!\x88!\xf2#\xf2#\xd9%\xd9%U'U'\xa0\ +'\xa0'i'i'\xfb&\xfb&\xaf%\xaf%\xba\ +#\xba#Z!Z!D\x1eD\x1e\xe1\x1a\xe1\x1aw\ +\x17w\x17V\x13V\x13\xcf\x0e\xcf\x0e*\x0a*\x0aC\ +\x05C\x05\x9d\x00\x9d\x00\xc7\xfb\xc7\xfbZ\xf6Z\xf6\x03\ +\xf1\x03\xf1\xa0\xec\xa0\xec^\xe8^\xe8[\xe4[\xe4!\ +\xe1!\xe1@\xde@\xde\xec\xdb\xec\xdbm\xdam\xda@\ +\xd9@\xd9q\xd8q\xd8\x83\xd8\x83\xd8\x1b\xd9\x1b\xd9n\ +\xdan\xda^\xdc^\xdc\xf8\xde\xf8\xde\xd8\xe1\xd8\xe1.\ +\xe5.\xe5\x96\xe9\x96\xe9\x06\xee\x06\xee\xcb\xf2\xcb\xf2\xc4\ +\xf7\xc4\xf7\x8e\xfc\x8e\xfc}\x01}\x01\x98\x06\x98\x06+\ +\x0b+\x0bZ\x0fZ\x0f\x87\x13\x87\x13a\x17a\x17\xd9\ +\x1a\xd9\x1a\xca\x1d\xca\x1d\xfe\x1f\xfe\x1f\xf2!\xf2!6\ +$6$\x16&\x16&\xc3&\xc3&\xd7&\xd7&I\ +&I&/%/%\xb1#\xb1#w!w!y\ +\x1ey\x1e!\x1b!\x1bw\x17w\x17t\x13t\x13\xeb\ +\x0e\xeb\x0e\x1c\x0a\x1c\x0aW\x05W\x05t\x00t\x003\ +\xfb3\xfb\xc6\xf5\xc6\xf5\x0f\xf1\x0f\xf1\xd6\xec\xd6\xec\xe7\ +\xe8\xe7\xe8_\xe5_\xe5\xc1\xe1\xc1\xe1\xce\xde\xce\xde.\ +\xdd.\xdd\xf7\xdb\xf7\xdb\xa3\xda\xa3\xda\x98\xd9\x98\xd97\ +\xd97\xd9u\xd9u\xd9i\xdai\xda\xad\xdb\xad\xdb-\ +\xdd-\xdd\x9a\xdf\x9a\xdfA\xe3A\xe3\x05\xe7\x05\xe77\ +\xeb7\xeb\xe6\xef\xe6\xef[\xf4[\xf4\x11\xf9\x11\xf9\xe5\ +\xfd\xe5\xfdl\x02l\x02:\x07:\x07\xa0\x0c\xa0\x0c5\ +\x115\x11\xf5\x14\xf5\x14\xe9\x18\xe9\x18\x94\x1c\x94\x1c\xeb\ +\x1f\xeb\x1f\x84\x22\x84\x22\x00$\x00$\xdf$\xdf$\x1d\ +&\x1d&\xe8&\xe8&\xa7&\xa7&\x05&\x05&\x84\ +$\x84$\x05#\x05#E!E!\x84\x1e\x84\x1e\xe6\ +\x1a\xe6\x1a\xf8\x16\xf8\x16\xa4\x12\xa4\x12-\x0e-\x0e\xaf\ +\x09\xaf\x09\x82\x04\x82\x04f\xfff\xff\xd3\xfa\xd3\xfa\x00\ +\xf6\x00\xf6\xb2\xf0\xb2\xf0M\xecM\xec0\xe80\xe8`\ +\xe4`\xe4f\xe1f\xe1b\xdeb\xde.\xdc.\xdc\x8a\ +\xdb\x8a\xdb$\xdb$\xdb\x93\xda\x93\xda\x7f\xda\x7f\xda\xc9\ +\xda\xc9\xda\xfd\xdb\xfd\xdb\xc1\xdd\xc1\xdd\xd9\xdf\xd9\xdf\x92\ +\xe2\x92\xe2u\xe6u\xe6\xaf\xea\xaf\xea/\xef/\xef\xd5\ +\xf3\xd5\xf3\x9d\xf8\x9d\xf8\xdc\xfd\xdc\xfd\x93\x02\x93\x02\xaa\ +\x06\xaa\x06\x09\x0b\x09\x0b\x8c\x0f\x8c\x0f\x9b\x13\x9b\x13\x85\ +\x17\x85\x17\x0c\x1b\x0c\x1b$\x1e$\x1e\x11!\x11!h\ +#h#}$}$\xea$\xea$\xbd$\xbd$\xda\ +#\xda#\xd8\x22\xd8\x22\x01!\x01!\xcf\x1e\xcf\x1e\x92\ +\x1c\x92\x1c\xd6\x19\xd6\x19a\x16a\x16\xe7\x12\xe7\x12\xde\ +\x0e\xde\x0ek\x0ak\x0a\xde\x05\xde\x05'\x01'\x01\x93\ +\xfc\x93\xfcL\xf8L\xf8\x00\xf4\x00\xf4\x9f\xef\x9f\xef\xd5\ +\xeb\xd5\xeb\x0b\xe8\x0b\xe8\xc0\xe4\xc0\xe4G\xe2G\xe2\xb4\ +\xdf\xb4\xdfq\xddq\xddR\xdcR\xdc\xd0\xdb\xd0\xdb\xed\ +\xdb\xed\xdb\xbe\xdc\xbe\xdc\x9d\xdd\x9d\xddX\xdfX\xdf\xd2\ +\xe1\xd2\xe1r\xe4r\xe4;\xe7;\xe7\xa2\xea\xa2\xea\x22\ +\xee\x22\xee$\xf2$\xf2\xa7\xf6\xa7\xf6C\xfbC\xfb4\ +\x004\x00X\x05X\x05\xdf\x09\xdf\x09\x19\x0e\x19\x0e*\ +\x12*\x12\xa7\x15\xa7\x15\xd1\x18\xd1\x18d\x1bd\x1bk\ +\x1dk\x1d\x98\x1f\x98\x1fu!u!\xaf\x22\xaf\x22r\ +#r#H#H#8\x228\x22\xc8 \xc8 \x01\ +\x1f\x01\x1f\x82\x1c\x82\x1c\xf5\x19\xf5\x19T\x17T\x17\xd6\ +\x13\xd6\x13?\x10?\x10\x9f\x0c\x9f\x0c\x86\x08\x86\x08\xd1\ +\x03\xd1\x03\xef\xfe\xef\xfe\xf0\xf9\xf0\xf9[\xf5[\xf5\xe5\ +\xf0\xe5\xf0\xe8\xec\xe8\xec\x94\xe9\x94\xe9\x9c\xe6\x9c\xe6\xf3\ +\xe3\xf3\xe3\x87\xe1\x87\xe1\xc1\xdf\xc1\xdfq\xdeq\xdev\ +\xddv\xdd\xf3\xdc\xf3\xdc\x8f\xdc\x8f\xdc\x0f\xdd\x0f\xdd\xbc\ +\xde\xbc\xde\xda\xe0\xda\xe0>\xe3>\xe3&\xe6&\xe6*\ +\xe9*\xe9\xd4\xec\xd4\xec\xf5\xf0\xf5\xf0\x14\xf5\x14\xf5\x14\ +\xf9\x14\xf9\x96\xfd\x96\xfd:\x02:\x02\xb1\x06\xb1\x06f\ +\x0bf\x0b%\x10%\x10Q\x14Q\x14\xfb\x17\xfb\x17\xec\ +\x1a\xec\x1aW\x1dW\x1d\x99\x1f\x99\x1f\x8f!\x8f!\xd9\ +\x22\xd9\x22\x8c#\x8c#*$*$d$d$}\ +#}#\xe0!\xe0!\x8d\x1f\x8d\x1f|\x1c|\x1cd\ +\x19d\x19\xb2\x15\xb2\x15B\x11B\x11\xfc\x0c\xfc\x0c\x91\ +\x08\x91\x08\xa3\x03\xa3\x03\x05\xff\x05\xff0\xfa0\xfa:\ +\xf5:\xf5\xc8\xf0\xc8\xf0U\xecU\xec\x06\xe8\x06\xe8\xe9\ +\xe4\xe9\xe4\xc5\xe2\xc5\xe2\xbf\xe0\xbf\xe0\xda\xde\xda\xde\xf0\ +\xdc\xf0\xdc\xc7\xdb\xc7\xdb\xd0\xdb\xd0\xdb-\xdc-\xdc\x1c\ +\xdd\x1c\xdd \xdf \xdf|\xe1|\xe18\xe48\xe4\x9c\ +\xe7\x9c\xe7\xb2\xea\xb2\xeaO\xeeO\xee\xa2\xf2\xa2\xf2\x5c\ +\xf6\x5c\xf6\x5c\xfa\x5c\xfa\xe9\xfe\xe9\xfem\x03m\x03\xe4\ +\x07\xe4\x07M\x0cM\x0c\x09\x10\x09\x10\xe9\x13\xe9\x13\x0f\ +\x18\x0f\x18l\x1bl\x1b\x09\x1e\x09\x1e\x1f \x1f \xa0\ +!\xa0!\xd9\x22\xd9\x22\xc1#\xc1#\xb5#\xb5#\x15\ +#\x15#(\x22(\x22m m M\x1eM\x1e\x89\ +\x1b\x89\x1b/\x18/\x18X\x14X\x14\xcb\x0f\xcb\x0f\x15\ +\x0b\x15\x0b\xb4\x06\xb4\x06(\x02(\x02\xa0\xfd\xa0\xfd)\ +\xf9)\xf9\x22\xf4\x22\xf4\xac\xef\xac\xef/\xec/\xecv\ +\xe8v\xe8\xfa\xe4\xfa\xe4,\xe2,\xe2\xc4\xdf\xc4\xdf\xb2\ +\xde\xb2\xdew\xdew\xde\xb6\xdd\xb6\xdd\x1b\xdd\x1b\xdd\x8b\ +\xdd\x8b\xddo\xdeo\xde\xfd\xdf\xfd\xdf\xcf\xe1\xcf\xe1\xcd\ +\xe3\xcd\xe3\xab\xe6\xab\xe6$\xea$\xea\x19\xee\x19\xee\xcd\ +\xf2\xcd\xf2\xb0\xf7\xb0\xf7f\xfcf\xfc\x0a\x01\x0a\x01\x89\ +\x05\x89\x053\x0a3\x0a\xe3\x0e\xe3\x0e\x0b\x13\x0b\x13z\ +\x16z\x16\xc9\x19\xc9\x19\xc3\x1c\xc3\x1cT\x1fT\x1fY\ +!Y!\xb6\x22\xb6\x22r#r#\xca#\xca#\x82\ +#\x82#c\x22c\x22\xd3 \xd3 \x94\x1e\x94\x1e\xfd\ +\x1b\xfd\x1b\x04\x19\x04\x19u\x15u\x15h\x11h\x11\x13\ +\x0d\x13\x0dP\x08P\x08\x99\x03\x99\x03\xf9\xfe\xf9\xfe\x22\ +\xfa\x22\xfai\xf5i\xf5\xfd\xf0\xfd\xf0\xd6\xec\xd6\xecC\ +\xe9C\xe91\xe61\xe6\x5c\xe3\x5c\xe3!\xe1!\xe1d\ +\xdfd\xdf\xec\xdd\xec\xdd%\xdd%\xdd\x1d\xdd\x1d\xddM\ +\xddM\xddT\xdeT\xde\x08\xe0\x08\xe0\xdd\xe1\xdd\xe1\x8c\ +\xe4\x8c\xe4\xad\xe7\xad\xe7\xcb\xea\xcb\xea\x9a\xee\x9a\xee\xb4\ +\xf2\xb4\xf2\x96\xf6\x96\xf6\xf0\xfa\xf0\xfao\xffo\xff\xae\ +\x03\xae\x03%\x08%\x08h\x0ch\x0c@\x10@\x10k\ +\x14k\x14n\x18n\x18\x9f\x1b\x9f\x1b7\x1e7\x1eR\ + R \xc2!\xc2!\x07#\x07#\xb1#\xb1#`\ +#`#\xb6\x22\xb6\x22\x9b!\x9b!\xec\x1f\xec\x1f\xa8\ +\x1d\xa8\x1d\xb3\x1a\xb3\x1a5\x175\x17@\x13@\x13\xe2\ +\x0e\xe2\x0ee\x0ae\x0a\x15\x06\x15\x06\x82\x01\x82\x01\x0b\ +\xfd\x0b\xfd=\xf8=\xf8K\xf3K\xf3;\xef;\xef\x9e\ +\xeb\x9e\xeb\xba\xe7\xba\xe7k\xe4k\xe4\xa1\xe1\xa1\xe1\xb6\ +\xdf\xb6\xdf\x1b\xdf\x1b\xdf\x89\xde\x89\xde\xa3\xdd\xa3\xddj\ +\xddj\xdd\x08\xde\x08\xde\x0d\xdf\x0d\xdf\x98\xe0\x98\xe0\x15\ +\xe2\x15\xe2%\xe4%\xe4\x07\xe7\x07\xe7\x7f\xea\x7f\xea\xce\ +\xee\xce\xee\xd5\xf3\xd5\xf3\x82\xf8\x82\xf8\x1e\xfd\x1e\xfd\xb5\ +\x01\xb5\x01X\x06X\x06\xf3\x0a\xf3\x0a2\x0f2\x0f\xdd\ +\x12\xdd\x12V\x16V\x16\xea\x19\xea\x19 \x1d \x1d\xca\ +\x1f\xca\x1f\xa0!\xa0!\xd7\x22\xd7\x22Z#Z#t\ +#t#\xd7\x22\xd7\x22\x94!\x94!\x99\x1f\x99\x1f\x82\ +\x1d\x82\x1d]\x1b]\x1b\x8c\x18\x8c\x18\x1f\x15\x1f\x15\xf7\ +\x10\xf7\x10U\x0cU\x0c\xd1\x07\xd1\x078\x038\x03b\ +\xfeb\xfe\x87\xf9\x87\xf9\xc6\xf4\xc6\xf4\xb5\xf0\xb5\xf0*\ +\xed*\xed\xbc\xe9\xbc\xe9<\xe6<\xe6O\xe3O\xe3:\ +\xe1:\xe1\xdb\xdf\xdb\xdf\xe2\xde\xe2\xdeG\xdeG\xde\x01\ +\xde\x01\xde6\xde6\xdeP\xdfP\xdf\xa2\xe0\xa2\xe0\x80\ +\xe2\x80\xe2\x14\xe5\x14\xe5\x0d\xe8\x0d\xe8^\xeb^\xebR\ +\xefR\xef\x8f\xf3\x8f\xf3\xf7\xf7\xf7\xf7\x82\xfc\x82\xfc\xa6\ +\x00\xa6\x00\xca\x04\xca\x04,\x09,\x09\x95\x0d\x95\x0d\x87\ +\x11\x87\x11\x1f\x15\x1f\x15}\x18}\x18\xb2\x1b\xb2\x1b\xab\ +\x1e\xab\x1e\xb2 \xb2 \xdf!\xdf!\x9f\x22\x9f\x22\xd1\ +\x22\xd1\x22T\x22T\x22*!*!\x97\x1f\x97\x1f\x8a\ +\x1d\x8a\x1d\x10\x1b\x10\x1b\xde\x17\xde\x17M\x14M\x14\xcb\ +\x10\xcb\x10\xc3\x0c\xc3\x0cz\x08z\x08\x09\x04\x09\x04\x96\ +\xff\x96\xff\x02\xfb\x02\xfb\x82\xf6\x82\xf6U\xf2U\xf2m\ +\xeem\xee\x0f\xeb\x0f\xeb\xe3\xe7\xe3\xe7\xff\xe4\xff\xe4\xbf\ +\xe2\xbf\xe2L\xe1L\xe1=\xe0=\xe0Y\xdfY\xdf\xc6\ +\xde\xc6\xde\xfb\xde\xfb\xde3\xe03\xe0\xc4\xe1\xc4\xe15\ +\xe35\xe3(\xe5(\xe5\xd8\xe7\xd8\xe7\x0c\xeb\x0c\xeb\xd5\ +\xee\xd5\xee\xa2\xf2\xa2\xf2\x94\xf6\x94\xf6\x18\xfb\x18\xfb\xba\ +\xff\xba\xff\xad\x03\xad\x03\x96\x07\x96\x07_\x0b_\x0b6\ +\x0f6\x0f\xd2\x12\xd2\x12\xa5\x15\xa5\x15B\x18B\x18W\ +\x1bW\x1b\xdc\x1d\xdc\x1d\x8f\x1f\x8f\x1f\x82 \x82 \xe6\ + \xe6 \xec \xec C C \xac\x1e\xac\x1e\xc2\ +\x1c\xc2\x1c\xe5\x1a\xe5\x1a\xca\x18\xca\x18\x1d\x16\x1d\x16}\ +\x12}\x12\xe3\x0e\xe3\x0el\x0bl\x0b\x9e\x07\x9e\x07\xeb\ +\x02\xeb\x02L\xfeL\xfe\xa8\xf9\xa8\xf9Q\xf5Q\xf5\xab\ +\xf1\xab\xf1\xdc\xed\xdc\xed!\xea!\xeaA\xe7A\xe7\xd6\ +\xe4\xd6\xe4\x95\xe2\x95\xe2(\xe1(\xe1\x05\xe0\x05\xe0D\ +\xdfD\xdf\x0a\xdf\x0a\xdf2\xdf2\xdfY\xe0Y\xe0G\ +\xe2G\xe2\x1a\xe4\x1a\xe4G\xe6G\xe6<\xe9<\xe9d\ +\xecd\xec\xd6\xef\xd6\xef\xf4\xf3\xf4\xf3\xd2\xf7\xd2\xf7\x05\ +\xfc\x05\xfc\x88\x00\x88\x00i\x04i\x04\xac\x08\xac\x08\xeb\ +\x0c\xeb\x0c\x94\x10\x94\x10B\x14B\x14\xd8\x17\xd8\x17\x8d\ +\x1a\x8d\x1a\x1f\x1d\x1f\x1d$\x1f$\x1fW W \x83\ +!\x83!\xc5!\xc5!\xd2 \xd2 \x06 \x06 \xde\ +\x1e\xde\x1e\xdd\x1c\xdd\x1c\xd1\x1a\xd1\x1a\xe5\x17\xe5\x17\xeb\ +\x13\xeb\x13f\x10f\x10\xd1\x0c\xd1\x0cV\x08V\x08\x0a\ +\x04\x0a\x04u\xffu\xff\xc9\xfa\xc9\xfa\xe3\xf6\xe3\xf6\x1d\ +\xf3\x1d\xf3]\xef]\xef;\xec;\xec9\xe99\xe9\x5c\ +\xe6\x5c\xe6\x1d\xe4\x1d\xe4?\xe2?\xe2\x0a\xe1\x0a\xe1\x9d\ +\xe0\x9d\xe0\x12\xe0\x12\xe0\x0f\xe0\x0f\xe05\xe15\xe1\xdb\ +\xe2\xdb\xe2\xb3\xe4\xb3\xe4\xc4\xe6\xc4\xe6\x14\xe9\x14\xe9\x08\ +\xec\x08\xec\xb4\xef\xb4\xefZ\xf3Z\xf3?\xf7?\xf7\xc4\ +\xfb\xc4\xfb\xf1\xff\xf1\xff\x00\x04\x00\x04\xd5\x07\xd5\x07\x0f\ +\x0b\x0f\x0bI\x0eI\x0e\xc3\x11\xc3\x11}\x14}\x14\x0c\ +\x17\x0c\x17r\x19r\x19^\x1b^\x1b+\x1d+\x1d.\ +\x1e.\x1e$\x1e$\x1e\x11\x1e\x11\x1e\xb8\x1d\xb8\x1dt\ +\x1ct\x1c\xd9\x1a\xd9\x1a\x07\x19\x07\x19\xb4\x16\xb4\x16J\ +\x14J\x146\x116\x11\x9b\x0d\x9b\x0d(\x0a(\x0ac\ +\x06c\x06\x05\x02\x05\x02\xda\xfd\xda\xfd\xd3\xf9\xd3\xf9\x03\ +\xf6\x03\xf6\xdb\xf2\xdb\xf2\xf4\xef\xf4\xef\xf6\xec\xf6\xec\x8f\ +\xea\x8f\xea]\xe8]\xe8J\xe6J\xe6\xae\xe4\xae\xe4W\ +\xe3W\xe3\x92\xe2\x92\xe2\x81\xe2\x81\xe2\xc9\xe2\xc9\xe2\x97\ +\xe3\x97\xe3\x1f\xe5\x1f\xe5&\xe7&\xe7\x1c\xe9\x1c\xe9a\ +\xeba\xeb\xf8\xed\xf8\xed\xf1\xf0\xf1\xf0\xaa\xf4\xaa\xf4O\ +\xf8O\xf8\xcd\xfb\xcd\xfb\xda\xff\xda\xff\x15\x04\x15\x04Y\ +\x08Y\x085\x0c5\x0ca\x0fa\x0f?\x12?\x12\x17\ +\x15\x17\x15g\x17g\x17H\x19H\x19\x04\x1b\x04\x1bt\ +\x1ct\x1co\x1do\x1d|\x1d|\x1d\xde\x1c\xde\x1c9\ +\x1c9\x1c\xf5\x1a\xf5\x1a\xcf\x18\xcf\x18w\x16w\x16\xe3\ +\x13\xe3\x134\x114\x11\xb8\x0e\xb8\x0e\xaa\x0b\xaa\x0b\x0f\ +\x08\x0f\x08\xab\x04\xab\x04+\x01+\x01V\xfdV\xfd\xcb\ +\xf9\xcb\xf9\x0b\xf6\x0b\xf6\x84\xf2\x84\xf2\xca\xef\xca\xef\xcd\ +\xec\xcd\xec\x16\xea\x16\xeaE\xe8E\xe8]\xe6]\xe6\xcd\ +\xe4\xcd\xe4\x01\xe4\x01\xe4^\xe3^\xe34\xe34\xe3\xd2\ +\xe3\xd2\xe3\x8c\xe4\x8c\xe4\xd6\xe5\xd6\xe5\xf1\xe7\xf1\xe7$\ +\xea$\xea\xd5\xec\xd5\xec&\xf0&\xf0j\xf3j\xf3\x08\ +\xf7\x08\xf7\x03\xfb\x03\xfb\x81\xfe\x81\xfe<\x02<\x02\xec\ +\x05\xec\x05\x0f\x09\x0f\x09q\x0cq\x0c\xd9\x0f\xd9\x0f\x9a\ +\x12\x9a\x12u\x15u\x15\xde\x17\xde\x17\x80\x19\x80\x19\x11\ +\x1b\x11\x1b\x02\x1c\x02\x1cZ\x1cZ\x1c\xa5\x1c\xa5\x1cV\ +\x1cV\x1cg\x1bg\x1bE\x1aE\x1a\x5c\x18\x5c\x18\xed\ +\x15\xed\x15c\x13c\x13b\x10b\x10\xfe\x0c\xfe\x0c\x93\ +\x09\x93\x09\xd9\x05\xd9\x05\xfa\x01\xfa\x01L\xfeL\xfee\ +\xfae\xfa\xc2\xf6\xc2\xf6\xbf\xf3\xbf\xf3\x90\xf0\x90\xf0\x8d\ +\xed\x8d\xed\xf7\xea\xf7\xea\xac\xe8\xac\xe8\x19\xe7\x19\xe7\xdc\ +\xe5\xdc\xe5\xa1\xe4\xa1\xe4\x17\xe4\x17\xe4\x1f\xe4\x1f\xe4R\ +\xe4R\xe4-\xe5-\xe5\xb0\xe6\xb0\xe6/\xe8/\xe8\xff\ +\xe9\xff\xe9M\xecM\xec\xed\xee\xed\xee\x17\xf2\x17\xf2\x82\ +\xf5\x82\xf5\xaa\xf8\xaa\xf8#\xfc#\xfc\xde\xff\xde\xff\xb5\ +\x03\xb5\x03\x99\x07\x99\x07\x0d\x0b\x0d\x0b\x1f\x0e\x1f\x0eV\ +\x11V\x11B\x14B\x14\x86\x16\x86\x16\x8d\x18\x8d\x18.\ +\x1a.\x1ad\x1bd\x1b3\x1c3\x1c;\x1c;\x1c\xdb\ +\x1b\xdb\x1bO\x1bO\x1b\x02\x1a\x02\x1a\x0b\x18\x0b\x18\xea\ +\x15\xea\x15v\x13v\x13\xe6\x10\xe6\x10\x1a\x0e\x1a\x0e\xb4\ +\x0a\xb4\x0a2\x072\x07\xdf\x03\xdf\x03E\x00E\x00\xd9\ +\xfc\xd9\xfc]\xf9]\xf9\x98\xf5\x98\xf5y\xf2y\xf2\xc6\ +\xef\xc6\xef\xde\xec\xde\xec\xb5\xea\xb5\xea\xfb\xe8\xfb\xe8\x22\ +\xe7\x22\xe7\x07\xe6\x07\xe6X\xe5X\xe5\xd0\xe4\xd0\xe4\xfb\ +\xe4\xfb\xe4\x81\xe5\x81\xe5>\xe6>\xe6\xcd\xe7\xcd\xe7\xb4\ +\xe9\xb4\xe9\xc6\xeb\xc6\xeb\x83\xee\x83\xee\x87\xf1\x87\xf1\x8f\ +\xf4\x8f\xf4\x17\xf8\x17\xf8f\xfbf\xfb\x83\xfe\x83\xfe\x14\ +\x02\x14\x02R\x05R\x05Z\x08Z\x08\xb6\x0b\xb6\x0b\xc3\ +\x0e\xc3\x0e\x91\x11\x91\x11n\x14n\x14j\x16j\x16\xe7\ +\x17\xe7\x17R\x19R\x19\x1d\x1a\x1d\x1a\xa3\x1a\xa3\x1a\xff\ +\x1a\xff\x1a\xa1\x1a\xa1\x1a\xff\x19\xff\x19\xf2\x18\xf2\x18\x03\ +\x17\x03\x17\xcb\x14\xcb\x14k\x12k\x12}\x0f}\x0fW\ +\x0cW\x0c4\x094\x09\xb1\x05\xb1\x05T\x02T\x02\xe2\ +\xfe\xe2\xfe!\xfb!\xfb\xdb\xf7\xdb\xf7\xcb\xf4\xcb\xf4\xa2\ +\xf1\xa2\xf1\xbb\xee\xbb\xee\x07\xec\x07\xec\xfc\xe9\xfc\xe9\xbe\ +\xe8\xbe\xe8\x8b\xe7\x8b\xe7{\xe6{\xe6\xed\xe5\xed\xe5\xb9\ +\xe5\xb9\xe5\xd8\xe5\xd8\xe5\xc7\xe6\xc7\xe6\xbc\xe7\xbc\xe7\xf8\ +\xe8\xf8\xe8\x02\xeb\x02\xebe\xede\xed\x19\xf0\x19\xf0\x19\ +\xf3\x19\xf3\xcc\xf5\xcc\xf5\xb4\xf8\xb4\xf8\xf5\xfb\xf5\xfb%\ +\xff%\xff\xa5\x02\xa5\x02=\x06=\x06\x89\x09\x89\x09\xeb\ +\x0c\xeb\x0cw\x10w\x10`\x13`\x13\xde\x15\xde\x15\xd7\ +\x17\xd7\x17A\x19A\x19^\x1a^\x1a,\x1b,\x1bo\ +\x1bo\x1bm\x1bm\x1b\x0f\x1b\x0f\x1b\x04\x1a\x04\x1aQ\ +\x18Q\x18\x1e\x16\x1e\x16l\x13l\x13k\x10k\x10\x0d\ +\x0d\x0d\x0ds\x09s\x09\x00\x06\x00\x06u\x02u\x02'\ +\xff'\xff\xcf\xfb\xcf\xfb\x19\xf8\x19\xf8\x9c\xf4\x9c\xf4\xb2\ +\xf1\xb2\xf1\xfe\xee\xfe\xeet\xect\xec\x8c\xea\x8c\xea\xec\ +\xe8\xec\xe8\xe8\xe7\xe8\xe7X\xe7X\xe7\xb4\xe6\xb4\xe6\x81\ +\xe6\x81\xe6\x10\xe7\x10\xe7\xdb\xe7\xdb\xe7\x03\xe9\x03\xe9p\ +\xeap\xeaB\xecB\xec\x87\xee\x87\xee\x15\xf1\x15\xf1\xb2\ +\xf3\xb2\xf3\xd4\xf6\xd4\xf6\xea\xf9\xea\xf9\xce\xfc\xce\xfc\xd9\ +\xff\xd9\xff\xb9\x02\xb9\x02\x88\x05\x88\x05~\x08~\x08y\ +\x0by\x0b:\x0e:\x0e\xdf\x10\xdf\x10\x1d\x13\x1d\x13\xdb\ +\x14\xdb\x140\x160\x16\xfa\x16\xfa\x16\x95\x17\x95\x17\xe3\ +\x17\xe3\x17\xd2\x17\xd2\x17\x82\x17\x82\x17\x12\x17\x12\x17\x22\ +\x16\x22\x16\xc1\x14\xc1\x14\xed\x12\xed\x12X\x10X\x10\x97\ +\x0d\x97\x0d\xbf\x0a\xbf\x0a\x96\x07\x96\x07[\x04[\x04\x22\ +\x01\x22\x01\x02\xfe\x02\xfe\x12\xfb\x12\xfb1\xf81\xf8I\ +\xf5I\xf5\xc2\xf2\xc2\xf2)\xf0)\xf0\xaf\xed\xaf\xed\xf4\ +\xeb\xf4\xeb\x9a\xea\x9a\xea\x8d\xe9\x8d\xe9\xc8\xe8\xc8\xe8d\ +\xe8d\xe8Q\xe8Q\xe8\xb9\xe8\xb9\xe8w\xe9w\xe9S\ +\xeaS\xea\xa9\xeb\xa9\xeb}\xed}\xed\xbe\xef\xbe\xefT\ +\xf2T\xf2\xe5\xf4\xe5\xf4\xaf\xf7\xaf\xf7\xf2\xfa\xf2\xfa#\ +\xfe#\xfe\xfc\x00\xfc\x00\xc0\x03\xc0\x03\x8c\x06\x8c\x06Y\ +\x09Y\x09\x02\x0c\x02\x0cP\x0eP\x0e\x97\x10\x97\x10\xcb\ +\x12\xcb\x12\xb3\x14\xb3\x14\xd6\x15\xd6\x15\xa2\x16\xa2\x164\ +\x174\x17h\x17h\x17\xef\x16\xef\x16\x15\x16\x15\x16\xfb\ +\x14\xfb\x14\xd1\x13\xd1\x13Y\x12Y\x12|\x10|\x10\x09\ +\x0e\x09\x0e\x5c\x0b\x5c\x0b\xe9\x08\xe9\x085\x065\x06^\ +\x03^\x03v\x00v\x00\xc6\xfd\xc6\xfd\x10\xfb\x10\xfbQ\ +\xf8Q\xf8\x9e\xf5\x9e\xf5U\xf3U\xf3K\xf1K\xf1/\ +\xef/\xefI\xedI\xed\xd4\xeb\xd4\xeb\xf2\xea\xf2\xea\x86\ +\xea\x86\xea%\xea%\xea\x19\xea\x19\xea\xa8\xea\xa8\xeag\ +\xebg\xeb\x81\xec\x81\xec\xe0\xed\xe0\xed\x8d\xef\x8d\xef\x8c\ +\xf1\x8c\xf1\xdc\xf3\xdc\xf3,\xf6,\xf6\x82\xf8\x82\xf8J\ +\xfbJ\xfb\x0a\xfe\x0a\xfe\xa4\x00\xa4\x00s\x03s\x03\x1e\ +\x06\x1e\x06\xae\x08\xae\x08T\x0bT\x0b\x81\x0d\x81\x0dc\ +\x0fc\x0f6\x116\x11\xb3\x12\xb3\x12\xc6\x13\xc6\x13\xaa\ +\x14\xaa\x14\x17\x15\x17\x150\x150\x150\x150\x15z\ +\x14z\x14\x93\x13\x93\x13\x8f\x12\x8f\x12\xfc\x10\xfc\x10\xc6\ +\x0e\xc6\x0eu\x0cu\x0c\xf1\x09\xf1\x09e\x07e\x07\xbe\ +\x04\xbe\x04\xbd\x01\xbd\x01\x17\xff\x17\xff\xd1\xfc\xd1\xfc7\ +\xfa7\xfaP\xf7P\xf7\x85\xf4\x85\xf4:\xf2:\xf2\xa2\ +\xf0\xa2\xf0/\xef/\xef\xc0\xed\xc0\xed\xb3\xec\xb3\xec8\ +\xec8\xec\xf9\xeb\xf9\xeb\xee\xeb\xee\xeb\xf6\xeb\xf6\xeb!\ +\xec!\xec\xbf\xec\xbf\xec\x8f\xed\x8f\xed\xb1\xee\xb1\xeea\ +\xf0a\xf0\x88\xf2\x88\xf2\xdb\xf4\xdb\xf4f\xf7f\xf7\xee\ +\xf9\xee\xf9\xbd\xfc\xbd\xfc\xbb\xff\xbb\xff\xaa\x02\xaa\x029\ +\x059\x05\xb9\x07\xb9\x07I\x0aI\x0a\xe7\x0c\xe7\x0c\x1a\ +\x0f\x1a\x0f\xef\x10\xef\x10\x8b\x12\x8b\x12\xd2\x13\xd2\x13\xb7\ +\x14\xb7\x14#\x15#\x15/\x15/\x15\xee\x14\xee\x14k\ +\x14k\x14\x9f\x13\x9f\x13x\x12x\x12\xf3\x10\xf3\x10E\ +\x0fE\x0f*\x0d*\x0d\xf1\x0a\xf1\x0a\x81\x08\x81\x08\xe2\ +\x05\xe2\x05\x0e\x03\x0e\x03=\x00=\x00\xae\xfd\xae\xfd-\ +\xfb-\xfb\xcf\xf8\xcf\xf8\x99\xf6\x99\xf6}\xf4}\xf4g\ +\xf2g\xf2\xa2\xf0\xa2\xf0\x1b\xef\x1b\xef\xcf\xed\xcf\xed\xe7\ +\xec\xe7\xec/\xec/\xec\xde\xeb\xde\xeb\xf8\xeb\xf8\xebM\ +\xecM\xec\x02\xed\x02\xed\x10\xee\x10\xee8\xef8\xef\x95\ +\xf0\x95\xf0B\xf2B\xf25\xf45\xf4f\xf6f\xf6\xc1\ +\xf8\xc1\xf8$\xfb$\xfb\xa8\xfd\xa8\xfdc\x00c\x00 \ +\x03 \x03\xb2\x05\xb2\x05,\x08,\x08\x84\x0a\x84\x0a\xa8\ +\x0c\xa8\x0c\xbb\x0e\xbb\x0ey\x10y\x10\xee\x11\xee\x11\x01\ +\x13\x01\x13\xa8\x13\xa8\x13\xee\x13\xee\x13$\x14$\x14\xf3\ +\x13\xf3\x13Z\x13Z\x13s\x12s\x12%\x11%\x11\x81\ +\x0f\x81\x0f\xa8\x0d\xa8\x0d\x9c\x0b\x9c\x0bs\x09s\x090\ +\x070\x07\x9d\x04\x9d\x04\x0d\x02\x0d\x02\xbf\xff\xbf\xffQ\ +\xfdQ\xfd\xa5\xfa\xa5\xfa\x1c\xf8\x1c\xf8\xb7\xf5\xb7\xf5\xc5\ +\xf3\xc5\xf3(\xf2(\xf2\xa7\xf0\xa7\xf0f\xeff\xefu\ +\xeeu\xee\xc2\xed\xc2\xedH\xedH\xed3\xed3\xed5\ +\xed5\xed~\xed~\xed\x18\xee\x18\xee\xe7\xee\xe7\xee(\ +\xf0(\xf0\xd1\xf1\xd1\xf1\xb1\xf3\xb1\xf3\xc2\xf5\xc2\xf5\xe4\ +\xf7\xe4\xf7\x22\xfa\x22\xfa\xb9\xfc\xb9\xfct\xfft\xff\xf8\ +\x01\xf8\x01L\x04L\x04\xab\x06\xab\x06$\x09$\x09\x97\ +\x0b\x97\x0b\x8e\x0d\x8e\x0d>\x0f>\x0f\xa5\x10\xa5\x10\xdd\ +\x11\xdd\x11\xc2\x12\xc2\x12A\x13A\x13Y\x13Y\x13U\ +\x13U\x13\x03\x13\x03\x13^\x12^\x129\x119\x11\xe1\ +\x0f\xe1\x0f;\x0e;\x0ev\x0cv\x0c_\x0a_\x0a\xff\ +\x07\xff\x07\x8d\x05\x8d\x05\x01\x03\x01\x03}\x00}\x00+\ +\xfe+\xfe\xc9\xfb\xc9\xfb\x8c\xf9\x8c\xf9\x85\xf7\x85\xf7\x81\ +\xf5\x81\xf5\xa6\xf3\xa6\xf3\x03\xf2\x03\xf2\x83\xf0\x83\xf0G\ +\xefG\xefX\xeeX\xee\x9e\xed\x9e\xedQ\xedQ\xedF\ +\xedF\xed\x85\xed\x85\xed<\xee<\xee4\xef4\xef1\ +\xf01\xf0s\xf1s\xf1\xfa\xf2\xfa\xf2\xcd\xf4\xcd\xf4\xed\ +\xf6\xed\xf6\x07\xf9\x07\xf91\xfb1\xfb\x9e\xfd\x9e\xfd1\ +\x001\x00\xac\x02\xac\x02\x02\x05\x02\x059\x079\x07O\ +\x09O\x09y\x0by\x0bl\x0dl\x0d'\x0f'\x0f\xad\ +\x10\xad\x10\xc4\x11\xc4\x11c\x12c\x12\xcb\x12\xcb\x12\xf6\ +\x12\xf6\x12\xb6\x12\xb6\x12(\x12(\x12$\x11$\x11\xd8\ +\x0f\xd8\x0f~\x0e~\x0e\xf9\x0c\xf9\x0c<\x0b<\x0b`\ +\x09`\x093\x073\x07\xe7\x04\xe7\x04\xa2\x02\xa2\x02A\ +\x00A\x00\xdc\xfd\xdc\xfdq\xfbq\xfb\x18\xf9\x18\xf9\xf7\ +\xf6\xf7\xf6\x11\xf5\x11\xf5G\xf3G\xf3\xc9\xf1\xc9\xf1\x96\ +\xf0\x96\xf0\x8a\xef\x8a\xef\xb4\xee\xb4\xeeE\xeeE\xee2\ +\xee2\xee`\xee`\xee\xd3\xee\xd3\xee{\xef{\xefi\ +\xf0i\xf0\xaa\xf1\xaa\xf1\x05\xf3\x05\xf3\xa5\xf4\xa5\xf4e\ +\xf6e\xf6#\xf8#\xf8=\xfa=\xfa\xa0\xfc\xa0\xfc\xff\ +\xfe\xff\xfe;\x01;\x01q\x03q\x03\xa5\x05\xa5\x05\x10\ +\x08\x10\x086\x0a6\x0a\xde\x0b\xde\x0bV\x0dV\x0d\xa6\ +\x0e\xa6\x0e\xc5\x0f\xc5\x0f\xb7\x10\xb7\x10L\x11L\x11\xab\ +\x11\xab\x11\xd1\x11\xd1\x11\xae\x11\xae\x11\x17\x11\x17\x11*\ +\x10*\x10\x0a\x0f\x0a\x0f\x88\x0d\x88\x0d\xe7\x0b\xe7\x0b\xc4\ +\x09\xc4\x09\x94\x07\x94\x07d\x05d\x05 \x03 \x03\xe7\ +\x00\xe7\x00\xc8\xfe\xc8\xfe\x86\xfc\x86\xfc}\xfa}\xfa\xa4\ +\xf8\xa4\xf8\xb5\xf6\xb5\xf6\xfa\xf4\xfa\xf4\x5c\xf3\x5c\xf3\xf5\ +\xf1\xf5\xf1\xec\xf0\xec\xf0\x0a\xf0\x0a\xf0R\xefR\xef\xf6\ +\xee\xf6\xee\xc0\xee\xc0\xee\x06\xef\x06\xef\x9a\xef\x9a\xef`\ +\xf0`\xf0S\xf1S\xf1\x9d\xf2\x9d\xf2\x10\xf4\x10\xf4\xb4\ +\xf5\xb4\xf5\xaa\xf7\xaa\xf7\xa4\xf9\xa4\xf9\xa2\xfb\xa2\xfb\xbf\ +\xfd\xbf\xfd\xd8\xff\xd8\xff\xe1\x01\xe1\x01\xf4\x03\xf4\x03\xcd\ +\x05\xcd\x05\x93\x07\x93\x07Y\x09Y\x09\x05\x0b\x05\x0b\x89\ +\x0c\x89\x0c\xf1\x0d\xf1\x0d\xd5\x0e\xd5\x0e\x87\x0f\x87\x0f\x19\ +\x10\x19\x10D\x10D\x10-\x10-\x10\xed\x0f\xed\x0fK\ +\x0fK\x0fm\x0em\x0e\x92\x0d\x92\x0d~\x0c~\x0c*\ +\x0b*\x0b\x80\x09\x80\x09\x88\x07\x88\x07\x8d\x05\x8d\x05\x93\ +\x03\x93\x03s\x01s\x01w\xffw\xffe\xfde\xfdG\ +\xfbG\xfb`\xf9`\xf9\x9a\xf7\x9a\xf7\xdd\xf5\xdd\xf5c\ +\xf4c\xf4@\xf3@\xf3\x08\xf2\x08\xf2\x14\xf1\x14\xf1}\ +\xf0}\xf0P\xf0P\xf0j\xf0j\xf0\x8a\xf0\x8a\xf0\xd8\ +\xf0\xd8\xf0\x93\xf1\x93\xf1\x96\xf2\x96\xf2\x97\xf3\x97\xf3\xc5\ +\xf4\xc5\xf4\x01\xf6\x01\xf6\x81\xf7\x81\xf7N\xf9N\xf9\x0c\ +\xfb\x0c\xfb\xf5\xfc\xf5\xfc$\xff$\xffZ\x01Z\x01q\ +\x03q\x03^\x05^\x051\x071\x07\x0a\x09\x0a\x09\xb7\ +\x0a\xb7\x0a\x09\x0c\x09\x0c3\x0d3\x0d@\x0e@\x0e@\ +\x0f@\x0f\xd2\x0f\xd2\x0f \x10 \x10\xf2\x0f\xf2\x0f\x9a\ +\x0f\x9a\x0f2\x0f2\x0f}\x0e}\x0eo\x0do\x0d(\ +\x0c(\x0c\x8f\x0a\x8f\x0a\xf3\x08\xf3\x08>\x07>\x07E\ +\x05E\x053\x033\x03\x18\x01\x18\x01\x11\xff\x11\xff\xf3\ +\xfc\xf3\xfc\xd3\xfa\xd3\xfa\xc3\xf8\xc3\xf8\xfd\xf6\xfd\xf6Z\ +\xf5Z\xf5\xee\xf3\xee\xf3\xeb\xf2\xeb\xf2\x19\xf2\x19\xf2Q\ +\xf1Q\xf1\xfe\xf0\xfe\xf0\xfb\xf0\xfb\xf0*\xf1*\xf1\x8b\ +\xf1\x8b\xf1\x10\xf2\x10\xf2\xb5\xf2\xb5\xf2\x8e\xf3\x8e\xf3s\ +\xf4s\xf4\x86\xf5\x86\xf5\xe3\xf6\xe3\xf6\x81\xf8\x81\xf85\ +\xfa5\xfa\xe6\xfb\xe6\xfb\xb5\xfd\xb5\xfd\xbd\xff\xbd\xff\xe2\ +\x01\xe2\x01\xc5\x03\xc5\x03r\x05r\x05,\x07,\x07\x0a\ +\x09\x0a\x09\x9c\x0a\x9c\x0a\xd4\x0b\xd4\x0b\xd6\x0c\xd6\x0c\xb3\ +\x0d\xb3\x0dv\x0ev\x0e\xf7\x0e\xf7\x0e\x1d\x0f\x1d\x0f\xea\ +\x0e\xea\x0e{\x0e{\x0e\xcb\x0d\xcb\x0d\xda\x0c\xda\x0c\xa7\ +\x0b\xa7\x0bE\x0aE\x0a\xc4\x08\xc4\x08+\x07+\x07u\ +\x05u\x05\xc0\x03\xc0\x03\xec\x01\xec\x01-\x00-\x00s\ +\xfes\xfe\xab\xfc\xab\xfc\xfe\xfa\xfe\xfaw\xf9w\xf9\xe8\ +\xf7\xe8\xf7\xa0\xf6\xa0\xf6v\xf5v\xf5\x80\xf4\x80\xf4\xaa\ +\xf3\xaa\xf3\x18\xf3\x18\xf3\xb1\xf2\xb1\xf2w\xf2w\xf2\x9e\ +\xf2\x9e\xf2\xe7\xf2\xe7\xf2\x5c\xf3\x5c\xf3%\xf4%\xf4\xfe\ +\xf4\xfe\xf4\xdf\xf5\xdf\xf5\x0c\xf7\x0c\xf7;\xf8;\xf8\x97\ +\xf9\x97\xf9\x22\xfb\x22\xfb\xb8\xfc\xb8\xfc\x5c\xfe\x5c\xfe5\ +\x005\x00\x0a\x02\x0a\x02\xcd\x03\xcd\x03\x7f\x05\x7f\x05\x01\ +\x07\x01\x07e\x08e\x08\xae\x09\xae\x09\xab\x0a\xab\x0at\ +\x0bt\x0b<\x0c<\x0c\xc3\x0c\xc3\x0c\x15\x0d\x15\x0d2\ +\x0d2\x0d\x0d\x0d\x0d\x0d\xcd\x0c\xcd\x0cc\x0cc\x0c\x91\ +\x0b\x91\x0by\x0ay\x0a7\x097\x09\xe3\x07\xe3\x07w\ +\x06w\x06\xdc\x04\xdc\x04\x14\x03\x14\x03\x5c\x01\x5c\x01\xd4\ +\xff\xd4\xff4\xfe4\xfe\x91\xfc\x91\xfc\x06\xfb\x06\xfb\x8d\ +\xf9\x8d\xf9\x1b\xf8\x1b\xf8\xdd\xf6\xdd\xf6\xcd\xf5\xcd\xf5\xe1\ +\xf4\xe1\xf41\xf41\xf4\xa1\xf3\xa1\xf3O\xf3O\xf3K\ +\xf3K\xf3x\xf3x\xf3\xd5\xf3\xd5\xf3U\xf4U\xf4\x01\ +\xf5\x01\xf5\xda\xf5\xda\xf5\xf7\xf6\xf7\xf6%\xf8%\xf8[\ +\xf9[\xf9\xc3\xfa\xc3\xfa?\xfc?\xfc\xdc\xfd\xdc\xfd}\ +\xff}\xff\xfe\x00\xfe\x00\x8e\x02\x8e\x02\x22\x04\x22\x04\xb7\ +\x05\xb7\x05\x1a\x07\x1a\x07e\x08e\x08\x8e\x09\x8e\x09\x93\ +\x0a\x93\x0ai\x0bi\x0b\xfd\x0b\xfd\x0be\x0ce\x0c\x9d\ +\x0c\x9d\x0c\x8d\x0c\x8d\x0c=\x0c=\x0c\xce\x0b\xce\x0b1\ +\x0b1\x0bc\x0ac\x0a`\x09`\x09\x16\x08\x16\x08\xc7\ +\x06\xc7\x06p\x05p\x05\xed\x03\xed\x03G\x02G\x02\xa8\ +\x00\xa8\x00#\xff#\xff\x9a\xfd\x9a\xfd\x11\xfc\x11\xfc\x88\ +\xfa\x88\xfa\x17\xf9\x17\xf9\xd6\xf7\xd6\xf7\xc8\xf6\xc8\xf6\xe0\ +\xf5\xe0\xf5\x06\xf5\x06\xf5~\xf4~\xf4\x15\xf4\x15\xf4\x02\ +\xf4\x02\xf4\x19\xf4\x19\xf4C\xf4C\xf4\x9e\xf4\x9e\xf4?\ +\xf5?\xf5\xeb\xf5\xeb\xf5\xb9\xf6\xb9\xf6\xac\xf7\xac\xf7\xbc\ +\xf8\xbc\xf8\x15\xfa\x15\xfa\x83\xfb\x83\xfb\xed\xfc\xed\xfc\x7f\ +\xfe\x7f\xfe\x0c\x00\x0c\x00\x9f\x01\x9f\x019\x039\x03\xb8\ +\x04\xb8\x04\x1f\x06\x1f\x06k\x07k\x07\xa8\x08\xa8\x08\xa3\ +\x09\xa3\x09\x81\x0a\x81\x0a8\x0b8\x0b\xa1\x0b\xa1\x0b\xf4\ +\x0b\xf4\x0b\x19\x0c\x19\x0c\xf9\x0b\xf9\x0b\xce\x0b\xce\x0bd\ +\x0bd\x0b\x98\x0a\x98\x0a\xb4\x09\xb4\x09\xa5\x08\xa5\x08e\ +\x07e\x07\x11\x06\x11\x06\x97\x04\x97\x04\x01\x03\x01\x03\x81\ +\x01\x81\x01\x0b\x00\x0b\x00\x89\xfe\x89\xfe\x15\xfd\x15\xfd\xaa\ +\xfb\xaa\xfb<\xfa<\xfa\xe4\xf8\xe4\xf8\xae\xf7\xae\xf7\xa7\ +\xf6\xa7\xf6\xe7\xf5\xe7\xf52\xf52\xf5\xb4\xf4\xb4\xf4q\ +\xf4q\xf4k\xf4k\xf4\x81\xf4\x81\xf4\xc6\xf4\xc6\xf4'\ +\xf5'\xf5\xbb\xf5\xbb\xf5\x80\xf6\x80\xf6}\xf7}\xf7s\ +\xf8s\xf8\x97\xf9\x97\xf9\xec\xfa\xec\xfac\xfcc\xfc\xed\ +\xfd\xed\xfdm\xffm\xff\xdb\x00\xdb\x00Y\x02Y\x02\xdb\ +\x03\xdb\x035\x055\x05p\x06p\x06\xaf\x07\xaf\x07\xc9\ +\x08\xc9\x08\xc6\x09\xc6\x09\x8d\x0a\x8d\x0a\x11\x0b\x11\x0b\x7f\ +\x0b\x7f\x0b\xc4\x0b\xc4\x0b\xae\x0b\xae\x0bg\x0bg\x0b\xfb\ +\x0a\xfb\x0aq\x0aq\x0a\xc1\x09\xc1\x09\xc2\x08\xc2\x08\x9c\ +\x07\x9c\x07k\x06k\x06\x1b\x05\x1b\x05\xb3\x03\xb3\x032\ +\x022\x02\xa1\x00\xa1\x00G\xffG\xff\xd6\xfd\xd6\xfde\ +\xfce\xfc\xfa\xfa\xfa\xfa\x9a\xf9\x9a\xf9j\xf8j\xf8i\ +\xf7i\xf7u\xf6u\xf6\xb2\xf5\xb2\xf5/\xf5/\xf5\xf9\ +\xf4\xf9\xf4\xdf\xf4\xdf\xf4\xe2\xf4\xe2\xf4\x03\xf5\x03\xf5\x5c\ +\xf5\x5c\xf5\xe0\xf5\xe0\xf5w\xf6w\xf63\xf73\xf7!\ +\xf8!\xf81\xf91\xf9\x80\xfa\x80\xfa\xcf\xfb\xcf\xfb:\ +\xfd:\xfd\xa1\xfe\xa1\xfe\xf8\xff\xf8\xffV\x01V\x01\xce\ +\x02\xce\x02:\x04:\x04x\x05x\x05\xc0\x06\xc0\x06\xf8\ +\x07\xf8\x07\xf6\x08\xf6\x08\xdc\x09\xdc\x09c\x0ac\x0a\xcd\ +\x0a\xcd\x0a0\x0b0\x0bN\x0bN\x0b=\x0b=\x0b\x09\ +\x0b\x09\x0b\x91\x0a\x91\x0a\xe7\x09\xe7\x091\x091\x09\x1e\ +\x08\x1e\x08\xf1\x06\xf1\x06\xb4\x05\xb4\x05W\x04W\x04\xf9\ +\x02\xf9\x02\x99\x01\x99\x013\x003\x00\xec\xfe\xec\xfe\x93\ +\xfd\x93\xfd*\xfc*\xfc\xe2\xfa\xe2\xfa\xa6\xf9\xa6\xf9}\ +\xf8}\xf8\x97\xf7\x97\xf7\xe1\xf6\xe1\xf6?\xf6?\xf6\xe7\ +\xf5\xe7\xf5\xb1\xf5\xb1\xf5\x89\xf5\x89\xf5\x96\xf5\x96\xf5\xc6\ +\xf5\xc6\xf5\x07\xf6\x07\xf6f\xf6f\xf6\xe8\xf6\xe8\xf6\x95\ +\xf7\x95\xf7q\xf8q\xf8w\xf9w\xf9\xab\xfa\xab\xfa\x03\ +\xfc\x03\xfc{\xfd{\xfd\xf8\xfe\xf8\xfeV\x00V\x00\xb3\ +\x01\xb3\x01\x07\x03\x07\x03k\x04k\x04\xa2\x05\xa2\x05\xb6\ +\x06\xb6\x06\xce\x07\xce\x07\xc8\x08\xc8\x08\x94\x09\x94\x09>\ +\x0a>\x0a\xd1\x0a\xd1\x0a\x11\x0b\x11\x0b\x18\x0b\x18\x0b\xe2\ +\x0a\xe2\x0a\x86\x0a\x86\x0a\xfe\x09\xfe\x096\x096\x09M\ +\x08M\x08X\x07X\x07Y\x06Y\x06%\x05%\x05\xf6\ +\x03\xf6\x03\x93\x02\x93\x02\x1b\x01\x1b\x01\xce\xff\xce\xff{\ +\xfe{\xfe\x0d\xfd\x0d\xfd\xae\xfb\xae\xfbM\xfaM\xfa\x0f\ +\xf9\x0f\xf9\x04\xf8\x04\xf8,\xf7,\xf7z\xf6z\xf6\x16\ +\xf6\x16\xf6\xa8\xf5\xa8\xf5e\xf5e\xf5U\xf5U\xf5q\ +\xf5q\xf5\xd9\xf5\xd9\xf5c\xf6c\xf6\xfa\xf6\xfa\xf6\xcd\ +\xf7\xcd\xf7\xdb\xf8\xdb\xf8\xd4\xf9\xd4\xf9\xfc\xfa\xfc\xfa\x1a\ +\xfc\x1a\xfc8\xfd8\xfdx\xfex\xfe\xb8\xff\xb8\xff\xea\ +\x00\xea\x00K\x02K\x02\xa1\x03\xa1\x03\xdc\x04\xdc\x04\x09\ +\x06\x09\x06\x0a\x07\x0a\x07\xd6\x07\xd6\x07\x8e\x08\x8e\x082\ +\x092\x09\xa4\x09\xa4\x09\x0a\x0a\x0a\x0aS\x0aS\x0ak\ +\x0ak\x0aK\x0aK\x0a\xf1\x09\xf1\x09}\x09}\x09\xe4\ +\x08\xe4\x08\xf6\x07\xf6\x07\xda\x06\xda\x06\x95\x05\x95\x05Y\ +\x04Y\x048\x038\x03\x0a\x02\x0a\x02\xbd\x00\xbd\x00}\ +\xff}\xff=\xfe=\xfe\xf2\xfc\xf2\xfc\xac\xfb\xac\xfbZ\ +\xfaZ\xfa?\xf9?\xf9?\xf8?\xf8\x8e\xf7\x8e\xf7\xde\ +\xf6\xde\xf6k\xf6k\xf60\xf60\xf6\x08\xf6\x08\xf6\xf9\ +\xf5\xf9\xf5\x1a\xf6\x1a\xf6Z\xf6Z\xf6\xdd\xf6\xdd\xf6\x96\ +\xf7\x96\xf7H\xf8H\xf8\x1d\xf9\x1d\xf9&\xfa&\xfa]\ +\xfb]\xfb\x84\xfc\x84\xfc\xa0\xfd\xa0\xfd\xdc\xfe\xdc\xfe\x1c\ +\x00\x1c\x00S\x01S\x01\x85\x02\x85\x02\xa1\x03\xa1\x03\xbb\ +\x04\xbb\x04\xdc\x05\xdc\x05\xd1\x06\xd1\x06\xb2\x07\xb2\x07]\ +\x08]\x08\xf6\x08\xf6\x08h\x09h\x09\x9c\x09\x9c\x09\xb1\ +\x09\xb1\x09\xa8\x09\xa8\x09x\x09x\x090\x090\x09\xa8\ +\x08\xa8\x08\x02\x08\x02\x08N\x07N\x07S\x06S\x06,\ +\x05,\x05\x0e\x04\x0e\x04\xd2\x02\xd2\x02\x94\x01\x94\x01Q\ +\x00Q\x00\x0f\xff\x0f\xff\xe7\xfd\xe7\xfd\xc3\xfc\xc3\xfc\xaa\ +\xfb\xaa\xfb\xa9\xfa\xa9\xfa\xbc\xf9\xbc\xf9\xe3\xf8\xe3\xf89\ +\xf89\xf8\xa9\xf7\xa9\xf7(\xf7(\xf7\xdc\xf6\xdc\xf6\xc1\ +\xf6\xc1\xf6\xbc\xf6\xbc\xf6\xf8\xf6\xf8\xf6W\xf7W\xf7\xc9\ +\xf7\xc9\xf7]\xf8]\xf8\x08\xf9\x08\xf9\xc7\xf9\xc7\xf9\xb8\ +\xfa\xb8\xfa\xae\xfb\xae\xfb\xba\xfc\xba\xfc\xdc\xfd\xdc\xfd\xf6\ +\xfe\xf6\xfe&\x00&\x00`\x01`\x01o\x02o\x02x\ +\x03x\x03\x96\x04\x96\x04\xa0\x05\xa0\x05\x8f\x06\x8f\x06J\ +\x07J\x07\xda\x07\xda\x07U\x08U\x08\xb6\x08\xb6\x08\xdd\ +\x08\xdd\x08\xf3\x08\xf3\x08\xe5\x08\xe5\x08\x9b\x08\x9b\x08C\ +\x08C\x08\xb6\x07\xb6\x07\x0f\x07\x0f\x07E\x06E\x06j\ +\x05j\x05j\x04j\x04I\x03I\x031\x021\x02\x06\ +\x01\x06\x01\xeb\xff\xeb\xff\xd0\xfe\xd0\xfe\xb5\xfd\xb5\xfd\xb0\ +\xfc\xb0\xfc\xbf\xfb\xbf\xfb\xbb\xfa\xbb\xfa\xf3\xf9\xf3\xf9Q\ +\xf9Q\xf9\xc0\xf8\xc0\xf8Z\xf8Z\xf8\x17\xf8\x17\xf8\xe8\ +\xf7\xe8\xf7\xe5\xf7\xe5\xf7\x08\xf8\x08\xf8\x1d\xf8\x1d\xf8[\ +\xf8[\xf8\xb5\xf8\xb5\xf88\xf98\xf9\xd4\xf9\xd4\xf9\x87\ +\xfa\x87\xfa8\xfb8\xfb0\xfc0\xfc!\xfd!\xfd\x0f\ +\xfe\x0f\xfe\x09\xff\x09\xff\x14\x00\x14\x00&\x01&\x01<\ +\x02<\x021\x031\x03\x0b\x04\x0b\x04\xf6\x04\xf6\x04\xc3\ +\x05\xc3\x05b\x06b\x06\xfa\x06\xfa\x06a\x07a\x07\xc4\ +\x07\xc4\x07\x0b\x08\x0b\x08\x0b\x08\x0b\x08\xf4\x07\xf4\x07\xd6\ +\x07\xd6\x07\x8d\x07\x8d\x07%\x07%\x07\x91\x06\x91\x06\xe5\ +\x05\xe5\x05-\x05-\x05H\x04H\x04]\x03]\x03V\ +\x02V\x02R\x01R\x01S\x00S\x00V\xffV\xffd\ +\xfed\xfe[\xfd[\xfdW\xfcW\xfc\x81\xfb\x81\xfb\xae\ +\xfa\xae\xfa\x01\xfa\x01\xfa}\xf9}\xf9\xfc\xf8\xfc\xf8\x98\ +\xf8\x98\xf8e\xf8e\xf82\xf82\xf8,\xf8,\xf8K\ +\xf8K\xf8\x90\xf8\x90\xf8\xef\xf8\xef\xf8i\xf9i\xf9\xf5\ +\xf9\xf5\xf9\x98\xfa\x98\xfaj\xfbj\xfbD\xfcD\xfc(\ +\xfd(\xfd\x13\xfe\x13\xfe\x07\xff\x07\xff\x0c\x00\x0c\x00\x09\ +\x01\x09\x01\xfc\x01\xfc\x01\xcc\x02\xcc\x02\xbd\x03\xbd\x03\xa0\ +\x04\xa0\x04y\x05y\x05\x1f\x06\x1f\x06\xa9\x06\xa9\x06+\ +\x07+\x07\x8c\x07\x8c\x07\xc7\x07\xc7\x07\xd1\x07\xd1\x07\xc0\ +\x07\xc0\x07\x89\x07\x89\x07C\x07C\x07\xcc\x06\xcc\x067\ +\x067\x06\x8b\x05\x8b\x05\xce\x04\xce\x04\xf9\x03\xf9\x03\xfe\ +\x02\xfe\x02\x00\x02\x00\x02\x04\x01\x04\x01\x15\x00\x15\x00*\ +\xff*\xff6\xfe6\xfeY\xfdY\xfd\x86\xfc\x86\xfc\xae\ +\xfb\xae\xfb\xf5\xfa\xf5\xfaU\xfaU\xfa\xc9\xf9\xc9\xf9c\ +\xf9c\xf9\x0e\xf9\x0e\xf9\xdb\xf8\xdb\xf8\xc2\xf8\xc2\xf8\xdf\ +\xf8\xdf\xf8\x00\xf9\x00\xf9=\xf9=\xf9\x95\xf9\x95\xf9\xf8\ +\xf9\xf8\xf9\x82\xfa\x82\xfa\x22\xfb\x22\xfb\xb6\xfb\xb6\xfbp\ +\xfcp\xfcU\xfdU\xfd2\xfe2\xfe\x14\xff\x14\xff\xfa\ +\xff\xfa\xff\xe1\x00\xe1\x00\xdb\x01\xdb\x01\xbf\x02\xbf\x02\x86\ +\x03\x86\x03F\x04F\x04\xff\x04\xff\x04\x90\x05\x90\x05\xfc\ +\x05\xfc\x05a\x06a\x06\xb1\x06\xb1\x06\xf2\x06\xf2\x06\x05\ +\x07\x05\x07\xe8\x06\xe8\x06\xc5\x06\xc5\x06\x8f\x06\x8f\x06/\ +\x06/\x06\xc1\x05\xc1\x05$\x05$\x05\x90\x04\x90\x04\xd3\ +\x03\xd3\x03\x0b\x03\x0b\x03?\x02?\x02]\x01]\x01v\ +\x00v\x00\x97\xff\x97\xff\xc9\xfe\xc9\xfe\xec\xfd\xec\xfd\x0b\ +\xfd\x0b\xfd=\xfc=\xfc\x8b\xfb\x8b\xfb\xe1\xfa\xe1\xfap\ +\xfap\xfa\x09\xfa\x09\xfa\xa6\xf9\xa6\xf9n\xf9n\xf9G\ +\xf9G\xf9D\xf9D\xf9P\xf9P\xf9v\xf9v\xf9\xc6\ +\xf9\xc6\xf96\xfa6\xfa\xb2\xfa\xb2\xfa0\xfb0\xfb\xe2\ +\xfb\xe2\xfb\xab\xfc\xab\xfcm\xfdm\xfd8\xfe8\xfe\xf6\ +\xfe\xf6\xfe\xd8\xff\xd8\xff\xbb\x00\xbb\x00\x98\x01\x98\x01M\ +\x02M\x02\x0c\x03\x0c\x03\xcc\x03\xcc\x03\x87\x04\x87\x04#\ +\x05#\x05\x92\x05\x92\x05\x02\x06\x02\x06\x5c\x06\x5c\x06\x9f\ +\x06\x9f\x06\xad\x06\xad\x06\xa5\x06\xa5\x06\x82\x06\x82\x06M\ +\x06M\x06\xfe\x05\xfe\x05\x7f\x05\x7f\x05\xe5\x04\xe5\x04S\ +\x04S\x04\x97\x03\x97\x03\xc4\x02\xc4\x02\xe4\x01\xe4\x01\x04\ +\x01\x04\x01;\x00;\x00\x94\xff\x94\xff\xca\xfe\xca\xfe\x17\ +\xfe\x17\xfek\xfdk\xfd\xbf\xfc\xbf\xfc)\xfc)\xfc\xa0\ +\xfb\xa0\xfb\x08\xfb\x08\xfb\x9a\xfa\x9a\xfaE\xfaE\xfa\xef\ +\xf9\xef\xf9\xc0\xf9\xc0\xf9\xb7\xf9\xb7\xf9\xbc\xf9\xbc\xf9\xf1\ +\xf9\xf1\xf9B\xfaB\xfa\x9a\xfa\x9a\xfa\x0e\xfb\x0e\xfb\x98\ +\xfb\x98\xfb*\xfc*\xfc\xc0\xfc\xc0\xfcy\xfdy\xfdF\ +\xfeF\xfe\x12\xff\x12\xff\xe0\xff\xe0\xff\x9a\x00\x9a\x00h\ +\x01h\x01*\x02*\x02\xda\x02\xda\x02\x8e\x03\x8e\x03\x17\ +\x04\x17\x04\x8f\x04\x8f\x04\x04\x05\x04\x05W\x05W\x05\xad\ +\x05\xad\x05\xda\x05\xda\x05\xea\x05\xea\x05\xe5\x05\xe5\x05\xc8\ +\x05\xc8\x05\x9f\x05\x9f\x05;\x05;\x05\xdc\x04\xdc\x04f\ +\x04f\x04\xdb\x03\xdb\x03J\x03J\x03\xa7\x02\xa7\x02\xf3\ +\x01\xf3\x01W\x01W\x01\x91\x00\x91\x00\xdc\xff\xdc\xff\x18\ +\xff\x18\xffG\xfeG\xfe\x8e\xfd\x8e\xfd\xe4\xfc\xe4\xfca\ +\xfca\xfc\xd1\xfb\xd1\xfbh\xfbh\xfb\x1c\xfb\x1c\xfb\xe2\ +\xfa\xe2\xfa\xad\xfa\xad\xfa~\xfa~\xfac\xfac\xfau\ +\xfau\xfa\x96\xfa\x96\xfa\xdc\xfa\xdc\xfa(\xfb(\xfb\xa1\ +\xfb\xa1\xfb.\xfc.\xfc\xbc\xfc\xbc\xfcK\xfdK\xfd\xfa\ +\xfd\xfa\xfd\x94\xfe\x94\xfe \xff \xff\xc7\xff\xc7\xffW\ +\x00W\x00\x10\x01\x10\x01\xbd\x01\xbd\x01`\x02`\x02\xfc\ +\x02\xfc\x02\x86\x03\x86\x03\x0f\x04\x0f\x04{\x04{\x04\xd3\ +\x04\xd3\x04\x10\x05\x10\x056\x056\x05M\x05M\x05F\ +\x05F\x052\x052\x05\x13\x05\x13\x05\xc1\x04\xc1\x04t\ +\x04t\x04\xfc\x03\xfc\x03\x7f\x03\x7f\x03\x0d\x03\x0d\x03n\ +\x02n\x02\xc4\x01\xc4\x01%\x01%\x01o\x00o\x00\xd7\ +\xff\xd7\xffE\xffE\xff\xa2\xfe\xa2\xfe\xfd\xfd\xfd\xfdr\ +\xfdr\xfd\xed\xfc\xed\xfci\xfci\xfc\x09\xfc\x09\xfc\xa6\ +\xfb\xa6\xfb`\xfb`\xfb%\xfb%\xfb\x07\xfb\x07\xfb\xf7\ +\xfa\xf7\xfa\x01\xfb\x01\xfb\x16\xfb\x16\xfbI\xfbI\xfb\xa2\ +\xfb\xa2\xfb\x05\xfc\x05\xfc}\xfc}\xfc\xed\xfc\xed\xfcj\ +\xfdj\xfd\xf3\xfd\xf3\xfdw\xfew\xfe\x18\xff\x18\xff\xc4\ +\xff\xc4\xff\x5c\x00\x5c\x00\xf9\x00\xf9\x00\x91\x01\x91\x01\x12\ +\x02\x12\x02\x9d\x02\x9d\x02\x15\x03\x15\x03\x86\x03\x86\x03\xec\ +\x03\xec\x03/\x04/\x04f\x04f\x04\x91\x04\x91\x04\xa5\ +\x04\xa5\x04\xa8\x04\xa8\x04\x95\x04\x95\x04u\x04u\x04L\ +\x04L\x04\x08\x04\x08\x04\x9a\x03\x9a\x03+\x03+\x03\xa6\ +\x02\xa6\x02\x0c\x02\x0c\x02y\x01y\x01\xd9\x00\xd9\x009\ +\x009\x00\xab\xff\xab\xff\x13\xff\x13\xff\x8b\xfe\x8b\xfe\x0c\ +\xfe\x0c\xfe\x89\xfd\x89\xfd\x1d\xfd\x1d\xfd\xbb\xfc\xbb\xfc\x86\ +\xfc\x86\xfc9\xfc9\xfc\x04\xfc\x04\xfc\xc0\xfb\xc0\xfb\x96\ +\xfb\x96\xfb\xa3\xfb\xa3\xfb\xaa\xfb\xaa\xfb\xc8\xfb\xc8\xfb\xf9\ +\xfb\xf9\xfb=\xfc=\xfc\xa9\xfc\xa9\xfc\x12\xfd\x12\xfdv\ +\xfdv\xfd\xf1\xfd\xf1\xfdz\xfez\xfe\xfd\xfe\xfd\xfe\x91\ +\xff\x91\xff\x08\x00\x08\x00\x84\x00\x84\x00\x12\x01\x12\x01\x8b\ +\x01\x8b\x01\xf7\x01\xf7\x01o\x02o\x02\xd3\x02\xd3\x025\ +\x035\x03\x8d\x03\x8d\x03\xc7\x03\xc7\x03\xe9\x03\xe9\x03\x09\ +\x04\x09\x04\x13\x04\x13\x04\xf6\x03\xf6\x03\xe0\x03\xe0\x03\xc0\ +\x03\xc0\x03\x88\x03\x88\x03A\x03A\x03\xe8\x02\xe8\x02\x87\ +\x02\x87\x02\x1f\x02\x1f\x02\xaa\x01\xaa\x01<\x01<\x01\xbf\ +\x00\xbf\x00/\x00/\x00\xce\xff\xce\xffD\xffD\xff\xc0\ +\xfe\xc0\xfeF\xfeF\xfe\xd5\xfd\xd5\xfdy\xfdy\xfd/\ +\xfd/\xfd\xdc\xfc\xdc\xfc\xa7\xfc\xa7\xfcm\xfcm\xfcZ\ +\xfcZ\xfcK\xfcK\xfc7\xfc7\xfcJ\xfcJ\xfcu\ +\xfcu\xfc\xa9\xfc\xa9\xfc\xe3\xfc\xe3\xfc\x0d\xfd\x0d\xfdn\ +\xfdn\xfd\xd5\xfd\xd5\xfd?\xfe?\xfe\x9a\xfe\x9a\xfe\x12\ +\xff\x12\xff\x80\xff\x80\xff\xe7\xff\xe7\xffU\x00U\x00\xc7\ +\x00\xc7\x006\x016\x01\x95\x01\x95\x01\x01\x02\x01\x02`\ +\x02`\x02\xb8\x02\xb8\x02\x08\x03\x08\x03@\x03@\x03e\ +\x03e\x03v\x03v\x03\x81\x03\x81\x03\x7f\x03\x7f\x03\x84\ +\x03\x84\x03y\x03y\x03F\x03F\x03\x01\x03\x01\x03\xc2\ +\x02\xc2\x02j\x02j\x02\x17\x02\x17\x02\xaf\x01\xaf\x019\ +\x019\x01\xba\x00\xba\x006\x006\x00\xc8\xff\xc8\xffS\ +\xffS\xff\xe4\xfe\xe4\xfel\xfel\xfe\x08\xfe\x08\xfe\xb0\ +\xfd\xb0\xfde\xfde\xfd\x0a\xfd\x0a\xfd\xc8\xfc\xc8\xfc\x99\ +\xfc\x99\xfcz\xfcz\xfcb\xfcb\xfcY\xfcY\xfc]\ +\xfc]\xfc\x84\xfc\x84\xfc\xbf\xfc\xbf\xfc\xf9\xfc\xf9\xfc=\ +\xfd=\xfd\x9c\xfd\x9c\xfd\xf9\xfd\xf9\xfdi\xfei\xfe\xcf\ +\xfe\xcf\xfe@\xff@\xff\xbb\xff\xbb\xff(\x00(\x00\x98\ +\x00\x98\x00\x10\x01\x10\x01\x85\x01\x85\x01\xf0\x01\xf0\x01K\ +\x02K\x02\xa1\x02\xa1\x02\xe9\x02\xe9\x02$\x03$\x03H\ +\x03H\x03^\x03^\x03y\x03y\x03t\x03t\x03m\ +\x03m\x03]\x03]\x030\x030\x03\xf9\x02\xf9\x02\xb9\ +\x02\xb9\x02^\x02^\x02\x0e\x02\x0e\x02\xa4\x01\xa4\x01=\ +\x01=\x01\xc9\x00\xc9\x00I\x00I\x00\xe6\xff\xe6\xff\x82\ +\xff\x82\xff\x1e\xff\x1e\xff\xa3\xfe\xa3\xfe7\xfe7\xfe\xea\ +\xfd\xea\xfd\x94\xfd\x94\xfdO\xfdO\xfd\x0b\xfd\x0b\xfd\xe3\ +\xfc\xe3\xfc\xd0\xfc\xd0\xfc\xad\xfc\xad\xfc\xab\xfc\xab\xfc\xb6\ +\xfc\xb6\xfc\xd9\xfc\xd9\xfc\x0e\xfd\x0e\xfd4\xfd4\xfdp\ +\xfdp\xfd\xc8\xfd\xc8\xfd\x1a\xfe\x1a\xfew\xfew\xfe\xc6\ +\xfe\xc6\xfe.\xff.\xff\x8e\xff\x8e\xff\xe8\xff\xe8\xff;\ +\x00;\x00\x9a\x00\x9a\x00\xf3\x00\xf3\x00R\x01R\x01\xab\ +\x01\xab\x01\xff\x01\xff\x01H\x02H\x02\x84\x02\x84\x02\xb9\ +\x02\xb9\x02\xe7\x02\xe7\x02\xfb\x02\xfb\x02\xfd\x02\xfd\x02\x01\ +\x03\x01\x03\x08\x03\x08\x03\xf6\x02\xf6\x02\xc9\x02\xc9\x02\xa1\ +\x02\xa1\x02c\x02c\x02*\x02*\x02\xdf\x01\xdf\x01\x86\ +\x01\x86\x01(\x01(\x01\xb6\x00\xb6\x00R\x00R\x00\xf8\ +\xff\xf8\xff\x99\xff\x99\xff3\xff3\xff\xd7\xfe\xd7\xfer\ +\xfer\xfe!\xfe!\xfe\xce\xfd\xce\xfd\x82\xfd\x82\xfdL\ +\xfdL\xfd(\xfd(\xfd\xff\xfc\xff\xfc\xeb\xfc\xeb\xfc\xda\ +\xfc\xda\xfc\xdb\xfc\xdb\xfc\x05\xfd\x05\xfd)\xfd)\xfdP\ +\xfdP\xfd\x91\xfd\x91\xfd\xd3\xfd\xd3\xfd \xfe \xfe|\ +\xfe|\xfe\xd0\xfe\xd0\xfe8\xff8\xff\x9a\xff\x9a\xff\xf7\ +\xff\xf7\xffU\x00U\x00\xbb\x00\xbb\x00&\x01&\x01}\ +\x01}\x01\xd2\x01\xd2\x01\x1e\x02\x1e\x02^\x02^\x02\x9c\ +\x02\x9c\x02\xb7\x02\xb7\x02\xda\x02\xda\x02\xf1\x02\xf1\x02\xf9\ +\x02\xf9\x02\x00\x03\x00\x03\xf0\x02\xf0\x02\xcf\x02\xcf\x02\xa2\ +\x02\xa2\x02o\x02o\x02)\x02)\x02\xdb\x01\xdb\x01\x87\ +\x01\x87\x012\x012\x01\xc9\x00\xc9\x00f\x00f\x00\x0a\ +\x00\x0a\x00\xc6\xff\xc6\xff`\xff`\xff\xf8\xfe\xf8\xfe\x99\ +\xfe\x99\xfe@\xfe@\xfe\xe5\xfd\xe5\xfd\x9e\xfd\x9e\xfdf\ +\xfdf\xfdC\xfdC\xfd$\xfd$\xfd\x15\xfd\x15\xfd\x17\ +\xfd\x17\xfd!\xfd!\xfdC\xfdC\xfdc\xfdc\xfd\x95\ +\xfd\x95\xfd\xca\xfd\xca\xfd\x16\xfe\x16\xfe\x5c\xfe\x5c\xfe\xa1\ +\xfe\xa1\xfe\xeb\xfe\xeb\xfeH\xffH\xff\x93\xff\x93\xff\xec\ +\xff\xec\xff6\x006\x00\x87\x00\x87\x00\xcf\x00\xcf\x00)\ +\x01)\x01o\x01o\x01\xb4\x01\xb4\x01\xf6\x01\xf6\x01%\ +\x02%\x02Y\x02Y\x02\x82\x02\x82\x02\x93\x02\x93\x02\x95\ +\x02\x95\x02\x98\x02\x98\x02\x94\x02\x94\x02s\x02s\x02`\ +\x02`\x021\x021\x02\x06\x02\x06\x02\xce\x01\xce\x01\x84\ +\x01\x84\x01E\x01E\x01\xf7\x00\xf7\x00\xab\x00\xab\x00U\ +\x00U\x00\xff\xff\xff\xff\xc7\xff\xc7\xffk\xffk\xff\x0a\ +\xff\x0a\xff\xb1\xfe\xb1\xfeh\xfeh\xfe,\xfe,\xfe\xfa\ +\xfd\xfa\xfd\xc6\xfd\xc6\xfd\xa7\xfd\xa7\xfd\x90\xfd\x90\xfd\x87\ +\xfd\x87\xfdu\xfdu\xfdt\xfdt\xfd\x87\xfd\x87\xfd\xa3\ +\xfd\xa3\xfd\xc9\xfd\xc9\xfd\xf5\xfd\xf5\xfd,\xfe,\xfeh\ +\xfeh\xfe\xa0\xfe\xa0\xfe\xee\xfe\xee\xfeL\xffL\xff\x9b\ +\xff\x9b\xff\xe8\xff\xe8\xff/\x00/\x00x\x00x\x00\xba\ +\x00\xba\x00\x05\x01\x05\x01_\x01_\x01\x9e\x01\x9e\x01\xce\ +\x01\xce\x01\xf8\x01\xf8\x01\x19\x02\x19\x02#\x02#\x02:\ +\x02:\x02<\x02<\x02<\x02<\x02+\x02+\x02\x13\ +\x02\x13\x02\xf9\x01\xf9\x01\xc9\x01\xc9\x01\xa5\x01\xa5\x01d\ +\x01d\x010\x010\x01\xef\x00\xef\x00\xb2\x00\xb2\x00l\ +\x00l\x00!\x00!\x00\xf4\xff\xf4\xff\xac\xff\xac\xff]\ +\xff]\xff\x15\xff\x15\xff\xd8\xfe\xd8\xfe\x9c\xfe\x9c\xfeu\ +\xfeu\xfe>\xfe>\xfe\x1a\xfe\x1a\xfe\xfb\xfd\xfb\xfd\xf3\ +\xfd\xf3\xfd\xf4\xfd\xf4\xfd\xef\xfd\xef\xfd\xf6\xfd\xf6\xfd\xff\ +\xfd\xff\xfd\x0e\xfe\x0e\xfe1\xfe1\xfed\xfed\xfe\x91\ +\xfe\x91\xfe\xba\xfe\xba\xfe\xfb\xfe\xfb\xfe8\xff8\xff\x80\ +\xff\x80\xff\xbe\xff\xbe\xff\xf6\xff\xf6\xff4\x004\x00\x80\ +\x00\x80\x00\xb8\x00\xb8\x00\xf4\x00\xf4\x00:\x01:\x01i\ +\x01i\x01\x94\x01\x94\x01\xbd\x01\xbd\x01\xd1\x01\xd1\x01\xf3\ +\x01\xf3\x01\xf8\x01\xf8\x01\x03\x02\x03\x02\x02\x02\x02\x02\xef\ +\x01\xef\x01\xd8\x01\xd8\x01\xbb\x01\xbb\x01\x9b\x01\x9b\x01i\ +\x01i\x01K\x01K\x01\x16\x01\x16\x01\xcd\x00\xcd\x00\x8e\ +\x00\x8e\x00N\x00N\x00\x01\x00\x01\x00\xd7\xff\xd7\xff\x94\ +\xff\x94\xffd\xffd\xff(\xff(\xff\xe3\xfe\xe3\xfe\xab\ +\xfe\xab\xfe\x89\xfe\x89\xfee\xfee\xfeC\xfeC\xfe)\ +\xfe)\xfe\x14\xfe\x14\xfe\x0f\xfe\x0f\xfe\x03\xfe\x03\xfe\x0c\ +\xfe\x0c\xfe.\xfe.\xfeL\xfeL\xfeu\xfeu\xfe\x9e\ +\xfe\x9e\xfe\xc0\xfe\xc0\xfe\xf6\xfe\xf6\xfe2\xff2\xfft\ +\xfft\xff\xaa\xff\xaa\xff\xe2\xff\xe2\xff\x14\x00\x14\x00U\ +\x00U\x00\x93\x00\x93\x00\xd3\x00\xd3\x00\x05\x01\x05\x013\ +\x013\x01X\x01X\x01|\x01|\x01\xa5\x01\xa5\x01\xaf\ +\x01\xaf\x01\xc5\x01\xc5\x01\xc9\x01\xc9\x01\xc7\x01\xc7\x01\xc8\ +\x01\xc8\x01\xad\x01\xad\x01\x9b\x01\x9b\x01\x90\x01\x90\x01X\ +\x01X\x019\x019\x01\xfa\x00\xfa\x00\xc4\x00\xc4\x00\x91\ +\x00\x91\x00V\x00V\x00\x1a\x00\x1a\x00\xf1\xff\xf1\xff\xc0\ +\xff\xc0\xff{\xff{\xffD\xffD\xff\x11\xff\x11\xff\xde\ +\xfe\xde\xfe\xbf\xfe\xbf\xfe\xa2\xfe\xa2\xfex\xfex\xfef\ +\xfef\xfeK\xfeK\xfeN\xfeN\xfe<\xfe<\xfeA\ +\xfeA\xfeS\xfeS\xfee\xfee\xfez\xfez\xfe\x8f\ +\xfe\x8f\xfe\xb5\xfe\xb5\xfe\xe5\xfe\xe5\xfe\x18\xff\x18\xffM\ +\xffM\xff\x86\xff\x86\xff\xc1\xff\xc1\xff\xf5\xff\xf5\xff\x1c\ +\x00\x1c\x00X\x00X\x00\x8e\x00\x8e\x00\xc4\x00\xc4\x00\xf1\ +\x00\xf1\x00#\x01#\x01I\x01I\x01m\x01m\x01\x89\ +\x01\x89\x01\x9e\x01\x9e\x01\xb0\x01\xb0\x01\xc4\x01\xc4\x01\xc1\ +\x01\xc1\x01\xc8\x01\xc8\x01\xbf\x01\xbf\x01\xa8\x01\xa8\x01\x8f\ +\x01\x8f\x01r\x01r\x01E\x01E\x01!\x01!\x01\x01\ +\x01\x01\x01\xc6\x00\xc6\x00\x81\x00\x81\x00Q\x00Q\x00\x12\ +\x00\x12\x00\xe0\xff\xe0\xff\xac\xff\xac\xffu\xffu\xffI\ +\xffI\xff\x0b\xff\x0b\xff\xd4\xfe\xd4\xfe\xa6\xfe\xa6\xfe\x84\ +\xfe\x84\xfeY\xfeY\xfe:\xfe:\xfe&\xfe&\xfe\x1a\ +\xfe\x1a\xfe\x0d\xfe\x0d\xfe\x18\xfe\x18\xfe'\xfe'\xfeF\ +\xfeF\xfej\xfej\xfe\x8d\xfe\x8d\xfe\ +\x00\x00\x0e\xfe\ +\x00\ +\x00:Vx\xda\xe5\x1b\xfbo\x1bE\xfaw$\xfe\x87\ +!\x15J\xd2\xb3\x9d\xf5\xfa\x91\xc4&H\xe9\x8b\x22\xb5\ +\x1c!\x1c\xe8\x84P\xb4\xb67\xf6^\xd7\xbb\xbe\xddu\ +\xd3\xb4\x8a\x94pm\xd3\x94>8(}\xa4)}P\ +\x8e\x80\xa0-UES\xf7\xf5\xcfx\xed\xe4'\xfe\x85\ +\xfbff\xdf;\xbb\xb6{\x9c\xd0\xe9\xbc\xb8$\xb33\ +\xdf|\xef\xf9\x1e\x93\xb1\xdd\xc8<\xb5i\xfe\xb2l\xfe\ +|\xa5\xfd\xfc+\xf3\xcbs\xe6\xcdV\xf7\xf6r\xf7\xc7\ ++\xe6\xc5o;\xb7\x1e\x9b\xcf.\xa2\x91wd\xb5$\ +\xc8\xe8\x80\xaa\x18HP*h\x8f\xa0\x8b\xe8cI\xa9\ +\xa8\x0bh\xd6X\x94E}\x14\xed\x1e{\xfd\xb5\xdd\xe8\ +\xc4\xeb\xaf!\xf8\xcc\xc3\xcc\xe4\xbcP\x97\xe4\xc5\x02\x1a\ +\x9e\x15\xab\xaa\x88\xfe\xf2\xeep\x02\x0d\x1f\x96\xca\x9a\xaa\ +\xab\xf3\x06\xfa\xabpP\x94\xe8\xe8\xb4&\x09r\x02\xe9\ +\x82\xa2'uQ\x93\xe6\x8b\x14\x8c\xda4dI\x11\x0b\ +HQ\x15\xb1\x88\xc6v\xa3\xee\xf7Ow\xae\xdd\xed\x9e\ +\xfcW\xf7\xb3'\x9d+\xbfv\xd7On_[\xef\xb6\ +^vn\x9fF#\x1f\x88u\xf5\xa8\x88*\xaaa\x88\ +\x15\xc0\xa1\xdc\xd4m\x10\x14?\x075]:\x0eP\xd3\ +\x99\x86Q\xf4\x8c.\x88R\xb5f\x14P\x96\xe3`x\ +\xe9\xf5\xd7\xe83sX\x90\x14Jm\x02\xcd\xec\x03T\ +\xd5\xaaMhI(\x1f\xa9jjS\xa9$\xcb\xaa\xac\ +j\x05\xb4\x8b/\xe3\xc7\x060\xf3\xb1T\xa9\x8aF?\ +\xf3\xf1{{p\x9e\xc3\x8f\x03\xe4\x1dX\xd2\xd8\xa3\x1e\ +s\xc0\xa8ZE\x84y\xe9\xc61\xa4\xab\xb2TA\xbb\ +r\xb9\x5c\xd1\xfb2\xa9\x09\x15\xa9\xa9\x17P\xbeq\xcc\ +zQ\x17\xb4\xaa\xa4$\x0d\xb5\x01+y\x18\xc6\x1cm\ +o\xb5:\xb7Vw\xee\x5c\xed~}\xcd\x5cmu\x7f\ +h\xed\x5cy\x84Ff\x1bBY\x04\xb6h\xc8\x90\x0c\ +\xd9\xc3\xbf\x86P\xa9HJ5\x00\xa5{\xfb^\xfb\xe5\ +\x0d\xf3\xf4)\xf3\xde\x13sc\x93Blo}\xde\xb9\ +\xfc\x04\x8d\xecW\xf4\xa6&\x02m\x8a!\x82\x06I:\ +*\x892\xa8N\x00r\x987\x86\x06\x1a\xd1\x104X\ +EY\xe10\xa2P \x8bm~\xe8\xcd\x12\x86\xae\xa9\ +rR\xd5$ \xb2`\x11[\x0c\xbdo\xa8\xbadH\ +*\xcc\x00\x0aP\x19@\x8bZ\xd1GZ\x01qh\xc2\ +a\x9a-\x11A\x10X\xbaRR\xe5\x8a-&\xe0C\ +\xe7\xe2\x17\xdd\xbb\xad\xce\xe5U\xb0'\xf3\x5c\xcb\xbcy\ +\x13X\xd1\xfd\xf9\x05J\xc2\xab\x8b\xdb/\x1f\xd0A4\ +\xb2sgc{\xed!\xb6\xb7\xd3\xa7\xb6_<\xd9~\ +|\x832bV\x94\xc5\xb2!\x94d\xf1\x90\x00\x5c\xfa\ +\xa4,\x0b\xba>5\xd4\xd0\xd4z\xc3H\xcaxl\xe8\ +S\x9blG[\xc8\x87\x0a\xa2u\xbf{\xed9\x80\xfe\ +\xed\xd9\xb9\xf6\xd6\x85\xed\x17_\x99\xa7\xbe\x03\xeb\xa0(\ +\xb5\xb7\x96\xb7W\x1f\x05eY@\xbcC.\x80p,\ +\x04KJ\x019\xe9\xa0\xbf\x95E\x05\xcc\xb9,\xc8\xf2\ +\x22Yn\x13|X\xd0\x8e\x80a(\xe6\xa9\x7ft\x1f\ +\xdd\xb2\x1c\x06\x10\xfb\xf8\x17\xc0\xa1\xbd\xf5\x14\xd4\x80E\ +\x17\xaa\xa5\x13(4\xc63\xc62\x09\xc6\xe2,cb\ +\x8e1\x96\x0fpJRj\xe0^\x0c\x9f=\x80\xb8S\ +\xbcXG\x5c/\xf1\x86I\xf0\xb9;\xcb\xa7\xa4\xb2b\ +=r\x05\xcf\x5c\xc1\xc7\xac\xc80W\xa4\xa3W\xe8\xa0\ +\xe5J5\xcc\x8b\x92\x0fP\x80>&\x8bl\x11\xb7\x9f\ +~\xdb\xbd\xb5\xd2K\xc0\xceD\xaa\xd9\xa0\x95\xd9\xe9I\ +n?\x8fF\xb6\xbf\xba\x01\x03T\xbd\xc9<\xac\xf2+\ +\x97\xe8l\x07`\xf7\xde\xb2y\xee\xb2\xf9\xe4W\xf3Y\ +\x0b~ z\xbf\xb3\xbc\xd6\xf9\xfc\x07\xf3\xda&S\x85\ +Re\xb5\x22&%\x05\xfb\xf7\xa0EX{\xbf!\xd5\ +\x1b\xaaf\x08\x8a-\xf2\xb0\x8f\xd1\xaa%a$\xcf%\ +\x90\xfd\xe5R\xd9\xd1\xf0B\xaf\xa9\xa0,|\xc3\xa0\xfd\ +n7\xc3\x9a\xe3?\x17\xf7\xaa\x0a8oA'\xe7\xa2\ +\xaa\x08e\x15\xff\xb4Wmj\x92\xa8\xa1\xf7\xc4\x05\xf8\ +\xb5\xae*\xaaN|\xb1\x0f\x98-\x9b\x9d\xe5\xf5\xee\xa5\ +M\x86\x84X\x0c\xc3\xfc\xfa\x7fe\x94\xc5\xa2\x1bWb\ +\xf8C\x15\xaa$\xab\xe5#\x0c\xa7\xd3\xd0\xfaf\xde\xef\ +\x86\xbd\xcfK\xc5\xf0\x9a\x8b\x13 \x0d\x9f\xa2\xe4\x10\xf5\ +\xb6\x22\xe9\x0dYX\xc4\x0e\x81\x18X\ +\xdd\xa3\x0b\x03\x88\xe1d\xd3\x9e\x17Q\xc9B]R\x92\ +\x0bR\xc5\xa8\xe1\x1a\x00\xe7\xce\xc7\xe35kz\x86V\ +\x1e\x96|4\x15j\xeaQ8\x08O\xb0\x88\xca\x92O\ +\x11\x05V\xc0)\xa2\xeb\xc0e\xe6\x9a\x0c\xf9\x84\xd6@\ +\xf4\x82\xc5\x17\xb1\x08\xfb;G\x8b'''\xc9r\x1f\ +\x80]z\xb3T\x97\x8c\xb9R/A\xf09\xfc\xf4#\ +\x08\xdeu\xb9\x99i\xfc\x04\x0f8\x5cLA^f\x86\ +\x99\x8f\x98\x82L\xe7\xd8\x02\xc8s\x0c\x01\xf8)\x8b\x13\ +G\x86\xc3O\x91E@6\x87\x9f \xd7\x03\xa0c\xe5\ +\xc6s\xf8a\x02\xcf\xe4\xf0\xc3\x92\x89\x086U\x11\xb4\ +Ek\x07\x88\xee\xb7\xc0\xb9\xc7\xb0\xff\x98\x8d\xdf\ +\x86\xe1%\xad\xcd\xd2\xe9\xc9\x84\xfd%\xf5\xa9 $\xf0\ +>Ir\xaf\xd1\x86\x95@\xde\xd7z\xb3\xe4\x7f\x0d\x9b\ +\xd8\xd8\xb2\xe8\xc7\xe0\x1aX\xd9\xa2\xc1\xf9^\x07pv\ +\xc4C\xfc\xc4U\x18br\xd4\xac\x0b:\x90Y.\xf5\ +M0\x9c#\x06\xbe8\xd0/\xd9\xee|\x06\xf1\xf4e\ +\xe0\x04\xa2:\xce\xa4\xcaaR\xde\xe7\xf7m]\x19\x88\ +\x04;\x9f\x1c\x8c\x10wU$9\xbd\xcaV\xbe\xc3\x00\ +\x94\xf0=\xd5\xd2Lw*I80,\xddV\xd7\xc1\ +H\xa3\x15\x04\xdc\xa0\x18\x90:\xef\xc2h\x02\xedY}\ +\xd2\xe8?0)\xc5N\x8d\xc3K\xf5+\xd1j\xe5\xec\ +\x03\x12\xea\xac\x8a\xa6\xd2J\xfa_\x99D\xba>L^\ +O\x02\x15\x91V\xb4z\x91D\xe7\x85\x1c\x88\x9b\x0f\xc4\ +\xe4J\x83u\x05q\xf7:\xc6q\xdb\xee\x94\xdc\x0b\xd0\ +!\xc01j\x9e\x96Too\x8a\x97\xf5$\x97uS\ +\x83^^\x0ft\xc9\xe8\x0dz\x99^gRP\x0cP\ +\xf7>N\xcc\x9e\xbb\xe8o\x1fb\xa8\x16\x12X\xc0\xd3\ +\xba\xde\xac\xe3\xeb\x12\x8bj\x93Tw<\xfd6_\xad\ +\x9d\xd5\xe8f\xd4\xf5\xbcu\xf9l\xc3\xca\x8a\xfaE\xee\ + \xc42\x03\xe2\x16\xc8G\xc3H\xa4\x1b\xde2\xadS\ +\x84\xb2\x84\xfb\x1fp\xd5\xadg\xc5\xd5_\x03Y\xefR\ +\x7f0\xa3s\xdc\xf4\x847\xc7\xa5\xbf\xc5\x95\xc2\xd9\x97\ +\x05\x8b\xbdR\xae\x01\x91\xec\xa3v\x15\xccZ\xa2r\xc1\ +\xff^\xed)=\xe1\xd6\x9e\xf0\xcf\xff\xab\xb5'Dk\ +8\xd4P,=fEd\xf4\xe0\x1d!\x7f$\x84\x8b\ +\xb8\xd6\x95a\x9a\x0d\xb0\x160\x9d\x93'\xb4\xa2\xfb\xce\ +6d\xc9\x00T\xed\xc2\xd1\x883`\xff\xadY\x12u\ +\x1f\xbe\xd8^}\xe4\xbd\xf7\xebL\x1a\xb0\xafbAZ\ +?i\x9e9m\xae=tZ8\xf4&\x9e\xd5\xd1\xfc\ +\xec'se#Ttr\xf6\xfc\xc4\xf5\x1bSCu\ +\xc8\x8d\xec\x17C\x9f\xdayc\xc23\xdd\x1e\x8bK\x8b\ +\xac\xabPV\xf9+\x16\x8d\xfen{\xf1\xac\xd4\xc1{\ +.\x0dB\x0d\x84n\x9at\x1c\xe7\x812\x8b0\xcf\xeb\ +\x80w\xc9\xb3z\xf3yV\xc34\xff\x8a\x989I0\ +\x0b/7\x05\xa6\xfb97\xa5\x98\x15\xc3<\xb37\x9b\ +\x7fe\x96\xe1\x80\x98\xcd\xad\xd88\xd8\xf1l\xa4\x81A\ +t\x00\xff\xe9\xe3\xe6\xf9v\xeb\x9e\x1b+\x0d\x84\x8b}\ +/\x87\x85M\xcff\xae\x13\x1b\x11|\xce\xad\xe1?\xf3\ +#\xf8t\xd6\xafP|\xfe\x0d\xaeI\xc7\xa6\ +\x00\x00\x0e\x1b\ +\x00\ +\x008\xc5x\xda\xe5[{o\xdbF\x12\xff?@\xbe\ +\xc3\xd6Aa''\xd9\x94,\xcb\xb2\x5c\x07\x88\xd3'\ +\x90\x14u\xdd\x07\x0eEaP\xe4J\xe2\x85\x22U\x92\ +\x8a\xec\x04\x01\x9c^^n\xdd$m\xd3&q\xdc&\ +M\xd3\xab\xaf\xb8\xa6i\x91k\x1c;M\xbe\x8c(\xd9\ +\x7f\xf5+\xdc\xec.I\xf1\xb1\xa4$\xdf\x1d\x8a\xc3\x91\ +m\xaa,wggfg~;3\xbb\x1d;\x84\xca\ +\x18\xcb%Q:\xb1\xd0P\xc6LkI\xc5\xe6\x98\xaa\ +T\xaa\xd6\x82U\xc55<\xfa\x81i\xa2Cc\xfb\xf7\ +\xed\xdf7v\x08\xd9\xe77\xec\x9f\x97\xed\x1f\xaf\xb7~\ +\xfb\xdc\xfel\xd5\xbe\xbd\xd5\xf9f\xb9\xf3\xc3u\xfb\xca\ +\xb7\xed;\x8f\xec'W\xd0\xc8+\xaa^\x12U\xf4\xb2\ +\xaeYH\xd4d4+\x9a\x18\xbd\xabh\xb2\xdeD\xf3\ +\x94\xfcAJ\xef\x10:\xbd\x7f\x1f\x82\xa7\x0c=\xd3e\ +\xb1\xa6\xa8KE4<\x8f+:Fo\xbf6\x9cB\ +\xc3\xc7\x15\xc9\xd0M\xbdl\xa1?\x8b\xafb\x85\xb5\x1e\ +1\x14QM!S\xd4\xcc\xb4\x89\x0d\xa5<\xcd\xc8\xe8\ +\x0dKU4\x5cD\x9a\xae\xe1i\x04\xccv\xbe\xdf\xde\ +\xbdy\xafs\xeeo\x9d\x0f\x1f\xb7\xaf\xff\xdaY;\xb7\ +ss\xad\xb3\xf5\xac\xfd\xcd\x054\xf2&\xae\xe9'1\ +\x92u\xcb\xc22\xf0 5L\x97\x04\xe3\xcfc\xcdT\ +N\x01\xd5\xccx\xdd\x9a\xf6\xb561\xd1Q\x11\xe5\x04\ +\x01\x9a\xcf\x10\xfd\x90w\xee\xb8\xa8hL\xda\x14\x9a{\ +\x11X\xd5+\xae\xa0D\xc9\x15CohrZ\xd2U\ +\xdd(\xa2\x03e\x81\xbc.\x81\xb9w\x15\xb9\x82\xad~\ +\xfa\x93\xefnc\x86>\x1e\x91W`H}V_\xf4\ +\xc8\xe8\x86\x8c\xa1_\xa6\xbe\x88L]Udt@\x96\ +\xc8;\xed\xff\x9e6DYi\x98E\x94\xaf/:\x1f\ +j\xa2QQ\xb4\xb4\xa5\xd7ap\xd6k\xae\x8b\xb2\xac\ +h\x95H{\x94]\xcb\x80E\xaa\x8b\x06\xd6,\xc6\x9d\ +\xc7[\xb1h)\x96\x8a]\x16\xcdFI\x02\xa5\x1a\xba\ +\x9a\xd6\x0d\x05&-:\x93OG\xbe\xd7uS\xb1\x14\ +\x1dz\xc0\xfcH\x02\xd2\xd8\x082VD\x02*x\x5c\ +\xb9J\x9a\xa4\x0fo\x05K\xba*\xbb\xca\x03\xabi_\ +\xb9\xda\xb9\xb7\xd5\xfe\xf2\x22X\xb9\xbd\xbae\xdf\xbe\xdd\ +\xbes\xb1\xf3\xe3S\x94\x86OWv\x9e=`\x8dh\ +d\xf7\xee\xfa\xce\xca/\xc4\x0b.\x9c\xdfy\xfax\xe7\ +\xd1W\xccp\xe6\xb1\x8a%K,\xa9\xf8\x98X\xc2\xea\ +{\x92*\x9a\xe6\xccP\xdd\xd0ku+\xad\x92\xb6\xa1\ +\xf7]\xc9#k\x18\x10\xa4\xab\x5c`\xcc3F\xa4\x98\ +`\xe4Md\x82\xa9\xc8K\x1ax\x8e$\xaa\xea\x12\x9d\ +\xdc\x95\xe2\xb8h\x9c\x00\x1b\xd4\xec\xf3\x7f\xed<\xbc\xe3\ +\xf8&H\xf0\xcf\xf3\xc0sks{\xf7\xee\x0d\x1e\xb3\ +\xa8\x9aI\xa1H[\x96\xd36\x9e\xe2\x0c\xceq:N\ +p\xda\xf2!\xf1\x15\xad\x0a\x9el\x05\xec\x0e\x96q4\ +\x8bkH\xe8\xb5fQ\x11\x02\xc8\xe2\xb8\xefh\x0e\xd7\ +bGd\xb9#\xb2\x09#\xc6\xb9#2\xf1#L\xb0\ +^\xad\x12\xd5E)@($\x1fWE\x5c\xf2\xb8\x16\ +%\xad\x04y$\xd8\x0bt,QU\xa4D\xda`>\ +\xad\xedo;w\xce\xf62\x1e\xaf#s\x050\xe3\xdc\ +\x91)\xe1\xa5,\x1a\xd9\xf9\xfc+h8\xe8\xdb8\xc0\ +G\xce^c\xbd=\x82\x9d\xfb\xcb\xf6\xea\x97\xf6\xe3_\ +\xed'[\xf0\x83:\xca\xee\xf2J\xfb\xe3\xbf\xdb77\ +\xb8\xe69*\xe92N+\x1a\x81\xe9\xb0\x0b9s?\ +\xa7\xd4\xea\xbaa\x89\x9a\x15\x8bKF\xa5$\x8ed\xb3\ +B\x0au\xff\x10Fs\x07\xa3c\xfd\x9e\x88r\xf0o\ +\x94z\x10=\xc7y}\x82;\xdcQ]\x03\x18\x16M\ +\xba\xc3\xe9\x9a(\xe9\xe4\xd7Q\xbda(\xd8@\xaf\xe3\ +&\xfc\xb5\xa6k: \xa7\x84\x83\xc4\xdc\xe5\xd9]^\ +\xeb\x5c\xdb\xe0,\x12OgDe\xff\xc7\xbar\xb4\xf4\ +\xd5\xf5\x04\x151\xb3*\xa9\xbat\x82\x03ku\xa3o\ +\xfd\xfd\xc7\xb8\x0f\xe0`\x82\xae\x85\xa45d\xb1P\xdc\ +:\xc4}\x95\x15\xb3\xae\x8aK\x04\x16\xa8\x9b%k\x15\ +\x1c\x1b\x02+\x90\xc0\xd9j\xb8\xfa%\x1a$]\xfaQ\ +\xa3;\xc3\xdc[x\xd1zIV,\xe4G\x8d\xf6\xb5\ +\x9f\xda\xabgC`\xd4zr\x03\xe0\xa3\xb3r\xa9\xbd\ +\xfe\x0f:\x7fw,Y\xd8\x94\x8f\x16p\x92B/;\ +\x11o\xa8W\xa4\xf9\x8fXx\x16\xc3\xf5\xe2\xcc\xef\xd3\ +\x7f\xac\xcf\x86\xf8\xe5\xeb\xd7\xa7\xc8~\xb9\xcd&q[\ +\xe8\x83\xd3$iT\x5c\xb6\x98,N@\x1ck\x88\x91\ +\xed\x9a`\xc4\x07\x0d\xdd\xc2\xc1\xf0:JR\xa2O(\ +ff\xbd2B(\xc6&\xb1\xce\x84/\xd6\xe1\x86E\ +\xdc]\xbc\x8f\xa85,B\xe7\xe7m4b_\xf9\xc1\ +\xde\xbaF6\x91\xad\xcb\xfe\xb0\x96m\xd9s\xf3\x90x\ +\xa9\xea\x11\x03\x8b\x91\xe1\xfc\xc0\x95\x04\xa8\xedG?\xef\ +n\x7f\x0a^\xf9\xfb\x93\xd5\xd6\xe6\xe5\x9d\xa7\x9f\xdb\xe7\ +\xbf\x83d\x8bq\xd5\xda\x5c\xde\xb9\xf8\x90\x9bZ\xe5!\ +\xb5\xa2\x04~\xba\xe6\x0d\x02P!)W\xfb\xd6C\xfb\ +\xde\xf70\xb6\xb5u+\x10\xe0B\x9c\xb0\xfb\xcdc\xc6\ +4\xf4e\xd2\xba9(\x8bG\x12\xc3q\xbdN\xb2\x88\ +\xb8py\xc4z\ +\x0d2\xaf\xc0|}\xe4y`Ao4\xcc\xeal\xc3\ +\xb2t-!\x9d\xc5\x19\xf2N#\xbeI\xc5\xa6\xaf\xa2\ +L\xde\xf8\xf4\x15q\xe0\x22\xe3\xfb\x10\x17j\xd7 \xdb\ +m*\xb2U%y\xad\xd0\xedO\xda\xabN\xf7q\x96\ +\x1f\x9f\x09\x88X\xac\xea'\x01\xd8O\xf3dtp\x00\ +\x85F\x00$\x9a&\x96\xf9cJ\xf4\x89\x8c\x81\x9d\x99\ +\xac~\xcc '\xa9\xf7\xd48E\x1fJ$@\xe6\x00\ +d\xd35\xc5Z(\xf5Z\x9d2}\xa8\x13\x1c\x17-\ +\xa9\x8a\xac*F\x16`:\xc2\x04\xd4\xbb#<\xdbw\ +Gf3\xe4\xa5#_\x84D\x94\x0d*\xeb\x06\xa2Y\ +\xbchZ\xde\x08\xce\xeaJ\x02y\xe9\xe0#$\xf3'\ +u\x02\xd6\x0dR~$\xe32\x09J\x08+\x8e\x00.\ +)o\xb9IM\x02\xf9\xd7/n\xbdC\xa6\x93\x99\xe0\ +/y^\xe0,yP\x8b\x9e\x01\xc4+S$/\x95\ +\xea\x1dl,!\x93\xd6\xd8\x90T\x15\xb5\x0aF \x06\ +\xa3\x10\xd4K\xd7 \x04\xf2\xf6b\xa2kS\xf1lL\ +\x90\xd7Qn\x09VQ\x86\xf5\x81y\x9bU\xac!w\ +|\x0c\x13\xa2@^\x8e\x87\x1f01,,PZr\ +8\x81\x88\xcb\xf7U\x06\x84\xb1\xf0\x82$j\x1a\x96\x17\ +\x14\x0b\xd7\xfa\xb0\xbd)\xf2\x86\x91a\x9c>\x09\xc8\xc0\ +\xdbt\x8bh\x82\xf8\x7f\x9c?wk,~\xf7\x17\xfc\ +\xdd\xc5\xc5p\xf73\xc9\xf23s\xe8S\x0b\xae\xed\xc4\ +\xa3$}\xa6y\xf2:@\xd1\x9b!gi\xfbe\xc9\ +\xb3\xa4X\xa6d\xfaDgNX\xec\x04\xab\x94p\x01\ +\xe7\xbb\xd0%N\x08X\xe0K\x5c\x9e\x90\xf2R)\xba\ +b9g\xc1\xfae'\x09\xb2\xcb\x05yR\x16\xa7\xc3\ +\xf6_\xce\x80\x13N\x86Q\xb9\x1f-r'\xc9IY\ +i\x92\x03\xcfuE[h\xd2\x0a\xf2\x82\x08\x1b\xf3I\ +\x9c\xb4\x0a\x02y\x1dw\xd6tK\x910\xd9!\x084\ +\xa2\xaah@\x8e\xa1\xe8\x0d\x13U\x0c\xbc\x14\x01i\x81\ +>t\xec\xac\x0a\xa4\xbb(\xcdjU]\xb0\x86\xc5\x0b\ +P\xe0m\xc9\x0c\x1b\x18#\x0e\xa6\xd01\x0erG@\ +\xda\xf3\xc9\xc1\x5c\xb2_\x8f\x8c(1q\x8b.\x907\ +\xb2\xb0Q\x1a\xc9\x9bv\x81\xbc\xce\x82\x12\x98\xcf\x8d\x0a\ +\x88\xa5\xa6\xed\xd5\x95\xdd\xcf\xee\xb3\xe8\xb4s\xf9\x81\xbd\ +\xbe\xb1\xbb\xbc\xb6\xf3\xec\x22\x0b\x1d\xdb\xe7nwn\x7f\ +\x07\xe1%+\x92\xb1n\xf6G\x1b\xed\xe5\xb3,F\xef\ +\x96\xd1(\xcd\xf6\x97\x0f\xec\xbb_\xff\xfed\xcd\x0b\x96\ +Y\x12\xce\xe6\xb2oA\x18|\xd1?#\xaft\xe7\x97\ +\x93\x84\xc25\xe5\x94H\x82\xc0\x05\x05V\xbd7gjmny(\xd4\xda\xfc\x91d\ +\xe24\xdbo?\xda\xb6?\xbac\x9f_\xb1\xd7/\xb5\ +\xbf\xb8\xd4\xfez=X\x98+\x161W\xdf\xac\xdd\xd5\ +:\xd9\x91\xd2fU\x04\x08&\xa5N\x81\x96\xa9h\xbd\ +HH\x91\x14-\x85\xb2\x99\x09V\xdb\x9a\xa6\xbc\x13\x9c\ +\xa3G\x0f0s\xfb\xe6\x17\xbe\x999;_x\xf5\xdd\ +\xa8-\xc2\x93\xb7u\xc4\x06`t\xeb\xf0\xfa\xd3\xd3d\ +\x0e\x1dv\xca|:\x14\xbef\xb9\xa9\xcf\xdb&F\x22\ +\x22\x87\xdf)d**0\x90\xa6\xfb)\xd9\x9f\x19\x9d\ +h\xde\x93\xf1B\xa0\x9e%\x12\x9f\x07\xa1\xb9\xa3U,\ +\x9d\x98\xd5\x17S\xdd\x9f\x09\x05\x11V\x04\xf1\xa6'5\ +L7\xcb\xe6\x19W!|pL\xf5\xe7\xc0M\x98\x83\ +b\x11\xf6[03K7R\xdcV\x97/\x07\xcb\xb3\ +\xc47\x907\x17\xfd\xdb\x80\x05\x83\x1c\x1d\xc3qo\x99\ +\xbc\xbd\xf9,J\xa4\x8d\x86\xd6\x09_\x13p$?K\ +\xde\xf8*\xbd\xcf>\xd8!v\xb4\xabR\x13+\xfe\x9b\ +\x0fG\x8d\x86\xa4\x88*5\x96\xf9w^\xf1\xcfJ\xbb\ +\x92D\xba\xa9Cb\xeeEt\xa1\x0eE\xd40\xd4\x91\ +!Y\xb4\xc4\x22m\x183OV\xfe\xb4XSS/\ +\xc0\x0f\x04?4sf\xb8jY\xf5\xe2\xd8X\xb3\xd9\ +\x1cm\x8e\x8f\xeaFe,\x0b~E\xba\x0e\xb3\x05\x9a\ +\x19\xcef\x87\x9d\xe5a\xbfO*\xb8\x09\x1a\x9a\x19\xa6\ +\xde\x9c\x83\x7f\x86\x0f\xbfP\x17\xad**+\xaa:3\ +\xfc|v\x9c\xe1\xea0\x92g\x86\x8fOAh0\x9a\ +\x99<\x96\x1b-\x8c\x83\xc3\xab\xe9\xcch.K\x0es\ +3\xc7\xe0\xd3\x14\xb8?\x9a\xa4\x8d\x99\xb4\xdbH\xfa\x9f\ +\x1a\x1e;\xfc\x02\xe1\xe3\xf0\xd0\xc1\xe8\xc9V\xf7\x0a\x81\ +s}\xc0\xff\xd1\xc0u,ZD\x97\xce\xcf>,\x80\ +\xe2C\xf1\xb9dC\x08vJ\xb8\x9c\xe1\xdcTH(\ +\x22\xf4m\x93^\x1e\xda\xb3O\x82}F\x8d.\x9c\x18\ +\x15\xe8\x13s\x8c\xf3&\xf8\x99\xeeT\x02\x13\xe2Q\x7f\ +\xb7x\xecim^\xb6\xef}B\xce\x88\xdd\xb22+\ +\xf5\x86\x8a\xcb\xff\x16&\xf98\x19\x08x\xf6T\xaed\ +\x98\xedE\xbe<\x87\xe4\x02\x12\x9f\xc9>\xd0\xc6\xa3\x12\ +\x031\x0c\x8d\xba\xd1@\xeb\xb7g\x90\x81\xd8O\xcf\xd9\ +W?mo^\xb5\xaf\xdc\xb0/\xdd\xb2\xb7\xb7\x80_\ +r\xad*\xb8\xc1Fa\x84\xc8)\xaa\xb0u\xc1\x7f\xc1\ +\xd9F$\xc5\x90T\x9c\xf2Po\x5cx>\xe5\x0f\xc9\ + \xde~\xfe`\xb2\x8c\x83:R\xaf\x92\x5c?\x0a\x0d\ +9I\xd0\xfc\xe3\xe6\xe9\xa5\x02\xc7\xb1\x12T\xe0\xdc)\ +{\xd9\x10k\xf8\xbd2\xf9s\xbe*\xd6\xf1\xccPn\ +\xe8}\xb28\xaf\x1e#\xb5\xd0Cca\x8fqK\xc1\ +\xfeD\x86\x1a\x9a{\xdcD\x02M\xef\xb4)\xa1\x96\xec\ +?)v\x14\xe5;\x1a:\xdd#\xbc\x0e\x0eG\xdd\xd1\ +\xb3\xa2Q\x04}Z$\xa0\x0cP\x09\x8dw\x9c\xad\xe0\ +g\x9d\x86\xc8AR\xc5\xaa\xa8\xc9*\x8e#\xe9\x04\x8c\ +\xf4\x1d\xcdL@\xa8\x18\x00\x03\xa1\x1b4\x04\x83\x82\xe4\ +I8\x05\x85\xd0TY2U\x88\x08\xc0M\x9a^S\ +t\xc9\x008\xfb>\x9b\x8dR\xf03\xd0w\x19\xe5\x09\ +N\xc8\xd5\x89\x95\xc5\x93\x0b|\x0e\xb1\xeb\xad\xcb\xfe}\ +\x91\xc4\xe70\xea}\x1d\x11Oa\x09\x97\x83\xc8\xc6\xae\ +\x05\xf6w\xd7\x90\x04\x01\xd4\x14}\x95 \xff\xa9\x8d\xeb\ +\x05\xaf\x11Oz\xc3\xc0$z\xd8\x0bS}3\x94\x8b\ +\x1c\xccz\xe9\x7f\x94\x07\x1e(\x043ka\xb2 \xe7\ +|\x9b\xa1s\x1f\xd4\xaccI)+\x12b\xd7m)\ +|\x1e\x175\xa0\x7f\x94\x16\x11\xdf\xc4f]\xd7Ll\ +:\xfd\xe7\x8e)\xa6\xc5\xe6L!\xf7D3\xd8\x95\xdf\ +\xf3EC\xacT\xe8\x81\xa1\xd7\xec\xb2\xeb/\xc4M\xa3\ +^In/e\xa2\xf8\xd0:\xfe\xaej\x9f\x22\x03\x1c\ +[\xe4z[\xbf\x82w\xfbs\xc4g\x1fC@^\xd2\ +\x01\xf6k\x01\xb9\xdc\x9a\xbb\xa7\xa6|\x00>]\xa3\x18\ +H\x047\x1a\x1bL\x90\xee\xa8Xqz\x86p\xce\xa9\ +\xcf\x1eXfe\x04\x12\xd4\x0e\xc8\xb5\x7f`<\xe3n\ +\xaf$w.\x94\x852\xe6mE\x83I\xe2$\xe5\x03\ +\x8a\xe1\x8d\x8a\x97\xc1\xc9\xea\xf7 @O\x114\xcc\xaa\ +R\xbd\x98f\xfd\x22\xae]H\xb8\x9b\x9d\x5c\xbeB{\ +\xab_\xe5\xba\x90\x0dhGo\x0d\x98\xb0\x89[UE\ +C2\xe5\xb6\x1f\xb0#\xc3z\xca\xcc\xbb\x011A\x1f\ +\xc6A\xcf\xc1\x07\xd8\xdf\xde\x22\xf7\xd0\x03\xd4\xa2\x85\xe5\ +\xa4\x12\x9f\xbf8\x9d\xab[\x83M\xfe\xaa\xa2Y\x5cI\ +\xa2\xb7\xd5\x9d)2u\x7fr\xe5Uk\x1c\x1d'\xcc\ +\xdb\xbd\x0a\xc3g+\xae\xf0\x134\x88Pbu\xa6?\ +\x9a\xf1iT\xa6\xe0O\xa3\xd8\xdf\x06\xcc\xa0\xc6\xfb\xa8\ +\xdf\x0c\xc8d\x1fi\x94cj\x87\xf0%\xcf;\ +I\xce\xf3N\x05\xf3{\xe4\xccK\xbcx|u\xd3.\ +6\x9fw\xa3\x88[\x96\xcas\x0f \xf3{V\x19\x09\ +\x1b\xf9\xdaJ\x8e\x16\x9d\xab\x1d\xec@\x85\xda\x00\xf9\xbf\ +\xe76>im\xdd\xef\x9e\xfd\x0c\xc4\x8b{\xfb\x84\xc7\ +M\xef\x03L\xf7\x8c\x85\xf2\xb3\xba\xd2\xda\xfc\x98\xf1\xd3\ +^\xbb\xce\xf8\xf9\x17\xcfj2h\ +\x00\x00\x145\ +<\ +\xb8d\x18\xca\xef\x9c\x95\xcd!\x1c\xbf`\xa1\xbd\xddB\ +\x00\x00\x01h\x00,\xc2/\x00\x00\x10\x85\x002\x02/\ +\x00\x00\x10\xc8\x02\x0dS}\x00\x00\x01\x13\x02O\xb3\x1d\ +\x00\x00\x02\x96\x03\x19Y\x9f\x00\x00\x04[\x04J\x87\xfd\ +\x00\x00\x08\x96\x04\xd1\x07\x12\x00\x00\x03A\x04\xd1\x07\x12\ +\x00\x00\x0e/\x07\x02\x1cb\x00\x00\x05\x16\x07\x08\xb2-\ +\x00\x00\x06\xdc\x07\x0f\xa4\xdd\x00\x00\x00?\x07\xf5\xce\x13\ +\x00\x00\x00\x00\x07\xfe\xb0=\x00\x00\x0e\xbe\x07\xff3\x88\ +\x00\x00\x04\x0d\x0bf\xe4\xc2\x00\x00\x02\x0e\x0bf\xe4\xc2\ +\x00\x00\x0cm\x0b\x90$b\x00\x00\x0b\x7f\x0b\xfdQ\xc9\ +\x00\x00\x09\xf6\x0b\xfdQ\xc9\x00\x00\x0f\x89\x0cN0\xd8\ +\x00\x00\x0f\xdd\x0cz\x0b\xe2\x00\x00\x07I\x0c\xc9\xf1\xd8\ +\x00\x00\x09\xa0\x0c\xc9\xf1\xd8\x00\x00\x0f4\x0c\xf0\x06X\ +\x00\x00\x0c\xf4\x0c\xf9\x97\x1d\x00\x00\x0b\x08\x0d'df\ +\x00\x00\x0d\x5c\x0d*!\x12\x00\x00\x11\xc9\x0d\xcb\xc4\xfe\ +\x00\x00\x08\xfd\x0d\xd1\xfc\xf8\x00\x00\x11B\x0d\xdeB\xa4\ +\x00\x00\x06&\x0e\x09\x9d\x17\x00\x00\x0d\xc8\x0e\x1d\xf3\xda\ +\x00\x00\x06~\x0e\x1e\xd0\xb8\x00\x00\x10L\x0e\xdd\xfc\xcf\ +\x00\x00\x00\x87\x0e\xe1#\x1d\x00\x00\x01\x8c\x0e\xe1#\x1d\ +\x00\x00\x0a\x88\x0e\xe2\xf34\x00\x00\x01\xcc\x0e\xe2\xf34\ +\x00\x00\x0a\xc7\x0f\x03\xbf\xe7\x00\x00\x10\x15\x0f\x070\x10\ +\x00\x00\x02\xff\x0f\x09\xc0h\x00\x00\x0aK\x0f#\x9e@\ +\x00\x00\x03\xd1\x0f:\xc3\x00\x00\x00\x11\x90\x0f:\xd3~\ +\x00\x00\x00\xde\x0f:\xd3~\x00\x00\x11\x09i\x00\x00\x12\ +\xb3\x03\x00\x00\x00\x14\x00P\x00i\x00n\x00 \x00W\ +\x00i\x00n\x00d\x00o\x00w\x08\x00\x00\x00\x00\x06\ +\x00\x00\x00\x0c\xe5\x9b\xba\xe5\xae\x9a\xe7\xaa\x97\xe5\x8f\xa3\ +\x07\x00\x00\x00\x0aFeedbackUI\x01\ +\x03\x00\x00\x00 \x00C\x00a\x00n\x00n\x00e\x00\ +d\x00 \x00R\x00e\x00s\x00p\x00o\x00n\x00\ +s\x00e\x00s\x08\x00\x00\x00\x00\x06\x00\x00\x00\x09\xe5\ +\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\x07\x00\x00\x00\x0aFee\ +dbackUI\x01\x03\x00\x00\x00&\x00O\x00\ +p\x00e\x00n\x00 \x00s\x00e\x00t\x00t\x00\ +i\x00n\x00g\x00s\x00 \x00p\x00a\x00n\x00\ +e\x00l\x08\x00\x00\x00\x00\x06\x00\x00\x00\x12\xe6\x89\x93\ +\xe5\xbc\x80\xe8\xae\xbe\xe7\xbd\xae\xe9\x9d\xa2\xe6\x9d\xbf\x07\ +\x00\x00\x00\x0aFeedbackUI\x01\x03\ +\x00\x00\x00\x10\x00S\x00e\x00t\x00t\x00i\x00n\ +\x00g\x00s\x08\x00\x00\x00\x00\x06\x00\x00\x00\x06\xe8\xae\ +\xbe\xe7\xbd\xae\x07\x00\x00\x00\x0aFeedbac\ +kUI\x01\x03\x00\x00\x00B\x00S\x00e\x00l\x00\ +e\x00c\x00t\x00 \x00o\x00r\x00 \x00m\x00\ +a\x00n\x00a\x00g\x00e\x00 \x00c\x00a\x00\ +n\x00n\x00e\x00d\x00 \x00r\x00e\x00s\x00\ +p\x00o\x00n\x00s\x00e\x00s\x08\x00\x00\x00\x00\ +\x06\x00\x00\x00\x18\xe9\x80\x89\xe6\x8b\xa9\xe6\x88\x96\xe7\xae\ +\xa1\xe7\x90\x86\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\x07\x00\x00\ +\x00\x0aFeedbackUI\x01\x03\x00\x00\ +\x00\x0a\x00C\x00l\x00o\x00s\x00e\x08\x00\x00\x00\ +\x00\x06\x00\x00\x00\x06\xe5\x85\xb3\xe9\x97\xad\x07\x00\x00\x00\ +\x1bManageCannedRes\ +ponsesDialog\x01\x03\x00\x00\ +\x00\x0c\x00D\x00e\x00l\x00e\x00t\x00e\x08\x00\ +\x00\x00\x00\x06\x00\x00\x00\x06\xe5\x88\xa0\xe9\x99\xa4\x07\x00\ +\x00\x00\x1bManageCannedR\ +esponsesDialog\x01\x03\ +\x00\x00\x00@\x00C\x00a\x00n\x00n\x00e\x00d\ +\x00 \x00r\x00e\x00s\x00p\x00o\x00n\x00s\ +\x00e\x00 \x00c\x00a\x00n\x00n\x00o\x00t\ +\x00 \x00b\x00e\x00 \x00e\x00m\x00p\x00t\ +\x00y\x00.\x08\x00\x00\x00\x00\x06\x00\x00\x00\x18\xe5\xb8\ +\xb8\xe7\x94\xa8\xe8\xaf\xad\xe4\xb8\x8d\xe8\x83\xbd\xe4\xb8\xba\ +\xe7\xa9\xba\xe3\x80\x82\x07\x00\x00\x00\x1bManag\ +eCannedResponses\ +Dialog\x01\x03\x00\x00\x00$\x00S\x00u\ +\x00c\x00c\x00e\x00s\x00s\x00f\x00u\x00l\ +\x00l\x00y\x00 \x00a\x00d\x00d\x00e\x00d\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00\x15\xe6\x88\x90\xe5\x8a\x9f\ +\xe6\xb7\xbb\xe5\x8a\xa0\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\x07\ +\x00\x00\x00\x1bManageCanned\ +ResponsesDialog\x01\ +\x03\x00\x00\x00\x0c\x00U\x00p\x00d\x00a\x00t\x00\ +e\x08\x00\x00\x00\x00\x06\x00\x00\x00\x06\xe6\x9b\xb4\xe6\x96\ +\xb0\x07\x00\x00\x00\x1bManageCann\ +edResponsesDialo\ +g\x01\x03\x00\x00\x00H\x00T\x00h\x00i\x00s\x00\ + \x00c\x00a\x00n\x00n\x00e\x00d\x00 \x00\ +r\x00e\x00s\x00p\x00o\x00n\x00s\x00e\x00\ + \x00a\x00l\x00r\x00e\x00a\x00d\x00y\x00\ + \x00e\x00x\x00i\x00s\x00t\x00s\x00.\x08\ +\x00\x00\x00\x00\x06\x00\x00\x00\x18\xe6\xad\xa4\xe5\xb8\xb8\xe7\ +\x94\xa8\xe8\xaf\xad\xe5\xb7\xb2\xe5\xad\x98\xe5\x9c\xa8\xe3\x80\ +\x82\x07\x00\x00\x00\x1bManageCann\ +edResponsesDialo\ +g\x01\x03\x00\x00\x00\x06\x00A\x00d\x00d\x08\x00\x00\ +\x00\x00\x06\x00\x00\x00\x06\xe6\xb7\xbb\xe5\x8a\xa0\x07\x00\x00\ +\x00\x1bManageCannedRe\ +sponsesDialog\x01\x03\x00\ +\x00\x00\x12\x00C\x00l\x00e\x00a\x00r\x00 \x00\ +A\x00l\x00l\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0c\xe6\ +\xb8\x85\xe7\xa9\xba\xe5\x85\xa8\xe9\x83\xa8\x07\x00\x00\x00\x1b\ +ManageCannedResp\ +onsesDialog\x01\x03\x00\x00\x00\ +j\x00A\x00r\x00e\x00 \x00y\x00o\x00u\x00\ + \x00s\x00u\x00r\x00e\x00 \x00y\x00o\x00\ +u\x00 \x00w\x00a\x00n\x00t\x00 \x00t\x00\ +o\x00 \x00d\x00e\x00l\x00e\x00t\x00e\x00\ + \x00t\x00h\x00i\x00s\x00 \x00c\x00a\x00\ +n\x00n\x00e\x00d\x00 \x00r\x00e\x00s\x00\ +p\x00o\x00n\x00s\x00e\x00?\x08\x00\x00\x00\x00\ +\x06\x00\x00\x00!\xe7\xa1\xae\xe5\xae\x9a\xe8\xa6\x81\xe5\x88\ +\xa0\xe9\x99\xa4\xe6\xad\xa4\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\ +\xe5\x90\x97\xef\xbc\x9f\x07\x00\x00\x00\x1bManag\ +eCannedResponses\ +Dialog\x01\x03\x00\x00\x00\xa4\x00A\x00r\ +\x00e\x00 \x00y\x00o\x00u\x00 \x00s\x00u\ +\x00r\x00e\x00 \x00y\x00o\x00u\x00 \x00w\ +\x00a\x00n\x00t\x00 \x00t\x00o\x00 \x00c\ +\x00l\x00e\x00a\x00r\x00 \x00a\x00l\x00l\ +\x00 \x00c\x00a\x00n\x00n\x00e\x00d\x00 \ +\x00r\x00e\x00s\x00p\x00o\x00n\x00s\x00e\ +\x00s\x00?\x00 \x00T\x00h\x00i\x00s\x00 \ +\x00a\x00c\x00t\x00i\x00o\x00n\x00 \x00c\ +\x00a\x00n\x00n\x00o\x00t\x00 \x00b\x00e\ +\x00 \x00u\x00n\x00d\x00o\x00n\x00e\x00.\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00<\xe7\xa1\xae\xe5\xae\x9a\ +\xe8\xa6\x81\xe6\xb8\x85\xe7\xa9\xba\xe6\x89\x80\xe6\x9c\x89\xe5\ +\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\xe5\x90\x97\xef\xbc\x9f\xe6\xad\ +\xa4\xe6\x93\x8d\xe4\xbd\x9c\xe4\xb8\x8d\xe5\x8f\xaf\xe6\x92\xa4\ +\xe9\x94\x80\xe3\x80\x82\x07\x00\x00\x00\x1bManag\ +eCannedResponses\ +Dialog\x01\x03\x00\x00\x00\x1c\x00C\x00o\ +\x00n\x00f\x00i\x00r\x00m\x00 \x00D\x00e\ +\x00l\x00e\x00t\x00e\x08\x00\x00\x00\x00\x06\x00\x00\ +\x00\x0c\xe7\xa1\xae\xe8\xae\xa4\xe5\x88\xa0\xe9\x99\xa4\x07\x00\ +\x00\x00\x1bManageCannedR\ +esponsesDialog\x01\x03\ +\x00\x00\x00\x22\x00C\x00o\x00n\x00f\x00i\x00r\ +\x00m\x00 \x00C\x00l\x00e\x00a\x00r\x00 \ +\x00A\x00l\x00l\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0c\ +\xe7\xa1\xae\xe8\xae\xa4\xe6\xb8\x85\xe7\xa9\xba\x07\x00\x00\x00\ +\x1bManageCannedRes\ +ponsesDialog\x01\x03\x00\x00\ +\x00.\x00M\x00a\x00n\x00a\x00g\x00e\x00 \ +\x00C\x00a\x00n\x00n\x00e\x00d\x00 \x00R\ +\x00e\x00s\x00p\x00o\x00n\x00s\x00e\x00s\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0f\xe7\xae\xa1\xe7\x90\x86\ +\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\x07\x00\x00\x00\x1bMa\ +nageCannedRespon\ +sesDialog\x01\x03\x00\x00\x00\xba\x00\ +M\x00a\x00n\x00a\x00g\x00e\x00 \x00y\x00\ +o\x00u\x00r\x00 \x00c\x00a\x00n\x00n\x00\ +e\x00d\x00 \x00f\x00e\x00e\x00d\x00b\x00\ +a\x00c\x00k\x00 \x00p\x00h\x00r\x00a\x00\ +s\x00e\x00s\x00.\x00 \x00C\x00l\x00i\x00\ +c\x00k\x00 \x00a\x00 \x00l\x00i\x00s\x00\ +t\x00 \x00i\x00t\x00e\x00m\x00 \x00t\x00\ +o\x00 \x00e\x00d\x00i\x00t\x00,\x00 \x00\ +t\x00h\x00e\x00n\x00 \x00c\x00l\x00i\x00\ +c\x00k\x00 \x00t\x00h\x00e\x00 \x00U\x00\ +p\x00d\x00a\x00t\x00e\x00 \x00b\x00u\x00\ +t\x00t\x00o\x00n\x00.\x08\x00\x00\x00\x00\x06\x00\ +\x00\x00c\xe7\xae\xa1\xe7\x90\x86\xe6\x82\xa8\xe7\x9a\x84\xe5\ +\xb8\xb8\xe7\x94\xa8\xe5\x8f\x8d\xe9\xa6\x88\xe7\x9f\xad\xe8\xaf\ +\xad\xe3\x80\x82\xe7\x82\xb9\xe5\x87\xbb\xe5\x88\x97\xe8\xa1\xa8\ +\xe9\xa1\xb9\xe8\xbf\x9b\xe8\xa1\x8c\xe7\xbc\x96\xe8\xbe\x91\xef\ +\xbc\x8c\xe7\xbc\x96\xe8\xbe\x91\xe5\xae\x8c\xe6\x88\x90\xe5\x90\ +\x8e\xe7\x82\xb9\xe5\x87\xbb\xe6\x9b\xb4\xe6\x96\xb0\xe6\x8c\x89\ +\xe9\x92\xae\xe3\x80\x82\x07\x00\x00\x00\x1bManag\ +eCannedResponses\ +Dialog\x01\x03\x00\x00\x00(\x00E\x00d\ +\x00i\x00t\x00 \x00C\x00a\x00n\x00n\x00e\ +\x00d\x00 \x00R\x00e\x00s\x00p\x00o\x00n\ +\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0f\xe7\xbc\ +\x96\xe8\xbe\x91\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\x07\x00\x00\ +\x00\x1bManageCannedRe\ +sponsesDialog\x01\x03\x00\ +\x00\x00F\x00E\x00n\x00t\x00e\x00r\x00 \x00\ +n\x00e\x00w\x00 \x00o\x00r\x00 \x00e\x00\ +d\x00i\x00t\x00 \x00s\x00e\x00l\x00e\x00\ +c\x00t\x00e\x00d\x00 \x00r\x00e\x00s\x00\ +p\x00o\x00n\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\ +\x00\x00-\xe8\xbe\x93\xe5\x85\xa5\xe6\x96\xb0\xe7\x9a\x84\xe5\ +\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\xe6\x88\x96\xe7\xbc\x96\xe8\xbe\ +\x91\xe9\x80\x89\xe4\xb8\xad\xe7\x9a\x84\xe9\xa1\xb9\xe7\x9b\xae\ +\x07\x00\x00\x00\x1bManageCanne\ +dResponsesDialog\ +\x01\x03\x00\x00\x00\x1a\x00I\x00n\x00v\x00a\x00l\ +\x00i\x00d\x00 \x00I\x00n\x00p\x00u\x00t\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0c\xe8\xbe\x93\xe5\x85\xa5\ +\xe6\x97\xa0\xe6\x95\x88\x07\x00\x00\x00\x1bManag\ +eCannedResponses\ +Dialog\x01\x03\x00\x00\x00\x1c\x00D\x00u\ +\x00p\x00l\x00i\x00c\x00a\x00t\x00e\x00 \ +\x00I\x00t\x00e\x00m\x08\x00\x00\x00\x00\x06\x00\x00\ +\x00\x09\xe9\x87\x8d\xe5\xa4\x8d\xe9\xa1\xb9\x07\x00\x00\x00\x1b\ +ManageCannedResp\ +onsesDialog\x01\x03\x00\x00\x00\ +\x08\x00S\x00a\x00v\x00e\x08\x00\x00\x00\x00\x06\x00\ +\x00\x00\x06\xe4\xbf\x9d\xe5\xad\x98\x07\x00\x00\x00\x1aSe\ +lectCannedRespon\ +seDialog\x01\x03\x00\x00\x00\x0a\x00C\ +\x00l\x00o\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\x00\ +\x00\x06\xe5\x85\xb3\xe9\x97\xad\x07\x00\x00\x00\x1aSel\ +ectCannedRespons\ +eDialog\x01\x03\x00\x00\x00\x0c\x00D\x00\ +e\x00l\x00e\x00t\x00e\x08\x00\x00\x00\x00\x06\x00\ +\x00\x00\x06\xe5\x88\xa0\xe9\x99\xa4\x07\x00\x00\x00\x1aSe\ +lectCannedRespon\ +seDialog\x01\x03\x00\x00\x006\x00D\ +\x00e\x00l\x00e\x00t\x00e\x00 \x00t\x00h\ +\x00i\x00s\x00 \x00c\x00a\x00n\x00n\x00e\ +\x00d\x00 \x00r\x00e\x00s\x00p\x00o\x00n\ +\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\x00\x00\x12\xe5\x88\ +\xa0\xe9\x99\xa4\xe6\xad\xa4\xe5\xb8\xb8\xe7\x94\xa8\xe8\xaf\xad\ +\x07\x00\x00\x00\x1aSelectCanne\ +dResponseDialog\x01\ +\x03\x00\x00\x00z\x00D\x00o\x00u\x00b\x00l\x00\ +e\x00-\x00c\x00l\x00i\x00c\x00k\x00 \x00\ +t\x00o\x00 \x00i\x00n\x00s\x00e\x00r\x00\ +t\x00,\x00 \x00c\x00l\x00i\x00c\x00k\x00\ + \x00d\x00e\x00l\x00e\x00t\x00e\x00 \x00\ +b\x00u\x00t\x00t\x00o\x00n\x00,\x00 \x00\ +d\x00r\x00a\x00g\x00 \x00t\x00o\x00 \x00\ +r\x00e\x00o\x00r\x00d\x00e\x00r\x00.\x08\ +\x00\x00\x00\x00\x06\x00\x00\x00E\xe5\x8f\x8c\xe5\x87\xbb\xe6\ +\x8f\x92\xe5\x85\xa5\xe6\x96\x87\xe6\x9c\xac\xef\xbc\x8c\xe7\x82\ +\xb9\xe5\x87\xbb\xe5\x88\xa0\xe9\x99\xa4\xe6\x8c\x89\xe9\x92\xae\ +\xe7\xa7\xbb\xe9\x99\xa4\xef\xbc\x8c\xe6\x8b\x96\xe6\x8b\xbd\xe8\ +\xb0\x83\xe6\x95\xb4\xe9\xa1\xba\xe5\xba\x8f\xe3\x80\x82\x07\x00\ +\x00\x00\x1aSelectCannedR\ +esponseDialog\x01\x03\x00\ +\x00\x00@\x00C\x00a\x00n\x00n\x00e\x00d\x00\ + \x00r\x00e\x00s\x00p\x00o\x00n\x00s\x00\ +e\x00 \x00c\x00a\x00n\x00n\x00o\x00t\x00\ + \x00b\x00e\x00 \x00e\x00m\x00p\x00t\x00\ +y\x00.\x08\x00\x00\x00\x00\x06\x00\x00\x00\x18\xe5\xb8\xb8\ +\xe7\x94\xa8\xe8\xaf\xad\xe4\xb8\x8d\xe8\x83\xbd\xe4\xb8\xba\xe7\ +\xa9\xba\xe3\x80\x82\x07\x00\x00\x00\x1aSelect\ +CannedResponseDi\ +alog\x01\x03\x00\x00\x00*\x00C\x00a\x00n\ +\x00n\x00e\x00d\x00 \x00R\x00e\x00s\x00p\ +\x00o\x00n\x00s\x00e\x00s\x00 \x00L\x00i\ +\x00s\x00t\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0f\xe5\xb8\ +\xb8\xe7\x94\xa8\xe8\xaf\xad\xe5\x88\x97\xe8\xa1\xa8\x07\x00\x00\ +\x00\x1aSelectCannedRe\ +sponseDialog\x01\x03\x00\x00\ +\x00.\x00M\x00a\x00n\x00a\x00g\x00e\x00 \ +\x00C\x00a\x00n\x00n\x00e\x00d\x00 \x00R\ +\x00e\x00s\x00p\x00o\x00n\x00s\x00e\x00s\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0f\xe5\xb8\xb8\xe7\x94\xa8\ +\xe8\xaf\xad\xe7\xae\xa1\xe7\x90\x86\x07\x00\x00\x00\x1aSe\ +lectCannedRespon\ +seDialog\x01\x03\x00\x00\x00&\x00S\ +\x00h\x00o\x00w\x00 \x00S\x00h\x00o\x00r\ +\x00t\x00c\x00u\x00t\x00 \x00I\x00c\x00o\ +\x00n\x00s\x08\x00\x00\x00\x00\x06\x00\x00\x00\x12\xe6\x98\ +\xbe\xe7\xa4\xba\xe5\xbf\xab\xe6\x8d\xb7\xe5\x9b\xbe\xe6\xa0\x87\ +\x07\x00\x00\x00\x1aSelectCanne\ +dResponseDialog\x01\ +\x03\x00\x00\x00H\x00T\x00h\x00i\x00s\x00 \x00\ +c\x00a\x00n\x00n\x00e\x00d\x00 \x00r\x00\ +e\x00s\x00p\x00o\x00n\x00s\x00e\x00 \x00\ +a\x00l\x00r\x00e\x00a\x00d\x00y\x00 \x00\ +e\x00x\x00i\x00s\x00t\x00s\x00.\x08\x00\x00\ +\x00\x00\x06\x00\x00\x00\x18\xe6\xad\xa4\xe5\xb8\xb8\xe7\x94\xa8\ +\xe8\xaf\xad\xe5\xb7\xb2\xe5\xad\x98\xe5\x9c\xa8\xe3\x80\x82\x07\ +\x00\x00\x00\x1aSelectCanned\ +ResponseDialog\x01\x03\ +\x00\x00\x002\x00E\x00n\x00t\x00e\x00r\x00 \ +\x00n\x00e\x00w\x00 \x00c\x00a\x00n\x00n\ +\x00e\x00d\x00 \x00r\x00e\x00s\x00p\x00o\ +\x00n\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\x00\x00\x15\ +\xe8\xbe\x93\xe5\x85\xa5\xe6\x96\xb0\xe7\x9a\x84\xe5\xb8\xb8\xe7\ +\x94\xa8\xe8\xaf\xad\x07\x00\x00\x00\x1aSelect\ +CannedResponseDi\ +alog\x01\x03\x00\x00\x00\x1a\x00I\x00n\x00v\ +\x00a\x00l\x00i\x00d\x00 \x00I\x00n\x00p\ +\x00u\x00t\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0c\xe8\xbe\ +\x93\xe5\x85\xa5\xe6\x97\xa0\xe6\x95\x88\x07\x00\x00\x00\x1aS\ +electCannedRespo\ +nseDialog\x01\x03\x00\x00\x00\x1c\x00\ +D\x00u\x00p\x00l\x00i\x00c\x00a\x00t\x00\ +e\x00 \x00I\x00t\x00e\x00m\x08\x00\x00\x00\x00\ +\x06\x00\x00\x00\x09\xe9\x87\x8d\xe5\xa4\x8d\xe9\xa1\xb9\x07\x00\ +\x00\x00\x1aSelectCannedR\ +esponseDialog\x01\x03\x00\ +\x00\x00\x0e\x00E\x00n\x00g\x00l\x00i\x00s\x00\ +h\x08\x00\x00\x00\x00\x06\x00\x00\x00\x07Engli\ +sh\x07\x00\x00\x00\x0eSettingsD\ +ialog\x01\x03\x00\x00\x00\x0e\x00C\x00h\x00\ +i\x00n\x00e\x00s\x00e\x08\x00\x00\x00\x00\x06\x00\ +\x00\x00\x06\xe4\xb8\xad\xe6\x96\x87\x07\x00\x00\x00\x0eSe\ +ttingsDialog\x01\x03\x00\x00\ +\x00\x0a\x00T\x00h\x00e\x00m\x00e\x08\x00\x00\x00\ +\x00\x06\x00\x00\x00\x0c\xe5\xa4\x96\xe8\xa7\x82\xe4\xb8\xbb\xe9\ +\xa2\x98\x07\x00\x00\x00\x0eSettingsD\ +ialog\x01\x03\x00\x00\x00\x14\x00L\x00i\x00\ +g\x00h\x00t\x00 \x00M\x00o\x00d\x00e\x08\ +\x00\x00\x00\x00\x06\x00\x00\x00\x0c\xe6\xb5\x85\xe8\x89\xb2\xe6\ +\xa8\xa1\xe5\xbc\x8f\x07\x00\x00\x00\x0eSettin\ +gsDialog\x01\x03\x00\x00\x00\x12\x00D\ +\x00a\x00r\x00k\x00 \x00M\x00o\x00d\x00e\ +\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0c\xe6\xb7\xb1\xe8\x89\xb2\ +\xe6\xa8\xa1\xe5\xbc\x8f\x07\x00\x00\x00\x0eSetti\ +ngsDialog\x01\x03\x00\x00\x00\x10\x00\ +S\x00e\x00t\x00t\x00i\x00n\x00g\x00s\x08\ +\x00\x00\x00\x00\x06\x00\x00\x00\x06\xe8\xae\xbe\xe7\xbd\xae\x07\ +\x00\x00\x00\x0eSettingsDial\ +og\x01\x03\x00\x00\x00\x1c\x00S\x00e\x00t\x00t\ +\x00i\x00n\x00g\x00s\x00 \x00S\x00a\x00v\ +\x00e\x00d\x08\x00\x00\x00\x00\x06\x00\x00\x00\x0f\xe8\xae\ +\xbe\xe7\xbd\xae\xe5\xb7\xb2\xe4\xbf\x9d\xe5\xad\x98\x07\x00\x00\ +\x00\x0eSettingsDialog\ +\x01\x03\x00\x00\x00\x10\x00L\x00a\x00n\x00g\x00u\ +\x00a\x00g\x00e\x08\x00\x00\x00\x00\x06\x00\x00\x00\x06\ +\xe8\xaf\xad\xe8\xa8\x80\x07\x00\x00\x00\x0eSetti\ +ngsDialog\x01\x03\x00\x00\x00\x94\x00\ +L\x00a\x00n\x00g\x00u\x00a\x00g\x00e\x00\ + \x00c\x00h\x00a\x00n\x00g\x00e\x00s\x00\ + \x00w\x00i\x00l\x00l\x00 \x00t\x00a\x00\ +k\x00e\x00 \x00e\x00f\x00f\x00e\x00c\x00\ +t\x00 \x00t\x00h\x00e\x00 \x00n\x00e\x00\ +x\x00t\x00 \x00t\x00i\x00m\x00e\x00 \x00\ +y\x00o\x00u\x00 \x00s\x00t\x00a\x00r\x00\ +t\x00 \x00t\x00h\x00e\x00 \x00a\x00p\x00\ +p\x00l\x00i\x00c\x00a\x00t\x00i\x00o\x00\ +n\x00.\x08\x00\x00\x00\x00\x06\x00\x00\x003\xe8\xaf\xad\ +\xe8\xa8\x80\xe6\x9b\xb4\xe6\x94\xb9\xe5\xb0\x86\xe5\x9c\xa8\xe6\ +\x82\xa8\xe4\xb8\x8b\xe6\xac\xa1\xe5\x90\xaf\xe5\x8a\xa8\xe5\xba\ +\x94\xe7\x94\xa8\xe6\x97\xb6\xe7\x94\x9f\xe6\x95\x88\xe3\x80\x82\ +\x07\x00\x00\x00\x0eSettingsDia\ +log\x01\ +" + +qt_resource_name = b"\ +\x00\x0c\ +\x0d\xfc\x11\x13\ +\x00t\ +\x00r\x00a\x00n\x00s\x00l\x00a\x00t\x00i\x00o\x00n\x00s\ +\x00\x06\ +\x07\xac\x02\xc3\ +\x00s\ +\x00t\x00y\x00l\x00e\x00s\ +\x00\x06\ +\x07\xa6\xc4\xb3\ +\x00s\ +\x00o\x00u\x00n\x00d\x00s\ +\x00\x10\ +\x03\xe36f\ +\x00n\ +\x00o\x00t\x00i\x00f\x00i\x00c\x00a\x00t\x00i\x00o\x00n\x00.\x00w\x00a\x00v\ +\x00\x08\ +\x08\x8eU\xe3\ +\x00d\ +\x00a\x00r\x00k\x00.\x00q\x00s\x00s\ +\x00\x09\ +\x0d\xf7\xbdC\ +\x00l\ +\x00i\x00g\x00h\x00t\x00.\x00q\x00s\x00s\ +\x00\x08\ +\x04Jh\xfd\ +\x00e\ +\x00n\x00_\x00U\x00S\x00.\x00q\x00m\ +" + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x000\x00\x02\x00\x00\x00\x01\x00\x00\x00\x07\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x1e\x00\x02\x00\x00\x00\x02\x00\x00\x00\x05\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x04\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x96\x00\x00\x00\x00\x00\x01\x00\x00\xb9\x91\ +\x00\x00\x01\x97CW\x1dm\ +\x00\x00\x00h\x00\x01\x00\x00\x00\x01\x00\x00\x9cp\ +\x00\x00\x01\x98\x08\x84\xec\xf3\ +\x00\x00\x00~\x00\x01\x00\x00\x00\x01\x00\x00\xabr\ +\x00\x00\x01\x98\x08\x85&\x04\ +\x00\x00\x00B\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x97_Q\xaf\xfc\ +" + + +def qInitResources(): + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +def qCleanupResources(): + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +qInitResources() diff --git a/src/feedback_ui/styles/dark_theme.qss b/src/feedback_ui/styles/dark_theme.qss new file mode 100644 index 0000000..0301a32 --- /dev/null +++ b/src/feedback_ui/styles/dark_theme.qss @@ -0,0 +1,503 @@ +/* 全局字体和基础窗口样式 (Global Font and Base Window Styles) */ +* { + font-family: 'Segoe UI', 'Microsoft YaHei UI', Arial, sans-serif; + outline: none; /* 移除焦点时的虚线框 (Remove dotted focus outline) */ + font-size: 13pt; + font-weight: 400; +} + + + +QMainWindow, QDialog { + background-color: #2c2c2c; +} + +QWidget { + background-color: #2c2c2c; + color: #f0f0f0; +} + +QGroupBox { + border: 1px solid #555; + border-radius: 6px; + margin-top: 12px; /* 为标题留出空间 (Space for title) */ + padding-top: 12px; /* 确保内容在标题下方 (Ensure content is below title) */ + background-color: transparent; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top center; + padding: 0 8px; + color: #aaa; + font-weight: bold; +} + +/* 提示文字区域标签 - 描述区域 (颜色和内边距) */ +SelectableLabel[class="prompt-label"] { + color: #ffffff; /* 纯白色,与输入框文字一致 */ + padding: 2px; + /* font-size is now set dynamically */ +} + +/* Markdown元素样式 - 深色主题 */ +SelectableLabel h1, SelectableLabel h2, SelectableLabel h3, +SelectableLabel h4, SelectableLabel h5, SelectableLabel h6 { + color: inherit; + margin: 0.2em 0; + font-weight: bold; +} + +SelectableLabel h1 { + font-size: 1.4em; +} + +SelectableLabel h2 { + font-size: 1.2em; +} + +SelectableLabel h3 { + font-size: 1.1em; +} + +SelectableLabel strong, SelectableLabel b { + font-weight: bold; + color: inherit; +} + +/* 代码元素样式 - 深色主题 */ +/* 代码颜色: #4A90E2 (蓝色) */ + +/* 内联代码样式 - 简化并强化class选择器 */ +SelectableLabel .code-inline { + color: #4A90E2 !important; + background-color: rgba(60, 60, 60, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +/* 通用代码元素样式 */ +SelectableLabel code { + color: #4A90E2 !important; + background-color: rgba(60, 60, 60, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +/* 代码块样式 */ +SelectableLabel .code-block, +SelectableLabel pre { + color: #4A90E2 !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; + margin: 0 !important; + padding: 0 !important; + background: none !important; + border: none !important; + display: inline !important; +} + +/* 代码块内的span元素 */ +SelectableLabel pre span { + color: #4A90E2 !important; +} + +/* QTextEdit 代码样式支持 - 优化版本 */ +QTextEdit code, QTextEdit pre, FeedbackTextEdit code, FeedbackTextEdit pre { + color: #4A90E2 !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +QTextEdit code, FeedbackTextEdit code { + background-color: rgba(60, 60, 60, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; +} + +QTextEdit pre, FeedbackTextEdit pre { + background-color: rgba(60, 60, 60, 0.2) !important; + padding: 8px !important; + border-radius: 4px !important; + border-left: 3px solid #4A90E2 !important; +} + +SelectableLabel em, SelectableLabel i { + font-style: italic; + color: inherit; +} + +/* 移除重复的代码样式定义,避免与上方的蓝色代码样式冲突 */ + +SelectableLabel blockquote { + border-left: 3px solid #555555; + padding-left: 10px; + margin: 0.5em 0; + color: inherit; + font-style: italic; +} + +/* 选项区域的标签样式 (颜色) */ +SelectableLabel[class="option-label"] { + color: #aaaaaa; /* 更明显的灰色 */ + /* font-size is now set dynamically */ +} + +/* 除了滚动区域内的可选择标签 */ +QScrollArea SelectableLabel { + color: #ffffff; /* 纯白色,与输入框文字一致 */ + font-size: 16pt; /* 比输入框的13pt更大一些 */ +} + +/* ClickableLabel from clickable_label.py */ +ClickableLabel { + color: #ffffff; + selection-background-color: #2374E1; /* Qt.blue is similar */ + selection-color: white; + /* padding already applied in QLabel general rule, can be more specific if needed */ +} +/* AtIconLabel from clickable_label.py, specific styling if needed beyond QLabel */ +AtIconLabel { + background-color: transparent; +} + + +QPushButton { + background-color: #3C3C3C; + color: white; + border: none; + border-radius: 6px; + padding: 8px 16px; + font-weight: bold; + min-width: 120px; + min-height: 36px; +} +QPushButton:hover { background-color: #444444; } +QPushButton:pressed { background-color: #333333; } +QPushButton:disabled { background-color: #555; color: #999; } + +QPushButton#submit_button { + background-color: #252525; + color: white; + border: 2px solid #3A3A3A; + padding: 12px 20px; + font-weight: bold; + border-radius: 15px; + min-height: 60px; +} +QPushButton#submit_button:hover { background-color: #303030; border: 2px solid #454545; } +QPushButton#submit_button:pressed { background-color: #202020; border: 2px solid #353535; } + +QPushButton#secondary_button, QPushButton#delete_canned_item_button { + background-color: transparent; + color: white; + border: 1px solid #454545; + padding: 5px 10px; + min-height: 32px; + min-width: 100px; + max-height: 32px; +} +QPushButton#secondary_button:hover, QPushButton#delete_canned_item_button:hover { + background-color: rgba(64, 64, 64, 0.3); border: 1px solid #555555; +} +QPushButton#secondary_button:pressed, QPushButton#delete_canned_item_button:pressed { + background-color: rgba(48, 48, 48, 0.4); +} +/* Specific style for delete button in dialogs if it has objectName "delete_canned_item_button" */ +QPushButton#delete_canned_item_button { + background-color: #d32f2f; min-width: 40px; +} +QPushButton#delete_canned_item_button:hover { background-color: #f44336; } +QPushButton#delete_canned_item_button:pressed { background-color: #b71c1c; } + +QPushButton#pin_window_active { + background-color: rgba(80, 80, 80, 0.5); + color: white; + border: 1px solid #606060; + padding: 5px 10px; + min-height: 32px; + min-width: 100px; + max-height: 32px; +} +QPushButton#pin_window_active:hover { background-color: rgba(85, 85, 85, 0.6); border: 1px solid #676767; } +QPushButton#pin_window_active:pressed { background-color: rgba(69, 69, 69, 0.6); } + +/* V4.0 优化按钮样式现在通过主题感知的内联样式动态应用 */ + +/* V4.0 新增:输入框内的优化图标按钮样式 */ +QPushButton#optimization_icon_button { + background-color: rgba(45, 85, 135, 0.9); + color: white; + border: 1px solid #4A90E2; + border-radius: 16px; /* 圆形按钮 */ + font-size: 12px; + font-weight: bold; + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; +} +QPushButton#optimization_icon_button:hover { + background-color: rgba(55, 95, 145, 1.0); + border: 1px solid #5BA0F2; +} +QPushButton#optimization_icon_button:pressed { + background-color: rgba(35, 75, 125, 1.0); + border: 1px solid #3A80D2; +} +QPushButton#optimization_icon_button:disabled { + background-color: rgba(60, 60, 60, 0.7); + color: #999999; + border: 1px solid #555555; +} + +/* 优化按钮容器样式 */ +QWidget#optimization_container { + background-color: transparent; +} + +/* QTextEdit and FeedbackTextEdit from feedback_text_edit.py */ +QTextEdit, FeedbackTextEdit { + background-color: #272727; + color: #ffffff; + border: 2px solid #3A3A3A; + border-radius: 10px; + padding: 12px; + selection-background-color: #505050; + selection-color: white; + min-height: 250px; + /* font-size is now set dynamically */ +} + +/* 为输入框中的文字添加光圈效果 */ +QTextEdit::edit, FeedbackTextEdit::edit { + text-shadow: 0 0 2px rgba(255, 255, 255, 0.5); + color: #ffffff; +} + +QTextEdit:hover, FeedbackTextEdit:hover { border: 2px solid #454545; background-color: #272727; } +QTextEdit:focus, FeedbackTextEdit:focus { border: 2px solid #505050; } +/* PlaceholderText color is set via QPalette in FeedbackTextEdit and MainWindow */ + +QCheckBox { + color: #aaaaaa; /* 更明显的灰色 */ + spacing: 8px; + min-height: 28px; + padding: 1px; +} +QCheckBox::indicator { + width: 22px; height: 22px; + border: 2px solid #444444; + border-radius: 4px; + background-color: #2c2c2c; +} +QCheckBox::indicator:checked { + background-color: #555555; /* 深色主题协调的灰色背景 */ + border: 2px solid #666666; + image: none; /* Crucial for SVG background-image to work */ + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; +} +QCheckBox::indicator:hover:!checked { + border: 2px solid #666666; + background-color: #3a3a3a; +} +QCheckBox::indicator:checked:hover { + background-color: #666666; /* 悬停时稍亮的灰色 */ + border: 2px solid #777777; +} +QCheckBox::indicator:disabled { + border: 2px solid #333333; + background-color: #1a1a1a; +} +QCheckBox::indicator:checked:disabled { + background-color: #555555; + border: 2px solid #555555; + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; +} + +/* QRadioButton 样式 - 深色主题 - 增强视觉效果 */ +QRadioButton { + color: #aaaaaa; /* 与复选框文字颜色一致 */ + spacing: 8px; + min-height: 28px; + padding: 1px; +} +QRadioButton::indicator { + width: 22px; height: 22px; + border: 2px solid #444444; + border-radius: 11px; /* 圆形 */ + background-color: #2c2c2c; +} +QRadioButton::indicator:checked { + background-color: #555555; /* 深色主题协调的灰色背景 */ + border: 2px solid #666666; + /* 使用径向渐变创建更明显的圆点效果 */ + background-image: radial-gradient(circle, #ffffff 25%, #555555 30%); +} +QRadioButton::indicator:hover:!checked { + border: 2px solid #666666; + background-color: #3a3a3a; +} +QRadioButton::indicator:checked:hover { + background-color: #666666; /* 悬停时稍亮的灰色 */ + border: 2px solid #777777; + background-image: radial-gradient(circle, #ffffff 25%, #666666 30%); +} +QRadioButton::indicator:disabled { + border: 2px solid #333333; + background-color: #1a1a1a; +} +QRadioButton::indicator:checked:disabled { + background-color: #555555; + border: 2px solid #555555; + background-image: radial-gradient(circle, #888888 25%, #555555 30%); +} + + + +QFrame[frameShape="4"] /* HLine */ { + color: #555555; max-height: 1px; margin: 10px 0; + background-color: #555555; border: none; +} +QScrollArea { background-color: transparent; border: none; } +QScrollBar:vertical { background: transparent; width: 8px; margin: 0px; } +QScrollBar::handle:vertical { background: rgba(85,85,85,0.3); min-height: 20px; border-radius: 4px; } +QScrollBar::handle:vertical:hover { background: rgba(119,119,119,0.4); } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } + +/* FeedbackTextEdit's internal images_container (QWidget) */ +FeedbackTextEdit > QWidget { + background-color: #4a4a4a; + border-top: 1px solid #555555; + border-radius: 0 0 10px 10px; /* Only bottom corners rounded */ + padding: 8px; +} + +/* ImagePreviewWidget from image_preview.py */ +ImagePreviewWidget { + background-color: rgba(51, 51, 51, 200); + border: 1px solid #555; + border-radius: 4px; + margin: 2px; +} +ImagePreviewWidget:hover { + border: 1px solid #2a82da; /* Highlight on hover */ +} + +/* Dialog specific styles */ +ManageCannedResponsesDialog QListWidget, +SelectCannedResponseDialog QListWidget, +DraggableListWidget { + padding: 5px; + background-color: #2D2D2D; + border: 1px solid #3A3A3A; + border-radius: 4px; + color: white; +} +ManageCannedResponsesDialog QListWidget::item, +SelectCannedResponseDialog QListWidget::item, +DraggableListWidget::item { + border-bottom: 1px solid #3A3A3A; padding: 6px; margin: 1px; +} +ManageCannedResponsesDialog QListWidget::item:hover, +SelectCannedResponseDialog QListWidget::item:hover, +DraggableListWidget::item:hover { + background-color: transparent; /* No hover background for items */ +} +ManageCannedResponsesDialog QListWidget::item:selected, +SelectCannedResponseDialog QListWidget::item:selected, +DraggableListWidget::item:selected { + background-color: transparent; border: none; /* No selection background */ +} +ManageCannedResponsesDialog QListWidget::item:focus, +SelectCannedResponseDialog QListWidget::item:focus, +DraggableListWidget::item:focus { + background-color: transparent; border: none; /* No focus background */ +} + +ManageCannedResponsesDialog QLineEdit, +SelectCannedResponseDialog QLineEdit { + padding: 8px; + background-color: #333333; + color: white; + border: 1px solid #444; + border-radius: 4px; +} + +/* Labels within dialogs */ +ManageCannedResponsesDialog QLabel, +SelectCannedResponseDialog QLabel { + color: #aaa; +} +/* Specific title label in SelectCannedResponseDialog */ +SelectCannedResponseDialog QLabel#DialogTitleLabel { /* Assuming you set objectName */ + font-weight: bold; + color: white; + font-size: 14pt; +} +SelectCannedResponseDialog QLabel#DialogHintLabel { /* Assuming you set objectName */ + color: #aaaaaa; + font-size: 11pt; +} + +/* CheckBox within SelectCannedResponseDialog */ +SelectCannedResponseDialog QCheckBox { + color: #ffffff; + spacing: 8px; +} +SelectCannedResponseDialog QCheckBox::indicator { + width: 18px; height: 18px; border: 1px solid #555555; + border-radius: 3px; background-color: #333333; +} +SelectCannedResponseDialog QCheckBox::indicator:checked { + background-color: #555555; border: 1px solid #666666; + background-image: url("data:image/svg+xml,"); + background-position: center; background-repeat: no-repeat; +} + +/* QLabel within DraggableListWidget items (for text display) */ +DraggableListWidget QLabel { + color: white; +} + +/* QSplitter 样式 (Splitter Styles) - 精致版本 */ +QSplitter { + background-color: transparent; +} + +/* 精致的分割器样式,与按钮悬停颜色一致 */ +QSplitter[objectName="mainSplitter"]::handle, +QSplitter::handle { + background-color: #444444; /* 与按钮悬停颜色一致 */ + border: none; + border-radius: 2px; + margin: 2px 4px; +} + +QSplitter[objectName="mainSplitter"]::handle:horizontal, +QSplitter::handle:horizontal { + width: 6px; + min-width: 6px; + max-width: 6px; +} + +QSplitter[objectName="mainSplitter"]::handle:vertical, +QSplitter::handle:vertical { + height: 6px; + min-height: 6px; + max-height: 6px; +} + +QSplitter[objectName="mainSplitter"]::handle:hover, +QSplitter::handle:hover { + background-color: #555555; /* 悬停时稍亮 */ +} + +QSplitter[objectName="mainSplitter"]::handle:pressed, +QSplitter::handle:pressed { + background-color: #333333; /* 按下时稍暗 */ +} \ No newline at end of file diff --git a/src/feedback_ui/styles/light_theme.qss b/src/feedback_ui/styles/light_theme.qss new file mode 100644 index 0000000..47185a5 --- /dev/null +++ b/src/feedback_ui/styles/light_theme.qss @@ -0,0 +1,509 @@ +/* feedback_ui/styles/light_theme.qss */ + +/* 全局字体和基础窗口样式 (Global Font and Base Window Styles) */ +* { + font-family: 'Segoe UI', 'Microsoft YaHei UI', Arial, sans-serif; + outline: none; /* 移除焦点时的虚线框 (Remove dotted focus outline) */ + font-size: 13pt; + font-weight: 400; +} + + + +QMainWindow, QDialog { + background-color: #f0f0f0; +} + +QWidget { + background-color: #f0f0f0; + color: #111111; +} + +QGroupBox { + border: 1px solid #dcdcdc; + border-radius: 6px; + margin-top: 12px; + padding-top: 12px; + background-color: transparent; +} +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top center; + padding: 0 8px; + color: #777777; + font-weight: bold; +} + +/* 提示文字区域标签 - 描述区域 (颜色和内边距) */ +SelectableLabel[class="prompt-label"] { + color: #111111; + padding: 2px; + /* font-size is now set dynamically */ +} + +/* Markdown元素样式 - 浅色主题 */ +SelectableLabel h1, SelectableLabel h2, SelectableLabel h3, +SelectableLabel h4, SelectableLabel h5, SelectableLabel h6 { + color: inherit; + margin: 0.2em 0; + font-weight: bold; +} + +SelectableLabel h1 { + font-size: 1.4em; +} + +SelectableLabel h2 { + font-size: 1.2em; +} + +SelectableLabel h3 { + font-size: 1.1em; +} + +SelectableLabel strong, SelectableLabel b { + font-weight: bold; + color: inherit; +} + +SelectableLabel em, SelectableLabel i { + font-style: italic; + color: inherit; +} + +/* 代码元素样式 - 浅色主题 */ +/* 代码颜色: #4A90E2 (蓝色) */ + +/* 内联代码样式 - 简化并强化class选择器 */ +SelectableLabel .code-inline { + color: #4A90E2 !important; + background-color: rgba(220, 220, 220, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +/* 通用代码元素样式 */ +SelectableLabel code { + color: #4A90E2 !important; + background-color: rgba(220, 220, 220, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +/* 代码块样式 */ +SelectableLabel .code-block, +SelectableLabel pre { + color: #4A90E2 !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; + margin: 0 !important; + padding: 0 !important; + background: none !important; + border: none !important; + display: inline !important; +} + +/* 代码块内的span元素 */ +SelectableLabel pre span { + color: #4A90E2 !important; +} + +/* QTextEdit 代码样式支持 - 浅色主题优化版本 */ +QTextEdit code, QTextEdit pre, FeedbackTextEdit code, FeedbackTextEdit pre { + color: #4A90E2 !important; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important; +} + +QTextEdit code, FeedbackTextEdit code { + background-color: rgba(220, 220, 220, 0.4) !important; + padding: 2px 4px !important; + border-radius: 3px !important; +} + +QTextEdit pre, FeedbackTextEdit pre { + background-color: rgba(220, 220, 220, 0.2) !important; + padding: 8px !important; + border-radius: 4px !important; + border-left: 3px solid #4A90E2 !important; +} + +SelectableLabel blockquote { + border-left: 3px solid #cccccc; + padding-left: 10px; + margin: 0.5em 0; + color: inherit; + font-style: italic; +} + +/* 提示文字区域标签 - SelectableLabel 类 (只应用于描述区域) */ +QScrollArea SelectableLabel { + color: #111111; /* 深黑色,与输入框文字一致 */ + font-size: 16pt; /* 比输入框的13pt更大一些 */ +} + +/* 选项区域的标签样式 (颜色) */ +SelectableLabel[class="option-label"] { + color: #666666; /* 更明显的灰色,适合亮色主题 */ + /* font-size is now set dynamically */ +} + +/* ClickableLabel from clickable_label.py */ +ClickableLabel { + color: #111111; + selection-background-color: #d0e4f8; + selection-color: #111111; +} +AtIconLabel { + background-color: transparent; +} + +QPushButton { + background-color: #e1e1e1; + color: #111111; + border: 1px solid #adadad; + border-radius: 6px; + padding: 8px 16px; + font-weight: bold; + min-width: 120px; + min-height: 36px; +} +QPushButton:hover { background-color: #cccccc; } +QPushButton:pressed { background-color: #bbbbbb; } +QPushButton:disabled { background-color: #dcdcdc; color: #999999; } + +QPushButton#submit_button { + background-color: #ffffff; /* Match the text edit background */ + color: #212121; /* Dark text for contrast */ + border: 1px solid #c0c0c0; /* A subtle border to define the button */ + padding: 12px 20px; + font-weight: bold; + border-radius: 15px; + min-height: 60px; +} +QPushButton#submit_button:hover { + background-color: #fafafa; /* Very slight change on hover */ + border-color: #b0b0b0; +} +QPushButton#submit_button:pressed { + background-color: #f5f5f5; /* A bit darker when pressed */ + border-color: #a0a0a0; +} + +QPushButton#secondary_button, QPushButton#delete_canned_item_button { + background-color: #f9f9f9; + color: #333333; + border: 1px solid #cccccc; + padding: 5px 10px; + min-height: 32px; + min-width: 100px; + max-height: 32px; +} +QPushButton#secondary_button:hover, QPushButton#delete_canned_item_button:hover { + background-color: #eeeeee; border: 1px solid #bbbbbb; +} +QPushButton#secondary_button:pressed, QPushButton#delete_canned_item_button:pressed { + background-color: #dddddd; +} +QPushButton#delete_canned_item_button { + background-color: #fce8e6; color: #a50e0e; border: 1px solid #f5c6cb; + min-width: 40px; +} +QPushButton#delete_canned_item_button:hover { background-color: #f8d7da; border-color: #f1b0b7; } +QPushButton#delete_canned_item_button:pressed { background-color: #f4c2c7; } + +QPushButton#pin_window_active { + background-color: #d0d0d0; /* A noticeable but harmonious grey */ + color: #000000; /* Black text for strong contrast on grey */ + border: 1px solid #a0a0a0; /* A darker grey border */ + padding: 5px 10px; + min-height: 32px; + min-width: 100px; + max-height: 32px; +} +QPushButton#pin_window_active:hover { background-color: #c8c8c8; } +QPushButton#pin_window_active:pressed { background-color: #b8b8b8; } + +/* V4.0 优化按钮样式现在通过主题感知的内联样式动态应用 */ + +/* V4.0 新增:输入框内的优化图标按钮样式 - 浅色主题 */ +QPushButton#optimization_icon_button { + background-color: #1976d2; + color: white; + border: 1px solid #1565c0; + border-radius: 16px; /* 圆形按钮 */ + font-size: 12px; + font-weight: bold; + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; +} +QPushButton#optimization_icon_button:hover { + background-color: #1565c0; + border: 1px solid #0d47a1; +} +QPushButton#optimization_icon_button:pressed { + background-color: #0d47a1; + border: 1px solid #01579b; +} +QPushButton#optimization_icon_button:disabled { + background-color: #e0e0e0; + color: #bdbdbd; + border: 1px solid #cccccc; +} + +/* 优化按钮容器样式 */ +QWidget#optimization_container { + background-color: transparent; +} + +QTextEdit, FeedbackTextEdit { + background-color: #ffffff; + color: #111111; + border: 1px solid #cccccc; + border-radius: 10px; + padding: 12px; + selection-background-color: #d0e4f8; + selection-color: #111111; + min-height: 250px; + /* font-size is now set dynamically */ +} + +/* 为输入框中的文字添加光圈效果 */ +QTextEdit::edit, FeedbackTextEdit::edit { + text-shadow: 0 0 2px rgba(0, 120, 215, 0.4); /* 浅蓝色光晕效果 */ + color: #000000; +} + +QTextEdit:hover, FeedbackTextEdit:hover { border: 1px solid #bbbbbb; } +QTextEdit:focus, FeedbackTextEdit:focus { + border: 2px solid #c0c0c0; /* Use a soft, silver-grey for focus */ + padding: 11px; +} + +/* 选项区域的标签样式 - 增强视觉效果 */ +QWidget QCheckBox, QCheckBox { + color: #666666; /* 灰色 */ + spacing: 8px; + min-height: 28px; + padding: 1px; +} +QWidget QCheckBox::indicator, QCheckBox::indicator { + width: 22px; height: 22px; + border: 2px solid #adadad; + border-radius: 4px; + background-color: #ffffff; +} +QWidget QCheckBox::indicator:checked, QCheckBox::indicator:checked { + background-color: #6B6B6B !important; /* 浅色主题协调的深灰色背景 */ + border: 2px solid #777777 !important; + image: none; /* Crucial for SVG background-image to work */ + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; +} +QWidget QCheckBox::indicator:hover:!checked, QCheckBox::indicator:hover:!checked { + border: 2px solid #777777; + background-color: #f5f5f5; +} +QWidget QCheckBox::indicator:checked:hover, QCheckBox::indicator:checked:hover { + background-color: #777777 !important; /* 悬停时稍亮的灰色 */ + border: 2px solid #888888 !important; +} +QWidget QCheckBox::indicator:disabled, QCheckBox::indicator:disabled { + border: 2px solid #cccccc; + background-color: #f0f0f0; +} +QWidget QCheckBox::indicator:checked:disabled, QCheckBox::indicator:checked:disabled { + background-color: #cccccc !important; + border: 2px solid #cccccc !important; + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; +} + +/* QRadioButton 样式 - 浅色主题 - 增强视觉效果 */ +QRadioButton { + color: #666666; /* 与复选框文字颜色一致 */ + spacing: 8px; + min-height: 28px; + padding: 1px; +} +QRadioButton::indicator { + width: 22px; height: 22px; + border: 2px solid #adadad; + border-radius: 11px; /* 圆形 */ + background-color: #ffffff; +} +QRadioButton::indicator:checked { + background-color: #6B6B6B; /* 浅色主题协调的深灰色背景 */ + border: 2px solid #777777; + /* 使用径向渐变创建更明显的圆点效果 */ + background-image: radial-gradient(circle, #ffffff 25%, #6B6B6B 30%); +} +QRadioButton::indicator:hover:!checked { + border: 2px solid #777777; + background-color: #f5f5f5; +} +QRadioButton::indicator:checked:hover { + background-color: #777777; /* 悬停时稍亮的灰色 */ + border: 2px solid #888888; + background-image: radial-gradient(circle, #ffffff 25%, #777777 30%); +} +QRadioButton::indicator:disabled { + border: 2px solid #cccccc; + background-color: #f0f0f0; +} +QRadioButton::indicator:checked:disabled { + background-color: #cccccc; + border: 2px solid #cccccc; + background-image: radial-gradient(circle, #ffffff 25%, #cccccc 30%); +} +QRadioButton::indicator:checked:hover { + border-color: #777777; + background-image: radial-gradient(circle, #777777 30%, transparent 32%); +} + + + +QFrame[frameShape="4"] /* HLine */ { + color: #dcdcdc; max-height: 1px; margin: 10px 0; + background-color: #dcdcdc; border: none; +} +QScrollArea { background-color: transparent; border: none; } +QScrollBar:vertical { background: transparent; width: 8px; margin: 0px; } +QScrollBar::handle:vertical { background: rgba(0,0,0,0.15); min-height: 20px; border-radius: 4px; } +QScrollBar::handle:vertical:hover { background: rgba(0,0,0,0.25); } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } + +FeedbackTextEdit > QWidget { + background-color: #e9ecef; + border-top: 1px solid #dcdcdc; + border-radius: 0 0 10px 10px; + padding: 8px; +} + +ImagePreviewWidget { + background-color: #e9ecef; + border: 1px solid #dcdcdc; + border-radius: 4px; + margin: 2px; +} +ImagePreviewWidget:hover { + border: 1px solid #0078d4; +} + +/* Dialog specific styles */ +ManageCannedResponsesDialog QListWidget, +SelectCannedResponseDialog QListWidget, +DraggableListWidget { + padding: 5px; + background-color: #ffffff; + border: 1px solid #dcdcdc; + border-radius: 4px; + color: #111111; +} +ManageCannedResponsesDialog QListWidget::item, +SelectCannedResponseDialog QListWidget::item, +DraggableListWidget::item { + border-bottom: 1px solid #eeeeee; padding: 6px; margin: 1px; +} +ManageCannedResponsesDialog QListWidget::item:hover, +SelectCannedResponseDialog QListWidget::item:hover, +DraggableListWidget::item:hover { + background-color: #f5f5f5; +} +ManageCannedResponsesDialog QListWidget::item:selected, +SelectCannedResponseDialog QListWidget::item:selected, +DraggableListWidget::item:selected { + background-color: #e8f0fe; border: none; +} +ManageCannedResponsesDialog QListWidget::item:focus, +SelectCannedResponseDialog QListWidget::item:focus, +DraggableListWidget::item:focus { + background-color: #e8f0fe; border: none; +} + +ManageCannedResponsesDialog QLineEdit, +SelectCannedResponseDialog QLineEdit { + padding: 8px; + background-color: #ffffff; + color: #111111; + border: 1px solid #cccccc; + border-radius: 4px; +} + +/* Labels within dialogs */ +ManageCannedResponsesDialog QLabel, +SelectCannedResponseDialog QLabel { + color: #555555; +} +SelectCannedResponseDialog QLabel#DialogTitleLabel { + font-weight: bold; + color: #111111; + font-size: 14pt; +} +SelectCannedResponseDialog QLabel#DialogHintLabel { + color: #777777; + font-size: 11pt; +} + +/* CheckBox within SelectCannedResponseDialog */ +SelectCannedResponseDialog QCheckBox { + color: #111111; + spacing: 8px; +} +SelectCannedResponseDialog QCheckBox::indicator { + width: 18px; height: 18px; border: 1px solid #adadad; + border-radius: 3px; background-color: #fdfdfd; +} +SelectCannedResponseDialog QCheckBox::indicator:checked { + background-color: #555555; border: 1px solid #666666; + background-image: url("data:image/svg+xml,"); + background-position: center; background-repeat: no-repeat; +} + +/* QLabel within DraggableListWidget items (for text display) */ +DraggableListWidget QLabel { + color: #111111; +} + +/* QSplitter 样式 (Splitter Styles) - 精致版本 */ +QSplitter { + background-color: transparent; +} + +/* 精致的分割器样式,与按钮悬停颜色一致 */ +QSplitter[objectName="mainSplitter"]::handle, +QSplitter::handle { + background-color: #cccccc; /* 与按钮悬停颜色一致 */ + border: none; + border-radius: 2px; + margin: 2px 4px; +} + +QSplitter[objectName="mainSplitter"]::handle:horizontal, +QSplitter::handle:horizontal { + width: 6px; + min-width: 6px; + max-width: 6px; +} + +QSplitter[objectName="mainSplitter"]::handle:vertical, +QSplitter::handle:vertical { + height: 6px; + min-height: 6px; + max-height: 6px; +} + +QSplitter[objectName="mainSplitter"]::handle:hover, +QSplitter::handle:hover { + background-color: #dddddd; /* 悬停时稍亮 */ +} + +QSplitter[objectName="mainSplitter"]::handle:pressed, +QSplitter::handle:pressed { + background-color: #bbbbbb; /* 按下时稍暗 */ +} \ No newline at end of file diff --git a/src/feedback_ui/utils/__init__.py b/src/feedback_ui/utils/__init__.py new file mode 100644 index 0000000..eb8f022 --- /dev/null +++ b/src/feedback_ui/utils/__init__.py @@ -0,0 +1,20 @@ +# feedback_ui/utils/__init__.py +# This file makes the 'utils' directory a Python package. +# 这个文件使得 'utils' 目录成为一个 Python 包。 + +# Optionally, you can make some classes/functions directly available +# when importing the package, e.g.: +# from .constants import APP_NAME +# from .style_manager import apply_global_style +# from .settings_manager import SettingsManager +# from .image_processor import process_single_image +# +# (可选) 如果您想在导入包时直接使用某些类/函数,可以在此公开它们,例如: +# from .constants import APP_NAME +# from .style_manager import apply_global_style +# from .settings_manager import SettingsManager +# from .image_processor import process_single_image + +# For now, we'll keep it simple and require explicit imports from submodules. +# 目前,我们保持简单,并要求从子模块显式导入。 +pass diff --git a/src/feedback_ui/utils/ansi_parser.py b/src/feedback_ui/utils/ansi_parser.py new file mode 100644 index 0000000..9b581f7 --- /dev/null +++ b/src/feedback_ui/utils/ansi_parser.py @@ -0,0 +1,219 @@ +""" +ANSI转义序列解析器 +ANSI Escape Sequence Parser + +用于解析终端输出中的ANSI转义序列,将其转换为Qt富文本格式。 +Parses ANSI escape sequences in terminal output and converts them to Qt rich text format. +""" + +import re +from typing import List, Tuple +from PySide6.QtGui import QColor, QTextCharFormat, QFont + + +class ANSIParser: + """ANSI转义序列解析器""" + + # ANSI颜色映射表 + ANSI_COLORS = { + # 标准颜色 (30-37, 40-47) + 30: QColor(0, 0, 0), # 黑色 + 31: QColor(205, 49, 49), # 红色 + 32: QColor(13, 188, 121), # 绿色 + 33: QColor(229, 229, 16), # 黄色 + 34: QColor(36, 114, 200), # 蓝色 + 35: QColor(188, 63, 188), # 洋红 + 36: QColor(17, 168, 205), # 青色 + 37: QColor(229, 229, 229), # 白色 + # 亮色 (90-97, 100-107) + 90: QColor(102, 102, 102), # 亮黑色(灰色) + 91: QColor(241, 76, 76), # 亮红色 + 92: QColor(35, 209, 139), # 亮绿色 + 93: QColor(245, 245, 67), # 亮黄色 + 94: QColor(59, 142, 234), # 亮蓝色 + 95: QColor(214, 112, 214), # 亮洋红 + 96: QColor(41, 184, 219), # 亮青色 + 97: QColor(255, 255, 255), # 亮白色 + } + + def __init__(self): + # ANSI转义序列正则表达式(更全面的匹配) + self.ansi_escape = re.compile( + r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)" + ) + + # SGR (Select Graphic Rendition) 参数正则表达式 + self.sgr_pattern = re.compile(r"\x1B\[([0-9;]*)m") + + # OSC (Operating System Command) 序列正则表达式 + self.osc_pattern = re.compile(r"\x1B\]([0-9]+);([^\x07]*)\x07") + + # 损坏的ANSI序列修复模式(处理转义字符丢失的情况) + self.broken_ansi_pattern = re.compile(r"\[\]([0-9;]*)m") + + # 当前文本格式状态 + self.reset_format() + + def reset_format(self): + """重置格式状态""" + self.current_format = QTextCharFormat() + self.current_format.setForeground(QColor(255, 255, 255)) # 默认白色文字 + self.current_format.setBackground(QColor(30, 30, 30)) # 默认深色背景 + + def parse_sgr_codes(self, codes_str: str) -> QTextCharFormat: + """解析SGR代码并返回对应的文本格式""" + if not codes_str: + codes = [0] # 默认重置 + else: + try: + codes = [int(code) for code in codes_str.split(";") if code] + except ValueError: + codes = [0] + + format_copy = QTextCharFormat(self.current_format) + + i = 0 + while i < len(codes): + code = codes[i] + + if code == 0: # 重置所有格式 + format_copy = QTextCharFormat() + format_copy.setForeground(QColor(255, 255, 255)) + format_copy.setBackground(QColor(30, 30, 30)) + + elif code == 1: # 粗体 + format_copy.setFontWeight(QFont.Weight.Bold) + + elif code == 2: # 暗淡 + format_copy.setFontWeight(QFont.Weight.Light) + + elif code == 3: # 斜体 + format_copy.setFontItalic(True) + + elif code == 4: # 下划线 + format_copy.setFontUnderline(True) + + elif code == 7: # 反转颜色 + fg = format_copy.foreground().color() + bg = format_copy.background().color() + format_copy.setForeground(bg) + format_copy.setBackground(fg) + + elif code == 22: # 正常强度(取消粗体/暗淡) + format_copy.setFontWeight(QFont.Weight.Normal) + + elif code == 23: # 取消斜体 + format_copy.setFontItalic(False) + + elif code == 24: # 取消下划线 + format_copy.setFontUnderline(False) + + elif code == 27: # 取消反转 + # 这里需要恢复到默认颜色,简化处理 + format_copy.setForeground(QColor(255, 255, 255)) + format_copy.setBackground(QColor(30, 30, 30)) + + elif 30 <= code <= 37: # 前景色 + if code in self.ANSI_COLORS: + format_copy.setForeground(self.ANSI_COLORS[code]) + + elif code == 39: # 默认前景色 + format_copy.setForeground(QColor(255, 255, 255)) + + elif 40 <= code <= 47: # 背景色 + bg_code = code - 10 # 转换为前景色代码 + if bg_code in self.ANSI_COLORS: + format_copy.setBackground(self.ANSI_COLORS[bg_code]) + + elif code == 49: # 默认背景色 + format_copy.setBackground(QColor(30, 30, 30)) + + elif 90 <= code <= 97: # 亮前景色 + if code in self.ANSI_COLORS: + format_copy.setForeground(self.ANSI_COLORS[code]) + + elif 100 <= code <= 107: # 亮背景色 + bg_code = code - 10 # 转换为前景色代码 + if bg_code in self.ANSI_COLORS: + format_copy.setBackground(self.ANSI_COLORS[bg_code]) + + i += 1 + + self.current_format = format_copy + return format_copy + + def parse_text(self, text: str) -> List[Tuple[str, QTextCharFormat]]: + """ + 解析包含ANSI转义序列的文本 + 返回 (文本片段, 格式) 的列表 + """ + # 首先修复损坏的ANSI序列(将 []32m 转换为 \x1b[32m) + text = self.broken_ansi_pattern.sub(lambda m: f"\x1b[{m.group(1)}m", text) + + # 移除OSC序列(如窗口标题设置) + text = self.osc_pattern.sub("", text) + + result = [] + last_end = 0 + + # 查找所有SGR序列 + for match in self.sgr_pattern.finditer(text): + start, end = match.span() + + # 添加SGR序列之前的文本(使用当前格式) + if start > last_end: + plain_text = text[last_end:start] + if plain_text: + result.append((plain_text, QTextCharFormat(self.current_format))) + + # 解析SGR代码并更新格式 + codes_str = match.group(1) + self.parse_sgr_codes(codes_str) + + last_end = end + + # 添加剩余的文本 + if last_end < len(text): + remaining_text = text[last_end:] + if remaining_text: + result.append((remaining_text, QTextCharFormat(self.current_format))) + + return result + + def strip_ansi(self, text: str) -> str: + """移除文本中的所有ANSI转义序列""" + # 先修复损坏的ANSI序列 + text = self.broken_ansi_pattern.sub(lambda m: f"\x1b[{m.group(1)}m", text) + # 移除OSC序列 + text = self.osc_pattern.sub("", text) + # 移除其他ANSI序列 + return self.ansi_escape.sub("", text) + + def has_ansi(self, text: str) -> bool: + """检查文本是否包含ANSI转义序列""" + return bool( + self.ansi_escape.search(text) or self.broken_ansi_pattern.search(text) + ) + + +class ANSITextProcessor: + """ANSI文本处理器,用于与QTextEdit集成""" + + def __init__(self): + self.parser = ANSIParser() + + def process_text(self, text: str) -> List[Tuple[str, QTextCharFormat]]: + """处理文本并返回格式化的片段""" + return self.parser.parse_text(text) + + def reset(self): + """重置解析器状态""" + self.parser.reset_format() + + def strip_ansi(self, text: str) -> str: + """移除ANSI转义序列""" + return self.parser.strip_ansi(text) + + def has_ansi(self, text: str) -> bool: + """检查是否包含ANSI转义序列""" + return self.parser.has_ansi(text) diff --git a/src/feedback_ui/utils/audio_manager.py b/src/feedback_ui/utils/audio_manager.py new file mode 100644 index 0000000..40d8fa4 --- /dev/null +++ b/src/feedback_ui/utils/audio_manager.py @@ -0,0 +1,524 @@ +# feedback_ui/utils/audio_manager.py + +""" +音频管理器 +Audio Manager + +提供统一的音频播放接口,支持提示音播放、音量控制等功能。 +使用跨平台的原生音频播放,无需 QtMultimedia 依赖。 +Provides unified audio playback interface with notification sounds and volume control. +Uses cross-platform native audio playback without QtMultimedia dependency. +""" + +import os +import sys +import platform +import subprocess +import threading +from typing import Optional, Union +from pathlib import Path + +try: + from PySide6.QtCore import QObject, Signal + + PYSIDE6_AVAILABLE = True +except ImportError: + PYSIDE6_AVAILABLE = False + print("警告: PySide6.QtCore 不可用,音频功能将被禁用", file=sys.stderr) + + # 创建虚拟的Signal类用于回退 + class Signal: + def __init__(self, *_): + pass + + def connect(self, *_): + pass + + def emit(self, *_): + pass + + # 创建虚拟的QObject类用于回退 + class QObject: + def __init__(self, parent=None): + pass + + +class AudioManager(QObject): + """ + 音频管理器类 + Audio Manager Class + + 管理应用程序的音频播放功能,包括提示音、音量控制等。 + 使用跨平台的原生音频播放,无需 QtMultimedia 依赖。 + Manages application audio playback including notification sounds and volume control. + Uses cross-platform native audio playback without QtMultimedia dependency. + """ + + # 信号定义 + audio_played = Signal(str) # 音频播放完成信号 + audio_error = Signal(str) # 音频播放错误信号 + + def __init__(self, parent: Optional[QObject] = None): + super().__init__(parent) + + self._volume: float = 0.5 # 默认音量50% + self._enabled: bool = True # 默认启用音频 + self._current_audio_file: Optional[str] = None + self._system_type = platform.system().lower() + self._audio_backend: Optional[str] = None + + # 初始化音频系统 + self._initialize_audio() + + def _initialize_audio(self) -> bool: + """ + 初始化音频系统 + Initialize audio system + + Returns: + bool: 初始化是否成功 + """ + try: + # 检测系统音频播放能力 + if self._system_type == "windows": + # Windows: 检查是否有 winsound 或 PowerShell + try: + import winsound + + self._audio_backend = "winsound" + print("音频系统初始化成功 (Windows winsound)", file=sys.stderr) + return True + except ImportError: + # 回退到 PowerShell + self._audio_backend = "powershell" + print("音频系统初始化成功 (Windows PowerShell)", file=sys.stderr) + return True + + elif self._system_type == "darwin": + # macOS: 使用 afplay + self._audio_backend = "afplay" + print("音频系统初始化成功 (macOS afplay)", file=sys.stderr) + return True + + elif self._system_type == "linux": + # Linux: 尝试多种播放器 + for player in ["aplay", "paplay", "play"]: + if self._check_command_available(player): + self._audio_backend = player + print(f"音频系统初始化成功 (Linux {player})", file=sys.stderr) + return True + + # 如果都不可用,使用系统默认提示音 + print( + "Linux 原生音频播放器不可用,将使用系统默认提示音", file=sys.stderr + ) + self._audio_backend = "system_beep" + return True + else: + print(f"不支持的操作系统: {self._system_type}", file=sys.stderr) + self._audio_backend = None + return False + + except Exception as e: + print(f"音频系统初始化失败: {e}", file=sys.stderr) + self._audio_backend = None + return False + + def _check_command_available(self, command: str) -> bool: + """检查命令是否可用""" + try: + subprocess.run([command, "--version"], capture_output=True, timeout=5) + return True + except ( + subprocess.SubprocessError, + FileNotFoundError, + subprocess.TimeoutExpired, + ): + return False + + def _on_audio_finished(self, audio_file: str): + """音频播放完成回调""" + # 播放完成 + if audio_file: + self.audio_played.emit(audio_file) + + def set_enabled(self, enabled: bool): + """ + 设置音频是否启用 + Set whether audio is enabled + + Args: + enabled: 是否启用音频 + """ + self._enabled = enabled + + def is_enabled(self) -> bool: + """ + 获取音频是否启用 + Get whether audio is enabled + + Returns: + bool: 音频是否启用 + """ + return self._enabled and self._audio_backend is not None + + def set_volume(self, volume: Union[int, float]): + """ + 设置音量 + Set volume + + Args: + volume: 音量值 (0-100 或 0.0-1.0) + """ + # 标准化音量值到0.0-1.0范围 + if isinstance(volume, int) and volume > 1: + volume = volume / 100.0 + + self._volume = max(0.0, min(1.0, float(volume))) + + # 注意:原生音频播放器通常不支持程序化音量控制 + # 这里保存音量设置主要用于兼容性 + print( + f"设置音量为 {self._volume:.1%}(原生播放器可能不支持程序化音量控制)", + file=sys.stderr, + ) + + def get_volume(self) -> float: + """ + 获取当前音量 + Get current volume + + Returns: + float: 当前音量 (0.0-1.0) + """ + return self._volume + + def validate_audio_file(self, audio_file: str) -> tuple[bool, str]: + """ + 验证音频文件是否适合作为提示音 + Validate if audio file is suitable for notification sound + + Args: + audio_file: 音频文件路径 + + Returns: + tuple[bool, str]: (是否有效, 提示信息) + """ + if not os.path.exists(audio_file): + return False, "文件不存在" + + # 检查文件大小(建议小于1MB) + file_size = os.path.getsize(audio_file) + if file_size > 1024 * 1024: # 1MB + return False, f"文件过大 ({file_size // 1024}KB),建议使用小于1MB的音频文件" + + # 检查文件扩展名 + ext = Path(audio_file).suffix.lower() + supported_formats = [".wav", ".mp3", ".ogg", ".flac", ".aac"] + if ext not in supported_formats: + return False, f"不支持的格式 {ext},支持: {', '.join(supported_formats)}" + + # 基本验证通过,文件格式和大小都符合要求 + return True, "音频文件有效" + + def play_notification_sound(self, audio_file: Optional[str] = None) -> bool: + """ + 播放提示音 + Play notification sound + + Args: + audio_file: 音频文件路径,如果为None则使用默认提示音 + + Returns: + bool: 是否成功开始播放 + """ + if not self.is_enabled(): + return False + + if not self._audio_backend: + print("音频后端不可用", file=sys.stderr) + return False + + try: + # 确定要播放的音频文件 + if audio_file is None: + audio_file = self._get_default_notification_sound() + + # 如果没有音频文件,使用系统默认提示音 + if not audio_file or not os.path.exists(audio_file): + print( + f"音频文件不存在,使用系统默认提示音: {audio_file}", file=sys.stderr + ) + return self._play_system_notification_sound() + + self._current_audio_file = audio_file + + # 根据后端播放音频 + success = self._play_audio_with_backend(audio_file) + + if success: + # 异步发送播放完成信号 + threading.Timer(0.1, self._on_audio_finished, args=[audio_file]).start() + + return success + + except Exception as e: + print(f"播放提示音失败: {e}", file=sys.stderr) + self.audio_error.emit(str(e)) + return False + + def _play_audio_with_backend(self, audio_file: str) -> bool: + """使用指定后端播放音频""" + try: + if self._audio_backend == "winsound": + import winsound + + # 异步播放 + winsound.PlaySound( + audio_file, winsound.SND_FILENAME | winsound.SND_ASYNC + ) + return True + + elif self._audio_backend == "powershell": + # Windows PowerShell 播放 + cmd = f'powershell -c "(New-Object Media.SoundPlayer \\"{audio_file}\\").PlaySync()"' + subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + + elif self._audio_backend == "afplay": + # macOS afplay + subprocess.Popen( + ["afplay", audio_file], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + + elif self._audio_backend in ["aplay", "paplay", "play"]: + # Linux 音频播放器 + subprocess.Popen( + [self._audio_backend, audio_file], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + + elif self._audio_backend == "system_beep": + # 系统默认提示音回退方案 + return self._play_system_notification_sound() + + else: + print(f"未知的音频后端: {self._audio_backend}", file=sys.stderr) + return False + + except Exception as e: + print(f"音频播放失败: {e}", file=sys.stderr) + return False + + def _get_default_notification_sound(self) -> Optional[str]: + """ + 获取默认提示音文件路径 - uv安装兼容版本 + Get default notification sound file path - uv installation compatible version + + Returns: + str: 默认提示音文件路径 + """ + # 策略1:尝试从Qt资源系统获取(最可靠) + resource_path = ":/sounds/notification.wav" + if self._check_qt_resource(resource_path): + print("使用Qt资源系统音频文件", file=sys.stderr) + return resource_path + + # 策略2:尝试使用importlib.resources(Python 3.9+,uv安装优先) + try: + if sys.version_info >= (3, 9): + import importlib.resources as resources + + try: + # 尝试直接访问资源 + with resources.path( + "feedback_ui.resources.sounds", "notification.wav" + ) as sound_path: + if sound_path.exists(): + print( + f"使用importlib.resources音频文件: {sound_path}", + file=sys.stderr, + ) + return str(sound_path) + except (FileNotFoundError, ModuleNotFoundError): + # 尝试使用files API(更现代的方式) + try: + files = resources.files("feedback_ui.resources.sounds") + sound_file = files / "notification.wav" + if sound_file.is_file(): + # 创建临时文件以供播放 + import tempfile + + with tempfile.NamedTemporaryFile( + suffix=".wav", delete=False + ) as tmp: + tmp.write(sound_file.read_bytes()) + print( + f"使用importlib.resources临时音频文件: {tmp.name}", + file=sys.stderr, + ) + return tmp.name + except Exception as e: + print( + f"importlib.resources files API失败: {e}", file=sys.stderr + ) + except ImportError: + pass + + # 策略3:尝试从包内资源获取(开发模式) + try: + # 获取当前文件所在目录 + current_dir = Path(__file__).parent.parent + sound_file = current_dir / "resources" / "sounds" / "notification.wav" + + if sound_file.exists(): + print(f"使用包内音频文件: {sound_file}", file=sys.stderr) + return str(sound_file) + except Exception as e: + print(f"获取包内音频文件失败: {e}", file=sys.stderr) + + # 策略4:尝试从安装包数据目录获取(pkg_resources回退) + try: + import pkg_resources + + try: + sound_path = pkg_resources.resource_filename( + "feedback_ui.resources.sounds", "notification.wav" + ) + if os.path.exists(sound_path): + print(f"使用pkg_resources音频文件: {sound_path}", file=sys.stderr) + return sound_path + except (pkg_resources.DistributionNotFound, FileNotFoundError): + pass + except ImportError: + pass + + # 最后回退:使用系统默认提示音 + print("所有音频文件获取方式失败,使用系统默认提示音", file=sys.stderr) + return None + + def _play_system_notification_sound(self) -> bool: + """ + 播放系统默认提示音 + Play system default notification sound + + Returns: + bool: 是否成功播放 + """ + try: + if self._system_type == "windows": + # Windows 系统默认提示音 + if self._audio_backend == "winsound": + import winsound + + winsound.MessageBeep(winsound.MB_ICONINFORMATION) + return True + elif self._audio_backend == "powershell": + # PowerShell 播放系统提示音 + cmd = 'powershell -c "[console]::beep(800,200)"' + subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + + elif self._system_type == "darwin": + # macOS 系统默认提示音 + subprocess.Popen( + ["afplay", "/System/Library/Sounds/Glass.aiff"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return True + + elif self._system_type == "linux": + # Linux 系统提示音 (通过终端铃声) + print("\a", end="", flush=True) # 终端铃声 + return True + + return False + + except Exception as e: + print(f"播放系统提示音失败: {e}", file=sys.stderr) + return False + + def _check_qt_resource(self, resource_path: str) -> bool: + """ + 检查Qt资源是否存在 + Check if Qt resource exists + + Args: + resource_path: Qt资源路径 + + Returns: + bool: 资源是否存在 + """ + try: + if PYSIDE6_AVAILABLE: + from PySide6.QtCore import QFile + + return QFile.exists(resource_path) + else: + return False + except: + return False + + def stop_current_sound(self): + """ + 停止当前播放的音频 + Stop currently playing audio + + 注意:由于使用原生音频播放,无法精确控制停止, + 此方法主要用于兼容性,实际效果有限。 + """ + # 原生音频播放通常无法精确停止,这里主要用于兼容性 + print("停止音频播放请求(原生播放器可能无法精确停止)", file=sys.stderr) + + def is_playing(self) -> bool: + """ + 检查是否正在播放音频 + Check if audio is currently playing + + Returns: + bool: 是否正在播放 + + 注意:由于使用原生音频播放,无法精确检测播放状态, + 此方法主要用于兼容性,始终返回 False。 + """ + # 原生音频播放无法精确检测状态,为了兼容性返回 False + return False + + +# 全局音频管理器实例 +_global_audio_manager: Optional[AudioManager] = None + + +def get_audio_manager() -> Optional[AudioManager]: + """ + 获取全局音频管理器实例 + Get global audio manager instance + + Returns: + AudioManager: 音频管理器实例 + """ + global _global_audio_manager + + if _global_audio_manager is None: + _global_audio_manager = AudioManager() + + return _global_audio_manager + + +# 移除了便捷函数,直接使用 get_audio_manager().play_notification_sound() 更清晰 diff --git a/src/feedback_ui/utils/constants.py b/src/feedback_ui/utils/constants.py new file mode 100644 index 0000000..fd29c7a --- /dev/null +++ b/src/feedback_ui/utils/constants.py @@ -0,0 +1,110 @@ +# feedback_ui/utils/constants.py +from typing import TypedDict + +# --- 常量定义 (Constant Definitions) --- +APP_NAME = "InteractiveFeedbackMCP" +SETTINGS_GROUP_MAIN = "MainWindow_General" +SETTINGS_GROUP_CANNED_RESPONSES = "CannedResponses" +SETTINGS_KEY_GEOMETRY = "geometry" +SETTINGS_KEY_WINDOW_STATE = "windowState" +SETTINGS_KEY_WINDOW_PINNED = "windowPinned" +SETTINGS_KEY_PHRASES = "phrases" + +# 分割器设置 (Splitter Settings) +SETTINGS_KEY_SPLITTER_SIZES = "splitterSizes" +SETTINGS_KEY_SPLITTER_STATE = "splitterState" + +# 字体大小设置 (Font Size Settings) +SETTINGS_GROUP_FONTS = "FontSettings" +SETTINGS_KEY_PROMPT_FONT_SIZE = "promptFontSize" +SETTINGS_KEY_OPTIONS_FONT_SIZE = "optionsFontSize" +SETTINGS_KEY_INPUT_FONT_SIZE = "inputFontSize" + +# 默认字体大小 (Default Font Sizes) +DEFAULT_PROMPT_FONT_SIZE = 16 +DEFAULT_OPTIONS_FONT_SIZE = 13 +DEFAULT_INPUT_FONT_SIZE = 13 + +# 默认分割器配置 (Default Splitter Configuration) +DEFAULT_UPPER_AREA_HEIGHT = 250 +DEFAULT_LOWER_AREA_HEIGHT = 400 +DEFAULT_SPLITTER_RATIO = [250, 400] # 上:下 = 250:400 + +# 最小区域高度限制 (Minimum Area Height Limits) +MIN_UPPER_AREA_HEIGHT = 150 +MIN_LOWER_AREA_HEIGHT = 200 + +# 布局方向常量 (Layout Direction Constants) +LAYOUT_VERTICAL = "vertical" # 上下布局 +LAYOUT_HORIZONTAL = "horizontal" # 左右布局 +DEFAULT_LAYOUT_DIRECTION = LAYOUT_VERTICAL + +# 布局设置键 (Layout Settings Keys) +SETTINGS_KEY_LAYOUT_DIRECTION = "ui/layout_direction" +SETTINGS_KEY_HORIZONTAL_SPLITTER_SIZES = "ui/horizontal_splitter_sizes" +SETTINGS_KEY_HORIZONTAL_SPLITTER_STATE = "ui/horizontal_splitter_state" + +# 默认水平分割比例 (Default Horizontal Splitter Configuration) +# 调整为5:5比例,给左侧更多空间展示长文本和选项 +DEFAULT_HORIZONTAL_SPLITTER_RATIO = [500, 500] # 左右比例 5:5 +MIN_LEFT_AREA_WIDTH = 350 # 增加左侧最小宽度以容纳更多内容 +MIN_RIGHT_AREA_WIDTH = 400 + +MAX_IMAGE_WIDTH = 512 +MAX_IMAGE_HEIGHT = 512 +MAX_IMAGE_BYTES = 2097152 # 2MB (2兆字节) + +# 图像压缩相关常量 (Image Compression Constants) +IMAGE_QUALITY = 100 # JPEG质量 (100% = 无损压缩) +IMAGE_SCALE_FACTOR = 0.8 # 尺寸缩放因子 + +# 支持的图片文件扩展名 (Supported Image File Extensions) +SUPPORTED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"] + + +# --- 类型定义 (Type Definitions) --- +class ContentItem(TypedDict): + """ + Represents a single piece of content, which can be text, image, or file reference. + Corresponds to MCP message format. + 表示单个内容项,可以是文本、图像或文件引用。 + 对应 MCP 消息格式。 + """ + + type: str + text: str | None # Used for text type (用于文本类型) + data: str | None # Used for image type (base64 encoded) (用于图像类型,base64编码) + mimeType: str | None # Used for image type (e.g., "image/jpeg") (用于图像类型) + display_name: ( + str | None + ) # For file_reference type (e.g., "@filename.txt") (用于文件引用类型) + path: ( + str | None + ) # Full path to the file for file_reference type (文件引用的完整路径) + + +class FeedbackResult(TypedDict): + """ + The structured result returned by the feedback UI, containing a list of content items. + 反馈UI返回的结构化结果,包含内容项列表。 + """ + + content: list[ContentItem] + + +# 已删除终端相关常量 - 终端功能已移除 + +# 选项间距相关常量 (Option Spacing Constants) +DEFAULT_OPTION_SPACING = 8 # 默认选项间距 +MAX_OPTION_SPACING = 24 # 最大选项间距(3倍限制) +MIN_OPTION_SPACING = 6 # 最小选项间距 +OPTION_SPACING_MULTIPLIER = 3 # 间距倍数限制 + +# 截图功能相关常量 (Screenshot Feature Constants) +SCREENSHOT_MIN_SIZE = 10 # 最小截图尺寸(像素) +SCREENSHOT_OVERLAY_OPACITY = 100 # 遮罩透明度 (0-255) +SCREENSHOT_BORDER_COLOR = (0, 120, 215) # 选择框边框颜色 (RGB) +SCREENSHOT_BORDER_WIDTH = 2 # 选择框边框宽度 +SCREENSHOT_TEXT_COLOR = (255, 255, 255) # 尺寸文本颜色 (RGB) +SCREENSHOT_WINDOW_MINIMIZE_DELAY = 500 # 主窗口最小化延迟(毫秒) +SCREENSHOT_FOCUS_DELAY = 100 # 截图后焦点设置延迟(毫秒) diff --git a/src/feedback_ui/utils/image_processor.py b/src/feedback_ui/utils/image_processor.py new file mode 100644 index 0000000..5cbb9bb --- /dev/null +++ b/src/feedback_ui/utils/image_processor.py @@ -0,0 +1,262 @@ +# feedback_ui/utils/image_processor.py +import base64 +from typing import Any + +from PySide6.QtCore import QBuffer, QByteArray, QIODevice +from PySide6.QtGui import QPixmap, Qt # Qt 已在之前添加 +from PySide6.QtWidgets import QMessageBox + +from .constants import ( + MAX_IMAGE_BYTES, + MAX_IMAGE_HEIGHT, + MAX_IMAGE_WIDTH, + IMAGE_QUALITY, + IMAGE_SCALE_FACTOR, + ContentItem, +) +from .resource_manager import managed_image_processing + + +def process_single_image(pixmap_to_save: QPixmap) -> dict[str, Any] | None: + """ + Processes a QPixmap into a dictionary containing Base64 encoded image data and its metadata. + The image is resized and compressed if necessary to meet defined limits. + Uses object pools to reduce memory allocation overhead. + + 将 QPixmap 处理为包含 Base64 编码图像数据及其元数据的字典。 + 如有必要,图像将被调整大小和压缩以满足定义的限制。 + 使用对象池减少内存分配开销。 + + Returns: + dict[str, Any] | None: 处理结果或None(如果失败) + """ + if pixmap_to_save is None or pixmap_to_save.isNull(): + return None + + # 图像缩放处理 + current_pixmap = pixmap_to_save + if ( + current_pixmap.width() > MAX_IMAGE_WIDTH + or current_pixmap.height() > MAX_IMAGE_HEIGHT + ): + current_pixmap = current_pixmap.scaled( + MAX_IMAGE_WIDTH, + MAX_IMAGE_HEIGHT, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + # 使用托管资源处理图像 (V3.2 资源管理优化) + try: + with managed_image_processing() as resources: + return _process_image_with_managed_resources(current_pixmap, resources) + except Exception as e: + print(f"托管资源处理失败,使用回退方法: {e}") + return _process_image_fallback(current_pixmap) + + +def _process_image_with_managed_resources( + pixmap: QPixmap, resources: dict +) -> dict[str, Any] | None: + """ + 使用托管资源处理图像 (简化版本 - 100%质量,超过2MB时缩小尺寸) + Process image using managed resources (Simplified - 100% quality, scale down if > 2MB) + """ + save_format = "JPEG" + mime_type = "image/jpeg" + current_pixmap = pixmap + byte_array = resources["byte_array"] + + # 最多尝试10次缩放(防止无限循环) + max_attempts = 10 + attempt = 0 + + while attempt < max_attempts: + # 重置资源状态 + byte_array.clear() + + # 正确创建QBuffer - 直接关联byte_array + buffer = QBuffer(byte_array) + + # 尝试保存图像 + if buffer.open(QIODevice.OpenModeFlag.WriteOnly): + try: + if current_pixmap.save(buffer, save_format, IMAGE_QUALITY): + buffer.close() + + # 检查大小是否符合要求 + if byte_array.size() <= MAX_IMAGE_BYTES: + return _create_image_result( + current_pixmap, + byte_array, + save_format, + mime_type, + IMAGE_QUALITY, + ) + else: + # 超过大小限制,缩小图片 + new_width = int(current_pixmap.width() * IMAGE_SCALE_FACTOR) + new_height = int(current_pixmap.height() * IMAGE_SCALE_FACTOR) + + # 防止图片过小 + if new_width < 32 or new_height < 32: + break + + current_pixmap = current_pixmap.scaled( + new_width, + new_height, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + attempt += 1 + continue + except Exception: + pass + finally: + if buffer.isOpen(): + buffer.close() + + break + + # 无法满足大小要求 + _show_image_error("无法将图像压缩到合适的大小") + return None + + +def _process_image_fallback(pixmap: QPixmap) -> dict[str, Any] | None: + """ + 回退处理方法(不使用对象池,简化版本) + Fallback processing method (without object pools, simplified) + """ + save_format = "JPEG" + mime_type = "image/jpeg" + current_pixmap = pixmap + + # 最多尝试10次缩放(防止无限循环) + max_attempts = 10 + attempt = 0 + + while attempt < max_attempts: + byte_array = QByteArray() + buffer = QBuffer(byte_array) + + if buffer.open(QIODevice.OpenModeFlag.WriteOnly): + try: + if current_pixmap.save(buffer, save_format, IMAGE_QUALITY): + buffer.close() + + if byte_array.size() <= MAX_IMAGE_BYTES: + return _create_image_result( + current_pixmap, + byte_array, + save_format, + mime_type, + IMAGE_QUALITY, + ) + else: + # 超过大小限制,缩小图片 + new_width = int(current_pixmap.width() * IMAGE_SCALE_FACTOR) + new_height = int(current_pixmap.height() * IMAGE_SCALE_FACTOR) + + # 防止图片过小 + if new_width < 32 or new_height < 32: + break + + current_pixmap = current_pixmap.scaled( + new_width, + new_height, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + attempt += 1 + continue + except Exception: + pass + finally: + if buffer.isOpen(): + buffer.close() + + break + + _show_image_error("无法将图像压缩到合适的大小") + return None + + +def _create_image_result( + pixmap: QPixmap, + byte_array: QByteArray, + save_format: str, + mime_type: str, + quality: int, +) -> dict[str, Any]: + """ + 创建图像处理结果 + Create image processing result + """ + image_data_bytes = byte_array.data() + if not image_data_bytes: + _show_image_error("无法获取图像数据") + return None + + try: + base64_encoded_data = base64.b64encode(image_data_bytes).decode("utf-8") + + metadata = { + "width": pixmap.width(), + "height": pixmap.height(), + "format": save_format.lower(), + "size": byte_array.size(), + "compression_quality_used": quality, + } + + image_data_dict: ContentItem = { + "type": "image", + "text": None, + "data": base64_encoded_data, + "mimeType": mime_type, + "display_name": None, + "path": None, + } + + return { + "image_data": image_data_dict, + "metadata": metadata, + } + + except Exception as e: + _show_image_error(f"图像数据编码失败: {e}") + return None + + +def _show_image_error(message: str) -> None: + """ + 显示图像处理错误消息 + Show image processing error message + """ + QMessageBox.critical(None, "图像处理错误 (Image Processing Error)", message) + + +def get_image_items_from_widgets(image_widgets: dict[int, Any]) -> list[ContentItem]: + """ + Collects processed image data (as ContentItem) for all image widgets. + The 'Any' for image_widgets value should ideally be ImagePreviewWidget. + + 收集所有图像小部件已处理的图像数据(作为 ContentItem)。 + image_widgets 值的 'Any' 类型理想情况下应为 ImagePreviewWidget。 + """ + processed_image_items: list[ContentItem] = [] + # 使用 list(image_widgets.keys()) 以防在迭代时修改字典 (Use list() in case dict is modified during iteration) + for image_id in list(image_widgets.keys()): + widget = image_widgets.get(image_id) + if widget and hasattr( + widget, "original_pixmap" + ): # 确保 widget 是 ImagePreviewWidget 的实例 (Ensure widget is instance of ImagePreviewWidget) + pixmap = ( + widget.original_pixmap + ) # original_pixmap 应该是高分辨率版本 (should be full-res version) + processed_data = process_single_image(pixmap) + if processed_data and "image_data" in processed_data: + # 确保项目符合 ContentItem (Ensure the item conforms to ContentItem) + img_item: ContentItem = processed_data["image_data"] + processed_image_items.append(img_item) + return processed_image_items diff --git a/src/feedback_ui/utils/object_pool.py b/src/feedback_ui/utils/object_pool.py new file mode 100644 index 0000000..987dcbf --- /dev/null +++ b/src/feedback_ui/utils/object_pool.py @@ -0,0 +1,287 @@ +# feedback_ui/utils/object_pool.py + +""" +对象池模式实现 +Object Pool Pattern Implementation + +提供高效的对象重用机制,减少对象创建和销毁的开销, +特别适用于重对象如QByteArray、QBuffer、QPixmap等。 + +Provides efficient object reuse mechanism to reduce object creation +and destruction overhead, especially suitable for heavy objects +like QByteArray, QBuffer, QPixmap, etc. +""" + +import threading +from typing import TypeVar, Generic, List, Callable, Optional, Any, Dict + +T = TypeVar("T") + + +class ObjectPool(Generic[T]): + """ + 通用对象池基类 + Generic Object Pool Base Class + + 实现LRU淘汰策略、线程安全和对象重置功能 + Implements LRU eviction strategy, thread safety and object reset functionality + """ + + def __init__( + self, + factory: Callable[[], T], + max_size: int = 50, + reset_func: Optional[Callable[[T], None]] = None, + ): + """ + 初始化对象池 + Initialize object pool + + Args: + factory: 对象创建工厂函数 + max_size: 池最大大小 + reset_func: 对象重置函数 + """ + self._factory = factory + self._max_size = max_size + self._reset_func = reset_func + self._pool: List[T] = [] + self._lock = threading.Lock() + + # 统计信息 + self._created_count = 0 + self._acquired_count = 0 + self._released_count = 0 + self._hit_count = 0 + self._miss_count = 0 + + def acquire(self) -> T: + """ + 从池中获取对象 + Acquire object from pool + + Returns: + T: 可用的对象实例 + """ + with self._lock: + self._acquired_count += 1 + + if self._pool: + # 从池中获取(LRU:取最后一个) + obj = self._pool.pop() + self._hit_count += 1 + return obj + else: + # 池为空,创建新对象 + obj = self._factory() + self._created_count += 1 + self._miss_count += 1 + return obj + + def release(self, obj: T) -> None: + """ + 将对象归还到池中 + Release object back to pool + + Args: + obj: 要归还的对象 + """ + if obj is None: + return + + with self._lock: + self._released_count += 1 + + if len(self._pool) < self._max_size: + # 重置对象状态 + if self._reset_func: + try: + self._reset_func(obj) + except Exception as e: + # 重置失败,不放回池中 + return + + # 放回池中(LRU:放在末尾) + self._pool.append(obj) + + def clear(self) -> None: + """ + 清空对象池 + Clear object pool + """ + with self._lock: + self._pool.clear() + + def get_stats(self) -> Dict[str, Any]: + """ + 获取池统计信息 + Get pool statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + with self._lock: + total_requests = self._hit_count + self._miss_count + hit_rate = ( + (self._hit_count / total_requests * 100) if total_requests > 0 else 0 + ) + + return { + "pool_size": len(self._pool), + "max_size": self._max_size, + "created_count": self._created_count, + "acquired_count": self._acquired_count, + "released_count": self._released_count, + "hit_count": self._hit_count, + "miss_count": self._miss_count, + "hit_rate_percent": round(hit_rate, 2), + "utilization_percent": round(len(self._pool) / self._max_size * 100, 2), + } + + +class PooledResource(Generic[T]): + """ + 池化资源上下文管理器 + Pooled Resource Context Manager + + 确保资源正确归还到池中 + Ensures resources are properly returned to pool + """ + + def __init__(self, pool: ObjectPool[T]): + self._pool = pool + self._resource: Optional[T] = None + + def __enter__(self) -> T: + self._resource = self._pool.acquire() + return self._resource + + def __exit__(self, exc_type, exc_val, exc_tb): + if self._resource is not None: + self._pool.release(self._resource) + self._resource = None + + +# Qt对象专用池实现 +try: + from PySide6.QtCore import QByteArray, QBuffer + from PySide6.QtGui import QPixmap + + class QByteArrayPool(ObjectPool[QByteArray]): + """QByteArray对象池""" + + def __init__(self, max_size: int = 20): + super().__init__( + factory=lambda: QByteArray(), + max_size=max_size, + reset_func=self._reset_byte_array, + ) + + @staticmethod + def _reset_byte_array(byte_array: QByteArray) -> None: + """重置QByteArray对象""" + byte_array.clear() + + class QBufferPool(ObjectPool[QBuffer]): + """QBuffer对象池""" + + def __init__(self, max_size: int = 10): + super().__init__( + factory=lambda: QBuffer(), + max_size=max_size, + reset_func=self._reset_buffer, + ) + + @staticmethod + def _reset_buffer(buffer: QBuffer) -> None: + """重置QBuffer对象""" + if buffer.isOpen(): + buffer.close() + buffer.setData(QByteArray()) + + class QPixmapPool(ObjectPool[QPixmap]): + """QPixmap对象池(用于临时缩放等操作)""" + + def __init__(self, max_size: int = 15): + super().__init__( + factory=lambda: QPixmap(), + max_size=max_size, + reset_func=self._reset_pixmap, + ) + + @staticmethod + def _reset_pixmap(pixmap: QPixmap) -> None: + """重置QPixmap对象""" + # QPixmap没有clear方法,创建空的pixmap + pixmap.swap(QPixmap()) + +except ImportError: + # 如果PySide6不可用,提供空实现 + QByteArrayPool = None + QBufferPool = None + QPixmapPool = None + + +# 全局对象池实例 +_byte_array_pool: Optional[QByteArrayPool] = None +_buffer_pool: Optional[QBufferPool] = None +_pixmap_pool: Optional[QPixmapPool] = None + + +def _get_or_create_pool(pool_class, pool_instance_var: str): + """通用池获取函数,减少重复代码""" + if pool_class is None: + return None + + pool_instance = globals().get(pool_instance_var) + if pool_instance is None: + pool_instance = pool_class() + globals()[pool_instance_var] = pool_instance + return pool_instance + + +def get_byte_array_pool() -> Optional[QByteArrayPool]: + """获取全局QByteArray池""" + return _get_or_create_pool(QByteArrayPool, "_byte_array_pool") + + +def get_buffer_pool() -> Optional[QBufferPool]: + """获取全局QBuffer池""" + return _get_or_create_pool(QBufferPool, "_buffer_pool") + + +def get_pixmap_pool() -> Optional[QPixmapPool]: + """获取全局QPixmap池""" + return _get_or_create_pool(QPixmapPool, "_pixmap_pool") + + +def get_all_pool_stats() -> Dict[str, Dict[str, Any]]: + """ + 获取所有池的统计信息 + Get statistics for all pools + + Returns: + Dict[str, Dict[str, Any]]: 所有池的统计信息 + """ + stats = {} + + if _byte_array_pool: + stats["byte_array_pool"] = _byte_array_pool.get_stats() + + if _buffer_pool: + stats["buffer_pool"] = _buffer_pool.get_stats() + + if _pixmap_pool: + stats["pixmap_pool"] = _pixmap_pool.get_stats() + + return stats + + +def clear_all_pools() -> None: + """清空所有全局池""" + if _byte_array_pool: + _byte_array_pool.clear() + if _buffer_pool: + _buffer_pool.clear() + if _pixmap_pool: + _pixmap_pool.clear() diff --git a/src/feedback_ui/utils/platform_utils.py b/src/feedback_ui/utils/platform_utils.py new file mode 100644 index 0000000..c3be598 --- /dev/null +++ b/src/feedback_ui/utils/platform_utils.py @@ -0,0 +1,159 @@ +# src/feedback_ui/utils/platform_utils.py +""" +平台相关工具函数 +Platform-related utility functions + +提供跨平台兼容性支持,包括操作系统检测和平台特定的UI文本。 +Provides cross-platform compatibility support, including OS detection and platform-specific UI text. +""" + +import platform +from typing import Dict, Tuple + + +def get_platform_info() -> Dict[str, str]: + """ + 获取平台信息 + Get platform information + + Returns: + Dict[str, str]: 包含平台信息的字典 + - system: 操作系统名称 ('Windows', 'Darwin', 'Linux') + - platform: 平台标识 ('windows', 'macos', 'linux') + - modifier_key: 修饰键名称 ('Ctrl', 'Cmd') + - modifier_symbol: 修饰键符号 ('Ctrl', '⌘') + """ + system = platform.system() + + if system == "Darwin": # macOS + return { + "system": system, + "platform": "macos", + "modifier_key": "Cmd", + "modifier_symbol": "⌘", + } + elif system == "Windows": + return { + "system": system, + "platform": "windows", + "modifier_key": "Ctrl", + "modifier_symbol": "Ctrl", + } + else: # Linux and others + return { + "system": system, + "platform": "linux", + "modifier_key": "Ctrl", + "modifier_symbol": "Ctrl", + } + + +def get_submit_shortcut_text(submit_method: str = "enter") -> Tuple[str, str]: + """ + 获取提交快捷键的显示文本 + Get submit shortcut display text + + Args: + submit_method: 提交方式 ('enter' 或 'ctrl_enter') + + Returns: + Tuple[str, str]: (中文文本, 英文文本) + """ + platform_info = get_platform_info() + modifier_key = platform_info["modifier_key"] + modifier_symbol = platform_info["modifier_symbol"] + + if submit_method == "ctrl_enter": + zh_text = f"{modifier_symbol}+Enter提交" + en_text = f"{modifier_key}+Enter to submit" + else: # enter + zh_text = "Enter提交" + en_text = "Enter to submit" + + return zh_text, en_text + + +def get_submit_method_options() -> Dict[str, Dict[str, str]]: + """ + 获取提交方式选项的显示文本 + Get submit method options display text + + Returns: + Dict[str, Dict[str, str]]: 提交方式选项的多语言文本 + """ + platform_info = get_platform_info() + modifier_key = platform_info["modifier_key"] + modifier_symbol = platform_info["modifier_symbol"] + + return { + "enter": {"zh_CN": "Enter键直接提交", "en_US": "Enter key to submit"}, + "ctrl_enter": { + "zh_CN": f"{modifier_symbol}+Enter组合键提交", + "en_US": f"{modifier_key}+Enter to submit", + }, + } + + +def get_placeholder_text(submit_method: str = "enter", language: str = "zh_CN") -> str: + """ + 获取输入框占位符文本 + Get input placeholder text + + Args: + submit_method: 提交方式 ('enter' 或 'ctrl_enter') + language: 语言代码 ('zh_CN' 或 'en_US') + + Returns: + str: 占位符文本 + """ + platform_info = get_platform_info() + modifier_key = platform_info["modifier_key"] + modifier_symbol = platform_info["modifier_symbol"] + + if language == "zh_CN": + if submit_method == "ctrl_enter": + return f"在此输入反馈... (可拖拽文件和图片到输入框,{modifier_symbol}+Enter提交反馈,Shift+Enter换行,Ctrl+V复制剪切板信息)" + else: + return "在此输入反馈... (可拖拽文件和图片到输入框,Enter提交反馈,Shift+Enter换行,Ctrl+V复制剪切板信息)" + else: # en_US + if submit_method == "ctrl_enter": + return f"Enter feedback here... (Drag files and images to input box, {modifier_key}+Enter to submit, Shift+Enter for newline, Ctrl+V to paste)" + else: + return "Enter feedback here... (Drag files and images to input box, Enter to submit, Shift+Enter for newline, Ctrl+V to paste)" + + +def is_macos() -> bool: + """ + 检查是否为macOS系统 + Check if running on macOS + + Returns: + bool: 是否为macOS + """ + return platform.system() == "Darwin" + + +def is_windows() -> bool: + """ + 检查是否为Windows系统 + Check if running on Windows + + Returns: + bool: 是否为Windows + """ + return platform.system() == "Windows" + + +def is_linux() -> bool: + """ + 检查是否为Linux系统 + Check if running on Linux + + Returns: + bool: 是否为Linux + """ + return platform.system() == "Linux" + + +# 注意:get_modifier_key_for_platform 和 get_modifier_symbol_for_platform 函数已移除 +# 请直接使用 get_platform_info()["modifier_key"] 和 get_platform_info()["modifier_symbol"] diff --git a/src/feedback_ui/utils/powershell_formatter.py b/src/feedback_ui/utils/powershell_formatter.py new file mode 100644 index 0000000..f19b5f6 --- /dev/null +++ b/src/feedback_ui/utils/powershell_formatter.py @@ -0,0 +1,125 @@ +""" +PowerShell输出格式化器 +PowerShell Output Formatter + +优化PowerShell命令输出的显示格式,提供更友好的文件大小显示。 +""" + +import re +from typing import List, Tuple + + +class PowerShellFormatter: + """PowerShell输出格式化器""" + + def __init__(self): + # 文件列表输出的正则表达式 + self.file_list_pattern = re.compile( + r"^([d\-][a-z\-]{4})\s+(\d{4}/\d{1,2}/\d{1,2})\s+(\d{1,2}:\d{2})\s+(\d+)?\s+(.+)$" + ) + + def format_file_size(self, size_bytes: int) -> str: + """格式化文件大小为人类可读格式""" + if size_bytes == 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB"] + size = float(size_bytes) + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + else: + return f"{size:.1f} {units[unit_index]}" + + def format_file_list_line(self, line: str) -> str: + """格式化文件列表行""" + match = self.file_list_pattern.match(line.strip()) + if not match: + return line # 不匹配的行保持原样 + + mode, date, time, size_str, name = match.groups() + + # 格式化文件大小 + if size_str: + size_bytes = int(size_str) + formatted_size = self.format_file_size(size_bytes) + # 右对齐文件大小,固定宽度 + size_display = f"{formatted_size:>8}" + else: + # 目录没有大小 + size_display = " " # 8个空格 + + # 重新组装行 + return f"{mode} {date} {time} {size_display} {name}" + + def format_output(self, text: str) -> str: + """格式化整个输出文本""" + lines = text.split("\n") + formatted_lines = [] + + for line in lines: + if line.strip(): + formatted_line = self.format_file_list_line(line) + formatted_lines.append(formatted_line) + else: + formatted_lines.append(line) + + return "\n".join(formatted_lines) + + def should_format_output(self, text: str) -> bool: + """判断是否应该格式化输出(检测是否为文件列表)""" + lines = text.strip().split("\n") + + # 检查是否包含典型的PowerShell文件列表标题 + header_patterns = [ + r"Mode\s+LastWriteTime\s+Length\s+Name", + r"----\s+-------------\s+------\s+----", + ] + + for line in lines[:5]: # 只检查前5行 + for pattern in header_patterns: + if re.search(pattern, line): + return True + + return False + + +# 全局格式化器实例 +_formatter = PowerShellFormatter() + + +def format_powershell_output(text: str) -> str: + """格式化PowerShell输出的便捷函数""" + if _formatter.should_format_output(text): + return _formatter.format_output(text) + return text + + +def format_file_size(size_bytes: int) -> str: + """格式化文件大小的便捷函数""" + return _formatter.format_file_size(size_bytes) + + +# 测试函数 +def test_formatter(): + """测试格式化器""" + test_input = """Mode LastWriteTime Length Name +---- ------------- ------ ---- +d---- 2025/6/6 14:02 .cursor +-a--- 2025/6/8 2:25 14505 安装与配置指南.md +-a--- 2025/6/8 2:25 12578 功能说明.md +-a--- 2025/6/9 21:58 178152 uv.lock""" + + print("原始输出:") + print(test_input) + print("\n格式化后:") + print(format_powershell_output(test_input)) + + +if __name__ == "__main__": + test_formatter() diff --git a/src/feedback_ui/utils/resource_manager.py b/src/feedback_ui/utils/resource_manager.py new file mode 100644 index 0000000..ac7e3ab --- /dev/null +++ b/src/feedback_ui/utils/resource_manager.py @@ -0,0 +1,405 @@ +# feedback_ui/utils/resource_manager.py + +""" +资源管理器 +Resource Manager + +提供统一的资源管理和上下文管理器,确保资源正确释放, +防止内存泄漏和资源泄漏。 + +Provides unified resource management and context managers to ensure +proper resource cleanup, preventing memory and resource leaks. +""" + +import os +import tempfile +import threading +import time +import weakref +from contextlib import contextmanager +from typing import Generator, Any, Dict, List, Optional, Callable, Union + +try: + from PySide6.QtCore import QByteArray, QBuffer, QIODevice + from PySide6.QtGui import QPixmap + + PYSIDE6_AVAILABLE = True +except ImportError: + PYSIDE6_AVAILABLE = False + +from .object_pool import get_byte_array_pool, get_buffer_pool, PooledResource + + +class ResourceTracker: + """ + 资源跟踪器 + Resource Tracker + + 跟踪和管理应用程序中的资源使用情况 + Tracks and manages resource usage in the application + """ + + def __init__(self): + self._resources: Dict[str, Any] = {} + self._temp_files: List[str] = [] + self._cleanup_callbacks: List[Callable] = [] + self._lock = threading.RLock() + self._creation_time = time.time() + + # 统计信息 + self._resource_count = 0 + self._cleanup_count = 0 + self._error_count = 0 + + def register_resource( + self, name: str, resource: Any, cleanup_func: Optional[Callable] = None + ) -> None: + """ + 注册资源 + Register resource + + Args: + name: 资源名称 + resource: 资源对象 + cleanup_func: 清理函数 + """ + with self._lock: + self._resources[name] = { + "resource": resource, + "cleanup_func": cleanup_func, + "created_at": time.time(), + } + self._resource_count += 1 + + def unregister_resource(self, name: str) -> bool: + """ + 注销资源 + Unregister resource + + Args: + name: 资源名称 + + Returns: + bool: 是否成功注销 + """ + with self._lock: + if name in self._resources: + resource_info = self._resources[name] + + # 执行清理函数 + if resource_info["cleanup_func"]: + try: + resource_info["cleanup_func"](resource_info["resource"]) + self._cleanup_count += 1 + except Exception as e: + self._error_count += 1 + print(f"资源清理失败 {name}: {e}") + + del self._resources[name] + return True + return False + + def register_temp_file(self, file_path: str) -> None: + """注册临时文件""" + with self._lock: + if file_path not in self._temp_files: + self._temp_files.append(file_path) + + def add_cleanup_callback(self, callback: Callable) -> None: + """添加清理回调""" + with self._lock: + self._cleanup_callbacks.append(callback) + + def cleanup_all(self) -> None: + """清理所有资源""" + with self._lock: + # 清理注册的资源 + for name in list(self._resources.keys()): + self.unregister_resource(name) + + # 清理临时文件 + for file_path in self._temp_files[:]: + try: + if os.path.exists(file_path): + os.remove(file_path) + self._temp_files.remove(file_path) + self._cleanup_count += 1 + except Exception as e: + self._error_count += 1 + print(f"临时文件清理失败 {file_path}: {e}") + + # 执行清理回调 + for callback in self._cleanup_callbacks: + try: + callback() + self._cleanup_count += 1 + except Exception as e: + self._error_count += 1 + print(f"清理回调失败: {e}") + + self._cleanup_callbacks.clear() + + def get_stats(self) -> Dict[str, Any]: + """获取统计信息""" + with self._lock: + return { + "active_resources": len(self._resources), + "temp_files": len(self._temp_files), + "cleanup_callbacks": len(self._cleanup_callbacks), + "total_created": self._resource_count, + "total_cleaned": self._cleanup_count, + "error_count": self._error_count, + "uptime_seconds": time.time() - self._creation_time, + } + + +# 全局资源跟踪器 +_global_resource_tracker = ResourceTracker() + + +@contextmanager +def managed_resource( + resource: Any, cleanup_func: Optional[Callable] = None, name: Optional[str] = None +) -> Generator[Any, None, None]: + """ + 托管资源上下文管理器 + Managed resource context manager + + Args: + resource: 资源对象 + cleanup_func: 清理函数 + name: 资源名称 + + Yields: + Any: 资源对象 + """ + resource_name = name or f"resource_{id(resource)}" + + try: + _global_resource_tracker.register_resource( + resource_name, resource, cleanup_func + ) + yield resource + finally: + _global_resource_tracker.unregister_resource(resource_name) + + +@contextmanager +def managed_temp_file( + suffix: str = "", prefix: str = "tmp_", dir: Optional[str] = None +) -> Generator[str, None, None]: + """ + 托管临时文件上下文管理器 + Managed temporary file context manager + + Args: + suffix: 文件后缀 + prefix: 文件前缀 + dir: 目录路径 + + Yields: + str: 临时文件路径 + """ + fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=dir) + os.close(fd) # 关闭文件描述符 + + try: + _global_resource_tracker.register_temp_file(temp_path) + yield temp_path + finally: + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except Exception as e: + print(f"临时文件清理失败 {temp_path}: {e}") + + +if PYSIDE6_AVAILABLE: + + @contextmanager + def managed_qbuffer() -> Generator[QBuffer, None, None]: + """ + 托管QBuffer上下文管理器 + Managed QBuffer context manager + """ + buffer_pool = get_buffer_pool() + + if buffer_pool: + # 使用对象池 + with PooledResource(buffer_pool) as buffer: + yield buffer + else: + # 回退到直接创建 + buffer = QBuffer() + try: + yield buffer + finally: + if buffer.isOpen(): + buffer.close() + + @contextmanager + def managed_qbytearray() -> Generator[QByteArray, None, None]: + """ + 托管QByteArray上下文管理器 + Managed QByteArray context manager + """ + byte_array_pool = get_byte_array_pool() + + if byte_array_pool: + # 使用对象池 + with PooledResource(byte_array_pool) as byte_array: + yield byte_array + else: + # 回退到直接创建 + byte_array = QByteArray() + try: + yield byte_array + finally: + byte_array.clear() + + @contextmanager + def managed_image_processing() -> Generator[Dict[str, Any], None, None]: + """ + 托管图像处理资源上下文管理器 + Managed image processing resources context manager + + Yields: + Dict[str, Any]: 包含图像处理所需资源的字典 + """ + resources = {} + temp_files = [] + + try: + # 获取池化资源 + byte_array_pool = get_byte_array_pool() + buffer_pool = get_buffer_pool() + + if byte_array_pool and buffer_pool: + # 使用对象池 + byte_array = byte_array_pool.acquire() + buffer = buffer_pool.acquire() + + resources.update( + { + "byte_array": byte_array, + "buffer": buffer, + "temp_files": temp_files, + "using_pools": True, + } + ) + + try: + yield resources + finally: + # 归还到池中 + buffer_pool.release(buffer) + byte_array_pool.release(byte_array) + else: + # 回退到直接创建 + byte_array = QByteArray() + buffer = QBuffer() + + resources.update( + { + "byte_array": byte_array, + "buffer": buffer, + "temp_files": temp_files, + "using_pools": False, + } + ) + + try: + yield resources + finally: + if buffer.isOpen(): + buffer.close() + byte_array.clear() + + finally: + # 清理临时文件 + for temp_file in temp_files: + try: + if os.path.exists(temp_file): + os.remove(temp_file) + except Exception as e: + print(f"临时文件清理失败 {temp_file}: {e}") + + +class ResourceManager: + """ + 资源管理器类 + Resource Manager Class + + 提供高级的资源管理功能 + Provides advanced resource management functionality + """ + + def __init__(self): + self._tracker = ResourceTracker() + self._weak_refs: List[weakref.ref] = [] + + def track_object(self, obj: Any, cleanup_func: Optional[Callable] = None) -> None: + """ + 跟踪对象生命周期 + Track object lifecycle + + Args: + obj: 要跟踪的对象 + cleanup_func: 清理函数 + """ + + def cleanup_callback(ref): + if cleanup_func: + try: + cleanup_func() + except Exception as e: + print(f"对象清理失败: {e}") + + # 从弱引用列表中移除 + if ref in self._weak_refs: + self._weak_refs.remove(ref) + + weak_ref = weakref.ref(obj, cleanup_callback) + self._weak_refs.append(weak_ref) + + def cleanup_dead_references(self) -> int: + """ + 清理死引用 + Cleanup dead references + + Returns: + int: 清理的引用数量 + """ + initial_count = len(self._weak_refs) + self._weak_refs = [ref for ref in self._weak_refs if ref() is not None] + return initial_count - len(self._weak_refs) + + def get_resource_stats(self) -> Dict[str, Any]: + """获取资源统计信息""" + tracker_stats = self._tracker.get_stats() + + return { + "tracker_stats": tracker_stats, + "weak_references": len(self._weak_refs), + "dead_references": self.cleanup_dead_references(), + } + + def cleanup_all(self) -> None: + """清理所有资源""" + self._tracker.cleanup_all() + self.cleanup_dead_references() + + +def get_global_resource_tracker() -> ResourceTracker: + """获取全局资源跟踪器""" + return _global_resource_tracker + + +def cleanup_global_resources() -> None: + """清理全局资源""" + _global_resource_tracker.cleanup_all() + + +def get_resource_stats() -> Dict[str, Any]: + """获取全局资源统计信息""" + return _global_resource_tracker.get_stats() diff --git a/src/feedback_ui/utils/settings_manager.py b/src/feedback_ui/utils/settings_manager.py new file mode 100644 index 0000000..52a738b --- /dev/null +++ b/src/feedback_ui/utils/settings_manager.py @@ -0,0 +1,322 @@ +# feedback_ui/utils/settings_manager.py + +from PySide6.QtCore import QByteArray, QObject, QSettings + +from .constants import ( + APP_NAME, + DEFAULT_HORIZONTAL_SPLITTER_RATIO, + DEFAULT_INPUT_FONT_SIZE, + DEFAULT_LAYOUT_DIRECTION, + DEFAULT_OPTIONS_FONT_SIZE, + DEFAULT_PROMPT_FONT_SIZE, + DEFAULT_SPLITTER_RATIO, + SETTINGS_GROUP_CANNED_RESPONSES, + SETTINGS_GROUP_FONTS, + SETTINGS_GROUP_MAIN, + SETTINGS_KEY_GEOMETRY, + SETTINGS_KEY_HORIZONTAL_SPLITTER_SIZES, + SETTINGS_KEY_HORIZONTAL_SPLITTER_STATE, + SETTINGS_KEY_INPUT_FONT_SIZE, + SETTINGS_KEY_LAYOUT_DIRECTION, + SETTINGS_KEY_OPTIONS_FONT_SIZE, + SETTINGS_KEY_PHRASES, + SETTINGS_KEY_PROMPT_FONT_SIZE, + SETTINGS_KEY_SPLITTER_SIZES, + SETTINGS_KEY_SPLITTER_STATE, + SETTINGS_KEY_WINDOW_PINNED, + SETTINGS_KEY_WINDOW_STATE, +) + + +class SettingsManager(QObject): + """ + Manages application settings using QSettings. + 使用 QSettings 管理应用程序设置。 + """ + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + # 在 Qt 中,通常使用组织名称和应用程序名称。 + # 如果您的应用程序很简单,可以为两者使用相同的名称。 + # In Qt, organization name and application name are typically used. + # If your app is simple, you can use the same name for both. + self.settings = QSettings(APP_NAME, APP_NAME) + + # --- Main Window Settings (主窗口设置) --- + def get_main_window_geometry(self) -> QByteArray | None: + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + # Provide a default value of None if not found or wrong type + # 如果未找到或类型错误,则提供默认值 None + geometry = self.settings.value(SETTINGS_KEY_GEOMETRY, defaultValue=None) + self.settings.endGroup() + return geometry if isinstance(geometry, QByteArray) else None + + def set_main_window_geometry(self, geometry: QByteArray): + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue(SETTINGS_KEY_GEOMETRY, geometry) + self.settings.endGroup() + self.settings.sync() # 确保设置立即写入 (Ensure settings are written immediately) + + def get_main_window_state(self) -> QByteArray | None: + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + state = self.settings.value(SETTINGS_KEY_WINDOW_STATE, defaultValue=None) + self.settings.endGroup() + return state if isinstance(state, QByteArray) else None + + def set_main_window_state(self, state: QByteArray): + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue(SETTINGS_KEY_WINDOW_STATE, state) + self.settings.endGroup() + self.settings.sync() + + def get_main_window_size(self) -> tuple | None: + """获取保存的窗口大小 (宽, 高)""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + width = self.settings.value("window_width", defaultValue=None, type=int) + height = self.settings.value("window_height", defaultValue=None, type=int) + self.settings.endGroup() + + if width is not None and height is not None: + return (width, height) + return None + + def set_main_window_size(self, width: int, height: int): + """单独保存窗口大小 (宽, 高)""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue("window_width", width) + self.settings.setValue("window_height", height) + self.settings.endGroup() + self.settings.sync() + + def get_main_window_position(self) -> tuple[int, int] | None: + """获取保存的窗口位置 (x, y)""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + x = self.settings.value("window_x", defaultValue=None, type=int) + y = self.settings.value("window_y", defaultValue=None, type=int) + self.settings.endGroup() + + if x is not None and y is not None: + return (x, y) + return None + + def set_main_window_position(self, x: int, y: int): + """保存窗口位置 (x, y)""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue("window_x", x) + self.settings.setValue("window_y", y) + self.settings.endGroup() + self.settings.sync() + + def get_main_window_pinned(self) -> bool: + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + # Default to False if not found + pinned = self.settings.value(SETTINGS_KEY_WINDOW_PINNED, False, type=bool) + self.settings.endGroup() + return pinned + + def set_main_window_pinned(self, pinned: bool): + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue(SETTINGS_KEY_WINDOW_PINNED, pinned) + self.settings.endGroup() + self.settings.sync() + + # --- Canned Responses Settings (常用语设置) --- + def get_canned_responses(self) -> list[str]: + self.settings.beginGroup(SETTINGS_GROUP_CANNED_RESPONSES) + responses = self.settings.value( + SETTINGS_KEY_PHRASES, [] + ) # Default to empty list + self.settings.endGroup() + + if responses is None: + return [] + # 确保它是字符串列表,并过滤掉空/仅空白的字符串 + # Ensure it's a list of strings, filter out empty/whitespace-only strings + return ( + [str(r) for r in responses if isinstance(r, str) and str(r).strip()] + if isinstance(responses, list) + else [] + ) + + def set_canned_responses(self, responses: list[str]): + self.settings.beginGroup(SETTINGS_GROUP_CANNED_RESPONSES) + self.settings.setValue(SETTINGS_KEY_PHRASES, responses) + self.settings.endGroup() + self.settings.sync() + + # --- Splitter Settings (分割器设置) --- + def get_splitter_sizes(self) -> list[int]: + """获取保存的分割器尺寸比例""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + sizes = self.settings.value(SETTINGS_KEY_SPLITTER_SIZES, DEFAULT_SPLITTER_RATIO) + self.settings.endGroup() + + # 确保返回有效的整数列表 + if isinstance(sizes, list) and len(sizes) == 2: + try: + return [int(sizes[0]), int(sizes[1])] + except (ValueError, TypeError): + return DEFAULT_SPLITTER_RATIO + return DEFAULT_SPLITTER_RATIO + + def set_splitter_sizes(self, sizes: list[int]): + """保存分割器尺寸比例""" + if len(sizes) == 2: + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue(SETTINGS_KEY_SPLITTER_SIZES, sizes) + self.settings.endGroup() + self.settings.sync() + + def get_splitter_state(self) -> QByteArray | None: + """获取分割器状态""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + state = self.settings.value(SETTINGS_KEY_SPLITTER_STATE, None) + self.settings.endGroup() + return state if isinstance(state, (QByteArray, type(None))) else None + + def set_splitter_state(self, state: QByteArray): + """保存分割器状态""" + self.settings.beginGroup(SETTINGS_GROUP_MAIN) + self.settings.setValue(SETTINGS_KEY_SPLITTER_STATE, state) + self.settings.endGroup() + self.settings.sync() + + def get_current_theme(self) -> str: + # 从配置中读取主题设置,若无则默认为 'dark' + return self.settings.value("ui/theme", "dark") + + def set_current_theme(self, theme_name: str): + self.settings.setValue("ui/theme", theme_name) + self.settings.sync() + + def get_current_language(self) -> str: + # 默认为 'zh_CN' (中文) + return self.settings.value("ui/language", "zh_CN") + + def set_current_language(self, lang_code: str): + self.settings.setValue("ui/language", lang_code) + self.settings.sync() + + # --- 布局方向设置 (Layout Direction Settings) --- + def get_layout_direction(self) -> str: + """获取布局方向设置""" + return self.settings.value( + SETTINGS_KEY_LAYOUT_DIRECTION, DEFAULT_LAYOUT_DIRECTION + ) + + def set_layout_direction(self, direction: str): + """设置布局方向""" + self.settings.setValue(SETTINGS_KEY_LAYOUT_DIRECTION, direction) + self.settings.sync() + + # --- 水平分割器设置 (Horizontal Splitter Settings) --- + def get_horizontal_splitter_sizes(self) -> list: + """获取水平分割器尺寸""" + try: + sizes = self.settings.value( + SETTINGS_KEY_HORIZONTAL_SPLITTER_SIZES, + DEFAULT_HORIZONTAL_SPLITTER_RATIO, + ) + if isinstance(sizes, list) and len(sizes) == 2: + return [int(size) for size in sizes] + except (ValueError, TypeError): + pass + return DEFAULT_HORIZONTAL_SPLITTER_RATIO + + def set_horizontal_splitter_sizes(self, sizes: list): + """设置水平分割器尺寸""" + if isinstance(sizes, list) and len(sizes) == 2: + self.settings.setValue(SETTINGS_KEY_HORIZONTAL_SPLITTER_SIZES, sizes) + self.settings.sync() + + def get_horizontal_splitter_state(self) -> bytes: + """获取水平分割器状态""" + state = self.settings.value(SETTINGS_KEY_HORIZONTAL_SPLITTER_STATE, b"") + return state if isinstance(state, bytes) else b"" + + def set_horizontal_splitter_state(self, state: bytes): + """设置水平分割器状态""" + if isinstance(state, bytes): + self.settings.setValue(SETTINGS_KEY_HORIZONTAL_SPLITTER_STATE, state) + self.settings.sync() + + # --- 字体大小设置 (Font Size Settings) --- + def get_prompt_font_size(self) -> int: + """获取提示区域字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + size = self.settings.value( + SETTINGS_KEY_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE, type=int + ) + self.settings.endGroup() + return size + + def set_prompt_font_size(self, size: int): + """设置提示区域字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + self.settings.setValue(SETTINGS_KEY_PROMPT_FONT_SIZE, size) + self.settings.endGroup() + self.settings.sync() + + def get_options_font_size(self) -> int: + """获取选项区域字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + size = self.settings.value( + SETTINGS_KEY_OPTIONS_FONT_SIZE, DEFAULT_OPTIONS_FONT_SIZE, type=int + ) + self.settings.endGroup() + return size + + def set_options_font_size(self, size: int): + """设置选项区域字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + self.settings.setValue(SETTINGS_KEY_OPTIONS_FONT_SIZE, size) + self.settings.endGroup() + self.settings.sync() + + def get_input_font_size(self) -> int: + """获取输入框字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + size = self.settings.value( + SETTINGS_KEY_INPUT_FONT_SIZE, DEFAULT_INPUT_FONT_SIZE, type=int + ) + self.settings.endGroup() + return size + + def set_input_font_size(self, size: int): + """设置输入框字体大小""" + self.settings.beginGroup(SETTINGS_GROUP_FONTS) + self.settings.setValue(SETTINGS_KEY_INPUT_FONT_SIZE, size) + self.settings.endGroup() + self.settings.sync() + + # 已删除终端相关设置方法 + + # --- Audio Settings (音频设置) --- + def get_audio_enabled(self) -> bool: + """获取音频是否启用""" + return self.settings.value("audio/enabled", True, type=bool) + + def set_audio_enabled(self, enabled: bool): + """设置音频是否启用""" + self.settings.setValue("audio/enabled", enabled) + self.settings.sync() + + def get_audio_volume(self) -> float: + """获取音频音量 (0.0-1.0)""" + volume = self.settings.value("audio/volume", 0.5, type=float) + return max(0.0, min(1.0, volume)) # 确保在有效范围内 + + def set_audio_volume(self, volume: float): + """设置音频音量 (0.0-1.0)""" + volume = max(0.0, min(1.0, volume)) # 确保在有效范围内 + self.settings.setValue("audio/volume", volume) + self.settings.sync() + + def get_notification_sound_path(self) -> str: + """获取提示音文件路径""" + return self.settings.value("audio/notification_sound_path", "") + + def set_notification_sound_path(self, path: str): + """设置提示音文件路径""" + self.settings.setValue("audio/notification_sound_path", path) + self.settings.sync() diff --git a/src/feedback_ui/utils/style_manager.py b/src/feedback_ui/utils/style_manager.py new file mode 100644 index 0000000..b886320 --- /dev/null +++ b/src/feedback_ui/utils/style_manager.py @@ -0,0 +1,108 @@ +# feedback_ui/utils/style_manager.py +from PySide6.QtCore import QFile, QIODevice +from PySide6.QtWidgets import QApplication + +from .settings_manager import SettingsManager + +# 必须导入刚刚编译的资源模块,否则无法访问资源路径 +# 注意:此导入是动态生成的,如果不存在,需要先编译.qrc文件 +try: + import feedback_ui.resources_rc +except ImportError: + # 在某些情况下,直接运行此模块可能无法找到 `resources_rc`。 + # 确保在应用程序启动前已生成此文件。 + print( + "Warning: Could not import resources_rc.py. Make sure it has been generated from resources.qrc." + ) + + +def apply_theme(app: QApplication, theme_name: str = "dark"): + """根据主题名称加载并应用QSS样式,并附加动态字体大小。""" + qss_path = f":/styles/{theme_name}.qss" + qss_file = QFile(qss_path) + + base_stylesheet = "" + if qss_file.open(QIODevice.ReadOnly | QIODevice.Text): + base_stylesheet = qss_file.readAll().data().decode("utf-8") + qss_file.close() + else: + print(f"错误:无法打开主题文件 {qss_path}") + # 如果主题文件加载失败,提供一个基础的回退样式 + app.setStyleSheet("QWidget { background-color: #333; color: white; }") + return + + # 设置QPalette以确保复选框等控件使用正确的颜色 + _apply_theme_palette(app, theme_name) + + # 从设置中获取动态字体大小 + settings_manager = SettingsManager() + prompt_font_size = settings_manager.get_prompt_font_size() + options_font_size = settings_manager.get_options_font_size() + input_font_size = settings_manager.get_input_font_size() + + # 创建动态字体样式 - 恢复输入框独立的字体大小控制 + dynamic_font_style = f""" +/* Dynamically Applied Font Sizes */ +SelectableLabel[class="prompt-label"] {{ + font-size: {prompt_font_size}pt; +}} +SelectableLabel[class="option-label"] {{ + font-size: {options_font_size}pt; +}} +QTextEdit, FeedbackTextEdit {{ + font-size: {input_font_size}pt; +}} +""" + + # 合并基础样式和动态字体样式 + final_stylesheet = base_stylesheet + "\n" + dynamic_font_style + app.setStyleSheet(final_stylesheet) + + +def _apply_theme_palette(app: QApplication, theme_name: str): + """为指定主题设置QPalette,确保控件颜色正确""" + from PySide6.QtGui import QPalette, QColor + + palette = app.palette() + + if theme_name == "dark": + # 深色主题的QPalette设置 + palette.setColor(QPalette.ColorRole.Highlight, QColor("#4D4D4D")) # 深灰色高亮 + palette.setColor( + QPalette.ColorRole.HighlightedText, QColor("#FFFFFF") + ) # 白色高亮文本 + + # 设置按钮和选择控件的颜色 + palette.setColor(QPalette.ColorRole.Button, QColor("#3C3C3C")) # 按钮背景 + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#FFFFFF")) # 按钮文字 + + # 设置选择控件的强调色(影响单选按钮、复选框等) + palette.setColor(QPalette.ColorRole.Accent, QColor("#4D4D4D")) # 深灰色强调色 + + # 设置窗口和基础颜色 + palette.setColor(QPalette.ColorRole.Window, QColor("#2c2c2c")) # 窗口背景 + palette.setColor(QPalette.ColorRole.WindowText, QColor("#f0f0f0")) # 窗口文字 + palette.setColor(QPalette.ColorRole.Base, QColor("#272727")) # 输入框背景 + palette.setColor(QPalette.ColorRole.Text, QColor("#ffffff")) # 输入框文字 + + else: + # 浅色主题的QPalette设置 - 使用灰色而不是蓝色 + palette.setColor(QPalette.ColorRole.Highlight, QColor("#6B6B6B")) # 灰色高亮 + palette.setColor( + QPalette.ColorRole.HighlightedText, QColor("#FFFFFF") + ) # 白色高亮文本 + + # 设置按钮和选择控件的颜色 + palette.setColor(QPalette.ColorRole.Button, QColor("#e1e1e1")) # 按钮背景 + palette.setColor(QPalette.ColorRole.ButtonText, QColor("#111111")) # 按钮文字 + + # 设置选择控件的强调色(影响单选按钮、复选框等) + palette.setColor(QPalette.ColorRole.Accent, QColor("#6B6B6B")) # 灰色强调色 + + # 设置窗口和基础颜色 + palette.setColor(QPalette.ColorRole.Window, QColor("#f0f0f0")) # 窗口背景 + palette.setColor(QPalette.ColorRole.WindowText, QColor("#111111")) # 窗口文字 + palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) # 输入框背景 + palette.setColor(QPalette.ColorRole.Text, QColor("#111111")) # 输入框文字 + + app.setPalette(palette) diff --git a/src/feedback_ui/utils/text_formatter.py b/src/feedback_ui/utils/text_formatter.py new file mode 100644 index 0000000..3d7c531 --- /dev/null +++ b/src/feedback_ui/utils/text_formatter.py @@ -0,0 +1,294 @@ +# feedback_ui/utils/text_formatter.py + +import re +from typing import Dict +from functools import lru_cache + + +class TextFormatter: + """ + 文本格式化工具类,将Markdown格式转换为更易读的纯文本格式 + Text formatter utility class that converts Markdown format to more readable plain text + """ + + def __init__(self): + # 编译正则表达式模式以提高性能 + self._patterns = self._compile_patterns() + # 缓存格式化结果以提高性能 + self._format_cache = {} + + def _compile_patterns(self) -> Dict[str, re.Pattern]: + """编译所有正则表达式模式""" + return { + # 标题格式 (# ## ### 等) + "headers": re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE), + # 粗体格式 (**text** 或 __text__) + "bold": re.compile(r"\*\*(.+?)\*\*|__(.+?)__"), + # 斜体格式 (*text* 或 _text_) + "italic": re.compile(r"(? text) + "blockquote": re.compile(r"^>\s+(.+)$", re.MULTILINE), + # 水平分割线 + "horizontal_rule": re.compile(r"^[-*_]{3,}$", re.MULTILINE), + } + + def format_text(self, text: str) -> str: + """ + 将包含Markdown格式的文本转换为更易读的纯文本 + Convert text with Markdown formatting to more readable plain text + + Args: + text (str): 原始文本,可能包含Markdown格式 + + Returns: + str: 格式化后的纯文本 + """ + if not text or not isinstance(text, str): + return text or "" + + # 检查缓存 + if text in self._format_cache: + return self._format_cache[text] + + # 创建文本副本进行处理 + formatted_text = text + + # 1. 处理代码块(优先处理,避免内部格式被误处理) + formatted_text = self._format_code_blocks(formatted_text) + + # 2. 处理标题 + formatted_text = self._format_headers(formatted_text) + + # 3. 处理粗体 + formatted_text = self._format_bold(formatted_text) + + # 4. 处理斜体 + formatted_text = self._format_italic(formatted_text) + + # 5. 处理内联代码 + formatted_text = self._format_inline_code(formatted_text) + + # 6. 处理列表 + formatted_text = self._format_lists(formatted_text) + + # 7. 处理链接 + formatted_text = self._format_links(formatted_text) + + # 8. 处理引用 + formatted_text = self._format_blockquotes(formatted_text) + + # 9. 处理水平分割线 + formatted_text = self._format_horizontal_rules(formatted_text) + + # 10. 清理多余的空行 + formatted_text = self._clean_extra_newlines(formatted_text) + + # 缓存结果(限制缓存大小避免内存泄漏) + if len(self._format_cache) < 100: + self._format_cache[text] = formatted_text + + return formatted_text + + def _format_headers(self, text: str) -> str: + """格式化标题""" + + def replace_header(match): + level = len(match.group(1)) # # 的数量 + title = match.group(2).strip() + + # 根据标题级别添加不同的装饰 + if level == 1: + return f"【{title}】" + elif level == 2: + return f"■ {title}" + elif level == 3: + return f"▶ {title}" + else: + return f"• {title}" + + return self._patterns["headers"].sub(replace_header, text) + + def _apply_simple_format( + self, text: str, pattern_name: str, format_func, group_index: int = 1 + ) -> str: + """ + 通用的简单格式化方法,减少代码重复 + Generic simple formatting method to reduce code duplication + """ + + def replace_match(match): + if group_index == "multi": + # 处理多组匹配(如粗体和斜体的多种语法) + content = match.group(1) or match.group(2) + else: + content = match.group(group_index) + return format_func(content) + + return self._patterns[pattern_name].sub(replace_match, text) + + def _format_bold(self, text: str) -> str: + """格式化粗体文本""" + return self._apply_simple_format( + text, "bold", lambda content: f"【{content}】", "multi" + ) + + def _format_italic(self, text: str) -> str: + """格式化斜体文本""" + return self._apply_simple_format( + text, "italic", lambda content: f"〈{content}〉", "multi" + ) + + def _format_inline_code(self, text: str) -> str: + """格式化内联代码""" + return self._apply_simple_format( + text, "inline_code", lambda content: f"『{content}』" + ) + + def _format_code_blocks(self, text: str) -> str: + """格式化代码块""" + + def replace_code_block(match): + code = match.group(1).strip() + lines = code.split("\n") + + # 为每行代码添加前缀 + formatted_lines = [] + for line in lines: + formatted_lines.append(f" {line}") + + return "\n".join(formatted_lines) + + return self._patterns["code_block"].sub(replace_code_block, text) + + def _format_lists(self, text: str) -> str: + """格式化列表""" + # 使用通用方法处理列表 + text = self._apply_simple_format( + text, "unordered_list", lambda item: f" • {item}" + ) + text = self._apply_simple_format( + text, "ordered_list", lambda item: f" ① {item}" + ) + return text + + def _format_links(self, text: str) -> str: + """格式化链接""" + return self._apply_simple_format(text, "links", lambda link_text: link_text) + + def _format_blockquotes(self, text: str) -> str: + """格式化引用""" + return self._apply_simple_format( + text, "blockquote", lambda quote_text: f" ▌{quote_text}" + ) + + def _format_horizontal_rules(self, text: str) -> str: + """格式化水平分割线""" + return self._patterns["horizontal_rule"].sub("─" * 40, text) + + def _clean_extra_newlines(self, text: str) -> str: + """清理多余的空行""" + # 将连续的多个换行符替换为最多两个换行符 + text = re.sub(r"\n{3,}", "\n\n", text) + + # 只去除开头和结尾的换行符,保留空格缩进 + text = text.strip("\n") + + return text + + def is_formatted_text(self, text: str) -> bool: + """ + 检查文本是否包含Markdown格式标记 + Check if text contains Markdown formatting marks + + Args: + text (str): 要检查的文本 + + Returns: + bool: 如果包含格式标记返回True + """ + if not text or len(text.strip()) < 3: + return False + + import re + + # 更精确的检测逻辑,减少误判 + + # 1. 检查标题(必须在行首) + if re.search(r"^#{1,6}\s+", text, re.MULTILINE): + return True + + # 2. 检查代码块(必须独立成行) + if re.search(r"^```", text, re.MULTILINE): + return True + + # 3. 检查列表(必须在行首) + if re.search(r"^[-*+]\s+", text, re.MULTILINE): + return True + + # 4. 检查数字列表(必须在行首) + if re.search(r"^\s*\d+\.\s+", text, re.MULTILINE): + return True + + # 5. 检查引用(必须在行首) + if re.search(r"^>\s+", text, re.MULTILINE): + return True + + # 6. 检查成对的粗体标记 + bold_matches = re.findall(r"\*\*[^*]+\*\*", text) + if bold_matches: + return True + + # 7. 检查成对的斜体标记(但排除单个星号) + # 只有当星号成对出现且包围非空内容时才认为是斜体 + # 更严格的斜体检测:确保星号前后有空格或标点,避免误判 + italic_matches = re.findall( + r"(?:^|\s)\*([^*\s][^*]*[^*\s])\*(?:\s|$|[,.!?])", text + ) + if italic_matches: + return True + + # 8. 检查成对的内联代码 + code_matches = re.findall(r"`[^`]+`", text) + if code_matches: + return True + + # 9. 检查链接格式 + if re.search(r"\[.+\]\(.+\)", text): + return True + + # 10. 检查下划线格式(成对出现) + if re.search(r"__[^_]+__", text) or re.search(r"___[^_]+___", text): + return True + + return False + + def clear_cache(self): + """ + 清理格式化缓存 + Clear formatting cache + """ + self._format_cache.clear() + + def get_cache_size(self) -> int: + """ + 获取当前缓存大小 + Get current cache size + + Returns: + int: 缓存中的条目数量 + """ + return len(self._format_cache) + + +# 创建全局实例以供使用 +text_formatter = TextFormatter() diff --git a/src/feedback_ui/utils/theme_colors.py b/src/feedback_ui/utils/theme_colors.py new file mode 100644 index 0000000..d883747 --- /dev/null +++ b/src/feedback_ui/utils/theme_colors.py @@ -0,0 +1,172 @@ +""" +主题颜色常量定义 +统一管理所有UI组件的颜色,避免重复定义 +""" + + +class ThemeColors: + """主题颜色管理类""" + + # 深色主题颜色 + DARK_THEME = { + # 基础颜色 + "background": "#2c2c2c", + "text": "#f0f0f0", + "border": "#555555", + # 按钮颜色 + "button_bg": "#3C3C3C", + "button_text": "#FFFFFF", + "button_hover": "#555555", + "button_pressed": "#333333", + # 选择控件颜色(复选框、单选按钮等)- 主题协调版本 + "checkbox_bg": "#2c2c2c", + "checkbox_border": "#444444", + "checkbox_checked_bg": "#555555", # 深色主题协调的灰色 + "checkbox_checked_border": "#666666", + "checkbox_hover_bg": "#3a3a3a", + "checkbox_hover_border": "#666666", + "checkbox_checked_hover_bg": "#666666", # 悬停时稍亮的灰色 + "checkbox_checked_hover_border": "#777777", + # 预览窗口颜色 + "preview_bg": "#2d2d2d", + "preview_border": "#555555", + "preview_text": "#ffffff", + "preview_item_bg": "#3c3c3c", + "preview_item_border": "#444444", + "preview_item_hover_bg": "#555555", + "preview_item_hover_border": "#666666", + # 高亮和强调色 + "highlight": "#4D4D4D", + "highlight_text": "#FFFFFF", + "accent": "#4D4D4D", + # 输入框颜色 + "input_bg": "#272727", + "input_text": "#ffffff", + "input_border": "#444444", + "input_hover_border": "#555555", + "input_focus_border": "#666666", + # 分割器颜色 + "splitter_base": "#444444", + "splitter_hover": "#555555", + "splitter_pressed": "#333333", + # 优化按钮颜色 + "optimization_button_bg": "#404040", + "optimization_button_text": "#ffffff", + "optimization_button_border": "#555555", + "optimization_button_hover_bg": "#505050", + "optimization_button_hover_border": "#666666", + "optimization_button_pressed_bg": "#303030", + "optimization_button_pressed_border": "#444444", + } + + # 浅色主题颜色 + LIGHT_THEME = { + # 基础颜色 + "background": "#f0f0f0", + "text": "#111111", + "border": "#CCCCCC", + # 按钮颜色 + "button_bg": "#e1e1e1", + "button_text": "#111111", + "button_hover": "#dddddd", + "button_pressed": "#bbbbbb", + # 选择控件颜色(复选框、单选按钮等)- 主题协调版本 + "checkbox_bg": "#ffffff", + "checkbox_border": "#adadad", + "checkbox_checked_bg": "#6B6B6B", # 浅色主题协调的深灰色 + "checkbox_checked_border": "#777777", + "checkbox_hover_bg": "#f5f5f5", + "checkbox_hover_border": "#777777", + "checkbox_checked_hover_bg": "#777777", # 悬停时稍亮的灰色 + "checkbox_checked_hover_border": "#888888", + # 预览窗口颜色 + "preview_bg": "#FFFFFF", + "preview_border": "#CCCCCC", + "preview_text": "#333333", + "preview_item_bg": "#F8F9FA", + "preview_item_border": "#E0E0E0", + "preview_item_hover_bg": "#E0E0E0", + "preview_item_hover_border": "#BBBBBB", + # 高亮和强调色 + "highlight": "#6B6B6B", + "highlight_text": "#FFFFFF", + "accent": "#6B6B6B", + # 输入框颜色 + "input_bg": "#ffffff", + "input_text": "#111111", + "input_border": "#dcdcdc", + "input_hover_border": "#bbb", + "input_focus_border": "#999", + # 分割器颜色 + "splitter_base": "#cccccc", + "splitter_hover": "#dddddd", + "splitter_pressed": "#bbbbbb", + # 优化按钮颜色 + "optimization_button_bg": "#f8f8f8", + "optimization_button_text": "#333333", + "optimization_button_border": "#cccccc", + "optimization_button_hover_bg": "#eeeeee", + "optimization_button_hover_border": "#bbbbbb", + "optimization_button_pressed_bg": "#e0e0e0", + "optimization_button_pressed_border": "#aaaaaa", + } + + @classmethod + def get_theme_colors(cls, theme_name: str) -> dict: + """获取指定主题的颜色配置""" + if theme_name == "dark": + return cls.DARK_THEME + else: + return cls.LIGHT_THEME + + @classmethod + def get_preview_colors(cls, theme_name: str) -> dict: + """获取预览窗口相关的颜色配置""" + colors = cls.get_theme_colors(theme_name) + return { + "bg_color": colors["preview_bg"], + "border_color": colors["preview_border"], + "text_color": colors["preview_text"], + "item_bg": colors["preview_item_bg"], + "item_border": colors["preview_item_border"], + "item_hover_bg": colors["preview_item_hover_bg"], + "item_hover_border": colors["preview_item_hover_border"], + } + + @classmethod + def get_checkbox_colors(cls, theme_name: str) -> dict: + """获取复选框相关的颜色配置""" + colors = cls.get_theme_colors(theme_name) + return { + "text_color": colors["text"], + "bg_color": colors["checkbox_bg"], + "border_color": colors["checkbox_border"], + "checked_bg": colors["checkbox_checked_bg"], + "checked_border": colors["checkbox_checked_border"], + "hover_bg": colors["checkbox_hover_bg"], + "hover_border": colors["checkbox_hover_border"], + } + + @classmethod + def get_splitter_colors(cls, theme_name: str) -> dict: + """获取分割器相关的颜色配置""" + colors = cls.get_theme_colors(theme_name) + return { + "base_color": colors["splitter_base"], + "hover_color": colors["splitter_hover"], + "pressed_color": colors["splitter_pressed"], + } + + @classmethod + def get_optimization_button_colors(cls, theme_name: str) -> dict: + """获取优化按钮相关的颜色配置""" + colors = cls.get_theme_colors(theme_name) + return { + "bg_color": colors["optimization_button_bg"], + "text_color": colors["optimization_button_text"], + "border_color": colors["optimization_button_border"], + "hover_bg": colors["optimization_button_hover_bg"], + "hover_border": colors["optimization_button_hover_border"], + "pressed_bg": colors["optimization_button_pressed_bg"], + "pressed_border": colors["optimization_button_pressed_border"], + } diff --git a/src/feedback_ui/utils/ui_factory.py b/src/feedback_ui/utils/ui_factory.py new file mode 100644 index 0000000..84f2a96 --- /dev/null +++ b/src/feedback_ui/utils/ui_factory.py @@ -0,0 +1,394 @@ +# src/feedback_ui/utils/ui_factory.py +""" +UI组件工厂 +UI Component Factory + +提供通用的UI组件创建函数,减少重复代码。 +Provides common UI component creation functions to reduce code duplication. +""" + +from typing import Callable, Optional, List, Tuple +from PySide6.QtWidgets import ( + QRadioButton, + QHBoxLayout, + QVBoxLayout, + QGroupBox, + QWidget, + QPushButton, + QLineEdit, + QLabel, +) +from PySide6.QtCore import Qt + + +def create_radio_button_pair( + text1: str, + text2: str, + checked_index: int = 0, + callback1: Optional[Callable] = None, + callback2: Optional[Callable] = None, +) -> Tuple[QRadioButton, QRadioButton, QHBoxLayout]: + """ + 创建一对单选按钮 + Create a pair of radio buttons + + Args: + text1: 第一个按钮的文本 + text2: 第二个按钮的文本 + checked_index: 默认选中的按钮索引 (0 或 1) + callback1: 第一个按钮的回调函数 + callback2: 第二个按钮的回调函数 + + Returns: + Tuple[QRadioButton, QRadioButton, QHBoxLayout]: (按钮1, 按钮2, 布局) + """ + layout = QHBoxLayout() + + radio1 = QRadioButton(text1) + radio2 = QRadioButton(text2) + + # 设置默认选中状态 + if checked_index == 0: + radio1.setChecked(True) + else: + radio2.setChecked(True) + + # 连接回调函数 + if callback1: + radio1.toggled.connect(callback1) + if callback2: + radio2.toggled.connect(callback2) + + layout.addWidget(radio1) + layout.addWidget(radio2) + + return radio1, radio2, layout + + +def create_toggle_radio_button( + text: str, checked: bool = False, callback: Optional[Callable] = None +) -> QRadioButton: + """ + 创建可切换的单选按钮(独立工作,不与其他按钮互斥) + Create a toggle radio button (works independently, not exclusive with others) + + Args: + text: 按钮文本 + checked: 是否默认选中 + callback: 回调函数 + + Returns: + QRadioButton: 配置好的单选按钮 + """ + radio = QRadioButton(text) + radio.setCheckable(True) + radio.setAutoExclusive(False) # 不与其他单选按钮互斥 + radio.setChecked(checked) + + if callback: + radio.toggled.connect(callback) + + return radio + + +def create_grouped_layout( + title: str, widgets: List[QWidget], layout_type: str = "vertical" +) -> QGroupBox: + """ + 创建分组布局 + Create grouped layout + + Args: + title: 分组标题 + widgets: 要添加的组件列表 + layout_type: 布局类型 ("vertical" 或 "horizontal") + + Returns: + QGroupBox: 配置好的分组框 + """ + group = QGroupBox(title) + + if layout_type == "horizontal": + layout = QHBoxLayout() + else: + layout = QVBoxLayout() + + for widget in widgets: + layout.addWidget(widget) + + group.setLayout(layout) + return group + + +def create_collapsible_section( + title: str, + content_widget: QWidget, + expanded: bool = False, + toggle_callback: Optional[Callable] = None, +) -> Tuple[QPushButton, QWidget]: + """ + 创建可折叠区域 + Create collapsible section + + Args: + title: 标题文本 + content_widget: 内容组件 + expanded: 是否默认展开 + toggle_callback: 切换回调函数 + + Returns: + Tuple[QPushButton, QWidget]: (切换按钮, 内容组件) + """ + toggle_button = QPushButton(f"{'▼' if expanded else '▶'} {title}") + toggle_button.setCheckable(True) + toggle_button.setChecked(expanded) + + # 设置简洁的按钮样式 + toggle_button.setStyleSheet( + """ + QPushButton { + text-align: left; + padding: 4px 8px; + border: none; + background-color: transparent; + font-size: 10pt; + color: gray; + } + QPushButton:hover { + background-color: rgba(128, 128, 128, 0.1); + } + """ + ) + + content_widget.setVisible(expanded) + + def on_toggle(): + is_expanded = toggle_button.isChecked() + content_widget.setVisible(is_expanded) + toggle_button.setText(f"{'▼' if is_expanded else '▶'} {title}") + if toggle_callback: + toggle_callback(is_expanded) + + toggle_button.clicked.connect(on_toggle) + + return toggle_button, content_widget + + +def create_input_field_with_label( + label_text: str, + placeholder: str = "", + default_value: str = "", + callback: Optional[Callable] = None, +) -> Tuple[QLabel, QLineEdit]: + """ + 创建带标签的输入框 + Create input field with label + + Args: + label_text: 标签文本 + placeholder: 占位符文本 + default_value: 默认值 + callback: 文本变化回调函数 + + Returns: + Tuple[QLabel, QLineEdit]: (标签, 输入框) + """ + label = QLabel(label_text) + input_field = QLineEdit() + + if placeholder: + input_field.setPlaceholderText(placeholder) + if default_value: + input_field.setText(default_value) + + if callback: + input_field.textChanged.connect(callback) + + return label, input_field + + +def apply_theme_aware_styling(widget: QWidget, theme: str = "dark") -> None: + """ + 应用主题感知的样式 - 增强版本 + Apply theme-aware styling - Enhanced version + + Args: + widget: 要应用样式的组件 + theme: 主题名称 ("dark" 或 "light") + """ + if theme == "dark": + # 深色主题样式 + style = """ + QRadioButton { + color: #aaaaaa; + spacing: 8px; + min-height: 28px; + padding: 1px; + } + QRadioButton::indicator { + width: 22px; height: 22px; + border: 2px solid #444444; + border-radius: 11px; + background-color: #2c2c2c; + } + QRadioButton::indicator:checked { + background-color: #555555; + border: 2px solid #666666; + background-image: radial-gradient(circle, #ffffff 25%, #555555 30%); + } + QRadioButton::indicator:hover:!checked { + border: 2px solid #666666; + background-color: #3a3a3a; + } + QRadioButton::indicator:checked:hover { + background-color: #666666; + border: 2px solid #777777; + background-image: radial-gradient(circle, #ffffff 25%, #666666 30%); + } + QCheckBox { + color: #aaaaaa; + spacing: 8px; + min-height: 28px; + padding: 1px; + } + QCheckBox::indicator { + width: 22px; height: 22px; + border: 2px solid #444444; + border-radius: 4px; + background-color: #2c2c2c; + } + QCheckBox::indicator:checked { + background-color: #555555; + border: 2px solid #666666; + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; + } + QCheckBox::indicator:hover:!checked { + border: 2px solid #666666; + background-color: #3a3a3a; + } + QCheckBox::indicator:checked:hover { + background-color: #666666; + border: 2px solid #777777; + } + """ + else: + # 浅色主题样式 + style = """ + QRadioButton { + color: #666666; + spacing: 8px; + min-height: 28px; + padding: 1px; + } + QRadioButton::indicator { + width: 22px; height: 22px; + border: 2px solid #adadad; + border-radius: 11px; + background-color: #ffffff; + } + QRadioButton::indicator:checked { + background-color: #6B6B6B; + border: 2px solid #777777; + background-image: radial-gradient(circle, #ffffff 25%, #6B6B6B 30%); + } + QRadioButton::indicator:hover:!checked { + border: 2px solid #777777; + background-color: #f5f5f5; + } + QRadioButton::indicator:checked:hover { + background-color: #777777; + border: 2px solid #888888; + background-image: radial-gradient(circle, #ffffff 25%, #777777 30%); + } + QCheckBox { + color: #666666; + spacing: 8px; + min-height: 28px; + padding: 1px; + } + QCheckBox::indicator { + width: 22px; height: 22px; + border: 2px solid #adadad; + border-radius: 4px; + background-color: #ffffff; + } + QCheckBox::indicator:checked { + background-color: #6B6B6B; + border: 2px solid #777777; + background-image: url("data:image/svg+xml,"); + background-position: center; + background-repeat: no-repeat; + } + QCheckBox::indicator:hover:!checked { + border: 2px solid #777777; + background-color: #f5f5f5; + } + QCheckBox::indicator:checked:hover { + background-color: #777777; + border: 2px solid #888888; + } + """ + + widget.setStyleSheet(style) + + +# 常用样式常量 +COMMON_STYLES = { + "small_font_label": "font-size: 10pt;", + "tiny_font_label": "font-size: 9pt;", + "compact_input": "font-size: 10pt; padding: 2px;", + "small_button": "font-size: 8pt; padding: 2px; padding-top: -3px;", + "input_dark": "QLineEdit { background-color: #2d2d2d; color: #ffffff; border: 1px solid #555555; padding: 4px; }", + "input_light": "QLineEdit { background-color: #ffffff; color: #000000; border: 1px solid #cccccc; padding: 4px; }", +} + + +def apply_common_style(widget: QWidget, style_name: str) -> None: + """ + 应用常用样式 + Apply common style + + Args: + widget: 要应用样式的组件 + style_name: 样式名称 + """ + if style_name in COMMON_STYLES: + widget.setStyleSheet(COMMON_STYLES[style_name]) + + +def apply_enhanced_control_styling(widget: QWidget, theme: str = "dark") -> None: + """ + 为设置对话框中的控件应用增强的样式 + Apply enhanced styling for controls in settings dialogs + + Args: + widget: 要应用样式的组件(通常是对话框或容器) + theme: 主题名称 ("dark" 或 "light") + """ + # 递归查找并应用样式到所有单选按钮和复选框 + from PySide6.QtWidgets import QRadioButton, QCheckBox + + def apply_to_children(parent): + for child in parent.findChildren(QRadioButton): + apply_theme_aware_styling(child, theme) + for child in parent.findChildren(QCheckBox): + apply_theme_aware_styling(child, theme) + + apply_to_children(widget) + + +def apply_input_theme_style(widget: QWidget, theme: str = "dark") -> None: + """ + 应用输入框主题样式 + Apply input field theme style + + Args: + widget: 输入框组件 + theme: 主题名称 + """ + if theme == "dark": + widget.setStyleSheet(COMMON_STYLES["input_dark"]) + else: + widget.setStyleSheet(COMMON_STYLES["input_light"]) diff --git a/src/feedback_ui/utils/ui_helpers.py b/src/feedback_ui/utils/ui_helpers.py new file mode 100644 index 0000000..6599580 --- /dev/null +++ b/src/feedback_ui/utils/ui_helpers.py @@ -0,0 +1,18 @@ +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QLabel + + +def set_selection_colors(label: QLabel) -> None: + """ + 设置标签选择文本时的高亮颜色为灰色。 + Sets the text selection highlight color to gray for a label. + + Args: + label (QLabel): 要设置高亮颜色的标签 + """ + palette = label.palette() + # 设置选择区域的背景色为灰色 (RGB: 153, 153, 153) + palette.setColor(QPalette.ColorRole.Highlight, QColor(153, 153, 153)) + # 设置选择区域的文本颜色为白色,确保在灰色背景上可读 + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) + label.setPalette(palette) diff --git a/src/feedback_ui/widgets/__init__.py b/src/feedback_ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/feedback_ui/widgets/clickable_label.py b/src/feedback_ui/widgets/clickable_label.py new file mode 100644 index 0000000..17cf660 --- /dev/null +++ b/src/feedback_ui/widgets/clickable_label.py @@ -0,0 +1,84 @@ +# feedback_ui/widgets/clickable_label.py +from PySide6.QtCore import QEvent, QObject, Qt, Signal +from PySide6.QtWidgets import QLabel + + +class CursorOverrideFilter(QObject): + """ + An event filter to override the cursor shape for a widget. + 一个事件过滤器,用于覆盖小部件的光标形状。 + """ + + def __init__(self, parent=None): + super().__init__(parent) + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + # This filter seems to intend to force ArrowCursor on certain interactions. + # However, ClickableLabel sets PointingHandCursor. This might create conflicts + # or have a specific desired interaction order. + # For now, keeping original logic. + # 此过滤器似乎打算在某些交互上强制使用 ArrowCursor。 + # 然而,ClickableLabel 设置了 PointingHandCursor。这可能会产生冲突 + # 或具有特定的期望交互顺序。 + # 目前,保留原始逻辑。 + if event.type() in ( + QEvent.Type.Enter, + QEvent.Type.HoverEnter, + QEvent.Type.HoverMove, + QEvent.Type.MouseMove, + QEvent.Type.MouseButtonPress, + QEvent.Type.MouseButtonRelease, + ): + if hasattr(obj, "setCursor"): # Check if object has setCursor method + obj.setCursor(Qt.CursorShape.ArrowCursor) # Use enum member + return False # Event not handled here, just cursor override + return super().eventFilter(obj, event) + + +class ClickableLabel(QLabel): + """ + A QLabel that emits a 'clicked' signal when pressed. + 一个在按下时发出 'clicked' 信号的 QLabel。 + """ + + clicked = Signal() + + def __init__(self, text: str = "", parent: QObject = None): + super().__init__(text, parent) + self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMouseTracking(True) + # The CursorOverrideFilter might conflict with PointingHandCursor. + # Consider if this filter is truly needed for ClickableLabel or if PointingHandCursor is sufficient. + # self._cursor_filter = CursorOverrideFilter(self) # Temporarily commented for review + # self.installEventFilter(self._cursor_filter) # Temporarily commented for review + + def mouseMoveEvent(self, event: QEvent): # Parameter type QMouseEvent expected + # QApplication.restoreOverrideCursor() # Overriding global cursor can be problematic + # QApplication.setOverrideCursor(Qt.CursorShape.PointingHandCursor) + super().mouseMoveEvent(event) + + def enterEvent(self, event: QEvent): # Parameter type QEnterEvent expected + # QApplication.setOverrideCursor(Qt.CursorShape.PointingHandCursor) + super().enterEvent(event) + + def leaveEvent(self, event: QEvent): + # QApplication.restoreOverrideCursor() + super().leaveEvent(event) + + def mousePressEvent(self, event: QEvent): # Parameter type QMouseEvent expected + if event.button() == Qt.MouseButton.LeftButton: + event.accept() + else: + super().mousePressEvent(event) + + def mouseReleaseEvent(self, event: QEvent): # Parameter type QMouseEvent expected + if event.button() == Qt.MouseButton.LeftButton: + # Check if the mouse release is within the label's bounds + if self.rect().contains( + event.position().toPoint() + ): # event.pos() in PySide6 is QPointF + self.clicked.emit() + event.accept() + else: + super().mouseReleaseEvent(event) diff --git a/src/feedback_ui/widgets/feedback_text_edit.py b/src/feedback_ui/widgets/feedback_text_edit.py new file mode 100644 index 0000000..61171f2 --- /dev/null +++ b/src/feedback_ui/widgets/feedback_text_edit.py @@ -0,0 +1,847 @@ +# feedback_ui/widgets/feedback_text_edit.py +import os +import re +import sys +from typing import Any # For type hinting parent + +from PySide6.QtCore import QEvent, QMimeData, Qt, QTimer +from PySide6.QtGui import ( + QColor, + QFont, + QKeyEvent, + QPalette, + QPixmap, + QSyntaxHighlighter, + QTextCharFormat, + QTextCursor, + QTextDocument, +) +from PySide6.QtWidgets import QApplication, QHBoxLayout, QTextEdit, QWidget + +# Forward declaration for type hinting to avoid circular import +# This is a common pattern when dealing with tightly coupled classes +# that will be in different modules. +# FeedbackUI 类型的前向声明,以避免循环导入。 +# 这是处理将位于不同模块中的紧密耦合类时的常见模式。 +FeedbackUI = "FeedbackUI" + + +class FileReferenceHighlighter(QSyntaxHighlighter): + """语法高亮器,用于在纯文本模式下高亮显示文件引用""" + + def __init__(self, parent: QTextDocument, text_edit: "FeedbackTextEdit"): + super().__init__(parent) + self.text_edit = text_edit + + # 创建文件引用的格式 + self.file_ref_format = QTextCharFormat() + self.file_ref_format.setForeground(QColor("#0078d4")) # 蓝色 + + def highlightBlock(self, text: str): + """高亮显示文本块中的文件引用""" + # 获取父窗口的文件引用字典 + parent_feedback_ui = self.text_edit._find_feedback_ui_parent() + if not parent_feedback_ui or not parent_feedback_ui.dropped_file_references: + return + + # 高亮显示所有文件引用 + for display_name in parent_feedback_ui.dropped_file_references.keys(): + start_index = 0 + while True: + index = text.find(display_name, start_index) + if index == -1: + break + # 应用蓝色格式 + self.setFormat(index, len(display_name), self.file_ref_format) + start_index = index + len(display_name) + + +class FeedbackTextEdit(QTextEdit): + """ + Custom QTextEdit for feedback input, handling text, image pasting/dropping, + and file reference management with rich text support for file references. + + 用于反馈输入的自定义 QTextEdit,处理文本、图像粘贴/拖放以及文件引用管理,支持文件引用的富文本格式。 + """ + + # Define signals if you want to decouple further, e.g.: + # image_pasted = Signal(QPixmap) + # file_dropped = Signal(str, str) # file_path, file_name + # submit_triggered = Signal() + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + # 彻底重构:使用纯文本模式,避免富文本格式污染 + self.setAcceptRichText(False) + self.setPlainText("") + + # 获取输入框的字体大小设置 + input_font_size = self._get_input_font_size() + font = QFont("Segoe UI", input_font_size) + font.setStyleStrategy(QFont.StyleStrategy.PreferAntialias) + font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 101.5) + font.setWordSpacing(1.0) + self.setFont(font) + + # 设置语法高亮器,用于文件引用的蓝色显示 + self.highlighter = FileReferenceHighlighter(self.document(), self) + + self._file_reference_cache = { + "text": "", + "references": [], # List of display_name strings + "positions": {}, # Dict of display_name: (start_pos, end_pos) + } + self._cache_valid = False + self._last_cursor_pos = 0 + + self.setCursorWidth(2) + self.setAcceptDrops(True) + self.viewport().setCursor(Qt.CursorShape.IBeamCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + # Timer to ensure cursor visibility after certain key events + # 用于在某些按键事件后确保光标可见性的计时器 + self._key_repeat_timer = QTimer(self) + self._key_repeat_timer.setSingleShot(True) + self._key_repeat_timer.setInterval(10) # ms + self._key_repeat_timer.timeout.connect( + self._ensure_cursor_visible_slot + ) # Renamed slot + + # V4.3 优化:缓存配置获取函数,避免重复导入 + self._get_config_func = None + self._init_config_func() + + self._is_key_repeating = False + + # Container for image previews shown at the bottom of the text edit + # 用于在文本编辑器底部显示图像预览的容器 + self.images_container = QWidget(self) + self.images_layout = QHBoxLayout(self.images_container) + self.images_layout.setContentsMargins(10, 10, 10, 10) + self.images_layout.setSpacing(10) + self.images_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.images_container.setVisible(False) + + # Set placeholder text color using QPalette + # 使用 QPalette 设置占位符文本颜色 + palette = self.palette() + palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#777777")) + self.setPalette(palette) + + def resizeEvent(self, event: QEvent): # QResizeEvent + super().resizeEvent(event) + container_height = 60 # Height of the images container + # Position images_container at the bottom + self.images_container.setGeometry( + 0, self.height() - container_height, self.width(), container_height + ) + + if self.images_container.isVisible(): + self.setViewportMargins(0, 0, 0, container_height) + else: + self.setViewportMargins(0, 0, 0, 0) + + def showEvent(self, event: QEvent): # QShowEvent + super().showEvent(event) + # Ensure correct geometry for images_container on show + container_height = 60 + self.images_container.setGeometry( + 0, self.height() - container_height, self.width(), container_height + ) + if self.images_container.isVisible(): + self.setViewportMargins(0, 0, 0, container_height) + + QTimer.singleShot( + 10, self.ensureCursorVisible + ) # Ensure cursor is visible on show + + def keyPressEvent(self, event: QKeyEvent): + key = event.key() + + self._is_key_repeating = event.isAutoRepeat() + + if key in ( + Qt.Key.Key_Left, + Qt.Key.Key_Right, + Qt.Key.Key_Up, + Qt.Key.Key_Down, + Qt.Key.Key_Home, + Qt.Key.Key_End, + ): + super().keyPressEvent(event) + self._last_cursor_pos = self.textCursor().position() + return + + cursor_pos = self.textCursor().position() + self._last_cursor_pos = cursor_pos + + parent_feedback_ui = self._find_feedback_ui_parent() + + if key == Qt.Key.Key_Backspace: + if ( + parent_feedback_ui + and parent_feedback_ui.dropped_file_references + and self._is_cursor_near_file_reference(cursor_pos, is_backspace=True) + ): + if self._handle_file_reference_deletion_action(is_backspace=True): + self._invalidate_reference_cache() + self._schedule_ensure_cursor_visible() + return + # Standard backspace behavior + cursor = self.textCursor() + if not cursor.hasSelection(): + cursor.deletePreviousChar() + else: + cursor.removeSelectedText() + self._invalidate_reference_cache() + return + + elif key == Qt.Key.Key_Delete: + if ( + parent_feedback_ui + and parent_feedback_ui.dropped_file_references + and self._is_cursor_near_file_reference(cursor_pos, is_backspace=False) + ): + if self._handle_file_reference_deletion_action(is_backspace=False): + self._invalidate_reference_cache() + self._schedule_ensure_cursor_visible() + return + # Standard delete behavior + cursor = self.textCursor() + if not cursor.hasSelection(): + cursor.deleteChar() + else: + cursor.removeSelectedText() + self._invalidate_reference_cache() + return + + elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter: + if ( + event.modifiers() == Qt.KeyboardModifier.ShiftModifier + ): # Shift + Enter for newline + super().keyPressEvent(event) + self._invalidate_reference_cache() + else: + # 根据配置决定提交方式 + submit_method = self._get_submit_method() + should_submit = False + + if submit_method == "enter": + # Enter键直接提交模式 + if event.modifiers() == Qt.KeyboardModifier.NoModifier: + should_submit = True + elif event.modifiers() == Qt.KeyboardModifier.ControlModifier: + # Ctrl+Enter在Enter模式下也换行 + super().keyPressEvent(event) + self._invalidate_reference_cache() + elif submit_method == "ctrl_enter": + # Ctrl+Enter组合键提交模式 + if event.modifiers() == Qt.KeyboardModifier.ControlModifier: + should_submit = True + elif event.modifiers() == Qt.KeyboardModifier.NoModifier: + # Enter在Ctrl+Enter模式下换行 + super().keyPressEvent(event) + self._invalidate_reference_cache() + else: + # 其他修饰键组合,换行 + super().keyPressEvent(event) + self._invalidate_reference_cache() + + if should_submit: + if parent_feedback_ui and hasattr( + parent_feedback_ui, "_prepare_and_submit_feedback" + ): + parent_feedback_ui._prepare_and_submit_feedback() + + return # Event handled + + elif ( + key == Qt.Key.Key_V + and event.modifiers() == Qt.KeyboardModifier.ControlModifier + ): # Ctrl + V for paste + clipboard = QApplication.clipboard() + mime_data = clipboard.mimeData() + + if mime_data.hasImage(): + if parent_feedback_ui and hasattr( + parent_feedback_ui, "handle_paste_image" + ): + if ( + parent_feedback_ui.handle_paste_image() + ): # Parent handles image paste + return # Paste handled by parent + + super().keyPressEvent(event) # Default paste for text etc. + self._invalidate_reference_cache() + self._schedule_ensure_cursor_visible() + return # Event handled + + else: # Default key press handling + super().keyPressEvent(event) + self._invalidate_reference_cache() + + def keyReleaseEvent(self, event: QKeyEvent): + self._is_key_repeating = False + super().keyReleaseEvent(event) + + def _schedule_ensure_cursor_visible(self): + """Schedules a call to ensure the cursor is visible.""" + self._key_repeat_timer.start() + + def _ensure_cursor_visible_slot(self): + """Slot connected to the timer to make the cursor visible.""" + self.ensureCursorVisible() + + def mousePressEvent(self, event: QEvent): # QMouseEvent + self._key_repeat_timer.stop() # Stop timer on mouse press + self._is_key_repeating = False + super().mousePressEvent(event) + self._last_cursor_pos = self.textCursor().position() + + def mouseReleaseEvent(self, event: QEvent): # QMouseEvent + super().mouseReleaseEvent(event) + self.ensureCursorVisible() # Ensure visibility after mouse release + + def focusInEvent(self, event: QEvent): # QFocusEvent + """获得焦点时的处理""" + super().focusInEvent(event) + + def focusOutEvent(self, event: QEvent): # QFocusEvent + """失去焦点时的处理""" + super().focusOutEvent(event) + + def _get_input_font_size(self) -> int: + """获取输入框的字体大小设置""" + try: + # 尝试从父窗口获取设置管理器 + parent_feedback_ui = self._find_feedback_ui_parent() + if parent_feedback_ui and hasattr(parent_feedback_ui, "settings_manager"): + return parent_feedback_ui.settings_manager.get_input_font_size() + except Exception: + pass + + # 如果无法获取,使用默认值 + from ..utils.constants import DEFAULT_INPUT_FONT_SIZE + + return DEFAULT_INPUT_FONT_SIZE + + def _init_config_func(self): + """V4.3 优化:初始化配置获取函数,避免重复导入""" + try: + try: + from interactive_feedback_server.utils import get_config + + self._get_config_func = get_config + except ImportError: + # 开发模式导入 + import sys + import os + + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + from src.interactive_feedback_server.utils import get_config + + self._get_config_func = get_config + except Exception: + self._get_config_func = None + + def _get_submit_method(self) -> str: + """获取当前的提交方式设置""" + try: + # V4.3 优化:使用缓存的配置函数 + if self._get_config_func: + config = self._get_config_func() + return config.get("submit_method", "enter") + else: + return "enter" # 配置函数未初始化,使用默认值 + except Exception: + # 如果获取失败,使用默认值 + return "enter" + + def update_font_size(self): + """更新输入框字体大小""" + try: + input_font_size = self._get_input_font_size() + current_font = self.font() + current_font.setPointSize(input_font_size) + self.setFont(current_font) + except Exception: + # 忽略异常,避免影响正常使用 + pass + + def _find_feedback_ui_parent( + self, + ) -> Any | None: # Should be Optional[FeedbackUI] + """ + Finds the FeedbackUI instance in the parent hierarchy. + This creates a tight coupling. Consider using signals/slots for decoupling. + + 在父级层次结构中查找 FeedbackUI 实例。 + 这会产生紧密耦合。考虑使用信号/槽进行解耦。 + """ + parent = self.parent() + while parent: + # Check class name due to forward declaration of FeedbackUI + if parent.__class__.__name__ == "FeedbackUI": + return parent + parent = parent.parent() + return None + + def _invalidate_reference_cache(self): + """Marks the file reference cache as invalid.""" + self._cache_valid = False + + def _update_file_reference_cache_if_needed(self): + """Updates the file reference cache from the current text content if it's invalid.""" + if self._cache_valid: + return + + parent_feedback_ui = self._find_feedback_ui_parent() + if not parent_feedback_ui or not parent_feedback_ui.dropped_file_references: + self._file_reference_cache["text"] = self.toPlainText() + self._file_reference_cache["references"] = [] + self._file_reference_cache["positions"] = {} + self._cache_valid = True + return + + current_text = self.toPlainText() + # Only update if text has actually changed + if current_text == self._file_reference_cache["text"]: + self._cache_valid = True + return + + self._file_reference_cache["text"] = current_text + self._file_reference_cache["references"] = [] + self._file_reference_cache["positions"] = {} + + # Rebuild cache based on current text and parent's references + for display_name in parent_feedback_ui.dropped_file_references.keys(): + start_pos = 0 + while True: + pos = current_text.find(display_name, start_pos) + if pos == -1: + break + # Store display name and its start/end positions + self._file_reference_cache["references"].append(display_name) + self._file_reference_cache["positions"][display_name] = ( + pos, + pos + len(display_name), + ) + start_pos = pos + len(display_name) + self._cache_valid = True + + def _is_cursor_near_file_reference( + self, cursor_pos: int, is_backspace: bool = True + ) -> bool: + """Checks if the cursor is at the start/end of a known file reference.""" + self._update_file_reference_cache_if_needed() + for _display_name, (start, end) in self._file_reference_cache[ + "positions" + ].items(): + if ( + is_backspace and cursor_pos == end + ): # Cursor at the end of reference for backspace + return True + elif ( + not is_backspace and cursor_pos == start + ): # Cursor at the start of reference for delete + return True + return False + + def _handle_file_reference_deletion_action(self, is_backspace: bool = True) -> bool: + """Handles the deletion of a file reference when Backspace or Delete is pressed.""" + parent_feedback_ui = self._find_feedback_ui_parent() + if not parent_feedback_ui or not parent_feedback_ui.dropped_file_references: + return False + + self._update_file_reference_cache_if_needed() + cursor = self.textCursor() + if cursor.hasSelection(): # Don't interfere with selection deletion + return False + + cursor_pos = cursor.position() + + # Iterate over a copy of items if modifying the underlying dict + for display_name, (start, end) in list( + self._file_reference_cache["positions"].items() + ): + should_delete = False + if is_backspace and cursor_pos == end: + should_delete = True + elif not is_backspace and cursor_pos == start: + should_delete = True + + if should_delete: + # Select the display_name text and remove it + cursor.setPosition(start) + cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + cursor.removeSelectedText() + + # Remove from parent's tracking and internal cache + if display_name in parent_feedback_ui.dropped_file_references: + del parent_feedback_ui.dropped_file_references[display_name] + if display_name in self._file_reference_cache["positions"]: + del self._file_reference_cache["positions"][display_name] + if display_name in self._file_reference_cache["references"]: + self._file_reference_cache["references"].remove(display_name) + + # 清理字典中不再存在于文本中的引用 + self._cleanup_orphaned_references(parent_feedback_ui) + + self._invalidate_reference_cache() # Mark cache as invalid for next update + # 触发语法高亮更新 + self.highlighter.rehighlight() + return True # Deletion handled + return False + + def _cleanup_orphaned_references(self, parent_feedback_ui: Any): + """清理字典中不再存在于文本中的文件引用""" + if not parent_feedback_ui or not parent_feedback_ui.dropped_file_references: + return + + current_text = self.toPlainText() + orphaned_refs = [] + + # 找出不再存在于文本中的引用 + for display_name in parent_feedback_ui.dropped_file_references.keys(): + if display_name not in current_text: + orphaned_refs.append(display_name) + + # 删除孤立的引用 + for ref in orphaned_refs: + del parent_feedback_ui.dropped_file_references[ref] + + def insertFromMimeData(self, source: QMimeData): + """ + 处理从剪贴板粘贴内容(图像、文本)到文本编辑小部件。 + Handles pasting content (images, text) from clipboard into the text edit widget. + """ + handled = False + parent_feedback_ui = self._find_feedback_ui_parent() + + # Handle images + if ( + source.hasImage() + and parent_feedback_ui + and hasattr(parent_feedback_ui, "add_image_preview") + ): + try: + pixmap = QPixmap(source.imageData()) + if not pixmap.isNull() and pixmap.width() > 0: + parent_feedback_ui.add_image_preview(pixmap) + handled = True + except Exception as e: + print( + f"ERROR: FeedbackTextEdit insertFromMimeData - Image handling failed: {e}", + file=sys.stderr, + ) + + # Handle plain text (should be standard, but added for completeness) + if source.hasText() and not handled: + text_to_insert = source.text().strip() + if text_to_insert: # Only insert if there's actual text + self.insertPlainText(text_to_insert) + # Mark as handled if text was present, even if empty after strip + # This prevents super().insertFromMimeData if only whitespace was pasted + handled = True + + if not handled: # If neither image nor text was handled by custom logic + super().insertFromMimeData(source) + + self._invalidate_reference_cache() + + # 确保在粘贴内容后设置焦点 + self.setFocus(Qt.FocusReason.OtherFocusReason) + self.ensureCursorVisible() + + def show_images_container(self, visible: bool): + """Shows or hides the image preview container at the bottom.""" + self.images_container.setVisible(visible) + container_height = 60 if visible else 0 + self.setViewportMargins(0, 0, 0, container_height) + self.viewport().update() + + def dragEnterEvent(self, event: QEvent): # QDragEnterEvent + mime_data = event.mimeData() + if ( + mime_data.hasUrls() or mime_data.hasText() or mime_data.hasImage() + ): # Simplified + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event: QEvent): # QDragMoveEvent + # Same conditions as dragEnterEvent generally + if ( + event.mimeData().hasUrls() + or event.mimeData().hasText() + or event.mimeData().hasImage() + ): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event: QEvent): # QDropEvent + """ + 处理将内容(图像、文件、文本)拖放到文本编辑小部件上的操作。 + 确保在拖放后激活光标,使用户可以直接输入文字。 + Handles dropping content (images, files, text) onto the text edit widget. + Ensures cursor is activated after dropping, allowing users to type directly. + """ + mime_data = event.mimeData() + parent_feedback_ui = self._find_feedback_ui_parent() + + # 1. Handle image drop + if mime_data.hasImage(): + if parent_feedback_ui and hasattr(parent_feedback_ui, "add_image_preview"): + pixmap = QPixmap(mime_data.imageData()) + parent_feedback_ui.add_image_preview(pixmap) + event.acceptProposedAction() + self._invalidate_reference_cache() + + # 设置焦点,确保UI更新后触发 + QTimer.singleShot(50, self._focus_after_content_drop) + return + + # 2. Handle file drop from local system + if mime_data.hasUrls(): + urls = mime_data.urls() + if urls and parent_feedback_ui: + # Assuming one file drop at a time for simplicity + file_path = urls[0].toLocalFile() + if os.path.isfile(file_path): + file_name = os.path.basename(file_path) + # Use a custom function to insert text and manage references + self._insert_file_reference_text( + parent_feedback_ui, file_path, file_name + ) + event.acceptProposedAction() + self._invalidate_reference_cache() + + # 设置焦点,确保UI更新后触发 + QTimer.singleShot(50, self._focus_after_content_drop) + return + + # 3. Handle text drop (could be from another app or internally) + if mime_data.hasText(): + # Check if text is a potential file path + if self._process_text_drop_as_file(event, mime_data, parent_feedback_ui): + self._invalidate_reference_cache() + + # 设置焦点,确保UI更新后触发 + QTimer.singleShot(50, self._focus_after_content_drop) + return + else: + # Standard text drop + super().dropEvent(event) + self._invalidate_reference_cache() + + # 设置焦点,确保UI更新后触发 + QTimer.singleShot(50, self._focus_after_content_drop) + return + + # Fallback for unhandled drop types + super().dropEvent(event) + + # 即使是未处理的拖放类型,也尝试激活光标 + QTimer.singleShot(50, self._focus_after_content_drop) + + def _process_text_drop_as_file( + self, event: QEvent, mime_data: QMimeData, parent_feedback_ui: Any + ) -> bool: + """ + Attempts to interpret dropped text as one or more file paths. + Returns True if text was successfully processed as file(s), False otherwise. + + 尝试将拖放的文本解释为一个或多个文件路径。 + 如果文本成功处理为文件,则返回 True,否则返回 False。 + """ + text = mime_data.text() + potential_paths = [] + + # Check for typical file URI scheme + if text.startswith("file:///"): + try: + from urllib.parse import unquote + + # Remove scheme and decode, handle OS-specific path adjustments + path_str = unquote(text.replace("file:///", "")) + if ( + sys.platform.startswith("win") + and len(path_str) > 1 + and path_str[1] != ":" + ): # e.g. /D:/path + path_str = path_str[1:] if path_str.startswith("/") else path_str + potential_paths.append(path_str) + except Exception as e: + print( + f"ERROR: FeedbackTextEdit _process_text_drop - Parsing file URI failed: {e}", + file=sys.stderr, + ) + else: + # Check for Windows-style absolute paths (e.g., C:\...) + # Or treat lines as potential paths if they exist + lines = text.splitlines() + for line in lines: + line = line.strip() + if not line: + continue + # Simple check for Windows path or if path exists (more generic) + if (re.match(r"^[a-zA-Z]:[/\\].+", line) and os.path.exists(line)) or ( + not re.match(r"^[a-zA-Z]:[/\\].+", line) and os.path.exists(line) + ): # Unix-like or relative paths that exist + potential_paths.append( + line.replace("\\", os.sep) + ) # Normalize separators + + processed_any_file = False + for path_str in potential_paths: + if os.path.exists(path_str): + file_name = os.path.basename(path_str) + # Try to add as image first + is_image_file = os.path.isfile(path_str) and os.path.splitext(path_str)[ + 1 + ].lower() in [".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp"] + + image_added = False + if is_image_file: + try: + pixmap = QPixmap(path_str) + if not pixmap.isNull() and pixmap.width() > 0: + if hasattr(parent_feedback_ui, "add_image_preview"): + parent_feedback_ui.add_image_preview(pixmap) + image_added = True + processed_any_file = True + except Exception as e: + print( + f"ERROR: FeedbackTextEdit _process_text_drop - Loading image from text path failed: {path_str}, {e}", + file=sys.stderr, + ) + + # If not an image or image loading failed, add as file reference + if not image_added: + self._insert_file_reference_text( + parent_feedback_ui, path_str, file_name + ) + processed_any_file = True + + if processed_any_file: + event.acceptProposedAction() + return True + return False + + def _insert_file_reference_text( + self, parent_feedback_ui: Any, file_path: str, file_name: str + ): + """插入文件引用占位符(纯文本模式)""" + base_display_name = f"@{file_name}" + display_name = base_display_name + + # 检查当前文本中实际存在的文件引用,避免重复 + current_text = self.toPlainText() + counter = 1 + while display_name in current_text: + display_name = f"@{file_name}({counter})" + counter += 1 + + # 存储文件引用到父窗口的跟踪字典 + parent_feedback_ui.dropped_file_references[display_name] = file_path + + try: + cursor = self.textCursor() + cursor.clearSelection() + + # 添加前导空格(如果需要) + if cursor.position() > 0: + cursor.insertText(" ") + + # 插入文件引用(纯文本) + cursor.insertText(display_name) + + # 添加后续空格 + cursor.insertText(" ") + + self.setTextCursor(cursor) + self._invalidate_reference_cache() + + # 触发语法高亮更新 + self.highlighter.rehighlight() + + except Exception as e: + print( + f"ERROR: FeedbackTextEdit _insert_file_reference - Text insertion failed: {e}", + file=sys.stderr, + ) + + def _ensure_focus_after_insert( + self, cursor: QTextCursor + ): # Keep for specific focus needs + """Helper to ensure focus and cursor visibility after content insertion.""" + self.setFocus(Qt.FocusReason.OtherFocusReason) + self.setTextCursor(cursor) + self.ensureCursorVisible() + + def _focus_after_content_drop(self): + """ + 设置拖放事件后的焦点和光标位置。 + 确保光标处于激活状态,用户可以直接输入文字。 + """ + # 确保窗口获得焦点 + if parent_widget := self.window(): + parent_widget.activateWindow() + parent_widget.raise_() + + # 设置焦点并移动光标到文本末尾 + self.setFocus(Qt.FocusReason.MouseFocusReason) + cursor = self.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.setTextCursor(cursor) + + # 确保光标可见 + self.ensureCursorVisible() + + def replace_text_with_undo_support(self, new_text: str): + """ + 替换文本内容,同时保留撤销历史 + Replace text content while preserving undo history + + Args: + new_text: 新的文本内容 + """ + # 使用QTextCursor选择全部文本并替换,这样可以保留撤销历史 + cursor = self.textCursor() + cursor.select(QTextCursor.SelectionType.Document) + cursor.insertText(new_text) + self.setTextCursor(cursor) + + # 使缓存失效,确保文件引用正确更新 + self._invalidate_reference_cache() + + # 触发语法高亮更新 + if hasattr(self, "highlighter"): + self.highlighter.rehighlight() + + def activate_input_focus(self): + """ + 激活输入框焦点和光标 + Activate input focus and cursor + """ + # 确保窗口获得焦点 + if parent_widget := self.window(): + parent_widget.activateWindow() + parent_widget.raise_() + + # 设置焦点 + self.setFocus(Qt.FocusReason.OtherFocusReason) + + # 移动光标到文本末尾 + cursor = self.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.setTextCursor(cursor) + + # 确保光标可见并闪烁 + self.ensureCursorVisible() diff --git a/src/feedback_ui/widgets/image_preview.py b/src/feedback_ui/widgets/image_preview.py new file mode 100644 index 0000000..9f605ae --- /dev/null +++ b/src/feedback_ui/widgets/image_preview.py @@ -0,0 +1,222 @@ +# feedback_ui/widgets/image_preview.py + +from PySide6.QtCore import QEvent, Qt, Signal # Added QObject +from PySide6.QtGui import QColor, QCursor, QPainter, QPixmap +from PySide6.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QMainWindow, + QVBoxLayout, + QWidget, +) + +from ..utils.object_pool import get_pixmap_pool, PooledResource + + +class ImagePreviewWidget(QWidget): + """ + A widget to display a small thumbnail of an image. + Shows a larger preview on hover and allows deletion on click. + + 用于显示图像小缩略图的小部件。 + 悬停时显示较大的预览,并允许单击删除。 + """ + + image_deleted = Signal( + int + ) # Emits the image_id when deleted (发出删除图像的 image_id) + + def __init__( + self, image_pixmap: QPixmap, image_id: int, parent: QWidget | None = None + ): + super().__init__(parent) + self.image_pixmap = ( + image_pixmap # This is the thumbnail, original_pixmap is full res + ) + self.image_id = image_id + self.original_pixmap = ( + image_pixmap # Store the full resolution pixmap for preview + ) + self.is_hovering = False + + self.setFixedSize(48, 48) # Fixed size for the thumbnail widget + + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) # Small margins around the thumbnail + layout.setSpacing(0) + + self.thumbnail_label = QLabel() + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Create scaled thumbnail for display (using object pool if available) + self.display_thumbnail = self._create_scaled_pixmap(44, 44) + self.hover_thumbnail = self._create_hover_thumbnail( + self.display_thumbnail + ) # Thumbnail for hover state + + self.thumbnail_label.setPixmap(self.display_thumbnail) + layout.addWidget(self.thumbnail_label) + self.setMouseTracking( + True + ) # Needed for enterEvent/leaveEvent without mouse button press + self.preview_window: QMainWindow | None = None # To hold the preview pop-up + + def _create_hover_thumbnail(self, base_thumbnail: QPixmap) -> QPixmap: + """Creates a version of the thumbnail with a red tint for hover effect.""" + if base_thumbnail.isNull(): + return base_thumbnail + + hover_pixmap = QPixmap(base_thumbnail.size()) + hover_pixmap.fill(Qt.GlobalColor.transparent) # Transparent background + + painter = QPainter(hover_pixmap) + painter.drawPixmap(0, 0, base_thumbnail) # Draw original thumbnail + # Apply a semi-transparent red overlay + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceAtop) + painter.fillRect( + hover_pixmap.rect(), QColor(255, 100, 100, 160) + ) # Reddish tint + painter.end() + return hover_pixmap + + def enterEvent(self, event: QEvent): # QEnterEvent + self.is_hovering = True + self.thumbnail_label.setPixmap(self.hover_thumbnail) # Change to hover version + self._show_full_image_preview() + super().enterEvent(event) + + def leaveEvent(self, event: QEvent): + self.is_hovering = False + self.thumbnail_label.setPixmap( + self.display_thumbnail + ) # Revert to default thumbnail + if self.preview_window and self.preview_window.isVisible(): + self.preview_window.close() # Close the pop-up preview + self.preview_window = None + super().leaveEvent(event) + + def mousePressEvent(self, event: QEvent): # QMouseEvent + if event.button() == Qt.MouseButton.LeftButton: + self._delete_image() # Trigger deletion on left click + event.accept() # Event handled + return + super().mousePressEvent(event) + + def _show_full_image_preview(self): + """Displays a larger, non-modal preview of the image near the cursor.""" + if not self.is_hovering or self.original_pixmap.isNull(): + return + + if ( + self.preview_window and self.preview_window.isVisible() + ): # Close existing first + self.preview_window.close() + self.preview_window = None + + max_preview_width = 400 + max_preview_height = 300 + + # 使用优化的缩放方法 + preview_pixmap = self._create_scaled_pixmap( + max_preview_width, max_preview_height + ) + + cursor_pos = QCursor.pos() # Global cursor position + + # Create a frameless window that stays on top + self.preview_window = QMainWindow(None) # Parent to None or self.window() + self.preview_window.setWindowFlags( + Qt.WindowType.FramelessWindowHint + | Qt.WindowType.Tool # Behaves like a tooltip window + | Qt.WindowType.WindowStaysOnTopHint + ) + self.preview_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + self.preview_window.setAttribute( + Qt.WidgetAttribute.WA_TranslucentBackground + ) # For rounded corners if QSS is used + + preview_widget_container = QWidget() + preview_widget_container.setObjectName("ImagePreviewPopupContainer") + preview_widget_container.setStyleSheet( + "#ImagePreviewPopupContainer { background-color: #2b2b2b; border: 1px solid #444; border-radius: 5px; padding: 5px; }" + "QLabel#PreviewImageLabel { background-color: transparent; }" + "QLabel#PreviewInfoLabel { color: #ccc; font-size: 9pt; background-color: transparent; padding-top: 3px; }" + ) + + layout = QVBoxLayout(preview_widget_container) + layout.setContentsMargins(5, 5, 5, 5) # Margins within the popup + + image_label = QLabel() + image_label.setObjectName("PreviewImageLabel") + image_label.setPixmap(preview_pixmap) + image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(image_label) + + info_text = f"尺寸 (Size): {self.original_pixmap.width()} x {self.original_pixmap.height()}" + info_label = QLabel(info_text) + info_label.setObjectName("PreviewInfoLabel") + info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(info_label) + + self.preview_window.setCentralWidget(preview_widget_container) + self.preview_window.adjustSize() # Adjust size to content + + # Position the preview window near the cursor, avoiding screen edges + popup_x = cursor_pos.x() + 20 + popup_y = cursor_pos.y() + 20 + + screen_geometry = QApplication.primaryScreen().availableGeometry() + if popup_x + self.preview_window.width() > screen_geometry.right(): + popup_x = cursor_pos.x() - self.preview_window.width() - 10 + if popup_y + self.preview_window.height() > screen_geometry.bottom(): + popup_y = cursor_pos.y() - self.preview_window.height() - 10 + + # Ensure it's not off-screen to the top/left either + popup_x = max(screen_geometry.left(), popup_x) + popup_y = max(screen_geometry.top(), popup_y) + + self.preview_window.move(popup_x, popup_y) + self.preview_window.show() + + def _delete_image(self): + """Emits the signal for image deletion and prepares for self-destruction.""" + if self.preview_window and self.preview_window.isVisible(): + self.preview_window.close() # Close preview if open + self.image_deleted.emit(self.image_id) + # The parent (FeedbackUI) will handle self.deleteLater() + + def _create_scaled_pixmap(self, max_width: int, max_height: int) -> QPixmap: + """ + 创建缩放后的QPixmap,优先使用对象池 + Create scaled QPixmap, preferring object pool + """ + # 检查是否需要缩放 + if ( + self.original_pixmap.width() <= max_width + and self.original_pixmap.height() <= max_height + ): + return self.original_pixmap + + # 尝试使用对象池 + pixmap_pool = get_pixmap_pool() + if pixmap_pool is not None: + with PooledResource(pixmap_pool) as temp_pixmap: + # 使用池化的pixmap进行缩放 + scaled = self.original_pixmap.scaled( + max_width, + max_height, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + # 复制结果到新的pixmap(因为池化对象会被回收) + result = QPixmap(scaled) + return result + else: + # 回退到直接创建 + return self.original_pixmap.scaled( + max_width, + max_height, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) diff --git a/src/feedback_ui/widgets/loading_overlay.py b/src/feedback_ui/widgets/loading_overlay.py new file mode 100644 index 0000000..e89734e --- /dev/null +++ b/src/feedback_ui/widgets/loading_overlay.py @@ -0,0 +1,232 @@ +""" +Loading Overlay Widget +加载覆盖层组件 + +提供一个半透明的加载覆盖层,显示在父窗口中央,用于表示正在进行的操作。 +Provides a semi-transparent loading overlay that displays in the center of the parent window +to indicate ongoing operations. +""" + +from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QTimer +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QGraphicsOpacityEffect +from PySide6.QtGui import QPainter, QColor + +# 常量定义 +DEFAULT_AUTO_HIDE_DELAY = 500 # 默认自动隐藏延迟(毫秒) +CONTAINER_WIDTH = 260 # 容器宽度 +ICON_SIZE = 60 # 图标尺寸 +FADE_IN_DURATION = 300 # 淡入动画时长 +FADE_OUT_DURATION = 200 # 淡出动画时长 + + +class LoadingOverlay(QWidget): + """ + 加载覆盖层组件 + Loading Overlay Component + + 在父窗口上显示半透明的加载指示器,包含旋转的进度条和提示文本。 + Displays a semi-transparent loading indicator over the parent window with + a spinning progress bar and hint text. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._setup_ui() + self._setup_animations() + self._apply_styles() + + # 初始隐藏 + self.hide() + + def _setup_ui(self): + """设置UI布局""" + # 设置窗口属性 + self.setWindowFlags(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + # 主布局 + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + + # 创建中央容器 + self.central_container = QWidget() + self.central_container.setObjectName("loadingContainer") + + # 中央容器布局 + container_layout = QVBoxLayout(self.central_container) + container_layout.setContentsMargins(30, 25, 30, 25) + container_layout.setSpacing(15) + + # 静态加载图标(替代动态进度条) + self.loading_icon = QLabel("⏳") + self.loading_icon.setObjectName("loadingIcon") + self.loading_icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.loading_icon.setStyleSheet("font-size: 32px; margin: 10px;") + self.loading_icon.setFixedSize(ICON_SIZE, ICON_SIZE) + + # 加载文本 + self.loading_label = QLabel("🔄 正在优化文本,请稍候...") + self.loading_label.setObjectName("loadingLabel") + self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # 添加到容器布局 + container_layout.addWidget(self.loading_icon, 0, Qt.AlignmentFlag.AlignCenter) + container_layout.addWidget(self.loading_label, 0, Qt.AlignmentFlag.AlignCenter) + + # 添加到主布局(居中) + main_layout.addWidget(self.central_container, 0, Qt.AlignmentFlag.AlignCenter) + + def _setup_animations(self): + """设置动画效果""" + # 透明度效果 + self.opacity_effect = QGraphicsOpacityEffect() + self.setGraphicsEffect(self.opacity_effect) + + # 淡入动画 + self.fade_in_animation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.fade_in_animation.setDuration(FADE_IN_DURATION) + self.fade_in_animation.setStartValue(0.0) + self.fade_in_animation.setEndValue(1.0) + self.fade_in_animation.setEasingCurve(QEasingCurve.Type.OutCubic) + + # 淡出动画 + self.fade_out_animation = QPropertyAnimation(self.opacity_effect, b"opacity") + self.fade_out_animation.setDuration(FADE_OUT_DURATION) + self.fade_out_animation.setStartValue(1.0) + self.fade_out_animation.setEndValue(0.0) + self.fade_out_animation.setEasingCurve(QEasingCurve.Type.InCubic) + self.fade_out_animation.finished.connect(self.hide) + + def _apply_styles(self): + """应用样式 - 优化版本""" + # 检测主题(简单的检测方法) + is_dark_theme = True # 默认深色主题 + if self.parent(): + # 尝试从父窗口获取主题信息 + parent_bg = self.parent().palette().color(self.parent().backgroundRole()) + is_dark_theme = parent_bg.lightness() < 128 + + self._apply_theme_styles(is_dark_theme) + + def _apply_theme_styles(self, is_dark_theme: bool): + """应用主题样式 - 优化版本""" + if is_dark_theme: + bg_color = "rgba(45, 45, 45, 1.0)" + border_color = "rgba(100, 100, 100, 0.6)" + text_color = "#ffffff" + icon_color = "#4A90E2" + else: + bg_color = "rgba(255, 255, 255, 1.0)" + border_color = "rgba(200, 200, 200, 0.8)" + text_color = "#333333" + icon_color = "#1565c0" + + # 统一的样式模板 + style_template = f""" + QWidget#loadingContainer {{ + background-color: {bg_color}; + border: 2px solid {border_color}; + border-radius: 12px; + min-width: {CONTAINER_WIDTH}px; + max-width: {CONTAINER_WIDTH}px; + }} + + QLabel#loadingLabel {{ + color: {text_color}; + font-size: 14px; + font-weight: 500; + padding: 5px; + background-color: transparent; + }} + + QLabel#loadingIcon {{ + color: {icon_color}; + background-color: transparent; + border: none; + }} + """ + self.setStyleSheet(style_template) + + def show_loading(self, message: str = "🔄 正在优化文本,请稍候..."): + """ + 显示加载覆盖层 + Show loading overlay + + Args: + message: 加载提示文本 + """ + # 重置为加载状态 + self.loading_icon.setText("⏳") + self.loading_label.setText(message) + self._update_position() + + # 确保完全不透明显示 + self.opacity_effect.setOpacity(1.0) + self.show() + self.raise_() + + def hide_loading(self): + """ + 隐藏加载覆盖层 + Hide loading overlay + """ + self.fade_out_animation.start() + + def show_success( + self, message: str = "✅ 完成!", auto_hide_delay: int = DEFAULT_AUTO_HIDE_DELAY + ): + """ + 显示成功状态并自动隐藏 + Show success status and auto hide + + Args: + message: 成功提示文本 + auto_hide_delay: 自动隐藏延迟(毫秒) + """ + # 更新为成功状态 + self.loading_icon.setText("✅") # 更改图标为成功标志 + self.loading_label.setText(message) + + # 确保完全不透明显示 + self.opacity_effect.setOpacity(1.0) + + # 如果当前未显示,则显示 + if not self.isVisible(): + self.show() + self.raise_() + # 成功状态直接显示,无需动画 + + # 自动隐藏 + QTimer.singleShot(auto_hide_delay, self.hide_loading) + + def _update_position(self): + """更新位置,确保居中显示""" + if self.parent(): + parent_rect = self.parent().rect() + self.setGeometry(parent_rect) + + def resizeEvent(self, event): + """窗口大小变化时重新定位""" + super().resizeEvent(event) + self._update_position() + + def paintEvent(self, event): + """绘制半透明背景""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 绘制半透明背景 + overlay_color = QColor(0, 0, 0, 80) # 半透明黑色 + painter.fillRect(self.rect(), overlay_color) + + super().paintEvent(event) + + def set_theme(self, is_dark_theme: bool): + """ + 设置主题 - 优化版本 + Set theme + + Args: + is_dark_theme: 是否为深色主题 + """ + self._apply_theme_styles(is_dark_theme) diff --git a/src/feedback_ui/widgets/screenshot_window.py b/src/feedback_ui/widgets/screenshot_window.py new file mode 100644 index 0000000..1b08043 --- /dev/null +++ b/src/feedback_ui/widgets/screenshot_window.py @@ -0,0 +1,208 @@ +""" +截图窗口模块 +提供全屏截图选择功能 +""" + +import sys +from PySide6.QtCore import Qt, QRect, QPoint, Signal +from PySide6.QtGui import QPainter, QPen, QBrush, QColor, QPixmap, QCursor +from PySide6.QtWidgets import QWidget, QApplication + +from ..utils.constants import ( + SCREENSHOT_MIN_SIZE, + SCREENSHOT_OVERLAY_OPACITY, + SCREENSHOT_BORDER_COLOR, + SCREENSHOT_BORDER_WIDTH, + SCREENSHOT_TEXT_COLOR, +) + + +class ScreenshotWindow(QWidget): + """ + 全屏截图选择窗口 + 用户可以通过拖拽鼠标选择矩形区域进行截图 + """ + + # 信号:截图完成,传递QPixmap对象 + screenshot_taken = Signal(QPixmap) + # 信号:截图取消 + screenshot_cancelled = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setup_window() + self.init_variables() + # 先捕获屏幕,再显示窗口,减少闪烁 + self.capture_screen() + + def setup_window(self): + """设置窗口属性""" + # 设置为无边框、置顶、全屏窗口 + self.setWindowFlags( + Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint + | Qt.WindowType.Tool + ) + + # 设置窗口透明背景 + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + # 设置鼠标追踪 + self.setMouseTracking(True) + + # 设置光标为十字形 + self.setCursor(QCursor(Qt.CursorShape.CrossCursor)) + + # 设置窗口几何但不显示 + screen = QApplication.primaryScreen().geometry() + self.setGeometry(screen) + + def init_variables(self): + """初始化变量""" + self.start_point = QPoint() # 拖拽起始点 + self.end_point = QPoint() # 拖拽结束点 + self.is_dragging = False # 是否正在拖拽 + self.screen_pixmap = None # 屏幕截图 + + def capture_screen(self): + """捕获当前屏幕内容""" + try: + # 直接捕获屏幕,不显示窗口 + screen = QApplication.primaryScreen() + if not screen: + raise RuntimeError("无法获取主屏幕") + + self.screen_pixmap = screen.grabWindow(0) + if self.screen_pixmap.isNull(): + raise RuntimeError("屏幕截图为空") + + # 捕获完成后再显示窗口 + self.showFullScreen() + except (RuntimeError, AttributeError) as e: + print(f"ERROR: 捕获屏幕失败: {e}", file=sys.stderr) + self.screenshot_cancelled.emit() + self.close() + + def mousePressEvent(self, event): + """鼠标按下事件""" + if event.button() == Qt.MouseButton.LeftButton: + self.start_point = event.pos() + self.end_point = event.pos() + self.is_dragging = True + self.update() + + def mouseMoveEvent(self, event): + """鼠标移动事件""" + if self.is_dragging: + self.end_point = event.pos() + self.update() + + def mouseReleaseEvent(self, event): + """鼠标释放事件""" + if event.button() == Qt.MouseButton.LeftButton and self.is_dragging: + self.is_dragging = False + self.capture_selected_area() + elif event.button() == Qt.MouseButton.RightButton: + # 右键取消截图 + self.screenshot_cancelled.emit() + self.close() + + def keyPressEvent(self, event): + """键盘按下事件""" + if event.key() == Qt.Key.Key_Escape: + # ESC键取消截图 + self.screenshot_cancelled.emit() + self.close() + super().keyPressEvent(event) + + def paintEvent(self, event): + """绘制事件""" + super().paintEvent(event) + painter = QPainter(self) + + # 绘制屏幕背景(半透明遮罩) + if self.screen_pixmap: + painter.drawPixmap(0, 0, self.screen_pixmap) + + # 绘制半透明遮罩 + overlay = QBrush(QColor(0, 0, 0, SCREENSHOT_OVERLAY_OPACITY)) # 黑色半透明 + painter.fillRect(self.rect(), overlay) + + # 如果正在选择,绘制选择区域 + if self.is_dragging or (self.start_point != self.end_point): + selection_rect = self.get_selection_rect() + + # 清除选择区域的遮罩(显示原始屏幕内容) + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Clear) + painter.fillRect(selection_rect, QBrush(QColor(0, 0, 0, 0))) + + # 重新绘制选择区域的原始内容 + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_SourceOver + ) + if self.screen_pixmap: + painter.drawPixmap(selection_rect, self.screen_pixmap, selection_rect) + + # 绘制选择框边框 + pen = QPen( + QColor(*SCREENSHOT_BORDER_COLOR), SCREENSHOT_BORDER_WIDTH + ) # 蓝色边框 + painter.setPen(pen) + painter.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + painter.drawRect(selection_rect) + + # 绘制尺寸信息 + self.draw_size_info(painter, selection_rect) + + def get_selection_rect(self): + """获取选择矩形""" + return QRect( + min(self.start_point.x(), self.end_point.x()), + min(self.start_point.y(), self.end_point.y()), + abs(self.end_point.x() - self.start_point.x()), + abs(self.end_point.y() - self.start_point.y()), + ) + + def draw_size_info(self, painter, rect): + """绘制尺寸信息""" + if rect.width() > 0 and rect.height() > 0: + # 设置文本样式 + painter.setPen(QPen(QColor(*SCREENSHOT_TEXT_COLOR), 1)) + + # 尺寸文本 + size_text = f"{rect.width()} × {rect.height()}" + + # 计算文本位置(在选择框上方) + text_x = rect.x() + 5 + text_y = rect.y() - 5 + + # 确保文本不超出屏幕 + if text_y < 20: + text_y = rect.y() + 20 + + painter.drawText(text_x, text_y, size_text) + + def capture_selected_area(self): + """捕获选择的区域""" + selection_rect = self.get_selection_rect() + + # 检查选择区域是否有效 + if ( + selection_rect.width() < SCREENSHOT_MIN_SIZE + or selection_rect.height() < SCREENSHOT_MIN_SIZE + ): + self.screenshot_cancelled.emit() + self.close() + return + + # 从屏幕截图中提取选择区域 + if self.screen_pixmap and not self.screen_pixmap.isNull(): + selected_pixmap = self.screen_pixmap.copy(selection_rect) + if not selected_pixmap.isNull(): + self.screenshot_taken.emit(selected_pixmap) + else: + self.screenshot_cancelled.emit() + else: + self.screenshot_cancelled.emit() + + self.close() diff --git a/src/feedback_ui/widgets/selectable_label.py b/src/feedback_ui/widgets/selectable_label.py new file mode 100644 index 0000000..9787f74 --- /dev/null +++ b/src/feedback_ui/widgets/selectable_label.py @@ -0,0 +1,386 @@ +import re +import importlib +from PySide6.QtCore import QEvent, QObject, Qt, Signal +from PySide6.QtGui import QTextDocument +from PySide6.QtWidgets import QLabel + +from ..utils.ui_helpers import set_selection_colors + +# 预编译正则表达式,提高性能 +_COLOR_STYLE_PATTERN = re.compile(r"color:\s*[^;]+;") +_BODY_TAG_PATTERN = re.compile(r"]*>") + +# 代码颜色常量 +_CODE_COLOR = "#4A90E2" + +# 预编译代码处理的正则表达式 - 增强版本以处理更多格式 +_INLINE_CODE_PATTERN = re.compile( + r']*>.*?)]*>.*?.*?)', + re.DOTALL, +) + + +class SelectableLabel(QLabel): + """ + 一个可以选择文本的标签,同时支持点击操作。 + A label that allows text selection while also supporting click operations. + """ + + clicked = Signal() + + def __init__(self, text: str = "", parent: QObject = None): + super().__init__(parent) + # 启用文本选择 + self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.setMouseTracking(True) + self.setWordWrap(True) + + # 设置选择文本时的高亮颜色为灰色 + set_selection_colors(self) + + # 跟踪鼠标按下的位置,用于判断是否为点击操作 + self._press_pos = None + self._is_dragging = False + + # 初始化属性 + self._original_text = "" + self._enable_formatting = False # 默认禁用格式化 + + # 缓存text_formatter模块,避免重复导入 + self._text_formatter = None + self._text_formatter_loaded = False + + # 设置初始文本(如果提供) + if text: + self.setText(text) + + def mousePressEvent(self, event: QEvent): + """记录鼠标按下的位置,用于后续判断是点击还是拖拽选择文本""" + if event.button() == Qt.MouseButton.LeftButton: + self._press_pos = event.position().toPoint() + self._is_dragging = False + + # 调用父类的事件处理,确保文本选择功能正常 + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QEvent): + """如果鼠标移动超过阈值,标记为拖拽操作""" + if ( + self._press_pos + and (event.position().toPoint() - self._press_pos).manhattanLength() > 5 + ): + self._is_dragging = True + + # 调用父类的事件处理,确保文本选择功能正常 + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: QEvent): + """根据是否为拖拽操作,决定是发送点击信号还是执行文本选择""" + if event.button() == Qt.MouseButton.LeftButton and self._press_pos: + # 如果不是拖拽操作,并且鼠标释放在标签范围内,则发射点击信号 + if not self._is_dragging and self.rect().contains( + event.position().toPoint() + ): + # 如果没有选中文本,才发射点击信号 + if not self.hasSelectedText(): + self.clicked.emit() + + # 重置状态 + self._press_pos = None + self._is_dragging = False + + # 调用父类的事件处理,确保文本选择功能正常 + super().mouseReleaseEvent(event) + + def hasSelectedText(self) -> bool: + """检查是否有选中的文本""" + # QLabel没有直接的方法检查选中文本,使用多种方法检查 + try: + from PySide6.QtGui import QGuiApplication + + # 方法1:检查系统剪贴板 + clipboard = QGuiApplication.clipboard() + if clipboard and clipboard.ownsSelection(): + return True + + # 方法2:检查是否有选择模式(更可靠的方法) + if hasattr(self, "selectionStart") and hasattr(self, "selectionLength"): + return self.selectionLength() > 0 + + except Exception: + # 如果检查失败,保守地返回False + pass + + return False + + def setText(self, text: str): + """ + 设置文本内容,自动检测并渲染Markdown + Set text content with automatic Markdown detection and rendering + + Args: + text (str): 要设置的文本 + """ + # 存储原始文本 + self._original_text = text or "" + + try: + # 检测是否为Markdown内容 + if self._is_markdown_content(text): + # 设置为富文本格式并渲染Markdown + self.setTextFormat(Qt.TextFormat.RichText) + html_content = self._convert_markdown_to_html(text) + + # 验证HTML内容是否有效 + if html_content and html_content != text: + super().setText(html_content) + else: + # 如果转换失败,回退到普通文本 + self.setTextFormat(Qt.TextFormat.PlainText) + super().setText(text or "") + else: + # 普通文本,保持原有逻辑 + self.setTextFormat(Qt.TextFormat.PlainText) + super().setText(text or "") + except Exception: + # 任何异常都回退到普通文本模式,确保稳定性 + self.setTextFormat(Qt.TextFormat.PlainText) + super().setText(text or "") + + def setFormattingEnabled(self, enabled: bool): + """ + 启用或禁用文本格式化功能(当前已禁用格式化功能) + Enable or disable text formatting feature (formatting is currently disabled) + + Args: + enabled (bool): True启用格式化,False禁用 + """ + self._enable_formatting = enabled + # 注意:当前版本已禁用格式化功能,此方法保留用于兼容性 + + def getOriginalText(self) -> str: + """ + 获取原始未格式化的文本 + Get the original unformatted text + + Returns: + str: 原始文本 + """ + return self._original_text + + def isFormattingEnabled(self) -> bool: + """ + 检查是否启用了文本格式化 + Check if text formatting is enabled + + Returns: + bool: True表示启用格式化 + """ + return self._enable_formatting + + def _get_text_formatter(self): + """ + 获取text_formatter模块,使用缓存避免重复导入 + Get text_formatter module with caching to avoid repeated imports + """ + if self._text_formatter_loaded: + return self._text_formatter + + # 标记已尝试加载,避免重复尝试 + self._text_formatter_loaded = True + + # 多重导入策略 + strategies = [ + # 策略1:相对导入(开发环境) + lambda: __import__( + "feedback_ui.utils.text_formatter", fromlist=["text_formatter"] + ).text_formatter, + # 策略2:绝对导入(uv安装环境) + lambda: importlib.import_module( + "feedback_ui.utils.text_formatter" + ).text_formatter, + ] + + for strategy in strategies: + try: + self._text_formatter = strategy() + if hasattr(self._text_formatter, "is_formatted_text"): + return self._text_formatter + except (ImportError, AttributeError, ValueError): + continue + + return None + + def _is_markdown_content(self, text: str) -> bool: + """ + 检测文本是否包含Markdown格式 + Detect if text contains Markdown formatting + + Args: + text (str): 要检测的文本 + + Returns: + bool: True表示包含Markdown格式 + """ + if not text: + return False + + # 尝试使用text_formatter进行精确检测 + text_formatter = self._get_text_formatter() + if text_formatter: + try: + return text_formatter.is_formatted_text(text) + except Exception: + pass + + # 回退到简化的检测逻辑 + return self._fallback_markdown_detection(text) + + def _fallback_markdown_detection(self, text: str) -> bool: + """ + 简化的回退Markdown检测逻辑,减少误判 + Simplified fallback Markdown detection logic with reduced false positives + + Args: + text (str): 要检测的文本 + + Returns: + bool: True表示可能包含Markdown格式 + """ + if not text or len(text.strip()) < 3: + return False + + # 检测明确的Markdown标记(行首标记) + lines = text.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + + # 标题、代码块、列表、引用(必须在行首) + if ( + (line.startswith("#") and len(line) > 1 and line[1] in (" ", "#")) + or line.startswith("```") + or line.startswith(("- ", "* ", "> ")) + or re.match(r"^\d+\.\s+", line) + ): + return True + + # 检测成对的格式标记 + if ( + (text.count("**") >= 2 and text.count("**") % 2 == 0) + or (text.count("`") >= 2 and text.count("`") % 2 == 0) + or re.search(r"\[.+\]\(.+\)", text) + ): + return True + + return False + + def _convert_markdown_to_html(self, markdown_text: str) -> str: + """ + 将Markdown转换为HTML + Convert Markdown to HTML + + Args: + markdown_text (str): Markdown文本 + + Returns: + str: 转换后的HTML + """ + try: + # 使用QTextDocument的原生Markdown支持 + doc = QTextDocument() + doc.setMarkdown(markdown_text) + html = doc.toHtml() + + # 验证转换结果并处理 + if html and html.strip(): + html = self._apply_code_colors(html) + html = _BODY_TAG_PATTERN.sub("", html) + return html + else: + return markdown_text + except Exception: + # 任何异常都回退到原文本,确保稳定性 + return markdown_text + + def _apply_code_colors(self, html: str) -> str: + """ + 为代码元素添加CSS类名和蓝色样式 - 增强版本 + Add CSS classes and blue color styles to code elements - Enhanced version + + Args: + html (str): 原始HTML内容 + + Returns: + str: 处理后的HTML内容 + """ + + # 1. 处理内联代码(span标签包含Courier New字体) + def replace_inline_code(match): + style = match.group(1) + # 移除现有的颜色样式,然后添加代码颜色 + style_without_color = re.sub(r"color:\s*[^;]+;?", "", style) + # 确保样式以分号结尾,但避免双分号 + style_clean = style_without_color.rstrip(";") + # 使用更强的内联样式,包含背景和字体 + enhanced_style = f"{style_clean}; color: {_CODE_COLOR} !important; background-color: rgba(60, 60, 60, 0.4) !important; padding: 2px 4px !important; border-radius: 3px !important; font-family: 'Consolas', 'Monaco', 'Courier New', monospace !important;" + return f' str: + """ + 移除非代码元素的颜色样式 + Remove color styles from non-code elements + + Args: + html (str): HTML内容 + + Returns: + str: 处理后的HTML内容 + """ + # 定义代码相关标记(元组比列表更高效) + code_markers = ( + "font-family:'Courier New'", + " bool: + """检查参数是否有效(非空且非纯空白)""" + return param and param.strip() + + +def _process_ui_output(ui_output_dict: Dict[str, Any]) -> List[Union[str, Image]]: + """ + 处理UI输出内容,提取文本、图片和文件引用 + + Args: + ui_output_dict: UI返回的输出字典 + + Returns: + List[Union[str, Image]]: 处理后的内容列表 + """ + processed_content: List[Union[str, Image]] = [] + + if not ( + ui_output_dict + and "content" in ui_output_dict + and isinstance(ui_output_dict["content"], list) + ): + return processed_content + + for item in ui_output_dict.get("content", []): + if not isinstance(item, dict): + print(f"警告: 无效的内容项格式: {item}", file=sys.stderr) + continue + + item_type = item.get("type") + if item_type == "text": + text_content = item.get("text", "") + if text_content: + processed_content.append(text_content) + elif item_type == "image": + _process_image_item(item, processed_content) + elif item_type == "file_reference": + _process_file_reference_item(item, processed_content) + else: + print(f"警告: 未知的内容项类型: {item_type}", file=sys.stderr) + + return processed_content + + +def _process_image_item( + item: Dict[str, Any], processed_content: List[Union[str, Image]] +) -> None: + """处理图片项""" + base64_data = item.get("data") + mime_type = item.get("mimeType") + if base64_data and mime_type: + try: + image_format_str = mime_type.split("/")[-1].lower() + if image_format_str == "jpeg": + image_format_str = "jpg" + + image_bytes = base64.b64decode(base64_data) + mcp_image = Image(data=image_bytes, format=image_format_str) + processed_content.append(mcp_image) + except Exception as e: + print(f"错误: 处理图像失败: {e}", file=sys.stderr) + processed_content.append(f"[图像处理失败: {mime_type or 'unknown type'}]") + + +def _process_file_reference_item( + item: Dict[str, Any], processed_content: List[Union[str, Image]] +) -> None: + """处理文件引用项""" + display_name = item.get("display_name", "") + file_path = item.get("path", "") + if display_name and file_path: + file_info = f"引用文件: {display_name} [路径: {file_path}]" + processed_content.append(file_info) + + +def get_system_prompts(): + """ + 获取系统提示词(从配置读取,使用config_manager中的默认值) + Get system prompts (read from config, use defaults from config_manager) + + Returns: + dict: 包含optimize和reinforce提示词的字典 + """ + try: + config = get_config() + optimizer_config = config.get("expression_optimizer", {}) + return optimizer_config.get("prompts", {}) + except Exception: + # 回退到config_manager中的默认配置 + from .utils.config_manager import DEFAULT_CONFIG + + return DEFAULT_CONFIG["expression_optimizer"]["prompts"] + + +def format_prompt_for_mode( + original_text: str, mode: str, reinforcement_prompt: str = None +) -> str: + """ + 根据模式格式化提示词 + Format prompt based on mode + + Args: + original_text: 原始文本 + mode: 优化模式 + reinforcement_prompt: 强化指令(可选) + + Returns: + str: 格式化后的提示词 + """ + if mode == "reinforce" and reinforcement_prompt: + return f"强化指令: '{reinforcement_prompt}'\n\n原始文本: '{original_text}'" + else: + return original_text + + +print(f"Server.py 启动 - Python解释器路径: {sys.executable}") +print(f"Server.py 当前工作目录: {os.getcwd()}") + + +mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR") + + +def launch_feedback_ui( + summary: str, predefined_options_list: Optional[List[str]] = None +) -> Dict[str, Any]: + """ + Launches the feedback UI as a separate process using its command-line entry point. + Collects user input and returns it as a structured dictionary. + """ + tmp_file_path = None + try: + # 创建输出文件 + with tempfile.NamedTemporaryFile( + suffix=".json", delete=False, mode="w", encoding="utf-8" + ) as tmp: + tmp_file_path = tmp.name + + options_str = ( + "|||".join(predefined_options_list) if predefined_options_list else "" + ) + + # Build the argument list for the 'feedback-ui' command + args_list = [ + "feedback-ui", + "--prompt", + summary, + "--output-file", + tmp_file_path, + "--predefined-options", + options_str, + ] + + # Run the feedback-ui command + process_result = subprocess.run( + args_list, + check=False, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + close_fds=( + os.name != "nt" + ), # close_fds is not supported on Windows when shell=False + text=True, + encoding="utf-8", + errors="replace", + ) + + if process_result.returncode != 0: + print( + f"错误: 启动反馈UI失败,返回码: {process_result.returncode}", + file=sys.stderr, + ) + if process_result.stdout: + print(f"UI STDOUT:\n{process_result.stdout}", file=sys.stderr) + if process_result.stderr: + print(f"UI STDERR:\n{process_result.stderr}", file=sys.stderr) + raise Exception(f"启动反馈UI失败: {process_result.returncode}") + + with open(tmp_file_path, "r", encoding="utf-8") as f: + ui_result_data = json.load(f) + + return ui_result_data + + except FileNotFoundError: + print("错误: 'feedback-ui' 命令未找到", file=sys.stderr) + print("请确保项目已在可编辑模式下安装 (pip install -e .)", file=sys.stderr) + raise + except Exception as e: + print(f"错误: launch_feedback_ui 异常: {e}", file=sys.stderr) + raise + finally: + # 清理临时文件 + if tmp_file_path and os.path.exists(tmp_file_path): + try: + os.unlink(tmp_file_path) + except OSError as e_unlink: + print( + f"警告: 删除临时文件失败 '{tmp_file_path}': {e_unlink}", + file=sys.stderr, + ) + + +@mcp.tool() +def interactive_feedback( + message: Optional[str] = Field( + default=None, + description="[SIMPLE mode] Concise question for user input (AI must display full response in chat first)", + ), + full_response: Optional[str] = Field( + default=None, + description="[FULL mode] AI's complete response content (AI must display this in chat first)", + ), + predefined_options: List[str] = Field( + default_factory=list, description="Predefined options for user selection" + ), +) -> Tuple[Union[str, Image], ...]: # 返回字符串和/或 fastmcp.Image 对象的元组 + """ + Requests user input via GUI after AI displays complete response in chat. + + USAGE FLOW: + 1. AI displays complete response in chat dialog + 2. AI calls this tool to collect user input + 3. Tool returns user feedback only + + This tool collects user input, not for displaying AI responses. + AI responses must appear in chat dialog before calling this tool. + + PARAMETER REQUIREMENTS: + - AI MUST provide BOTH 'message' and 'full_response' parameters + - Both parameters cannot be empty or whitespace-only + - MCP service will automatically select which content to display based on user's display_mode setting + + USAGE PATTERN: + + # Step 1: AI displays complete response in chat + # Step 2: AI calls tool with BOTH parameters + interactive_feedback( + message="你希望我实现这些更改吗?", # Required: concise question + full_response="我分析了你的代码,发现了3个问题...", # Required: complete response + predefined_options=["修复方案A", "修复方案B", "让我想想"] + ) + + Note: MCP service automatically selects appropriate content based on user's display mode configuration. + """ + + # 严格的双参数验证:AI必须同时提供两个有效参数 + if not _is_valid_param(message) or not _is_valid_param(full_response): + return (ERROR_MESSAGES["missing_both_params"],) + + # 获取配置(一次性读取,避免重复) + config = get_config() + display_mode = get_display_mode(config) + + # 根据用户配置的显示模式选择要展示的内容 + prompt_to_display = full_response if display_mode == "full" else message + + # 解析最终选项 - 兼容性修复:将空列表转换为None以保持现有逻辑 + ai_options_normalized = predefined_options if predefined_options else None + final_options = resolve_final_options( + ai_options=ai_options_normalized, text=prompt_to_display, config=config + ) + + # 转换为UI需要的格式(final_options已经是字符串列表,无需转换) + options_list_for_ui = final_options if final_options else None + + # 启动UI并获取用户输入 + ui_output_dict = launch_feedback_ui(prompt_to_display, options_list_for_ui) + + # 处理UI输出内容 + processed_mcp_content = _process_ui_output(ui_output_dict) + + if not processed_mcp_content: + return (ERROR_MESSAGES["no_user_feedback"],) + + return tuple(processed_mcp_content) + + +def _optimize_user_input_internal( + original_text: str, + mode: str, + reinforcement_prompt: Optional[str] = None, +) -> str: + """ + 内部优化函数,供GUI和MCP工具共同使用 + Internal optimization function for both GUI and MCP tool usage + + Args: + original_text: 用户的原始输入文本 + mode: 优化模式 ('optimize' 或 'reinforce') + reinforcement_prompt: 在 'reinforce' 模式下用户的自定义指令 + + Returns: + str: 优化后的文本或错误信息 + """ + try: + # 导入LLM模块 + from .llm.factory import get_llm_provider + from .llm.performance_manager import get_optimization_manager + + # 获取配置 + config = get_config().get("expression_optimizer", {}) + + # 获取LLM provider + provider, status_message = get_llm_provider(config) + + if not provider: + return f"[优化功能不可用] {status_message}" + + # 获取系统提示词 + system_prompts = get_system_prompts() + + # 验证模式和参数 + if mode == "optimize": + system_prompt = system_prompts["optimize"] + elif mode == "reinforce": + if not reinforcement_prompt: + return "[错误] 'reinforce' 模式需要提供强化指令" + system_prompt = system_prompts["reinforce"] + else: + return f"[错误] 无效的优化模式: {mode}。支持的模式: 'optimize', 'reinforce'" + + # 简化逻辑:默认使用性能管理器(包含缓存功能) + manager = get_optimization_manager(config) + + result = manager.optimize_with_cache( + provider=provider, + text=original_text, + mode=mode, + system_prompt=system_prompt, + reinforcement=reinforcement_prompt or "", + ) + + # 检查是否是错误信息 + if result.startswith("[ERROR"): + return f"[优化失败] {result}" + + return result + + except ImportError as e: + return f"[配置错误] LLM模块导入失败: {e}" + except Exception as e: + return f"[系统错误] 优化过程中发生异常: {e}" + + +@mcp.tool() +def optimize_user_input( + original_text: str = Field(description="用户的原始输入文本"), + mode: str = Field(description="优化模式: 'optimize' 或 'reinforce'"), + reinforcement_prompt: Optional[str] = Field( + default=None, description="在 'reinforce' 模式下用户的自定义指令" + ), +) -> str: + """ + 使用配置的 LLM API 来优化或强化用户输入的文本。 + + 此功能可以帮助用户将口语化的、可能存在歧义的输入,转化为更结构化、 + 更清晰、更便于 AI 模型理解的文本。 + + Args: + original_text: 用户的原始输入文本 + mode: 优化模式 + - 'optimize': 一键优化,使用预设的通用优化指令 + - 'reinforce': 提示词强化,使用用户自定义的强化指令 + reinforcement_prompt: 在 'reinforce' 模式下用户的自定义指令 + + Returns: + str: 优化后的文本或错误信息 + """ + return _optimize_user_input_internal(original_text, mode, reinforcement_prompt) + + +def main(): + """Main function to run the MCP server.""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/interactive_feedback_server/cli.py.backup b/src/interactive_feedback_server/cli.py.backup new file mode 100644 index 0000000..0a1f6c4 --- /dev/null +++ b/src/interactive_feedback_server/cli.py.backup @@ -0,0 +1,395 @@ +# Interactive Feedback MCP +# Developed by Fábio Ferreira (https://x.com/fabiomlferreira) +# Inspired by/related to dotcursorrules.com (https://dotcursorrules.com/) +# Enhanced by pawa (https://github.com/pawaovo) with ideas from https://github.com/noopstudios/interactive-feedback-mcp +import os +import sys +import json +import tempfile +import subprocess +import base64 + +# from typing import Annotated # Annotated 未在此文件中直接使用 (Annotated not directly used in this file) +from typing import ( + Dict, + List, + Any, + Optional, + Tuple, + Union, +) # 简化导入 (Simplified imports) + +from fastmcp import FastMCP, Image +from pydantic import ( + Field, +) # Field 由 FastMCP 内部使用 (Field is used internally by FastMCP) + +from .utils import get_config, resolve_final_options, get_display_mode + +# 错误消息常量 +ERROR_MESSAGES = { + "missing_both_params": "[错误] AI必须同时提供message和full_response两个参数,不能为空", + "no_user_feedback": "[用户未提供反馈]", +} + + +def _is_valid_param(param: Optional[str]) -> bool: + """检查参数是否有效(非空且非纯空白)""" + return param and param.strip() + + +def _process_ui_output(ui_output_dict: Dict[str, Any]) -> List[Union[str, Image]]: + """ + 处理UI输出内容,提取文本、图片和文件引用 + + Args: + ui_output_dict: UI返回的输出字典 + + Returns: + List[Union[str, Image]]: 处理后的内容列表 + """ + processed_content: List[Union[str, Image]] = [] + + if not ( + ui_output_dict + and "content" in ui_output_dict + and isinstance(ui_output_dict["content"], list) + ): + return processed_content + + for item in ui_output_dict.get("content", []): + if not isinstance(item, dict): + print(f"警告: 无效的内容项格式: {item}", file=sys.stderr) + continue + + item_type = item.get("type") + if item_type == "text": + text_content = item.get("text", "") + if text_content: + processed_content.append(text_content) + elif item_type == "image": + _process_image_item(item, processed_content) + elif item_type == "file_reference": + _process_file_reference_item(item, processed_content) + else: + print(f"警告: 未知的内容项类型: {item_type}", file=sys.stderr) + + return processed_content + + +def _process_image_item( + item: Dict[str, Any], processed_content: List[Union[str, Image]] +) -> None: + """处理图片项""" + base64_data = item.get("data") + mime_type = item.get("mimeType") + if base64_data and mime_type: + try: + image_format_str = mime_type.split("/")[-1].lower() + if image_format_str == "jpeg": + image_format_str = "jpg" + + image_bytes = base64.b64decode(base64_data) + mcp_image = Image(data=image_bytes, format=image_format_str) + processed_content.append(mcp_image) + except Exception as e: + print(f"错误: 处理图像失败: {e}", file=sys.stderr) + processed_content.append(f"[图像处理失败: {mime_type or 'unknown type'}]") + + +def _process_file_reference_item( + item: Dict[str, Any], processed_content: List[Union[str, Image]] +) -> None: + """处理文件引用项""" + display_name = item.get("display_name", "") + file_path = item.get("path", "") + if display_name and file_path: + file_info = f"引用文件: {display_name} [路径: {file_path}]" + processed_content.append(file_info) + + +def get_system_prompts(): + """ + 获取系统提示词(从配置读取,使用config_manager中的默认值) + Get system prompts (read from config, use defaults from config_manager) + + Returns: + dict: 包含optimize和reinforce提示词的字典 + """ + try: + config = get_config() + optimizer_config = config.get("expression_optimizer", {}) + return optimizer_config.get("prompts", {}) + except Exception: + # 回退到config_manager中的默认配置 + from .utils.config_manager import DEFAULT_CONFIG + + return DEFAULT_CONFIG["expression_optimizer"]["prompts"] + + +def format_prompt_for_mode( + original_text: str, mode: str, reinforcement_prompt: str = None +) -> str: + """ + 根据模式格式化提示词 + Format prompt based on mode + + Args: + original_text: 原始文本 + mode: 优化模式 + reinforcement_prompt: 强化指令(可选) + + Returns: + str: 格式化后的提示词 + """ + if mode == "reinforce" and reinforcement_prompt: + return f"强化指令: '{reinforcement_prompt}'\n\n原始文本: '{original_text}'" + else: + return original_text + + +print(f"Server.py 启动 - Python解释器路径: {sys.executable}") +print(f"Server.py 当前工作目录: {os.getcwd()}") + + +mcp = FastMCP("Interactive Feedback MCP", log_level="ERROR") + + +def launch_feedback_ui( + summary: str, predefined_options_list: Optional[List[str]] = None +) -> Dict[str, Any]: + """ + Launches the feedback UI as a separate process using its command-line entry point. + Collects user input and returns it as a structured dictionary. + """ + tmp_file_path = None + try: + # 创建输出文件 + with tempfile.NamedTemporaryFile( + suffix=".json", delete=False, mode="w", encoding="utf-8" + ) as tmp: + tmp_file_path = tmp.name + + options_str = ( + "|||".join(predefined_options_list) if predefined_options_list else "" + ) + + # Build the argument list for the 'feedback-ui' command + args_list = [ + "feedback-ui", + "--prompt", + summary, + "--output-file", + tmp_file_path, + "--predefined-options", + options_str, + ] + + # Run the feedback-ui command + process_result = subprocess.run( + args_list, + check=False, + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + close_fds=( + os.name != "nt" + ), # close_fds is not supported on Windows when shell=False + text=True, + encoding="utf-8", + errors="replace", + ) + + if process_result.returncode != 0: + print( + f"错误: 启动反馈UI失败,返回码: {process_result.returncode}", + file=sys.stderr, + ) + if process_result.stdout: + print(f"UI STDOUT:\n{process_result.stdout}", file=sys.stderr) + if process_result.stderr: + print(f"UI STDERR:\n{process_result.stderr}", file=sys.stderr) + raise Exception(f"启动反馈UI失败: {process_result.returncode}") + + with open(tmp_file_path, "r", encoding="utf-8") as f: + ui_result_data = json.load(f) + + return ui_result_data + + except FileNotFoundError: + print("错误: 'feedback-ui' 命令未找到", file=sys.stderr) + print("请确保项目已在可编辑模式下安装 (pip install -e .)", file=sys.stderr) + raise + except Exception as e: + print(f"错误: launch_feedback_ui 异常: {e}", file=sys.stderr) + raise + finally: + # 清理临时文件 + if tmp_file_path and os.path.exists(tmp_file_path): + try: + os.unlink(tmp_file_path) + except OSError as e_unlink: + print( + f"警告: 删除临时文件失败 '{tmp_file_path}': {e_unlink}", + file=sys.stderr, + ) + + +@mcp.tool() +def interactive_feedback( + message: Optional[str] = Field( + default=None, + description="[SIMPLE mode] Concise question for user input (AI must display full response in chat first)", + ), + full_response: Optional[str] = Field( + default=None, + description="[FULL mode] AI's complete response content (AI must display this in chat first)", + ), + predefined_options: List[str] = Field( + default_factory=list, + description="Predefined options for user selection" + ), +) -> Tuple[Union[str, Image], ...]: # 返回字符串和/或 fastmcp.Image 对象的元组 + """ + Requests user input via GUI after AI displays complete response in chat. + + USAGE FLOW: + 1. AI displays complete response in chat dialog + 2. AI calls this tool to collect user input + 3. Tool returns user feedback only + + This tool collects user input, not for displaying AI responses. + AI responses must appear in chat dialog before calling this tool. + + PARAMETER REQUIREMENTS: + - AI MUST provide BOTH 'message' and 'full_response' parameters + - Both parameters cannot be empty or whitespace-only + - MCP service will automatically select which content to display based on user's display_mode setting + + USAGE PATTERN: + + # Step 1: AI displays complete response in chat + # Step 2: AI calls tool with BOTH parameters + interactive_feedback( + message="你希望我实现这些更改吗?", # Required: concise question + full_response="我分析了你的代码,发现了3个问题...", # Required: complete response + predefined_options=["修复方案A", "修复方案B", "让我想想"] + ) + + Note: MCP service automatically selects appropriate content based on user's display mode configuration. + """ + + # 严格的双参数验证:AI必须同时提供两个有效参数 + if not _is_valid_param(message) or not _is_valid_param(full_response): + return (ERROR_MESSAGES["missing_both_params"],) + + # 获取配置(一次性读取,避免重复) + config = get_config() + display_mode = get_display_mode(config) + + # 根据用户配置的显示模式选择要展示的内容 + prompt_to_display = full_response if display_mode == "full" else message + + # 解析最终选项 - 兼容性修复:将空列表转换为None以保持现有逻辑 + ai_options_normalized = predefined_options if predefined_options else None + final_options = resolve_final_options( + ai_options=ai_options_normalized, text=prompt_to_display, config=config + ) + + # 转换为UI需要的格式(final_options已经是字符串列表,无需转换) + options_list_for_ui = final_options if final_options else None + + # 启动UI并获取用户输入 + ui_output_dict = launch_feedback_ui(prompt_to_display, options_list_for_ui) + + # 处理UI输出内容 + processed_mcp_content = _process_ui_output(ui_output_dict) + + if not processed_mcp_content: + return (ERROR_MESSAGES["no_user_feedback"],) + + return tuple(processed_mcp_content) + + +@mcp.tool() +def optimize_user_input( + original_text: str = Field(description="用户的原始输入文本"), + mode: str = Field(description="优化模式: 'optimize' 或 'reinforce'"), + reinforcement_prompt: Optional[str] = Field( + default=None, description="在 'reinforce' 模式下用户的自定义指令" + ), +) -> str: + """ + 使用配置的 LLM API 来优化或强化用户输入的文本。 + + 此功能可以帮助用户将口语化的、可能存在歧义的输入,转化为更结构化、 + 更清晰、更便于 AI 模型理解的文本。 + + Args: + original_text: 用户的原始输入文本 + mode: 优化模式 + - 'optimize': 一键优化,使用预设的通用优化指令 + - 'reinforce': 提示词强化,使用用户自定义的强化指令 + reinforcement_prompt: 在 'reinforce' 模式下用户的自定义指令 + + Returns: + str: 优化后的文本或错误信息 + """ + try: + # 导入LLM模块 + from .llm.factory import get_llm_provider + from .llm.performance_manager import get_optimization_manager + + # 获取配置 + config = get_config().get("expression_optimizer", {}) + + # 获取LLM provider + provider, status_message = get_llm_provider(config) + + if not provider: + return f"[优化功能不可用] {status_message}" + + # 获取系统提示词 + system_prompts = get_system_prompts() + + # 验证模式和参数 + if mode == "optimize": + system_prompt = system_prompts["optimize"] + elif mode == "reinforce": + if not reinforcement_prompt: + return "[错误] 'reinforce' 模式需要提供强化指令" + system_prompt = system_prompts["reinforce"] + else: + return f"[错误] 无效的优化模式: {mode}。支持的模式: 'optimize', 'reinforce'" + + # 简化逻辑:默认使用性能管理器(包含缓存功能) + manager = get_optimization_manager(config) + + result = manager.optimize_with_cache( + provider=provider, + text=original_text, + mode=mode, + system_prompt=system_prompt, + reinforcement=reinforcement_prompt or "", + ) + + # 检查是否是错误信息 + if result.startswith("[ERROR"): + return f"[优化失败] {result}" + + return result + + except ImportError as e: + return f"[配置错误] LLM模块导入失败: {e}" + except Exception as e: + return f"[系统错误] 优化过程中发生异常: {e}" + + +def main(): + """Main function to run the MCP server.""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/src/interactive_feedback_server/core/__init__.py b/src/interactive_feedback_server/core/__init__.py new file mode 100644 index 0000000..02b3f22 --- /dev/null +++ b/src/interactive_feedback_server/core/__init__.py @@ -0,0 +1,50 @@ +# interactive_feedback_server/core/__init__.py + +""" +核心模块 - 优化版本 +Core Module - Optimized Version + +提供统一的核心功能,消除重复代码和模式。 +Provides unified core functionality, eliminates duplicate code and patterns. +""" + +from .singleton_manager import ( + SingletonManager, + SingletonBase, + register_singleton, + get_singleton, + clear_singleton, + get_singleton_manager, +) + +from .stats_collector import ( + UnifiedStatsCollector, + StatEntry, + get_stats_collector, + increment_stat, + set_stat_gauge, + record_stat_value, + get_all_stats, +) + +# 已删除未使用的统一配置加载器模块 + +__all__ = [ + # 单例管理 + "SingletonManager", + "SingletonBase", + "register_singleton", + "get_singleton", + "clear_singleton", + "get_singleton_manager", + # 统计收集 + "UnifiedStatsCollector", + "StatEntry", + "get_stats_collector", + "increment_stat", + "set_stat_gauge", + "record_stat_value", + "get_all_stats", +] + +__version__ = "3.3.0" diff --git a/src/interactive_feedback_server/core/singleton_manager.py b/src/interactive_feedback_server/core/singleton_manager.py new file mode 100644 index 0000000..cffc4a6 --- /dev/null +++ b/src/interactive_feedback_server/core/singleton_manager.py @@ -0,0 +1,174 @@ +# interactive_feedback_server/core/singleton_manager.py + +""" +统一的单例管理器 - 优化版本 +Unified Singleton Manager - Optimized Version + +消除重复的全局实例模式,提供统一的单例管理。 +Eliminates duplicate global instance patterns, provides unified singleton management. +""" + +import threading +from typing import Dict, Any, Type, TypeVar, Callable, Optional +from functools import wraps + +T = TypeVar("T") + + +class SingletonManager: + """ + 单例管理器 + Singleton Manager + + 统一管理所有单例实例,避免重复的全局变量模式 + Unified management of all singleton instances, avoiding duplicate global variable patterns + """ + + def __init__(self): + """初始化单例管理器""" + self._instances: Dict[str, Any] = {} + self._factories: Dict[str, Callable] = {} + self._lock = threading.RLock() + + def register_factory(self, name: str, factory: Callable[[], T]) -> None: + """ + 注册单例工厂函数 + Register singleton factory function + + Args: + name: 单例名称 + factory: 工厂函数 + """ + with self._lock: + self._factories[name] = factory + + def get_instance(self, name: str) -> Any: + """ + 获取单例实例 + Get singleton instance + + Args: + name: 单例名称 + + Returns: + Any: 单例实例 + """ + with self._lock: + if name not in self._instances: + if name not in self._factories: + raise ValueError(f"未注册的单例: {name}") + + self._instances[name] = self._factories[name]() + + return self._instances[name] + + def clear_instance(self, name: str) -> bool: + """ + 清除单例实例 + Clear singleton instance + + Args: + name: 单例名称 + + Returns: + bool: 是否成功清除 + """ + with self._lock: + if name in self._instances: + del self._instances[name] + return True + return False + + def clear_all(self) -> None: + """清除所有单例实例""" + with self._lock: + self._instances.clear() + + def get_registered_names(self) -> list: + """获取已注册的单例名称列表""" + with self._lock: + return list(self._factories.keys()) + + def get_active_instances(self) -> list: + """获取已创建的实例名称列表""" + with self._lock: + return list(self._instances.keys()) + + +# 全局单例管理器 +_singleton_manager = SingletonManager() + + +def register_singleton(name: str): + """ + 单例注册装饰器 + Singleton registration decorator + + Args: + name: 单例名称 + """ + + def decorator(factory_func: Callable[[], T]) -> Callable[[], T]: + _singleton_manager.register_factory(name, factory_func) + + @wraps(factory_func) + def wrapper() -> T: + return _singleton_manager.get_instance(name) + + return wrapper + + return decorator + + +def get_singleton(name: str) -> Any: + """ + 获取单例实例 + Get singleton instance + + Args: + name: 单例名称 + + Returns: + Any: 单例实例 + """ + return _singleton_manager.get_instance(name) + + +def clear_singleton(name: str) -> bool: + """ + 清除单例实例 + Clear singleton instance + + Args: + name: 单例名称 + + Returns: + bool: 是否成功清除 + """ + return _singleton_manager.clear_instance(name) + + +def get_singleton_manager() -> SingletonManager: + """获取单例管理器实例""" + return _singleton_manager + + +# 便捷的单例基类 +class SingletonMeta(type): + """单例元类""" + + _instances = {} + _lock = threading.RLock() + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class SingletonBase(metaclass=SingletonMeta): + """单例基类""" + + pass diff --git a/src/interactive_feedback_server/core/stats_collector.py b/src/interactive_feedback_server/core/stats_collector.py new file mode 100644 index 0000000..4b41a5b --- /dev/null +++ b/src/interactive_feedback_server/core/stats_collector.py @@ -0,0 +1,379 @@ +# interactive_feedback_server/core/stats_collector.py + +""" +统一的统计收集器 - 优化版本 +Unified Statistics Collector - Optimized Version + +消除重复的统计收集逻辑,提供统一的统计管理。 +Eliminates duplicate statistics collection logic, provides unified statistics management. +""" + +import time +import threading +from typing import Dict, Any, Optional, List, Union +from collections import defaultdict, deque +from dataclasses import dataclass, field + + +@dataclass +class StatEntry: + """统计条目""" + + name: str + value: Union[int, float] + timestamp: float + tags: Dict[str, str] = field(default_factory=dict) + category: str = "general" + + +class UnifiedStatsCollector: + """ + 统一统计收集器 + Unified Statistics Collector + + 提供统一的统计收集、聚合和查询功能 + Provides unified statistics collection, aggregation and query functionality + """ + + def __init__(self, max_history: int = 1000): + """ + 初始化统计收集器 + Initialize statistics collector + + Args: + max_history: 最大历史记录数 + """ + self.max_history = max_history + + # 统计存储 + self._counters: Dict[str, Union[int, float]] = defaultdict(lambda: 0) + self._gauges: Dict[str, Union[int, float]] = {} + self._histograms: Dict[str, List[float]] = defaultdict(list) + self._history: deque = deque(maxlen=max_history) + + # 分类统计 + self._category_stats: Dict[str, Dict[str, Any]] = defaultdict( + lambda: { + "count": 0, + "total": 0, + "min": float("inf"), + "max": float("-inf"), + "avg": 0, + } + ) + + # 线程安全 + self._lock = threading.RLock() + + # 元数据 + self._start_time = time.time() + self._last_reset = time.time() + + def increment( + self, name: str, value: Union[int, float] = 1, category: str = "general", **tags + ) -> None: + """ + 增加计数器 + Increment counter + + Args: + name: 统计名称 + value: 增加值 + category: 分类 + **tags: 标签 + """ + with self._lock: + self._counters[name] += value + + # 记录历史 + entry = StatEntry( + name=name, + value=self._counters[name], + timestamp=time.time(), + tags=tags, + category=category, + ) + self._history.append(entry) + + # 更新分类统计 + self._update_category_stats(category, value) + + def set_gauge( + self, name: str, value: Union[int, float], category: str = "general", **tags + ) -> None: + """ + 设置仪表值 + Set gauge value + + Args: + name: 统计名称 + value: 值 + category: 分类 + **tags: 标签 + """ + with self._lock: + self._gauges[name] = value + + # 记录历史 + entry = StatEntry( + name=name, + value=value, + timestamp=time.time(), + tags=tags, + category=category, + ) + self._history.append(entry) + + def record_value( + self, name: str, value: float, category: str = "general", **tags + ) -> None: + """ + 记录数值到直方图 + Record value to histogram + + Args: + name: 统计名称 + value: 值 + category: 分类 + **tags: 标签 + """ + with self._lock: + self._histograms[name].append(value) + + # 保持历史记录限制 + if len(self._histograms[name]) > self.max_history: + self._histograms[name] = self._histograms[name][-self.max_history :] + + # 记录历史 + entry = StatEntry( + name=name, + value=value, + timestamp=time.time(), + tags=tags, + category=category, + ) + self._history.append(entry) + + # 更新分类统计 + self._update_category_stats(category, value) + + def _update_category_stats(self, category: str, value: Union[int, float]) -> None: + """更新分类统计""" + stats = self._category_stats[category] + stats["count"] += 1 + stats["total"] += value + stats["min"] = min(stats["min"], value) + stats["max"] = max(stats["max"], value) + stats["avg"] = stats["total"] / stats["count"] + + def get_counter(self, name: str) -> Union[int, float]: + """获取计数器值""" + with self._lock: + return self._counters.get(name, 0) + + def get_gauge(self, name: str) -> Optional[Union[int, float]]: + """获取仪表值""" + with self._lock: + return self._gauges.get(name) + + def get_histogram_stats(self, name: str) -> Dict[str, float]: + """获取直方图统计""" + with self._lock: + values = self._histograms.get(name, []) + if not values: + return {} + + sorted_values = sorted(values) + count = len(sorted_values) + + return { + "count": count, + "min": min(sorted_values), + "max": max(sorted_values), + "mean": sum(sorted_values) / count, + "median": sorted_values[count // 2], + "p95": sorted_values[int(count * 0.95)] if count > 0 else 0, + "p99": sorted_values[int(count * 0.99)] if count > 0 else 0, + } + + def get_category_stats(self, category: str = None) -> Dict[str, Any]: + """ + 获取分类统计 + Get category statistics + + Args: + category: 分类名称,None表示所有分类 + + Returns: + Dict[str, Any]: 分类统计 + """ + with self._lock: + if category: + return dict(self._category_stats.get(category, {})) + else: + return {cat: dict(stats) for cat, stats in self._category_stats.items()} + + def get_all_stats(self) -> Dict[str, Any]: + """ + 获取所有统计信息 + Get all statistics + + Returns: + Dict[str, Any]: 所有统计信息 + """ + with self._lock: + current_time = time.time() + + return { + "counters": dict(self._counters), + "gauges": dict(self._gauges), + "histograms": { + name: self.get_histogram_stats(name) for name in self._histograms + }, + "categories": self.get_category_stats(), + "metadata": { + "start_time": self._start_time, + "last_reset": self._last_reset, + "uptime_seconds": current_time - self._start_time, + "total_entries": len(self._history), + "collection_time": current_time, + }, + } + + def get_recent_entries( + self, limit: int = 10, category: str = None + ) -> List[StatEntry]: + """ + 获取最近的统计条目 + Get recent statistics entries + + Args: + limit: 限制数量 + category: 分类过滤 + + Returns: + List[StatEntry]: 最近的条目 + """ + with self._lock: + entries = list(self._history) + + if category: + entries = [e for e in entries if e.category == category] + + return entries[-limit:] + + def reset_stats(self, category: str = None) -> None: + """ + 重置统计信息 + Reset statistics + + Args: + category: 分类名称,None表示重置所有 + """ + with self._lock: + if category: + # 重置特定分类 + keys_to_remove = [] + for entry in self._history: + if entry.category == category: + if entry.name in self._counters: + keys_to_remove.append(entry.name) + + for key in keys_to_remove: + if key in self._counters: + del self._counters[key] + if key in self._gauges: + del self._gauges[key] + if key in self._histograms: + del self._histograms[key] + + if category in self._category_stats: + del self._category_stats[category] + else: + # 重置所有 + self._counters.clear() + self._gauges.clear() + self._histograms.clear() + self._category_stats.clear() + self._history.clear() + + self._last_reset = time.time() + + def export_stats(self, format: str = "json") -> str: + """ + 导出统计信息 + Export statistics + + Args: + format: 导出格式 ('json', 'csv') + + Returns: + str: 导出的数据 + """ + stats = self.get_all_stats() + + if format.lower() == "json": + import json + + return json.dumps(stats, indent=2, ensure_ascii=False) + elif format.lower() == "csv": + lines = ["timestamp,category,name,value,type"] + + for entry in self._history: + entry_type = ( + "counter" + if entry.name in self._counters + else "gauge" if entry.name in self._gauges else "histogram" + ) + lines.append( + f"{entry.timestamp},{entry.category},{entry.name},{entry.value},{entry_type}" + ) + + return "\n".join(lines) + else: + raise ValueError(f"不支持的导出格式: {format}") + + +# 全局统计收集器实例 +_global_stats_collector: Optional[UnifiedStatsCollector] = None + + +def get_stats_collector() -> UnifiedStatsCollector: + """ + 获取全局统计收集器实例 + Get global statistics collector instance + + Returns: + UnifiedStatsCollector: 统计收集器实例 + """ + global _global_stats_collector + if _global_stats_collector is None: + _global_stats_collector = UnifiedStatsCollector() + return _global_stats_collector + + +# 便捷函数 +def increment_stat( + name: str, value: Union[int, float] = 1, category: str = "general", **tags +) -> None: + """增加统计计数""" + get_stats_collector().increment(name, value, category, **tags) + + +def set_stat_gauge( + name: str, value: Union[int, float], category: str = "general", **tags +) -> None: + """设置统计仪表""" + get_stats_collector().set_gauge(name, value, category, **tags) + + +def record_stat_value( + name: str, value: float, category: str = "general", **tags +) -> None: + """记录统计值""" + get_stats_collector().record_value(name, value, category, **tags) + + +def get_all_stats() -> Dict[str, Any]: + """获取所有统计信息""" + return get_stats_collector().get_all_stats() diff --git a/src/interactive_feedback_server/error_handling/__init__.py b/src/interactive_feedback_server/error_handling/__init__.py new file mode 100644 index 0000000..409df54 --- /dev/null +++ b/src/interactive_feedback_server/error_handling/__init__.py @@ -0,0 +1,61 @@ +# interactive_feedback_server/error_handling/__init__.py + +""" +错误处理模块 +Error Handling Module + +提供分级错误处理、自动恢复和系统稳定性保障功能。 +Provides hierarchical error handling, automatic recovery and system stability assurance functionality. +""" + +from .error_types import ( + ErrorLevel, + ErrorCategory, + RecoveryStrategy, + ErrorContext, + ErrorInfo, + SystemError, + ValidationError, + ConfigurationError, + PluginError, + PerformanceError, + ExternalServiceError, + create_error_context, + create_system_error, +) + +from .error_handler import ErrorHandler, get_error_handler + +from .recovery_manager import ( + RecoveryManager, + RecoveryTask, + RecoveryStatus, + get_recovery_manager, +) + +__all__ = [ + # 错误类型 + "ErrorLevel", + "ErrorCategory", + "RecoveryStrategy", + "ErrorContext", + "ErrorInfo", + "SystemError", + "ValidationError", + "ConfigurationError", + "PluginError", + "PerformanceError", + "ExternalServiceError", + "create_error_context", + "create_system_error", + # 错误处理器 + "ErrorHandler", + "get_error_handler", + # 恢复管理器 + "RecoveryManager", + "RecoveryTask", + "RecoveryStatus", + "get_recovery_manager", +] + +__version__ = "3.3.0" diff --git a/src/interactive_feedback_server/error_handling/error_handler.py b/src/interactive_feedback_server/error_handling/error_handler.py new file mode 100644 index 0000000..140b722 --- /dev/null +++ b/src/interactive_feedback_server/error_handling/error_handler.py @@ -0,0 +1,434 @@ +# interactive_feedback_server/error_handling/error_handler.py + +""" +错误处理器 - V3.3 架构改进版本 +Error Handler - V3.3 Architecture Improvement Version + +提供分级错误处理和自动恢复机制。 +Provides hierarchical error handling and automatic recovery mechanisms. +""" + +import time +import threading +from typing import Dict, List, Any, Optional, Callable, Union +from collections import defaultdict, deque +import logging + +from .error_types import ( + ErrorLevel, + ErrorCategory, + RecoveryStrategy, + ErrorContext, + SystemError, + create_error_context, + create_system_error, +) + + +class ErrorHandler: + """ + 错误处理器 + Error Handler + + 提供分级错误处理、恢复策略和错误统计功能 + Provides hierarchical error handling, recovery strategies and error statistics + """ + + def __init__(self, max_error_history: int = 1000): + """ + 初始化错误处理器 + Initialize error handler + + Args: + max_error_history: 最大错误历史记录数 + """ + self.max_error_history = max_error_history + + # 错误存储 + self._error_history: deque = deque(maxlen=max_error_history) + self._error_counts: Dict[str, int] = defaultdict(int) + self._recovery_handlers: Dict[RecoveryStrategy, Callable] = {} + + # 线程安全 + self._lock = threading.RLock() + + # 错误统计 + self._stats = { + "total_errors": 0, + "errors_by_level": defaultdict(int), + "errors_by_category": defaultdict(int), + "recovery_attempts": defaultdict(int), + "recovery_successes": defaultdict(int), + } + + # 熔断器状态 + self._circuit_breakers: Dict[str, Dict[str, Any]] = {} + + # 初始化默认恢复处理器 + self._setup_default_recovery_handlers() + + # 日志记录器 + self.logger = logging.getLogger(__name__) + + def _setup_default_recovery_handlers(self) -> None: + """设置默认恢复处理器""" + self._recovery_handlers[RecoveryStrategy.RETRY] = self._handle_retry + self._recovery_handlers[RecoveryStrategy.FALLBACK] = self._handle_fallback + self._recovery_handlers[RecoveryStrategy.CIRCUIT_BREAKER] = ( + self._handle_circuit_breaker + ) + self._recovery_handlers[RecoveryStrategy.GRACEFUL_DEGRADATION] = ( + self._handle_graceful_degradation + ) + self._recovery_handlers[RecoveryStrategy.ESCALATE] = self._handle_escalate + + def handle_error( + self, error: Union[SystemError, Exception], context: ErrorContext = None + ) -> Optional[Any]: + """ + 处理错误 + Handle error + + Args: + error: 错误对象 + context: 错误上下文 + + Returns: + Optional[Any]: 恢复结果 + """ + with self._lock: + # 转换为SystemError + if not isinstance(error, SystemError): + system_error = self._convert_to_system_error(error, context) + else: + system_error = error + + # 记录错误 + self._record_error(system_error) + + # 执行恢复策略 + recovery_result = self._execute_recovery(system_error) + + # 记录日志 + self._log_error(system_error, recovery_result) + + return recovery_result + + def _convert_to_system_error( + self, error: Exception, context: ErrorContext = None + ) -> SystemError: + """将普通异常转换为SystemError""" + if context is None: + context = create_error_context("unknown", "unknown") + + # 根据异常类型确定错误分类 + category = self._determine_error_category(error) + level = self._determine_error_level(error) + + return create_system_error( + level=level, + category=category, + message=str(error), + description=f"未处理的异常: {type(error).__name__}", + context=context, + exception=error, + recovery_strategy=self._determine_recovery_strategy(error), + ) + + def _determine_error_category(self, error: Exception) -> ErrorCategory: + """确定错误分类""" + error_type = type(error).__name__ + + if "Network" in error_type or "Connection" in error_type: + return ErrorCategory.NETWORK + elif "Database" in error_type or "SQL" in error_type: + return ErrorCategory.DATABASE + elif "Validation" in error_type or "Value" in error_type: + return ErrorCategory.VALIDATION + elif "Permission" in error_type or "Auth" in error_type: + return ErrorCategory.AUTHORIZATION + elif "Config" in error_type: + return ErrorCategory.CONFIGURATION + else: + return ErrorCategory.SYSTEM + + def _determine_error_level(self, error: Exception) -> ErrorLevel: + """确定错误级别""" + error_type = type(error).__name__ + + if error_type in ["SystemExit", "KeyboardInterrupt"]: + return ErrorLevel.FATAL + elif error_type in ["MemoryError", "OSError"]: + return ErrorLevel.CRITICAL + elif error_type in ["ValueError", "TypeError", "AttributeError"]: + return ErrorLevel.ERROR + else: + return ErrorLevel.WARNING + + def _determine_recovery_strategy(self, error: Exception) -> RecoveryStrategy: + """确定恢复策略""" + error_type = type(error).__name__ + + if "Network" in error_type or "Connection" in error_type: + return RecoveryStrategy.RETRY + elif "Config" in error_type: + return RecoveryStrategy.FALLBACK + elif "Permission" in error_type: + return RecoveryStrategy.ESCALATE + else: + return RecoveryStrategy.GRACEFUL_DEGRADATION + + def _record_error(self, error: SystemError) -> None: + """记录错误""" + self._error_history.append(error) + self._stats["total_errors"] += 1 + self._stats["errors_by_level"][error.error_info.level.value] += 1 + self._stats["errors_by_category"][error.error_info.category.value] += 1 + + # 更新错误计数 + error_key = ( + f"{error.error_info.category.value}_{error.error_info.context.component}" + ) + self._error_counts[error_key] += 1 + + def _execute_recovery(self, error: SystemError) -> Optional[Any]: + """执行恢复策略""" + strategy = error.error_info.recovery_strategy + + if strategy == RecoveryStrategy.NONE: + return None + + self._stats["recovery_attempts"][strategy.value] += 1 + + try: + handler = self._recovery_handlers.get(strategy) + if handler: + result = handler(error) + if result is not None: + self._stats["recovery_successes"][strategy.value] += 1 + return result + except Exception as e: + self.logger.error(f"恢复策略执行失败 {strategy.value}: {e}") + + return None + + def _handle_retry(self, error: SystemError) -> Optional[Any]: + """处理重试策略""" + error_info = error.error_info + + if error_info.retry_count >= error_info.max_retries: + self.logger.warning(f"重试次数已达上限: {error_info.error_id}") + return None + + error_info.retry_count += 1 + + # 指数退避 + delay = min(2**error_info.retry_count, 60) # 最大60秒 + time.sleep(delay) + + self.logger.info( + f"重试 {error_info.retry_count}/{error_info.max_retries}: {error_info.error_id}" + ) + + return {"action": "retry", "delay": delay, "attempt": error_info.retry_count} + + def _handle_fallback(self, error: SystemError) -> Optional[Any]: + """处理降级策略""" + error_info = error.error_info + + # 根据错误类型提供不同的降级方案 + if error_info.category == ErrorCategory.CONFIGURATION: + return {"action": "fallback", "data": "使用默认配置"} + elif error_info.category == ErrorCategory.EXTERNAL_SERVICE: + return {"action": "fallback", "data": "使用缓存数据"} + else: + return {"action": "fallback", "data": "使用简化功能"} + + def _handle_circuit_breaker(self, error: SystemError) -> Optional[Any]: + """处理熔断器策略""" + error_info = error.error_info + component = error_info.context.component + + # 获取或创建熔断器状态 + if component not in self._circuit_breakers: + self._circuit_breakers[component] = { + "state": "closed", # closed, open, half_open + "failure_count": 0, + "last_failure_time": 0, + "failure_threshold": 5, + "timeout": 60, # 60秒后尝试半开 + } + + breaker = self._circuit_breakers[component] + current_time = time.time() + + # 更新失败计数 + breaker["failure_count"] += 1 + breaker["last_failure_time"] = current_time + + # 检查是否需要打开熔断器 + if breaker["failure_count"] >= breaker["failure_threshold"]: + breaker["state"] = "open" + self.logger.warning(f"熔断器打开: {component}") + return {"action": "circuit_breaker", "state": "open"} + + return None + + def _handle_graceful_degradation(self, error: SystemError) -> Optional[Any]: + """处理优雅降级策略""" + error_info = error.error_info + + # 根据组件类型提供不同的降级方案 + if error_info.context.component == "plugin_manager": + return {"action": "graceful_degradation", "data": "禁用插件,使用核心功能"} + elif error_info.context.component == "performance_monitor": + return {"action": "graceful_degradation", "data": "禁用监控,保持基本功能"} + else: + return {"action": "graceful_degradation", "data": "降级到基本功能"} + + def _handle_escalate(self, error: SystemError) -> Optional[Any]: + """处理升级策略""" + error_info = error.error_info + + # 记录需要人工干预的错误 + self.logger.critical( + f"错误需要升级处理: {error_info.error_id} - {error_info.message}" + ) + + return {"action": "escalate", "data": "已通知管理员"} + + def _log_error(self, error: SystemError, recovery_result: Any) -> None: + """记录错误日志""" + error_info = error.error_info + + log_data = { + "error_id": error_info.error_id, + "level": error_info.level.value, + "category": error_info.category.value, + "component": error_info.context.component, + "operation": error_info.context.operation, + "message": error_info.message, + "recovery_strategy": error_info.recovery_strategy.value, + "recovery_result": recovery_result, + } + + if error_info.level == ErrorLevel.FATAL: + self.logger.critical(f"致命错误: {log_data}") + elif error_info.level == ErrorLevel.CRITICAL: + self.logger.critical(f"严重错误: {log_data}") + elif error_info.level == ErrorLevel.ERROR: + self.logger.error(f"错误: {log_data}") + elif error_info.level == ErrorLevel.WARNING: + self.logger.warning(f"警告: {log_data}") + else: + self.logger.info(f"信息: {log_data}") + + def register_recovery_handler( + self, strategy: RecoveryStrategy, handler: Callable + ) -> None: + """ + 注册自定义恢复处理器 + Register custom recovery handler + + Args: + strategy: 恢复策略 + handler: 处理函数 + """ + with self._lock: + self._recovery_handlers[strategy] = handler + + def get_error_statistics(self) -> Dict[str, Any]: + """ + 获取错误统计信息 + Get error statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + with self._lock: + return { + "total_errors": self._stats["total_errors"], + "errors_by_level": dict(self._stats["errors_by_level"]), + "errors_by_category": dict(self._stats["errors_by_category"]), + "recovery_attempts": dict(self._stats["recovery_attempts"]), + "recovery_successes": dict(self._stats["recovery_successes"]), + "error_counts": dict(self._error_counts), + "circuit_breakers": dict(self._circuit_breakers), + "error_history_size": len(self._error_history), + } + + def get_recent_errors(self, limit: int = 10) -> List[SystemError]: + """ + 获取最近的错误 + Get recent errors + + Args: + limit: 限制数量 + + Returns: + List[SystemError]: 最近的错误列表 + """ + with self._lock: + return list(self._error_history)[-limit:] + + def clear_error_history(self) -> None: + """清空错误历史""" + with self._lock: + self._error_history.clear() + self._error_counts.clear() + self._stats = { + "total_errors": 0, + "errors_by_level": defaultdict(int), + "errors_by_category": defaultdict(int), + "recovery_attempts": defaultdict(int), + "recovery_successes": defaultdict(int), + } + + def is_circuit_breaker_open(self, component: str) -> bool: + """ + 检查熔断器是否打开 + Check if circuit breaker is open + + Args: + component: 组件名称 + + Returns: + bool: 是否打开 + """ + with self._lock: + if component not in self._circuit_breakers: + return False + + breaker = self._circuit_breakers[component] + current_time = time.time() + + # 检查是否可以尝试半开 + if ( + breaker["state"] == "open" + and current_time - breaker["last_failure_time"] > breaker["timeout"] + ): + breaker["state"] = "half_open" + self.logger.info(f"熔断器半开: {component}") + + return breaker["state"] == "open" + + +# 使用统一的单例管理器 +from ..core import register_singleton + + +@register_singleton("error_handler") +def create_error_handler() -> ErrorHandler: + """创建错误处理器实例""" + return ErrorHandler() + + +def get_error_handler() -> ErrorHandler: + """ + 获取全局错误处理器实例 + Get global error handler instance + + Returns: + ErrorHandler: 错误处理器实例 + """ + return create_error_handler() diff --git a/src/interactive_feedback_server/error_handling/error_types.py b/src/interactive_feedback_server/error_handling/error_types.py new file mode 100644 index 0000000..da423cb --- /dev/null +++ b/src/interactive_feedback_server/error_handling/error_types.py @@ -0,0 +1,348 @@ +# interactive_feedback_server/error_handling/error_types.py + +""" +错误类型定义 - V3.3 架构改进版本 +Error Types Definition - V3.3 Architecture Improvement Version + +定义系统中的各种错误类型和分级处理策略。 +Defines various error types and hierarchical handling strategies in the system. +""" + +from enum import Enum +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +import time +import traceback + + +class ErrorLevel(Enum): + """错误级别枚举""" + + DEBUG = "debug" # 调试信息 + INFO = "info" # 一般信息 + WARNING = "warning" # 警告 + ERROR = "error" # 错误 + CRITICAL = "critical" # 严重错误 + FATAL = "fatal" # 致命错误 + + +class ErrorCategory(Enum): + """错误分类枚举""" + + SYSTEM = "system" # 系统错误 + NETWORK = "network" # 网络错误 + DATABASE = "database" # 数据库错误 + VALIDATION = "validation" # 验证错误 + AUTHENTICATION = "authentication" # 认证错误 + AUTHORIZATION = "authorization" # 授权错误 + BUSINESS_LOGIC = "business_logic" # 业务逻辑错误 + EXTERNAL_SERVICE = "external_service" # 外部服务错误 + CONFIGURATION = "configuration" # 配置错误 + PLUGIN = "plugin" # 插件错误 + PERFORMANCE = "performance" # 性能错误 + + +class RecoveryStrategy(Enum): + """恢复策略枚举""" + + NONE = "none" # 无恢复策略 + RETRY = "retry" # 重试 + FALLBACK = "fallback" # 降级处理 + CIRCUIT_BREAKER = "circuit_breaker" # 熔断器 + GRACEFUL_DEGRADATION = "graceful_degradation" # 优雅降级 + RESTART = "restart" # 重启 + ESCALATE = "escalate" # 升级处理 + + +@dataclass +class ErrorContext: + """ + 错误上下文 + Error Context + + 包含错误发生时的上下文信息 + Contains context information when error occurs + """ + + timestamp: float # 错误发生时间 + component: str # 发生错误的组件 + operation: str # 执行的操作 + additional_data: Optional[Dict[str, Any]] = None # 额外数据 + + def __post_init__(self): + if self.additional_data is None: + self.additional_data = {} + + +@dataclass +class ErrorInfo: + """ + 错误信息 + Error Information + + 包含完整的错误信息和处理策略 + Contains complete error information and handling strategy + """ + + error_id: str # 错误唯一标识 + level: ErrorLevel # 错误级别 + category: ErrorCategory # 错误分类 + message: str # 错误消息 + description: str # 详细描述 + context: ErrorContext # 错误上下文 + exception: Optional[Exception] = None # 原始异常 + stack_trace: Optional[str] = None # 堆栈跟踪 + recovery_strategy: RecoveryStrategy = RecoveryStrategy.NONE # 恢复策略 + retry_count: int = 0 # 重试次数 + max_retries: int = 3 # 最大重试次数 + recovery_data: Optional[Dict[str, Any]] = None # 恢复数据 + + def __post_init__(self): + if self.recovery_data is None: + self.recovery_data = {} + + # 自动提取堆栈跟踪 + if self.exception and not self.stack_trace: + self.stack_trace = "".join( + traceback.format_exception( + type(self.exception), self.exception, self.exception.__traceback__ + ) + ) + + +class SystemError(Exception): + """ + 系统错误基类 + System Error Base Class + + 所有系统错误的基类,包含错误信息 + Base class for all system errors, contains error information + """ + + def __init__(self, error_info: ErrorInfo): + """ + 初始化系统错误 + Initialize system error + + Args: + error_info: 错误信息 + """ + self.error_info = error_info + super().__init__(error_info.message) + + def __str__(self) -> str: + return f"[{self.error_info.level.value.upper()}] {self.error_info.category.value}: {self.error_info.message}" + + def __repr__(self) -> str: + return f"SystemError(id={self.error_info.error_id}, level={self.error_info.level.value}, category={self.error_info.category.value})" + + +class ValidationError(SystemError): + """验证错误""" + + def __init__( + self, + message: str, + field: str = None, + value: Any = None, + context: ErrorContext = None, + ): + error_info = ErrorInfo( + error_id=f"validation_{int(time.time() * 1000)}", + level=ErrorLevel.WARNING, + category=ErrorCategory.VALIDATION, + message=message, + description=f"验证失败: {message}", + context=context + or ErrorContext( + timestamp=time.time(), + component="validation", + operation="validate", + additional_data={"field": field, "value": value}, + ), + recovery_strategy=RecoveryStrategy.NONE, + ) + super().__init__(error_info) + + +class ConfigurationError(SystemError): + """配置错误""" + + def __init__( + self, message: str, config_key: str = None, context: ErrorContext = None + ): + error_info = ErrorInfo( + error_id=f"config_{int(time.time() * 1000)}", + level=ErrorLevel.ERROR, + category=ErrorCategory.CONFIGURATION, + message=message, + description=f"配置错误: {message}", + context=context + or ErrorContext( + timestamp=time.time(), + component="configuration", + operation="load_config", + additional_data={"config_key": config_key}, + ), + recovery_strategy=RecoveryStrategy.FALLBACK, + ) + super().__init__(error_info) + + +class PluginError(SystemError): + """插件错误""" + + def __init__( + self, message: str, plugin_name: str = None, context: ErrorContext = None + ): + error_info = ErrorInfo( + error_id=f"plugin_{int(time.time() * 1000)}", + level=ErrorLevel.ERROR, + category=ErrorCategory.PLUGIN, + message=message, + description=f"插件错误: {message}", + context=context + or ErrorContext( + timestamp=time.time(), + component="plugin_manager", + operation="plugin_operation", + additional_data={"plugin_name": plugin_name}, + ), + recovery_strategy=RecoveryStrategy.GRACEFUL_DEGRADATION, + ) + super().__init__(error_info) + + +class PerformanceError(SystemError): + """性能错误""" + + def __init__( + self, + message: str, + metric_name: str = None, + threshold: float = None, + actual_value: float = None, + context: ErrorContext = None, + ): + error_info = ErrorInfo( + error_id=f"performance_{int(time.time() * 1000)}", + level=ErrorLevel.WARNING, + category=ErrorCategory.PERFORMANCE, + message=message, + description=f"性能问题: {message}", + context=context + or ErrorContext( + timestamp=time.time(), + component="performance_monitor", + operation="performance_check", + additional_data={ + "metric_name": metric_name, + "threshold": threshold, + "actual_value": actual_value, + }, + ), + recovery_strategy=RecoveryStrategy.GRACEFUL_DEGRADATION, + ) + super().__init__(error_info) + + +class ExternalServiceError(SystemError): + """外部服务错误""" + + def __init__( + self, + message: str, + service_name: str = None, + status_code: int = None, + context: ErrorContext = None, + ): + error_info = ErrorInfo( + error_id=f"external_{int(time.time() * 1000)}", + level=ErrorLevel.ERROR, + category=ErrorCategory.EXTERNAL_SERVICE, + message=message, + description=f"外部服务错误: {message}", + context=context + or ErrorContext( + timestamp=time.time(), + component="external_service", + operation="service_call", + additional_data={ + "service_name": service_name, + "status_code": status_code, + }, + ), + recovery_strategy=RecoveryStrategy.RETRY, + max_retries=3, + ) + super().__init__(error_info) + + +def create_error_context( + component: str, + operation: str, + **additional_data, +) -> ErrorContext: + """ + 创建错误上下文 + Create error context + + Args: + component: 组件名称 + operation: 操作名称 + **additional_data: 额外数据 + + Returns: + ErrorContext: 错误上下文 + """ + return ErrorContext( + timestamp=time.time(), + component=component, + operation=operation, + additional_data=additional_data, + ) + + +def create_system_error( + level: ErrorLevel, + category: ErrorCategory, + message: str, + description: str = None, + context: ErrorContext = None, + exception: Exception = None, + recovery_strategy: RecoveryStrategy = RecoveryStrategy.NONE, + max_retries: int = 3, +) -> SystemError: + """ + 创建系统错误 + Create system error + + Args: + level: 错误级别 + category: 错误分类 + message: 错误消息 + description: 详细描述 + context: 错误上下文 + exception: 原始异常 + recovery_strategy: 恢复策略 + max_retries: 最大重试次数 + + Returns: + SystemError: 系统错误 + """ + error_info = ErrorInfo( + error_id=f"{category.value}_{int(time.time() * 1000)}", + level=level, + category=category, + message=message, + description=description or message, + context=context + or ErrorContext( + timestamp=time.time(), component="unknown", operation="unknown" + ), + exception=exception, + recovery_strategy=recovery_strategy, + max_retries=max_retries, + ) + + return SystemError(error_info) diff --git a/src/interactive_feedback_server/error_handling/recovery_manager.py b/src/interactive_feedback_server/error_handling/recovery_manager.py new file mode 100644 index 0000000..af076ce --- /dev/null +++ b/src/interactive_feedback_server/error_handling/recovery_manager.py @@ -0,0 +1,487 @@ +# interactive_feedback_server/error_handling/recovery_manager.py + +""" +恢复管理器 - V3.3 架构改进版本 +Recovery Manager - V3.3 Architecture Improvement Version + +提供自动恢复机制和系统稳定性保障。 +Provides automatic recovery mechanisms and system stability assurance. +""" + +import time +import threading +from typing import Dict, List, Any, Optional, Callable, Tuple +from dataclasses import dataclass +from enum import Enum + +from .error_types import ErrorLevel, ErrorCategory, SystemError, ErrorContext +from .error_handler import get_error_handler + + +class RecoveryStatus(Enum): + """恢复状态枚举""" + + PENDING = "pending" # 等待中 + IN_PROGRESS = "in_progress" # 进行中 + SUCCESS = "success" # 成功 + FAILED = "failed" # 失败 + CANCELLED = "cancelled" # 已取消 + + +@dataclass +class RecoveryTask: + """ + 恢复任务 + Recovery Task + """ + + task_id: str # 任务ID + component: str # 组件名称 + operation: str # 操作名称 + recovery_function: Callable # 恢复函数 + priority: int # 优先级 (1-10, 数字越小优先级越高) + max_attempts: int # 最大尝试次数 + timeout: float # 超时时间(秒) + dependencies: List[str] # 依赖的任务ID + created_at: float # 创建时间 + status: RecoveryStatus = RecoveryStatus.PENDING # 状态 + attempts: int = 0 # 已尝试次数 + last_attempt_at: Optional[float] = None # 最后尝试时间 + error_message: Optional[str] = None # 错误消息 + result: Optional[Any] = None # 恢复结果 + + +class RecoveryManager: + """ + 恢复管理器 + Recovery Manager + + 管理系统的自动恢复任务和稳定性保障 + Manages automatic recovery tasks and system stability assurance + """ + + def __init__(self): + """初始化恢复管理器""" + self.error_handler = get_error_handler() + + # 恢复任务管理 + self._recovery_tasks: Dict[str, RecoveryTask] = {} + self._task_queue: List[str] = [] # 按优先级排序的任务队列 + self._running_tasks: Dict[str, threading.Thread] = {} + + # 线程安全 + self._lock = threading.RLock() + + # 恢复统计 + self._stats = { + "total_tasks": 0, + "successful_recoveries": 0, + "failed_recoveries": 0, + "cancelled_recoveries": 0, + "average_recovery_time": 0.0, + } + + # 系统健康检查 + self._health_checks: Dict[str, Callable] = {} + self._health_status: Dict[str, bool] = {} + + # 自动恢复配置 + self._auto_recovery_enabled = True + self._max_concurrent_recoveries = 3 + + # 启动恢复监控线程 + self._monitor_thread = threading.Thread( + target=self._recovery_monitor, daemon=True + ) + self._monitor_thread.start() + + def register_recovery_function( + self, + component: str, + operation: str, + recovery_function: Callable, + priority: int = 5, + max_attempts: int = 3, + timeout: float = 30.0, + dependencies: List[str] = None, + ) -> str: + """ + 注册恢复函数 + Register recovery function + + Args: + component: 组件名称 + operation: 操作名称 + recovery_function: 恢复函数 + priority: 优先级 + max_attempts: 最大尝试次数 + timeout: 超时时间 + dependencies: 依赖任务 + + Returns: + str: 任务ID + """ + with self._lock: + task_id = f"{component}_{operation}_{int(time.time() * 1000)}" + + task = RecoveryTask( + task_id=task_id, + component=component, + operation=operation, + recovery_function=recovery_function, + priority=priority, + max_attempts=max_attempts, + timeout=timeout, + dependencies=dependencies or [], + created_at=time.time(), + ) + + self._recovery_tasks[task_id] = task + self._add_to_queue(task_id) + self._stats["total_tasks"] += 1 + + return task_id + + def _add_to_queue(self, task_id: str) -> None: + """将任务添加到队列""" + task = self._recovery_tasks[task_id] + + # 按优先级插入队列 + inserted = False + for i, existing_task_id in enumerate(self._task_queue): + existing_task = self._recovery_tasks[existing_task_id] + if task.priority < existing_task.priority: + self._task_queue.insert(i, task_id) + inserted = True + break + + if not inserted: + self._task_queue.append(task_id) + + def execute_recovery(self, task_id: str) -> bool: + """ + 执行恢复任务 + Execute recovery task + + Args: + task_id: 任务ID + + Returns: + bool: 是否成功启动 + """ + with self._lock: + if task_id not in self._recovery_tasks: + return False + + task = self._recovery_tasks[task_id] + + # 检查任务状态 + if task.status != RecoveryStatus.PENDING: + return False + + # 检查依赖 + if not self._check_dependencies(task): + return False + + # 检查并发限制 + if len(self._running_tasks) >= self._max_concurrent_recoveries: + return False + + # 启动恢复任务 + task.status = RecoveryStatus.IN_PROGRESS + task.attempts += 1 + task.last_attempt_at = time.time() + + recovery_thread = threading.Thread( + target=self._execute_recovery_task, args=(task_id,), daemon=True + ) + + self._running_tasks[task_id] = recovery_thread + recovery_thread.start() + + return True + + def _check_dependencies(self, task: RecoveryTask) -> bool: + """检查任务依赖""" + for dep_id in task.dependencies: + if dep_id in self._recovery_tasks: + dep_task = self._recovery_tasks[dep_id] + if dep_task.status != RecoveryStatus.SUCCESS: + return False + return True + + def _execute_recovery_task(self, task_id: str) -> None: + """执行恢复任务的具体逻辑""" + task = self._recovery_tasks[task_id] + start_time = time.time() + + try: + # 设置超时 + def timeout_handler(): + time.sleep(task.timeout) + if task.status == RecoveryStatus.IN_PROGRESS: + task.status = RecoveryStatus.FAILED + task.error_message = "恢复任务超时" + + timeout_thread = threading.Thread(target=timeout_handler, daemon=True) + timeout_thread.start() + + # 执行恢复函数 + result = task.recovery_function() + + # 检查是否超时 + if task.status == RecoveryStatus.IN_PROGRESS: + task.status = RecoveryStatus.SUCCESS + task.result = result + self._stats["successful_recoveries"] += 1 + + # 更新平均恢复时间 + recovery_time = time.time() - start_time + self._update_average_recovery_time(recovery_time) + + except Exception as e: + task.status = RecoveryStatus.FAILED + task.error_message = str(e) + self._stats["failed_recoveries"] += 1 + + # 记录错误 + error_context = ErrorContext( + timestamp=time.time(), + component=task.component, + operation=f"recovery_{task.operation}", + additional_data={"task_id": task_id, "attempt": task.attempts}, + ) + + self.error_handler.handle_error(e, error_context) + + finally: + # 清理运行任务 + with self._lock: + if task_id in self._running_tasks: + del self._running_tasks[task_id] + + # 如果失败且还有重试机会,重新加入队列 + if ( + task.status == RecoveryStatus.FAILED + and task.attempts < task.max_attempts + ): + task.status = RecoveryStatus.PENDING + self._add_to_queue(task_id) + + def _update_average_recovery_time(self, recovery_time: float) -> None: + """更新平均恢复时间""" + current_avg = self._stats["average_recovery_time"] + successful_count = self._stats["successful_recoveries"] + + if successful_count == 1: + self._stats["average_recovery_time"] = recovery_time + else: + # 计算新的平均值 + total_time = current_avg * (successful_count - 1) + recovery_time + self._stats["average_recovery_time"] = total_time / successful_count + + def _recovery_monitor(self) -> None: + """恢复监控线程""" + while True: + try: + if self._auto_recovery_enabled: + self._process_recovery_queue() + self._perform_health_checks() + + time.sleep(5) # 每5秒检查一次 + + except Exception as e: + print(f"恢复监控线程错误: {e}") + time.sleep(10) + + def _process_recovery_queue(self) -> None: + """处理恢复队列""" + with self._lock: + # 处理队列中的任务 + tasks_to_process = [] + for task_id in self._task_queue[:]: + if len(self._running_tasks) >= self._max_concurrent_recoveries: + break + + task = self._recovery_tasks[task_id] + if task.status == RecoveryStatus.PENDING and self._check_dependencies( + task + ): + tasks_to_process.append(task_id) + self._task_queue.remove(task_id) + + # 启动任务 + for task_id in tasks_to_process: + self.execute_recovery(task_id) + + def _perform_health_checks(self) -> None: + """执行健康检查""" + for component, health_check in self._health_checks.items(): + try: + is_healthy = health_check() + previous_status = self._health_status.get(component, True) + self._health_status[component] = is_healthy + + # 如果组件从不健康变为健康,记录恢复 + if not previous_status and is_healthy: + print(f"组件 {component} 已恢复健康") + elif previous_status and not is_healthy: + print(f"组件 {component} 健康检查失败") + + # 触发自动恢复 + self._trigger_auto_recovery(component) + + except Exception as e: + self._health_status[component] = False + print(f"健康检查异常 {component}: {e}") + + def _trigger_auto_recovery(self, component: str) -> None: + """触发自动恢复""" + # 查找该组件的恢复任务 + for task_id, task in self._recovery_tasks.items(): + if task.component == component and task.status == RecoveryStatus.PENDING: + self.execute_recovery(task_id) + break + + def register_health_check(self, component: str, health_check: Callable) -> None: + """ + 注册健康检查函数 + Register health check function + + Args: + component: 组件名称 + health_check: 健康检查函数,返回bool + """ + with self._lock: + self._health_checks[component] = health_check + self._health_status[component] = True + + def cancel_recovery(self, task_id: str) -> bool: + """ + 取消恢复任务 + Cancel recovery task + + Args: + task_id: 任务ID + + Returns: + bool: 是否成功取消 + """ + with self._lock: + if task_id not in self._recovery_tasks: + return False + + task = self._recovery_tasks[task_id] + + if task.status == RecoveryStatus.PENDING: + task.status = RecoveryStatus.CANCELLED + if task_id in self._task_queue: + self._task_queue.remove(task_id) + self._stats["cancelled_recoveries"] += 1 + return True + + return False + + def get_recovery_status(self, task_id: str) -> Optional[RecoveryTask]: + """ + 获取恢复任务状态 + Get recovery task status + + Args: + task_id: 任务ID + + Returns: + Optional[RecoveryTask]: 任务信息 + """ + with self._lock: + return self._recovery_tasks.get(task_id) + + def get_recovery_statistics(self) -> Dict[str, Any]: + """ + 获取恢复统计信息 + Get recovery statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + with self._lock: + success_rate = 0.0 + if self._stats["total_tasks"] > 0: + success_rate = ( + self._stats["successful_recoveries"] / self._stats["total_tasks"] + ) * 100 + + return { + "total_tasks": self._stats["total_tasks"], + "successful_recoveries": self._stats["successful_recoveries"], + "failed_recoveries": self._stats["failed_recoveries"], + "cancelled_recoveries": self._stats["cancelled_recoveries"], + "success_rate_percent": round(success_rate, 2), + "average_recovery_time": round(self._stats["average_recovery_time"], 3), + "running_tasks": len(self._running_tasks), + "pending_tasks": len(self._task_queue), + "health_status": dict(self._health_status), + "auto_recovery_enabled": self._auto_recovery_enabled, + } + + def set_auto_recovery(self, enabled: bool) -> None: + """ + 设置自动恢复开关 + Set auto recovery switch + + Args: + enabled: 是否启用 + """ + with self._lock: + self._auto_recovery_enabled = enabled + + def get_system_health(self) -> Dict[str, Any]: + """ + 获取系统健康状态 + Get system health status + + Returns: + Dict[str, Any]: 健康状态 + """ + with self._lock: + total_components = len(self._health_status) + healthy_components = sum( + 1 for status in self._health_status.values() if status + ) + + overall_health = "healthy" + if total_components == 0: + overall_health = "unknown" + elif healthy_components == 0: + overall_health = "critical" + elif healthy_components < total_components: + overall_health = "degraded" + + return { + "overall_health": overall_health, + "healthy_components": healthy_components, + "total_components": total_components, + "health_percentage": round( + (healthy_components / max(total_components, 1)) * 100, 1 + ), + "component_status": dict(self._health_status), + "last_check_time": time.time(), + } + + +# 全局恢复管理器实例 +_global_recovery_manager: Optional[RecoveryManager] = None + + +def get_recovery_manager() -> RecoveryManager: + """ + 获取全局恢复管理器实例 + Get global recovery manager instance + + Returns: + RecoveryManager: 恢复管理器实例 + """ + global _global_recovery_manager + if _global_recovery_manager is None: + _global_recovery_manager = RecoveryManager() + return _global_recovery_manager diff --git a/src/interactive_feedback_server/llm/__init__.py b/src/interactive_feedback_server/llm/__init__.py new file mode 100644 index 0000000..0426784 --- /dev/null +++ b/src/interactive_feedback_server/llm/__init__.py @@ -0,0 +1,43 @@ +""" +LLM模块:提供多种大语言模型的统一接口 + +此模块实现了适配器模式,支持多种LLM提供商: +- OpenAI (GPT系列) +- Google Gemini +- DeepSeek +- 其他兼容OpenAI API的模型 + +主要组件: +- base.py: 定义LLMProvider抽象基类 +- factory.py: 工厂函数,根据配置创建provider实例 +- openai_provider.py: OpenAI适配器实现 +- 其他provider实现... +""" + +from .base import LLMProvider +from .factory import get_llm_provider, validate_provider_config + +# Provider implementations +try: + from .openai_provider import OpenAIProvider +except ImportError: + OpenAIProvider = None + +try: + from .gemini_provider import GeminiProvider +except ImportError: + GeminiProvider = None + +try: + from .volcengine_provider import VolcEngineProvider +except ImportError: + VolcEngineProvider = None + +__all__ = [ + "LLMProvider", + "get_llm_provider", + "validate_provider_config", + "OpenAIProvider", + "GeminiProvider", + "VolcEngineProvider", +] diff --git a/src/interactive_feedback_server/llm/base.py b/src/interactive_feedback_server/llm/base.py new file mode 100644 index 0000000..d0f65b5 --- /dev/null +++ b/src/interactive_feedback_server/llm/base.py @@ -0,0 +1,38 @@ +""" +LLM Provider抽象基类 + +定义所有LLM提供商必须实现的统一接口 +""" + +from abc import ABC, abstractmethod + + +class LLMProvider(ABC): + """定义所有 LLM Provider 必须遵循的接口。""" + + @abstractmethod + def generate(self, prompt: str, system_prompt: str) -> str: + """ + 根据给定的 prompt 生成文本。 + + Args: + prompt: 用户的主要输入或问题 + system_prompt: 定义模型角色的系统级指令 + + Returns: + str: 模型生成的文本 + + Raises: + Exception: 当API调用失败时抛出异常 + """ + pass + + @abstractmethod + def validate_config(self) -> tuple[bool, str]: + """ + 验证当前provider的配置是否有效。 + + Returns: + tuple[bool, str]: (是否有效, 状态信息) + """ + pass diff --git a/src/interactive_feedback_server/llm/config_manager.py b/src/interactive_feedback_server/llm/config_manager.py new file mode 100644 index 0000000..0fcf828 --- /dev/null +++ b/src/interactive_feedback_server/llm/config_manager.py @@ -0,0 +1,229 @@ +""" +配置管理器 + +统一管理LLM相关的配置读写、缓存和默认值 +""" + +import json +import os +from typing import Dict, Any +from .constants import DEFAULT_OPTIMIZER_CONFIG, DEFAULT_PROVIDER_CONFIGS +from .config_validator import get_config_validator + + +class ConfigManager: + """ + LLM配置管理器 - 简化版本 + LLM Configuration Manager - Simplified Version + + 委托给主配置管理器,专注于LLM相关的配置处理 + Delegates to main config manager, focuses on LLM-related configuration handling + """ + + def __init__(self, config_file: str = "config.json"): + """ + 初始化配置管理器 + + Args: + config_file: 配置文件路径(保留兼容性,实际使用主配置管理器) + """ + self.config_file = config_file # 保留兼容性 + self.validator = get_config_validator() + + # 使用主配置管理器 + from ..utils.config_manager import get_config, save_config + + self._get_config = get_config + self._save_config = save_config + + def _load_config_from_file(self) -> Dict[str, Any]: + """ + 从文件加载配置 - 委托给主配置管理器 + Load configuration from file - delegate to main config manager + + Returns: + dict: 配置字典 + """ + # 直接使用主配置管理器 + return self._get_config() + + def _save_config_to_file(self, config: Dict[str, Any]) -> bool: + """ + 保存配置到文件 - 委托给主配置管理器 + Save configuration to file - delegate to main config manager + + Args: + config: 配置字典 + + Returns: + bool: 是否保存成功 + """ + # 直接使用主配置管理器 + return self._save_config(config) + + def get_config(self) -> Dict[str, Any]: + """ + 获取完整配置(支持环境变量) + + Returns: + dict: 完整配置 + """ + # 直接从主配置管理器获取配置(包含环境变量支持) + config = self._load_config_from_file() + + # 确保有默认的优化器配置 + if "expression_optimizer" not in config: + config["expression_optimizer"] = DEFAULT_OPTIMIZER_CONFIG.copy() + + return config + + def get_optimizer_config(self) -> Dict[str, Any]: + """ + 获取优化器配置 + + Returns: + dict: 优化器配置 + """ + config = self.get_config() + return config.get("expression_optimizer", DEFAULT_OPTIMIZER_CONFIG.copy()) + + def get_provider_config(self, provider_name: str) -> Dict[str, Any]: + """ + 获取指定Provider的配置 + + Args: + provider_name: Provider名称 + + Returns: + dict: Provider配置 + """ + optimizer_config = self.get_optimizer_config() + providers = optimizer_config.get("providers", {}) + + # 如果没有配置,返回默认配置 + if provider_name not in providers: + return DEFAULT_PROVIDER_CONFIGS.get(provider_name, {}).copy() + + return providers[provider_name].copy() + + def set_optimizer_config(self, optimizer_config: Dict[str, Any]) -> bool: + """ + 设置优化器配置 + + Args: + optimizer_config: 优化器配置 + + Returns: + bool: 是否设置成功 + """ + # 验证配置 + is_valid, message = self.validator.validate_optimizer_config(optimizer_config) + if not is_valid: + return False + + # 获取完整配置 + config = self.get_config() + config["expression_optimizer"] = optimizer_config + + # 保存到文件 + return self._save_config_to_file(config) + + def set_provider_config( + self, provider_name: str, provider_config: Dict[str, Any] + ) -> bool: + """ + 设置指定Provider的配置 + + Args: + provider_name: Provider名称 + provider_config: Provider配置 + + Returns: + bool: 是否设置成功 + """ + # 验证配置 + is_valid, message = self.validator.validate_provider_config( + provider_name, provider_config + ) + if not is_valid: + return False + + # 获取优化器配置 + optimizer_config = self.get_optimizer_config() + + # 确保providers字段存在 + if "providers" not in optimizer_config: + optimizer_config["providers"] = {} + + # 更新Provider配置 + optimizer_config["providers"][provider_name] = provider_config + + # 保存优化器配置 + return self.set_optimizer_config(optimizer_config) + + def set_active_provider(self, provider_name: str) -> bool: + """ + 设置活动的Provider + + Args: + provider_name: Provider名称 + + Returns: + bool: 是否设置成功 + """ + # 检查Provider是否支持 + if provider_name not in self.validator.get_supported_providers(): + return False + + # 获取优化器配置 + optimizer_config = self.get_optimizer_config() + + # 检查Provider是否已配置 + providers = optimizer_config.get("providers", {}) + if provider_name not in providers: + # 使用默认配置 + default_config = DEFAULT_PROVIDER_CONFIGS.get(provider_name, {}) + if not default_config: + return False + providers[provider_name] = default_config.copy() + optimizer_config["providers"] = providers + + # 设置活动Provider + optimizer_config["active_provider"] = provider_name + + # 保存配置 + return self.set_optimizer_config(optimizer_config) + + def enable_optimizer(self, enabled: bool = True) -> bool: + """ + 启用或禁用优化器 + + Args: + enabled: 是否启用 + + Returns: + bool: 是否设置成功 + """ + optimizer_config = self.get_optimizer_config() + optimizer_config["enabled"] = enabled + return self.set_optimizer_config(optimizer_config) + + +# 全局配置管理器实例 +_global_config_manager = None + + +def get_config_manager(config_file: str = "config.json") -> ConfigManager: + """ + 获取全局配置管理器实例 + + Args: + config_file: 配置文件路径 + + Returns: + ConfigManager: 配置管理器实例 + """ + global _global_config_manager + if _global_config_manager is None: + _global_config_manager = ConfigManager(config_file) + return _global_config_manager diff --git a/src/interactive_feedback_server/llm/config_validator.py b/src/interactive_feedback_server/llm/config_validator.py new file mode 100644 index 0000000..353d9bc --- /dev/null +++ b/src/interactive_feedback_server/llm/config_validator.py @@ -0,0 +1,233 @@ +""" +配置验证器 + +专门负责LLM配置的验证逻辑,分离关注点 +""" + +from typing import Dict, Tuple, List, Any +from .constants import API_KEY_VALIDATION, SUPPORTED_MODELS, API_ENDPOINTS + + +class ConfigValidator: + """ + 配置验证器 + + 负责验证各种LLM Provider的配置是否正确 + """ + + def __init__(self): + """初始化配置验证器""" + self.validation_rules = API_KEY_VALIDATION + self.supported_models = SUPPORTED_MODELS + self.api_endpoints = API_ENDPOINTS + + def validate_api_key(self, provider_name: str, api_key: str) -> Tuple[bool, str]: + """ + 验证API密钥格式 + + Args: + provider_name: Provider名称 + api_key: API密钥 + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + if not api_key: + return False, f"{provider_name} API密钥未配置" + + # 检查占位符 + placeholder_patterns = ["YOUR_", "_API_KEY_HERE", "your-actual-api-key"] + if any(pattern in api_key for pattern in placeholder_patterns): + return False, f"{provider_name} API密钥未配置(仍为占位符)" + + # 获取验证规则 + validation_rules = self.validation_rules.get(provider_name, {}) + + # 检查前缀 + if "prefix" in validation_rules: + prefix = validation_rules["prefix"] + if not api_key.startswith(prefix): + return False, f"{provider_name} API密钥格式可能无效(应以{prefix}开头)" + + # 检查长度 + if "min_length" in validation_rules: + min_length = validation_rules["min_length"] + if len(api_key) < min_length: + return False, f"{provider_name} API密钥长度不足(至少{min_length}字符)" + + # 检查UUID格式(火山引擎) + if validation_rules.get("format") == "uuid": + if "contains" in validation_rules: + required_char = validation_rules["contains"] + if required_char not in api_key: + return False, f"{provider_name} API密钥格式可能无效(应为UUID格式)" + + return True, "API密钥格式有效" + + def validate_model(self, provider_name: str, model: str) -> Tuple[bool, str]: + """ + 验证模型是否支持 + + Args: + provider_name: Provider名称 + model: 模型名称 + + Returns: + tuple[bool, str]: (是否支持, 错误信息) + """ + if not model: + return True, "未指定模型,将使用默认模型" + + # 对于用户配置的模型,我们采用宽松验证策略 + # 只要模型名称不为空,就认为是有效的,让API自己验证 + # 这样用户可以使用任何新发布的模型,而不需要等待代码更新 + supported_models = self.supported_models.get(provider_name, []) + if model not in supported_models: + # 不再返回错误,而是给出警告信息 + return True, f"模型 {model} 不在预定义列表中,但将尝试使用(如果API支持)" + + return True, "模型支持" + + def validate_base_url(self, base_url: str) -> Tuple[bool, str]: + """ + 验证Base URL格式 + + Args: + base_url: API基础URL + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + if not base_url: + return True, "未指定Base URL,将使用默认值" + + if not base_url.startswith(("http://", "https://")): + return False, "Base URL格式无效(应以http://或https://开头)" + + return True, "Base URL格式有效" + + def validate_provider_config( + self, provider_name: str, config: Dict[str, Any] + ) -> Tuple[bool, str]: + """ + 验证完整的Provider配置 + + Args: + provider_name: Provider名称 + config: 配置字典 + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + # 验证API密钥 + api_key = config.get("api_key", "") + is_valid, message = self.validate_api_key(provider_name, api_key) + if not is_valid: + return False, message + + # 验证模型 + model = config.get("model", "") + is_valid, message = self.validate_model(provider_name, model) + if not is_valid: + return False, message + + # 验证Base URL(如果存在) + base_url = config.get("base_url") + if base_url: + is_valid, message = self.validate_base_url(base_url) + if not is_valid: + return False, message + + return True, "配置有效" + + def validate_optimizer_config(self, config: Dict[str, Any]) -> Tuple[bool, str]: + """ + 验证完整的优化器配置 + + Args: + config: 优化器配置 + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + # 检查基本结构 + if not isinstance(config, dict): + return False, "配置格式无效(应为字典)" + + # 检查是否启用 + enabled = config.get("enabled", False) + if not enabled: + return True, "优化功能未启用" + + # 检查活动Provider + active_provider = config.get("active_provider") + if not active_provider: + return False, "未指定活动的LLM Provider" + + # 检查Provider配置 + providers = config.get("providers", {}) + if not providers: + return False, "未配置任何LLM Provider" + + if active_provider not in providers: + return False, f"活动Provider '{active_provider}' 未在配置中找到" + + # 验证活动Provider的配置 + provider_config = providers[active_provider] + is_valid, message = self.validate_provider_config( + active_provider, provider_config + ) + if not is_valid: + return False, f"{active_provider} 配置无效: {message}" + + return True, f"优化器配置有效,使用 {active_provider}" + + def get_supported_providers(self) -> List[str]: + """ + 获取支持的Provider列表 + + Returns: + list[str]: 支持的Provider名称列表 + """ + return list(self.supported_models.keys()) + + def get_supported_models_for_provider(self, provider_name: str) -> List[str]: + """ + 获取指定Provider支持的模型列表 + + Args: + provider_name: Provider名称 + + Returns: + list[str]: 支持的模型列表 + """ + return self.supported_models.get(provider_name, []) + + def get_default_endpoint(self, provider_name: str) -> str: + """ + 获取Provider的默认端点 + + Args: + provider_name: Provider名称 + + Returns: + str: 默认API端点 + """ + return self.api_endpoints.get(provider_name, "") + + +# 全局配置验证器实例 +_global_validator = None + + +def get_config_validator() -> ConfigValidator: + """ + 获取全局配置验证器实例 + + Returns: + ConfigValidator: 验证器实例 + """ + global _global_validator + if _global_validator is None: + _global_validator = ConfigValidator() + return _global_validator diff --git a/src/interactive_feedback_server/llm/constants.py b/src/interactive_feedback_server/llm/constants.py new file mode 100644 index 0000000..a498a83 --- /dev/null +++ b/src/interactive_feedback_server/llm/constants.py @@ -0,0 +1,103 @@ +""" +LLM模块常量定义 + +统一管理所有LLM相关的常量,避免重复定义 +""" + +# 默认Provider配置 +DEFAULT_PROVIDER_CONFIGS = { + "openai": { + "api_key": "", + "model": "gpt-4o-mini", + "base_url": "https://api.openai.com/v1", + }, + "gemini": {"api_key": "", "model": "gemini-2.0-flash"}, + "deepseek": { + "api_key": "", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + }, + "volcengine": { + "api_key": "", + "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "model": "deepseek-v3-250324", + }, +} + +# 默认优化器配置 - V4.2 用户友好版本 +DEFAULT_OPTIMIZER_CONFIG = { + "enabled": True, # V4.2 改为默认启用 + "active_provider": "openai", + "providers": DEFAULT_PROVIDER_CONFIGS, +} + +# 支持的模型列表 +SUPPORTED_MODELS = { + "openai": [ + # OpenAI模型 + "gpt-3.5-turbo", + "gpt-4", + "gpt-4-turbo", + "gpt-4o", + "gpt-4o-mini", + # DeepSeek模型(兼容OpenAI接口) + "deepseek-chat", + "deepseek-coder", + ], + "gemini": [ + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-2.5-flash-preview-04-17", + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-1.0-pro", + "gemini-pro", + ], + "deepseek": ["deepseek-chat", "deepseek-coder"], + "volcengine": [ + "deepseek-v3-250324", + "doubao-pro-4k", + "doubao-pro-32k", + "doubao-pro-128k", + "doubao-lite-4k", + "doubao-lite-32k", + ], +} + +# API端点配置 +API_ENDPOINTS = { + "openai": "https://api.openai.com/v1", + "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/", + "deepseek": "https://api.deepseek.com/v1", + "volcengine": "https://ark.cn-beijing.volces.com/api/v3", +} + +# 通用配置 +COMMON_CONFIG = {"timeout": 30, "temperature": 0.7, "max_tokens": 1024} + +# 错误消息模板 +ERROR_MESSAGES = { + "auth": "[ERROR:AUTH] {provider} API密钥无效,请检查配置", + "rate": "[ERROR:RATE] {provider} API调用频率过高,请稍后再试", + "timeout": "[ERROR:TIMEOUT] {provider} 请求超时,请稍后重试", + "request": "[ERROR:REQUEST] {provider} 请求参数无效,请检查输入内容", + "safety": "[ERROR:SAFETY] 内容被{provider}安全过滤器阻止,请修改输入", + "model": "[ERROR:MODEL] {provider} 模型 {model} 不存在或不支持", + "unknown": "[ERROR:UNKNOWN] {provider} 服务异常: {error}", +} + +# Provider显示名称 +PROVIDER_DISPLAY_NAMES = { + "openai": {"zh_CN": "OpenAI", "en_US": "OpenAI"}, + "gemini": {"zh_CN": "Google Gemini", "en_US": "Google Gemini"}, + "deepseek": {"zh_CN": "DeepSeek", "en_US": "DeepSeek"}, + "volcengine": {"zh_CN": "火山引擎", "en_US": "Huoshan"}, +} + +# API密钥验证规则 +API_KEY_VALIDATION = { + "openai": {"prefix": "sk-", "min_length": 30}, # 调整为更合理的长度 + "gemini": {"prefix": "AIza", "min_length": 30}, + "deepseek": {"prefix": "sk-", "min_length": 30}, + "volcengine": {"format": "uuid", "min_length": 30, "contains": "-"}, # UUID格式 +} diff --git a/src/interactive_feedback_server/llm/factory.py b/src/interactive_feedback_server/llm/factory.py new file mode 100644 index 0000000..62b196c --- /dev/null +++ b/src/interactive_feedback_server/llm/factory.py @@ -0,0 +1,120 @@ +""" +LLM Provider工厂函数 + +根据配置动态创建和管理LLM provider实例 +""" + +from typing import Optional, Tuple +from .base import LLMProvider +from .config_validator import get_config_validator +from .config_manager import get_config_manager + + +def validate_provider_config(provider_name: str, config: dict) -> Tuple[bool, str]: + """ + 验证特定provider的配置是否有效 + + Args: + provider_name: provider名称 (如 'openai', 'gemini') + config: provider的配置字典 + + Returns: + tuple[bool, str]: (是否有效, 状态信息) + """ + validator = get_config_validator() + return validator.validate_provider_config(provider_name, config) + + +def get_llm_provider(config: dict = None) -> Tuple[Optional[LLMProvider], str]: + """ + 根据配置,实例化并返回对应的 LLM Provider + + Args: + config: expression_optimizer配置字典(可选,如果不提供则从配置管理器获取) + + Returns: + tuple[LLMProvider | None, str]: (provider实例, 状态信息) + """ + # 如果没有提供配置,从配置管理器获取 + if config is None: + config_manager = get_config_manager() + config = config_manager.get_optimizer_config() + + # 使用验证器验证配置 + validator = get_config_validator() + is_valid, message = validator.validate_optimizer_config(config) + if not is_valid: + return None, f"配置无效: {message}" + + active_provider_name = config.get("active_provider") + provider_config = config["providers"][active_provider_name] + + # 创建provider实例 + if active_provider_name == "openai": + try: + from .openai_provider import OpenAIProvider + + return ( + OpenAIProvider( + api_key=provider_config.get("api_key"), + base_url=provider_config.get("base_url"), + model=provider_config.get("model", "gpt-3.5-turbo"), + ), + "配置有效", + ) + except ImportError: + return None, "OpenAI provider未安装" + + elif active_provider_name == "gemini": + try: + from .gemini_provider import GeminiProvider + + return ( + GeminiProvider( + api_key=provider_config.get("api_key"), + model=provider_config.get("model", "gemini-2.0-flash"), + base_url=provider_config.get("base_url"), + ), + "配置有效", + ) + except ImportError: + return ( + None, + "Gemini provider未安装,请安装: pip install google-generativeai", + ) + + elif active_provider_name == "deepseek": + try: + from .openai_provider import OpenAIProvider # DeepSeek兼容OpenAI API + + return ( + OpenAIProvider( + api_key=provider_config.get("api_key"), + base_url=provider_config.get( + "base_url", "https://api.deepseek.com/v1" + ), + model=provider_config.get("model", "deepseek-chat"), + ), + "配置有效", + ) + except ImportError: + return None, "DeepSeek provider未安装" + + elif active_provider_name == "volcengine": + try: + from .volcengine_provider import VolcEngineProvider + + return ( + VolcEngineProvider( + api_key=provider_config.get("api_key"), + model=provider_config.get("model", "doubao-pro-4k"), + base_url=provider_config.get( + "base_url", "https://ark.cn-beijing.volces.com/api/v3" + ), + ), + "配置有效", + ) + except ImportError: + return None, "火山引擎 provider未安装" + + return None, f"不支持的provider: {active_provider_name}" diff --git a/src/interactive_feedback_server/llm/gemini_provider.py b/src/interactive_feedback_server/llm/gemini_provider.py new file mode 100644 index 0000000..6ad51c6 --- /dev/null +++ b/src/interactive_feedback_server/llm/gemini_provider.py @@ -0,0 +1,135 @@ +""" +Google Gemini Provider实现 + +使用OpenAI兼容的API端点,支持最新的gemini-2.0-flash模型 +""" + +import time +import random +from typing import Optional +from .base import LLMProvider +from .utils import ( + create_openai_client, + handle_api_error, + create_chat_completion, + validate_api_key, + validate_model, +) + + +class GeminiProvider(LLMProvider): + """Google Gemini API适配器(使用OpenAI兼容接口)""" + + def __init__( + self, api_key: str, model: str = "gemini-2.0-flash", base_url: str = None + ): + """ + 初始化Gemini Provider + + Args: + api_key: Google Gemini API密钥 + model: 使用的模型名称 + base_url: API基础URL,如果不提供则使用默认值 + """ + self.api_key = api_key + self.model = model + # 使用配置文件中的base_url,如果没有则使用默认值 + self.base_url = ( + base_url or "https://generativelanguage.googleapis.com/v1beta/openai/" + ) + self._client = None + self.last_request_time = 0 + self.min_interval = 2.0 # 减少间隔,因为使用OpenAI兼容接口 + + @property + def client(self): + """延迟初始化OpenAI兼容的Gemini客户端""" + if self._client is None: + self._client = create_openai_client(self.api_key, self.base_url) + return self._client + + def generate(self, prompt: str, system_prompt: str) -> str: + """ + 使用Gemini API生成文本(OpenAI兼容接口) + + Args: + prompt: 用户输入 + system_prompt: 系统提示词 + + Returns: + str: 生成的文本或错误信息 + """ + # 实现请求间隔控制 + current_time = time.time() + time_since_last = current_time - self.last_request_time + + if time_since_last < self.min_interval: + sleep_time = self.min_interval - time_since_last + time.sleep(sleep_time) + + # 记录请求时间 + self.last_request_time = time.time() + + # 简单重试机制(仅针对频率限制) + max_retries = 3 + for attempt in range(max_retries): + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + return create_chat_completion(self.client, self.model, messages) + + except Exception as e: + error_str = str(e).lower() + + # 如果是频率限制,进行重试 + if ( + "quota" in error_str or "rate" in error_str or "429" in error_str + ) and attempt < max_retries - 1: + wait_time = (2**attempt) + random.uniform(0, 1) # 指数退避 + time.sleep(wait_time) + continue + + # 使用统一的错误处理 + return handle_api_error(e, "Gemini", self.model) + + return "[ERROR:RATE] Gemini API重试次数已用完,请稍后再试" + + def validate_config(self) -> tuple[bool, str]: + """ + 验证Gemini配置 + + Returns: + tuple[bool, str]: (是否有效, 状态信息) + """ + # 验证API密钥 + is_valid, message = validate_api_key("gemini", self.api_key) + if not is_valid: + return False, message + + # 验证模型 + is_valid, message = validate_model("gemini", self.model) + if not is_valid: + return False, message + + return True, "配置有效" + + def test_connection(self) -> tuple[bool, str]: + """ + 测试Gemini API连接 + + Returns: + tuple[bool, str]: (是否成功, 状态信息) + """ + try: + # 发送一个简单的测试请求 + test_response = self.generate("你好", "你是一个AI助手,请简短回复。") + + if test_response.startswith("[ERROR:"): + return False, f"连接测试失败: {test_response}" + else: + return True, "Gemini API连接成功" + + except Exception as e: + return False, f"连接测试异常: {str(e)}" diff --git a/src/interactive_feedback_server/llm/openai_provider.py b/src/interactive_feedback_server/llm/openai_provider.py new file mode 100644 index 0000000..5c27dda --- /dev/null +++ b/src/interactive_feedback_server/llm/openai_provider.py @@ -0,0 +1,105 @@ +""" +OpenAI Provider实现 + +封装OpenAI API调用,提供统一的LLM接口 +""" + +from typing import Optional +from .base import LLMProvider +from .utils import ( + create_openai_client, + handle_api_error, + create_chat_completion, + validate_api_key, + validate_model, +) + + +class OpenAIProvider(LLMProvider): + """OpenAI API适配器""" + + def __init__( + self, api_key: str, base_url: Optional[str] = None, model: str = "gpt-3.5-turbo" + ): + """ + 初始化OpenAI Provider + + Args: + api_key: OpenAI API密钥 + base_url: 可选的API基础URL(用于代理或兼容API) + model: 使用的模型名称 + """ + self.api_key = api_key + self.base_url = base_url + self.model = model + self._client = None + + @property + def client(self): + """延迟初始化OpenAI客户端""" + if self._client is None: + self._client = create_openai_client(self.api_key, self.base_url) + return self._client + + def generate(self, prompt: str, system_prompt: str) -> str: + """ + 使用OpenAI API生成文本 + + Args: + prompt: 用户输入 + system_prompt: 系统提示词 + + Returns: + str: 生成的文本或错误信息 + """ + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + return create_chat_completion(self.client, self.model, messages) + + except Exception as e: + return handle_api_error(e, "OpenAI", self.model) + + def validate_config(self) -> tuple[bool, str]: + """ + 验证OpenAI配置 + + Returns: + tuple[bool, str]: (是否有效, 状态信息) + """ + # 验证API密钥 + is_valid, message = validate_api_key("openai", self.api_key) + if not is_valid: + return False, message + + # 验证Base URL + if self.base_url and not self.base_url.startswith(("http://", "https://")): + return False, "Base URL格式无效" + + # 验证模型 + is_valid, message = validate_model("openai", self.model) + if not is_valid: + return False, message + + return True, "配置有效" + + def test_connection(self) -> tuple[bool, str]: + """ + 测试OpenAI API连接 + + Returns: + tuple[bool, str]: (是否成功, 状态信息) + """ + try: + # 发送一个简单的测试请求 + test_response = self.generate("你好", "你是一个AI助手,请简短回复。") + + if test_response.startswith("[ERROR:"): + return False, f"连接测试失败: {test_response}" + else: + return True, "OpenAI API连接成功" + + except Exception as e: + return False, f"连接测试异常: {str(e)}" diff --git a/src/interactive_feedback_server/llm/performance_manager.py b/src/interactive_feedback_server/llm/performance_manager.py new file mode 100644 index 0000000..4ac2029 --- /dev/null +++ b/src/interactive_feedback_server/llm/performance_manager.py @@ -0,0 +1,253 @@ +""" +简化的性能管理器 + +提供基本的缓存和统计功能,移除过度复杂的异步和重试逻辑 +""" + +import hashlib +from datetime import datetime, timedelta +from typing import Dict, Tuple, Optional, Any +from .base import LLMProvider + + +class OptimizationManager: + """ + 简化的优化管理器 + + 提供以下功能: + - 简单缓存:缓存相同输入的优化结果 + - 基本统计:记录请求和缓存统计 + """ + + def __init__(self, cache_ttl_minutes: int = 10): + """ + 初始化优化管理器 + + Args: + cache_ttl_minutes: 缓存生存时间(分钟) + """ + self.cache_ttl = timedelta(minutes=cache_ttl_minutes) + + # 缓存存储:{cache_key: (result, timestamp)} + self.cache: Dict[str, Tuple[str, datetime]] = {} + + # 统计信息 + self.stats = { + "total_requests": 0, + "cache_hits": 0, + "cache_misses": 0, + "successful_requests": 0, + "failed_requests": 0, + } + + def _get_cache_key(self, text: str, mode: str, reinforcement: str = "") -> str: + """ + 生成缓存键 + + Args: + text: 原始文本 + mode: 优化模式 + reinforcement: 强化指令 + + Returns: + str: 缓存键 + """ + content = f"{text}|{mode}|{reinforcement}" + return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] + + def _is_cache_valid(self, timestamp: datetime) -> bool: + """ + 检查缓存是否有效 + + Args: + timestamp: 缓存时间戳 + + Returns: + bool: 是否有效 + """ + return datetime.now() - timestamp < self.cache_ttl + + def _cleanup_expired_cache(self): + """清理过期的缓存项""" + current_time = datetime.now() + expired_keys = [ + key + for key, (_, timestamp) in self.cache.items() + if current_time - timestamp >= self.cache_ttl + ] + + for key in expired_keys: + del self.cache[key] + + def _is_basic_valid_result(self, result: str) -> bool: + """ + 基本结果有效性检查 - V4.1 精简版本 + Basic result validity check - V4.1 Simplified Version + + Args: + result: 优化结果 + + Returns: + bool: 是否有效 + """ + if not result or not isinstance(result, str): + return False + + # 只检查明显的错误信息,移除复杂的污染检测 + if result.startswith("[ERROR") or result.startswith("[系统错误]"): + return False + + # 基本长度检查 + if len(result.strip()) < 2: + return False + + return True + + def get_cache_stats(self) -> Dict[str, Any]: + """ + 获取缓存统计信息 + + Returns: + Dict: 统计信息 + """ + self._cleanup_expired_cache() + + return { + "cache_size": len(self.cache), + "cache_hit_rate": ( + self.stats["cache_hits"] / max(1, self.stats["total_requests"]) + ) + * 100, + **self.stats, + } + + def optimize_with_cache( + self, + provider: LLMProvider, + text: str, + mode: str, + system_prompt: str, + reinforcement: str = "", + ) -> str: + """ + 带缓存的优化处理(简化的同步版本) + + Args: + provider: LLM提供商实例 + text: 原始文本 + mode: 优化模式 + system_prompt: 系统提示词 + reinforcement: 强化指令 + + Returns: + str: 优化结果 + """ + self.stats["total_requests"] += 1 + + # 检查缓存 + cache_key = self._get_cache_key(text, mode, reinforcement) + if cache_key in self.cache: + result, timestamp = self.cache[cache_key] + if self._is_cache_valid(timestamp): + self.stats["cache_hits"] += 1 + return f"[CACHED] {result}" + + self.stats["cache_misses"] += 1 + + # 直接调用provider(同步) + try: + # 使用统一的提示词格式化函数 + from ..cli import format_prompt_for_mode + + prompt = format_prompt_for_mode(text, mode, reinforcement) + + # 简化的重试机制:只重试一次 + max_retries = 1 + for attempt in range(max_retries + 1): + result = provider.generate(prompt, system_prompt) + + # 检查API错误 + if result.startswith("[ERROR"): + self.stats["failed_requests"] += 1 + return result + + # 基本有效性检查 + if self._is_basic_valid_result(result): + # 结果有效,缓存并返回 + self.cache[cache_key] = (result, datetime.now()) + self.stats["successful_requests"] += 1 + return result + + # 如果还有重试机会且结果明显异常,进行重试 + if attempt < max_retries and len(result.strip()) < 5: + continue + + # 即使结果可能有小问题,也直接返回,避免过度检查 + self.cache[cache_key] = (result, datetime.now()) + self.stats["successful_requests"] += 1 + return result + + except Exception as e: + self.stats["failed_requests"] += 1 + return f"[ERROR:UNKNOWN] 优化异常: {str(e)}" + + def clear_cache(self): + """清空缓存""" + self.cache.clear() + + def reset_stats(self): + """重置统计信息""" + for key in self.stats: + self.stats[key] = 0 + + # V4.1 移除:复杂的健康检查和缓存污染检测功能已删除 + # 这些功能对于简单的文本优化来说过度复杂,影响性能 + + +# 全局性能管理器实例 +_global_manager: Optional[OptimizationManager] = None + + +def get_optimization_manager(config: Dict[str, Any]) -> OptimizationManager: + """ + 获取全局优化管理器实例 + + Args: + config: 性能配置 + + Returns: + OptimizationManager: 管理器实例 + """ + global _global_manager + + if _global_manager is None: + performance_config = config.get("performance", {}) + + _global_manager = OptimizationManager( + cache_ttl_minutes=performance_config.get("cache_ttl_minutes", 10) + ) + + return _global_manager + + +def reset_global_manager(): + """ + 重置全局管理器实例 + Reset global manager instance + """ + global _global_manager + _global_manager = None + + +def force_clear_all_caches(): + """ + 强制清空所有缓存(包括重置全局实例) + Force clear all caches (including resetting global instance) + """ + global _global_manager + if _global_manager is not None: + _global_manager.clear_cache() + _global_manager.reset_stats() + + # 重置全局实例 + _global_manager = None diff --git a/src/interactive_feedback_server/llm/utils.py b/src/interactive_feedback_server/llm/utils.py new file mode 100644 index 0000000..dfed1b0 --- /dev/null +++ b/src/interactive_feedback_server/llm/utils.py @@ -0,0 +1,178 @@ +""" +LLM模块工具函数 + +提供通用的工具函数,减少代码重复 +""" + +from typing import Optional, Tuple +from .constants import ERROR_MESSAGES, COMMON_CONFIG + + +def create_openai_client(api_key: str, base_url: Optional[str] = None): + """ + 创建OpenAI兼容的客户端 + + Args: + api_key: API密钥 + base_url: 可选的API基础URL + + Returns: + OpenAI客户端实例 + + Raises: + ImportError: 如果openai库未安装 + """ + try: + from openai import OpenAI + + return OpenAI(api_key=api_key, base_url=base_url) + except ImportError: + raise ImportError("请安装openai库: pip install openai") + + +def handle_api_error(error: Exception, provider_name: str, model: str = "") -> str: + """ + 统一的API错误处理 + + Args: + error: 异常对象 + provider_name: Provider名称 + model: 模型名称(可选) + + Returns: + str: 格式化的错误消息 + """ + error_str = str(error).lower() + error_type = type(error).__name__ + + # 认证错误 + if any(keyword in error_str for keyword in ["authentication", "401", "api_key"]): + return ERROR_MESSAGES["auth"].format(provider=provider_name) + + # 频率限制 + elif any(keyword in error_str for keyword in ["rate", "429", "quota"]): + return ERROR_MESSAGES["rate"].format(provider=provider_name) + + # 超时错误 + elif "timeout" in error_str or "TimeoutError" in error_type: + return ERROR_MESSAGES["timeout"].format(provider=provider_name) + + # 请求参数错误 + elif any(keyword in error_str for keyword in ["invalid", "400"]): + return ERROR_MESSAGES["request"].format(provider=provider_name) + + # 安全过滤器 + elif any(keyword in error_str for keyword in ["blocked", "safety"]): + return ERROR_MESSAGES["safety"].format(provider=provider_name) + + # 模型不存在 + elif "not found" in error_str or ("model" in error_str and model): + return ERROR_MESSAGES["model"].format(provider=provider_name, model=model) + + # 未知错误 + else: + return ERROR_MESSAGES["unknown"].format( + provider=provider_name, error=str(error) + ) + + +def create_chat_completion(client, model: str, messages: list, **kwargs) -> str: + """ + 统一的聊天完成请求 + + Args: + client: OpenAI兼容的客户端 + model: 模型名称 + messages: 消息列表 + **kwargs: 额外参数 + + Returns: + str: 生成的文本 + + Raises: + Exception: API调用失败时抛出异常 + """ + # 合并默认配置和用户配置 + config = {**COMMON_CONFIG, **kwargs} + + response = client.chat.completions.create( + model=model, + messages=messages, + temperature=config.get("temperature", 0.7), + max_tokens=config.get("max_tokens", 1024), + timeout=config.get("timeout", 30), + ) + + if response.choices and response.choices[0].message.content: + return response.choices[0].message.content + else: + return "" + + +# 验证函数已迁移到config_validator.py +# 为了向后兼容,提供简单的包装函数 + + +def validate_api_key(provider_name: str, api_key: str) -> Tuple[bool, str]: + """ + 验证API密钥格式(向后兼容包装函数) + + Args: + provider_name: Provider名称 + api_key: API密钥 + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + from .config_validator import get_config_validator + + validator = get_config_validator() + return validator.validate_api_key(provider_name, api_key) + + +def validate_model(provider_name: str, model: str) -> Tuple[bool, str]: + """ + 验证模型是否支持(向后兼容包装函数) + + Args: + provider_name: Provider名称 + model: 模型名称 + + Returns: + tuple[bool, str]: (是否支持, 错误信息) + """ + from .config_validator import get_config_validator + + validator = get_config_validator() + return validator.validate_model(provider_name, model) + + +def get_default_config(provider_name: str) -> dict: + """ + 获取Provider的默认配置 + + Args: + provider_name: Provider名称 + + Returns: + dict: 默认配置 + """ + from .constants import DEFAULT_PROVIDER_CONFIGS + + return DEFAULT_PROVIDER_CONFIGS.get(provider_name, {}).copy() + + +def get_provider_display_name(provider_name: str, language: str = "zh_CN") -> str: + """ + 获取Provider的显示名称 + + Args: + provider_name: Provider名称 + language: 语言代码 + + Returns: + str: 显示名称 + """ + from .constants import PROVIDER_DISPLAY_NAMES + + return PROVIDER_DISPLAY_NAMES.get(provider_name, {}).get(language, provider_name) diff --git a/src/interactive_feedback_server/llm/volcengine_provider.py b/src/interactive_feedback_server/llm/volcengine_provider.py new file mode 100644 index 0000000..3681863 --- /dev/null +++ b/src/interactive_feedback_server/llm/volcengine_provider.py @@ -0,0 +1,108 @@ +""" +火山引擎(豆包)Provider实现 + +封装火山引擎API调用,提供统一的LLM接口 +""" + +from typing import Optional +from .base import LLMProvider +from .utils import ( + create_openai_client, + handle_api_error, + create_chat_completion, + validate_api_key, + validate_model, +) + + +class VolcEngineProvider(LLMProvider): + """火山引擎(豆包)API适配器""" + + def __init__( + self, + api_key: str, + model: str = "deepseek-v3-250324", + base_url: str = "https://ark.cn-beijing.volces.com/api/v3", + ): + """ + 初始化火山引擎 Provider + + Args: + api_key: 火山引擎API密钥 + model: 使用的模型名称 + base_url: API基础URL + """ + self.api_key = api_key + self.model = model + self.base_url = base_url + self._client = None + + @property + def client(self): + """延迟初始化火山引擎客户端(使用OpenAI兼容接口)""" + if self._client is None: + self._client = create_openai_client(self.api_key, self.base_url) + return self._client + + def generate(self, prompt: str, system_prompt: str) -> str: + """ + 使用火山引擎API生成文本 + + Args: + prompt: 用户输入 + system_prompt: 系统提示词 + + Returns: + str: 生成的文本或错误信息 + """ + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + return create_chat_completion(self.client, self.model, messages) + + except Exception as e: + return handle_api_error(e, "火山引擎", self.model) + + def validate_config(self) -> tuple[bool, str]: + """ + 验证火山引擎配置 + + Returns: + tuple[bool, str]: (是否有效, 状态信息) + """ + # 验证API密钥 + is_valid, message = validate_api_key("volcengine", self.api_key) + if not is_valid: + return False, message + + # 验证Base URL + if not self.base_url or not self.base_url.startswith(("http://", "https://")): + return False, "火山引擎Base URL格式无效" + + # 验证模型 + is_valid, message = validate_model("volcengine", self.model) + if not is_valid: + return False, message + + return True, "配置有效" + + def test_connection(self) -> tuple[bool, str]: + """ + 测试火山引擎API连接 + + Returns: + tuple[bool, str]: (是否成功, 状态信息) + """ + try: + # 发送一个简单的测试请求 + test_response = self.generate("你好", "你是一个AI助手,请简短回复。") + + if test_response.startswith("[ERROR:"): + return False, f"连接测试失败: {test_response}" + else: + return True, "火山引擎API连接成功" + + except Exception as e: + return False, f"连接测试异常: {str(e)}" diff --git a/src/interactive_feedback_server/monitoring/__init__.py b/src/interactive_feedback_server/monitoring/__init__.py new file mode 100644 index 0000000..8166513 --- /dev/null +++ b/src/interactive_feedback_server/monitoring/__init__.py @@ -0,0 +1,34 @@ +# interactive_feedback_server/monitoring/__init__.py + +""" +性能监控模块 +Performance Monitoring Module + +提供全面的性能监控、分析和可视化功能。 +Provides comprehensive performance monitoring, analysis and visualization functionality. +""" + +from .performance_monitor import ( + MetricCollector, + PerformanceTimer, + MetricType, + MetricData, + PerformanceSnapshot, + timer_decorator, + get_metric_collector, +) + +# 已删除未使用的性能分析器和监控仪表板模块 + +__all__ = [ + # 性能监控 + "MetricCollector", + "PerformanceTimer", + "MetricType", + "MetricData", + "PerformanceSnapshot", + "timer_decorator", + "get_metric_collector", +] + +__version__ = "3.3.0" diff --git a/src/interactive_feedback_server/monitoring/performance_monitor.py b/src/interactive_feedback_server/monitoring/performance_monitor.py new file mode 100644 index 0000000..2a9c435 --- /dev/null +++ b/src/interactive_feedback_server/monitoring/performance_monitor.py @@ -0,0 +1,417 @@ +# interactive_feedback_server/monitoring/performance_monitor.py + +""" +性能监控系统 - V3.3 架构改进版本 +Performance Monitoring System - V3.3 Architecture Improvement Version + +提供全面的性能监控、指标收集和分析功能。 +Provides comprehensive performance monitoring, metrics collection and analysis functionality. +""" + +import time +import threading +import psutil +import statistics +from typing import Dict, List, Any, Callable +from dataclasses import dataclass, field +from collections import deque +from enum import Enum + + +class MetricType(Enum): + """指标类型枚举""" + + COUNTER = "counter" # 计数器 + GAUGE = "gauge" # 仪表 + HISTOGRAM = "histogram" # 直方图 + TIMER = "timer" # 计时器 + + +@dataclass +class MetricData: + """ + 指标数据 + Metric Data + """ + + name: str # 指标名称 + metric_type: MetricType # 指标类型 + value: float # 当前值 + timestamp: float # 时间戳 + tags: Dict[str, str] = field(default_factory=dict) # 标签 + unit: str = "" # 单位 + description: str = "" # 描述 + + +@dataclass +class PerformanceSnapshot: + """ + 性能快照 + Performance Snapshot + """ + + timestamp: float # 时间戳 + cpu_percent: float # CPU使用率 + memory_percent: float # 内存使用率 + memory_used_mb: float # 已用内存(MB) + disk_io_read_mb: float # 磁盘读取(MB) + disk_io_write_mb: float # 磁盘写入(MB) + network_sent_mb: float # 网络发送(MB) + network_recv_mb: float # 网络接收(MB) + active_threads: int # 活跃线程数 + open_files: int # 打开文件数 + + +class MetricCollector: + """ + 指标收集器 - 简化版本 + Metric Collector - Simplified Version + + 专注于系统性能监控,通用统计功能委托给统计收集器 + Focuses on system performance monitoring, delegates general statistics to stats collector + """ + + def __init__(self, max_history: int = 1000): + """ + 初始化指标收集器 + Initialize metric collector + + Args: + max_history: 最大历史记录数 + """ + self.max_history = max_history + self._lock = threading.RLock() + + # 系统指标收集 + self._system_snapshots: deque = deque(maxlen=max_history) + self._last_disk_io = None + self._last_network_io = None + + # 使用统一的统计收集器作为后端 + from ..core import get_stats_collector + + self._stats_collector = get_stats_collector() + + def increment_counter( + self, name: str, value: float = 1.0, tags: Dict[str, str] = None + ) -> None: + """ + 增加计数器 - 委托给统计收集器 + Increment counter - delegate to stats collector + + Args: + name: 指标名称 + value: 增加值 + tags: 标签 + """ + # 委托给统一的统计收集器 + self._stats_collector.increment( + name, value, category="performance_monitor", **(tags or {}) + ) + + def set_gauge(self, name: str, value: float, tags: Dict[str, str] = None) -> None: + """ + 设置仪表值 - 委托给统计收集器 + Set gauge value - delegate to stats collector + + Args: + name: 指标名称 + value: 值 + tags: 标签 + """ + # 委托给统一的统计收集器 + self._stats_collector.set_gauge( + name, value, category="performance_monitor", **(tags or {}) + ) + + def record_timer( + self, name: str, duration: float, tags: Dict[str, str] = None + ) -> None: + """ + 记录计时器 - 委托给统计收集器 + Record timer - delegate to stats collector + + Args: + name: 指标名称 + duration: 持续时间(秒) + tags: 标签 + """ + # 委托给统一的统计收集器 + self._stats_collector.record_value( + name, duration, category="performance_timer", **(tags or {}) + ) + + # 已删除 _record_metric 方法,功能委托给统计收集器 + + def collect_system_metrics(self) -> PerformanceSnapshot: + """ + 收集系统指标 + Collect system metrics + + Returns: + PerformanceSnapshot: 性能快照 + """ + try: + # CPU和内存 + cpu_percent = psutil.cpu_percent(interval=0.1) + memory = psutil.virtual_memory() + + # 磁盘IO + disk_io = psutil.disk_io_counters() + disk_read_mb = 0.0 + disk_write_mb = 0.0 + + if disk_io and self._last_disk_io: + disk_read_mb = ( + (disk_io.read_bytes - self._last_disk_io.read_bytes) / 1024 / 1024 + ) + disk_write_mb = ( + (disk_io.write_bytes - self._last_disk_io.write_bytes) / 1024 / 1024 + ) + + self._last_disk_io = disk_io + + # 网络IO + network_io = psutil.net_io_counters() + network_sent_mb = 0.0 + network_recv_mb = 0.0 + + if network_io and self._last_network_io: + network_sent_mb = ( + (network_io.bytes_sent - self._last_network_io.bytes_sent) + / 1024 + / 1024 + ) + network_recv_mb = ( + (network_io.bytes_recv - self._last_network_io.bytes_recv) + / 1024 + / 1024 + ) + + self._last_network_io = network_io + + # 进程信息 + process = psutil.Process() + active_threads = process.num_threads() + open_files = len(process.open_files()) + + snapshot = PerformanceSnapshot( + timestamp=time.time(), + cpu_percent=cpu_percent, + memory_percent=memory.percent, + memory_used_mb=memory.used / 1024 / 1024, + disk_io_read_mb=disk_read_mb, + disk_io_write_mb=disk_write_mb, + network_sent_mb=network_sent_mb, + network_recv_mb=network_recv_mb, + active_threads=active_threads, + open_files=open_files, + ) + + with self._lock: + self._system_snapshots.append(snapshot) + + return snapshot + + except Exception as e: + print(f"收集系统指标失败: {e}") + return PerformanceSnapshot( + timestamp=time.time(), + cpu_percent=0.0, + memory_percent=0.0, + memory_used_mb=0.0, + disk_io_read_mb=0.0, + disk_io_write_mb=0.0, + network_sent_mb=0.0, + network_recv_mb=0.0, + active_threads=0, + open_files=0, + ) + + def get_metric_history(self, name: str, limit: int = None) -> List[Dict[str, Any]]: + """ + 获取指标历史 - 委托给统计收集器 + Get metric history - delegate to stats collector + + Args: + name: 指标名称 + limit: 限制数量 + + Returns: + List[Dict[str, Any]]: 指标历史 + """ + # 从统计收集器获取历史数据 + history = self._stats_collector.get_history(name, limit) + return [ + { + "name": entry.name, + "value": entry.value, + "timestamp": entry.timestamp, + "tags": entry.tags, + "category": entry.category, + } + for entry in history + ] + + def get_timer_stats(self, name: str) -> Dict[str, float]: + """ + 获取计时器统计 - 委托给统计收集器 + Get timer statistics - delegate to stats collector + + Args: + name: 计时器名称 + + Returns: + Dict[str, float]: 统计信息 + """ + # 从统计收集器获取直方图统计 + return self._stats_collector.get_histogram_stats(name) + + # 已删除 _percentile 方法,功能委托给统计收集器 + + def get_system_stats(self, minutes: int = 5) -> Dict[str, Any]: + """ + 获取系统统计 + Get system statistics + + Args: + minutes: 统计时间范围(分钟) + + Returns: + Dict[str, Any]: 系统统计 + """ + with self._lock: + if not self._system_snapshots: + return {} + + # 过滤指定时间范围内的快照 + cutoff_time = time.time() - (minutes * 60) + recent_snapshots = [ + s for s in self._system_snapshots if s.timestamp >= cutoff_time + ] + + if not recent_snapshots: + return {} + + # 计算统计信息 + cpu_values = [s.cpu_percent for s in recent_snapshots] + memory_values = [s.memory_percent for s in recent_snapshots] + + return { + "time_range_minutes": minutes, + "sample_count": len(recent_snapshots), + "cpu": { + "min": min(cpu_values), + "max": max(cpu_values), + "mean": statistics.mean(cpu_values), + "current": recent_snapshots[-1].cpu_percent, + }, + "memory": { + "min": min(memory_values), + "max": max(memory_values), + "mean": statistics.mean(memory_values), + "current": recent_snapshots[-1].memory_percent, + }, + "latest_snapshot": recent_snapshots[-1], + } + + def get_all_metrics_summary(self) -> Dict[str, Any]: + """ + 获取所有指标摘要 - 合并统计收集器和系统监控数据 + Get all metrics summary - merge stats collector and system monitoring data + + Returns: + Dict[str, Any]: 指标摘要 + """ + # 从统计收集器获取通用统计 + stats_summary = self._stats_collector.get_all_stats() + + # 添加系统监控数据 + summary = { + **stats_summary, + "system": self.get_system_stats(), + "collection_time": time.time(), + } + + return summary + + +class PerformanceTimer: + """ + 性能计时器上下文管理器 + Performance Timer Context Manager + """ + + def __init__( + self, collector: MetricCollector, name: str, tags: Dict[str, str] = None + ): + """ + 初始化计时器 + Initialize timer + + Args: + collector: 指标收集器 + name: 计时器名称 + tags: 标签 + """ + self.collector = collector + self.name = name + self.tags = tags or {} + self.start_time = None + + def __enter__(self): + self.start_time = time.perf_counter() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.start_time is not None: + duration = time.perf_counter() - self.start_time + self.collector.record_timer(self.name, duration, self.tags) + # 忽略异常参数,不需要处理 + return False + + +def timer_decorator( + collector: MetricCollector, name: str = None, tags: Dict[str, str] = None +): + """ + 计时器装饰器 + Timer decorator + + Args: + collector: 指标收集器 + name: 计时器名称 + tags: 标签 + """ + + def decorator(func: Callable) -> Callable: + timer_name = name or f"{func.__module__}.{func.__name__}" + + def wrapper(*args, **kwargs): + with PerformanceTimer(collector, timer_name, tags): + return func(*args, **kwargs) + + return wrapper + + return decorator + + +# 使用统一的单例管理器 +from ..core import register_singleton + + +@register_singleton("metric_collector") +def create_metric_collector() -> MetricCollector: + """创建指标收集器实例""" + return MetricCollector() + + +def get_metric_collector() -> MetricCollector: + """ + 获取全局指标收集器实例 + Get global metric collector instance + + Returns: + MetricCollector: 指标收集器实例 + """ + return create_metric_collector() diff --git a/src/interactive_feedback_server/plugins/__init__.py b/src/interactive_feedback_server/plugins/__init__.py new file mode 100644 index 0000000..8cca108 --- /dev/null +++ b/src/interactive_feedback_server/plugins/__init__.py @@ -0,0 +1,37 @@ +# interactive_feedback_server/plugins/__init__.py + +""" +插件系统模块 +Plugin System Module + +提供插件化架构的核心功能,支持动态加载和热重载。 +Provides core functionality for plugin architecture with dynamic loading and hot reload support. +""" + +from .plugin_interface import ( + PluginInterface, + BasePlugin, + PluginMetadata, + PluginContext, + PluginType, + PluginStatus, + PluginEventHandler, +) + +from .plugin_manager import PluginManager, get_plugin_manager + +__all__ = [ + # 插件接口 + "PluginInterface", + "BasePlugin", + "PluginMetadata", + "PluginContext", + "PluginType", + "PluginStatus", + "PluginEventHandler", + # 插件管理器 + "PluginManager", + "get_plugin_manager", +] + +__version__ = "3.3.0" diff --git a/src/interactive_feedback_server/plugins/builtin/__init__.py b/src/interactive_feedback_server/plugins/builtin/__init__.py new file mode 100644 index 0000000..cc642ff --- /dev/null +++ b/src/interactive_feedback_server/plugins/builtin/__init__.py @@ -0,0 +1,9 @@ +# interactive_feedback_server/plugins/builtin/__init__.py + +""" +内置插件模块 +Built-in Plugins Module + +包含系统内置的插件实现 +Contains built-in plugin implementations +""" diff --git a/src/interactive_feedback_server/plugins/builtin/enhanced_ai_strategy_plugin.py b/src/interactive_feedback_server/plugins/builtin/enhanced_ai_strategy_plugin.py new file mode 100644 index 0000000..d1ec429 --- /dev/null +++ b/src/interactive_feedback_server/plugins/builtin/enhanced_ai_strategy_plugin.py @@ -0,0 +1,368 @@ +# interactive_feedback_server/plugins/builtin/enhanced_ai_strategy_plugin.py + +""" +增强AI策略插件 - V3.3 架构改进版本 +Enhanced AI Strategy Plugin - V3.3 Architecture Improvement Version + +提供增强的AI选项处理策略,作为插件化架构的示例实现。 +Provides enhanced AI option processing strategy as an example implementation of plugin architecture. +""" + +from typing import List, Optional, Dict, Any +from ..plugin_interface import BasePlugin, PluginMetadata, PluginType, PluginContext +from ...utils.option_strategy import BaseOptionStrategy, OptionContext, OptionResult + + +class EnhancedAIStrategy(BaseOptionStrategy): + """ + 增强AI策略 + Enhanced AI Strategy + + 提供比基础AI策略更智能的选项处理 + Provides more intelligent option processing than basic AI strategy + """ + + def __init__(self, config: Dict[str, Any] = None): + """初始化增强AI策略""" + super().__init__( + name="enhanced_ai_options", + priority=0, # 比基础AI策略优先级更高 + min_text_length=1, + max_options=5, + ) + self.config = config or {} + + # 增强功能配置 + self.enable_sentiment_analysis = self.config.get( + "enable_sentiment_analysis", True + ) + self.enable_context_awareness = self.config.get( + "enable_context_awareness", True + ) + self.enable_smart_filtering = self.config.get("enable_smart_filtering", True) + + def is_applicable(self, context: OptionContext) -> bool: + """ + 检查增强AI策略是否适用 + Check if enhanced AI strategy is applicable + """ + # 基础检查 + if not super().is_applicable(context): + return False + + # 检查是否有AI选项 + if not context.ai_options: + return False + + # 检查AI选项质量 + if not self._has_quality_ai_options(context.ai_options): + return False + + return True + + def parse_options(self, context: OptionContext) -> Optional[OptionResult]: + """ + 使用增强逻辑解析AI选项 + Parse AI options using enhanced logic + """ + if not context.ai_options: + return None + + # 智能过滤和增强 + enhanced_options = self._enhance_ai_options(context.ai_options, context) + + if not enhanced_options: + return None + + # 计算增强置信度 + confidence = self._calculate_enhanced_confidence(enhanced_options, context) + + return self.create_result( + options=enhanced_options, + confidence=confidence, + should_stop=True, + source="enhanced_ai", + original_count=len(context.ai_options), + enhanced_count=len(enhanced_options), + sentiment_analyzed=self.enable_sentiment_analysis, + context_aware=self.enable_context_awareness, + ) + + def _has_quality_ai_options(self, ai_options: List[str]) -> bool: + """ + 检查AI选项质量 + Check AI options quality + """ + if not ai_options: + return False + + valid_count = 0 + for option in ai_options: + if isinstance(option, str) and len(option.strip()) >= 2: + valid_count += 1 + + # 至少需要一个高质量选项 + return valid_count > 0 + + def _enhance_ai_options( + self, ai_options: List[str], context: OptionContext + ) -> List[str]: + """ + 增强AI选项 + Enhance AI options + """ + enhanced = [] + + for option in ai_options: + if not isinstance(option, str) or not option.strip(): + continue + + enhanced_option = option.strip() + + # 智能过滤 + if self.enable_smart_filtering: + enhanced_option = self._smart_filter_option(enhanced_option, context) + if not enhanced_option: + continue + + # 情感分析增强 + if self.enable_sentiment_analysis: + enhanced_option = self._sentiment_enhance_option( + enhanced_option, context + ) + + # 上下文感知增强 + if self.enable_context_awareness: + enhanced_option = self._context_enhance_option(enhanced_option, context) + + if enhanced_option and enhanced_option not in enhanced: + enhanced.append(enhanced_option) + + return enhanced[: self.max_options] + + def _smart_filter_option( + self, option: str, context: OptionContext + ) -> Optional[str]: + """ + 智能过滤选项 + Smart filter option + """ + # 过滤过短或过长的选项 + if len(option) < 1 or len(option) > 100: + return None + + # 过滤纯数字或纯符号 + if option.isdigit() or not any(c.isalpha() for c in option): + return None + + # 过滤重复词汇 + words = option.lower().split() + if len(words) > 1 and len(set(words)) == 1: + return None + + return option + + def _sentiment_enhance_option(self, option: str, context: OptionContext) -> str: + """ + 情感分析增强选项 + Sentiment analysis enhance option + """ + # 简单的情感分析(实际应用中可以使用更复杂的NLP库) + positive_words = ["好", "是", "同意", "确定", "yes", "ok", "good"] + negative_words = ["不", "否", "拒绝", "取消", "no", "cancel", "bad"] + + option_lower = option.lower() + + # 根据情感倾向调整选项表达 + if any(word in option_lower for word in positive_words): + # 积极选项,保持原样或稍作优化 + return option + elif any(word in option_lower for word in negative_words): + # 消极选项,保持原样 + return option + else: + # 中性选项,保持原样 + return option + + def _context_enhance_option(self, option: str, context: OptionContext) -> str: + """ + 上下文感知增强选项 + Context-aware enhance option + """ + # 根据输入文本的上下文调整选项 + text_lower = context.text.lower() + + # 问题类文本的选项优化 + if any(word in text_lower for word in ["?", "?", "如何", "怎么", "什么"]): + # 对于问题,确保选项是回答性的 + if not any(word in option.lower() for word in ["是", "不", "yes", "no"]): + return option + + # 确认类文本的选项优化 + if any( + word in text_lower for word in ["确认", "同意", "继续", "confirm", "agree"] + ): + # 对于确认类,优先确认/取消选项 + return option + + return option + + def _calculate_enhanced_confidence( + self, options: List[str], context: OptionContext + ) -> float: + """ + 计算增强置信度 + Calculate enhanced confidence + """ + base_confidence = 0.95 # 增强AI策略基础置信度很高 + + # 根据增强功能调整置信度 + enhancement_bonus = 0.0 + if self.enable_sentiment_analysis: + enhancement_bonus += 0.02 + if self.enable_context_awareness: + enhancement_bonus += 0.02 + if self.enable_smart_filtering: + enhancement_bonus += 0.01 + + # 根据选项质量调整 + quality_score = self._assess_enhanced_quality(options, context) + + final_confidence = (base_confidence + enhancement_bonus) * quality_score + return min(1.0, max(0.0, final_confidence)) + + def _assess_enhanced_quality( + self, options: List[str], context: OptionContext + ) -> float: + """ + 评估增强选项质量 + Assess enhanced option quality + """ + if not options: + return 0.0 + + quality_factors = [] + + for option in options: + # 长度合理性 + length_score = 1.0 + if len(option) < 2: + length_score = 0.5 + elif len(option) > 50: + length_score = 0.8 + + # 内容丰富性 + content_score = 1.0 + if len(option.split()) == 1: + content_score = 0.9 # 单词选项稍微降分 + + # 上下文相关性 + context_score = 1.0 + if context.text: + # 简单的相关性检查 + common_chars = set(option.lower()) & set(context.text.lower()) + if len(common_chars) > 0: + context_score = 1.1 # 有共同字符加分 + + option_quality = length_score * content_score * context_score + quality_factors.append(min(1.2, option_quality)) + + return sum(quality_factors) / len(quality_factors) + + +class EnhancedAIStrategyPlugin(BasePlugin): + """ + 增强AI策略插件 + Enhanced AI Strategy Plugin + + 将增强AI策略封装为插件 + Wraps enhanced AI strategy as a plugin + """ + + def __init__(self, metadata: PluginMetadata): + """初始化插件""" + super().__init__(metadata) + self.strategy: Optional[EnhancedAIStrategy] = None + + def _do_initialize(self) -> bool: + """执行插件初始化""" + try: + # 获取插件配置 + strategy_config = self.get_config("strategy", {}) + + # 创建增强AI策略实例 + self.strategy = EnhancedAIStrategy(strategy_config) + + return True + except Exception as e: + print(f"增强AI策略插件初始化失败: {e}") + return False + + def _do_activate(self) -> bool: + """执行插件激活""" + try: + if not self.strategy: + return False + + # 注册策略到选项解析器 + from ...utils.option_resolver import get_option_resolver + + resolver = get_option_resolver() + resolver.add_strategy(self.strategy) + + return True + except Exception as e: + print(f"增强AI策略插件激活失败: {e}") + return False + + def _do_deactivate(self) -> bool: + """执行插件停用""" + try: + if not self.strategy: + return True + + # 从选项解析器移除策略 + from ...utils.option_resolver import get_option_resolver + + resolver = get_option_resolver() + resolver.remove_strategy(self.strategy.name) + + return True + except Exception as e: + print(f"增强AI策略插件停用失败: {e}") + return False + + def _do_cleanup(self) -> bool: + """执行插件清理""" + try: + self.strategy = None + return True + except Exception as e: + print(f"增强AI策略插件清理失败: {e}") + return False + + def get_strategy(self) -> Optional[EnhancedAIStrategy]: + """获取策略实例""" + return self.strategy + + +# 插件工厂函数 +def create_plugin() -> EnhancedAIStrategyPlugin: + """ + 创建插件实例 + Create plugin instance + + Returns: + EnhancedAIStrategyPlugin: 插件实例 + """ + metadata = PluginMetadata( + name="enhanced_ai_strategy", + version="1.0.0", + description="增强AI选项策略插件,提供智能选项处理功能", + author="Interactive Feedback System", + plugin_type=PluginType.OPTION_STRATEGY, + dependencies=[], + min_system_version="3.3.0", + ) + + return EnhancedAIStrategyPlugin(metadata) diff --git a/src/interactive_feedback_server/plugins/plugin_interface.py b/src/interactive_feedback_server/plugins/plugin_interface.py new file mode 100644 index 0000000..9c3a244 --- /dev/null +++ b/src/interactive_feedback_server/plugins/plugin_interface.py @@ -0,0 +1,488 @@ +# interactive_feedback_server/plugins/plugin_interface.py + +""" +插件接口定义 - V3.3 架构改进版本 +Plugin Interface Definition - V3.3 Architecture Improvement Version + +定义插件系统的核心接口和基础实现,支持热加载和动态扩展。 +Defines core interfaces and base implementations for plugin system with hot loading and dynamic extension support. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, List, Optional, Callable +from dataclasses import dataclass +from enum import Enum +import threading + + +class PluginType(Enum): + """插件类型枚举""" + + OPTION_STRATEGY = "option_strategy" # 选项策略插件 + TEXT_PROCESSOR = "text_processor" # 文本处理插件 + RULE_ENGINE = "rule_engine" # 规则引擎插件 + UI_COMPONENT = "ui_component" # UI组件插件 + DATA_PROCESSOR = "data_processor" # 数据处理插件 + INTEGRATION = "integration" # 第三方集成插件 + + +class PluginStatus(Enum): + """插件状态枚举""" + + UNLOADED = "unloaded" # 未加载 + LOADING = "loading" # 加载中 + LOADED = "loaded" # 已加载 + ACTIVE = "active" # 活跃状态 + INACTIVE = "inactive" # 非活跃状态 + ERROR = "error" # 错误状态 + UNLOADING = "unloading" # 卸载中 + + +@dataclass +class PluginMetadata: + """ + 插件元数据 + Plugin Metadata + """ + + name: str # 插件名称 + version: str # 版本号 + description: str # 描述 + author: str # 作者 + plugin_type: PluginType # 插件类型 + dependencies: List[str] # 依赖列表 + min_system_version: str = "3.3.0" # 最小系统版本 + max_system_version: str = "" # 最大系统版本 + config_schema: Optional[Dict[str, Any]] = None # 配置模式 + permissions: List[str] = None # 权限列表 + + def __post_init__(self): + if self.permissions is None: + self.permissions = [] + + +@dataclass +class PluginContext: + """ + 插件上下文 + Plugin Context + + 提供插件运行时需要的系统信息和服务 + Provides system information and services needed by plugins at runtime + """ + + system_version: str # 系统版本 + config: Dict[str, Any] # 系统配置 + logger: Optional[Any] = None # 日志记录器 + event_bus: Optional[Any] = None # 事件总线 + service_registry: Optional[Dict[str, Any]] = None # 服务注册表 + + def __post_init__(self): + if self.service_registry is None: + self.service_registry = {} + + +class PluginInterface(ABC): + """ + 插件接口抽象基类 + Plugin Interface Abstract Base Class + + 所有插件必须实现的核心接口 + Core interface that all plugins must implement + """ + + def __init__(self, metadata: PluginMetadata): + """ + 初始化插件 + Initialize plugin + + Args: + metadata: 插件元数据 + """ + self.metadata = metadata + self.status = PluginStatus.UNLOADED + self.context: Optional[PluginContext] = None + self._lock = threading.RLock() + + # 插件统计 + self._stats = { + "load_time": 0.0, + "activation_count": 0, + "error_count": 0, + "last_error": None, + } + + @abstractmethod + def initialize(self, context: PluginContext) -> bool: + """ + 初始化插件 + Initialize plugin + + Args: + context: 插件上下文 + + Returns: + bool: 是否初始化成功 + """ + pass + + @abstractmethod + def activate(self) -> bool: + """ + 激活插件 + Activate plugin + + Returns: + bool: 是否激活成功 + """ + pass + + @abstractmethod + def deactivate(self) -> bool: + """ + 停用插件 + Deactivate plugin + + Returns: + bool: 是否停用成功 + """ + pass + + @abstractmethod + def cleanup(self) -> bool: + """ + 清理插件资源 + Cleanup plugin resources + + Returns: + bool: 是否清理成功 + """ + pass + + def get_metadata(self) -> PluginMetadata: + """获取插件元数据""" + return self.metadata + + def get_status(self) -> PluginStatus: + """获取插件状态""" + with self._lock: + return self.status + + def set_status(self, status: PluginStatus) -> None: + """设置插件状态""" + with self._lock: + self.status = status + + def get_stats(self) -> Dict[str, Any]: + """ + 获取插件统计信息 + Get plugin statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + with self._lock: + return { + "name": self.metadata.name, + "version": self.metadata.version, + "type": self.metadata.plugin_type.value, + "status": self.status.value, + "load_time": self._stats["load_time"], + "activation_count": self._stats["activation_count"], + "error_count": self._stats["error_count"], + "last_error": self._stats["last_error"], + } + + def _record_error(self, error: str) -> None: + """记录错误""" + with self._lock: + self._stats["error_count"] += 1 + self._stats["last_error"] = error + self.status = PluginStatus.ERROR + + def __str__(self) -> str: + return f"Plugin({self.metadata.name} v{self.metadata.version}, {self.status.value})" + + +class BasePlugin(PluginInterface): + """ + 基础插件实现 + Base Plugin Implementation + + 提供插件的通用实现基础 + Provides common implementation foundation for plugins + """ + + def __init__(self, metadata: PluginMetadata): + """初始化基础插件""" + super().__init__(metadata) + self._initialized = False + self._active = False + self._config: Dict[str, Any] = {} + + def initialize(self, context: PluginContext) -> bool: + """ + 初始化插件 + Initialize plugin + """ + try: + with self._lock: + if self._initialized: + return True + + import time + + start_time = time.time() + + self.set_status(PluginStatus.LOADING) + self.context = context + + # 验证依赖 + if not self._check_dependencies(): + self._record_error("依赖检查失败") + return False + + # 验证系统版本 + if not self._check_system_version(): + self._record_error("系统版本不兼容") + return False + + # 加载配置 + self._load_config() + + # 执行自定义初始化 + if not self._do_initialize(): + self._record_error("自定义初始化失败") + return False + + self._initialized = True + self.set_status(PluginStatus.LOADED) + + # 记录加载时间 + self._stats["load_time"] = time.time() - start_time + + return True + + except Exception as e: + self._record_error(f"初始化异常: {e}") + return False + + def activate(self) -> bool: + """ + 激活插件 + Activate plugin + """ + try: + with self._lock: + if not self._initialized: + self._record_error("插件未初始化") + return False + + if self._active: + return True + + # 执行自定义激活逻辑 + if not self._do_activate(): + self._record_error("激活失败") + return False + + self._active = True + self.set_status(PluginStatus.ACTIVE) + self._stats["activation_count"] += 1 + + return True + + except Exception as e: + self._record_error(f"激活异常: {e}") + return False + + def deactivate(self) -> bool: + """ + 停用插件 + Deactivate plugin + """ + try: + with self._lock: + if not self._active: + return True + + # 执行自定义停用逻辑 + if not self._do_deactivate(): + self._record_error("停用失败") + return False + + self._active = False + self.set_status(PluginStatus.INACTIVE) + + return True + + except Exception as e: + self._record_error(f"停用异常: {e}") + return False + + def cleanup(self) -> bool: + """ + 清理插件资源 + Cleanup plugin resources + """ + try: + with self._lock: + self.set_status(PluginStatus.UNLOADING) + + # 先停用 + if self._active: + self.deactivate() + + # 执行自定义清理逻辑 + if not self._do_cleanup(): + self._record_error("清理失败") + return False + + self._initialized = False + self.set_status(PluginStatus.UNLOADED) + + return True + + except Exception as e: + self._record_error(f"清理异常: {e}") + return False + + def _check_dependencies(self) -> bool: + """检查依赖""" + # 基础实现:假设所有依赖都满足 + # 子类可以重写此方法实现具体的依赖检查 + return True + + def _check_system_version(self) -> bool: + """检查系统版本兼容性""" + if not self.context: + return False + + system_version = self.context.system_version + min_version = self.metadata.min_system_version + max_version = self.metadata.max_system_version + + # 简单的版本比较(实际应用中可能需要更复杂的版本比较逻辑) + if min_version and system_version < min_version: + return False + + if max_version and system_version > max_version: + return False + + return True + + def _load_config(self) -> None: + """加载插件配置""" + if self.context and self.context.config: + plugin_config_key = f"plugins.{self.metadata.name}" + self._config = self.context.config.get(plugin_config_key, {}) + + def get_config(self, key: str, default: Any = None) -> Any: + """ + 获取配置值 + Get configuration value + + Args: + key: 配置键 + default: 默认值 + + Returns: + Any: 配置值 + """ + return self._config.get(key, default) + + def is_active(self) -> bool: + """检查插件是否活跃""" + with self._lock: + return self._active + + def is_initialized(self) -> bool: + """检查插件是否已初始化""" + with self._lock: + return self._initialized + + # 子类需要实现的方法 + def _do_initialize(self) -> bool: + """执行自定义初始化逻辑""" + return True + + def _do_activate(self) -> bool: + """执行自定义激活逻辑""" + return True + + def _do_deactivate(self) -> bool: + """执行自定义停用逻辑""" + return True + + def _do_cleanup(self) -> bool: + """执行自定义清理逻辑""" + return True + + +class PluginEventHandler: + """ + 插件事件处理器 + Plugin Event Handler + + 处理插件生命周期事件 + Handles plugin lifecycle events + """ + + def __init__(self): + self._handlers: Dict[str, List[Callable]] = {} + self._lock = threading.RLock() + + def register_handler(self, event: str, handler: Callable) -> None: + """ + 注册事件处理器 + Register event handler + + Args: + event: 事件名称 + handler: 处理函数 + """ + with self._lock: + if event not in self._handlers: + self._handlers[event] = [] + self._handlers[event].append(handler) + + def unregister_handler(self, event: str, handler: Callable) -> bool: + """ + 注销事件处理器 + Unregister event handler + + Args: + event: 事件名称 + handler: 处理函数 + + Returns: + bool: 是否成功注销 + """ + with self._lock: + if event in self._handlers and handler in self._handlers[event]: + self._handlers[event].remove(handler) + return True + return False + + def emit_event(self, event: str, *args, **kwargs) -> None: + """ + 触发事件 + Emit event + + Args: + event: 事件名称 + *args: 位置参数 + **kwargs: 关键字参数 + """ + with self._lock: + if event in self._handlers: + for handler in self._handlers[event]: + try: + handler(*args, **kwargs) + except Exception as e: + print(f"事件处理器执行失败 {event}: {e}") + + def get_handlers(self, event: str) -> List[Callable]: + """获取事件处理器列表""" + with self._lock: + return self._handlers.get(event, []).copy() diff --git a/src/interactive_feedback_server/plugins/plugin_manager.py b/src/interactive_feedback_server/plugins/plugin_manager.py new file mode 100644 index 0000000..5775195 --- /dev/null +++ b/src/interactive_feedback_server/plugins/plugin_manager.py @@ -0,0 +1,590 @@ +# interactive_feedback_server/plugins/plugin_manager.py + +""" +插件管理器 - V3.3 架构改进版本 +Plugin Manager - V3.3 Architecture Improvement Version + +提供插件的发现、加载、管理和热重载功能。 +Provides plugin discovery, loading, management and hot reload functionality. +""" + +import os +import sys +import importlib +import importlib.util +import threading +from pathlib import Path +from typing import Dict, List, Optional, Type, Any +import json + +from .plugin_interface import ( + PluginInterface, + PluginMetadata, + PluginContext, + PluginType, + PluginStatus, + PluginEventHandler, +) + + +class PluginManager: + """ + 插件管理器 + Plugin Manager + + 负责插件的生命周期管理、发现和加载 + Responsible for plugin lifecycle management, discovery and loading + """ + + def __init__(self, plugin_dirs: List[str] = None, system_version: str = "3.3.0"): + """ + 初始化插件管理器 + Initialize plugin manager + + Args: + plugin_dirs: 插件目录列表 + system_version: 系统版本 + """ + self.system_version = system_version + self.plugin_dirs = plugin_dirs or [] + + # 插件存储 + self._plugins: Dict[str, PluginInterface] = {} + self._plugin_modules: Dict[str, Any] = {} + + # 线程安全 + self._lock = threading.RLock() + + # 事件处理 + self.event_handler = PluginEventHandler() + + # 管理器统计 + self._stats = { + "total_discovered": 0, + "total_loaded": 0, + "total_active": 0, + "total_errors": 0, + "discovery_count": 0, + } + + # 默认插件目录 + self._add_default_plugin_dirs() + + def _add_default_plugin_dirs(self) -> None: + """添加默认插件目录""" + # 当前项目的插件目录 + current_dir = Path(__file__).parent + default_dirs = [ + str(current_dir / "builtin"), # 内置插件 + str(current_dir / "external"), # 外部插件 + str(current_dir / "user"), # 用户插件 + ] + + for dir_path in default_dirs: + if dir_path not in self.plugin_dirs: + self.plugin_dirs.append(dir_path) + + def add_plugin_directory(self, directory: str) -> bool: + """ + 添加插件目录 + Add plugin directory + + Args: + directory: 插件目录路径 + + Returns: + bool: 是否添加成功 + """ + try: + dir_path = Path(directory) + if dir_path.exists() and dir_path.is_dir(): + abs_path = str(dir_path.absolute()) + if abs_path not in self.plugin_dirs: + self.plugin_dirs.append(abs_path) + return True + return False + except Exception: + return False + + def discover_plugins(self) -> List[Dict[str, Any]]: + """ + 发现插件 + Discover plugins + + Returns: + List[Dict[str, Any]]: 发现的插件信息列表 + """ + with self._lock: + self._stats["discovery_count"] += 1 + discovered = [] + + for plugin_dir in self.plugin_dirs: + try: + discovered.extend(self._discover_plugins_in_directory(plugin_dir)) + except Exception as e: + print(f"发现插件失败 {plugin_dir}: {e}") + + self._stats["total_discovered"] = len(discovered) + return discovered + + def _discover_plugins_in_directory(self, directory: str) -> List[Dict[str, Any]]: + """ + 在指定目录中发现插件 + Discover plugins in specified directory + + Args: + directory: 目录路径 + + Returns: + List[Dict[str, Any]]: 发现的插件信息 + """ + discovered = [] + dir_path = Path(directory) + + if not dir_path.exists(): + return discovered + + # 查找插件文件 + for item in dir_path.iterdir(): + if ( + item.is_file() + and item.suffix == ".py" + and not item.name.startswith("_") + ): + # Python文件插件 + plugin_info = self._analyze_python_plugin(item) + if plugin_info: + discovered.append(plugin_info) + elif item.is_dir() and not item.name.startswith("_"): + # 插件包 + plugin_info = self._analyze_plugin_package(item) + if plugin_info: + discovered.append(plugin_info) + + return discovered + + def _analyze_python_plugin(self, file_path: Path) -> Optional[Dict[str, Any]]: + """ + 分析Python插件文件 + Analyze Python plugin file + + Args: + file_path: 插件文件路径 + + Returns: + Optional[Dict[str, Any]]: 插件信息 + """ + try: + # 读取文件内容查找插件元数据 + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # 简单的元数据提取(实际应用中可能需要更复杂的解析) + if "PluginInterface" in content or "BasePlugin" in content: + return { + "type": "python_file", + "path": str(file_path), + "name": file_path.stem, + "discovered_at": self._get_current_timestamp(), + } + except Exception: + pass + + return None + + def _analyze_plugin_package(self, package_path: Path) -> Optional[Dict[str, Any]]: + """ + 分析插件包 + Analyze plugin package + + Args: + package_path: 插件包路径 + + Returns: + Optional[Dict[str, Any]]: 插件信息 + """ + try: + # 查找插件清单文件 + manifest_file = package_path / "plugin.json" + if manifest_file.exists(): + with open(manifest_file, "r", encoding="utf-8") as f: + manifest = json.load(f) + + return { + "type": "plugin_package", + "path": str(package_path), + "name": manifest.get("name", package_path.name), + "manifest": manifest, + "discovered_at": self._get_current_timestamp(), + } + + # 查找__init__.py文件 + init_file = package_path / "__init__.py" + if init_file.exists(): + return { + "type": "python_package", + "path": str(package_path), + "name": package_path.name, + "discovered_at": self._get_current_timestamp(), + } + except Exception: + pass + + return None + + def load_plugin(self, plugin_path: str, plugin_name: str = None) -> bool: + """ + 加载插件 + Load plugin + + Args: + plugin_path: 插件路径 + plugin_name: 插件名称 + + Returns: + bool: 是否加载成功 + """ + with self._lock: + try: + if plugin_name is None: + plugin_name = Path(plugin_path).stem + + # 检查是否已加载 + if plugin_name in self._plugins: + return True + + # 加载插件模块 + plugin_module = self._load_plugin_module(plugin_path, plugin_name) + if not plugin_module: + return False + + # 查找插件类 + plugin_class = self._find_plugin_class(plugin_module) + if not plugin_class: + print(f"未找到插件类: {plugin_name}") + return False + + # 创建插件实例 + plugin_instance = self._create_plugin_instance( + plugin_class, plugin_name + ) + if not plugin_instance: + return False + + # 初始化插件 + context = self._create_plugin_context() + if not plugin_instance.initialize(context): + print(f"插件初始化失败: {plugin_name}") + return False + + # 注册插件 + self._plugins[plugin_name] = plugin_instance + self._plugin_modules[plugin_name] = plugin_module + + self._stats["total_loaded"] += 1 + + # 触发事件 + self.event_handler.emit_event( + "plugin_loaded", plugin_name, plugin_instance + ) + + return True + + except Exception as e: + print(f"加载插件失败 {plugin_name}: {e}") + self._stats["total_errors"] += 1 + return False + + def _load_plugin_module(self, plugin_path: str, plugin_name: str) -> Optional[Any]: + """ + 加载插件模块 + Load plugin module + + Args: + plugin_path: 插件路径 + plugin_name: 插件名称 + + Returns: + Optional[Any]: 插件模块 + """ + try: + path_obj = Path(plugin_path) + + if path_obj.is_file() and path_obj.suffix == ".py": + # 加载Python文件 + spec = importlib.util.spec_from_file_location(plugin_name, plugin_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + elif path_obj.is_dir(): + # 加载Python包 + if str(path_obj.parent) not in sys.path: + sys.path.insert(0, str(path_obj.parent)) + + module = importlib.import_module(path_obj.name) + return module + + except Exception as e: + print(f"加载插件模块失败 {plugin_name}: {e}") + + return None + + def _find_plugin_class(self, module: Any) -> Optional[Type[PluginInterface]]: + """ + 在模块中查找插件类 + Find plugin class in module + + Args: + module: 插件模块 + + Returns: + Optional[Type[PluginInterface]]: 插件类 + """ + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, PluginInterface) + and attr != PluginInterface + ): + return attr + return None + + def _create_plugin_instance( + self, plugin_class: Type[PluginInterface], plugin_name: str + ) -> Optional[PluginInterface]: + """ + 创建插件实例 + Create plugin instance + + Args: + plugin_class: 插件类 + plugin_name: 插件名称 + + Returns: + Optional[PluginInterface]: 插件实例 + """ + try: + # 创建默认元数据(实际应用中应该从插件中读取) + metadata = PluginMetadata( + name=plugin_name, + version="1.0.0", + description=f"Plugin: {plugin_name}", + author="Unknown", + plugin_type=PluginType.INTEGRATION, + dependencies=[], + ) + + return plugin_class(metadata) + except Exception as e: + print(f"创建插件实例失败 {plugin_name}: {e}") + return None + + def _create_plugin_context(self) -> PluginContext: + """ + 创建插件上下文 + Create plugin context + + Returns: + PluginContext: 插件上下文 + """ + return PluginContext( + system_version=self.system_version, config={}, service_registry={} + ) + + def unload_plugin(self, plugin_name: str) -> bool: + """ + 卸载插件 + Unload plugin + + Args: + plugin_name: 插件名称 + + Returns: + bool: 是否卸载成功 + """ + with self._lock: + if plugin_name not in self._plugins: + return False + + try: + plugin = self._plugins[plugin_name] + + # 清理插件 + if not plugin.cleanup(): + print(f"插件清理失败: {plugin_name}") + + # 移除插件 + del self._plugins[plugin_name] + if plugin_name in self._plugin_modules: + del self._plugin_modules[plugin_name] + + self._stats["total_loaded"] -= 1 + if plugin.get_status() == PluginStatus.ACTIVE: + self._stats["total_active"] -= 1 + + # 触发事件 + self.event_handler.emit_event("plugin_unloaded", plugin_name) + + return True + + except Exception as e: + print(f"卸载插件失败 {plugin_name}: {e}") + return False + + def activate_plugin(self, plugin_name: str) -> bool: + """ + 激活插件 + Activate plugin + + Args: + plugin_name: 插件名称 + + Returns: + bool: 是否激活成功 + """ + with self._lock: + if plugin_name not in self._plugins: + return False + + plugin = self._plugins[plugin_name] + if plugin.activate(): + if plugin.get_status() == PluginStatus.ACTIVE: + self._stats["total_active"] += 1 + + # 触发事件 + self.event_handler.emit_event("plugin_activated", plugin_name, plugin) + return True + + return False + + def deactivate_plugin(self, plugin_name: str) -> bool: + """ + 停用插件 + Deactivate plugin + + Args: + plugin_name: 插件名称 + + Returns: + bool: 是否停用成功 + """ + with self._lock: + if plugin_name not in self._plugins: + return False + + plugin = self._plugins[plugin_name] + was_active = plugin.get_status() == PluginStatus.ACTIVE + + if plugin.deactivate(): + if was_active: + self._stats["total_active"] -= 1 + + # 触发事件 + self.event_handler.emit_event("plugin_deactivated", plugin_name, plugin) + return True + + return False + + def get_plugin(self, plugin_name: str) -> Optional[PluginInterface]: + """ + 获取插件实例 + Get plugin instance + + Args: + plugin_name: 插件名称 + + Returns: + Optional[PluginInterface]: 插件实例 + """ + with self._lock: + return self._plugins.get(plugin_name) + + def get_all_plugins(self) -> Dict[str, PluginInterface]: + """ + 获取所有插件 + Get all plugins + + Returns: + Dict[str, PluginInterface]: 所有插件 + """ + with self._lock: + return self._plugins.copy() + + def get_plugins_by_type(self, plugin_type: PluginType) -> List[PluginInterface]: + """ + 按类型获取插件 + Get plugins by type + + Args: + plugin_type: 插件类型 + + Returns: + List[PluginInterface]: 指定类型的插件列表 + """ + with self._lock: + return [ + plugin + for plugin in self._plugins.values() + if plugin.metadata.plugin_type == plugin_type + ] + + def get_active_plugins(self) -> List[PluginInterface]: + """ + 获取活跃插件 + Get active plugins + + Returns: + List[PluginInterface]: 活跃插件列表 + """ + with self._lock: + return [ + plugin + for plugin in self._plugins.values() + if plugin.get_status() == PluginStatus.ACTIVE + ] + + def get_manager_stats(self) -> Dict[str, Any]: + """ + 获取管理器统计信息 + Get manager statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + with self._lock: + plugin_stats = {} + for name, plugin in self._plugins.items(): + plugin_stats[name] = plugin.get_stats() + + return { + "manager_stats": self._stats.copy(), + "plugin_directories": self.plugin_dirs.copy(), + "system_version": self.system_version, + "plugins": plugin_stats, + } + + def _get_current_timestamp(self) -> float: + """获取当前时间戳""" + import time + + return time.time() + + +# 全局插件管理器实例 +_global_plugin_manager: Optional[PluginManager] = None + + +def get_plugin_manager() -> PluginManager: + """ + 获取全局插件管理器实例 + Get global plugin manager instance + + Returns: + PluginManager: 插件管理器实例 + """ + global _global_plugin_manager + if _global_plugin_manager is None: + _global_plugin_manager = PluginManager() + return _global_plugin_manager diff --git a/src/interactive_feedback_server/utils/__init__.py b/src/interactive_feedback_server/utils/__init__.py new file mode 100644 index 0000000..798ff3d --- /dev/null +++ b/src/interactive_feedback_server/utils/__init__.py @@ -0,0 +1,68 @@ +""" +Interactive Feedback Server Utils + +工具模块,包含配置管理、规则引擎等核心功能。 +Utility modules containing configuration management, rule engine and other core features. +""" + +# 导出主要功能模块 +from .config_manager import ( + get_config, + save_config, + validate_config, + get_display_mode, + get_fallback_options, + # V4.1 简化:自定义选项开关 + get_custom_options_enabled, + set_custom_options_enabled, +) +from .rule_engine import ( + resolve_final_options, + # V4.0 简化:保留核心选项解析功能 +) + +# V3.2 优化:新增配置辅助工具 +from .config_helpers import ( + safe_get_config, + safe_get_feature_states, + safe_get_fallback_options, + handle_config_error, + safe_config_operation, +) + +# V3.2 Day 3 优化:新增文本处理工具 - V4.1 精简版本 +from .text_processor import ( + fast_normalize_text, + fast_extract_keywords, + fast_find_match, + get_text_processor, + get_optimized_matcher, + # V4.1 移除:get_text_processing_stats未使用 +) + +__all__ = [ + "get_config", + "save_config", + "validate_config", + "get_display_mode", + "get_fallback_options", + "filter_valid_options", # 新增:公共过滤函数 + # V4.1 简化:自定义选项开关 + "get_custom_options_enabled", + "set_custom_options_enabled", + # 文本处理工具 - V4.1 精简版本 + "fast_normalize_text", + "fast_extract_keywords", + "fast_find_match", + "get_text_processor", + "get_optimized_matcher", + # V4.1 移除:get_text_processing_stats未使用 + "resolve_final_options", + # V4.1 简化:保留核心功能 + # 配置辅助工具 + "safe_get_config", + "safe_get_feature_states", + "safe_get_fallback_options", + "handle_config_error", + "safe_config_operation", +] diff --git a/src/interactive_feedback_server/utils/config_helpers.py b/src/interactive_feedback_server/utils/config_helpers.py new file mode 100644 index 0000000..74aad65 --- /dev/null +++ b/src/interactive_feedback_server/utils/config_helpers.py @@ -0,0 +1,179 @@ +# src/interactive_feedback_server/utils/config_helpers.py +""" +配置获取辅助工具 (V3.2 写时复制优化版本) +Configuration helper utilities (V3.2 Copy-on-Write Optimized Version) + +提供统一的配置获取和错误处理逻辑,减少代码重复。 +使用写时复制配置对象优化内存使用和性能。 + +Provides unified configuration retrieval and error handling logic to reduce code duplication. +Uses copy-on-write configuration objects to optimize memory usage and performance. +""" + +from typing import Dict, Any, Optional, Tuple, Callable +from .config_manager import ( + get_config, + get_display_mode, + get_fallback_options, + get_custom_options_enabled, + DEFAULT_CONFIG, +) +from .list_optimizer import smart_extend, smart_merge + +# 已删除未使用的统一配置加载器导入 + + +def safe_get_config() -> Tuple[Dict[str, Any], str]: + """ + 安全获取配置,包含错误处理 (传统版本) + Safely get configuration with error handling (legacy version) + + Returns: + Tuple[Dict[str, Any], str]: (配置字典, 当前显示模式) + """ + try: + config = get_config() + current_mode = get_display_mode(config) + return config, current_mode + except Exception as e: + print(f"获取配置失败,使用默认值: {e}") + # 使用统一的默认配置 + return DEFAULT_CONFIG.copy(), DEFAULT_CONFIG["display_mode"] + + +# 已移除 safe_get_cow_config - 使用新的统一配置加载器替代 + + +def safe_get_feature_states( + config: Optional[Dict[str, Any]] = None, +) -> Tuple[bool, bool]: + """ + 安全获取功能开关状态 - V4.0 简化版本 + Safely get feature toggle states - V4.0 Simplified Version + + Args: + config: 可选的配置字典,如果为None则自动获取 + + Returns: + Tuple[bool, bool]: (规则引擎启用状态[已移除,始终False], 自定义选项启用状态) + """ + try: + if config is None: + config, _ = safe_get_config() + + # V4.0 简化:规则引擎已移除,始终返回False + rule_engine_enabled = False + custom_options_enabled = get_custom_options_enabled(config) + return rule_engine_enabled, custom_options_enabled + except Exception as e: + print(f"获取功能开关状态失败,使用默认值: {e}") + return False, False # 默认都禁用,与DEFAULT_CONFIG保持一致 + + +def safe_get_fallback_options(config: Optional[Dict[str, Any]] = None) -> list: + """ + 安全获取后备选项 - 简化版本,直接使用config_manager + Safely get fallback options - simplified version using config_manager + + Args: + config: 可选的配置字典,如果为None则自动获取 + + Returns: + list: 后备选项列表 + """ + try: + # 直接使用config_manager的函数,避免重复逻辑 + return get_fallback_options(config) + except Exception as e: + print(f"获取后备选项失败,使用默认值: {e}") + from .config_manager import filter_valid_options + + return filter_valid_options(DEFAULT_CONFIG["fallback_options"]) + + +def handle_config_error( + operation: str, error: Exception, default_value: Any = None +) -> Any: + """ + 统一的配置错误处理 + Unified configuration error handling + + Args: + operation: 操作描述 + error: 异常对象 + default_value: 默认返回值 + + Returns: + Any: 默认值或None + """ + print(f"{operation}失败: {error}") + return default_value + + +def safe_config_operation( + operation_func: Callable, operation_name: str, default_value: Any = None +) -> Any: + """ + 安全执行配置操作的通用函数 + Generic function to safely execute configuration operations + + Args: + operation_func: 要执行的操作函数 + operation_name: 操作名称(用于错误信息) + default_value: 操作失败时的默认返回值 + + Returns: + Any: 操作结果或默认值 + """ + try: + return operation_func() + except Exception as e: + return handle_config_error(operation_name, e, default_value) + + +def merge_config_options(*option_lists: list, remove_duplicates: bool = True) -> list: + """ + 合并多个配置选项列表 (V3.2 优化版本) + Merge multiple configuration option lists (V3.2 Optimized Version) + + Args: + *option_lists: 要合并的选项列表 + remove_duplicates: 是否移除重复项 + + Returns: + list: 合并后的选项列表 + """ + return smart_merge( + *option_lists, remove_duplicates=remove_duplicates, preserve_order=True + ) + + +# 已移除 create_config_hierarchy - 使用新的统一配置加载器替代 + + +def get_config_stats() -> Dict[str, Any]: + """ + 获取配置系统统计信息 - 简化版本 + Get configuration system statistics - simplified version + + Returns: + Dict[str, Any]: 统计信息 + """ + try: + # 使用主配置管理器的统计信息 + config = get_config() + + return { + "default_config_size": len(DEFAULT_CONFIG), + "current_config_size": len(config), + "optimization_enabled": True, + "list_optimizer_available": True, + "version": "V4.1-Simplified", + } + except Exception as e: + return { + "error": str(e), + "optimization_enabled": False, + "list_optimizer_available": False, + "version": "V4.1-Error", + } diff --git a/src/interactive_feedback_server/utils/config_manager.py b/src/interactive_feedback_server/utils/config_manager.py new file mode 100644 index 0000000..d5a4855 --- /dev/null +++ b/src/interactive_feedback_server/utils/config_manager.py @@ -0,0 +1,416 @@ +# src/interactive_feedback_server/utils/config_manager.py +""" +配置管理器 - V3.2 性能优化版本 +Configuration Manager - V3.2 Performance Optimized Version + +V3.2 新增:支持显示模式配置和功能开关 +V3.2 New: Support for display mode configuration and feature toggles + +V3.2 性能优化:集成配置缓存机制,显著提升配置获取速度 +V3.2 Performance Optimization: Integrated configuration caching for significant speed improvement +""" + +import os +import sys +import json +from typing import Dict, Any, List +from datetime import datetime + + +# 配置文件路径 - 简化路径选择 +def _get_config_file_path() -> str: + """ + 简化的配置文件路径选择,支持开发模式和生产模式 + Simplified config file path selection for development and production modes + + 优先级: + 1. 项目根目录 config.json(开发模式优先) + 2. 用户主目录 ~/.interactive-feedback/config.json(uvx安装) + """ + # 1. 项目根目录(开发模式优先) + try: + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + ) + project_config_path = os.path.join(project_root, "config.json") + + # 如果项目目录可写,使用项目配置 + if os.access(project_root, os.W_OK): + return project_config_path + except Exception: + pass + + # 2. 用户主目录(uvx安装回退) + try: + user_config_dir = os.path.expanduser("~/.interactive-feedback") + os.makedirs(user_config_dir, exist_ok=True) + return os.path.join(user_config_dir, "config.json") + except Exception: + # 最后的回退选项 + return os.path.expanduser("~/.interactive-feedback/config.json") + + +CONFIG_FILE_PATH = _get_config_file_path() + +# 出厂默认配置 - V4.2 用户友好版本 +DEFAULT_CONFIG = { + "display_mode": "full", # V4.2 改为默认完整模式 + "enable_custom_options": True, # V4.4 修复:默认启用自定义选项,解决uv安装用户看不到预定义选项的问题 + "submit_method": "enter", # V4.3 新增:提交方式设置 ('enter' 或 'ctrl_enter') + "fallback_options": [ + "好的,我明白了", + "请继续", + "需要更多信息", + "返回上一步", + "暂停,让我思考一下", + ], + "expression_optimizer": { + "enabled": True, # V4.2 改为默认启用,提升用户体验 + "active_provider": "openai", + "prompts": { + "optimize": "你是一个专业的文本优化助手。请将用户的输入文本改写为结构化、逻辑清晰的指令。只需要输出优化后的文本,不要包含任何技术参数、函数定义或元数据信息。", + "reinforce": "你是一个指令执行助手。请严格按照用户提供的'强化指令',对用户提供的'原始文本'进行处理和改写。只输出改写结果,不要包含任何技术信息。", + }, + "performance": { + "timeout_seconds": 30, + "max_retries": 3, + "retry_delay_seconds": 1, + "rate_limit_requests_per_minute": 60, + }, + "providers": {}, # 空的提供商配置,用户配置后填充 + }, + "version": "3.2", + "created_at": datetime.now().isoformat() + "Z", + "updated_at": datetime.now().isoformat() + "Z", +} + + +# 环境变量 API key 配置功能已移除 +# 现在用户只能通过 UI 设置页面管理 API key,避免配置冲突 + + +# _merge_env_config 函数已移除,不再支持环境变量配置合并 + + +def validate_config(config: Dict[str, Any]) -> bool: + """ + 验证配置文件的有效性 + Validate configuration file validity + + Args: + config: 配置字典 + + Returns: + bool: 配置是否有效 + """ + try: + # 检查必需字段 + if "display_mode" not in config: + return False + if "fallback_options" not in config: + return False + + # V4.0 简化:检查自定义选项控制字段(可选,有默认值) + if "enable_custom_options" in config: + if not isinstance(config["enable_custom_options"], bool): + return False + + # V4.3 新增:验证提交方式字段(可选,有默认值) + if "submit_method" in config: + if config["submit_method"] not in ["enter", "ctrl_enter"]: + return False + + # 验证display_mode值 + if config["display_mode"] not in ["simple", "full"]: + return False + + # 验证fallback_options + fallback_options = config["fallback_options"] + if not isinstance(fallback_options, list): + return False + if len(fallback_options) != 5: + return False + + # 验证每个选项(允许占位符存在) + for option in fallback_options: + if not isinstance(option, str): + return False + if len(option) > 50: # 字符长度限制 + return False + # 允许占位符和空字符串存在,由过滤函数处理 + + return True + + except Exception as e: + print(f"配置验证异常 (Config validation error): {e}", file=sys.stderr) + return False + + +def get_config() -> Dict[str, Any]: + """ + 安全地读取并解析配置文件,与出厂默认值合并 + Safely read and parse config file, merge with factory defaults + + 配置优先级:配置文件 > 默认配置 + + Returns: + Dict[str, Any]: 合并后的配置字典 + """ + return _load_config_with_fallback() + + +def _load_config_with_fallback() -> Dict[str, Any]: + """ + 简化的配置加载逻辑 + Simplified config loading logic + + 配置优先级:配置文件 > 默认配置 + + Returns: + Dict[str, Any]: 配置字典 + """ + # 从默认配置开始 + config = DEFAULT_CONFIG.copy() + + try: + # 1. 检查配置文件是否存在 + if not os.path.exists(CONFIG_FILE_PATH): + print( + f"配置文件不存在,使用默认配置 (Config file not found, using defaults): {CONFIG_FILE_PATH}", + file=sys.stderr, + ) + return config + + # 2. 读取配置文件 + with open(CONFIG_FILE_PATH, "r", encoding="utf-8") as f: + content = f.read().strip() + if not content: + print( + "配置文件为空,使用默认配置 (Config file empty, using defaults)", + file=sys.stderr, + ) + return config + + user_config = json.loads(content) + + # 3. 验证用户配置 + if not validate_config(user_config): + print( + "配置文件无效,使用默认配置 (Invalid config file, using defaults)", + file=sys.stderr, + ) + return config + + # 4. 合并配置:默认配置 <- 文件配置 + config.update(user_config) + + # 5. 更新时间戳 + config["updated_at"] = datetime.now().isoformat() + "Z" + + return config + + except json.JSONDecodeError as e: + print( + f"配置文件JSON解析失败,使用默认配置 (JSON parse error, using defaults): {e}", + file=sys.stderr, + ) + return config + except Exception as e: + print( + f"读取配置文件失败,使用默认配置 (Failed to read config, using defaults): {e}", + file=sys.stderr, + ) + return config + + +def save_config(config: Dict[str, Any]) -> bool: + """ + 保存配置到文件 (V3.2 缓存优化版本) + Save configuration to file (V3.2 Cached Version) + + V3.2 性能优化: + - 保存后自动清除缓存,确保下次读取最新配置 + - 支持缓存失效通知 + + Args: + config: 要保存的配置字典 + + Returns: + bool: 保存是否成功 + """ + try: + # 验证配置 + if not validate_config(config): + print("配置无效,无法保存 (Invalid config, cannot save)", file=sys.stderr) + return False + + # 更新时间戳 + config["updated_at"] = datetime.now().isoformat() + "Z" + + # 确保目录存在 + config_dir = os.path.dirname(CONFIG_FILE_PATH) + if not os.path.exists(config_dir): + os.makedirs(config_dir, exist_ok=True) + + # 保存配置文件 + with open(CONFIG_FILE_PATH, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + print(f"配置已保存 (Config saved): {CONFIG_FILE_PATH}") + return True + + except Exception as e: + print(f"保存配置失败 (Failed to save config): {e}", file=sys.stderr) + return False + + +# 占位符常量定义 +PLACEHOLDER_VALUES = ["请输入选项", "null"] + + +def filter_valid_options(options: List[str]) -> List[str]: + """ + 过滤有效选项,移除占位符和空值 + Filter valid options, remove placeholders and empty values + + Args: + options: 原始选项列表 + + Returns: + List[str]: 过滤后的有效选项列表 + """ + filtered_options = [] + for option in options: + if isinstance(option, str): + option = option.strip() + if option and option not in PLACEHOLDER_VALUES: + filtered_options.append(option) + return filtered_options + + +def get_fallback_options(config: Dict[str, Any] = None) -> List[str]: + """ + 获取后备选项列表(过滤空选项) + Get fallback options list (filter empty options) + + Args: + config: 配置字典,如果为None则自动读取 + + Returns: + List[str]: 后备选项列表(已过滤空选项) + """ + if config is None: + config = get_config() + + options = config.get("fallback_options", DEFAULT_CONFIG["fallback_options"]) + return filter_valid_options(options) + + +def safe_get_fallback_options(config: Dict[str, Any] = None) -> List[str]: + """ + 安全获取后备选项列表(确保至少有一个有效选项) + Safely get fallback options list (ensure at least one valid option) + + Args: + config: 配置字典,如果为None则自动读取 + + Returns: + List[str]: 后备选项列表(确保非空) + """ + options = get_fallback_options(config) + + # 如果没有有效选项,返回默认选项 + if not options: + return DEFAULT_CONFIG["fallback_options"] + + return options + + +def get_display_mode(config: Dict[str, Any] = None) -> str: + """ + 获取显示模式 + Get display mode + + Args: + config: 配置字典,如果为None则自动读取 + + Returns: + str: 显示模式 ("simple" 或 "full") + """ + if config is None: + config = get_config() + + return config.get("display_mode", DEFAULT_CONFIG["display_mode"]) + + +# V4.0 移除:get_rule_engine_enabled 函数已删除 + + +def get_custom_options_enabled(config: Dict[str, Any] = None) -> bool: + """ + 获取自定义选项启用状态 + Get custom options enabled status + + Args: + config: 配置字典,如果为None则自动读取 + + Returns: + bool: 是否启用自定义选项 + """ + if config is None: + config = get_config() + + return get_feature_enabled( + config, "enable_custom_options", DEFAULT_CONFIG["enable_custom_options"] + ) + + +# V4.0 移除:set_rule_engine_enabled 函数已删除 + + +def set_custom_options_enabled(enabled: bool) -> bool: + """ + 设置自定义选项启用状态 + Set custom options enabled status + + Args: + enabled: 是否启用自定义选项 + + Returns: + bool: 设置是否成功 + """ + config = get_config() + config["enable_custom_options"] = enabled + return save_config(config) + + +def get_feature_enabled( + config: Dict[str, Any], feature_key: str, default: bool = True +) -> bool: + """ + 统一的功能启用状态检查工具 + Unified feature enabled status check utility + + Args: + config: 配置字典 + feature_key: 功能配置键 + default: 默认值 + + Returns: + bool: 是否启用该功能 + """ + if not config or feature_key not in config: + return default + + value = config[feature_key] + if isinstance(value, bool): + return value + elif isinstance(value, str): + return value.lower() in ["true", "1", "yes", "on", "enabled"] + elif isinstance(value, int): + return value != 0 + + return default + + +# V4.1 简化:移除复杂的缓存管理函数,使用直接的配置加载逻辑 diff --git a/src/interactive_feedback_server/utils/list_optimizer.py b/src/interactive_feedback_server/utils/list_optimizer.py new file mode 100644 index 0000000..df4d617 --- /dev/null +++ b/src/interactive_feedback_server/utils/list_optimizer.py @@ -0,0 +1,241 @@ +# interactive_feedback_server/utils/list_optimizer.py + +""" +列表操作优化工具 +List Operation Optimization Tools + +提供智能的列表操作函数,避免不必要的复制和内存分配, +优化列表合并、扩展等常见操作的性能。 + +Provides intelligent list operation functions to avoid unnecessary +copying and memory allocation, optimizing performance of common +operations like list merging and extension. +""" + +from typing import List, Any, Optional, TypeVar, Callable + +T = TypeVar("T") + + +def smart_extend(target: List[T], source: List[T], in_place: bool = False) -> List[T]: + """ + 智能列表扩展,避免不必要的复制 + Smart list extension, avoiding unnecessary copying + + Args: + target: 目标列表 + source: 源列表 + in_place: 是否就地修改目标列表 + + Returns: + List[T]: 扩展后的列表 + """ + if not source: + return target if in_place else target.copy() + + if not target: + return source.copy() + + if in_place: + target.extend(source) + return target + else: + # 根据大小选择最优策略 + if len(source) == 1: + # 单个元素,使用 + 操作符更高效 + return target + [source[0]] + elif len(target) < len(source): + # 目标较小,复制目标后扩展 + result = target.copy() + result.extend(source) + return result + else: + # 源较小,使用 + 操作符 + return target + source + + +def smart_merge( + *lists: List[T], remove_duplicates: bool = False, preserve_order: bool = True +) -> List[T]: + """ + 智能列表合并,优化内存使用 + Smart list merging, optimizing memory usage + + Args: + *lists: 要合并的列表 + remove_duplicates: 是否移除重复项 + preserve_order: 是否保持顺序 + + Returns: + List[T]: 合并后的列表 + """ + if not lists: + return [] + + # 过滤空列表 + non_empty_lists = [lst for lst in lists if lst] + + if not non_empty_lists: + return [] + + if len(non_empty_lists) == 1: + result = non_empty_lists[0].copy() + else: + # 直接合并,避免不必要的长度计算 + result = [] + for lst in non_empty_lists: + result.extend(lst) + + if remove_duplicates: + if preserve_order: + # 保持顺序的去重 + seen = set() + unique_result = [] + for item in result: + if item not in seen: + seen.add(item) + unique_result.append(item) + return unique_result + else: + # 不保持顺序,使用set更高效 + return list(set(result)) + + return result + + +def smart_filter( + source: List[T], predicate: Callable[[T], bool], in_place: bool = False +) -> List[T]: + """ + 智能列表过滤,优化内存使用 + Smart list filtering, optimizing memory usage + + Args: + source: 源列表 + predicate: 过滤条件函数 + in_place: 是否就地修改(仅当可能时) + + Returns: + List[T]: 过滤后的列表 + """ + if not source: + return [] + + if in_place: + # 就地过滤(从后往前删除以避免索引问题) + for i in range(len(source) - 1, -1, -1): + if not predicate(source[i]): + del source[i] + return source + else: + # 创建新列表 + return [item for item in source if predicate(item)] + + +def smart_deduplicate( + source: List[T], + key_func: Optional[Callable[[T], Any]] = None, + preserve_order: bool = True, +) -> List[T]: + """ + 智能去重,支持自定义键函数 + Smart deduplication with custom key function support + + Args: + source: 源列表 + key_func: 键提取函数 + preserve_order: 是否保持顺序 + + Returns: + List[T]: 去重后的列表 + """ + if not source: + return [] + + if len(source) == 1: + return source.copy() + + if key_func is None: + # 简单去重 + if preserve_order: + seen = set() + result = [] + for item in source: + if item not in seen: + seen.add(item) + result.append(item) + return result + else: + return list(set(source)) + else: + # 基于键函数的去重 + seen_keys = set() + result = [] + + for item in source: + key = key_func(item) + if key not in seen_keys: + seen_keys.add(key) + result.append(item) + + return result + + +def smart_partition( + source: List[T], predicate: Callable[[T], bool] +) -> tuple[List[T], List[T]]: + """ + 智能分区,将列表分为满足和不满足条件的两部分 + Smart partitioning, dividing list into matching and non-matching parts + + Args: + source: 源列表 + predicate: 分区条件函数 + + Returns: + tuple[List[T], List[T]]: (满足条件的列表, 不满足条件的列表) + """ + if not source: + return [], [] + + true_items = [] + false_items = [] + + for item in source: + if predicate(item): + true_items.append(item) + else: + false_items.append(item) + + return true_items, false_items + + +def smart_chunk(source: List[T], chunk_size: int) -> List[List[T]]: + """ + 智能分块,将列表分割为指定大小的块 + Smart chunking, dividing list into chunks of specified size + + Args: + source: 源列表 + chunk_size: 块大小 + + Returns: + List[List[T]]: 分块后的列表 + """ + if not source or chunk_size <= 0: + return [] + + if chunk_size >= len(source): + return [source.copy()] + + chunks = [] + for i in range(0, len(source), chunk_size): + chunks.append(source[i : i + chunk_size]) + + return chunks + + +# V4.1 移除:LazyList类和create_lazy_list函数未在输入优化功能中使用 + + +# V4.1 移除:性能跟踪装饰器未在输入优化功能中使用,简化代码 diff --git a/src/interactive_feedback_server/utils/option_resolver.py b/src/interactive_feedback_server/utils/option_resolver.py new file mode 100644 index 0000000..56493d3 --- /dev/null +++ b/src/interactive_feedback_server/utils/option_resolver.py @@ -0,0 +1,619 @@ +# interactive_feedback_server/utils/option_resolver.py + +""" +选项解析器 - V3.3 架构改进版本 +Option Resolver - V3.3 Architecture Improvement Version + +统一的选项解析器,使用策略模式重构三层逻辑。 +Unified option resolver using strategy pattern to refactor three-layer logic. +""" + +from typing import List, Dict, Any, Optional +from .option_strategy import OptionContext, OptionResult, StrategyChain +from .option_strategies import ( + AIOptionsStrategy, + FallbackOptionsStrategy, +) +from ..monitoring import get_metric_collector, PerformanceTimer +from ..error_handling import get_error_handler, create_error_context, SystemError +from ..core import get_stats_collector, increment_stat, register_singleton + + +class OptionResolver: + """ + 选项解析器 + Option Resolver + + 使用策略链模式实现三层回退逻辑的统一解析 + Implements unified parsing of three-layer fallback logic using strategy chain pattern + """ + + def __init__(self): + """初始化选项解析器""" + self.strategy_chain = StrategyChain() + self._setup_default_strategies() + + # 插件系统集成 + self._plugin_integration_enabled = True + self._initialize_plugin_system() + + # 性能监控集成 + self._monitoring_enabled = True + self._initialize_monitoring() + + # 错误处理集成 + self._error_handling_enabled = True + self._initialize_error_handling() + + # 使用统一的统计收集器 + self.stats_collector = get_stats_collector() + + def _setup_default_strategies(self) -> None: + """ + 设置默认策略链 + Setup default strategy chain + """ + # V4.0 简化:按优先级添加策略(移除规则引擎) + self.strategy_chain.add_strategy(AIOptionsStrategy()) + self.strategy_chain.add_strategy(FallbackOptionsStrategy()) + + def _initialize_plugin_system(self) -> None: + """ + 初始化插件系统 + Initialize plugin system + """ + if not self._plugin_integration_enabled: + return + + try: + # 导入插件管理器 + from ..plugins import get_plugin_manager + + self.plugin_manager = get_plugin_manager() + + # 发现并加载内置插件 + self._load_builtin_plugins() + + except Exception as e: + print(f"插件系统初始化失败: {e}") + self._plugin_integration_enabled = False + + def _load_builtin_plugins(self) -> None: + """ + 加载内置插件 + Load built-in plugins + """ + try: + # 发现插件 + discovered_plugins = self.plugin_manager.discover_plugins() + + # 加载内置插件 + for plugin_info in discovered_plugins: + if "builtin" in plugin_info.get("path", ""): + plugin_path = plugin_info["path"] + plugin_name = plugin_info["name"] + + if self.plugin_manager.load_plugin(plugin_path, plugin_name): + # 激活插件 + self.plugin_manager.activate_plugin(plugin_name) + print(f"已加载内置插件: {plugin_name}") + + except Exception as e: + print(f"加载内置插件失败: {e}") + + def _initialize_monitoring(self) -> None: + """ + 初始化性能监控 + Initialize performance monitoring + """ + if not self._monitoring_enabled: + return + + try: + # 获取指标收集器 + self.metric_collector = get_metric_collector() + + # 初始化监控指标 + self.metric_collector.set_gauge("option_resolver.initialized", 1.0) + + except Exception as e: + print(f"性能监控初始化失败: {e}") + self._monitoring_enabled = False + + def _initialize_error_handling(self) -> None: + """ + 初始化错误处理 + Initialize error handling + """ + if not self._error_handling_enabled: + return + + try: + # 获取错误处理器 + self.error_handler = get_error_handler() + + # 注册恢复函数 + self._register_recovery_functions() + + except Exception as e: + print(f"错误处理初始化失败: {e}") + self._error_handling_enabled = False + + def _register_recovery_functions(self) -> None: + """注册恢复函数""" + try: + from ..error_handling import get_recovery_manager + + recovery_manager = get_recovery_manager() + + # 注册选项解析器恢复函数 + def recover_option_resolver(): + """选项解析器恢复函数""" + try: + # 重新初始化策略链 + self.strategy_chain = StrategyChain() + self._setup_default_strategies() + return True + except Exception: + return False + + recovery_manager.register_recovery_function( + component="option_resolver", + operation="recover", + recovery_function=recover_option_resolver, + priority=2, + max_attempts=3, + timeout=30.0, + ) + + # 注册健康检查 + def health_check(): + """选项解析器健康检查""" + try: + # 简单的健康检查:测试解析功能 + test_result = self.resolve_options("健康检查", None, None, "zh_CN") + return isinstance(test_result, list) + except Exception: + return False + + recovery_manager.register_health_check("option_resolver", health_check) + + except Exception as e: + print(f"注册恢复函数失败: {e}") + + def resolve_options( + self, + text: str, + ai_options: Optional[List[str]] = None, + config: Optional[Dict[str, Any]] = None, + language: str = "zh_CN", + ) -> List[str]: + """ + 解析选项 - V3.3 策略模式版本 + Resolve options - V3.3 Strategy Pattern Version + + Args: + text: 用户输入文本 + ai_options: AI提供的选项 + config: 配置信息 + language: 语言代码 + + Returns: + List[str]: 解析出的选项列表 + """ + # 性能监控 + if self._monitoring_enabled and hasattr(self, "metric_collector"): + self.metric_collector.increment_counter("option_resolver.resolve_requests") + timer = PerformanceTimer( + self.metric_collector, "option_resolver.resolve_duration" + ) + timer.__enter__() + else: + timer = None + + try: + # 统一的统计收集 + self.stats_collector.increment( + "total_resolutions", category="option_resolver" + ) + + # 创建解析上下文 + context = OptionContext( + text=text, + ai_options=ai_options, + config=config, + language=language, + metadata={ + "resolver_version": "V3.3", + "timestamp": self._get_current_timestamp(), + }, + ) + + # 执行策略链 + result = self.strategy_chain.execute(context) + + if result and result.options: + # 统一的成功统计 + self.stats_collector.increment( + "successful_resolutions", category="option_resolver" + ) + self.stats_collector.increment( + f"strategy_usage_{result.strategy_name}", category="option_resolver" + ) + + # 性能监控(如果启用) + if self._monitoring_enabled and hasattr(self, "metric_collector"): + self.metric_collector.increment_counter( + "option_resolver.successful_resolutions" + ) + self.metric_collector.increment_counter( + f"option_resolver.strategy_usage.{result.strategy_name}" + ) + + return result.options + + # 失败统计 + self.stats_collector.increment( + "failed_resolutions", category="option_resolver" + ) + if self._monitoring_enabled and hasattr(self, "metric_collector"): + self.metric_collector.increment_counter( + "option_resolver.failed_resolutions" + ) + + return [] + + except Exception as e: + # 错误处理 + if self._error_handling_enabled and hasattr(self, "error_handler"): + error_context = create_error_context( + component="option_resolver", + operation="resolve_options", + additional_data={ + "text_length": len(text) if text else 0, + "has_ai_options": bool(ai_options), + "language": language, + }, + ) + + recovery_result = self.error_handler.handle_error(e, error_context) + + # 如果有恢复结果,尝试使用 + if recovery_result and recovery_result.get("action") == "fallback": + return ["是的", "不是", "需要更多信息"] # 默认选项 + + # 如果错误处理失败,返回默认选项 + return ["是的", "不是", "需要更多信息"] + + finally: + # 结束性能计时 + if timer: + timer.__exit__(None, None, None) + + def resolve_with_details( + self, + text: str, + ai_options: Optional[List[str]] = None, + config: Optional[Dict[str, Any]] = None, + language: str = "zh_CN", + ) -> Dict[str, Any]: + """ + 解析选项并返回详细信息 + Resolve options and return detailed information + + Args: + text: 用户输入文本 + ai_options: AI提供的选项 + config: 配置信息 + language: 语言代码 + + Returns: + Dict[str, Any]: 包含选项和详细信息的字典 + """ + self._resolution_stats["total_resolutions"] += 1 + + # 创建解析上下文 + context = OptionContext( + text=text, + ai_options=ai_options, + config=config, + language=language, + metadata={ + "resolver_version": "V3.3", + "timestamp": self._get_current_timestamp(), + "detailed_mode": True, + }, + ) + + # 执行策略链 + result = self.strategy_chain.execute(context) + + if result and result.options: + self._resolution_stats["successful_resolutions"] += 1 + + # 更新层级使用统计 + strategy_name = result.strategy_name + if strategy_name in self._resolution_stats["layer_usage"]: + self._resolution_stats["layer_usage"][strategy_name] += 1 + + return { + "options": result.options, + "strategy_used": result.strategy_name, + "confidence": result.confidence, + "metadata": result.metadata, + "context": { + "text_length": len(text), + "has_ai_options": bool(ai_options), + "has_config": bool(config), + "language": language, + }, + "success": True, + } + + # 所有策略都失败 + return { + "options": [], + "strategy_used": None, + "confidence": 0.0, + "metadata": {}, + "context": { + "text_length": len(text), + "has_ai_options": bool(ai_options), + "has_config": bool(config), + "language": language, + }, + "success": False, + "error": "All strategies failed to generate options", + } + + def add_strategy(self, strategy) -> None: + """ + 添加自定义策略 + Add custom strategy + + Args: + strategy: 策略实例 + """ + self.strategy_chain.add_strategy(strategy) + + def remove_strategy(self, name: str) -> bool: + """ + 移除策略 + Remove strategy + + Args: + name: 策略名称 + + Returns: + bool: 是否成功移除 + """ + return self.strategy_chain.remove_strategy(name) + + def enable_strategy(self, name: str) -> bool: + """ + 启用策略 + Enable strategy + + Args: + name: 策略名称 + + Returns: + bool: 是否成功启用 + """ + strategy = self.strategy_chain.get_strategy(name) + if strategy: + strategy.enable() + return True + return False + + def disable_strategy(self, name: str) -> bool: + """ + 禁用策略 + Disable strategy + + Args: + name: 策略名称 + + Returns: + bool: 是否成功禁用 + """ + strategy = self.strategy_chain.get_strategy(name) + if strategy: + strategy.disable() + return True + return False + + def get_strategy_info(self, name: str) -> Optional[Dict[str, Any]]: + """ + 获取策略详细信息 + Get detailed strategy information + + Args: + name: 策略名称 + + Returns: + Optional[Dict[str, Any]]: 策略信息 + """ + strategy = self.strategy_chain.get_strategy(name) + if strategy and hasattr(strategy, "get_strategy_info"): + return strategy.get_strategy_info() + return None + + def get_resolver_stats(self) -> Dict[str, Any]: + """ + 获取解析器统计信息 + Get resolver statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + # 使用统一的统计收集器 + resolver_stats = self.stats_collector.get_category_stats("option_resolver") + chain_stats = self.strategy_chain.get_chain_stats() + + # 计算成功率 + total = resolver_stats.get("count", 0) + successful = self.stats_collector.get_counter("successful_resolutions") + success_rate = (successful / max(total, 1)) * 100 + + # 插件统计 + plugin_stats = {} + if self._plugin_integration_enabled and hasattr(self, "plugin_manager"): + try: + plugin_stats = self.plugin_manager.get_manager_stats() + except Exception: + plugin_stats = {"error": "Failed to get plugin stats"} + + return { + "resolver_stats": { + "total_resolutions": total, + "successful_resolutions": successful, + "failed_resolutions": self.stats_collector.get_counter( + "failed_resolutions" + ), + "success_rate_percent": round(success_rate, 2), + "strategy_usage": { + "ai_options": self.stats_collector.get_counter( + "strategy_usage_ai_options" + ), + "fallback_options": self.stats_collector.get_counter( + "strategy_usage_fallback_options" + ), + }, + }, + "strategy_chain_stats": chain_stats, + "plugin_stats": plugin_stats, + "plugin_integration_enabled": self._plugin_integration_enabled, + "monitoring_enabled": self._monitoring_enabled, + "error_handling_enabled": self._error_handling_enabled, + "version": "V3.3-Optimized", + } + + def load_plugin(self, plugin_path: str, plugin_name: str = None) -> bool: + """ + 加载插件 + Load plugin + + Args: + plugin_path: 插件路径 + plugin_name: 插件名称 + + Returns: + bool: 是否加载成功 + """ + if not self._plugin_integration_enabled or not hasattr(self, "plugin_manager"): + return False + + return self.plugin_manager.load_plugin(plugin_path, plugin_name) + + def activate_plugin(self, plugin_name: str) -> bool: + """ + 激活插件 + Activate plugin + + Args: + plugin_name: 插件名称 + + Returns: + bool: 是否激活成功 + """ + if not self._plugin_integration_enabled or not hasattr(self, "plugin_manager"): + return False + + return self.plugin_manager.activate_plugin(plugin_name) + + def deactivate_plugin(self, plugin_name: str) -> bool: + """ + 停用插件 + Deactivate plugin + + Args: + plugin_name: 插件名称 + + Returns: + bool: 是否停用成功 + """ + if not self._plugin_integration_enabled or not hasattr(self, "plugin_manager"): + return False + + return self.plugin_manager.deactivate_plugin(plugin_name) + + def get_loaded_plugins(self) -> List[str]: + """ + 获取已加载的插件列表 + Get loaded plugins list + + Returns: + List[str]: 插件名称列表 + """ + if not self._plugin_integration_enabled or not hasattr(self, "plugin_manager"): + return [] + + return list(self.plugin_manager.get_all_plugins().keys()) + + def reset_stats(self) -> None: + """重置所有统计信息""" + self.stats_collector.reset_stats("option_resolver") + self.strategy_chain.reset_stats() + + def _get_current_timestamp(self) -> float: + """获取当前时间戳""" + import time + + return time.time() + + def __str__(self) -> str: + return f"OptionResolver(strategies={len(self.strategy_chain)}, version=V3.3)" + + +# 使用单例管理器注册选项解析器 +@register_singleton("option_resolver") +def create_option_resolver() -> OptionResolver: + """创建选项解析器实例""" + return OptionResolver() + + +def get_option_resolver() -> OptionResolver: + """ + 获取全局选项解析器实例 + Get global option resolver instance + + Returns: + OptionResolver: 选项解析器实例 + """ + return create_option_resolver() + + +def resolve_final_options_v3( + text: str, + ai_options: Optional[List[str]] = None, + config: Optional[Dict[str, Any]] = None, + language: str = "zh_CN", +) -> List[str]: + """ + V3.3 版本的选项解析函数 + V3.3 version of option resolution function + + Args: + text: 用户输入文本 + ai_options: AI提供的选项 + config: 配置信息 + language: 语言代码 + + Returns: + List[str]: 解析出的选项列表 + """ + resolver = get_option_resolver() + return resolver.resolve_options(text, ai_options, config, language) + + +def get_resolver_stats() -> Dict[str, Any]: + """ + 获取全局解析器统计信息 + Get global resolver statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + resolver = get_option_resolver() + return resolver.get_resolver_stats() diff --git a/src/interactive_feedback_server/utils/option_strategies/__init__.py b/src/interactive_feedback_server/utils/option_strategies/__init__.py new file mode 100644 index 0000000..00bb48b --- /dev/null +++ b/src/interactive_feedback_server/utils/option_strategies/__init__.py @@ -0,0 +1,14 @@ +# interactive_feedback_server/utils/option_strategies/__init__.py + +""" +选项策略实现模块 - V4.0 简化版本 +Option Strategy Implementation Module - V4.0 Simplified Version + +包含简化的选项解析策略实现(移除规则引擎) +Contains simplified option parsing strategy implementations (rule engine removed) +""" + +from .ai_options_strategy import AIOptionsStrategy +from .fallback_options_strategy import FallbackOptionsStrategy + +__all__ = ["AIOptionsStrategy", "FallbackOptionsStrategy"] diff --git a/src/interactive_feedback_server/utils/option_strategies/ai_options_strategy.py b/src/interactive_feedback_server/utils/option_strategies/ai_options_strategy.py new file mode 100644 index 0000000..0de24e0 --- /dev/null +++ b/src/interactive_feedback_server/utils/option_strategies/ai_options_strategy.py @@ -0,0 +1,201 @@ +# interactive_feedback_server/utils/option_strategies/ai_options_strategy.py + +""" +AI选项策略 - V3.3 架构改进版本 +AI Options Strategy - V3.3 Architecture Improvement Version + +处理AI提供的选项,作为第一层回退逻辑。 +Handles AI-provided options as the first layer of fallback logic. +""" + +from typing import List, Optional +from ..option_strategy import BaseOptionStrategy, OptionContext, OptionResult + + +class AIOptionsStrategy(BaseOptionStrategy): + """ + AI选项策略 + AI Options Strategy + + 处理AI直接提供的选项,优先级最高 + Handles AI-provided options with highest priority + """ + + def __init__(self): + """初始化AI选项策略""" + super().__init__( + name="ai_options", + priority=1, # 最高优先级 + min_text_length=1, # AI选项不依赖文本长度 + max_options=5, # AI可能提供更多选项 + ) + + def is_applicable(self, context: OptionContext) -> bool: + """ + 检查是否有AI提供的选项 + Check if AI-provided options are available + + Args: + context: 选项解析上下文 + + Returns: + bool: 是否适用 + """ + # 检查是否有AI选项 + if not context.ai_options: + return False + + # 检查AI选项是否有效 + if not isinstance(context.ai_options, list): + return False + + # 检查是否有非空选项 + valid_options = [ + opt for opt in context.ai_options if isinstance(opt, str) and opt.strip() + ] + + return len(valid_options) > 0 + + def parse_options(self, context: OptionContext) -> Optional[OptionResult]: + """ + 解析AI提供的选项 + Parse AI-provided options + + Args: + context: 选项解析上下文 + + Returns: + Optional[OptionResult]: 解析结果 + """ + if not context.ai_options: + return None + + # 过滤和清理AI选项 + valid_options = [] + for option in context.ai_options: + if isinstance(option, str) and option.strip(): + clean_option = option.strip() + if clean_option not in valid_options: # 去重 + valid_options.append(clean_option) + + if not valid_options: + return None + + # 计算置信度(基于选项质量) + confidence = self._calculate_confidence(valid_options, context) + + return self.create_result( + options=valid_options, + confidence=confidence, + should_stop=True, # AI选项通常应该停止后续策略 + source="ai", + original_count=len(context.ai_options), + filtered_count=len(valid_options), + ) + + def _calculate_confidence( + self, options: List[str], context: OptionContext + ) -> float: + """ + 计算AI选项的置信度 + Calculate confidence of AI options + + Args: + options: 有效选项列表 + context: 选项解析上下文 + + Returns: + float: 置信度 (0.0-1.0) + """ + base_confidence = 0.9 # AI选项基础置信度较高 + + # 根据选项数量调整 + if len(options) == 0: + return 0.0 + elif len(options) == 1: + confidence = base_confidence * 0.8 # 单选项置信度稍低 + elif len(options) <= 3: + confidence = base_confidence # 2-3个选项置信度最高 + else: + confidence = base_confidence * 0.9 # 过多选项置信度稍低 + + # 根据选项质量调整 + quality_score = self._assess_option_quality(options) + confidence *= quality_score + + return min(1.0, max(0.0, confidence)) + + def _assess_option_quality(self, options: List[str]) -> float: + """ + 评估选项质量 + Assess option quality + + Args: + options: 选项列表 + + Returns: + float: 质量分数 (0.0-1.0) + """ + if not options: + return 0.0 + + quality_factors = [] + + for option in options: + # 长度合理性 (2-50字符) + length_score = 1.0 + if len(option) < 2: + length_score = 0.3 + elif len(option) > 50: + length_score = 0.7 + + # 内容合理性(不全是标点符号或数字) + content_score = 1.0 + if ( + option.replace(" ", "") + .replace(".", "") + .replace("?", "") + .replace("!", "") + .isdigit() + ): + content_score = 0.5 + elif not any(c.isalpha() for c in option): + content_score = 0.6 + + # 常见回复模式检测 + common_patterns = ["是", "否", "好的", "取消", "yes", "no", "ok", "cancel"] + pattern_score = 1.0 + if option.lower().strip() in [p.lower() for p in common_patterns]: + pattern_score = 1.2 # 常见模式加分 + + option_quality = length_score * content_score * pattern_score + quality_factors.append(min(1.0, option_quality)) + + # 返回平均质量分数 + return sum(quality_factors) / len(quality_factors) + + def get_strategy_info(self) -> dict: + """ + 获取策略详细信息 + Get detailed strategy information + + Returns: + dict: 策略信息 + """ + return { + "name": self.name, + "description": "AI选项策略 - 处理AI直接提供的选项", + "priority": self.priority, + "layer": 1, + "features": [ + "优先级最高", + "智能置信度计算", + "选项质量评估", + "自动去重和清理", + ], + "applicable_when": [ + "AI提供了有效选项", + "选项列表非空", + "至少包含一个有效字符串", + ], + } diff --git a/src/interactive_feedback_server/utils/option_strategies/fallback_options_strategy.py b/src/interactive_feedback_server/utils/option_strategies/fallback_options_strategy.py new file mode 100644 index 0000000..2eb47d4 --- /dev/null +++ b/src/interactive_feedback_server/utils/option_strategies/fallback_options_strategy.py @@ -0,0 +1,343 @@ +# interactive_feedback_server/utils/option_strategies/fallback_options_strategy.py + +""" +后备选项策略 - V3.3 架构改进版本 +Fallback Options Strategy - V3.3 Architecture Improvement Version + +提供用户配置的后备选项,作为第三层回退逻辑。 +Provides user-configured fallback options as the third layer of fallback logic. +""" + +from typing import List, Optional +from ..option_strategy import BaseOptionStrategy, OptionContext, OptionResult + + +class FallbackOptionsStrategy(BaseOptionStrategy): + """ + 后备选项策略 + Fallback Options Strategy + + 使用用户配置的后备选项作为最后的选择 + Uses user-configured fallback options as the last resort + """ + + def __init__(self): + """初始化后备选项策略""" + super().__init__( + name="fallback_options", + priority=3, # 最低优先级 + min_text_length=0, # 后备选项不依赖文本内容 + max_options=5, # 后备选项可能较多 + ) + + # 默认后备选项 + self._default_fallback_options = { + "zh_CN": ["是的", "不是", "需要更多信息"], + "en_US": ["Yes", "No", "Need more info"], + } + + def is_applicable(self, context: OptionContext) -> bool: + """ + 检查后备选项策略是否适用 + Check if fallback options strategy is applicable + + Args: + context: 选项解析上下文 + + Returns: + bool: 是否适用 + """ + # 检查配置是否启用自定义选项 + if context.config: + custom_options_enabled = self._get_custom_options_enabled(context.config) + if not custom_options_enabled: + return False + + # 后备选项作为最后的保障,在启用时总是适用 + return True + + def parse_options(self, context: OptionContext) -> Optional[OptionResult]: + """ + 解析后备选项 + Parse fallback options + + Args: + context: 选项解析上下文 + + Returns: + Optional[OptionResult]: 解析结果 + """ + # 尝试从配置获取后备选项 + fallback_options = self._get_fallback_options_from_config(context) + + # 如果配置中没有,使用默认选项 + if not fallback_options: + fallback_options = self._get_default_fallback_options(context.language) + + if not fallback_options: + return None + + # 计算置信度 + confidence = self._calculate_confidence(fallback_options, context) + + return self.create_result( + options=fallback_options, + confidence=confidence, + should_stop=True, # 后备选项是最后一层,必须停止 + source="fallback", + language=context.language, + from_config=context.config is not None, + is_default=not bool( + context.config and self._has_custom_fallback_options(context.config) + ), + ) + + def _get_fallback_options_from_config(self, context: OptionContext) -> List[str]: + """ + 从配置中获取后备选项 + Get fallback options from configuration + + Args: + context: 选项解析上下文 + + Returns: + List[str]: 后备选项列表 + """ + if not context.config: + return [] + + # 简化:只检查标准的fallback_options配置键 + if "fallback_options" in context.config: + options = context.config["fallback_options"] + if isinstance(options, list): + # 使用公共过滤函数 + from ..config_manager import filter_valid_options + + valid_options = filter_valid_options(options) + if valid_options: + return valid_options + + return [] + + def _get_custom_options_enabled(self, config: dict) -> bool: + """ + 检查配置中是否启用自定义选项 + Check if custom options are enabled in configuration + + Args: + config: 配置字典 + + Returns: + bool: 是否启用自定义选项 + """ + # 使用统一的配置检查工具 + try: + from ..config_manager import get_feature_enabled + + return get_feature_enabled(config, "enable_custom_options", False) + except ImportError: + # 回退到本地实现 + if "enable_custom_options" in config: + value = config["enable_custom_options"] + if isinstance(value, bool): + return value + elif isinstance(value, str): + return value.lower() in ["true", "1", "yes", "on", "enabled"] + elif isinstance(value, int): + return value != 0 + return False + + def _has_custom_fallback_options(self, config: dict) -> bool: + """ + 检查配置中是否有自定义后备选项 + Check if configuration has custom fallback options + + Args: + config: 配置字典 + + Returns: + bool: 是否有自定义选项 + """ + fallback_keys = [ + "fallback_options", + "default_options", + "backup_options", + "last_resort_options", + ] + + for key in fallback_keys: + if key in config and isinstance(config[key], list) and config[key]: + return True + + return False + + def _get_default_fallback_options(self, language: str) -> List[str]: + """ + 获取默认后备选项 + Get default fallback options + + Args: + language: 语言代码 + + Returns: + List[str]: 默认后备选项 + """ + if language in self._default_fallback_options: + return self._default_fallback_options[language].copy() + + # 如果不支持该语言,回退到中文 + return self._default_fallback_options["zh_CN"].copy() + + def _calculate_confidence( + self, options: List[str], context: OptionContext + ) -> float: + """ + 计算后备选项的置信度 + Calculate confidence of fallback options + + Args: + options: 选项列表 + context: 选项解析上下文 + + Returns: + float: 置信度 (0.0-1.0) + """ + if not options: + return 0.0 + + # 后备选项的基础置信度较低 + base_confidence = 0.5 + + # 如果是用户自定义的后备选项,置信度稍高 + if context.config and self._has_custom_fallback_options(context.config): + base_confidence = 0.6 + + # 根据选项数量调整 + if len(options) == 1: + confidence = base_confidence * 0.8 + elif len(options) <= 3: + confidence = base_confidence + else: + confidence = base_confidence * 0.9 + + # 根据选项质量调整 + quality_score = self._assess_option_quality(options) + confidence *= quality_score + + return min(1.0, max(0.1, confidence)) # 最低保证0.1的置信度 + + def _assess_option_quality(self, options: List[str]) -> float: + """ + 评估后备选项质量 + Assess fallback option quality + + Args: + options: 选项列表 + + Returns: + float: 质量分数 (0.0-1.0) + """ + if not options: + return 0.0 + + quality_factors = [] + + for option in options: + # 长度合理性 + length_score = 1.0 + if len(option) < 1: + length_score = 0.1 + elif len(option) > 30: + length_score = 0.8 + + # 内容有效性 + content_score = 1.0 + if not option.strip(): + content_score = 0.1 + elif option.strip() in ["", " ", " "]: # 空白字符 + content_score = 0.1 + + # 常见有效回复检测 + common_valid = [ + "是", + "否", + "是的", + "不是", + "好的", + "取消", + "确定", + "需要更多信息", + "yes", + "no", + "ok", + "cancel", + "confirm", + "need more info", + ] + if option.lower().strip() in [p.lower() for p in common_valid]: + content_score = 1.2 # 常见有效回复加分 + + option_quality = length_score * content_score + quality_factors.append(min(1.0, option_quality)) + + return sum(quality_factors) / len(quality_factors) + + def add_default_language(self, language: str, options: List[str]) -> None: + """ + 添加新语言的默认后备选项 + Add default fallback options for new language + + Args: + language: 语言代码 + options: 选项列表 + """ + if options and all(isinstance(opt, str) and opt.strip() for opt in options): + self._default_fallback_options[language] = [opt.strip() for opt in options] + + def get_supported_languages(self) -> List[str]: + """ + 获取支持的语言列表 + Get supported language list + + Returns: + List[str]: 语言代码列表 + """ + return list(self._default_fallback_options.keys()) + + def get_default_options_for_language(self, language: str) -> List[str]: + """ + 获取指定语言的默认选项 + Get default options for specified language + + Args: + language: 语言代码 + + Returns: + List[str]: 默认选项列表 + """ + return self._get_default_fallback_options(language) + + def get_strategy_info(self) -> dict: + """ + 获取策略详细信息 + Get detailed strategy information + + Returns: + dict: 策略信息 + """ + return { + "name": self.name, + "description": "后备选项策略 - 提供用户配置的后备选项", + "priority": self.priority, + "layer": 3, + "features": [ + "总是可用", + "支持用户自定义", + "多语言默认选项", + "质量评估", + "最低置信度保证", + ], + "applicable_when": ["总是适用(最后保障)"], + "supported_languages": self.get_supported_languages(), + "default_options": self._default_fallback_options, + } diff --git a/src/interactive_feedback_server/utils/option_strategy.py b/src/interactive_feedback_server/utils/option_strategy.py new file mode 100644 index 0000000..d2c6861 --- /dev/null +++ b/src/interactive_feedback_server/utils/option_strategy.py @@ -0,0 +1,442 @@ +# interactive_feedback_server/utils/option_strategy.py + +""" +选项策略接口 - V3.3 架构改进版本 +Option Strategy Interface - V3.3 Architecture Improvement Version + +定义统一的选项解析策略接口,实现策略模式重构三层逻辑。 +Defines unified option parsing strategy interface, implementing strategy pattern to refactor three-layer logic. +""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass + + +@dataclass +class OptionContext: + """ + 选项解析上下文 + Option parsing context + + 包含解析过程中需要的所有信息 + Contains all information needed during parsing process + """ + + text: str # 用户输入文本 + ai_options: Optional[List[str]] = None # AI提供的选项 + config: Optional[Dict[str, Any]] = None # 配置信息 + language: str = "zh_CN" # 语言代码 + metadata: Optional[Dict[str, Any]] = None # 额外元数据 + + def __post_init__(self): + """初始化后处理""" + if self.metadata is None: + self.metadata = {} + + +@dataclass +class OptionResult: + """ + 选项解析结果 + Option parsing result + + 包含解析结果和相关信息 + Contains parsing result and related information + """ + + options: List[str] # 解析出的选项 + strategy_name: str # 使用的策略名称 + confidence: float = 1.0 # 置信度 (0.0-1.0) + metadata: Optional[Dict[str, Any]] = None # 结果元数据 + should_stop: bool = True # 是否应该停止后续策略 + + def __post_init__(self): + """初始化后处理""" + if self.metadata is None: + self.metadata = {} + + +class OptionStrategy(ABC): + """ + 选项策略抽象基类 + Option Strategy Abstract Base Class + + 定义选项解析策略的统一接口 + Defines unified interface for option parsing strategies + """ + + def __init__(self, name: str, priority: int = 10): + """ + 初始化策略 + Initialize strategy + + Args: + name: 策略名称 + priority: 优先级(数字越小优先级越高) + """ + self.name = name + self.priority = priority + self._enabled = True + self._stats = {"call_count": 0, "success_count": 0, "error_count": 0} + + @abstractmethod + def is_applicable(self, context: OptionContext) -> bool: + """ + 检查策略是否适用于当前上下文 + Check if strategy is applicable to current context + + Args: + context: 选项解析上下文 + + Returns: + bool: 是否适用 + """ + pass + + @abstractmethod + def parse_options(self, context: OptionContext) -> Optional[OptionResult]: + """ + 解析选项 + Parse options + + Args: + context: 选项解析上下文 + + Returns: + Optional[OptionResult]: 解析结果,如果无法解析则返回None + """ + pass + + def execute(self, context: OptionContext) -> Optional[OptionResult]: + """ + 执行策略(包含统计和错误处理) + Execute strategy (with statistics and error handling) + + Args: + context: 选项解析上下文 + + Returns: + Optional[OptionResult]: 解析结果 + """ + if not self._enabled: + return None + + self._stats["call_count"] += 1 + + try: + # 检查适用性 + if not self.is_applicable(context): + return None + + # 执行解析 + result = self.parse_options(context) + + if result and result.options: + self._stats["success_count"] += 1 + return result + + return None + + except Exception as e: + self._stats["error_count"] += 1 + print(f"策略 {self.name} 执行失败: {e}") + return None + + def enable(self) -> None: + """启用策略""" + self._enabled = True + + def disable(self) -> None: + """禁用策略""" + self._enabled = False + + def is_enabled(self) -> bool: + """检查策略是否启用""" + return self._enabled + + def get_stats(self) -> Dict[str, Any]: + """ + 获取策略统计信息 + Get strategy statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + success_rate = 0.0 + if self._stats["call_count"] > 0: + success_rate = ( + self._stats["success_count"] / self._stats["call_count"] + ) * 100 + + return { + "name": self.name, + "priority": self.priority, + "enabled": self._enabled, + "call_count": self._stats["call_count"], + "success_count": self._stats["success_count"], + "error_count": self._stats["error_count"], + "success_rate_percent": round(success_rate, 2), + } + + def reset_stats(self) -> None: + """重置统计信息""" + self._stats = {"call_count": 0, "success_count": 0, "error_count": 0} + + def __str__(self) -> str: + return f"OptionStrategy(name={self.name}, priority={self.priority}, enabled={self._enabled})" + + def __repr__(self) -> str: + return self.__str__() + + +class BaseOptionStrategy(OptionStrategy): + """ + 基础选项策略实现 + Base Option Strategy Implementation + + 提供通用的策略实现基础 + Provides common strategy implementation foundation + """ + + def __init__( + self, + name: str, + priority: int = 10, + min_text_length: int = 2, + max_options: int = 3, + ): + """ + 初始化基础策略 + Initialize base strategy + + Args: + name: 策略名称 + priority: 优先级 + min_text_length: 最小文本长度 + max_options: 最大选项数量 + """ + super().__init__(name, priority) + self.min_text_length = min_text_length + self.max_options = max_options + + def is_applicable(self, context: OptionContext) -> bool: + """ + 基础适用性检查 + Basic applicability check + """ + # 检查文本有效性 + if not context.text or not isinstance(context.text, str): + return False + + # 检查文本长度 + if len(context.text.strip()) < self.min_text_length: + return False + + return True + + def validate_options(self, options: List[str]) -> List[str]: + """ + 验证和清理选项列表 + Validate and clean options list + + Args: + options: 原始选项列表 + + Returns: + List[str]: 清理后的选项列表 + """ + if not options: + return [] + + # 去重并保持顺序 + seen = set() + cleaned_options = [] + + for option in options: + if isinstance(option, str) and option.strip(): + clean_option = option.strip() + if clean_option not in seen: + seen.add(clean_option) + cleaned_options.append(clean_option) + + # 限制数量 + return cleaned_options[: self.max_options] + + def create_result( + self, + options: List[str], + confidence: float = 1.0, + should_stop: bool = True, + **metadata, + ) -> OptionResult: + """ + 创建选项结果 + Create option result + + Args: + options: 选项列表 + confidence: 置信度 + should_stop: 是否停止后续策略 + **metadata: 额外元数据 + + Returns: + OptionResult: 选项结果 + """ + validated_options = self.validate_options(options) + + return OptionResult( + options=validated_options, + strategy_name=self.name, + confidence=confidence, + should_stop=should_stop, + metadata=metadata, + ) + + +class StrategyChain: + """ + 策略链管理器 + Strategy Chain Manager + + 管理多个策略的执行顺序和结果合并 + Manages execution order and result merging of multiple strategies + """ + + def __init__(self): + """初始化策略链""" + self.strategies: List[OptionStrategy] = [] + self._execution_stats = { + "total_executions": 0, + "successful_executions": 0, + "strategy_usage": {}, + } + + def add_strategy(self, strategy: OptionStrategy) -> None: + """ + 添加策略到链中 + Add strategy to chain + + Args: + strategy: 要添加的策略 + """ + self.strategies.append(strategy) + # 按优先级排序 + self.strategies.sort(key=lambda s: s.priority) + + def remove_strategy(self, name: str) -> bool: + """ + 从链中移除策略 + Remove strategy from chain + + Args: + name: 策略名称 + + Returns: + bool: 是否成功移除 + """ + for i, strategy in enumerate(self.strategies): + if strategy.name == name: + del self.strategies[i] + return True + return False + + def get_strategy(self, name: str) -> Optional[OptionStrategy]: + """ + 获取指定名称的策略 + Get strategy by name + + Args: + name: 策略名称 + + Returns: + Optional[OptionStrategy]: 策略实例 + """ + for strategy in self.strategies: + if strategy.name == name: + return strategy + return None + + def execute(self, context: OptionContext) -> Optional[OptionResult]: + """ + 执行策略链 + Execute strategy chain + + Args: + context: 选项解析上下文 + + Returns: + Optional[OptionResult]: 第一个成功的策略结果 + """ + self._execution_stats["total_executions"] += 1 + + for strategy in self.strategies: + if not strategy.is_enabled(): + continue + + result = strategy.execute(context) + + if result and result.options: + # 更新使用统计 + strategy_name = strategy.name + if strategy_name not in self._execution_stats["strategy_usage"]: + self._execution_stats["strategy_usage"][strategy_name] = 0 + self._execution_stats["strategy_usage"][strategy_name] += 1 + + self._execution_stats["successful_executions"] += 1 + + # 如果策略要求停止,则返回结果 + if result.should_stop: + return result + + return None + + def get_chain_stats(self) -> Dict[str, Any]: + """ + 获取策略链统计信息 + Get strategy chain statistics + + Returns: + Dict[str, Any]: 统计信息 + """ + success_rate = 0.0 + if self._execution_stats["total_executions"] > 0: + success_rate = ( + self._execution_stats["successful_executions"] + / self._execution_stats["total_executions"] + ) * 100 + + strategy_stats = [strategy.get_stats() for strategy in self.strategies] + + return { + "total_strategies": len(self.strategies), + "enabled_strategies": len([s for s in self.strategies if s.is_enabled()]), + "total_executions": self._execution_stats["total_executions"], + "successful_executions": self._execution_stats["successful_executions"], + "success_rate_percent": round(success_rate, 2), + "strategy_usage": self._execution_stats["strategy_usage"], + "strategies": strategy_stats, + } + + def reset_stats(self) -> None: + """重置所有统计信息""" + self._execution_stats = { + "total_executions": 0, + "successful_executions": 0, + "strategy_usage": {}, + } + + for strategy in self.strategies: + strategy.reset_stats() + + def __len__(self) -> int: + return len(self.strategies) + + def __iter__(self): + return iter(self.strategies) + + def __str__(self) -> str: + enabled_count = len([s for s in self.strategies if s.is_enabled()]) + return ( + f"StrategyChain(strategies={len(self.strategies)}, enabled={enabled_count})" + ) diff --git a/src/interactive_feedback_server/utils/rule_engine.py b/src/interactive_feedback_server/utils/rule_engine.py new file mode 100644 index 0000000..a489151 --- /dev/null +++ b/src/interactive_feedback_server/utils/rule_engine.py @@ -0,0 +1,192 @@ +# src/interactive_feedback_server/utils/rule_engine.py +""" +规则引擎模块 - V3.3 架构改进版本 +Rule Engine Module - V3.3 Architecture Improvement Version + +V3.3 架构改进:集成可配置规则引擎,支持外部化配置 +V3.3 Architecture Improvement: Integrated configurable rule engine with externalized configuration + +V3.2 性能优化:集成缓存机制,显著提升处理速度 +V3.2 Performance Optimization: Integrated caching mechanism for significant speed improvement + +提供三层回退逻辑: +1. AI提供的选项(第一层) +2. 规则引擎生成的选项(第二层) +3. 用户配置的后备选项(第三层) + +Three-layer fallback logic: +1. AI-provided options (first layer) +2. Rule engine generated options (second layer) +3. User-configured fallback options (third layer) +""" + +from typing import List, Dict, Any +from .text_processor import fast_find_match + +# 规则引擎相关导入已移除 - V4.0 简化 + +# 核心模式定义 - 精选高频场景 +CORE_PATTERNS = { + # 疑问场景 - 最高优先级 + "question": { + "triggers": [ + "?", + "?", + "是否", + "如何", + "怎么", + "什么", + "为什么", + "哪个", + "哪些", + ], + "options": ["是的", "不是", "需要更多信息"], + }, + # 确认场景 - 高优先级 + "confirmation": { + "triggers": ["确认", "同意", "继续", "下一步", "开始", "执行", "好的"], + "options": ["好的,继续", "我明白了", "暂停一下"], + }, + # 选择场景 - 中优先级 + "choice": { + "triggers": ["选择", "决定", "考虑", "建议", "推荐", "方案", "选项"], + "options": ["选择这个", "看看其他的", "让我想想"], + }, + # 操作场景 - 中优先级 + "action": { + "triggers": ["修改", "更改", "调整", "优化", "删除", "添加", "创建", "生成"], + "options": ["执行操作", "先预览", "取消操作"], + }, +} + + +# V4.0 移除:extract_options_from_text 函数已删除 +# 规则引擎功能已完全移除,简化为AI选项+用户自定义选项的2级逻辑 + + +def is_valid_ai_options(ai_options) -> bool: + """ + 严格验证AI选项的有效性 - V3.2边界控制 + Strictly validate the validity of AI options - V3.2 boundary control + + Args: + ai_options: AI提供的选项 + + Returns: + bool: 是否为有效的AI选项 + """ + # 检查是否为None + if ai_options is None: + return False + + # 检查是否为空列表 + if isinstance(ai_options, list) and len(ai_options) == 0: + return False + + # 检查是否为非列表类型 + if not isinstance(ai_options, list): + return False + + # 检查列表中是否包含有效选项 + valid_count = 0 + for option in ai_options: + if isinstance(option, str) and option.strip(): + valid_count += 1 + + # 至少要有一个有效选项 + return valid_count > 0 + + +def resolve_final_options( + ai_options: List[str] = None, text: str = "", config: Dict[str, Any] = None +) -> List[str]: + """ + V4.0 简化的两层回退逻辑 + V4.0 Simplified two-layer fallback logic + + V4.0 简化改进: + - 移除规则引擎层,简化为AI选项 + 用户自定义选项 + - 保持严格的边界控制 + - 提高性能和可维护性 + + 严格的边界规则: + 1. 第一层:AI选项优先,有效时完全阻断后续层级 + 2. 第二层:用户自定义选项,仅在AI选项无效时使用 + 3. 每一层都有严格的有效性检查,确保边界清晰 + + Args: + ai_options: AI提供的预定义选项 + text: 文本内容(保留参数以兼容现有调用) + config: 配置字典,包含用户自定义的后备选项 + + Returns: + List[str]: 最终的选项列表 + """ + # 导入配置管理器(避免循环导入) + from .config_manager import ( + get_config, + safe_get_fallback_options, + get_custom_options_enabled, + ) + + if config is None: + config = get_config() + + # 第一层:AI选项优先 - 严格边界检查 + if is_valid_ai_options(ai_options): + # AI提供了有效选项,严格过滤并直接返回,完全阻断后续处理 + valid_ai_options = [ + option.strip() + for option in ai_options + if isinstance(option, str) and option.strip() + ] + if valid_ai_options: # 双重检查确保有效性 + return valid_ai_options + + # 第二层:用户自定义后备选项 - 可控制启用/禁用 + custom_options_enabled = get_custom_options_enabled(config) + if custom_options_enabled: + try: + fallback_options = safe_get_fallback_options(config) + if fallback_options and len(fallback_options) > 0: + return fallback_options + except Exception: + # 后备选项获取失败,静默处理 + pass + + # V4.0 严格边界控制:如果用户禁用了所有层级,返回空选项 + # 这样UI就不会显示任何选项,完全由用户手动输入 + return [] + + +def get_options_summary(options: List[str]) -> str: + """ + 获取选项的简要描述,用于调试和日志 + Get brief description of options for debugging and logging + + Args: + options: 选项列表 + + Returns: + str: 选项的简要描述 + """ + if not options: + return "无选项 (No options)" + + if len(options) <= 3: + return f"选项: {', '.join(options)}" + else: + return f"选项: {', '.join(options[:3])}... (共{len(options)}个)" + + +# V4.0 移除:规则引擎性能监控函数已删除 + + +# V4.0 移除:规则引擎管理函数已删除 + + +# V4.0 移除:规则引擎基准测试函数已删除 + + +# V4.0 移除:规则引擎测试函数已删除 +# 简化后的规则引擎只保留核心的2级逻辑:AI选项 → 用户自定义选项 diff --git a/src/interactive_feedback_server/utils/text_processor.py b/src/interactive_feedback_server/utils/text_processor.py new file mode 100644 index 0000000..e9f7c75 --- /dev/null +++ b/src/interactive_feedback_server/utils/text_processor.py @@ -0,0 +1,305 @@ +# src/interactive_feedback_server/utils/text_processor.py +""" +文本处理优化器 +Text Processing Optimizer + +V3.2 第一阶段性能优化 - Day 3: 字符串处理优化 +V3.2 Phase 1 Performance Optimization - Day 3: String Processing Optimization + +提供高性能的文本预处理和关键词匹配功能,显著提升文本处理速度。 +Provides high-performance text preprocessing and keyword matching for significant speed improvement. + +特性 Features: +- 预编译正则表达式 (Pre-compiled regex patterns) +- 智能文本标准化 (Intelligent text normalization) +- 高效关键词匹配 (Efficient keyword matching) +- 文本处理缓存 (Text processing cache) +""" + +import re +from typing import Dict, List, Tuple, Optional +from functools import lru_cache +import unicodedata + + +class TextProcessor: + """ + 高性能文本处理器 + High-Performance Text Processor + + 优化文本预处理和关键词匹配性能。 + Optimizes text preprocessing and keyword matching performance. + """ + + def __init__(self): + """初始化文本处理器""" + # 预编译正则表达式模式 + self._whitespace_pattern = re.compile(r"\s+") + self._punctuation_pattern = re.compile(r"[^\w\s]") + self._number_pattern = re.compile(r"\d+") + self._english_pattern = re.compile(r"[a-zA-Z]+") + self._chinese_pattern = re.compile(r"[\u4e00-\u9fff]+") + + # 简化标点符号处理,避免复杂映射 + self._punctuation_map = None # 暂时不使用复杂映射 + + # 停用词集合(用于快速查找) + self._stop_words = { + "的", + "了", + "在", + "是", + "我", + "有", + "和", + "就", + "不", + "人", + "都", + "一", + "一个", + "上", + "也", + "很", + "到", + "说", + "要", + "去", + "你", + "会", + "着", + "没有", + "看", + "好", + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + } + + # 文本长度阈值 + self.MIN_TEXT_LENGTH = 2 + self.MAX_TEXT_LENGTH = 1000 + + @lru_cache(maxsize=500) + def normalize_text(self, text: str) -> str: + """ + 标准化文本(带缓存) + Normalize text with caching + + Args: + text: 原始文本 + + Returns: + str: 标准化后的文本 + """ + if not text or not isinstance(text, str): + return "" + + # 长度检查 + if len(text) < self.MIN_TEXT_LENGTH: + return "" + if len(text) > self.MAX_TEXT_LENGTH: + text = text[: self.MAX_TEXT_LENGTH] + + # 1. Unicode标准化 + text = unicodedata.normalize("NFKC", text) + + # 2. 转小写 + text = text.lower() + + # 3. 简化标点符号处理(移除或替换为空格) + text = self._punctuation_pattern.sub(" ", text) + + # 4. 合并多个空白字符 + text = self._whitespace_pattern.sub(" ", text) + + # 5. 去除首尾空白 + text = text.strip() + + return text + + @lru_cache(maxsize=300) + def extract_keywords(self, text: str) -> Tuple[str, ...]: + """ + 提取关键词(带缓存) + Extract keywords with caching + + Args: + text: 输入文本 + + Returns: + Tuple[str, ...]: 关键词元组(用于缓存) + """ + normalized_text = self.normalize_text(text) + if not normalized_text: + return tuple() + + # 分词 + words = normalized_text.split() + + # 过滤停用词和短词 + keywords = [] + for word in words: + if len(word) >= 2 and word not in self._stop_words and not word.isdigit(): + keywords.append(word) + + return tuple(keywords) + + # V4.1 移除:get_text_features方法未在输入优化功能中使用 + + +class OptimizedMatcher: + """ + 优化的关键词匹配器 + Optimized Keyword Matcher + + 使用高效算法进行关键词匹配。 + Uses efficient algorithms for keyword matching. + """ + + def __init__(self, patterns: Dict[str, Dict[str, any]]): + """ + 初始化匹配器 + Initialize matcher + + Args: + patterns: 模式字典,格式同CORE_PATTERNS + """ + self.patterns = patterns + self._build_optimized_structures() + + def _build_optimized_structures(self): + """构建优化的数据结构""" + # 按长度分组的触发词 + self._triggers_by_length: Dict[int, List[Tuple[str, str, int]]] = {} + + # 类别优先级 + self._category_priority = { + "confirmation": 0, + "choice": 1, + "action": 2, + "question": 3, + } + + # 构建触发词索引 + for category, config in self.patterns.items(): + priority = self._category_priority.get(category, 999) + + for trigger in config["triggers"]: + trigger_len = len(trigger) + + if trigger_len not in self._triggers_by_length: + self._triggers_by_length[trigger_len] = [] + + self._triggers_by_length[trigger_len].append( + (trigger, category, priority) + ) + + # 按长度降序排序(长词优先) + self._sorted_lengths = sorted(self._triggers_by_length.keys(), reverse=True) + + # 对每个长度组内按优先级排序 + for length in self._sorted_lengths: + self._triggers_by_length[length].sort(key=lambda x: x[2]) # 按优先级排序 + + @lru_cache(maxsize=200) + def find_best_match(self, text: str) -> Optional[Tuple[str, str, List[str]]]: + """ + 查找最佳匹配(带缓存) + Find best match with caching + + Args: + text: 输入文本 + + Returns: + Optional[Tuple[str, str, List[str]]]: (触发词, 类别, 选项列表) 或 None + """ + if not text: + return None + + text_lower = text.lower() + + # 按长度优先级搜索 + for length in self._sorted_lengths: + triggers = self._triggers_by_length[length] + + for trigger, category, _ in triggers: # priority未使用 + if trigger in text_lower: + options = self.patterns[category]["options"] + return (trigger, category, options) + + return None + + # V4.1 移除:find_all_matches方法未在输入优化功能中使用 + + +# 全局实例 +_text_processor = TextProcessor() +_optimized_matcher = None # 延迟初始化 + + +def get_text_processor() -> TextProcessor: + """获取全局文本处理器实例""" + return _text_processor + + +def get_optimized_matcher() -> OptimizedMatcher: + """获取全局优化匹配器实例""" + global _optimized_matcher + + if _optimized_matcher is None: + # 延迟导入避免循环依赖 + from .rule_engine import CORE_PATTERNS + + _optimized_matcher = OptimizedMatcher(CORE_PATTERNS) + + return _optimized_matcher + + +def clear_text_processing_cache(): + """清空文本处理缓存""" + _text_processor.normalize_text.cache_clear() + _text_processor.extract_keywords.cache_clear() + + global _optimized_matcher + if _optimized_matcher: + _optimized_matcher.find_best_match.cache_clear() + + +# V4.1 移除:get_text_processing_stats函数未在输入优化功能中使用 + + +# 便捷函数 +def fast_normalize_text(text: str) -> str: + """快速文本标准化""" + return _text_processor.normalize_text(text) + + +def fast_extract_keywords(text: str) -> Tuple[str, ...]: + """快速关键词提取""" + return _text_processor.extract_keywords(text) + + +def fast_find_match(text: str) -> Optional[Tuple[str, str, List[str]]]: + """快速匹配查找""" + return get_optimized_matcher().find_best_match(text) diff --git a/uv.lock b/uv.lock deleted file mode 100644 index f28d2b1..0000000 --- a/uv.lock +++ /dev/null @@ -1,522 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.11" - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577, upload-time = "2025-01-31T02:16:47.166Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, -] - -[[package]] -name = "fastmcp" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "openapi-pydantic" }, - { name = "python-dotenv" }, - { name = "rich" }, - { name = "typer" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/e7/a37b7bf39ee9bcc01b51319b94759981be0fde84526e5c7c479d2abbbefd/fastmcp-2.3.0.tar.gz", hash = "sha256:28c8799d1c28c2d10cca91dd2076c33ee459b69484e2e39d34de11f9a88b628f", size = 978654, upload-time = "2025-05-08T20:29:18.112Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/22/494b4b037c87af874f932cc6810221738c0aaad044d58537e85d91f9a0ec/fastmcp-2.3.0-py3-none-any.whl", hash = "sha256:1634e88111adadd790e1d39a5f83248ef814643ac5643f9ad0a1986884e2bdad", size = 90411, upload-time = "2025-05-08T20:29:16.645Z" }, -] - -[[package]] -name = "h11" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196, upload-time = "2024-11-15T12:30:47.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551, upload-time = "2024-11-15T12:30:45.782Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, -] - -[[package]] -name = "interactive-feedback-mcp" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "fastmcp" }, - { name = "psutil" }, - { name = "pyside6" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastmcp", specifier = ">=2.0.0" }, - { name = "psutil", specifier = ">=7.0.0" }, - { name = "pyside6", specifier = ">=6.8.2.1" }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, -] - -[[package]] -name = "mcp" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "python-multipart" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/97/0a3e08559557b0ac5799f9fb535fbe5a4e4dcdd66ce9d32e7a74b4d0534d/mcp-1.8.0.tar.gz", hash = "sha256:263dfb700540b726c093f0c3e043f66aded0730d0b51f04eb0a3eb90055fe49b", size = 264641, upload-time = "2025-05-08T20:09:06.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/b2/4ac3bd17b1fdd65658f18de4eb0c703517ee0b483dc5f56467802a9197e0/mcp-1.8.0-py3-none-any.whl", hash = "sha256:889d9d3b4f12b7da59e7a3933a0acadae1fce498bfcd220defb590aa291a1334", size = 119544, upload-time = "2025-05-08T20:09:04.458Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "openapi-pydantic" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, -] - -[[package]] -name = "psutil" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, - { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, - { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, - { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, - { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, -] - -[[package]] -name = "pydantic" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681, upload-time = "2025-01-24T01:42:12.693Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696, upload-time = "2025-01-24T01:42:10.371Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443, upload-time = "2024-12-18T11:31:54.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421, upload-time = "2024-12-18T11:27:55.409Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998, upload-time = "2024-12-18T11:27:57.252Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167, upload-time = "2024-12-18T11:27:59.146Z" }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071, upload-time = "2024-12-18T11:28:02.625Z" }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244, upload-time = "2024-12-18T11:28:04.442Z" }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470, upload-time = "2024-12-18T11:28:07.679Z" }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291, upload-time = "2024-12-18T11:28:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613, upload-time = "2024-12-18T11:28:13.362Z" }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355, upload-time = "2024-12-18T11:28:16.587Z" }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661, upload-time = "2024-12-18T11:28:18.407Z" }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261, upload-time = "2024-12-18T11:28:21.471Z" }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361, upload-time = "2024-12-18T11:28:23.53Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484, upload-time = "2024-12-18T11:28:25.391Z" }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102, upload-time = "2024-12-18T11:28:28.593Z" }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127, upload-time = "2024-12-18T11:28:30.346Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340, upload-time = "2024-12-18T11:28:32.521Z" }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900, upload-time = "2024-12-18T11:28:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177, upload-time = "2024-12-18T11:28:36.488Z" }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046, upload-time = "2024-12-18T11:28:39.409Z" }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386, upload-time = "2024-12-18T11:28:41.221Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060, upload-time = "2024-12-18T11:28:44.709Z" }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870, upload-time = "2024-12-18T11:28:46.839Z" }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822, upload-time = "2024-12-18T11:28:48.896Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364, upload-time = "2024-12-18T11:28:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303, upload-time = "2024-12-18T11:28:54.122Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064, upload-time = "2024-12-18T11:28:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046, upload-time = "2024-12-18T11:28:58.107Z" }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092, upload-time = "2024-12-18T11:29:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709, upload-time = "2024-12-18T11:29:03.193Z" }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273, upload-time = "2024-12-18T11:29:05.306Z" }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027, upload-time = "2024-12-18T11:29:07.294Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888, upload-time = "2024-12-18T11:29:09.249Z" }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738, upload-time = "2024-12-18T11:29:11.23Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138, upload-time = "2024-12-18T11:29:16.396Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025, upload-time = "2024-12-18T11:29:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633, upload-time = "2024-12-18T11:29:23.877Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404, upload-time = "2024-12-18T11:29:25.872Z" }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130, upload-time = "2024-12-18T11:29:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946, upload-time = "2024-12-18T11:29:31.338Z" }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387, upload-time = "2024-12-18T11:29:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453, upload-time = "2024-12-18T11:29:35.533Z" }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186, upload-time = "2024-12-18T11:29:37.649Z" }, -] - -[[package]] -name = "pydantic-settings" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, -] - -[[package]] -name = "pyside6" -version = "6.8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/0f/bdb12758448b52497dba7a3bbfb5855dfb29129c64ddbda4da56c4b11f6c/PySide6-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:3fcb551729f235475b2abe7d919027de54a65d850e744f60716f890202273720", size = 550254, upload-time = "2025-02-06T13:56:07.585Z" }, - { url = "https://files.pythonhosted.org/packages/fa/00/0b232a25eeb8671202d7a7ec92893bd25b965debfd1d5d7aad637b067efe/PySide6-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:23d2a1a77b25459a049c4276b4e0bbfb375b73d3921061b1a16bcfa64e1fe517", size = 550489, upload-time = "2025-02-06T13:56:09.913Z" }, - { url = "https://files.pythonhosted.org/packages/8b/8a/9eb78cf71233399236c257cf85770ca4673ed0b9b959895856285157f643/PySide6-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bfefa80a93db06dc64c0e7beef0377c9b8ca51e007cfc34575defe065af893b6", size = 550491, upload-time = "2025-02-06T13:56:12.51Z" }, - { url = "https://files.pythonhosted.org/packages/fb/3d/3e626e1953408cb8977a050ce54b1f1adff9a4c06bb519f6d56ebaf9310c/PySide6-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:92361e41727910e3560ea5ba494fabecc76cd20892c9fcb2ced07619081c4e65", size = 556167, upload-time = "2025-02-06T13:56:15.394Z" }, -] - -[[package]] -name = "pyside6-addons" -version = "6.8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/79/a868ffac6eb446afdd25312b61872d0d11173032d50320d48b5277b68ccf/PySide6_Addons-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:5558816018042fecd0d782111ced529585a23ea9a010b518f8495764f578a01f", size = 302704501, upload-time = "2025-02-06T13:50:40.242Z" }, - { url = "https://files.pythonhosted.org/packages/95/3a/93e0028805c50ceff8b8ae0f274d502805b8a864129b83d705ab12d48f78/PySide6_Addons-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f3d85e676851ada8238bc76ebfacbee738fc0b35b3bc15c9765dd107b8ee6ec4", size = 160641392, upload-time = "2025-02-06T13:51:32.153Z" }, - { url = "https://files.pythonhosted.org/packages/84/5c/e822e4ef6c2140b273cb0f8531d7e200c8771bd61832decc524fc318c335/PySide6_Addons-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d904179f16deeca4ba440b4ef78e8d54df2b994b46784ad9d53b741082f3b2a7", size = 156398179, upload-time = "2025-02-06T13:51:57.622Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f8/98f85194f85a1fcff44ad98cd80cf6e856f7edee9e744fba81dec48b0ae9/PySide6_Addons-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:c761cc45022aa79d8419e671e7fb34a4a3e5b3826f1e68fcb819bd6e3a387fbb", size = 127973648, upload-time = "2025-02-06T13:52:22.998Z" }, -] - -[[package]] -name = "pyside6-essentials" -version = "6.8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/bb/0127a53530cec0f9e7268e2fe235322b7b6e592caeb36c558b64da6ec52c/PySide6_Essentials-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ae5cc48f7e9a08e73e3ec2387ce245c8150e620b8d5a87548ebd4b8e3aeae49b", size = 134909713, upload-time = "2025-02-06T13:53:07.533Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f9/aa4ff511ff1f3dd177f7e8f5a635e03fe578fa2045c8d6be4577e7db3b28/PySide6_Essentials-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5ab31e5395a4724102edd6e8ff980fa3f7cde2aa79050763a1dcc30bb914195a", size = 95331575, upload-time = "2025-02-06T13:53:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/fd/69/595002d860ee58431fe7add081d6f54fff94ae9680f2eb8cd355c1649bb6/PySide6_Essentials-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7aed46f91d44399b4c713cf7387f5fb6f0114413fbcdbde493a528fb8e19f6ed", size = 93200219, upload-time = "2025-02-06T13:53:41.404Z" }, - { url = "https://files.pythonhosted.org/packages/5b/54/28a8b03f327e2c1d27d4a1ccf1a44997afc73c00ad07125d889640367194/PySide6_Essentials-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:18de224f09108998d194e60f2fb8a1e86367dd525dd8a6192598e80e6ada649e", size = 72502927, upload-time = "2025-02-06T13:53:53.124Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, -] - -[[package]] -name = "rich" -version = "13.9.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "shiboken6" -version = "6.8.2.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8f/71ccc3642edb59efaca35d4ba974248b1d7847f5e4d87d3ea323e73b2cab/shiboken6-6.8.2.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:d3dedeb3732ecfc920c9f97da769c0022a1c3bda99346a9eba56fbf093deaa75", size = 401266, upload-time = "2025-02-06T13:55:54.499Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ff/ab4f287b9573e50b5a47c10e2af8feb5abecc3c7431bd5deec135efc969e/shiboken6-6.8.2.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c83e90056f13d0872cc4d2b7bf60b6d6e3b1b172f1f91910c0ba5b641af01758", size = 204273, upload-time = "2025-02-06T13:55:56.926Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b0/4fb102eb5260ee06d379769f3c4f0b82ef397c15f1cbbbbb3f6dceb86d5d/shiboken6-6.8.2.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8592401423acc693f51dbbfae5e7493cc3ed6738be79daaf90afa07f4da5bb25", size = 200909, upload-time = "2025-02-06T13:55:58.317Z" }, - { url = "https://files.pythonhosted.org/packages/ae/88/b56bdb38a11066e4eecd1da6be4205bb406398b733b392b11c5aaf9547f7/shiboken6-6.8.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:1b751d47b759762b7ca31bad278d52eca4105d3028880d93979261ebbfba810c", size = 1150270, upload-time = "2025-02-06T13:56:00.094Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sse-starlette" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376, upload-time = "2024-12-25T09:09:30.616Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120, upload-time = "2024-12-25T09:09:26.761Z" }, -] - -[[package]] -name = "starlette" -version = "0.46.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102, upload-time = "2025-03-08T10:55:34.504Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995, upload-time = "2025-03-08T10:55:32.662Z" }, -] - -[[package]] -name = "typer" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711, upload-time = "2025-02-27T19:17:34.807Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061, upload-time = "2025-02-27T19:17:32.111Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.34.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] diff --git "a/\345\212\237\350\203\275\350\257\264\346\230\216.md" "b/\345\212\237\350\203\275\350\257\264\346\230\216.md" new file mode 100644 index 0000000..bd716c5 --- /dev/null +++ "b/\345\212\237\350\203\275\350\257\264\346\230\216.md" @@ -0,0 +1,215 @@ +# interactive-feedback-mcp 功能说明 + +本文档详细介绍 `interactive-feedback-mcp` 服务的各项功能,旨在帮助用户全面了解其用途、交互方式以及与 AI 助手的协作流程。 + +## 快速开始 + +### 安装方式 + +**推荐方式(开发模式):** +```bash +# 克隆仓库 +git clone https://github.com/pawaovo/interactive-feedback-mcp.git +cd interactive-feedback-mcp + +# 安装依赖 +uv pip install -e . + +# 或使用现代 uv 语法 +# uv sync +``` + +**备选方式(uvx):** +```bash +# 直接运行,无需安装 +uvx interactive-feedback@latest + +# 如果首次安装失败,可以预安装: +uv tool install interactive-feedback@latest +``` + +**或使用pip:** +```bash +pip install interactive-feedback +``` + +**当前版本:** v2.5.10 - 文档重大更新,推荐开发模式安装;修复UI控件选中状态视觉效果 + +**PyPI项目页面:** https://pypi.org/project/interactive-feedback/ + +### MCP配置示例 + +**推荐配置(开发模式):** +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "/path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +**备选配置(uvx):** +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "interactive-feedback@latest" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +**请将 `/path/to/interactive-feedback-mcp` 替换为您实际的项目路径。** + +详细安装和配置说明请参阅 [安装与配置指南.md](./安装与配置指南.md)。 + +## 1. 项目简介 + +`interactive-feedback-mcp` 是一个模型上下文协议 (MCP) 服务,它通过在 AI 助手(如 Cursor)需要用户输入、澄清或确认时,弹出一个功能丰富的图形用户界面 (GUI) 反馈窗口。此服务旨在提升人与 AI 之间协作的效率、准确性和灵活性,支持文本、图片以及文件引用等多种反馈形式。 + +## 2. 核心功能 + +### 2.1. 交互式反馈窗口 + +* **触发方式**: + * AI 助手通过调用本 MCP 服务提供的 `interactive_feedback` 工具时,会自动弹出反馈窗口。 + * 用户也可以主动告知 AI 助手:"请用 `interactive_feedback mcp` 工具与我对话"来手动触发。 +* **文本输入**:用户可以在主输入框中输入纯文本反馈。支持通过按 `Enter`键发送反馈,按 `Shift+Enter` 组合键进行换行。 +* **预定义选项**:如果 AI 助手在调用时提供了 `predefined_options` 参数,这些选项会以带文本描述的复选框形式显示。用户可以直接勾选一个或多个选项,选中的选项文本会自动整合到最终发送的反馈内容中。 + +### 2.2. 图片处理与反馈 + +* **图片粘贴**:用户可以直接在反馈输入框中使用 `Ctrl+V` (或 macOS 上的 `Cmd+V`) 粘贴剪贴板中的单张或多张图片。 +* **图片拖拽**:支持从本地文件系统直接拖拽图片文件到文本输入框中进行添加。 +* **图片预览与管理**: + * 添加的图片会在文本输入框下方以缩略图形式显示。 + * 鼠标悬停在缩略图上会显示更大尺寸的图片预览及图片尺寸信息。 + * 点击缩略图可以直接删除该图片。 +* **图片处理机制**:为了优化传输和 AI 处理,图片在发送前会进行处理: + * 尺寸调整:较大的图片会被缩放到预设的最大宽度和高度(例如 512x512 像素),同时保持宽高比。 + * 格式与压缩:图片统一转换为 JPEG 格式,并可能根据需要调整压缩质量以满足大小限制(例如 1MB)。 +* **发送方式**:图片数据会经过 Base64 编码后,与文本内容一起作为结构化数据返回给 AI 助手。 + +### 2.3. 文件处理 + +* **文件拖拽**:将本地文件拖拽到文本输入框,生成蓝色加粗的文件引用(如 `@文件名.txt`)。 +* **文件选择**:点击"选择文件"按钮打开文件选择对话框,支持多文件选择。 +* **智能处理**:自动识别图片文件和普通文件,分别进行相应的处理和显示。 +* **智能光标定位**:文件添加后光标自动定位到合适位置,便于继续输入。 + +### 2.4. 常用语管理 + +* **预设管理**:预设和管理常用的反馈短语,支持添加、编辑、删除和排序。 +* **快速预览**:鼠标悬停在"常用语"按钮上显示预览窗口,支持滚动查看所有常用语。 +* **快速插入**:在预览窗口中点击常用语直接插入到输入框,或通过管理对话框双击插入。 +* **主题适配**:预览窗口和管理界面支持深色/浅色主题切换。 + + + +### 2.5. 界面布局 + +* **双布局模式**:支持垂直布局(上下分布)和水平布局(左右分布)。 +* **可拖拽分割器**:支持拖拽分割器调整各区域大小,双击重置为默认比例。 +* **实时切换**:在设置页面可以实时切换布局模式,状态自动保存。 + +### 2.6. 文本优化功能 + +* **一键优化**:将口语化输入转换为结构化、逻辑清晰的指令。 +* **自定义增强**:支持用户自定义增强指令,按特定要求处理文本内容。 +* **多AI提供商**:支持OpenAI、Google Gemini、DeepSeek、火山引擎等多个AI提供商。 +* **API密钥管理**:通过设置页面统一管理所有提供商的API密钥。 +* **优化体验**:支持加载动画、撤销功能(Ctrl+Z)、自动光标定位等。 + +### 2.7. 其他功能 + +* **音频提示**:窗口弹出时自动播放提示音,支持自定义音频文件。 +* **窗口截图**:支持矩形选择截图,截图完成后自动添加到输入内容中。 +* **显示模式**:支持简单模式(显示简洁问题)和完整模式(显示完整回复),可在设置页面实时切换。 +* **窗口控制**:支持窗口固定、自动最小化、UI状态持久化等功能。 + + + +## 3. 提供的 MCP 工具 + +本服务通过 MCP 向 AI 助手公开以下核心工具: + +### `interactive_feedback` + +* **功能**:向用户发起交互式会话,显示提示信息,提供可选选项,并收集用户的文本、图片和文件引用反馈。 +* **参数**: + * `message` (str, 可选): 简单模式下显示的简洁问题或提示 + * `full_response` (str, 可选): 完整模式下显示的AI原始完整回复内容 + * `predefined_options` (List[str], 可选): 一个字符串列表,每个字符串代表一个用户可以选择的预定义选项。如果提供,这些选项会显示为复选框。 +* **显示模式说明**: + * **简单模式**:优先使用 `message` 参数内容,显示简洁的问题或提示 + * **完整模式**:优先使用 `full_response` 参数内容,显示完整的AI回复 + * **智能回退**:如果主要参数为空,自动回退到备用参数,避免调用失败 + * **实时模式检测**:每次调用都读取最新的用户模式配置,支持动态切换 +* **返回给AI助手的数据格式**: + 该工具会返回一个包含结构化反馈内容的元组 (Tuple)。元组中的每个元素可以是字符串 (文本反馈或文件引用信息) 或 `fastmcp.Image` 对象 (图片反馈)。 + 具体来说,从UI收集到的数据会转换成以下 `content` 项列表,并由 MCP 服务器进一步处理成 FastMCP兼容的元组: + ```json + // UI返回给MCP服务器的原始JSON结构示例 + { + "content": [ + {"type": "text", "text": "用户的文本反馈..."}, + {"type": "image", "data": "base64_encoded_image_data", "mimeType": "image/jpeg"}, + {"type": "file_reference", "display_name": "@example.txt", "path": "/path/to/local/example.txt"} + // ... 可能有更多项 + ] + } + ``` + * **文本内容** (`type: "text"`):包含用户输入的文本和/或选中的预定义选项组合文本。 + * **图片内容** (`type: "image"`):包含 Base64 编码后的图片数据和图片的 MIME 类型 (如 `image/jpeg`)。这些在 MCP 服务器中会被转换为 `fastmcp.Image` 对象。 + * **文件引用** (`type: "file_reference"`):包含用户拖拽的文件的显示名 (如 `@filename.txt`) 和其在用户本地的完整路径。这些信息通常会作为文本字符串传递给AI助手。 + + **注意**:即使没有任何用户输入(例如用户直接关闭反馈窗口),工具也会返回一个表示"无反馈"的特定消息,如 `("[User provided no feedback]",)`。 + +### `optimize_user_input` + +* **功能**:使用配置的LLM API来优化或增强用户输入的文本,将口语化、可能存在歧义的输入转化为更结构化、更清晰、更便于AI模型理解的文本。 +* **参数**: + * `original_text` (str): **必须参数**。用户的原始输入文本 + * `mode` (str): **必须参数**。优化模式: + * `'optimize'`: 一键优化,使用预设的通用优化指令 + * `'reinforce'`: 提示词强化,使用用户自定义的强化指令 + * `reinforcement_prompt` (str, 可选): 在 'reinforce' 模式下用户的自定义指令 +* **支持的AI提供商**: + * **OpenAI**: GPT-4o-mini 等模型,提供高质量的文本优化 + * **Google Gemini**: Gemini-2.0-flash 等模型,快速响应和处理 + * **DeepSeek**: DeepSeek-chat 等模型,成本效益优化 + * **火山引擎**: DeepSeek-v3 等模型,国内访问优化 +* **配置要求**: + * 需要在UI设置页面配置相应提供商的API密钥 + * 支持自定义优化和增强提示词 + * 提供完善的错误处理和重试机制 +* **返回**:优化后的文本内容或详细的错误信息 + +## 4. 界面与体验特性 + +* **深色主题UI**:界面采用深色主题,提供舒适的视觉体验。 +* **快捷键支持**:支持Enter提交、Shift+Enter换行、Ctrl+V粘贴等快捷键操作。 +* **智能交互**:输入框智能提示、hover预览、流畅的鼠标交互体验。 +* **响应式布局**:界面支持不同布局模式,能根据窗口大小自动调整元素排列。 +* **富文本支持**:支持带颜色的文件引用显示和文本格式化。 \ No newline at end of file diff --git "a/\345\256\211\350\243\205\344\270\216\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/\345\256\211\350\243\205\344\270\216\351\205\215\347\275\256\346\214\207\345\215\227.md" new file mode 100644 index 0000000..43f6ab1 --- /dev/null +++ "b/\345\256\211\350\243\205\344\270\216\351\205\215\347\275\256\346\214\207\345\215\227.md" @@ -0,0 +1,672 @@ +# interactive-feedback-mcp 安装与配置指南 + +欢迎!本文档旨在指导您完成 `interactive-feedback-mcp` 服务的安装、配置以及在 AI 助手(如 Cursor)中的设置步骤。 + +## 目录 +- [interactive-feedback-mcp 安装与配置指南](#interactive-feedback-mcp-安装与配置指南) + - [目录](#目录) + - [开发安装(推荐)](#开发安装推荐) + - [开发安装步骤](#开发安装步骤) + - [快速安装(备选)](#快速安装备选) + - [方式一:使用uvx](#方式一使用uvx) + - [方式二:使用pip](#方式二使用pip) + - [环境准备](#环境准备) + - [Python](#python) + - [uv (Python 包安装工具)](#uv-python-包安装工具) + - [下载项目](#下载项目) + - [安装依赖](#安装依赖) + - [配置 MCP 服务](#配置-mcp-服务) + - [找到 `mcp_servers.json`](#找到-mcp_serversjson) + - [配置方式一:开发模式(推荐)](#配置方式一开发模式推荐) + - [推荐配置方式:开发模式 + UI 设置](#推荐配置方式开发模式--ui-设置) + - [配置方式二:uvx(备选)](#配置方式二uvx备选) + - [配置方式三:pip安装(备选)](#配置方式三pip安装备选) +- [开发模式环境变量配置已移除,请使用 UI 设置页面管理 API key](#开发模式环境变量配置已移除请使用-ui-设置页面管理-api-key) + - [配置文件管理](#配置文件管理) + - [配置文件概述](#配置文件概述) + - [1. `config.json` - 本地配置文件](#1-configjson---本地配置文件) + - [2. `config.template.json` - 配置模板文件](#2-configtemplatejson---配置模板文件) + - [配置管理方式](#配置管理方式) + - [当前推荐的配置方式](#当前推荐的配置方式) + - [✅ 推荐方式:UI 设置页面](#-推荐方式ui-设置页面) + - [❌ 不再支持的方式](#-不再支持的方式) + - [配置优先级](#配置优先级) + - [用户使用指南](#用户使用指南) + - [源代码用户](#源代码用户) + - [uvx 用户](#uvx-用户) + - [安全性说明](#安全性说明) + - [为什么移除配置文件中的 API key 支持?](#为什么移除配置文件中的-api-key-支持) + - [安全最佳实践](#安全最佳实践) + - [配置 AI 助手规则](#配置-ai-助手规则) + - [故障排除](#故障排除) + - [uvx安装故障排除](#uvx安装故障排除) + - [MCP配置问题](#mcp配置问题) + - [AI助手特定配置](#ai助手特定配置) + - [uv安装用户常见问题解决](#uv安装用户常见问题解决) + - [问题1:看不到预定义选项,只显示"继续"、"取消"等通用选项](#问题1看不到预定义选项只显示继续取消等通用选项) + - [问题2:音频提示音不工作或设置页面显示音频文件缺失](#问题2音频提示音不工作或设置页面显示音频文件缺失) + - [问题3:配置文件位置不明确](#问题3配置文件位置不明确) + - [注意事项](#注意事项) + +## 开发安装(推荐) + +**推荐使用开发模式安装,以获得最佳的稳定性和功能完整性。** + +开发模式安装提供: +- ✅ 完整的功能支持和最佳稳定性 +- ✅ 实时的代码更新和bug修复 +- ✅ 完整的资源文件和配置支持 +- ✅ 更好的调试和问题排查能力 +- ✅ 避免PyPI安装可能遇到的资源文件缺失问题 + +**当前版本:** v2.5.10 - 文档重大更新,推荐开发模式安装;修复UI控件选中状态视觉效果 + +### 开发安装步骤 + +如果您需要修改代码、参与开发,或希望获得最稳定的使用体验,请使用开发安装: + +## 快速安装(备选) + +### 方式一:使用uvx + +**无需安装,直接运行:** +```bash +uvx interactive-feedback@latest +``` + +**如果首次安装失败(通常由于PySide6等大包下载超时),可以预安装:** +```bash +uv tool install interactive-feedback@latest +``` + +### 方式二:使用pip + +**安装到系统或虚拟环境:** +```bash +pip install interactive-feedback +``` + +**PyPI项目页面:** https://pypi.org/project/interactive-feedback/ + +**注意:** PyPI安装可能存在资源文件缺失或配置问题,推荐使用开发模式安装。 + +## 环境准备 + +### Python +确保您的系统已安装 Python 3.11 或更高版本。您可以从 [Python 官方网站](https://www.python.org/downloads/) 下载并安装。 + +安装完成后,可以在终端或命令提示符中运行以下命令来验证 Python 版本: +```bash +python --version +# 或者 +python3 --version +``` +项目的 `pyproject.toml` 文件也指定了 `requires-python = ">=3.11"`。 + +### uv (Python 包安装工具) +本项目推荐使用 `uv` 进行包管理,它是一个非常快速的 Python 包安装和解析工具。 + +* **Windows**: + ```bash + pip install uv + ``` + 如果您尚未安装 `pip`,请先安装 Python,`pip` 通常会随之安装。 + +* **Linux/macOS**: + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + 或者参考 [`uv` 官方文档](https://github.com/astral-sh/uv) 获取其他安装方式。 + +安装完成后,可以通过运行 `uv --version` 来验证 `uv` 是否安装成功。 + +## 下载项目 +您可以使用 Git 克隆本项目的代码仓库到您的本地计算机。打开终端或命令提示符,然后运行以下命令: +```bash +git clone https://github.com/pawaovo/interactive-feedback-mcp.git +``` +这将在您当前的目录下创建一个名为 `interactive-feedback-mcp` 的文件夹,其中包含所有项目文件。 + +如果您不熟悉 Git,也可以直接从 GitHub 仓库页面下载 ZIP 压缩包并解压。 + +## 安装依赖 +项目运行所需的 Python 包在 `pyproject.toml` 文件中定义。项目已迁移到现代 Python 包管理,不再使用 `requirements.txt` 文件。 + +1. 首先,请确保您已经按照上述步骤下载了项目,并在终端或命令提示符中进入项目的主目录: + ```bash + cd path/to/interactive-feedback-mcp + ``` + 请将 `path/to/interactive-feedback-mcp` 替换为您实际的项目路径,如 `C:/Users/YourName/Projects/interactive-feedback-mcp` 。 + +2. 然后,使用 `uv` 安装项目依赖(开发模式安装): + ```bash + uv pip install -e . + ``` + 此命令会读取 `pyproject.toml` 文件,并自动下载和安装所有必要的库。`-e` 参数表示可编辑安装,这样对源代码的修改会立即生效。 + + **或者使用现代 uv 语法:** + ```bash + uv sync + ``` + 此命令使用 `uv.lock` 文件进行精确的依赖安装,确保版本一致性。 + + `pyproject.toml` 中包含的主要依赖项及其用途如下: + * `PySide6-Essentials>=6.8.2.1`: 用于创建图形用户界面 (GUI)。 + * `pyperclip>=1.8.2`: 用于跨平台的剪贴板操作(复制和粘贴)。 + * `Pillow>=9.0.0`: Python 图像处理库,用于处理和显示粘贴的图片。 + * `fastmcp>=2.0.0`: 模型上下文协议 (MCP) 的核心库。 + * `psutil>=7.0.0`: 用于访问系统进程和系统利用率信息。 + * `openai>=1.0.0`: 用于文本优化功能的AI提供商支持。 + * `pywin32>=228; sys_platform == "win32"`: 提供了访问 Windows 系统特定API的功能,仅在 Windows 系统上安装和使用。 + + `uv` 会自动处理特定平台的依赖项。 + +## 配置 MCP 服务 +为了让您的 AI 助手(如 Cursor)能够使用此 `interactive-feedback-mcp` 服务,您需要配置其 MCP 服务器设置。 + +### 找到 `mcp_servers.json` +此文件通常位于 AI 助手的用户配置目录中。对于 Cursor,它通常是: +* Windows: `%APPDATA%\Cursor\.cursor-ai\mcp_servers.json` 或 `~/.cursor-ai/mcp_servers.json` +* macOS: `~/.cursor-ai/mcp_servers.json` +* Linux: `~/.config/cursor/.cursor-ai/mcp_servers.json` 或 `~/.cursor-ai/mcp_servers.json` + +如果文件不存在,您可以创建一个。 + +### 配置方式一:开发模式(推荐) + +**最稳定的配置方式,使用现代 uv 包管理:** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "/path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +**请将 `/path/to/interactive-feedback-mcp` 替换为您实际的项目路径。** + +**备选配置(如果已通过 `uv pip install -e .` 安装):** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "interactive-feedback", + "cwd": "/path/to/interactive-feedback-mcp", + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +### 推荐配置方式:开发模式 + UI 设置 + +**MCP JSON 中配置开发模式,API key 通过 UI 设置页面管理:** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uv", + "args": [ + "--directory", + "/path/to/interactive-feedback-mcp", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +### 配置方式二:uvx(备选) + +**如果您选择使用uvx安装:** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "interactive-feedback@latest" + ], + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +**开发模式优势:** +- ✅ **最佳稳定性**:完整的功能支持和资源文件 +- ✅ **实时更新**:可以获得最新的代码修复 +- ✅ **完整功能**:避免PyPI安装可能遇到的问题 +- ✅ **灵活配置**:API key 通过 UI 界面管理 +- ✅ **多提供商**:支持多个 AI 提供商配置和切换 +- ✅ **用户友好**:直观的图形界面配置 + +**使用步骤:** +1. 克隆仓库并安装依赖 +2. 在 MCP JSON 中添加开发模式配置 +3. 重启 AI 助手 +4. 在 UI 设置页面中配置 API key +5. 开始使用所有功能 + +### 配置方式三:pip安装(备选) + +**如果您使用pip安装了包:** + +```json +{ + "mcpServers": { + "interactive-feedback": { + "command": "interactive-feedback", + "timeout": 600, + "autoApprove": [ + "interactive_feedback", + "optimize_user_input" + ] + } + } +} +``` + +# 开发模式环境变量配置已移除,请使用 UI 设置页面管理 API key + +**重要说明:** +* **路径替换**: 将配置中的路径替换为您在本地计算机上克隆项目的实际绝对路径。 +* **`autoApprove`**: 将工具名称添加到此列表意味着 AI 助手调用此工具时无需用户在IDE中手动批准。 + +配置完成后,保存 `mcp_servers.json` 文件。您可能需要重启 AI 助手才能使更改生效。 + +## 配置文件管理 + +### 配置文件概述 + +项目中有两个配置文件,它们的作用和用途不同: + +#### 1. `config.json` - 本地配置文件 + +**作用:** 实际的配置文件,包含真实的用户配置和 API key + +**特点:** +- ✅ 包含真实的 API key 和个人配置 +- ✅ 被 `.gitignore` 忽略,不会提交到 GitHub +- ✅ 用户的实际工作配置文件 +- ✅ 会被程序实际读取和使用 + +**位置:** +- 源代码用户:`D:\ai\interactive-feedback-mcp\config.json` +- uvx 用户:`~/.interactive-feedback/config.json` + +**内容示例:** +```json +{ + "display_mode": "full", + "enable_custom_options": true, + "expression_optimizer": { + "enabled": true, + "active_provider": "openai", + "providers": { + "openai": { + "api_key": "sk-proj-real-api-key-here", + "base_url": "https://api.openai.com/v1", + "model": "gpt-4o-mini" + } + } + } +} +``` + +#### 2. `config.template.json` - 配置模板文件 + +**作用:** 配置文件模板,用于 GitHub 提交和用户参考 + +**特点:** +- ✅ 不包含任何私人信息(API key 为空) +- ✅ 会提交到 GitHub,供用户参考 +- ✅ 展示完整的配置结构和默认值 +- ✅ 包含配置说明和注释 + +**用途:** +1. **GitHub 提交**:作为安全的配置示例 +2. **用户参考**:展示可用的配置选项 +3. **文档作用**:说明配置文件的结构 +4. **开发参考**:新功能的配置示例 + +### 配置管理方式 + +#### 当前推荐的配置方式 + +**不再支持在配置文件中直接填写 API key!** + +##### ✅ 推荐方式:UI 设置页面 +1. **MCP JSON 配置**:仅配置服务,不包含 API key + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "tool", + "run", + "interactive-feedback@latest" + ], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + +2. **API key 配置**:通过 UI 设置页面管理 + - 打开 UI 设置页面 + - 在"输入表达优化"部分配置 API key + - 支持多个提供商配置和切换 + - 配置立即保存和生效 + +##### ❌ 不再支持的方式 +- ~~环境变量配置 API key~~(已移除) +- ~~在配置文件中直接填写 API key~~(不推荐) + +### 配置优先级 + +**简化的配置优先级:** +``` +UI 设置页面 > 配置文件 > 默认配置 +``` + +**说明:** +- **UI 设置页面**:用户在界面中的配置具有最高优先级 +- **配置文件**:`config.json` 中的其他配置项 +- **默认配置**:程序内置的默认值 + +### 用户使用指南 + +#### 源代码用户 +1. **克隆项目**:`git clone https://github.com/pawaovo/interactive-feedback-mcp.git` +2. **参考模板**:查看 `config.template.json` 了解配置选项 +3. **配置 API key**:通过 UI 设置页面配置 +4. **自定义配置**:可以修改 `config.json` 中的其他配置项 + +#### uvx 用户 +1. **MCP 配置**:在 MCP JSON 中添加服务配置 +2. **首次启动**:自动生成 `~/.interactive-feedback/config.json` +3. **配置 API key**:通过 UI 设置页面配置 +4. **自定义配置**:通过 UI 设置页面管理所有配置 + +### 安全性说明 + +#### 为什么移除配置文件中的 API key 支持? + +1. **避免意外泄露**:防止 API key 被意外提交到版本控制 +2. **统一管理方式**:所有用户都通过 UI 管理 API key +3. **消除配置冲突**:避免环境变量和配置文件的优先级混淆 +4. **提升用户体验**:UI 配置更直观、更安全 + +#### 安全最佳实践 + +1. **永远不要**在配置文件中填写真实的 API key +2. **使用 UI 设置页面**管理所有敏感信息 +3. **定期检查** `.gitignore` 确保 `config.json` 被忽略 +4. **参考模板文件**了解配置选项,但不要在其中填写敏感信息 + +## 配置 AI 助手规则 +为了让 AI 助手在适当的时候调用 `interactive-feedback` 服务,您需要添加一些自定义规则。 + +在 Cursor 中,您可以通过 "设置" -> "Rules" -> "User Rules" (或类似路径) 添加以下规则: + +``` +Whenever you want to ask a question, always call the interactive_feedback MCP +If requirements or instructions are unclear use the tool interactive_feedback to ask clarifying questions to the user before proceeding, do not make assumptions. Whenever possible, present the user with predefined options through the interactive_feedback MCP tool to facilitate quick decisions. +Whenever you're about to complete a user request, call the interactive_feedback tool to request user feedback before ending the process. If the feedback is empty you can end the request and don't call the tool in loop. +``` + +这些规则会指示 AI 助手: +1. 当需求或指令不明确时,使用 `interactive_feedback` 工具向用户提问并澄清。 +2. 在即将完成用户请求时,调用 `interactive_feedback` 工具请求用户反馈。 + +## 故障排除 + +如果在安装或配置过程中遇到问题,请参考以下解决方案: + +### uvx安装故障排除 + +**问题1**:首次uvx安装失败,通常由于PySide6等大包下载超时。 + +**解决方案**: +1. **预安装工具**: + ```bash + uv tool install interactive-feedback@latest + ``` + +2. **修改MCP配置**(预安装后): + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "uvx", + "args": [ + "tool", + "run", + "interactive-feedback" + ], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + +**配置方式区别**: +- `@latest`:临时运行,每次都下载最新版本 +- 不带版本号:使用已安装的工具,启动更快 + +**重要说明**: +- `uvx` 是 `uv tool run` 的别名,因此配置中只需要 `["interactive-feedback@latest"]` +- ❌ 错误:`["tool", "run", "interactive-feedback@latest"]` (这会导致重复的 tool run 命令) +- ✅ 正确:`["interactive-feedback@latest"]` + +**问题2**:playsound依赖构建失败,错误信息包含 "Failed to build `playsound==1.3.0`"。 + +**解决方案**: +这是已知的兼容性问题,已在v2.5.9.6版本中修复。请更新到最新版本: +```bash +# 使用uvx更新 +uvx interactive-feedback@latest + +# 或使用pip更新 +pip install --upgrade interactive-feedback +``` + +新版本使用原生音频播放方案,无需playsound依赖,兼容性更好。 + +**问题3**:MCP配置中使用 `"command": "uvx"` 时出现"命令未找到"或"无法启动"错误。 + +**解决方案**: + +1. **检查uvx安装位置** + + 首先确认uvx的实际安装路径: + ```bash + # Windows + where uvx + + # Linux/macOS + which uvx + ``` + +2. **使用完整路径替换uvx** + + 如果uvx不在系统PATH中,请在MCP配置中使用完整路径: + + **Windows示例**: + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "D:/python/Scripts/uv.exe", + "args": [ +interactive-feedback@latest + ], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + + **Linux/macOS示例**: + ```json + { + "mcpServers": { + "interactive-feedback": { + "command": "/home/username/.local/bin/uv", + "args": [ +interactive-feedback@latest + ], + "timeout": 600, + "autoApprove": ["interactive_feedback"] + } + } + } + ``` + +3. **常见uvx路径** + - Windows: `C:\Users\用户名\AppData\Local\Programs\Python\Python3xx\Scripts\uv.exe` + - Windows (通过pip安装): `D:\python\Scripts\uv.exe` + - Linux: `~/.local/bin/uv` 或 `/usr/local/bin/uv` + - macOS: `~/.local/bin/uv` 或 `/opt/homebrew/bin/uv` + +### MCP配置问题 + +**问题现象**:AI助手无法识别或启动interactive-feedback服务。 + +**解决方案**: + +1. **验证JSON格式** + + 确保 `mcp_servers.json` 文件格式正确,可以使用在线JSON验证器检查。 + +2. **检查文件位置** + + 确认配置文件在正确位置: + - Cursor: `~/.cursor-ai/mcp_servers.json` + - Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) + +3. **重启AI助手** + + 修改配置后务必重启AI助手应用程序。 + +4. **查看日志** + + 检查AI助手的日志文件,通常包含MCP服务启动的详细错误信息。 + +### AI助手特定配置 + +**针对不同AI助手的配置建议**: + +1. **Cursor用户** + + 如果在Cursor中配置遇到问题,可以: + - 询问AI:"我在Cursor中配置MCP服务时遇到问题,请帮我检查这个配置文件" + - 提供您的配置文件内容,让AI帮助诊断问题 + +2. **Claude Desktop用户** + + 配置文件名称和位置可能不同,请参考Claude Desktop官方文档。 + +3. **其他AI助手** + + 如果使用其他支持MCP的AI助手: + - 查阅该助手的MCP配置文档 + - 将配置问题和错误信息提供给AI助手,请求具体的配置建议 + +## uv安装用户常见问题解决 + +### 问题1:看不到预定义选项,只显示"继续"、"取消"等通用选项 + +**原因**:v2.5.9.12及之前版本中,自定义选项功能默认禁用 + +**解决方案**: +1. **升级到最新版本**(推荐): + ```bash + uvx interactive-feedback@latest + ``` + +2. **手动启用自定义选项**: + - 打开设置页面 + - 找到"启用自定义选项"开关并启用 + - 或编辑配置文件 `~/.interactive-feedback/config.json`,设置 `"enable_custom_options": true` + +### 问题2:音频提示音不工作或设置页面显示音频文件缺失 + +**原因**:uv安装环境下音频文件路径解析问题 + +**解决方案**: +1. **升级到最新版本**(推荐): + ```bash + uvx interactive-feedback@latest + ``` + +2. **检查音频状态**: + - 打开设置页面 → 音频设置 + - 查看"默认音频文件"状态显示 + - 如果显示"✗",说明需要升级版本 + +3. **使用自定义音频文件**: + - 在音频设置中浏览并选择自己的音频文件 + - 支持格式:WAV、MP3、OGG、FLAC、AAC + +### 问题3:配置文件位置不明确 + +**配置文件位置**: +- **uv安装用户**:`~/.interactive-feedback/config.json` + - Windows: `%USERPROFILE%\.interactive-feedback\config.json` + - Linux/macOS: `~/.interactive-feedback/config.json` +- **开发模式用户**:项目根目录 `config.json` + +**配置文件会自动创建**,无需手动创建。 + +**通用调试步骤**: + +1. 确认Python和uv/uvx正确安装 +2. 手动测试命令是否可以在终端中运行 +3. 检查防火墙和安全软件设置 +4. 查看AI助手的错误日志 +5. 如果问题持续,请在GitHub Issues中报告问题 + +## 注意事项 +* **路径配置**:`mcp_servers.json` 中的 `cwd` 路径必须正确无误,指向项目根目录,否则服务可能无法启动或找不到脚本文件。 +* **Python 版本**:请务必使用 Python 3.11 或更高版本,以避免兼容性问题。 +* **防火墙/安全软件**:确保您的防火墙或安全软件没有阻止 Python 或 `uv` 运行本地服务,或者阻止本地网络通信(通常是 `127.0.0.1` 上的某个端口)。 + +如果您在安装或使用过程中遇到任何问题,可以查阅项目 GitHub 仓库的 Issues 区,或提出新的 Issue。 +