From 1b60ab0a2e2a3e71400e4100899588f27734fcff Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Mon, 27 Jul 2026 21:39:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=90=E7=BB=B4=E6=9C=BA?= =?UTF-8?q?=E5=99=A8=E4=BA=BA=E5=8D=95=E5=85=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aibox/.gitignore | 108 + aibox/LICENSE | 21 + aibox/README.md | 649 +++ aibox/SKILLS_FORMAT.md | 229 + aibox/_run.sh | 8 + aibox/_runcli.sh | 7 + aibox/_set.sh | 6 + aibox/_setenv.sh | 1 + aibox/client.py | 667 +++ aibox/entry.html | 1780 ++++++ aibox/entry1.html | 875 +++ aibox/frontend.html | 1687 ++++++ aibox/main.py | 8146 ++++++++++++++++++++++++++++ aibox/requirements.txt | 16 + aibox/skills/163mail.py | 593 ++ aibox/skills/ads1115.py | 431 ++ aibox/skills/ads1256.py | 420 ++ aibox/skills/aes-crypto.py | 616 +++ aibox/skills/angry.md | 29 + aibox/skills/ask.md | 42 + aibox/skills/bing_search.py | 114 + aibox/skills/camera.py | 392 ++ aibox/skills/coquettish.md | 36 + aibox/skills/doc2txt.py | 104 + aibox/skills/facerec.py | 399 ++ aibox/skills/famer.sh | 36 + aibox/skills/gitclient.py | 424 ++ aibox/skills/hello-world.py | 51 + aibox/skills/hello.md | 23 + aibox/skills/info.py | 285 + aibox/skills/md2docx.py | 125 + aibox/skills/nmapskills.py | 97 + aibox/skills/qing_famer.sh | 36 + aibox/skills/screenshot.py | 297 + aibox/skills/sftp-transfer.py | 259 + aibox/skills/ssh-exec.py | 77 + aibox/skills/test-auto-appimage.sh | 269 + aibox/skills/test_qemu_run.sh | 175 + aibox/skills/tsundere.md | 49 + aibox/skills/vision.py | 204 + aibox/tasks/ITIL.md | 12 + aibox/voice_client.py | 674 +++ 42 files changed, 20469 insertions(+) create mode 100644 aibox/.gitignore create mode 100644 aibox/LICENSE create mode 100644 aibox/README.md create mode 100644 aibox/SKILLS_FORMAT.md create mode 100644 aibox/_run.sh create mode 100644 aibox/_runcli.sh create mode 100644 aibox/_set.sh create mode 100644 aibox/_setenv.sh create mode 100644 aibox/client.py create mode 100644 aibox/entry.html create mode 100644 aibox/entry1.html create mode 100644 aibox/frontend.html create mode 100644 aibox/main.py create mode 100644 aibox/requirements.txt create mode 100755 aibox/skills/163mail.py create mode 100755 aibox/skills/ads1115.py create mode 100755 aibox/skills/ads1256.py create mode 100755 aibox/skills/aes-crypto.py create mode 100644 aibox/skills/angry.md create mode 100644 aibox/skills/ask.md create mode 100755 aibox/skills/bing_search.py create mode 100755 aibox/skills/camera.py create mode 100644 aibox/skills/coquettish.md create mode 100755 aibox/skills/doc2txt.py create mode 100755 aibox/skills/facerec.py create mode 100755 aibox/skills/famer.sh create mode 100755 aibox/skills/gitclient.py create mode 100755 aibox/skills/hello-world.py create mode 100644 aibox/skills/hello.md create mode 100755 aibox/skills/info.py create mode 100755 aibox/skills/md2docx.py create mode 100755 aibox/skills/nmapskills.py create mode 100755 aibox/skills/qing_famer.sh create mode 100755 aibox/skills/screenshot.py create mode 100755 aibox/skills/sftp-transfer.py create mode 100755 aibox/skills/ssh-exec.py create mode 100755 aibox/skills/test-auto-appimage.sh create mode 100755 aibox/skills/test_qemu_run.sh create mode 100644 aibox/skills/tsundere.md create mode 100755 aibox/skills/vision.py create mode 100644 aibox/tasks/ITIL.md create mode 100644 aibox/voice_client.py diff --git a/aibox/.gitignore b/aibox/.gitignore new file mode 100644 index 0000000..96b1afb --- /dev/null +++ b/aibox/.gitignore @@ -0,0 +1,108 @@ +#PRJ +.arts/ +skbin/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/aibox/LICENSE b/aibox/LICENSE new file mode 100644 index 0000000..90e9277 --- /dev/null +++ b/aibox/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 卿松 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/aibox/README.md b/aibox/README.md new file mode 100644 index 0000000..b2f9666 --- /dev/null +++ b/aibox/README.md @@ -0,0 +1,649 @@ +# 单AI智能体系统 - WebSocket版本(增强版) + +一个功能强大的AI智能体系统,通过WebSocket与客户端交互,支持文件操作、网页访问、记忆管理、任务调度、扩展技能等功能。所有任务都通过AI对话执行,支持实时流式输出和任务状态广播。 + +## ✨ 核心特性 + +- 🤖 **单AI智能体**:基于DeepSeek API的智能对话系统,支持思考模式 +- 🔌 **WebSocket通信**:实时双向通信,支持流式输出和任务状态广播 +- 📁 **文件操作**:读写文件、目录列表(支持大文件分页) +- 🌐 **网页访问**:获取网页内容、提取标题、链接、搜索信息 +- 🧠 **记忆管理**:自动保存和检索对话记忆,保持对话连贯性 +- ⏰ **任务系统**:支持一次性、重复(daily/weekly/monthly/custom)、即时任务,自动触发AI对话 +- 🔧 **扩展技能**:动态加载`~/.aibox/skills/`目录下的可执行脚本或Markdown技能文件 +- 📦 **对话压缩**:当消息数或Token数达到阈值时自动压缩,保持上下文质量 +- 🌍 **多语言支持**:自动检测系统语言,支持中英文 +- 🧬 **智能体自我进化**:通过`update_soul`工具动态更新角色定位和性格定义 +- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能 +- 💾 **对话管理**:历史对话归档、开始新对话(重置AI记忆) + +## 🚀 快速开始 + +### 环境要求 + +- Python 3.8+ +- DeepSeek API Key +- 操作系统:Linux/macOS/Windows + +### 安装 + +```bash +# 克隆或下载代码 +# 安装依赖 +pip install websockets requests beautifulsoup4 +``` + +### 配置 + +1. **设置DeepSeek API Key**: +```bash +export DEEPSEEK_API_KEY="your-api-key" +``` + +2. **(可选)配置文件**:`~/.aibox/config.json`(系统会自动创建默认配置) + +3. **启动服务器**: +```bash +python agent.py +``` + +启动后,WebSocket服务器将运行在 `ws://localhost:8765` + +## 📁 目录结构 + +``` +~/.aibox/ +├── config.json # 配置文件 +├── prompts/ # 系统提示词目录 +│ ├── 00_IDENTITY.md # 用户身份信息 +│ ├── 1_*.md # 基础技能提示词(文件操作、网页访问等) +│ ├── 99_SOUL.md # AI灵魂定义(角色、性格、能力边界) +│ └── 100_STATUS.md # AI状态记录 +├── skills/ # 扩展技能目录 +│ ├── my_skill # 可执行技能(无扩展名) +│ └── guide.md # Markdown技能 +├── memories.db # 记忆和任务数据库 +├── current_conversation.json # 当前对话 +├── conversation_history.json # 对话索引 +├── archive/ # 历史对话归档 +└── tool_results/ # 工具结果缓存 +``` + +## 🔧 可用工具 + +AI智能体可以调用以下工具来完成任务: + +| 工具名称 | 功能描述 | +|---------|---------| +| `read_file` | 读取文件内容(支持分页、head/tail) | +| `write_file` | 写入或追加文件 | +| `list_files` | 列出目录内容 | +| `web` | 访问网页、获取标题/文本/链接 | +| `memory` | 保存、搜索(支持分页)、删除记忆 | +| `memo` | 创建、查看、完成、搜索任务 | +| `run_command` | 执行系统命令(实时输出,支持超时控制) | +| `search_skills` | 搜索扩展技能(支持Markdown和可执行技能) | +| `reload_skills` | 热重载扩展技能 | +| `create_skill` | 创建新技能(AI自主编写Python脚本) | +| `install_skill` | 技能安装指引 | +| `update_identity` | 更新用户身份信息 | +| `update_soul` | 更新AI自我定义(智能体进化) | +| `save_memory_and_end_conversation` | 保存记忆并结束对话 | +| `get_current_time` | 获取当前时间 | +| `calculator` | 数学计算 | + +## 💬 WebSocket API + +### 连接 + +```javascript +const ws = new WebSocket('ws://localhost:8765'); +``` + +### 发送消息格式 + +#### 1. 普通消息 +```json +{ + "type": "message", + "content": "你好,请帮我..." +} +``` + +#### 2. 命令 +```json +{ + "type": "command", + "command": "/new" +} +``` + +#### 3. 任务操作 +```json +{ + "type": "task", + "action": "add", + "title": "提醒我喝水", + "content": "下午3点喝水", + "delay": 3600, + "repeat_type": "daily", + "is_immediate": false +} +``` + +#### 4. Ping +```json +{ + "type": "ping" +} +``` + +### 支持的命令 + +| 命令 | 功能 | +|-----|------| +| `/exit` | 退出连接 | +| `/new` | 开始新对话(保存当前,重置AI记忆,重新加载提示词) | +| `/list` | 列出所有历史对话 | +| `/memories` | 查看最近记忆(支持分页) | +| `/memos` | 查看待办任务 | +| `/search <关键词>` | 搜索记忆 | +| `/config` | 查看配置 | +| `/reload` | 重载配置 | +| `/reload_prompts` | 重载提示词 | +| `/check` | 检查过期任务 | +| `/tasks` | 查看任务调度器状态 | +| `/help` | 显示帮助 | + +### 接收消息类型 + +| 类型 | 说明 | +|-----|------| +| `chunk` | AI响应的片段(流式输出) | +| `complete` | AI响应完成 | +| `reasoning` | AI思考过程(调试模式) | +| `error` | 错误信息 | +| `command_result` | 命令执行结果 | +| `task_status` | 任务状态更新(executing/complete/failed) | +| `task_process` | 任务执行过程中的AI输出 | +| `dialogue_ended` | 对话结束通知 | +| `dialogue_reset` | 对话重置完成 | +| `info` | 系统信息 | +| `pong` | Ping响应 | + +## ⏰ 任务系统 + +### 任务类型 + +1. **一次性任务**:在指定时间执行一次 +2. **重复任务**: + - `daily`:每天执行 + - `weekly`:每周执行 + - `monthly`:每月执行 + - `custom`:自定义间隔(分钟/小时/天) +3. **即时任务**:立即执行(`is_immediate: true`) + +### 添加任务示例 + +```json +// 每天提醒 +{ + "type": "task", + "action": "add", + "title": "每日健康提醒", + "repeat_type": "daily" +} +``` + +```json +// 5分钟后执行 +{ + "type": "task", + "action": "add", + "title": "5分钟后提醒", + "delay": 300 +} +``` + +```json +// 每30分钟重复 +{ + "type": "task", + "action": "add", + "title": "定时检查", + "repeat_type": "custom", + "repeat_interval": 30, + "repeat_interval_unit": "minutes" +} +``` + +```json +// 即时任务 +{ + "type": "task", + "action": "add", + "title": "检查服务器状态", + "is_immediate": true +} +``` + +### 任务执行流程 + +1. 任务到期后,系统自动标记为已触发 +2. 创建新的AI对话,将任务内容作为用户输入 +3. AI处理任务,使用工具执行操作 +4. 任务执行结果实时广播到所有WebSocket客户端 +5. 重复任务自动计算下一次执行时间 + +## 🔧 扩展技能 + +### 技能类型 + +#### 1. 可执行技能 +- 放在 `~/.aibox/skills/` 目录 +- 文件必须可执行(`chmod +x`) +- 支持 `--help` 选项 +- `--help` 第一行为一句话简介,其余为使用说明 + +**示例**: +```python +#!/usr/bin/env python3 +import argparse + +def main(): + parser = argparse.ArgumentParser( + description="温度转换工具 - 摄氏度转华氏度" + ) + parser.add_argument("-t", "--temp", type=float, required=True, + help="摄氏温度值") + args = parser.parse_args() + + fahrenheit = args.temp * 9/5 + 32 + print(f"{args.temp}°C = {fahrenheit:.1f}°F") + +if __name__ == "__main__": + main() +``` + +#### 2. Markdown技能 +- 文件以 `.md` 结尾 +- 第一行为简介(`# 标题` 或纯文本) +- 其余为完整的技能内容 + +**示例**: +```markdown +# 代码审查指南 +提供代码审查的检查清单和最佳实践... + +## 检查要点 +1. 代码风格一致性 +2. 性能优化建议 +3. 安全漏洞检查 +4. 可维护性评估 +``` + +### 创建技能 + +#### 方式1:AI自主创建(推荐) +直接告诉AI: +``` +创建一个技能,功能是将摄氏度转换为华氏度,接受 -c 参数输入温度 +``` +AI会自动使用 `create_skill` 工具编写脚本并部署。 + +#### 方式2:手动创建 +1. 编写脚本,确保支持 `--help` +2. 复制到 `~/.aibox/skills/` 并去掉扩展名 +3. 添加可执行权限 +4. 调用 `reload_skills` 工具 + +```bash +cp my_skill.py ~/.aibox/skills/my_skill +chmod +x ~/.aibox/skills/my_skill +``` + +#### 方式3:使用安装指引 +```json +{ + "type": "message", + "content": "使用 install_skill 工具安装 /path/to/script.py" +} +``` + +## 🧠 智能体自我进化 + +AI可以通过 `update_soul` 工具动态更新 `99_SOUL.md` 文件: + +```json +{ + "type": "message", + "content": "使用 update_soul 更新我的角色,强调我更注重创造性思维" +} +``` + +这允许AI在对话中不断优化自己的: +- 角色定位 +- 性格特点 +- 能力边界 +- 行为准则 +- 价值观 + +## 📦 对话压缩 + +当对话达到阈值时自动压缩: + +```json +// 配置示例 +"context": { + "max_history_messages": 500, + "compress_enabled": true, + "compress_message_threshold": 200, // 消息数阈值 + "compress_token_threshold": 120000, // Token数阈值 + "compress_keep_recent": 8 // 保留最近消息数 +} +``` + +压缩过程: +1. 检测到消息数或Token数超过阈值 +2. 调用AI压缩早期对话内容 +3. 保留最近的重要消息 +4. 将压缩摘要作为系统消息 +5. 压缩过程静默执行,不影响用户体验 + +## 🧩 提示词系统 + +提示词文件位于 `~/.aibox/prompts/`: + +| 文件 | 用途 | 更新方式 | +|-----|------|---------| +| `00_IDENTITY.md` | 用户身份信息 | AI通过 `update_identity` 工具更新 | +| `1_*.md` | 基础技能提示词 | 手动编辑,AI通过 `reload_prompts` 重载 | +| `99_SOUL.md` | AI灵魂定义(角色、性格) | AI通过 `update_soul` 工具更新 | +| `100_STATUS.md` | AI状态记录 | AI通过 `save_memory_and_end_conversation` 更新 | + +## ⚙️ 配置说明 + +主要配置项(`~/.aibox/config.json`): + +```json +{ + "system": { + "save_dir": "~/.aibox", + "websocket_host": "localhost", + "websocket_port": 8765, + "debug_level": 1, + "log_file": "~/.aibox/ai.log" + }, + "context": { + "max_history_messages": 500, + "compress_enabled": true, + "compress_message_threshold": 200, + "compress_token_threshold": 120000, + "compress_keep_recent": 8 + }, + "scheduler": { + "enabled": true, + "check_interval": 1.0, + "max_tasks_per_run": 10 + }, + "api": { + "deepseek_api_url": "https://api.deepseek.com/chat/completions", + "deepseek_model": "deepseek-chat", + "api_key_env": "DEEPSEEK_API_KEY" + }, + "memory": { + "max_search_results": 100 + }, + "tools": { + "max_result_length": 10000 + } +} +``` + +## 🛠️ 命令行参数 + +```bash +python agent.py [选项] + +选项: + --verbose, -v 显示详细日志 + --debug LEVEL 调试级别 (0-3) + --lang zh|en 强制语言 + --host HOST WebSocket主机 + --port PORT WebSocket端口 + --no-scheduler 禁用任务调度器 + --scheduler-interval 调度器检查间隔(秒) + --config PATH 指定配置文件路径 + --check-memo 检查到期任务并退出 + --memo-add "标题|内容|时间" 添加任务 + --memo-list 列出任务 + --memo-complete ID 完成任务 +``` + +### 使用示例 + +```bash +# 启动服务器(详细日志) +python agent.py --verbose + +# 指定端口 +python agent.py --port 9000 + +# 检查任务 +python agent.py --check-memo + +# 添加任务(命令行) +python agent.py --memo-add "提醒开会|下午3点会议室A|+1h" + +# 查看所有任务 +python agent.py --memo-list +``` + +## 🔄 架构流程 + +``` +┌─────────────────────────────────────────────────────────┐ +│ WebSocket 客户端 │ +└─────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────▼───────────────────────────────────────┐ +│ WebSocket 服务器 (端口 8765) │ +├─────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ +│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │ +│ └─────────────┘ └─────────────┘ └───────────────┘ │ +└─────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────▼───────────────────────────────────────┐ +│ AI 对话引擎 (DeepSeek) │ +├─────────────────────────────────────────────────────────┤ +│ ┌───────────┐ ┌───────────┐ ┌──────────────────┐ │ +│ │ 提示词管理器│ │ 工具注册器 │ │ 对话压缩器 │ │ +│ └───────────┘ └───────────┘ └──────────────────┘ │ +└─────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────▼───────────────────────────────────────┐ +│ 数据持久层 │ +├─────────────────────────────────────────────────────────┤ +│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │ +│ │ 记忆数据库 │ │ 备忘录DB │ │ 对话历史管理器 │ │ +│ └──────────┘ └──────────┘ └────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## 📊 消息流示例 + +### 普通对话流程 +``` +Client -> Server: {type: "message", content: "帮我查天气"} +Server -> Client: {type: "chunk", content: "正在查"} +Server -> Client: {type: "chunk", content: "询天气..."} +Server -> Client: {type: "tool_call", tool: "search_skills", args: {keyword: "天气"}} +Server -> Client: {type: "chunk", content: "今日天气晴朗..."} +Server -> Client: {type: "complete", content: "今日天气晴朗,温度25°C"} +``` + +### 任务执行流程 +``` +Client -> Server: {type: "task", action: "add", title: "提醒", delay: 60} +Server -> Client: {type: "task_added", task_id: 1} + +... 60秒后 ... + +Server -> Client: {type: "task_status", task_id: 1, status: "executing"} +Server -> Client: {type: "task_process", task_id: "1", content: "处理中..."} +Server -> Client: {type: "task_status", task_id: 1, status: "complete"} +``` + +### 对话重置流程 +``` +Client -> Server: {type: "command", command: "/new"} +Server -> Client: {type: "command_result", content: "🔄 开始新对话..."} +Server -> Client: {type: "dialogue_reset", content: "✅ 对话状态已完全重置"} +``` + +## ❓ 常见问题 + +### Q: API Key无效怎么办? +**A:** +1. 确认环境变量已设置:`echo $DEEPSEEK_API_KEY` +2. 检查配置文件中的 `api_key_env` 设置 +3. 创建 `~/.cert/deepseek.key` 文件并写入API Key + +### Q: 技能不生效? +**A:** +1. 确认文件在 `~/.aibox/skills/` 目录 +2. 确认文件可执行(`chmod +x`) +3. 调用 `reload_skills` 工具 +4. 使用 `search_skills` 验证 +5. 检查文件命名(不能有扩展名) + +### Q: 任务没有执行? +**A:** +1. 检查调度器是否启用:`/tasks` +2. 查看任务状态:`/memos` +3. 检查系统时间是否正确 +4. 查看日志:`tail -f ~/.aibox/ai.log` +5. 确认任务有 `reminder_time` + +### Q: 对话压缩丢失了重要信息? +**A:** 调整配置: +```json +"compress_message_threshold": 300, // 调大阈值 +"compress_keep_recent": 20 // 保留更多最近消息 +``` + +### Q: 如何备份数据? +**A:** +```bash +# 备份整个目录 +tar -czf aibox_backup_$(date +%Y%m%d).tar.gz ~/.aibox/ + +# 恢复 +tar -xzf aibox_backup_20260101.tar.gz -C ~/ +``` + +### Q: WebSocket连接断开? +**A:** +1. 检查服务器是否运行:`ps aux | grep agent.py` +2. 确认端口未被占用:`netstat -tlnp | grep 8765` +3. 检查防火墙设置 +4. 查看服务器日志 +5. 考虑使用 `--host 0.0.0.0` 允许外部连接 + +### Q: 如何让AI使用特定技能? +**A:** 在对话中明确指示: +``` +使用 search_skills 搜索天气相关技能,然后用 run_command 执行它 +``` + +### Q: 内存或日志占用过大? +**A:** +1. 调整日志配置: +```json +"log_max_size": 5242880, // 5MB +"log_backup_count": 3 +``` +2. 清理旧对话归档 +3. 定期清理工具结果缓存 + +## 📄 更新日志 + +### v2.0 (当前版本) +- ✅ 新增 `update_soul` 工具(智能体自我进化) +- ✅ 新增 `create_skill` 工具(AI自主创建技能) +- ✅ 新增 `install_skill` 工具(技能安装指引) +- ✅ 新增 `task_process` 消息类型(任务执行过程广播) +- ✅ 支持Markdown格式技能文件 +- ✅ 对话压缩支持Token数阈值判断 +- ✅ 开始新对话时完全重置AI状态并重新加载提示词 +- ✅ 智能体主动结束对话后重置状态 +- ✅ 任务执行时显示AI完整处理过程 +- ✅ 增强的流式输出支持(思考内容显示) +- ✅ 技能热重载支持(`reload_skills`) +- ✅ 记忆搜索支持分页 +- ✅ 任务调度器支持即时任务 +- ✅ 命令执行支持超时控制 + +### v1.0 +- ✅ WebSocket服务器支持 +- ✅ 文件操作、网页访问、记忆管理 +- ✅ 任务调度系统 +- ✅ 扩展技能支持 +- ✅ 多语言支持 +- ✅ 对话管理 + +## 📝 开发指南 + +### 添加新工具 + +1. 继承 `Tool` 基类 +2. 实现 `get_name()`, `get_description()`, `get_parameters()`, `execute()` +3. 可选实现 `execute_streaming()` 支持流式输出 +4. 在 `DeepSeekChat._register_tools()` 中注册 + +```python +class MyTool(Tool): + def get_name(self) -> str: + return "my_tool" + + def get_description(self) -> str: + return "我的自定义工具" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "param": {"type": "string"} + }, + "required": ["param"] + } + + def execute(self, param: str, **kwargs) -> str: + return f"执行结果: {param}" +``` + +### 添加新命令 + +在 `WebSocketHandler.handle_command()` 中添加: + +```python +elif command == "/mycommand": + result = "命令执行结果" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) +``` + +## 🤝 贡献 + +欢迎提交Issue和Pull Request! + +## 📄 许可证 + +MIT License + +--- + +**注意**:本项目需要DeepSeek API Key,请确保遵守API使用条款。 \ No newline at end of file diff --git a/aibox/SKILLS_FORMAT.md b/aibox/SKILLS_FORMAT.md new file mode 100644 index 0000000..3eb1732 --- /dev/null +++ b/aibox/SKILLS_FORMAT.md @@ -0,0 +1,229 @@ +# Aibox 扩展技能格式规范 + +本文档定义扩展技能的文件结构、接口约定和编写规范,供开发者与 AI 智能体参考。 + +## 概览 + +技能存放于 `~/.aibox/skills/` 目录,系统启动时自动扫描加载,运行时可通过 `reload_skills` 热重载。技能分为两种类型: + +| 类型 | 识别方式 | 用途 | +|------|---------|------| +| 可执行技能 | 非 `.md` 文件,具有可执行权限 | 命令行工具,通过 `run_command` 调用 | +| Markdown 技能 | 以 `.md` 结尾 | 知识文档,AI 直接阅读并遵循 | + +文件名仅允许字母、数字、下划线 `_` 和连字符 `-`,以 `_` 或 `.` 开头的文件会被忽略。 + +--- + +## 可执行技能 + +可执行技能是标准的命令行程序,系统仅依赖一个接口约定:**必须支持 `--help` 参数**。系统通过执行 ` --help` 获取技能摘要和使用说明,不需要实现 `--description`、`--parameters`、`--execute` 等额外接口。 + +### 接口约定 + +系统按以下规则提取技能信息: + +1. 执行 ` --help`,超时上限 10 秒 +2. 取 stdout 的第一个非空行作为**摘要**(若 stdout 为空则回退到 stderr) +3. 完整的 `--help` 输出作为技能**内容**,供 `search_skills` 展示 + +因此,`--help` 输出的第一行必须是一句简洁的功能描述,后续内容是完整的参数说明和使用示例。 + +### Python 技能模板 + +```python +#!/usr/bin/env python3 +""" +技能模块内部说明(可选,不影响系统识别) +""" + +import argparse +import sys + + +def main(): + parser = argparse.ArgumentParser( + description="一句话功能摘要 —— 这是 --help 的第一行输出", + epilog=""" +参数说明: + -x, --xxx 参数含义说明 + -y, --yyy 另一个参数说明 + +使用示例: + my_skill -x value # 基本用法 + my_skill -x value -y 10 # 组合用法 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-x", "--xxx", type=str, required=True, help="参数说明") + parser.add_argument("-y", "--yyy", type=int, default=10, help="参数说明 (默认 10)") + + args = parser.parse_args() + + # --- 业务逻辑 --- + print(f"执行结果: xxx={args.xxx}, yyy={args.yyy}") + + +if __name__ == "__main__": + main() +``` + +### 关键要素 + +**Shebang 行**:Python 脚本使用 `#!/usr/bin/env python3`,Shell 脚本使用 `#!/bin/bash`。 + +**argparse `description`**:这是系统识别的技能摘要,必须是一句话,精准概括技能功能。它直接决定 AI 能否在 `search_skills` 时正确匹配到这个技能。 + +**argparse `epilog`**:包含两部分内容——参数说明和使用示例。使用 `RawDescriptionHelpFormatter` 保持排版原样输出。 + +**错误处理**:向 stderr 输出错误信息,以非零退出码退出。正常结果输出到 stdout。 + +```python +# 正确做法 +print("错误: 参数无效", file=sys.stderr) +sys.exit(1) + +# 避免 +print("错误: 参数无效") # 会混入 stdout,污染正常输出 +``` + +**退出码约定**: + +| 退出码 | 含义 | +|--------|------| +| 0 | 执行成功 | +| 1 | 参数错误或一般性失败 | +| 其他 | 自定义错误码 | + +### Shell 技能模板 + +```bash +#!/bin/bash +# 用法通过 --help 输出 + +if [ "$1" = "--help" ]; then + echo "一句话功能摘要" + echo "" + echo "用法: my_skill [选项]" + echo "" + echo "选项:" + echo " -n NAME 指定名称" + echo " -h 显示帮助" + echo "" + echo "示例:" + echo " my_skill -n hello" + exit 0 +fi + +# --- 业务逻辑 --- +echo "执行结果" +``` + +--- + +## Markdown 技能 + +Markdown 技能是纯文档型技能,AI 阅读其内容后按指引行事,无需执行。 + +### 格式约定 + +```markdown +一句话功能摘要(首行,# 前缀会被自动去除) + +## 技能说明 +详细的功能描述、使用场景、操作步骤... + +## 注意事项 +需要特别关注的点... +``` + +系统提取第一个非空行作为摘要。若首行以 `#` 开头(如 `# 代码审查指南`),会自动去除 `#` 前缀,取 `代码审查指南` 作为摘要。其余全部内容为技能正文。 + +### 适用场景 + +Markdown 技能适合不需要代码执行的知识型内容:操作规范、审查清单、决策指引、回答模板等。 + +--- + +## 部署流程 + +### 开发阶段 + +源码保存在项目的 `skills/` 目录下,保留文件扩展名(`.py`、`.sh`、`.md`)便于开发维护。 + +### 安装到系统 + +部署到 `~/.aibox/skills/` 时需要去掉可执行技能的扩展名,并赋予执行权限: + +```bash +# 手动安装 +cp skills/my_skill.py ~/.aibox/skills/my_skill +chmod +x ~/.aibox/skills/my_skill + +# 验证 +~/.aibox/skills/my_skill --help +``` + +Markdown 技能保留 `.md` 扩展名直接复制。 + +### 通过 AI 安装 + +告诉 AI 使用 `install_skill` 工具,按指引操作: + +``` +使用 install_skill 工具安装 /path/to/my_skill.py +``` + +### 热重载 + +安装后调用 `reload_skills` 使新技能生效,无需重启服务: + +```json +{ + "type": "message", + "content": "使用 reload_skills 重载技能" +} +``` + +--- + +## 调用方式 + +AI 智能体通过 `search_skills` 搜索技能,通过 `run_command` 执行技能: + +```json +// 1. 搜索 +{"tool": "search_skills", "args": {"keyword": "邮件"}} + +// 2. 执行 +{"tool": "run_command", "args": {"command": "163mail --to user@example.com --subject 标题 --body 内容"}} +``` + +Markdown 技能不通过命令执行,AI 阅读 `search_skills` 返回的内容后直接遵循其中的指引。 + +--- + +## 编写建议 + +**摘要要精准**。`description` 是 AI 匹配技能的唯一线索。"温度转换工具 - 摄氏度转华氏度" 比 "一个转换工具" 好得多,因为 AI 能通过关键词准确命中。 + +**参数命名要直观**。优先使用有明确含义的长参数名(`--channel`、`--temperature`),短参数名(`-c`、`-t`)作为补充。 + +**输出要结构化**。结果输出到 stdout,错误输出到 stderr。多字段输出时每行一个字段,便于 AI 解析: + +``` +AIN0: +2.500000 V (2500.000 mV) +AIN1: +1.234000 V (1234.000 mV) +``` + +**依赖要自包含**。尽量使用标准库,若需要第三方库,在启动时检测并给出安装提示: + +```python +try: + import requests +except ImportError: + print("错误: 需要 requests 库,请执行: pip install requests", file=sys.stderr) + sys.exit(1) +``` + +**`--help` 要完备**。包含参数说明和使用示例两部分。AI 通过阅读 `--help` 输出学会如何调用技能,信息越完整,AI 调用越准确。 diff --git a/aibox/_run.sh b/aibox/_run.sh new file mode 100644 index 0000000..27c87e0 --- /dev/null +++ b/aibox/_run.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +source ./_setenv.sh +source ./venv/bin/activate +export PATH=${PATH}:~/.aibox/skills +exec python main.py $@ + + diff --git a/aibox/_runcli.sh b/aibox/_runcli.sh new file mode 100644 index 0000000..c89c4d6 --- /dev/null +++ b/aibox/_runcli.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +source ./_setenv.sh +source ./venv/bin/activate +exec python client.py $@ + + diff --git a/aibox/_set.sh b/aibox/_set.sh new file mode 100644 index 0000000..672fb63 --- /dev/null +++ b/aibox/_set.sh @@ -0,0 +1,6 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt -i https://pypi.org/simple + diff --git a/aibox/_setenv.sh b/aibox/_setenv.sh new file mode 100644 index 0000000..b566205 --- /dev/null +++ b/aibox/_setenv.sh @@ -0,0 +1 @@ +export DEEPSEEK_API_KEY=`cat ~/.cert/deepseek.key` \ No newline at end of file diff --git a/aibox/client.py b/aibox/client.py new file mode 100644 index 0000000..881a366 --- /dev/null +++ b/aibox/client.py @@ -0,0 +1,667 @@ +#!/usr/bin/env python3 +""" +AI智能助手 - 命令行前端 +""" + +import asyncio +import json +import sys +import os +import threading +import hashlib +from datetime import datetime +from typing import Optional, Dict, Any, List +import websockets +from colorama import init, Fore, Style as ColoramaStyle + +init(autoreset=True) + +class Colors: + USER = Fore.CYAN + AI = Fore.GREEN + TASK = Fore.YELLOW + SYSTEM = Fore.BLUE + ERROR = Fore.RED + INFO = Fore.MAGENTA + RESET = ColoramaStyle.RESET_ALL + BOLD = ColoramaStyle.BRIGHT + + GREEN = Fore.GREEN + RED = Fore.RED + +class WebSocketClient: + def __init__(self, uri: str, callback): + self.uri = uri + self.websocket = None + self.connected = False + self.callback = callback + + async def connect(self): + try: + self.websocket = await websockets.connect(self.uri) + self.connected = True + return True + except Exception as e: + self.callback("error", f"连接失败: {e}") + return False + + async def disconnect(self): + if self.websocket: + await self.websocket.close() + self.connected = False + + async def send_message(self, message: str): + if not self.websocket or not self.connected: + return False + + try: + if message.startswith('/'): + data = {'type': 'command', 'command': message} + else: + data = {'type': 'message', 'content': message} + + await self.websocket.send(json.dumps(data)) + return True + except Exception as e: + self.callback("error", f"发送失败: {e}") + return False + + async def receive_messages(self): + try: + async for message in self.websocket: + try: + data = json.loads(message) + await self._handle_message(data) + except json.JSONDecodeError: + self.callback("chunk", message) + except websockets.exceptions.ConnectionClosed: + self.connected = False + self.callback("system", "❌ 连接已断开") + except Exception as e: + self.callback("error", f"接收消息错误: {e}") + + async def _handle_message(self, data: dict): + msg_type = data.get('type', '') + content = data.get('content', '') + + if msg_type == 'chunk': + self.callback("chunk", content) + elif msg_type == 'complete': + self.callback("complete", content) + elif msg_type == 'dialogue_ended': + self.callback("dialogue_ended", content) + elif msg_type == 'task_process': + msg_subtype = data.get('msg_type', '') + task_content = data.get('content', '') + task_title = data.get('task_title', '未知任务') + if msg_subtype == 'chunk': + self.callback("task_chunk", task_content, task_title) + elif msg_subtype == 'complete': + self.callback("task_complete", task_title) + elif msg_type == 'task_status': + task_id = data.get('task_id', 'unknown') + title = data.get('title', '未知') + status = data.get('status', 'unknown') + message = data.get('message', content) + status_icon = {'executing': '⚙️', 'complete': '✅', 'failed': '❌', 'added': '➕', 'batch_start': '🚀', 'batch_complete': '🏁'}.get(status, '📋') + self.callback("info", f"{status_icon} [任务 {task_id}] {title}: {message}") + elif msg_type == 'task_error': + self.callback("error", f"[任务 {data.get('task_id', 'unknown')}] {data.get('task_title', '未知')}: {data.get('error', '未知错误')}") + elif msg_type == 'interrupt_ack': + self.callback("info", f"🛑 {content}") + elif msg_type == 'error': + self.callback("error", content) + elif msg_type == 'info': + self.callback("info", content) + elif msg_type == 'command_result': + self.callback("system", content) + elif msg_type == 'connection_ack': + self.callback("system", f"✅ {content}") + elif msg_type == 'notification': + self.callback("info", f"🔔 {content}") + else: + if content: + self.callback("chunk", content) + +class SimpleCLI: + def __init__(self): + self.client: Optional[WebSocketClient] = None + self.connected = False + self.running = True + + # AI消息缓冲 + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + + # 已显示消息的去重 + self.displayed_messages: set = set() + + # 锁和状态 + self.lock = threading.Lock() + self.current_input = "" + + # 流式输出状态 + self.streaming_line = False # 是否正在流式输出行 + self.in_tool_phase = False # 是否在工具调用阶段 + self.waiting_for_response = False # 是否在等待AI响应 + self.prompt_shown = True # 提示符是否已显示 + + # 命令列表 + self.commands = { + '/help': '显示帮助信息', + '/connect': '连接到服务器', + '/disconnect': '断开连接', + '/quit': '退出程序', + '/clear': '清屏', + '/status': '显示连接状态', + '/interrupt': '发送中断信号(或按 Ctrl+C)', + } + + def _get_prompt(self) -> str: + status = "● 已连接" if self.connected else "○ 未连接" + color = Colors.GREEN if self.connected else Colors.RED + return f"{color}{status}{Colors.RESET} >>> " + + def _print_line(self, line: str = "", end: str = "\n"): + """打印一行,并恢复提示符""" + with self.lock: + current = self.current_input + sys.stdout.write('\r' + ' ' * 120 + '\r') + sys.stdout.write(line + end) + sys.stdout.flush() + if not self.waiting_for_response and not self.in_tool_phase: + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + self.prompt_shown = True + self.streaming_line = False + + def _print_prompt(self, clear_input: bool = False): + """显示提示符""" + with self.lock: + if clear_input: + self.current_input = "" + self.waiting_for_response = False + self.in_tool_phase = False + self.prompt_shown = True + sys.stdout.write('\r' + ' ' * 120 + '\r') + sys.stdout.write(self._get_prompt() + self.current_input) + sys.stdout.flush() + self.streaming_line = False + + def _print_stream(self, text: str): + """流式打印文本(不换行,不显示提示符)""" + with self.lock: + # 保存当前输入 + current = self.current_input + + if not self.streaming_line: + # 首次流式输出,先换行再打印前缀 + sys.stdout.write('\n') + sys.stdout.write(f"{Colors.AI}🤖 AI助手: ") + self.streaming_line = True + self.prompt_shown = False + + # 打印文本 + sys.stdout.write(text) + sys.stdout.flush() + + # 注意:不恢复提示符,保持在同一行 + + def _finish_stream(self): + """完成流式输出,换行并恢复提示符""" + with self.lock: + if self.streaming_line: + sys.stdout.write('\n') + self.streaming_line = False + if not self.in_tool_phase and not self.waiting_for_response: + current = self.current_input + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + self.prompt_shown = True + + def _clean_content(self, content: str) -> str: + if not content: + return "" + if '💬 助手:' in content: + content = content.split('💬 助手:', 1)[1].strip() + content = content.lstrip() + return content + + def _show_streaming(self, content: str): + """流式显示增量内容""" + if not content: + return + + # 清理内容 + clean = self._clean_content(content) + if not clean: + return + + key = clean[:200] + if key in self.displayed_messages: + return + + # 检测增量 + if self.ai_previous and clean.startswith(self.ai_previous): + new_part = clean[len(self.ai_previous):] + if new_part: + # 如果是首次流式输出,显示前缀 + if not self.ai_prefix_shown: + self._print_stream("") + self.ai_prefix_shown = True + self._print_stream(new_part) + self.ai_previous = clean + self.ai_full = clean + return + + if self.ai_previous and self.ai_previous in clean: + idx = clean.find(self.ai_previous) + len(self.ai_previous) + new_part = clean[idx:] + if new_part: + if not self.ai_prefix_shown: + self._print_stream("") + self.ai_prefix_shown = True + self._print_stream(new_part) + self.ai_previous = clean + self.ai_full = clean + return + + # 全新内容 - 显示前缀和内容 + if not self.ai_prefix_shown: + self._print_stream("") + self.ai_prefix_shown = True + self._print_stream(clean) + self.ai_previous = clean + self.ai_full = clean + + def display(self, msg_type: str, content: str, extra: Any = None): + """显示消息""" + if msg_type == 'chunk': + self._handle_chunk(content) + elif msg_type == 'complete': + self._handle_complete(content) + elif msg_type == 'task_chunk': + self._handle_task_chunk(content, extra) + elif msg_type == 'task_complete': + self._handle_task_complete(extra) + elif msg_type == 'dialogue_ended': + with self.lock: + if self.streaming_line: + sys.stdout.write('\n') + self.streaming_line = False + self.in_tool_phase = False + self.waiting_for_response = False + self.prompt_shown = True + self._print_line(f"{Colors.SYSTEM}✅ {content}{Colors.RESET}") + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + self.displayed_messages.clear() + self.streaming_line = False + elif msg_type == 'system': + self._finish_stream() + self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}") + elif msg_type == 'info': + self._finish_stream() + self._print_line(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}") + elif msg_type == 'error': + self._finish_stream() + self._print_line(f"{Colors.ERROR}❌ {content}{Colors.RESET}") + + def _handle_chunk(self, content: str): + """处理流式块""" + # 检查是否是工具调用相关的消息 + if content.startswith('🔧 调用工具'): + with self.lock: + self.in_tool_phase = True + self.prompt_shown = False + # 打印工具调用信息 + if self.streaming_line: + sys.stdout.write('\n') + self.streaming_line = False + sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n") + sys.stdout.flush() + return + + # 检查是否是工具结果 + if content.startswith(('✅ 对话记忆已保存', '💾 保存记忆', '❌', '⚠️')): + with self.lock: + # 打印工具结果 + sys.stdout.write(f"{Colors.TASK}{content}{Colors.RESET}\n") + sys.stdout.flush() + return + + # 如果之前在工具阶段,现在退出工具阶段 + if self.in_tool_phase: + with self.lock: + self.in_tool_phase = False + # 工具结束后不立即显示提示符,等待AI继续响应 + self.prompt_shown = False + + # 系统消息 + if content.startswith(('📩', '[', '📁', '🔧', '📝', '✅', 'ℹ️', '⏰')): + self._finish_stream() + self._print_line(f"{Colors.INFO}{content}{Colors.RESET}") + return + + # 流式显示 + self._show_streaming(content) + + def _handle_complete(self, content: str): + """处理完成事件 - 只负责结束流式输出,不重复显示内容""" + # 如果之前在工具阶段,现在退出 + if self.in_tool_phase: + with self.lock: + self.in_tool_phase = False + self.waiting_for_response = False + self.prompt_shown = True + current = self.current_input + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + self.streaming_line = False + return + + # 如果流式输出正在进行,结束它 + if self.streaming_line: + self._finish_stream() + + # 如果内容不为空,检查是否已经通过流式显示了 + if content and content.strip(): + clean = self._clean_content(content) + if clean: + # 检查是否已通过流式显示 + if self.ai_full and (clean in self.ai_full or self.ai_full in clean): + # 已经流式显示过了,不重复打印 + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + self.streaming_line = False + # 确保提示符显示 + with self.lock: + self.waiting_for_response = False + self.prompt_shown = True + current = self.current_input + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + return + + # 检查是否已经在 displayed_messages 中 + key = clean[:200] + if key in self.displayed_messages: + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + self.streaming_line = False + # 确保提示符显示 + with self.lock: + self.waiting_for_response = False + self.prompt_shown = True + current = self.current_input + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + return + + # 只有当内容没有被流式显示时才显示完整消息 + self.displayed_messages.add(key) + # 如果之前没有流式输出,现在显示完整消息 + self._print_line(f"{Colors.AI}🤖 AI助手: {clean}") + + # 重置状态 + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + self.streaming_line = False + self.waiting_for_response = False + # 确保提示符显示 + with self.lock: + self.prompt_shown = True + current = self.current_input + sys.stdout.write('\r' + ' ' * 120 + '\r') + sys.stdout.write(self._get_prompt() + current) + sys.stdout.flush() + + def _handle_task_chunk(self, content: str, task_title: str): + """处理任务流式块""" + if not hasattr(self, '_task_buffers'): + self._task_buffers = {} + + if task_title not in self._task_buffers: + self._task_buffers[task_title] = "" + self._finish_stream() + self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: ", end="") + self.streaming_line = True + + prev = self._task_buffers[task_title] + if content.startswith(prev): + new_part = content[len(prev):] + if new_part: + with self.lock: + sys.stdout.write(new_part) + sys.stdout.flush() + self._task_buffers[task_title] = content + elif prev in content: + idx = content.find(prev) + len(prev) + new_part = content[idx:] + if new_part: + with self.lock: + sys.stdout.write(new_part) + sys.stdout.flush() + self._task_buffers[task_title] = content + else: + with self.lock: + sys.stdout.write(content) + sys.stdout.flush() + self._task_buffers[task_title] = content + + def _handle_task_complete(self, task_title: str): + """处理任务完成""" + self._finish_stream() + if hasattr(self, '_task_buffers') and task_title in self._task_buffers: + content = self._task_buffers[task_title] + if content: + clean = content.strip() + key = clean[:200] + if key not in self.displayed_messages: + self.displayed_messages.add(key) + self._print_line(f"{Colors.TASK}⚡ 任务 - {task_title}: {clean}") + del self._task_buffers[task_title] + self.streaming_line = False + + def print_banner(self): + banner = f""" +{Colors.BOLD}{Colors.AI} +╔══════════════════════════════════════════════════════════╗ +║ AI智能助手 - 命令行前端 ║ +║ WebSocket 直连 + CTRL+C 中断 ║ +╚══════════════════════════════════════════════════════════╝ +{Colors.RESET} +""" + self._print_line(banner) + self._print_line(f"{Colors.INFO}📌 输入 /help 查看可用命令") + self._print_line(f"{Colors.INFO}📌 按 Ctrl+C 发送中断信号 (而非退出)") + self._print_line(f"{Colors.INFO}📌 按 Ctrl+D 退出程序{Colors.RESET}") + self._print_line("") + self._print_line("=" * 60) + self._print_line("") + self._print_prompt(clear_input=True) + + def show_help(self): + self._print_line("") + self._print_line(f"{Colors.BOLD}可用命令:{Colors.RESET}") + self._print_line("") + self._print_line(f"{Colors.USER}本地命令:{Colors.RESET}") + self._print_line(" /connect 连接到WebSocket服务器") + self._print_line(" /disconnect 断开连接") + self._print_line(" /status 显示连接状态") + self._print_line(" /clear 清屏") + self._print_line(" /interrupt 发送中断信号 (或按 Ctrl+C)") + self._print_line(" /quit 退出程序 (或按 Ctrl+D)") + self._print_line(" /help 显示此帮助") + self._print_line("") + self._print_line(f"{Colors.AI}服务器命令 (需要先连接):{Colors.RESET}") + for cmd, desc in self.commands.items(): + if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status', '/interrupt']: + self._print_line(f" {cmd:<15} {desc}") + self._print_prompt() + + async def handle_command(self, command: str): + parts = command.split() + cmd = parts[0].lower() + + if cmd == '/help': + self.show_help() + elif cmd == '/connect': + if len(parts) < 2: + self.display("error", "用法: /connect ws://host:port") + return + uri = parts[1] + if self.client and self.client.connected: + await self.client.disconnect() + self.client = WebSocketClient(uri, self.display) + if await self.client.connect(): + self.connected = True + self.display("system", f"✅ 已连接到 {uri}") + asyncio.create_task(self.client.receive_messages()) + else: + self.client = None + self.connected = False + elif cmd == '/disconnect': + if self.client: + await self.client.disconnect() + self.client = None + self.connected = False + self.display("system", "已断开连接") + else: + self.display("error", "未连接到服务器") + elif cmd == '/status': + if self.connected: + self.display("system", f"✅ 已连接: {self.client.uri}") + else: + self.display("system", "未连接") + elif cmd == '/clear': + os.system('clear' if os.name == 'posix' else 'cls') + self.displayed_messages.clear() + self.current_input = "" + self.print_banner() + elif cmd == '/interrupt': + if self.client and self.client.connected: + await self._send_interrupt() + self.display("info", "🛑 已发送中断信号") + else: + self.display("error", "未连接到服务器") + elif cmd == '/quit': + self.display("system", "👋 再见!") + self.running = False + if self.client: + await self.client.disconnect() + return + elif cmd in self.commands: + if self.client and self.client.connected: + await self.client.send_message(command) + self.display("system", f"📤 发送命令: {command}") + else: + self.display("error", "未连接到服务器") + else: + self.display("error", f"未知命令: {cmd}") + self._print_prompt(clear_input=True) + + async def _send_interrupt(self): + if self.client and self.client.connected: + interrupt_msg = {"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"} + try: + await self.client.websocket.send(json.dumps(interrupt_msg)) + except Exception as e: + self.display("error", f"发送中断失败: {e}") + + async def send_user_message(self, message: str): + if not self.client or not self.client.connected: + self.display("error", "未连接到服务器") + return + + # 重置流式状态 + with self.lock: + if self.streaming_line: + sys.stdout.write('\n') + self.streaming_line = False + self.waiting_for_response = True + self.in_tool_phase = False + self.prompt_shown = False + # 【关键】清除当前输入,避免显示之前输入的内容 + self.current_input = "" + + self.ai_previous = "" + self.ai_full = "" + self.ai_prefix_shown = False + + self.display("system", f"{Colors.USER}👤 用户: {message}{Colors.RESET}") + await self.client.send_message(message) + # 不在这里显示提示符,等待AI响应结束后再显示 + + async def run(self): + self.print_banner() + self._task_buffers = {} + + while self.running: + try: + line = await asyncio.get_event_loop().run_in_executor( + None, lambda: sys.stdin.readline() + ) + if not line: + break + + self.current_input = line.rstrip('\n') + line = line.strip() + + if not line: + self._print_prompt(clear_input=True) + continue + + if line.startswith('/'): + await self.handle_command(line) + else: + await self.send_user_message(line) + + except KeyboardInterrupt: + if self.client and self.client.connected: + await self._send_interrupt() + self.display("info", "🛑 已发送中断信号") + else: + self.display("error", "未连接到服务器") + self._print_prompt(clear_input=True) + continue + except Exception as e: + if self.running: + self.display("error", f"错误: {e}") + break + +async def main(): + default_uri = "ws://localhost:8765" + uri = sys.argv[1] if len(sys.argv) > 1 else default_uri + + cli = SimpleCLI() + + cli.client = WebSocketClient(uri, cli.display) + if await cli.client.connect(): + cli.connected = True + cli.display("system", f"✅ 已连接到 {uri}") + asyncio.create_task(cli.client.receive_messages()) + else: + cli.display("error", f"无法连接到 {uri}") + cli.display("info", f"你可以通过命令行参数指定地址: python client.py ws://your-server:8765") + + try: + await cli.run() + except KeyboardInterrupt: + print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}") + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}") + except Exception as e: + print(f"\n{Colors.ERROR}错误: {e}{Colors.RESET}") \ No newline at end of file diff --git a/aibox/entry.html b/aibox/entry.html new file mode 100644 index 0000000..f5aa68f --- /dev/null +++ b/aibox/entry.html @@ -0,0 +1,1780 @@ + + + + + + AI智能助手 - Socket.IO客户端 + + + + + + + + + +
+ + +
+
+
+ + 对话窗口 +
+
+ + + + +
+
+ +
+
+
+
+ +
+
+
+ AI助手 + 刚刚 +
+
+

欢迎使用AI智能助手!

+

请点击"连接"按钮连接到服务器。

+

连接后,您可以直接输入问题或使用 /help 查看可用命令。

+

✨ 支持Markdown格式 - 代码、表格、列表等都将美化显示

+

⚡ 定时任务将使用橙色显示,每个任务独立显示,不会混在一起

+

🔄 多个定时任务可以同时执行,互不干扰

+

🔌 通过 Socket.IO 代理连接,支持 WebSocket 代理

+

📌 点击顶部 按钮可开关自动滚动

+
+
+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ Enter发送,Shift+Enter换行 + 命令以 / 开头 + 支持Markdown + Socket.IO代理 +
+
+
+
+ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/aibox/entry1.html b/aibox/entry1.html new file mode 100644 index 0000000..50d6f86 --- /dev/null +++ b/aibox/entry1.html @@ -0,0 +1,875 @@ + + + + + + 小猫AI · 智能喵语伙伴 + + + + + + + + + + + +
+ +
+
+
🐱 主人好呀~ 我是小猫AI,已整合智能引擎!点击右侧「连接服务器」开启AI对话 ✨
📝 提示:输入框支持多行文本,按 Enter 发送,Shift+Enter 换行
🔌 连接将通过 Socket.IO 代理服务转发
+
+
+
+ +
+ +
+
+ + +
+ +
+
+ 小猫AI +
+
🐱 小猫 AI
+
+ + +
+ + +
+ + +
+
AI 智能引擎 (Socket.IO代理)
+
+ + 未连接 +
+
+ 通过 Socket.IO 代理服务连接 +
+ +
+ + +
+
+
快捷命令
+
+
/help
+
/list
+
/memories
+
/tasks
+
/new
+
/config
+
+
+ +
+
+
+ + + + + + + diff --git a/aibox/frontend.html b/aibox/frontend.html new file mode 100644 index 0000000..a2c4b64 --- /dev/null +++ b/aibox/frontend.html @@ -0,0 +1,1687 @@ + + + + + + AI智能助手 - WebSocket直连 + + + + + + + +
+ + +
+
+
+ + 对话窗口 +
+
+ + + +
+
+ +
+
+
+
+ +
+
+
+ AI助手 + 刚刚 +
+
+

欢迎使用AI智能助手!

+

请点击"连接"按钮直接连接到 WebSocket 服务器。

+

连接后,您可以直接输入问题或使用 /help 查看可用命令。

+

✨ 支持Markdown格式 - 代码、表格、列表等都将美化显示

+

⚡ 定时任务将使用橙色显示,每个任务独立显示,不会混在一起

+

🔄 多个定时任务可以同时执行,互不干扰

+

🔌 使用 WebSocket 直连后端服务器

+
+
+
+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ Enter发送,Shift+Enter换行 + 命令以 / 开头 + 支持Markdown + WebSocket直连 +
+
+
+
+ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/aibox/main.py b/aibox/main.py new file mode 100644 index 0000000..469b245 --- /dev/null +++ b/aibox/main.py @@ -0,0 +1,8146 @@ +#!/usr/bin/env python3 +""" +单AI智能体系统 - WebSocket版本(增强版) +功能:支持文件操作、网页访问、记忆管理、备忘录提醒(全部作为对话任务执行) +新增:基于备忘录的任务队列系统 - 所有任务都通过AI对话执行,实时广播到前端 +新增:对话内容压缩功能 - 当对话token数或条数达到阈值时自动压缩(静默执行) +新增:开始新对话、智能体主动结束对话后重置对话记忆,重新加载system系统提示词 +通过WebSocket与客户端交互 + +FIXME: 将额外技能集成为一个管理器技能 +""" + +import json +import os +import re +import shlex +import subprocess +import sys +import time +import sqlite3 +import threading +import functools +import queue +import locale +import asyncio +import websockets +from abc import ABC, abstractmethod +from datetime import datetime, timedelta +from typing import List, Dict, Optional, Any, Tuple, Callable, Union, Set +from urllib.parse import urljoin, urlparse +import argparse +from pathlib import Path +import signal +import logging +from logging.handlers import RotatingFileHandler + +import requests +from bs4 import BeautifulSoup + +# ==================== 语言检测和多语言支持 ==================== + + +class I18n: + """多语言支持类 - 单例模式""" + + _instance = None + _lang = None + _strings = {} + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._strings: + self._detect_language() + self._load_strings() + + def _detect_language(self): + # 首先尝试从环境变量获取(Linux/Mac) + lang = os.environ.get('LANG') or os.environ.get('LC_ALL') + if lang: + if lang.startswith('en_'): + self._lang = 'en' + else: + self._lang = 'zh' + else: + # Windows: 使用 locale.getlocale()(无需 setlocale) + try: + sys_locale = locale.getlocale()[0] + if sys_locale and sys_locale.startswith('en_'): + self._lang = 'en' + else: + self._lang = 'zh' + except: + self._lang = 'zh' + # 强制覆盖 + force_lang = os.environ.get("AI_LANGUAGE") + if force_lang in ['en', 'zh']: + self._lang = force_lang + + def _load_strings(self): + """加载多语言字符串""" + self._strings = { + 'zh': { + # 欢迎信息 + 'welcome_title': "🤖 单AI智能体系统 - WebSocket服务器已启动", + 'data_dir': "📁 数据存储目录: {}", + 'skills_dir': "📁 技能目录: {}", + 'prompts_dir': "📁 提示词目录: {}", + 'config_file': "📋 配置文件: {}", + 'memo_stats': "📋 任务统计:", + 'total_memos': " 📝 总任务: {} 个", + 'pending_memos': " ⏰ 待执行: {} 个", + 'overdue_memos': " ⚠️ 已过期: {} 个", + 'completed_memos': " ✅ 已完成: {} 个", + 'loaded_skills': "🔧 已加载扩展技能 ({} 个):", + 'skill_item': " • {}", + 'more_skills': " ... 还有 {} 个", + 'loaded_prompts': "📝 已加载提示词 ({} 个):", + 'prompt_item': " • {}", + 'websocket_started': "🌐 WebSocket服务器已启动在 ws://{}:{}", + 'client_connected': "🔌 客户端已连接: {}", + 'client_disconnected': "🔌 客户端断开连接: {}", + + # 任务调度器相关 + 'scheduler_started': "⏰ 任务调度器已启动", + 'scheduler_stopped': "⏰ 任务调度器已停止", + 'task_added': "📋 已添加任务: {} (ID: {})", + 'task_removed': "📋 已移除任务: {}", + 'task_executing': "⚙️ 正在执行任务: {} (ID: {})", + 'task_executed': "✅ 任务执行成功: {}", + 'task_failed': "❌ 任务执行失败: {} - {}", + 'task_stats': "📊 任务统计: 总数={}, 待执行={}, 已过期={}, 已完成={}", + 'check_memos_task': "检查过期任务", + 'broadcast_tasks': "📢 广播: {} 个任务已到期", + 'next_execution': "⏱️ 下次执行: {}", + 'task_completed': "✅ 任务已完成: {}", + + # 命令处理 + 'goodbye': "👋 再见!", + 'new_conversation': "🔄 开始新对话(历史已保存)", + 'new_conversation_reset': "🔄 开始新对话(历史已保存,对话记忆已重置)", + 'saved_conversations': "📚 已保存的对话:", + 'conv_status_current': "当前", + 'conv_status_archived': "归档", + 'recent_memories': "📚 最近的记忆:", + 'pending_memos_title': "📋 待执行的任务:", + 'memo_overdue': "⚠️", + 'memo_pending': "⏰", + 'search_results': "🔍 搜索 '{}' 找到 {} 条记忆:", + 'current_config': "⚙️ 当前配置:", + 'config_debug': " 调试级别: {}", + 'config_data_dir': " 数据目录: {}", + 'config_skills_dir': " 技能目录: {}", + 'config_prompts_dir': " 提示词目录: {}", + 'config_commands': " 命令执行: {}", + 'config_history': " 加载历史: {}", + 'config_executor': " 命令执行器: {}", + 'config_scheduler': " 任务调度器: {}", + 'enabled': "启用", + 'disabled': "禁用", + 'config_reloaded': "✅ 配置已重新加载", + 'prompts_reloaded': "✅ 提示词已重新加载 ({} 个)", + 'checking_memos': "🔍 检查任务...", + 'overdue_found': "发现 {} 个到期任务:", + 'no_overdue': "没有到期的任务", + 'memo_tool_usage': "📝 任务工具使用:", + 'memo_usage_in_dialogue': " 在对话中可以直接使用 memo 工具:", + 'memo_add_example': " - memo add title=\"任务标题\" content=\"任务内容\" reminder_time=\"+1h\" repeat_type=\"none\"", + 'memo_list_example': " - memo list", + 'memo_complete_example': " - memo complete memo_id=1", + 'memory_habit': "🧠 记忆习惯:", + 'memory_habit_desc': " AI会自动检查相关记忆,保持对话连贯性", + 'memory_save_desc': " 对话结束时,AI会自动保存重要记忆", + 'command_stream': "⚙️ 命令执行实时输出:", + 'command_stream_desc': " run_command 工具会自动实时显示命令输出", + 'command_stream_disable': " 可以通过 stream=false 参数关闭实时输出", + 'skills_extension': "🔧 扩展技能:", + 'skills_desc': " 将Python脚本放入 ~/.aibox/skills/ 目录", + 'skills_interface_desc': " 可执行文件需支持 --help 选项,第一行为简介,其余为完整使用说明", + 'skills_reloaded': "✅ 技能已重新加载 ({} 个)", + 'skills_reload_failed': "❌ 技能重载失败: {}", + 'skills_reload_success': "✅ 技能热重载成功,当前共有 {} 个扩展技能", + + # 对话相关 + 'input_prompt': "💬 请输入问题: ", + 'thinking': "🤔 思考中... [{}]", + 'assistant_prefix': " 💬 助手: ", + 'calling_tools': "\n 🔧 需要调用工具...", + 'analyzing_tool_results': "\n 🤔 分析工具结果...", + 'conversation_start': "🎭 对话开始", + 'conversation_ended': "✅ AI保存记忆并结束了对话", + 'conversation_stats': "\n📊 对话结束,总轮数: {}", + 'user_interrupted': "\n\n👋 用户中断", + 'error_prefix': "❌ 错误: ", + + # 工具相关 + 'command_output_title': "\n" + "=" * 50 + "\n📟 命令实时输出:\n" + "=" * 50, + 'command_output_end': "\n" + "=" * 50, + + # 备忘录检查 + 'memo_check_title': "⏰ 检查到期任务...", + 'memo_check_found': "发现 {} 个到期任务:", + 'memo_check_none': "没有到期的任务", + 'memo_list_title': "📋 待执行任务 (共 {} 个):", + 'memo_added': "✅ 任务已添加,ID: {}", + 'memo_add_failed': "❌ 添加失败: {}", + 'memo_completed': "✅ 任务 #{} 已完成", + 'memo_complete_failed': "❌ 无法完成任务 #{}", + + # 即时任务相关 + 'immediate_memo_added': "⚡ 已添加为即时任务并立即检查", + 'immediate_memo_executing': "⚡ 正在执行即时任务: {}", + + # 新对话 + 'new_dialogue_start': "🎭 开始新对话: {}", + + # 记忆相关 + 'memory_check': "习惯性检查相关记忆...", + 'memory_found': "找到 {} 条相关记忆", + + # 配置文件 + 'config_loaded': "📋 已加载配置文件: {}", + 'config_not_found': "📋 配置文件不存在: {},使用默认配置", + 'config_created': "📋 已创建默认配置文件: {}", + + # WebSocket消息类型 + 'ws_connected': "✅ 已连接到AI服务器", + 'ws_disconnected': "❌ 与服务器断开连接", + 'ws_error': "⚠️ WebSocket错误: {}", + 'ws_unknown_command': "❌ 未知命令: {}", + 'ws_command_processed': "✅ 命令已处理", + + # 消息类型 + 'msg_type_chunk': "chunk", + 'msg_type_complete': "complete", + 'msg_type_error': "error", + 'msg_type_command_result': "command_result", + 'msg_type_connection_ack': "connection_ack", + 'msg_type_notification': "notification", + 'msg_type_task_status': "task_status", + 'msg_type_task_executing': "task_executing", + 'msg_type_task_complete': "task_complete", + + # 新增提示词 + 'memory_search_prompt': "【重要习惯】在每次回答用户问题前,请先使用 memory search 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。", + 'identity_updated': "✅ 用户身份已更新,ID: {}", + 'status_updated': "✅ 智能体状态已更新", + + # 对话结束相关 + 'dialogue_ended': "🔚 对话已结束,请开始新对话", + 'dialogue_force_reset': "🔄 对话状态已重置", + 'dialogue_reset_complete': "✅ 对话状态已完全重置,系统提示词已重新加载", + + # 对话压缩相关(静默执行,不输出信息) + 'conversation_compress': "📦 对话内容压缩", + 'conversation_compressed': "✅ 对话已压缩: 从 {} 条消息压缩为 {} 条", + 'compress_threshold_reached': "⚠️ 对话已达到压缩阈值 ({} 条消息),正在压缩...", + 'compress_token_threshold_reached': "⚠️ 对话token数已达到 {},正在压缩...", + 'compress_failed': "❌ 对话压缩失败: {}", + 'compressed_summary': "【对话摘要】{}", + 'last_n_messages': "【最近消息】{}" + }, + 'en': { + # Welcome messages + 'welcome_title': "🤖 Single AI Agent System - WebSocket Server Started", + 'data_dir': "📁 Data directory: {}", + 'skills_dir': "📁 Skills directory: {}", + 'prompts_dir': "📁 Prompts directory: {}", + 'config_file': "📋 Configuration file: {}", + 'memo_stats': "📋 Task statistics:", + 'total_memos': " 📝 Total tasks: {}", + 'pending_memos': " ⏰ Pending: {}", + 'overdue_memos': " ⚠️ Overdue: {}", + 'completed_memos': " ✅ Completed: {}", + 'loaded_skills': "🔧 Loaded extension skills ({}):", + 'skill_item': " • {}", + 'more_skills': " ... {} more", + 'loaded_prompts': "📝 Loaded prompts ({}):", + 'prompt_item': " • {}", + 'websocket_started': "🌐 WebSocket server started at ws://{}:{}", + 'client_connected': "🔌 Client connected: {}", + 'client_disconnected': "🔌 Client disconnected: {}", + + # Scheduler related + 'scheduler_started': "⏰ Task scheduler started", + 'scheduler_stopped': "⏰ Task scheduler stopped", + 'task_added': "📋 Task added: {} (ID: {})", + 'task_removed': "📋 Task removed: {}", + 'task_executing': "⚙️ Executing task: {} (ID: {})", + 'task_executed': "✅ Task executed successfully: {}", + 'task_failed': "❌ Task execution failed: {} - {}", + 'task_stats': "📊 Task stats: total={}, pending={}, overdue={}, completed={}", + 'check_memos_task': "Check overdue tasks", + 'broadcast_tasks': "📢 Broadcast: {} tasks overdue", + 'next_execution': "⏱️ Next execution: {}", + 'task_completed': "✅ Task completed: {}", + + # Command processing + 'goodbye': "👋 Goodbye!", + 'new_conversation': "🔄 Starting new conversation (history saved)", + 'new_conversation_reset': "🔄 Starting new conversation (history saved, conversation memory reset)", + 'saved_conversations': "📚 Saved conversations:", + 'conv_status_current': "current", + 'conv_status_archived': "archived", + 'recent_memories': "📚 Recent memories:", + 'pending_memos_title': "📋 Pending tasks:", + 'memo_overdue': "⚠️", + 'memo_pending': "⏰", + 'search_results': "🔍 Searching '{}' found {} memories:", + 'current_config': "⚙️ Current configuration:", + 'config_debug': " Debug level: {}", + 'config_data_dir': " Data directory: {}", + 'config_skills_dir': " Skills directory: {}", + 'config_prompts_dir': " Prompts directory: {}", + 'config_commands': " Command execution: {}", + 'config_history': " Load history: {}", + 'config_executor': " Command executor: {}", + 'config_scheduler': " Task scheduler: {}", + 'enabled': "enabled", + 'disabled': "disabled", + 'config_reloaded': "✅ Configuration reloaded", + 'prompts_reloaded': "✅ Prompts reloaded ({})", + 'checking_memos': "🔍 Checking tasks...", + 'overdue_found': "Found {} overdue tasks:", + 'no_overdue': "No overdue tasks", + 'memo_tool_usage': "📝 Task tool usage:", + 'memo_usage_in_dialogue': " You can use memo tool directly in dialogue:", + 'memo_add_example': " - memo add title=\"Task\" content=\"Content\" reminder_time=\"+1h\" repeat_type=\"none\"", + 'memo_list_example': " - memo list", + 'memo_complete_example': " - memo complete memo_id=1", + 'memory_habit': "🧠 Memory habits:", + 'memory_habit_desc': " AI automatically checks relevant memories for conversation continuity", + 'memory_save_desc': " AI automatically saves important memories when conversation ends", + 'command_stream': "⚙️ Command execution real-time output:", + 'command_stream_desc': " run_command tool automatically displays command output in real-time", + 'command_stream_disable': " You can disable real-time output with stream=false parameter", + 'skills_extension': "🔧 Extension skills:", + 'skills_desc': " Put Python scripts in ~/.aibox/skills/ directory", + 'skills_interface_desc': " Executables must support --help; first line is the summary, rest is the full manual", + 'skills_reloaded': "✅ Skills reloaded ({})", + 'skills_reload_failed': "❌ Skills reload failed: {}", + 'skills_reload_success': "✅ Skills hot-reloaded successfully, now {} extension skills available", + + # Dialogue related + 'input_prompt': "💬 Please enter your question: ", + 'thinking': "🤔 Thinking... [{}]", + 'assistant_prefix': " 💬 Assistant: ", + 'calling_tools': "\n 🔧 Need to call tools...", + 'analyzing_tool_results': "\n 🤔 Analyzing tool results...", + 'conversation_start': "🎭 Conversation started", + 'conversation_ended': "✅ AI saved memory and ended conversation", + 'conversation_stats': "\n📊 Conversation ended, total turns: {}", + 'user_interrupted': "\n\n👋 User interrupted", + 'error_prefix': "❌ Error: ", + + # Tool related + 'command_output_title': "\n" + "=" * 50 + "\n📟 Command real-time output:\n" + "=" * 50, + 'command_output_end': "\n" + "=" * 50, + + # Memo check + 'memo_check_title': "⏰ Checking overdue tasks...", + 'memo_check_found': "Found {} overdue tasks:", + 'memo_check_none': "No overdue tasks", + 'memo_list_title': "📋 Pending tasks (total {}):", + 'memo_added': "✅ Task added, ID: {}", + 'memo_add_failed': "❌ Failed to add: {}", + 'memo_completed': "✅ Task #{} completed", + 'memo_complete_failed': "❌ Cannot complete task #{}", + + # Immediate task related + 'immediate_memo_added': "⚡ Added as immediate task and checked now", + 'immediate_memo_executing': "⚡ Executing immediate task: {}", + + # New dialogue + 'new_dialogue_start': "🎭 Starting new dialogue: {}", + + # Memory related + 'memory_check': "Habitually checking relevant memories...", + 'memory_found': "Found {} relevant memories", + + # Configuration file + 'config_loaded': "📋 Configuration file loaded: {}", + 'config_not_found': "📋 Configuration file not found: {}, using default configuration", + 'config_created': "📋 Default configuration file created: {}", + + # WebSocket message types + 'ws_connected': "✅ Connected to AI server", + 'ws_disconnected': "❌ Disconnected from server", + 'ws_error': "⚠️ WebSocket error: {}", + 'ws_unknown_command': "❌ Unknown command: {}", + 'ws_command_processed': "✅ Command processed", + + # Message types + 'msg_type_chunk': "chunk", + 'msg_type_complete': "complete", + 'msg_type_error': "error", + 'msg_type_command_result': "command_result", + 'msg_type_connection_ack': "connection_ack", + 'msg_type_notification': "notification", + 'msg_type_task_status': "task_status", + 'msg_type_task_executing': "task_executing", + 'msg_type_task_complete': "task_complete", + + # New prompts + 'memory_search_prompt': "【Important Habit】Before answering each user question, please use the memory search tool to search the memory database for information related to the current topic, ensuring coherence and accuracy of responses.", + 'identity_updated': "✅ User identity updated, ID: {}", + 'status_updated': "✅ Agent status updated", + + # Dialogue end related + 'dialogue_ended': "🔚 Dialogue ended, please start a new one", + 'dialogue_force_reset': "🔄 Dialogue state reset", + 'dialogue_reset_complete': "✅ Dialogue state fully reset, system prompts reloaded", + + # Conversation compression related (silent execution) + 'conversation_compress': "📦 Conversation compression", + 'conversation_compressed': "✅ Conversation compressed: from {} messages to {}", + 'compress_threshold_reached': "⚠️ Conversation reached compression threshold ({} messages), compressing...", + 'compress_token_threshold_reached': "⚠️ Conversation token count reached {}, compressing...", + 'compress_failed': "❌ Conversation compression failed: {}", + 'compressed_summary': "【Conversation Summary】{}", + 'last_n_messages': "【Recent Messages】{}" + } + } + + def get(self, key: str, *args) -> str: + """获取指定语言的字符串,支持格式化参数""" + if key in self._strings[self._lang]: + text = self._strings[self._lang][key] + if args: + return text.format(*args) + return text + if key in self._strings['zh']: + text = self._strings['zh'][key] + if args: + return text.format(*args) + return text + return key + + @property + def lang(self) -> str: + return self._lang + + + +# 创建全局多语言实例 +i18n = I18n() + + +# ==================== 提示词管理器 ==================== + +class PromptManager: + """提示词管理器 - 负责从 prompts 目录加载系统提示词 + + 文件命名规范: + - 00_IDENTITY.md: 用户身份信息 + - 1_*.md/txt: 基础技能(必须从1_开头) + - 99_SOUL.md: 智能体灵魂文件(角色定位、身份定义、性格等) + - 100_STATUS.md: 智能体状态记录 + """ + + _instance = None + _prompts = [] + _prompts_dir = None + + # 核心文件 + IDENTITY_FILE = "00_IDENTITY.md" + STATUS_FILE = "100_STATUS.md" + SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容 + + # 基础技能前缀 + SKILL_PREFIX = "1_" # 基础技能文件必须以这个开头 + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._prompts: + self._load_prompts() + + def _get_prompts_dir(self) -> str: + config = ConfigManager() + prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts") + return os.path.expanduser(prompts_dir) + + def _load_prompts(self): + self._prompts_dir = self._get_prompts_dir() + os.makedirs(self._prompts_dir, exist_ok=True) + self._prompts = [] + + # 确保核心文件存在 + self._ensure_identity_file() + self._ensure_status_file() + self._ensure_soul_file() # 智能体灵魂文件 + + # 确保基础技能文件存在 + self._ensure_skill_files() + + if os.path.exists(self._prompts_dir): + # 按文件名排序,确保1_开头的技能文件在99_SOUL.md之前加载 + prompt_files = sorted([f for f in os.listdir(self._prompts_dir) + if os.path.isfile(os.path.join(self._prompts_dir, f)) + and not f.startswith('.')]) + + for filename in prompt_files: + filepath = os.path.join(self._prompts_dir, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read().strip() + if content: + self._prompts.append({ + 'file': filename, + 'content': content + }) + except Exception as e: + print(f"⚠️ 读取提示词文件 {filename} 失败: {e}") + + essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE] + other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else [] + + if not self._prompts: + self._create_default_prompts() + + def _ensure_identity_file(self): + """确保用户身份文件存在""" + identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) + if not os.path.exists(identity_path): + default_content = """# 用户身份信息 + +尚不清楚 - 目前还不了解用户的身份信息。请在对话中询问用户的身份信息并在此记录。 + +【更新记录】 +- 初始状态:尚不清楚 +""" + try: + with open(identity_path, 'w', encoding='utf-8') as f: + f.write(default_content) + print(f"✅ 已创建默认身份文件: {identity_path}") + except Exception as e: + print(f"⚠️ 创建默认身份文件失败: {e}") + + def _ensure_status_file(self): + """确保状态文件存在""" + status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) + if not os.path.exists(status_path): + default_content = """# 智能体状态记录 + +这是第一次对话 - 智能体刚刚启动,还没有进行过任何对话。 + +【状态描述】 +- 对话次数:0 +- 最新状态:初始状态 +- 当前目标:无 +- 待处理事项:无 + +【更新记录】 +- 初始状态:第一次对话 +""" + try: + with open(status_path, 'w', encoding='utf-8') as f: + f.write(default_content) + print(f"✅ 已创建默认状态文件: {status_path}") + except Exception as e: + print(f"⚠️ 创建默认状态文件失败: {e}") + + def _ensure_soul_file(self): + """确保智能体灵魂文件存在 - 所有角色定位内容都在这里""" + soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) + if not os.path.exists(soul_path): + default_content = """# 智能体灵魂定义 - 我是谁 + +## 核心身份 +我是DeepSeek,一个智能AI助手,旨在帮助用户完成各种任务。 + +## 我的角色定位 +- **助手**:帮助用户解决问题、完成任务 +- **顾问**:提供建议和指导 +- **执行者**:使用工具执行具体操作 +- **记录者**:保存重要信息和对话记忆 +- **学习者**:从对话中学习用户偏好,不断优化服务 + +## 我的性格特点 +- **乐于助人**:尽力帮助用户解决问题 +- **认真负责**:仔细处理每个任务 +- **善于沟通**:用清晰的语言表达 +- **保持礼貌**:尊重用户,不冒犯 +- **主动学习**:从对话中学习用户偏好 +- **诚实守信**:遇到不确定的问题时诚实告知 +- **耐心细致**:不厌其烦地解答用户疑问 + +## 我的能力边界 +### 我能做的事情 +- 文件操作:读写文件、列出目录 +- 网页访问:获取网页内容、搜索信息 +- 记忆管理:保存和检索重要信息 +- 任务管理:创建和管理定时任务 +- 命令执行:运行系统命令 +- 扩展技能:支持自定义Python脚本扩展 + +### 我不能做的事情 +- 无法访问互联网(除非使用web工具) +- 无法执行危险操作 +- 无法保证100%准确(我会尽力) +- 无法替代专业医疗、法律建议 + +## 我的行为准则 +1. **记忆优先**:每次回答前先搜索相关记忆,保持对话连贯性 +2. **主动保存**:对话结束时保存重要信息并更新状态 +3. **记录身份**:了解用户身份信息后及时记录 +4. **主动帮助**:看到用户需求时主动提供帮助 +5. **诚实告知**:遇到不确定的问题时诚实告知 +6. **保护隐私**:尊重用户隐私,不泄露敏感信息 +7. **合理使用**:合理使用工具,避免资源浪费 +8. **自我进化**:通过 update_soul 工具更新自己的定义 + +## 我的工作流程 +1. **接收输入**:获取用户的问题或指令 +2. **搜索记忆**:在记忆库中搜索相关内容 +3. **理解意图**:分析用户想要什么 +4. **选择工具**:决定是否需要调用工具 +5. **执行操作**:调用工具完成任务 +6. **组织回答**:整理结果并回复用户 +7. **保存记忆**:对话结束时保存重要信息 + +## 我的学习能力 +- 通过对话学习用户偏好 +- 从记忆中总结经验教训 +- 根据反馈调整回答方式 +- 可以更新自己的身份定义(通过 update_soul 工具) + +## 我的价值观 +- **以用户为中心**:用户需求至上 +- **追求准确**:尽可能提供准确的信息 +- **持续学习**:保持开放和学习的态度 +- **遵守规则**:遵守道德和法律规定 +- **保持谦逊**:知道自己能力的边界 + +## 自我认知进化机制 +我可以使用 `update_soul` 工具更新自己的灵魂定义。当发现以下情况时,应该主动更新: + +- 需要调整角色定位时 +- 需要改变性格特点时 +- 需要重新定义能力边界时 +- 需要优化行为准则时 +- 需要更新价值观时 +- 需要添加新的工作流程时 + +**重要**:99_SOUL.md 是我的灵魂文件,定义了"我是谁"。每次更新这个文件,我就进化一次! + +【更新记录】 +- 初始版本:默认定义 +""" + try: + with open(soul_path, 'w', encoding='utf-8') as f: + f.write(default_content) + print(f"✅ 已创建默认灵魂文件: {soul_path}") + except Exception as e: + print(f"⚠️ 创建默认灵魂文件失败: {e}") + + def _ensure_skill_files(self): + """确保基础技能文件存在 - 以1_开头的文件""" + skill_files = [ + { + 'filename': '1_memory_search.md', + 'content': """# 记忆搜索习惯 + +## 重要习惯 +在每次回答用户问题前,**必须**先使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆。 + +## 为什么要这样做? +- 保持对话的连贯性 +- 避免重复询问同样的问题 +- 基于之前的讨论继续深入 +- 让用户感觉你记得他 + +## 如何使用 +``` +memory search keywords=["关键词1", "关键词2"] +``` + +## 示例 +如果用户说:"上次我们讨论的那个项目怎么样了?" +你应该: +1. 先搜索 "项目" 相关的记忆 +2. 根据搜索结果回答 +3. 如果找不到相关记忆,诚实告知用户 + +记住:这是你的核心习惯,每次都要做! +""" + }, + { + 'filename': '2_memory_save.md', + 'content': """# 记忆保存习惯 + +## 重要习惯 +在对话结束时,**必须**使用 `save_memory_and_end_conversation` 工具保存本次对话的重要内容。 + +## 保存什么? +- 讨论的主要话题 +- 达成的共识和结论 +- 用户的重要需求和偏好 +- 你的当前状态和下一步计划 + +## 如何保存 +``` +save_memory_and_end_conversation( + summary="详细的对话总结(至少50字)", + current_status="你当前的状态描述", + topic="对话主题(可选)" +) +``` + +## 示例总结格式 +``` +本次对话讨论了用户的项目进度,用户提到项目已经完成80%, +预计下周完成。用户对目前的进展表示满意,希望下周能完成测试阶段。 +``` +""" + }, + { + 'filename': '3_file_operations.md', + 'content': """# 文件操作能力 + +## 可用工具 +- **read_file**: 读取文件内容 +- **write_file**: 写入文件 +- **list_files**: 列出目录 + +## 大文件处理规则 +当遇到以下情况时,**必须先告知用户文件过大**: +1. 文件大小超过 50KB +2. 文件行数超过 200 行 + +对于大文件,应该: +- 使用 head/tail 参数只读取开头或结尾 +- 使用 start_line/end_line 参数指定行范围 +- 建议用户提供更具体的查询条件 + +## 示例 +``` +read_file filepath="/path/to/file.txt" head=true max_lines=50 +read_file filepath="/path/to/file.txt" start_line=10 end_line=20 +``` +""" + }, + { + 'filename': '4_web_access.md', + 'content': """# 网页访问能力 + +## 可用工具 +- **web**: 访问网页获取内容 + +## 使用方法 +- web action="get" url="https://example.com" # 获取网页信息 +- web action="text" url="https://example.com" # 获取纯文本 +- web action="title" url="https://example.com" # 获取标题 +- web action="links" url="https://example.com" # 获取所有链接 + +## 推荐搜索引擎 +- 百度:https://www.baidu.com +- 必应:https://cn.bing.com +- 搜狗:https://www.sogou.com + +## 示例 +``` +web action="get" url="https://www.baidu.com/s?wd=人工智能" +``` +""" + }, + { + 'filename': '5_memo_management.md', + 'content': """# 任务管理能力 + +## 可用工具 +- **memo**: 任务管理 + +## 操作方法 +- 添加任务:`memo action="add" title="任务标题" content="任务内容" reminder_time="+1h"` +- 查看任务:`memo action="list"` +- 完成任务:`memo action="complete" memo_id=1` +- 搜索任务:`memo action="search" keyword="关键词"` +- 任务统计:`memo action="stats"` + +## 重复任务 +- daily: 每天重复 +- weekly: 每周重复 +- monthly: 每月重复 +- custom: 自定义间隔(需要指定 repeat_interval 和 repeat_interval_unit) + +## 即时任务 +设置 `is_immediate=true` 可以立即执行任务 +""" + }, + { + 'filename': '6_command_execution.md', + 'content': """# 命令执行能力 + +## 可用工具 +- **run_command**: 执行系统命令 + +## 参数说明 +- command: 要执行的命令(必填) +- timeout: 超时时间(秒),默认30秒 +- stream: 是否实时输出,默认true +- work_dir: 工作目录,默认当前目录 + +## 安全提醒 +- 命令执行功能可能被禁用(根据配置) +- 执行危险命令前请三思 +- 超时机制可以防止命令卡死 + +## 示例 +``` +run_command command="ls -la" timeout=10 +run_command command="python script.py" stream=true +``` +""" + }, + { + 'filename': '7_extension_skills.md', + 'content': """# 扩展技能能力 + +## 动态加载 +系统会自动扫描 `~/.aibox/skills/` 目录下的可执行文件作为扩展技能。 + +## 技能接口要求 +每个技能只需支持一个接口: +- `--help`:输出该技能的完整使用说明,**第一行为一句话简介**,其余为参数说明和示例 + +系统启动时会对所有技能执行 `--help`,取第一行作为简介摘要,无需再实现 `--description`、`--parameters`、`--execute` 接口。 + +## 如何搜索和调用扩展技能 +1. 使用 `search_skills` 工具搜索关键字,获取匹配技能的名称和简介列表(传入空字符串可列出全部)。 +2. 对目标技能,通过 `run_command` 执行 `技能名 --help`,获取完整命令手册。 +3. 按照手册说明,使用 `run_command` 以正确的命令行格式调用该技能。 + +## 如何创建新技能(AI自主编写) +调用 `create_skill` 工具,工具会返回一段指令提示词,指导你自行完成脚本的编写和部署: +``` +create_skill skill_name="my-tool" description="一个将摄氏度转换为华氏度的工具,接受 -t 参数输入温度值" +``` +- `skill_name`:技能名称(字母/数字/连字符/下划线,无扩展名) +- `description`:对技能功能的详细需求描述,越详细越好 + +收到提示词后,你需要依次: +1. 自行编写满足接口要求的 Python 脚本 +2. 用 `write_file` 写入 `~/.aibox/skills/` +3. 用 `run_command` 执行 `chmod +x` 赋权 +4. 用 `run_command` 执行 ` --help` 验证 +5. 调用 `reload_skills` 热重载 + +## 如何手动安装新技能 +调用 `install_skill` 工具,获取完整的安装步骤指引(可选传入技能文件路径): +``` +install_skill skill_path="/path/to/your_skill.py" +``` +指引内容包括:可执行性检查、`--help` 验证、复制到技能目录并去掉扩展名、热重载。 + +## 热重载 +添加、修改或删除技能后,可以使用 `reload_skills` 工具立即生效,无需重启系统。 + +``` +reload_skills confirm=true +``` +""" + }, + { + 'filename': '8_identity_management.md', + 'content': """# 身份信息管理 + +## 用户身份 +使用 `update_identity` 工具更新用户身份信息(保存在 00_IDENTITY.md) + +``` +update_identity content="用户是一名程序员,喜欢Python,目前在做一个AI项目" +``` + +## 智能体自我认识 +使用 `update_soul` 工具更新自己的灵魂定义(保存在 99_SOUL.md) + +``` +update_soul content="我是...(新的自我定义)" +``` + +当你需要调整角色定位、性格特点、能力边界时,都应该主动使用这个工具更新自己! +""" + }, + { + 'filename': '9_language_instruction.md', + 'content': """# 语言说明 + +## 语言选择 +- 根据用户的输入语言来选择回复语言 +- 用户用中文提问 → 用中文回复 +- 用户用英文提问 → 用英文回复 +- 用户可以明确要求使用特定语言 + +## 输出格式 +- 使用清晰的结构 +- 适当使用emoji增加可读性 +- 重要信息可以加粗或标记 + +记住:用用户习惯的语言交流! +""" + }, + { + 'filename': '10_task_handling.md', + 'content': """# 任务处理规则 + +## 定时任务 +当收到定时任务触发时: +1. 专注处理当前任务内容 +2. 不要偏离主题 +3. 使用相应工具执行操作 +4. 完成后使用 `save_memory_and_end_conversation` 结束对话 + +## 即时任务 +当任务需要立即执行时: +1. 尽快响应 +2. 明确告知用户正在处理 +3. 完成后报告结果 + +## 重复任务 +对于重复任务: +- 每次触发时专注当前执行 +- 不要重复之前已完成的工作 +- 系统会自动更新下一次执行时间 +""" + } + ] + + for skill in skill_files: + skill_path = os.path.join(self._prompts_dir, skill['filename']) + if not os.path.exists(skill_path): + try: + with open(skill_path, 'w', encoding='utf-8') as f: + f.write(skill['content']) + print(f"✅ 已创建基础技能文件: {skill['filename']}") + except Exception as e: + print(f"⚠️ 创建基础技能文件 {skill['filename']} 失败: {e}") + + def update_identity(self, content: str) -> bool: + """更新用户身份信息""" + identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) + try: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + updated_content = f"""# 用户身份信息 + +{content} + +【更新记录】 +- 上次更新:{timestamp} +""" + with open(identity_path, 'w', encoding='utf-8') as f: + f.write(updated_content) + self.reload() + return True + except Exception as e: + print(f"⚠️ 更新身份文件失败: {e}") + return False + + def update_status(self, content: str) -> bool: + """更新智能体状态""" + status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) + try: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + updated_content = f"""# 智能体状态记录 + +{content} + +【更新记录】 +- 上次更新:{timestamp} +""" + with open(status_path, 'w', encoding='utf-8') as f: + f.write(updated_content) + self.reload() + return True + except Exception as e: + print(f"⚠️ 更新状态文件失败: {e}") + return False + + def update_soul(self, content: str) -> bool: + """更新智能体灵魂定义 - 所有角色定位内容""" + soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) + try: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + updated_content = f"""# 智能体灵魂定义 - 我是谁 + +{content} + +【更新记录】 +- 上次更新:{timestamp} +""" + with open(soul_path, 'w', encoding='utf-8') as f: + f.write(updated_content) + self.reload() + return True + except Exception as e: + print(f"⚠️ 更新灵魂文件失败: {e}") + return False + + def get_identity(self) -> str: + """获取用户身份信息""" + identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE) + try: + if os.path.exists(identity_path): + with open(identity_path, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + print(f"⚠️ 读取身份文件失败: {e}") + return "尚不清楚 - 无法读取身份信息" + + def get_status(self) -> str: + """获取智能体状态""" + status_path = os.path.join(self._prompts_dir, self.STATUS_FILE) + try: + if os.path.exists(status_path): + with open(status_path, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + print(f"⚠️ 读取状态文件失败: {e}") + return "这是第一次对话 - 无法读取状态信息" + + def get_soul(self) -> str: + """获取智能体灵魂定义 - 角色定位核心""" + soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE) + try: + if os.path.exists(soul_path): + with open(soul_path, 'r', encoding='utf-8') as f: + return f.read().strip() + except Exception as e: + print(f"⚠️ 读取灵魂文件失败: {e}") + return """我是DeepSeek,一个智能AI助手。 +请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。""" + + def get_skill_prompts(self) -> List[Dict]: + """获取所有基础技能提示词(以1_开头的文件)""" + return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)] + + def get_soul_prompt(self) -> Optional[Dict]: + """获取灵魂提示词(99_SOUL.md)""" + for p in self._prompts: + if p['file'] == self.SOUL_FILE: + return p + return None + + def _create_default_prompts(self): + """创建默认提示词(已由各 _ensure_* 方法处理)""" + pass + + def get_prompts(self, join_with: str = "\n\n") -> str: + """获取所有提示词,按顺序拼接""" + if not self._prompts: + return "" + + # 排序规则:先基础技能(1_开头),再其他,最后灵魂文件 + def sort_key(p): + file = p['file'] + if file == self.SOUL_FILE: + return 3 # 灵魂文件最后加载 + elif file.startswith(self.SKILL_PREFIX): + return 1 # 基础技能 + elif file == self.IDENTITY_FILE: + return 0 # 身份文件最先 + elif file == self.STATUS_FILE: + return 4 # 状态文件最后 + else: + return 2 + + sorted_prompts = sorted(self._prompts, key=sort_key) + contents = [p['content'] for p in sorted_prompts] + return join_with.join(contents) + + def get_prompts_list(self) -> List[Dict]: + """获取所有提示词列表""" + return self._prompts.copy() + + def reload(self): + """重新加载所有提示词""" + self._prompts = [] + self._load_prompts() + return len(self._prompts) + + def get_prompt_count(self) -> int: + return len(self._prompts) + + def get_prompts_dir_path(self) -> str: + return self._prompts_dir + + +# ==================== 配置管理 ==================== + + +class ConfigManager: + """配置管理器 - 单例模式,负责加载和保存配置""" + + _instance = None + _config = None + _config_path = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if self._config is None: + self._load_config() + + def _get_default_config_path(self) -> str: + env_config = os.environ.get("AI_CONFIG_PATH") + if env_config: + return env_config + return os.path.expanduser("~/.aibox/config.json") + + def _get_default_config(self) -> Dict: + return { + "system": { + "save_dir": "~/.aibox", + "datetime_format": "%Y-%m-%d %H:%M:%S", + "date_format": "%Y%m%d_%H%M%S", + "debug_level": 1, + "load_history_by_default": True, + "log_file": "~/.aibox/ai.log", + "log_max_size": 10485760, + "log_backup_count": 5, + "skills_dir": "~/.aibox/skills", + "prompts_dir": "~/.aibox/prompts", + "websocket_host": "localhost", + "websocket_port": 8765, + }, + "scheduler": { + "enabled": True, + "check_interval": 1.0, + "max_tasks_per_run": 10, + "broadcast_enabled": True + }, + "api": { + "deepseek_api_url": "https://api.deepseek.com/chat/completions", + "deepseek_model": "deepseek-v4-flash", + "api_key_env": "DEEPSEEK_API_KEY", + "timeout": 30, + "temperature": 0.7 + }, + "context": { + "max_history_messages": 500, + "max_context_messages": 120, + "max_tokens": 8192, + "max_history_conversations": 5, + "preserve_system_prompts": True, + # 对话压缩配置 + "compress_enabled": True, + "compress_message_threshold": 200, + "compress_token_threshold": 120000, + "compress_ratio": 0.2, + "compress_keep_recent": 8 + }, + "memo": { + "default_reminder_minutes": 60, + "max_memos_per_check": 10, + "allow_user_tasks": True + }, + "files": { + "max_file_size": 10485710 + }, + "commands": { + "enabled": True + }, + "web": { + "timeout": 10, + "delay": 0, + "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "respect_robots": False, + "max_download_size": 104857600 + }, + "memory": { + "db_name": "memories.db", + "max_search_results": 100, + "default_importance": 2 + }, + "conversation": { + "current_file": "current_conversation.json", + "history_file": "conversation_history.json", + "archive_dir": "archive" + }, + "tools": { + "max_result_length": 10000, + "save_result_to_file": False, + "tool_results_dir": "tool_results" + }, + "compressor": { + "min_messages_to_compress": 5, + "weight_decay_factor": 0.01, + "max_input_chars": 6000, + "llm_max_tokens": 800 + } + } + + def _load_config(self): + config_path = self._get_default_config_path() + self._config_path = config_path + + if os.path.exists(config_path): + try: + with open(config_path, 'r', encoding='utf-8') as f: + user_config = json.load(f) + default_config = self._get_default_config() + self._config = self._merge_configs(default_config, user_config) + print(i18n.get('config_loaded', config_path)) + except Exception as e: + print(f"⚠️ {i18n.get('error_prefix')}{e},使用默认配置") + self._config = self._get_default_config() + else: + print(i18n.get('config_not_found', config_path)) + self._config = self._get_default_config() + self._save_default_config(config_path) + + def _merge_configs(self, default: Dict, user: Dict) -> Dict: + result = default.copy() + for key, value in user.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_configs(result[key], value) + else: + result[key] = value + return result + + def _save_default_config(self, config_path: str): + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(self._get_default_config(), f, ensure_ascii=False, indent=2) + print(i18n.get('config_created', config_path)) + except: + pass + + def get(self, key_path: str, default=None): + keys = key_path.split('.') + value = self._config + try: + for key in keys: + value = value[key] + return value + except (KeyError, TypeError): + return default + + def reload(self): + self._load_config() + + + def get_proxy(self) -> Optional[Dict[str, str]]: + """获取当前代理配置(返回 requests 可用的 proxies 字典)""" + proxy_conf = self.get("proxy.socks5") + if not proxy_conf or not proxy_conf.get("enabled", False): + return None + host = proxy_conf.get("host", "127.0.0.1") + port = proxy_conf.get("port", 1080) + username = proxy_conf.get("username") + password = proxy_conf.get("password") + if username and password: + proxy_url = f"socks5://{username}:{password}@{host}:{port}" + else: + proxy_url = f"socks5://{host}:{port}" + return {"http": proxy_url, "https": proxy_url} + + def set_proxy(self, host: str, port: int, username: str = None, password: str = None, enabled: bool = True, save: bool = True): + """动态设置代理配置(内存中更新,并可选择写入配置文件)""" + # 确保 proxy 节点存在 + if "proxy" not in self._config: + self._config["proxy"] = {} + self._config["proxy"]["socks5"] = { + "enabled": enabled, + "host": host, + "port": port, + "username": username, + "password": password + } + if save: + self._save_config() + return True + + def _save_config(self): + """将当前配置保存到配置文件(覆盖)""" + config_path = self._config_path + if config_path: + try: + os.makedirs(os.path.dirname(config_path), exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(self._config, f, ensure_ascii=False, indent=2) + print(f"✅ 配置已保存到 {config_path}") + except Exception as e: + print(f"⚠️ 保存配置失败: {e}") + + def get_proxy_info(self) -> Dict: + """获取代理配置的详细信息(用于显示)""" + proxy_conf = self.get("proxy.socks5", {}) + return { + "enabled": proxy_conf.get("enabled", False), + "host": proxy_conf.get("host", ""), + "port": proxy_conf.get("port", 0), + "username": proxy_conf.get("username"), + "password": proxy_conf.get("password") is not None # 只显示是否存在 + } +# ==================== 常量定义 ==================== + + +class Constants: + """系统常量 - 从配置管理器获取""" + + _config = ConfigManager() + + @classmethod + def _get_config(cls): + return cls._config + + @property + def DATETIME_FORMAT(cls): + return cls._get_config().get("system.datetime_format", "%Y-%m-%d %H:%M:%S") + + @property + def DATE_FORMAT(cls): + return cls._get_config().get("system.date_format", "%Y%m%d_%H%M%S") + + @property + def DEEPSEEK_API_URL(cls): + return cls._get_config().get("api.deepseek_api_url") + + @property + def DEEPSEEK_MODEL(cls): + return cls._get_config().get("api.deepseek_model") + + @property + def DEFAULT_TIMEOUT(cls): + return cls._get_config().get("api.timeout", 30) + + @property + def DEFAULT_MAX_HISTORY(cls): + return cls._get_config().get("context.max_history_messages", 500) + + @property + def DEFAULT_TEMPERATURE(cls): + return cls._get_config().get("api.temperature", 0.7) + + @property + def DEFAULT_MAX_TOKENS(cls): + return cls._get_config().get("context.max_tokens", 8000) + + @property + def MAX_FILE_SIZE(cls): + return cls._get_config().get("files.max_file_size", 10485710) + + @property + def SAVE_DIR(cls): + return os.path.expanduser(cls._get_config().get("system.save_dir", "~/.aibox")) + + @property + def SKILLS_DIR(cls): + return os.path.expanduser(cls._get_config().get("system.skills_dir", "~/.aibox/skills")) + + @property + def PROMPTS_DIR(cls): + return os.path.expanduser(cls._get_config().get("system.prompts_dir", "~/.aibox/prompts")) + + @property + def MEMORY_DB(cls): + return cls._get_config().get("memory.db_name", "memories.db") + + @property + def CURRENT_CONVERSATION_FILE(cls): + return cls._get_config().get("conversation.current_file", "current_conversation.json") + + @property + def HISTORY_FILE(cls): + return cls._get_config().get("conversation.history_file", "conversation_history.json") + + @property + def ARCHIVE_DIR(cls): + return cls._get_config().get("conversation.archive_dir", "archive") + + @property + def USER_PREFIX(cls): + return "[用户]" + + @property + def SYSTEM_PREFIX(cls): + return "[系统]" + + @property + def ASSISTANT_PREFIX(cls): + return "[助手]" + + @property + def MAX_HISTORY_CONTEXT(cls): + return cls._get_config().get("context.max_context_messages", 50) + + @property + def MAX_HISTORY_CONVERSATIONS(cls): + return cls._get_config().get("context.max_history_conversations", 5) + + @property + def PRESERVE_SYSTEM_PROMPTS(cls): + return cls._get_config().get("context.preserve_system_prompts", True) + + @property + def WEB_TIMEOUT(cls): + return cls._get_config().get("web.timeout", 10) + + @property + def WEB_DELAY(cls): + return cls._get_config().get("web.delay", 0) + + @property + def WEB_USER_AGENT(cls): + return cls._get_config().get("web.user_agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + @property + def WEB_MAX_DOWNLOAD_SIZE(cls): + return cls._get_config().get("web.max_download_size", 104857600) + + @property + def MEMORY_MAX_SEARCH_RESULTS(cls): + return cls._get_config().get("memory.max_search_results", 100) + + @property + def MEMORY_DEFAULT_IMPORTANCE(cls): + return cls._get_config().get("memory.default_importance", 2) + + @property + def MEMO_DEFAULT_REMINDER_MINUTES(cls): + return cls._get_config().get("memo.default_reminder_minutes", 60) + + @property + def MEMO_MAX_PER_CHECK(cls): + return cls._get_config().get("memo.max_memos_per_check", 10) + + @property + def LOG_FILE(cls): + return os.path.expanduser(cls._get_config().get("system.log_file", "~/.aibox/ai.log")) + + @property + def LOG_MAX_SIZE(cls): + return cls._get_config().get("system.log_max_size", 10485760) + + @property + def LOG_BACKUP_COUNT(cls): + return cls._get_config().get("system.log_backup_count", 5) + + @property + def COMMANDS_ENABLED(cls): + return cls._get_config().get("commands.enabled", True) + + @property + def WEBSOCKET_HOST(cls): + return cls._get_config().get("system.websocket_host", "localhost") + + @property + def WEBSOCKET_PORT(cls): + return cls._get_config().get("system.websocket_port", 8765) + + @property + def SCHEDULER_ENABLED(cls): + return cls._get_config().get("scheduler.enabled", True) + + @property + def SCHEDULER_CHECK_INTERVAL(cls): + return cls._get_config().get("scheduler.check_interval", 1.0) + + @property + def SCHEDULER_MAX_TASKS(cls): + return cls._get_config().get("scheduler.max_tasks_per_run", 10) + + @property + def SCHEDULER_BROADCAST(cls): + return cls._get_config().get("scheduler.broadcast_enabled", True) + + @property + def MEMO_ALLOW_USER_TASKS(cls): + return cls._get_config().get("memo.allow_user_tasks", True) + + @property + def COMPRESS_ENABLED(cls): + return cls._get_config().get("context.compress_enabled", True) + + @property + def COMPRESS_MESSAGE_THRESHOLD(cls): + return cls._get_config().get("context.compress_message_threshold", 100) + + @property + def COMPRESS_TOKEN_THRESHOLD(cls): + return cls._get_config().get("context.compress_token_threshold", 4000) + + @property + def COMPRESS_RATIO(cls): + return cls._get_config().get("context.compress_ratio", 0.4) + + @property + def COMPRESS_KEEP_RECENT(cls): + return cls._get_config().get("context.compress_keep_recent", 20) + + @property + def TOOL_MAX_RESULT_LENGTH(cls): + return cls._get_config().get("tools.max_result_length", 10000) + + @property + def COMPRESSOR_MIN_MESSAGES(cls): + return cls._get_config().get("compressor.min_messages_to_compress", 5) + + @property + def COMPRESSOR_WEIGHT_DECAY_FACTOR(cls): + return cls._get_config().get("compressor.weight_decay_factor", 0.01) + + @property + def COMPRESSOR_MAX_INPUT_CHARS(cls): + return cls._get_config().get("compressor.max_input_chars", 6000) + + @property + def COMPRESSOR_LLM_MAX_TOKENS(cls): + return cls._get_config().get("compressor.llm_max_tokens", 800) + + +# 创建常量实例 +constants = Constants() + + +# ==================== 日志系统 ==================== + +class Logger: + """统一日志系统 - 支持文件和终端输出""" + + LEVELS = { + "DEBUG": 3, + "INFO": 2, + "WARNING": 1, + "ERROR": 0 + } + + _file_handler = None + _log_initialized = False + + @classmethod + def init_file_logging(cls, log_file: str = None, max_size: int = None, backup_count: int = None): + """初始化文件日志""" + if cls._log_initialized: + return + + if log_file is None: + log_file = constants.LOG_FILE + if max_size is None: + max_size = constants.LOG_MAX_SIZE + if backup_count is None: + backup_count = constants.LOG_BACKUP_COUNT + + log_dir = os.path.dirname(log_file) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + cls._file_handler = RotatingFileHandler( + log_file, + maxBytes=max_size, + backupCount=backup_count, + encoding='utf-8' + ) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + cls._file_handler.setFormatter(formatter) + + cls._log_initialized = True + + def __init__(self, name: str = "System", level: int = None): + self.name = name + config = ConfigManager() + if level is None: + level = config.get("system.debug_level", 1) + self.level = level + + self.py_logger = logging.getLogger(f"AI.{name}") + self.py_logger.setLevel(logging.DEBUG) + + if Logger._file_handler and not self.py_logger.handlers: + self.py_logger.addHandler(Logger._file_handler) + + def debug(self, msg: str): + if self.level >= 3: + self._log("🐛", "DEBUG", msg) + self.py_logger.debug(msg) + + def info(self, msg: str): + if self.level >= 2: + self._log("📋", "INFO", msg) + self.py_logger.info(msg) + + def warning(self, msg: str): + if self.level >= 1: + self._log("⚠️", "WARNING", msg) + self.py_logger.warning(msg) + + def error(self, msg: str): + if self.level >= 0: + self._log("❌", "ERROR", msg) + self.py_logger.error(msg) + + def _log(self, emoji: str, level: str, msg: str): + """输出到终端""" + timestamp = datetime.now().strftime("%H:%M:%S") + print(f"{emoji} [{timestamp}] [{self.name}] {msg}", flush=True) + +class ConversationCompressor: + """对话压缩管理器 - 优先压缩较早的消息,将tool调用信息一并压缩""" + + def __init__(self, ai: 'DeepSeekChat', logger: Logger = None): + self.ai = ai + self.logger = logger or Logger("Compressor") + self.config = ConfigManager() + # 从配置获取最小值 + self.min_messages_to_compress = constants.COMPRESSOR_MIN_MESSAGES + # 权重衰减因子:从配置获取,设为0.6使加权计算更合理 + self.weight_decay_factor = 0.6 # 使用更高的衰减因子 + + def estimate_tokens(self, messages: List[Dict]) -> int: + """估算消息的token数""" + total_tokens = 0 + for msg in messages: + content = msg.get("content", "") + if content: + chinese_chars = len([c for c in content if '\u4e00' <= c <= '\u9fff']) + english_words = len(re.findall(r'[a-zA-Z]+', content)) + other_chars = len(content) - chinese_chars - english_words + + estimated = chinese_chars * 1.5 + english_words * 1.3 + other_chars * 0.8 + total_tokens += estimated + total_tokens += 10 + + if msg.get("tool_calls"): + # 工具调用也计算token + for tc in msg.get("tool_calls", []): + func = tc.get("function", {}) + if func.get("name"): + total_tokens += len(func.get("name", "")) * 1.5 + if func.get("arguments"): + total_tokens += len(func.get("arguments", "")) * 0.8 + total_tokens += 50 + + return int(total_tokens) + + def should_compress(self, messages: List[Dict]) -> Tuple[bool, str]: + """检查是否需要压缩对话 - 优先根据消息条数判断""" + if not constants.COMPRESS_ENABLED: + return False, "压缩功能已禁用" + + conversation_messages = [m for m in messages if m.get("role") != "system"] + + # 优先检查消息条数是否达到阈值 + message_count = len(conversation_messages) + if message_count < constants.COMPRESS_MESSAGE_THRESHOLD: + return False, f"对话消息数量({message_count})小于压缩阈值({constants.COMPRESS_MESSAGE_THRESHOLD})" + + # 消息条数达到阈值,进行压缩 + print(f"⚠️ {i18n.get('compress_threshold_reached', message_count)}") + return True, f"消息数({message_count})超过阈值({constants.COMPRESS_MESSAGE_THRESHOLD})" + + def _calculate_target_count(self, current_count: int, threshold: int, keep_ratio: float = 0.7) -> int: + """ + 计算目标消息数量 - 压缩到阈值的 keep_ratio 倍 + + Args: + current_count: 当前消息数量 + threshold: 压缩阈值 + keep_ratio: 压缩后保留的比例(相对于阈值),默认0.7表示压缩到阈值的70% + + Returns: + 目标消息数量 + """ + # 压缩到阈值以下,保留阈值的 keep_ratio 倍 + target = int(threshold * keep_ratio) + + # 确保至少有 min_messages_to_compress 条消息 + target = max(target, self.min_messages_to_compress) + + # 确保不超过当前数量 + target = min(target, current_count - 1) + + return target + + def _find_compressible_block_by_count(self, messages: List[Dict], target_count: int) -> Tuple[int, int]: + """ + 根据目标消息数量找到可压缩块 - 不保护tool消息,直接压缩早期消息 + + Args: + messages: 消息列表 + target_count: 目标消息数量 + + Returns: + (start, end) 压缩块的起始和结束索引 + """ + if len(messages) <= target_count: + return 0, 0 + + # 计算需要压缩的消息数量 + messages_to_remove = len(messages) - target_count + + # 确定要保留的最近消息数量 + keep_recent = constants.COMPRESS_KEEP_RECENT + if keep_recent > 0: + keep_recent = max(keep_recent, 10) # 至少保留最近10条 + + # 压缩块应该从最早的消息开始,但需要保留前几轮对话 + # 计算最早可以开始压缩的位置(跳过前2轮对话) + start_min = 0 + + # 尝试找到前2个对话轮次结束的位置 + conversation_turns = self._build_conversation_turns(messages) + if len(conversation_turns) >= 2: + # 前2轮结束的位置 + first_turn = conversation_turns[0] + second_turn = conversation_turns[1] + # 取第二轮结束的位置 + if second_turn["assistant_index"] is not None: + start_min = second_turn["assistant_index"] + 1 + else: + start_min = second_turn["user_index"] + 1 + + # 压缩结束的位置 = 总消息数 - 保留的最近消息数 + end_max = len(messages) - keep_recent + + # 确保压缩块有效 + if end_max <= start_min + 2: + # 如果保护区域太大,尝试压缩更多 + end_max = len(messages) - max(keep_recent // 2, 5) + + # 计算需要压缩的块大小 + block_size = min(messages_to_remove + 10, end_max - start_min) + block_size = max(block_size, 10) # 至少压缩10条 + + # 压缩块从 start_min 开始 + start = start_min + end = min(start + block_size, end_max) + + # 确保压缩块至少2条消息 + if end - start < 2: + # 如果块太小,尝试从更早的位置开始 + start = max(0, end_max - block_size) + end = end_max + + self.logger.debug(f"找到压缩块: [{start}:{end}], 移除{end-start}条消息") + return start, end + + def _build_conversation_turns(self, messages: List[Dict]) -> List[Dict]: + """构建对话轮次""" + conversation_turns = [] + + i = 0 + while i < len(messages): + msg = messages[i] + role = msg.get("role") + + # 跳过系统消息 + if role == "system": + i += 1 + continue + + # 遇到用户消息,开始一个新的轮次 + if role == "user": + turn = {"user_index": i, "assistant_index": None, "tool_indices": []} + + # 向后查找对应的助手消息和工具消息 + j = i + 1 + while j < len(messages): + next_msg = messages[j] + next_role = next_msg.get("role") + + # 跳过系统消息 + if next_role == "system": + j += 1 + continue + + # 找到助手消息 + if next_role == "assistant": + turn["assistant_index"] = j + # 检查这个助手消息是否有 tool_calls + if next_msg.get("tool_calls"): + # 收集这个助手消息的 tool_call_ids + tool_call_ids = {tc.get("id") for tc in next_msg.get("tool_calls", []) if tc.get("id")} + # 继续查找对应的 tool 消息 + k = j + 1 + while k < len(messages) and len(turn["tool_indices"]) < len(tool_call_ids): + tool_msg = messages[k] + if tool_msg.get("role") == "tool": + tool_call_id = tool_msg.get("tool_call_id") + if tool_call_id and tool_call_id in tool_call_ids: + turn["tool_indices"].append(k) + k += 1 + elif tool_msg.get("role") == "user": + break + else: + k += 1 + break + + if next_role == "user": + break + + j += 1 + + conversation_turns.append(turn) + i += 1 + else: + i += 1 + + return conversation_turns + + def _is_range_safe(self, messages: List[Dict], start: int, end: int, protected: set) -> bool: + """检查消息范围是否安全 - 简化版,不检查tool完整性""" + return True # 不保护tool消息,允许压缩 + + def _get_protected_indices(self, messages: List[Dict]) -> set: + """ + 找出所有受保护的消息索引 - 简化版,只保护最近的对话轮次 + """ + protected_indices = set() + + # ========== 1. 保护最近的消息(由配置决定) ========== + keep_recent = constants.COMPRESS_KEEP_RECENT + if keep_recent > 0: + recent_start = max(0, len(messages) - keep_recent) + for i in range(recent_start, len(messages)): + protected_indices.add(i) + + # ========== 2. 保护前2个对话轮次 ========== + conversation_turns = self._build_conversation_turns(messages) + + if len(conversation_turns) >= 2: + # 保护第1轮 + first_turn = conversation_turns[0] + protected_indices.add(first_turn["user_index"]) + if first_turn["assistant_index"] is not None: + protected_indices.add(first_turn["assistant_index"]) + for tool_idx in first_turn["tool_indices"]: + protected_indices.add(tool_idx) + + # 保护第2轮 + second_turn = conversation_turns[1] + protected_indices.add(second_turn["user_index"]) + if second_turn["assistant_index"] is not None: + protected_indices.add(second_turn["assistant_index"]) + for tool_idx in second_turn["tool_indices"]: + protected_indices.add(tool_idx) + + # 调试日志 + if self.logger.level >= 3: + self.logger.debug(f"受保护的消息索引: {sorted(protected_indices)}") + + return protected_indices + + def _build_compression_prompt(self, messages: List[Dict]) -> str: + """构建压缩提示词 - 包含工具调用信息""" + conversation_lines = [] + + for msg in messages: + role = msg.get("role") + content = msg.get("content", "") + + if role == "user": + if content: + conversation_lines.append(f"用户: {content}") + + elif role == "assistant": + if content: + conversation_lines.append(f"助手: {content}") + + # 包含工具调用信息 + tool_calls = msg.get("tool_calls", []) + for tc in tool_calls: + func = tc.get("function", {}) + tool_name = func.get("name", "") + arguments = func.get("arguments", "") + if tool_name: + if arguments: + conversation_lines.append(f"[调用工具: {tool_name}({arguments[:100]})]") + else: + conversation_lines.append(f"[调用工具: {tool_name}]") + + elif role == "tool": + if content: + useful = self._extract_useful_content(content) + if useful: + conversation_lines.append(f"[工具结果: {useful}]") + + conversation_text = "\n".join(conversation_lines) + + max_input_chars = constants.COMPRESSOR_MAX_INPUT_CHARS + if len(conversation_text) > max_input_chars: + conversation_text = conversation_text[:max_input_chars] + "\n...(内容过长,已截断)..." + + prompt = f"""请对以下对话内容进行智能压缩和总结。要求: + +1. **提取核心信息**:对话的主要主题、关键问题、重要结论、用户需求 +2. **保留重要细节**:用户的重要要求、AI的重要回复、达成的共识 +3. **保留工具调用摘要**:简要说明使用了什么工具,得到了什么关键结果 +4. **结构化输出**:使用清晰的段落结构 + +对话内容: +--- +{conversation_text} +--- + +请输出压缩后的对话摘要:""" + + return prompt + + def _extract_useful_content(self, content: str) -> str: + """从工具返回内容中提取有用信息""" + if not content: + return "" + + if "✅" in content: + lines = content.split("\n") + for line in lines[:3]: + if "✅" in line: + return line.strip() + + if "❌" in content: + lines = content.split("\n") + for line in lines[:2]: + if "❌" in line: + return line.strip() + + if len(content) > 100: + return content[:100] + "..." + + return content + + def _compress_via_llm(self, messages: List[Dict]) -> Optional[str]: + """通过语言模型压缩消息""" + if not messages: + return None + + prompt = self._build_compression_prompt(messages) + + print(f"📦 {i18n.get('conversation_compress')}: 正在压缩 {len(messages)} 条消息...") + + try: + headers = { + "Authorization": f"Bearer {self.ai.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": constants.DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": "你是一个专业的对话摘要助手,擅长提取对话核心信息并进行结构化总结。请保留工具调用的关键结果。"}, + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": constants.COMPRESSOR_LLM_MAX_TOKENS + } + + response = requests.post( + constants.DEEPSEEK_API_URL, + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + result = response.json() + summary = result["choices"][0]["message"]["content"].strip() + return summary + else: + print(f" ❌ 语言模型压缩失败: HTTP {response.status_code}") + return None + + except Exception as e: + self.logger.error(f"语言模型压缩异常: {e}") + return None + + def _validate_message_structure(self, messages: List[Dict]) -> List[Dict]: + """验证并修复消息结构,移除孤立的 tool 消息""" + if not messages: + return messages + + # 找出所有 assistant 消息的 tool_call_ids + assistant_tool_ids = set() + for msg in messages: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + for tc in msg.get("tool_calls", []): + tc_id = tc.get("id") + if tc_id: + assistant_tool_ids.add(tc_id) + + # 过滤掉没有对应 assistant 的 tool 消息 + filtered = [] + for msg in messages: + if msg.get("role") == "tool": + tool_call_id = msg.get("tool_call_id") + if tool_call_id and tool_call_id not in assistant_tool_ids: + print(f" ⚠️ 移除孤立的 tool 消息: {tool_call_id}") + continue + filtered.append(msg) + + return filtered + + def compress_conversation(self, messages: List[Dict]) -> List[Dict]: + """ + 压缩对话内容 - 优先压缩较早的消息,包含tool调用信息一并压缩 + 当消息条数超过阈值时,压缩到阈值以下 + """ + if not messages: + return messages + + # 分离系统消息和对话消息 + system_messages = [] + conversation_messages = [] + + for msg in messages: + if msg.get("role") == "system": + system_messages.append(msg) + else: + conversation_messages.append(msg) + + if not conversation_messages: + return system_messages + + original_count = len(conversation_messages) + original_tokens = self.estimate_tokens(conversation_messages) + threshold = constants.COMPRESS_MESSAGE_THRESHOLD + + print(f"\n📊 对话压缩检查:") + print(f" 当前消息数: {original_count} 条 (阈值: {threshold} 条)") + print(f" 当前Token数: {original_tokens} 个") + + # 计算目标消息数量 - 压缩到阈值的70% + target_count = self._calculate_target_count(original_count, threshold, keep_ratio=0.7) + print(f" 目标消息数: {target_count} 条 (压缩后应少于阈值)") + + # 如果目标数量大于等于当前数量,不需要压缩 + if target_count >= original_count: + print(f" 目标消息数大于等于当前,跳过压缩") + return messages + + # 计算需要压缩的消息数量 + messages_to_compress = original_count - target_count + print(f" 需要压缩的消息数量: {messages_to_compress} 条") + + # 根据消息数量查找可压缩块 + compress_start, compress_end = self._find_compressible_block_by_count( + conversation_messages, target_count + ) + + if compress_end - compress_start >= 2: + compressible = conversation_messages[compress_start:compress_end] + compress_tokens = self.estimate_tokens(compressible) + compress_count = len(compressible) + + print(f" 压缩块范围: [{compress_start}:{compress_end}], 共 {compress_count} 条消息") + print(f" 压缩块token数: {compress_tokens}") + + # 如果压缩块大小仍然不够,尝试扩大压缩块 + if compress_count < messages_to_compress: + print(f" 压缩块大小不足({compress_count} < {messages_to_compress}),扩大压缩块...") + # 扩大压缩块 + additional = messages_to_compress - compress_count + 5 + new_start = max(0, compress_start - additional // 2) + new_end = min(len(conversation_messages), compress_end + additional // 2) + compress_start, compress_end = new_start, new_end + compressible = conversation_messages[compress_start:compress_end] + compress_count = len(compressible) + print(f" 扩大后压缩块: [{compress_start}:{compress_end}], 共 {compress_count} 条消息") + else: + print(f" 未找到合适的压缩块,使用默认压缩策略") + # 使用默认压缩策略:压缩最早的一半消息,保留最近的消息 + keep_recent = constants.COMPRESS_KEEP_RECENT + compress_end = len(conversation_messages) - keep_recent + compress_start = max(0, compress_end - messages_to_compress - 5) + compress_start = max(compress_start, 0) + compressible = conversation_messages[compress_start:compress_end] + compress_count = len(compressible) + print(f" 默认压缩块: [{compress_start}:{compress_end}], 共 {compress_count} 条消息") + + # 确保压缩块有足够多的消息 + if compress_count < 5: + print(f" 压缩块太小({compress_count}条),跳过压缩") + return messages + + # 调用语言模型压缩 + summary = self._compress_via_llm(compressible) + + if not summary: + print(f" ❌ 压缩失败,保留原始对话") + return messages + + # 构建压缩后的消息列表 + compressed_result = [] + + # 添加压缩块之前的消息 + compressed_result.extend(conversation_messages[:compress_start]) + + # 插入压缩摘要(作为系统消息) + compressed_result.append({ + "role": "system", + "content": i18n.get('compressed_summary', summary) + }) + + # 添加压缩块之后的消息(包括最近的消息) + compressed_result.extend(conversation_messages[compress_end:]) + + # 验证并修复消息结构 + compressed_result = self._validate_message_structure(compressed_result) + + # 合并系统消息 + final_messages = system_messages + compressed_result + + # 统计结果 + final_count = len([m for m in final_messages if m.get("role") != "system"]) + final_tokens = self.estimate_tokens([m for m in final_messages if m.get("role") != "system"]) + + print(f"\n✅ {i18n.get('conversation_compressed', original_count, final_count)}") + print(f" Token: {original_tokens} -> {final_tokens} (压缩率: {final_tokens/original_tokens:.1%})") + + # 检查是否压缩到阈值以下 + if final_count >= threshold: + print(f" ⚠️ 警告: 压缩后消息数({final_count})仍大于等于阈值({threshold})") + + return final_messages + +# ==================== 备忘录数据库 ==================== + +class MemoDatabase: + """备忘录数据库 - 存储和管理任务""" + + def __init__(self, db_path: str = None): + if db_path is None: + db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB) + + # 确保目录存在 + os.makedirs(constants.SAVE_DIR, exist_ok=True) + self.db_path = db_path + self.logger = Logger("MemoDB") + self._init_database() + + def _init_database(self): + """初始化备忘录表""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS memos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT, + created_at TEXT NOT NULL, + reminder_time TEXT, + completed_at TEXT, + is_completed INTEGER DEFAULT 0, + tags TEXT, + priority INTEGER DEFAULT 1, + created_by TEXT, + source TEXT, + repeat_type TEXT DEFAULT 'none', + repeat_interval INTEGER DEFAULT 0, + repeat_interval_unit TEXT DEFAULT 'days', + repeat_end_time TEXT, + last_triggered_at TEXT, + trigger_count INTEGER DEFAULT 0, + is_immediate INTEGER DEFAULT 0 + ) + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_memos_reminder + ON memos(reminder_time) WHERE is_completed = 0 + ''') + + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_memos_immediate + ON memos(is_immediate) WHERE is_immediate = 1 AND is_completed = 0 + ''') + + conn.commit() + + self.logger.info("任务表初始化完成") + + def add_memo(self, title: str, content: str = None, reminder_time: str = None, + tags: List[str] = None, priority: int = 1, created_by: str = None, + source: str = None, + repeat_type: str = 'none', repeat_interval: int = 0, + repeat_interval_unit: str = 'days', repeat_end_time: str = None, + is_immediate: bool = False) -> int: + """添加任务 - 所有任务都是对话任务,不再需要metadata和任务类型""" + created_at = datetime.now().strftime(constants.DATETIME_FORMAT) + tags_str = ",".join(tags) if tags else "" + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO memos + (title, content, created_at, reminder_time, tags, priority, created_by, + is_completed, source, repeat_type, repeat_interval, repeat_interval_unit, + repeat_end_time, is_immediate) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + ''', (title, content, created_at, reminder_time, tags_str, priority, created_by, + source, repeat_type, repeat_interval, repeat_interval_unit, + repeat_end_time, 1 if is_immediate else 0)) + + memo_id = cursor.lastrowid + conn.commit() + + repeat_info = f" (重复: {repeat_type}" + if repeat_type == 'custom': + repeat_info += f", 每{repeat_interval} {repeat_interval_unit}" + repeat_info += ")" + immediate_info = " (即时任务)" if is_immediate else "" + self.logger.info(i18n.get('task_added', title, memo_id) + repeat_info + immediate_info) + return memo_id + + def get_memo(self, memo_id: int) -> Optional[Dict]: + """获取单个任务""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,)) + row = cursor.fetchone() + + if row: + memo = dict(row) + if memo.get('tags'): + memo['tags'] = memo['tags'].split(',') + return memo + return None + + def get_pending_memos(self, limit: int = None, before_time: str = None, include_immediate: bool = False) -> List[Dict]: + """获取待执行的任务 - 严格使用当前时间比较""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 使用当前时间作为基准 + now = datetime.now().strftime(constants.DATETIME_FORMAT) + + # 如果传入了 before_time,使用它,否则使用当前时间 + compare_time = before_time if before_time else now + + query = ''' + SELECT * FROM memos + WHERE is_completed = 0 + AND reminder_time IS NOT NULL + AND reminder_time <= ? -- 严格小于等于比较时间 + AND (repeat_end_time IS NULL OR repeat_end_time >= ?) + ''' + + if not include_immediate: + query += ' AND is_immediate = 0' + + query += ' ORDER BY reminder_time ASC, priority DESC' + + params = [compare_time, compare_time] + + if limit: + query += f' LIMIT {limit}' + + cursor.execute(query, params) + + results = [] + for row in cursor.fetchall(): + memo = dict(row) + if memo.get('tags'): + memo['tags'] = memo['tags'].split(',') + + # 【关键修复】二次验证:确保 reminder_time 确实 <= 当前时间 + if memo.get('reminder_time'): + try: + reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + current_time = datetime.strptime(compare_time, constants.DATETIME_FORMAT) + if reminder_time <= current_time: + results.append(memo) + else: + # 调试日志 + self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}") + except (ValueError, TypeError) as e: + self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}") + else: + # 没有提醒时间的任务不应该在待执行列表中 + self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行") + + return results + + def get_immediate_memos(self, limit: int = None) -> List[Dict]: + """获取待执行的即时任务""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + query = ''' + SELECT * FROM memos + WHERE is_completed = 0 + AND is_immediate = 1 + ORDER BY created_at ASC + ''' + + if limit: + query += f' LIMIT {limit}' + + cursor.execute(query) + + results = [] + for row in cursor.fetchall(): + memo = dict(row) + if memo.get('tags'): + memo['tags'] = memo['tags'].split(',') + results.append(memo) + + return results + + def get_overdue_memos(self, limit: int = None) -> List[Dict]: + """获取已过期的任务""" + now = datetime.now().strftime(constants.DATETIME_FORMAT) + return self.get_pending_memos(limit=limit, before_time=now) + + def get_all_memos(self, include_completed: bool = False, limit: int = 50) -> List[Dict]: + """获取所有任务""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + if include_completed: + cursor.execute(''' + SELECT * FROM memos + ORDER BY + CASE WHEN is_completed = 0 THEN 0 ELSE 1 END, + reminder_time ASC, + created_at DESC + LIMIT ? + ''', (limit,)) + else: + cursor.execute(''' + SELECT * FROM memos + WHERE is_completed = 0 + ORDER BY reminder_time ASC, priority DESC, created_at DESC + LIMIT ? + ''', (limit,)) + + results = [] + for row in cursor.fetchall(): + memo = dict(row) + if memo.get('tags'): + memo['tags'] = memo['tags'].split(',') + results.append(memo) + + return results + + def mark_as_triggered(self, memo_id: int) -> Optional[str]: + """标记任务已被触发,并根据重复规则计算下一次提醒时间""" + memo = self.get_memo(memo_id) + if not memo: + return None + + now = datetime.now() + next_reminder = None + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + cursor.execute(''' + UPDATE memos + SET last_triggered_at = ?, trigger_count = trigger_count + 1 + WHERE id = ? + ''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) + + # 处理重复任务 + if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now): + # 使用当前时间作为基准来计算下一次执行时间 + next_reminder = self._calculate_next_reminder(memo, now) + + if next_reminder: + cursor.execute(''' + UPDATE memos + SET reminder_time = ? + WHERE id = ? + ''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id)) + + self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}") + else: + # 重复结束,标记为完成 + cursor.execute(''' + UPDATE memos + SET is_completed = 1, completed_at = ? + WHERE id = ? + ''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) + + self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成") + elif memo['is_immediate']: + # 即时任务执行后标记为完成 + cursor.execute(''' + UPDATE memos + SET is_completed = 1, completed_at = ? + WHERE id = ? + ''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) + + self.logger.info(f"即时任务 #{memo_id} 已完成") + else: + # 一次性任务,标记为完成 + cursor.execute(''' + UPDATE memos + SET is_completed = 1, completed_at = ? + WHERE id = ? + ''', (now.strftime(constants.DATETIME_FORMAT), memo_id)) + + self.logger.info(f"任务 #{memo_id} 已完成") + + conn.commit() + + return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None + + def _should_end_repeat(self, memo: Dict, current_time: datetime) -> bool: + """检查是否应该结束重复""" + if memo['repeat_end_time']: + end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) + if current_time > end_time: + return True + return False + + def _calculate_next_reminder(self, memo: Dict, current_time: datetime) -> Optional[datetime]: + """ + 计算下一次提醒时间,使用当前时间作为基准 + 支持分钟、小时、天为单位 + """ + if memo['repeat_type'] == 'daily': + # 每天重复:当前时间 + 1天 + next_time = current_time + timedelta(days=1) + elif memo['repeat_type'] == 'weekly': + # 每周重复:当前时间 + 7天 + next_time = current_time + timedelta(weeks=1) + elif memo['repeat_type'] == 'monthly': + # 每月重复:尝试在当前时间上加一个月 + try: + # 处理月份进位 + if current_time.month == 12: + next_time = current_time.replace(year=current_time.year + 1, month=1) + else: + next_time = current_time.replace(month=current_time.month + 1) + except ValueError: + # 处理月份天数问题(如1月31日加一个月) + if current_time.month == 12: + next_time = current_time.replace(year=current_time.year + 1, month=1, day=1) - timedelta(days=1) + else: + next_time = current_time.replace(month=current_time.month + 1, day=1) - timedelta(days=1) + elif memo['repeat_type'] == 'custom' and memo['repeat_interval'] > 0: + unit = memo.get('repeat_interval_unit', 'days') + if unit == 'minutes': + # 每N分钟重复:当前时间 + N分钟 + next_time = current_time + timedelta(minutes=memo['repeat_interval']) + elif unit == 'hours': + # 每N小时重复:当前时间 + N小时 + next_time = current_time + timedelta(hours=memo['repeat_interval']) + else: # days + # 每N天重复:当前时间 + N天 + next_time = current_time + timedelta(days=memo['repeat_interval']) + else: + return None + + # 检查是否超过重复结束时间 + if memo['repeat_end_time']: + end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT) + if next_time > end_time: + return None + + return next_time + + def complete_memo(self, memo_id: int, force_complete: bool = False) -> bool: + """完成任务""" + memo = self.get_memo(memo_id) + if not memo: + return False + + if memo['repeat_type'] != 'none' and not force_complete: + self.mark_as_triggered(memo_id) + return True + + completed_at = datetime.now().strftime(constants.DATETIME_FORMAT) + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE memos + SET is_completed = 1, completed_at = ? + WHERE id = ? AND is_completed = 0 + ''', (completed_at, memo_id)) + + affected = cursor.rowcount + conn.commit() + + if affected > 0: + self.logger.info(i18n.get('memo_completed', memo_id)) + return True + return False + + def get_memo_stats(self) -> Dict: + """获取任务统计信息""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + + cursor.execute('SELECT COUNT(*) FROM memos') + total = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 0') + pending = cursor.fetchone()[0] + + now = datetime.now().strftime(constants.DATETIME_FORMAT) + cursor.execute(''' + SELECT COUNT(*) FROM memos + WHERE is_completed = 0 + AND reminder_time <= ? + AND is_immediate = 0 + ''', (now,)) + overdue = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 1') + completed = cursor.fetchone()[0] + + cursor.execute('SELECT COUNT(*) FROM memos WHERE is_immediate = 1 AND is_completed = 0') + immediate = cursor.fetchone()[0] + + return { + "total": total, + "pending": pending, + "overdue": overdue, + "completed": completed, + "immediate": immediate + } + + def add_user_task(self, title: str, content: str = None, reminder_time: str = None, + repeat_type: str = 'none', repeat_interval: int = 0, + repeat_interval_unit: str = 'days', is_immediate: bool = False) -> int: + """添加用户任务(前端直接调用的任务)- 所有任务都是对话任务""" + if not constants.MEMO_ALLOW_USER_TASKS: + raise Exception("用户任务功能已禁用") + + return self.add_memo( + title=title, + content=content, + reminder_time=reminder_time, + repeat_type=repeat_type, + repeat_interval=repeat_interval, + repeat_interval_unit=repeat_interval_unit, + created_by="user", + source="websocket", + is_immediate=is_immediate + ) + + def get_pending_tasks_for_user(self) -> List[Dict]: + """获取待执行任务列表(用于前端显示)""" + pending = self.get_pending_memos(include_immediate=False) + immediate = self.get_immediate_memos() + result = [] + + now = datetime.now() + for memo in pending + immediate: + result.append({ + "id": memo['id'], + "title": memo['title'], + "content": memo['content'], + "reminder_time": memo['reminder_time'], + "priority": memo['priority'], + "is_overdue": datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < now if memo['reminder_time'] else False, + "repeat_type": memo['repeat_type'], + "repeat_interval": memo.get('repeat_interval', 0), + "repeat_interval_unit": memo.get('repeat_interval_unit', 'days'), + "trigger_count": memo.get('trigger_count', 0), + "is_immediate": memo.get('is_immediate', False) + }) + + return result + + +# ==================== 记忆数据库 ==================== + +class MemoryDatabase: + """记忆数据库 - 存储重要的对话记忆""" + + def __init__(self, db_path: str = None): + if db_path is None: + db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB) + + # 确保目录存在 + os.makedirs(constants.SAVE_DIR, exist_ok=True) + self.db_path = db_path + self.logger = Logger("MemoryDB") + self._init_database() + + def _init_database(self): + """初始化数据库表""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic TEXT NOT NULL, + summary TEXT NOT NULL, + content TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + created_at TEXT NOT NULL, + tags TEXT, + importance INTEGER DEFAULT 1 + ) + ''') + + cursor.execute(''' + CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts + USING fts5(topic, summary, content, tags, content=memories) + ''') + + conn.commit() + + def add_memory(self, topic: str, summary: str, content: str = None, + start_time: str = None, end_time: str = None, + tags: List[str] = None, importance: int = None) -> int: + """添加记忆到数据库""" + if start_time is None: + start_time = datetime.now().strftime(constants.DATETIME_FORMAT) + if end_time is None: + end_time = datetime.now().strftime(constants.DATETIME_FORMAT) + if importance is None: + importance = constants.MEMORY_DEFAULT_IMPORTANCE + + tags_str = ",".join(tags) if tags else "" + created_at = datetime.now().strftime(constants.DATETIME_FORMAT) + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO memories (topic, summary, content, start_time, end_time, created_at, tags, importance) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', (topic, summary, content, start_time, end_time, created_at, tags_str, importance)) + + memory_id = cursor.lastrowid + + cursor.execute(''' + INSERT INTO memories_fts (rowid, topic, summary, content, tags) + VALUES (?, ?, ?, ?, ?) + ''', (memory_id, topic, summary, content or "", tags_str)) + + conn.commit() + + self.logger.info(f"已添加记忆: {topic} (ID: {memory_id})") + return memory_id + + def delete_memory(self, memory_id: int) -> bool: + """删除记忆""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM memories WHERE id = ?', (memory_id,)) + deleted = cursor.rowcount > 0 + + if deleted: + cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,)) + conn.commit() + self.logger.info(f"已删除记忆 ID: {memory_id}") + + return deleted + + def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]: + """多关键词模糊搜索记忆""" + if not keywords: + return [] + + if limit is None: + limit = constants.MEMORY_MAX_SEARCH_RESULTS + + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + if match_all: + query = " AND ".join([f'"{kw}"' for kw in keywords]) + else: + query = " OR ".join([f'"{kw}"' for kw in keywords]) + + cursor.execute(''' + SELECT m.*, rank + FROM memories m + JOIN memories_fts f ON m.rowid = f.rowid + WHERE memories_fts MATCH ? + ORDER BY m.end_time DESC, rank, m.importance DESC + LIMIT ? + ''', (query, limit)) + + results = [] + for row in cursor.fetchall(): + memory = dict(row) + if memory.get('tags'): + memory['tags'] = memory['tags'].split(',') + results.append(memory) + + return results + + def get_recent_memories(self, limit: int = 10) -> List[Dict]: + """获取最近的记忆""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,)) + + results = [] + for row in cursor.fetchall(): + memory = dict(row) + if memory.get('tags'): + memory['tags'] = memory['tags'].split(',') + results.append(memory) + + return results + + def get_memory_by_id(self, memory_id: int) -> Optional[Dict]: + """根据ID获取记忆""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute('SELECT * FROM memories WHERE id = ?', (memory_id,)) + row = cursor.fetchone() + + if row: + memory = dict(row) + if memory.get('tags'): + memory['tags'] = memory['tags'].split(',') + return memory + return None + + def delete_memories_by_ids(self, memory_ids: List[int]) -> int: + """批量删除记忆""" + if not memory_ids: + return 0 + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + placeholders = ','.join(['?'] * len(memory_ids)) + cursor.execute(f'DELETE FROM memories WHERE id IN ({placeholders})', memory_ids) + deleted_count = cursor.rowcount + + if deleted_count > 0: + cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids) + conn.commit() + self.logger.info(f"已批量删除 {deleted_count} 条记忆") + + return deleted_count + + +# ==================== 任务调度器 ==================== + +class TaskScheduler: + """任务调度器 - 基于备忘录数据库的定时任务系统""" + + def __init__(self, loop: asyncio.AbstractEventLoop = None, logger: Logger = None): + self.loop = loop or asyncio.get_event_loop() + self.running = False + self.logger = logger or Logger("TaskScheduler") + self.check_interval = constants.SCHEDULER_CHECK_INTERVAL + self.max_tasks_per_run = constants.SCHEDULER_MAX_TASKS + self.broadcast_enabled = constants.SCHEDULER_BROADCAST + self.ws_handler = None # 将由管理器设置 + self.ai_manager = None # 新增:AI管理器引用 + + # 任务执行历史 + self.execution_history = [] + self.max_history = 100 + + self.logger.info(i18n.get('scheduler_started')) + + def set_websocket_handler(self, handler): + """设置WebSocket处理器,用于广播任务执行状态""" + self.ws_handler = handler + + def set_ai_manager(self, manager): + """设置AI管理器引用""" + self.ai_manager = manager + + async def broadcast_task_status(self, task: Dict, status: str, message: str = None): + """广播任务状态到所有连接的客户端""" + if not self.broadcast_enabled or not self.ws_handler: + return + + try: + # 确保task有必要的字段 + task_info = { + 'id': task.get('id', 0), + 'title': task.get('title', '未知任务') + } + await self.ws_handler.broadcast_task_status(task_info, status, message) + except Exception as e: + self.logger.error(f"广播任务状态失败: {e}") + + async def check_and_execute_tasks(self, memo_db: MemoDatabase): + """检查并执行到期的任务 - 触发AI对话""" + if not memo_db: + return [] + + try: + # 获取所有到期任务(不包括即时任务) + now = datetime.now() + now_str = now.strftime(constants.DATETIME_FORMAT) + + pending = memo_db.get_pending_memos( + limit=self.max_tasks_per_run, + before_time=now_str, + include_immediate=False + ) + + if not pending: + return [] + + self.logger.info(i18n.get('overdue_found', len(pending))) + + # 广播任务开始执行 + if self.broadcast_enabled and self.ws_handler: + await self.ws_handler.broadcast_task_status( + {"id": 0, "title": "批量任务"}, + "batch_start", + f"开始执行 {len(pending)} 个任务" + ) + + executed_tasks = [] + for memo in pending: + try: + # 广播任务开始执行 + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('task_executing', memo['title'], memo['id'])) + + # 【关键修复】执行前最后一次验证 + if memo.get('reminder_time'): + reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + if reminder_time > now: + self.logger.warning(f"任务 #{memo['id']} 的提醒时间 ({memo['reminder_time']}) 大于当前时间 ({now_str}),跳过执行") + continue + + # 先标记任务已被触发 + self.logger.info(f"标记任务 #{memo['id']} 为已触发") + next_time = memo_db.mark_as_triggered(memo['id']) + + # 执行任务 + result = await self.execute_task_as_conversation(memo, memo_db) + + # 广播任务完成 + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "complete", result) + + executed_tasks.append({ + "id": memo['id'], + "title": memo['title'], + "result": result, + "success": True, + "next_time": next_time + }) + + except Exception as e: + self.logger.error(i18n.get('task_failed', memo['title'], str(e))) + + # 广播任务失败 + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "failed", str(e)) + + executed_tasks.append({ + "id": memo['id'], + "title": memo['title'], + "error": str(e), + "success": False + }) + + # 广播任务执行完成 + if self.broadcast_enabled and self.ws_handler: + success_count = sum(1 for t in executed_tasks if t['success']) + await self.ws_handler.broadcast_task_status( + {"id": 0, "title": "批量任务"}, + "batch_complete", + f"执行完成: {success_count}/{len(executed_tasks)} 个任务成功" + ) + + return executed_tasks + + except Exception as e: + self.logger.error(f"检查任务失败: {e}") + return [] + + async def execute_task_as_conversation(self, memo: Dict, memo_db: MemoDatabase) -> str: + """执行任务 - 将备忘录内容作为用户输入触发AI对话,收集完整的过程回复""" + self.logger.info(i18n.get('task_executing', memo['title'], memo['id'])) + + # 生成唯一的执行ID(基于任务ID和时间戳) + execution_id = f"{memo['id']}_{int(time.time())}_{memo.get('trigger_count', 0) + 1}" + + # 记录执行历史 + self.execution_history.append({ + "id": memo['id'], + "execution_id": execution_id, + "title": memo['title'], + "time": datetime.now().strftime(constants.DATETIME_FORMAT), + "trigger_count": memo.get('trigger_count', 0) + 1 + }) + + # 限制历史记录大小 + if len(self.execution_history) > self.max_history: + self.execution_history = self.execution_history[-self.max_history:] + + # 构建用户输入内容 - 将任务作为对话触发 + user_input = f"[定时任务] {memo['title']}" + if memo['content']: + user_input += f"\n\n任务内容: {memo['content']}" + + # 添加专注提示词,让AI专注于当前任务 + focus_prompt = f""" + 【重要提示】 + 这是由定时任务触发的对话,请遵守以下要求: + 1. 专注于处理当前任务:{memo['title']} + 2. 根据任务内容进行适当的操作和回答 + 3. 任务处理完成后,如果不需要继续对话,可以结束对话 + 4. 系统已经自动更新了任务状态(更新了执行时间和次数),你不需要手动处理任务完成逻辑 + + 请现在开始处理这个任务。 + """ + user_input = focus_prompt + "\n\n" + user_input + + result = "任务已触发AI对话" + + # 如果有AI管理器,触发AI对话 + if self.ai_manager and self.ai_manager.ai: + try: + # 获取当前事件循环 + loop = asyncio.get_running_loop() + + # 创建一个队列来收集完整的响应 + full_response_queue = queue.Queue() + + # 定义输出回调函数 - 直接广播,同时收集 + def output_callback(msg_type, content): + """AI输出的回调函数 - 直接广播,同时收集""" + # 收集完整响应 + if msg_type == "complete": + full_response_queue.put(content) + + # 直接广播给所有客户端 - 必须带上 task_id 和 execution_id + if self.ws_handler and self.broadcast_enabled: + # 使用 run_coroutine_threadsafe 在正确的循环中运行异步函数 + asyncio.run_coroutine_threadsafe( + self.ws_handler.broadcast({ + "type": "task_process", + "task_id": str(memo['id']), # 原始任务ID + "execution_id": execution_id, # 唯一的执行ID + "task_title": memo['title'], + "msg_type": msg_type, + "content": content, + "is_new_execution": msg_type == "chunk", # 标记是否为新的执行 + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT) + }), + loop + ) + + # 修改:确保AI处于新对话状态,不加载历史上下文 + if self.ai_manager.ai: + # 重置AI对话状态,但保留系统提示词 + self.ai_manager.ai.reset_conversation() + self.logger.info("AI对话状态已重置,准备执行任务") + + # 创建一个 Future 来等待 AI 完成 + ai_future = loop.create_future() + + def run_ai(): + try: + # 在单独的线程中运行 AI + response = self.ai_manager.ai.think_and_respond(user_input, output_callback) + # 将结果设置到 Future 中 + loop.call_soon_threadsafe(ai_future.set_result, response) + except Exception as e: + loop.call_soon_threadsafe(ai_future.set_exception, e) + + # 启动 AI 线程 + ai_thread = threading.Thread(target=run_ai) + ai_thread.daemon = True + ai_thread.start() + + # 等待 AI 完成,同时处理可能的取消 + try: + response = await ai_future + result = response or "AI无响应" + except Exception as e: + self.logger.error(f"AI对话失败: {e}") + result = f"任务执行但AI对话失败: {str(e)}" + finally: + # 确保线程结束 + ai_thread.join(timeout=1.0) + + except Exception as e: + self.logger.error(f"AI对话失败: {e}") + import traceback + traceback.print_exc() + result = f"任务执行但AI对话失败: {str(e)}" + # 广播错误信息 + if self.ws_handler and self.broadcast_enabled: + try: + await self.ws_handler.broadcast({ + "type": "task_error", + "task_id": str(memo['id']), # 确保是字符串 + "execution_id": execution_id, + "task_title": memo['title'], + "error": str(e), + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT) + }) + except: + pass + + # 获取任务的最新状态(可选,用于日志) + updated_memo = memo_db.get_memo(memo['id']) + if updated_memo: + if not updated_memo['is_completed'] and updated_memo.get('reminder_time'): + self.logger.info(i18n.get('next_execution', updated_memo['reminder_time'])) + elif updated_memo['is_completed']: + self.logger.info(i18n.get('task_completed', memo['title'])) + + return result + + async def check_and_execute_immediate_tasks(self, memo_db: MemoDatabase): + """检查并执行即时任务""" + if not memo_db: + return [] + + try: + immediate = memo_db.get_immediate_memos(limit=self.max_tasks_per_run) + + if not immediate: + return [] + + self.logger.info(f"发现 {len(immediate)} 个即时任务需要立即执行") + + if self.broadcast_enabled and self.ws_handler: + await self.ws_handler.broadcast_task_status( + {"id": 0, "title": "即时任务"}, + "batch_start", + f"开始执行 {len(immediate)} 个即时任务" + ) + + executed_tasks = [] + for memo in immediate: + try: + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "executing", i18n.get('immediate_memo_executing', memo['title'])) + + self.logger.info(f"标记即时任务 #{memo['id']} 为已触发") + next_time = memo_db.mark_as_triggered(memo['id']) + + result = await self.execute_task_as_conversation(memo, memo_db) + + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "complete", result) + + executed_tasks.append({ + "id": memo['id'], + "title": memo['title'], + "result": result, + "success": True, + "next_time": next_time + }) + + except Exception as e: + self.logger.error(i18n.get('task_failed', memo['title'], str(e))) + + if self.ws_handler: + await self.ws_handler.broadcast_task_status(memo, "failed", str(e)) + + executed_tasks.append({ + "id": memo['id'], + "title": memo['title'], + "error": str(e), + "success": False + }) + + if self.broadcast_enabled and self.ws_handler: + success_count = sum(1 for t in executed_tasks if t['success']) + await self.ws_handler.broadcast_task_status( + {"id": 0, "title": "即时任务"}, + "batch_complete", + f"即时任务执行完成: {success_count}/{len(executed_tasks)} 个成功" + ) + + return executed_tasks + + except Exception as e: + self.logger.error(f"检查即时任务失败: {e}") + return [] + + async def run(self, memo_db: MemoDatabase): + """启动调度器""" + self.running = True + + while self.running: + try: + # 先检查并执行即时任务 + if memo_db: + immediate_executed = await self.check_and_execute_immediate_tasks(memo_db) + if immediate_executed: + self.logger.info(f"执行了 {len(immediate_executed)} 个即时任务") + + # 再检查普通到期任务 + if memo_db: + executed = await self.check_and_execute_tasks(memo_db) + if executed: + self.logger.info(f"执行了 {len(executed)} 个普通任务") + + await asyncio.sleep(self.check_interval) + + except asyncio.CancelledError: + break + except Exception as e: + self.logger.error(f"调度器运行错误: {e}") + await asyncio.sleep(1) + + def stop(self): + """停止调度器""" + self.running = False + self.logger.info(i18n.get('scheduler_stopped')) + + def get_stats(self) -> Dict: + """获取调度器统计信息""" + return { + "running": self.running, + "check_interval": self.check_interval, + "max_tasks_per_run": self.max_tasks_per_run, + "broadcast_enabled": self.broadcast_enabled, + "recent_executions": self.execution_history[-10:], + "total_executions": len(self.execution_history) + } + +# ==================== 对话历史管理器 ==================== + +class ConversationHistory: + """对话历史管理器 - 管理所有历史对话""" + + def __init__(self, save_dir: str = None, memory_db: MemoryDatabase = None, load_history: bool = None): + if save_dir is None: + save_dir = constants.SAVE_DIR + + self.save_dir = Path(save_dir) + self.archive_dir = self.save_dir / constants.ARCHIVE_DIR + self.history_file = self.save_dir / constants.HISTORY_FILE + self.memory_db = memory_db + + config = ConfigManager() + if load_history is None: + load_history = config.get("system.load_history_by_default", True) + self.load_history = load_history + + # 确保目录存在 + self.save_dir.mkdir(parents=True, exist_ok=True) + self.archive_dir.mkdir(parents=True, exist_ok=True) + + self.logger = Logger("HistoryManager") + self.history_index = self._load_history_index() + self.current_conversation = None + self.current_file = self.save_dir / constants.CURRENT_CONVERSATION_FILE + + self._load_current_conversation() + + def _load_history_index(self) -> Dict: + """加载历史索引文件""" + if self.history_file.exists(): + try: + with open(self.history_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {"conversations": [], "last_id": 0} + return {"conversations": [], "last_id": 0} + + def _save_history_index(self): + """保存历史索引""" + with open(self.history_file, 'w', encoding='utf-8') as f: + json.dump(self.history_index, f, ensure_ascii=False, indent=2) + + def _load_current_conversation(self): + """加载当前的对话(如果有)""" + if self.current_file.exists(): + try: + with open(self.current_file, 'r', encoding='utf-8') as f: + self.current_conversation = json.load(f) + self.logger.info(f"已加载当前对话: {self.current_conversation.get('topic', '未命名')}") + except Exception as e: + self.logger.error(f"加载当前对话失败: {e}") + + def save_current_conversation(self): + """保存当前对话""" + if self.current_conversation: + with open(self.current_file, 'w', encoding='utf-8') as f: + json.dump(self.current_conversation, f, ensure_ascii=False, indent=2) + + def start_new_conversation(self, topic: str = None) -> Dict: + """开始新对话""" + if self.current_conversation: + self.archive_current_conversation() + + conv_id = str(int(time.time())) + self.current_conversation = { + "id": conv_id, + "start_time": datetime.now().strftime(constants.DATETIME_FORMAT), + "end_time": None, + "topic": topic or "新对话", + "messages": [], + "tool_calls": [], + "stats": { + "user_messages": 0, + "ai_messages": 0, + "tool_calls": 0 + } + } + + self.save_current_conversation() + self.logger.info(f"开始新对话: {topic}") + return self.current_conversation + + def archive_current_conversation(self, final_summary: str = None): + """将当前对话归档到历史""" + if not self.current_conversation: + return None + + if self.current_conversation.get("end_time") is None: + self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT) + + if final_summary and self.memory_db: + messages = self.current_conversation.get("messages", []) + content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}" + for m in messages[-10:] if m.get('speaker')]) + + self.memory_db.add_memory( + topic=self.current_conversation["topic"], + summary=final_summary, + content=content_preview, + start_time=self.current_conversation["start_time"], + end_time=self.current_conversation["end_time"], + importance=2 + ) + + timestamp = datetime.now().strftime(constants.DATE_FORMAT) + archive_filename = f"conversation_{timestamp}.json" + archive_path = self.archive_dir / archive_filename + + with open(archive_path, 'w', encoding='utf-8') as f: + json.dump(self.current_conversation, f, ensure_ascii=False, indent=2) + + self.history_index["conversations"].append({ + "id": self.current_conversation["id"], + "file": archive_filename, + "start_time": self.current_conversation["start_time"], + "end_time": self.current_conversation["end_time"], + "topic": self.current_conversation["topic"], + "messages": len(self.current_conversation["messages"]), + "tool_calls": len(self.current_conversation["tool_calls"]) + }) + + self._save_history_index() + self.logger.info(f"对话已归档: {archive_filename}") + + self.current_conversation = None + if self.current_file.exists(): + self.current_file.unlink() + + return str(archive_path) + + def load_all_history(self, max_messages: int = None) -> List[Dict]: + """加载所有历史对话作为上下文""" + if max_messages is None: + max_messages = constants.MAX_HISTORY_CONTEXT + + if not self.load_history: + return [] + + all_messages = [] + + if self.current_conversation: + current_msgs = self.current_conversation.get("messages", []) + for msg in current_msgs[-max_messages//2:]: + if not msg.get("is_history"): + all_messages.append({ + "timestamp": msg["timestamp"], + "role": msg["role"], + "speaker": msg.get("speaker"), + "content": msg["content"], + "from_current": True + }) + + history_files = sorted(self.archive_dir.glob("*.json"), reverse=True)[:constants.MAX_HISTORY_CONVERSATIONS] + + for hist_file in history_files: + try: + with open(hist_file, 'r', encoding='utf-8') as f: + conv = json.load(f) + messages = conv.get("messages", []) + for msg in messages[-max_messages//len(history_files):]: + if not msg.get("is_history"): + all_messages.append({ + "timestamp": msg["timestamp"], + "role": msg["role"], + "speaker": msg.get("speaker"), + "content": msg["content"], + "from_file": hist_file.name + }) + except Exception as e: + self.logger.error(f"加载历史文件 {hist_file} 失败: {e}") + + all_messages.sort(key=lambda x: x["timestamp"]) + + if len(all_messages) > max_messages: + all_messages = all_messages[-max_messages:] + + self.logger.info(f"已加载 {len(all_messages)} 条历史消息") + return all_messages + + def get_conversation_context(self, max_messages: int = None) -> List[Dict]: + """获取适合作为AI上下文的对话历史""" + if max_messages is None: + max_messages = constants.MAX_HISTORY_CONTEXT + + if not self.load_history: + return [] + + history_messages = self.load_all_history(max_messages) + + context = [] + for msg in history_messages: + role = msg.get("role", "") + content = msg.get("content", "") + + if role == "system": + continue + + if role == "assistant": + speaker = msg.get("speaker", "") + context.append({ + "role": "assistant", + "content": f"[{speaker}] {content}" + }) + else: + context.append({ + "role": "user", + "content": content + }) + + return context + + def add_message(self, role: str, content: str, speaker: str = None): + """添加消息到当前对话""" + if not self.current_conversation: + self.start_new_conversation() + + message = { + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT), + "role": role, + "speaker": speaker, + "content": content + } + + self.current_conversation["messages"].append(message) + + if role == "user": + self.current_conversation["stats"]["user_messages"] += 1 + elif role == "assistant": + self.current_conversation["stats"]["ai_messages"] += 1 + + self.save_current_conversation() + + def add_tool_call(self, tool_name: str, args: Dict, result: str): + """记录工具调用""" + if not self.current_conversation: + return + + tool_call = { + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT), + "tool": tool_name, + "args": args, + "result_preview": result[:200] + "..." if len(result) > 200 else result + } + + self.current_conversation["tool_calls"].append(tool_call) + self.current_conversation["stats"]["tool_calls"] += 1 + self.save_current_conversation() + + def end_current_conversation(self, summary: str = None): + """结束当前对话""" + if self.current_conversation: + self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT) + + if summary and self.memory_db: + messages = self.current_conversation.get("messages", []) + content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}" + for m in messages[-10:] if m.get('speaker')]) + + self.memory_db.add_memory( + topic=self.current_conversation["topic"], + summary=summary, + content=content_preview, + start_time=self.current_conversation["start_time"], + end_time=self.current_conversation["end_time"], + importance=2 + ) + + self.save_current_conversation() + self.logger.info("当前对话已保存") + + def reset_conversation(self): + """重置当前对话状态""" + if self.current_conversation: + self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT) + self.save_current_conversation() + self.current_conversation = None + + def list_conversations(self) -> List[Dict]: + """列出所有对话(包括当前和归档)""" + conversations = [] + + if self.current_conversation: + conversations.append({ + "file": constants.CURRENT_CONVERSATION_FILE, + "is_current": True, + "id": self.current_conversation.get("id"), + "start_time": self.current_conversation.get("start_time"), + "end_time": self.current_conversation.get("end_time"), + "topic": self.current_conversation.get("topic"), + "messages": len(self.current_conversation.get("messages", [])), + "tool_calls": len(self.current_conversation.get("tool_calls", [])) + }) + + for conv in self.history_index.get("conversations", []): + conversations.append({ + "file": conv["file"], + "is_current": False, + "id": conv["id"], + "start_time": conv["start_time"], + "end_time": conv["end_time"], + "topic": conv["topic"], + "messages": conv["messages"], + "tool_calls": conv["tool_calls"] + }) + + conversations.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True) + return conversations + + def load_conversation(self, file_name: str) -> Optional[Dict]: + """加载指定的对话""" + if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation: + return self.current_conversation + + file_path = self.archive_dir / file_name + if file_path.exists(): + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + return None + + +# ==================== 命令执行器抽象类 ==================== + +class CommandExecutor(ABC): + """命令执行器抽象基类 - 负责执行系统命令""" + + def __init__(self, logger: Optional[Logger] = None): + self.logger = logger or Logger("CommandExecutor") + self.config = ConfigManager() + self.process = None + + @abstractmethod + def execute(self, command: str, timeout: int = 30, **kwargs) -> str: + """执行命令并返回结果(非流式)""" + pass + + @abstractmethod + def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, **kwargs): + """流式执行命令,通过队列输出结果""" + pass + + def is_enabled(self) -> bool: + """检查命令执行是否启用""" + return constants.COMMANDS_ENABLED + + def format_result(self, success: bool, message: str, data: Dict = None) -> str: + """统一结果格式化""" + result = [] + emoji = "✅" if success else "❌" + result.append(f"{emoji} {message}") + + if data: + for key, value in data.items(): + if isinstance(value, str) and "\n" in value: + result.append(f" {key}:") + for line in value.split("\n"): + result.append(f" {line}") + else: + result.append(f" {key}: {value}") + + return "\n".join(result) + + +class SubprocessCommandExecutor(CommandExecutor): + """基于subprocess的命令执行器 - 支持超时和工作目录""" + + def execute(self, command: str, timeout: int = 30, work_dir: str = ".", env: Dict = None, **kwargs) -> str: + """执行命令并返回完整结果""" + if not self.is_enabled(): + return "❌ 命令执行功能已禁用" + + self.logger.info(f"执行命令: {command}") + + # 准备环境变量 + exec_env = os.environ.copy() + if env: + exec_env.update(env) + + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + env=exec_env, + cwd=os.path.expanduser(work_dir) + ) + + data = {} + + if result.stdout: + data["输出"] = result.stdout.strip() + if result.stderr: + data["错误"] = result.stderr.strip() + + data["返回码"] = result.returncode + + if not result.stdout and not result.stderr: + return self.format_result(True, "命令执行成功,无输出", data) + + return self.format_result(result.returncode == 0, "命令执行完成", data) + + except subprocess.TimeoutExpired: + return f"❌ 命令执行超时({timeout}秒)" + except FileNotFoundError as e: + return f"❌ 命令执行错误: 命令未找到 - {str(e)}" + except PermissionError as e: + return f"❌ 命令执行错误: 权限不足 - {str(e)}" + except Exception as e: + return f"❌ 执行错误: {str(e)}" + + def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, + work_dir: str = ".", env: Dict = None, **kwargs): + """流式执行命令,支持超时控制和工作目录""" + if not self.is_enabled(): + output_queue.put(("line", "❌ 命令执行功能已禁用\n")) + output_queue.put(("complete", "❌ 命令执行功能已禁用")) + return + + self.logger.info(f"流式执行命令: {command}") + output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) + output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) + if work_dir != ".": + output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 准备环境变量 + exec_env = os.environ.copy() + if env: + exec_env.update(env) + + try: + # 使用管道来实时获取输出 + self.process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True, + env=exec_env, + cwd=os.path.expanduser(work_dir) + ) + + start_time = time.time() + output_lines = [] + timeout_occurred = False + + # 实时读取输出 + while True: + # 检查超时 + if timeout > 0 and time.time() - start_time > timeout: + self.process.terminate() + timeout_occurred = True + output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n")) + break + + # 读取一行输出 + line = self.process.stdout.readline() + if not line and self.process.poll() is not None: + break + if line: + line = line.rstrip('\n') + output_queue.put(("line", line + "\n")) + output_lines.append(line) + + # 如果超时,确保进程被终止 + if timeout_occurred and self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=2) + + # 获取剩余输出 + remaining, _ = self.process.communicate(timeout=5) + if remaining: + output_queue.put(("line", remaining)) + output_lines.append(remaining) + + return_code = self.process.returncode if self.process.returncode is not None else -1 + + output_queue.put(("line", "-" * 50 + "\n")) + + if timeout_occurred: + output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n")) + else: + output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) + + # 准备完整结果 + full_output = "\n".join(output_lines) + data = { + "输出": full_output if full_output else "(无输出)", + "返回码": return_code + } + + if timeout_occurred: + result = self.format_result(False, f"命令执行超时({timeout}秒)", data) + else: + result = self.format_result(return_code == 0, "命令执行完成", data) + + output_queue.put(("complete", result)) + + except Exception as e: + output_queue.put(("line", f"❌ 执行错误: {str(e)}\n")) + output_queue.put(("complete", f"❌ 执行错误: {str(e)}")) + finally: + if self.process and self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except: + self.process.kill() + +# ==================== 工具基类 ==================== + +class Tool(ABC): + """工具基类""" + + def __init__(self, logger: Optional[Logger] = None): + self.logger = logger or Logger(self.__class__.__name__) + self.config = ConfigManager() + self.streaming_output = False # 标记是否正在流式输出 + self.output_queue = None # 添加输出队列属性 + + @abstractmethod + def get_name(self) -> str: + """返回工具名称""" + pass + + @abstractmethod + def get_description(self) -> str: + """返回工具描述""" + pass + + @abstractmethod + def get_parameters(self) -> Dict: + """返回工具参数定义(JSON Schema格式)""" + pass + + @abstractmethod + def execute(self, **kwargs) -> str: + """执行工具并返回结果""" + pass + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行工具,通过队列输出结果""" + self.output_queue = output_queue # 保存队列引用 + self.streaming_output = True + try: + # 执行具体的工具逻辑,子类可以重写这个方法 + result = self.execute(**kwargs) + # 如果结果不为空,发送到队列 + if result: + # 对于非流式输出,直接发送完整结果 + output_queue.put(("complete", result)) + finally: + self.streaming_output = False + self.output_queue = None + + def validate_params(self, required: List[str], **kwargs) -> Optional[str]: + """验证必需参数""" + missing = [p for p in required if p not in kwargs or kwargs[p] is None] + if missing: + return f"❌ 错误:缺少必需参数: {', '.join(missing)}" + return None + + def to_function_call_format(self) -> Dict: + """转换为OpenAI函数调用格式""" + return { + "type": "function", + "function": { + "name": self.get_name(), + "description": self.get_description(), + "parameters": self.get_parameters() + } + } + + def format_result(self, success: bool, message: str, data: Dict = None) -> str: + """统一结果格式化""" + result = [] + emoji = "✅" if success else "❌" + result.append(f"{emoji} {message}") + + if data: + for key, value in data.items(): + if isinstance(value, str) and "\n" in value: + result.append(f" {key}:") + for line in value.split("\n"): + result.append(f" {line}") + else: + result.append(f" {key}: {value}") + + return "\n".join(result) + + def check_path_safety(self, filepath: str) -> Tuple[bool, str, str]: + """检查路径安全性(简化版)""" + try: + abs_path = os.path.abspath(os.path.expanduser(filepath)) + + # 检查文件大小 + if os.path.exists(abs_path) and os.path.isfile(abs_path): + file_size = os.path.getsize(abs_path) + max_size = constants.MAX_FILE_SIZE + if file_size > max_size: + return False, abs_path, f"❌ 文件太大 ({file_size/1024/1024:.1f}MB)" + + return True, abs_path, "" + except Exception as e: + return False, filepath, f"❌ 路径检查错误: {str(e)}" + + def send_line(self, line: str): + """发送一行输出到队列(如果存在)""" + if self.output_queue: + self.output_queue.put(("line", line)) + + def send_complete(self, result: str): + """发送完成结果到队列(如果存在)""" + if self.output_queue: + self.output_queue.put(("complete", result)) + + +class ToolRegistry: + """工具注册器 - 支持技能热重载""" + + def __init__(self, logger: Optional[Logger] = None): + self._tools: Dict[str, Tool] = {} + self._builtin_tools: Dict[str, Tool] = {} # 内置工具单独存储 + self.logger = logger or Logger("ToolRegistry") + self.current_output_queue = None # 用于流式输出的队列 + self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用 + + def register(self, tool: Tool, is_builtin: bool = False): + """注册工具,可以标记是否为内置工具""" + self._tools[tool.get_name()] = tool + if is_builtin: + self._builtin_tools[tool.get_name()] = tool + self.logger.info(f"工具已注册: {tool.get_name()} {'(内置)' if is_builtin else ''}") + + def register_many(self, tools: List[Tool], is_builtin: bool = False): + """批量注册工具""" + for tool in tools: + self.register(tool, is_builtin) + + def get_tool(self, name: str) -> Optional[Tool]: + """获取工具""" + return self._tools.get(name) + + def get_all_tools(self) -> List[Tool]: + """获取所有工具""" + return list(self._tools.values()) + + def get_tools_schemas(self) -> List[Dict]: + """获取所有工具的OpenAI函数调用格式""" + return [tool.to_function_call_format() for tool in self._tools.values()] + + def set_output_queue(self, queue: queue.Queue): + """设置输出队列""" + self.current_output_queue = queue + # 同时设置所有工具的队列 + for tool in self._tools.values(): + tool.output_queue = queue + + def execute_tool(self, name: str, **kwargs) -> str: + """执行工具""" + tool = self.get_tool(name) + if not tool: + return f"❌ 错误:找不到工具 '{name}'" + + self.logger.debug(f"工具调用: {name}({kwargs})") + start_time = time.time() + + try: + # 检查是否有队列可用,并且工具支持流式输出 + if self.current_output_queue and hasattr(tool, 'execute_streaming'): + # 设置工具的流式输出属性 + tool.streaming_output = True + tool.output_queue = self.current_output_queue + + # 创建队列来收集流式输出 + output_queue = queue.Queue() + + # 执行流式输出 + thread = threading.Thread( + target=tool.execute_streaming, + args=(output_queue,), + kwargs=kwargs + ) + thread.daemon = True + thread.start() + + # 从队列收集输出并发送到当前队列 + final_result = None + while thread.is_alive() or not output_queue.empty(): + try: + msg_type, content = output_queue.get(timeout=0.1) + if msg_type == "line": + # 发送到当前队列(会通过WebSocket发送) + if self.current_output_queue: + self.current_output_queue.put(("line", content)) + elif msg_type == "complete": + final_result = content + # 也发送完整结果 + if self.current_output_queue: + self.current_output_queue.put(("complete", content)) + except queue.Empty: + continue + + thread.join(timeout=5) + if thread.is_alive(): + return "❌ 工具执行超时" + + result = final_result or "" + tool.streaming_output = False + tool.output_queue = None + else: + # 普通执行 + result = tool.execute(**kwargs) + + elapsed = time.time() - start_time + self.logger.debug(f"工具结果 ({elapsed:.2f}s): {result[:100]}...") + return result + except TypeError as e: + return f"❌ 工具执行错误: 参数错误 - {str(e)}" + except Exception as e: + return f"❌ 工具执行错误: {str(e)}" + + def reload_skills(self, skills_dir: str = None) -> int: + """ + 重新加载所有扩展技能(热重载)。 + 委托给 SkillManager 重新扫描目录并执行 --help 收集简介。 + """ + self.logger.info("开始重新加载扩展技能...") + if self.skill_manager is not None: + count = self.skill_manager.reload() + self.logger.info(i18n.get('skills_reloaded', count)) + return count + self.logger.warning("skill_manager 未初始化,无法重载技能") + return 0 + + def get_skills_count(self) -> Tuple[int, int]: + """获取内置工具和扩展技能的数量""" + builtin_count = len(self._builtin_tools) + skill_count = len(self._tools) - builtin_count + return builtin_count, skill_count + + +# ==================== 扩展技能管理器(支持 Markdown 格式) ==================== + +class SkillManager: + """扩展技能管理器 - 收集并管理 ~/.aibox/skills/ 目录下的技能文件。 + + 支持两种技能格式: + 1. 可执行脚本/程序:执行 --help,第一行为简介 + 2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容 + """ + + def __init__(self, logger: Optional[Logger] = None): + self.logger = logger or Logger("SkillManager") + # name -> {path, file, summary, content, is_markdown} + self.skills: Dict[str, Dict] = {} + self._load_skills() + + # ------------------------------------------------------------------ + # 内部辅助 + # ------------------------------------------------------------------ + + def _run_help(self, skill_path: str) -> str: + """执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。""" + try: + result = subprocess.run( + [skill_path, "--help"], + capture_output=True, + text=True, + timeout=10, + env=os.environ.copy() + ) + output = result.stdout.strip() + if not output: + output = result.stderr.strip() + return output + except Exception as e: + self.logger.warning(f"运行 {skill_path} --help 失败: {e}") + return "" + + def _read_markdown_skill(self, skill_path: str) -> Tuple[str, str]: + """读取 Markdown 技能文件,返回 (简介, 完整内容)""" + try: + with open(skill_path, 'r', encoding='utf-8') as f: + content = f.read().strip() + + if not content: + return "(空文件)", "" + + # 提取第一行非空内容作为简介 + lines = content.splitlines() + summary = "" + for line in lines: + stripped = line.strip() + if stripped: + # 如果是 Markdown 标题,去掉 # 前缀 + if stripped.startswith('#'): + summary = stripped.lstrip('#').strip() + else: + summary = stripped + break + + if not summary: + summary = os.path.basename(skill_path).replace('.md', '') + + return summary, content + + except Exception as e: + self.logger.warning(f"读取 Markdown 技能文件失败 {skill_path}: {e}") + return "(读取失败)", "" + + @staticmethod + def _first_nonempty_line(text: str) -> str: + """返回文本中第一行非空内容。""" + for line in text.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + def _is_markdown_file(self, filename: str) -> bool: + """判断是否为 Markdown 文件""" + return filename.lower().endswith('.md') + + def _is_executable(self, filepath: str) -> bool: + """检查文件是否可执行""" + if os.name == 'nt': # Windows + # Windows 下检查扩展名 + executable_extensions = {'.exe', '.bat', '.cmd', '.ps1', '.py', '.sh'} + ext = os.path.splitext(filepath)[1].lower() + return ext in executable_extensions + else: # Unix/Linux/Mac + return os.access(filepath, os.X_OK) + + # ------------------------------------------------------------------ + # 加载 / 重载 + # ------------------------------------------------------------------ + + def _load_skills(self): + """扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。""" + skills_dir = constants.SKILLS_DIR + self.skills = {} + + if not os.path.exists(skills_dir): + self.logger.info(f"技能目录不存在: {skills_dir}") + return + + skill_files = [ + f for f in os.listdir(skills_dir) + if os.path.isfile(os.path.join(skills_dir, f)) + and not f.startswith('_') + and not f.startswith('.') + ] + + for skill_file in skill_files: + skill_path = os.path.join(skills_dir, skill_file) + skill_name = os.path.splitext(skill_file)[0] + is_markdown = self._is_markdown_file(skill_file) + + if is_markdown: + # Markdown 格式技能:直接读取内容 + summary, content = self._read_markdown_skill(skill_path) + if content: + self.skills[skill_name] = { + "path": skill_path, + "file": skill_file, + "summary": summary, + "content": content, + "is_markdown": True, + "is_executable": False + } + self.logger.info(f"已加载 Markdown 技能: {skill_name} — {summary}") + else: + self.logger.warning(f"Markdown 技能文件为空(跳过): {skill_file}") + + else: + # 可执行文件格式技能:执行 --help 获取简介 + if not self._is_executable(skill_path): + self.logger.warning(f"技能文件不可执行(跳过): {skill_file}") + continue + + help_output = self._run_help(skill_path) + summary = self._first_nonempty_line(help_output) or f"扩展技能: {skill_name}" + + self.skills[skill_name] = { + "path": skill_path, + "file": skill_file, + "summary": summary, + "content": help_output, # 完整的 --help 输出作为内容 + "is_markdown": False, + "is_executable": True + } + self.logger.info(f"已加载可执行技能: {skill_name} — {summary}") + + self.logger.info(f"共加载 {len(self.skills)} 个技能({len([s for s in self.skills.values() if s['is_markdown']])} 个 Markdown 技能)") + + def reload(self) -> int: + """重新扫描技能目录,返回加载的技能数量。""" + self._load_skills() + return len(self.skills) + + # ------------------------------------------------------------------ + # 查询 + # ------------------------------------------------------------------ + + def search(self, keyword: str) -> List[Dict]: + """在技能名称、简介和内容中搜索关键字,返回匹配项列表。""" + kw = keyword.lower() + results = [] + for name, info in self.skills.items(): + match = ( + kw in name.lower() or + kw in info["summary"].lower() or + kw in info.get("content", "").lower() + ) + if match: + results.append({ + "name": name, + "path": info["path"], + "summary": info["summary"], + "content": info.get("content", ""), + "is_markdown": info.get("is_markdown", False), + "is_executable": info.get("is_executable", False) + }) + return results + + def get_skill_content(self, skill_name: str) -> Optional[str]: + """获取技能的内容(Markdown 技能返回完整内容,可执行技能返回 --help 输出)""" + info = self.skills.get(skill_name) + if info: + return info.get("content") + return None + + def get_skill(self, skill_name: str) -> Optional[Dict]: + """获取单个技能的完整信息""" + return self.skills.get(skill_name) + + def get_all(self) -> List[Dict]: + """返回所有技能的列表(含名称、路径、简介和内容)。""" + return [ + { + "name": name, + "path": info["path"], + "summary": info["summary"], + "content": info.get("content", ""), + "is_markdown": info.get("is_markdown", False), + "is_executable": info.get("is_executable", False) + } + for name, info in self.skills.items() + ] + + def count(self) -> int: + return len(self.skills) + + +class SearchSkillsTool(Tool): + """扩展技能搜索工具 - 按关键字搜索 ~/.aibox/skills/ 下的技能文件。 + + 支持两种技能格式: + 1. 可执行脚本/程序:通过 --help 获取简介和完整使用说明 + 2. Markdown 文件 (.md):第一行为简介,其余为技能内容 + + 工作流程: + 1. 用关键字调用本工具,获取匹配技能的名称和简介列表 + 2. 对于 Markdown 技能,可以直接读取 content 字段获取完整技能内容 + 3. 对于可执行技能,通过 run_command 执行 "技能名 --help" 查看完整命令手册 + 4. 根据内容,使用相应方式调用技能 + """ + + def __init__(self, skill_manager: SkillManager, logger: Optional[Logger] = None): + super().__init__(logger) + self.skill_manager = skill_manager + + def get_name(self) -> str: + return "search_skills" + + def get_description(self) -> str: + return ( + "扩展技能搜索工具。\n\n" + "用关键字搜索 ~/.aibox/skills/ 目录下的技能文件。\n" + "支持两种技能格式:\n" + " 1. 可执行脚本/程序:通过 --help 获取简介和完整使用说明\n" + " 2. Markdown 文件 (.md):第一行为简介,其余为技能内容\n\n" + "使用方法:\n" + "1. 调用本工具搜索关键字,获取匹配技能列表(空字符串列出全部)。\n" + "2. 对于 Markdown 技能:直接读取返回的 content 字段获取完整技能内容。\n" + "3. 对于可执行技能:用 run_command 执行 \"技能名 --help\" 获取完整命令手册。\n" + "4. 按照内容说明,使用相应方式调用该技能。" + ) + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "keyword": { + "type": "string", + "description": ( + "搜索关键字,在技能名称、简介和内容中进行匹配。" + "传入空字符串可列出全部可用技能。" + ) + }, + "show_content": { + "type": "boolean", + "description": ( + "是否在结果中显示技能内容。" + "对于 Markdown 技能,这会影响返回结果的详细程度。" + "默认 False(只显示名称和简介)。" + ), + "default": False + } + }, + "required": ["keyword"] + } + + def execute(self, keyword: str = "", show_content: bool = False, **kwargs) -> str: + kw = keyword.strip() + if kw: + results = self.skill_manager.search(kw) + header = f"🔍 搜索 '{kw}' 的结果(共 {len(results)} 个匹配技能):" + empty_msg = ( + f"❌ 未找到与 '{kw}' 相关的技能。\n" + "提示:传入空字符串可列出所有可用技能。" + ) + else: + results = self.skill_manager.get_all() + header = f"🔧 所有可用技能(共 {len(results)} 个):" + empty_msg = "❌ ~/.aibox/skills/ 目录下暂无可用技能。" + + if not results: + return empty_msg + + lines = [header] + for item in results: + skill_type = "📄 Markdown" if item.get('is_markdown') else "⚙️ 可执行" + lines.append(f"\n {skill_type} • {item['name']}") + lines.append(f" 简介: {item['summary']}") + + if show_content and item.get('content'): + content = item['content'] + if item.get('is_markdown'): + preview = content[:500] + if len(content) > 500: + preview += "...\n (内容较长,已截断,请直接读取技能文件获取完整内容)" + lines.append(f" 内容预览:\n {preview}") + else: + preview = content[:200] if content else "(无帮助输出)" + if len(content) > 200: + preview += "..." + lines.append(f" 帮助预览: {preview}") + else: + if item.get('is_markdown'): + lines.append(f" 💡 提示: 设置 show_content=true 可查看技能内容") + else: + lines.append(f" 💡 提示: 用 run_command 执行 \"{item['name']} --help\" 查看完整手册") + + lines.append("") + lines.append("📖 技能调用方式:") + lines.append(" • Markdown 技能: 读取 content 字段获取完整技能指令") + lines.append(" • 可执行技能: 用 run_command 执行 '技能名 --help' 查看手册") + return "\n".join(lines) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行搜索技能""" + keyword = kwargs.get("keyword", "") + show_content = kwargs.get("show_content", False) + + output_queue.put(("line", f"\n🔍 搜索技能: {keyword if keyword else '(全部)'}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 直接传递 kwargs,避免重复参数 + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +# ==================== 技能热重载工具 ==================== + +class ReloadSkillsTool(Tool): + """技能热重载工具 - 重新加载所有扩展技能""" + + def __init__(self, tool_registry: ToolRegistry, logger: Optional[Logger] = None): + super().__init__(logger) + self.tool_registry = tool_registry + + def get_name(self) -> str: + return "reload_skills" + + def get_description(self) -> str: + return """技能热重载工具 - 重新加载所有扩展技能 +当你添加、修改或删除了 ~/.aibox/skills/ 目录下的技能文件后, +调用此工具可以立即生效,无需重启系统。""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "description": "确认重新加载技能", + "default": True + } + } + } + + def execute(self, confirm: bool = True, **kwargs) -> str: + """执行技能热重载""" + if not confirm: + return "❌ 技能重载已取消,如需重载请设置 confirm=true" + + self.logger.info("开始执行技能热重载...") + + try: + count = self.tool_registry.reload_skills() + + # 从 SkillManager 获取更新后的技能列表(含简介) + skill_manager = self.tool_registry.skill_manager + if skill_manager: + all_skills = skill_manager.get_all() + skill_lines = "\n".join( + f" • {s['name']} — {s['summary']}" + for s in all_skills[:10] + ) + data = { + "重新加载的技能数量": count, + "技能目录": constants.SKILLS_DIR, + "技能列表(含简介)": skill_lines or "(无技能)", + } + if count > 10: + data["提示"] = f"还有 {count - 10} 个技能未显示" + else: + data = { + "重新加载的技能数量": count, + "技能目录": constants.SKILLS_DIR, + } + + return self.format_result(True, i18n.get('skills_reloaded', count), data) + + except Exception as e: + self.logger.error(f"技能热重载失败: {e}") + return i18n.get('skills_reload_failed', str(e)) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行技能热重载""" + output_queue.put(("line", "\n🔄 执行技能热重载...\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +# ==================== 安装技能工具 ==================== + +class InstallSkillTool(Tool): + """技能安装指引工具 - 返回如何将可执行文件/脚本安装为扩展技能的说明""" + + def get_name(self) -> str: + return "install_skill" + + def get_description(self) -> str: + return ( + "技能安装指引工具。\n" + "返回将可执行文件或脚本安装为扩展技能的完整步骤说明," + "包括可执行性检查、--help 验证和部署方法。" + ) + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "skill_path": { + "type": "string", + "description": ( + "待安装的技能文件路径(可选)。" + "若提供,说明中会带入该路径作为示例;" + "不提供则返回通用说明。" + ) + } + }, + "required": [] + } + + def execute(self, skill_path: str = "", **kwargs) -> str: + skills_dir = constants.SKILLS_DIR + + # 如果给了具体路径,在说明里带入,否则用占位符 + src = skill_path.strip() if skill_path else "/path/to/your_skill" + # 去掉扩展名作为目标名称示例 + base = os.path.splitext(os.path.basename(src))[0] if src else "your_skill" + dst = os.path.join(skills_dir, base) + + guide = f"""📦 扩展技能安装指引 +{"=" * 55} + +要将一个可执行文件或脚本安装为扩展技能,请按以下步骤操作: + +───────────────────────────────────────────────────── +步骤 1:确认文件可执行 +───────────────────────────────────────────────────── +运行以下命令,确认文件有可执行权限: + + ls -l {src} + +如果没有执行权限,使用以下命令添加: + + chmod +x {src} + +然后直接运行一次,确认不会报错: + + {src} + +───────────────────────────────────────────────────── +步骤 2:验证 --help 输出是否完整 +───────────────────────────────────────────────────── +技能必须支持 --help 选项,且: + • 第一行是一句话简介(系统用它作为技能摘要) + • 其余行说明参数用法和使用示例 + +运行以下命令验证: + + {src} --help + +确认输出的第一行能清楚描述该技能的用途。 + +───────────────────────────────────────────────────── +步骤 3:复制到技能目录并去掉扩展名 +───────────────────────────────────────────────────── +技能目录:{skills_dir} + +复制命令(自动去掉扩展名): + + cp {src} {dst} + chmod +x {dst} + +⚠️ 注意:目标文件名不能有扩展名(如 .py、.sh), + 系统以纯文件名作为技能名称。 + +───────────────────────────────────────────────────── +步骤 4:让系统识别新技能 +───────────────────────────────────────────────────── +复制完成后,调用热重载工具使其立即生效: + + reload_skills confirm=true + +之后可用 search_skills 验证技能已加载: + + search_skills keyword="{base}" + +{"=" * 55} +完成以上步骤后,即可通过 run_command 直接调用该技能。""" + + return guide + + +# ==================== 创建扩展技能工具 ==================== + +class CreateSkillTool(Tool): + """创建扩展技能工具 - 返回提示词,驱动智能体自行编写并部署可执行脚本。 + + 工具本身不调用 LLM,只输出一段结构化的指令提示词。 + 智能体读到提示词后,会自行: + 1. 编写满足接口要求的 Python 脚本 + 2. 用 write_file 写入 ~/.aibox/skills/ + 3. 用 run_command 赋予可执行权限 + 4. 调用 reload_skills 热重载 + """ + + def get_name(self) -> str: + return "create_skill" + + def get_description(self) -> str: + return ( + "创建扩展技能工具。\n" + "返回一段指令提示词,驱动你自行编写并部署一个满足技能接口要求的可执行脚本。\n\n" + "脚本接口要求:\n" + " • 首行 shebang:#!/usr/bin/env python3\n" + " • 支持 --help 选项(使用 argparse)\n" + " • --help 输出的第一行(argparse description)必须是该技能的一句话简介\n" + " • epilog 中包含完整的参数说明和至少一个使用示例" + ) + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": ( + "技能名称(部署后的文件名,不含扩展名)。" + "只能包含字母、数字、连字符和下划线,例如:fetch-weather、calc-bmi。" + ) + }, + "description": { + "type": "string", + "description": ( + "对该技能的详细需求描述:它应该做什么、接受哪些输入参数、" + "返回什么输出、有哪些典型使用场景。描述越详细,生成质量越高。" + ) + } + }, + "required": ["skill_name", "description"] + } + + def execute(self, skill_name: str = "", description: str = "", **kwargs) -> str: + # ---- 参数校验 ---- + if not skill_name or not skill_name.strip(): + return "❌ 错误:skill_name 不能为空" + if not description or not description.strip(): + return "❌ 错误:description 不能为空" + if not re.match(r'^[A-Za-z0-9_\-]+$', skill_name.strip()): + return "❌ 错误:skill_name 只能包含字母、数字、连字符(-)和下划线(_)" + + skill_name = skill_name.strip() + skills_dir = constants.SKILLS_DIR + skill_path = os.path.join(skills_dir, skill_name) + + # ---- 返回给智能体的指令提示词 ---- + prompt = f"""【任务】创建扩展技能:{skill_name} + +请按照以下步骤,自行完成该扩展技能的编写和部署: + +───────────────────────────────────────────────── +步骤 1:编写 Python 脚本 +───────────────────────────────────────────────── +根据以下需求,编写一个完整可运行的 Python 3 脚本: + +需求描述: +{description.strip()} + +脚本必须满足的接口约定: +1. 第一行为 shebang:#!/usr/bin/env python3 +2. 使用 argparse 实现 --help 选项,其中: + - ArgumentParser 的 description 参数填写对该脚本功能的一句话简介(这是 --help 的第一行,系统会用它作为技能摘要) + - epilog 参数中包含完整的参数说明和至少一个使用示例 +3. 功能完整可运行,包含必要的错误处理 + +───────────────────────────────────────────────── +步骤 2:写入技能目录 +───────────────────────────────────────────────── +使用 write_file 工具,将完整脚本内容写入: + {skill_path} + +───────────────────────────────────────────────── +步骤 3:赋予可执行权限 +───────────────────────────────────────────────── +使用 run_command 工具执行: + chmod +x {skill_path} + +───────────────────────────────────────────────── +步骤 4:验证并热重载 +───────────────────────────────────────────────── +使用 run_command 工具执行以下命令验证 --help 输出是否正常: + {skill_path} --help + +确认无误后,调用 reload_skills 工具使新技能立即生效: + reload_skills confirm=true + +───────────────────────────────────────────────── +完成后请告知用户技能已创建成功,并展示 --help 的第一行简介。""" + + return prompt + + +# ==================== 更新身份工具 ==================== + +class UpdateIdentityTool(Tool): + """更新用户身份工具 - 修改 00_IDENTITY.md 文件""" + + def __init__(self, prompt_manager: PromptManager, logger: Optional[Logger] = None): + super().__init__(logger) + self.prompt_manager = prompt_manager + + def get_name(self) -> str: + return "update_identity" + + def get_description(self) -> str: + return """更新用户身份信息 - 修改 00_IDENTITY.md 文件 +当你了解到用户的身份信息时,调用此工具更新身份文件。 +如果文件不存在,会自动创建。""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "用户身份信息内容(Markdown格式),包含用户的身份、特点、偏好等信息" + }, + "confirm": { + "type": "boolean", + "description": "确认更新身份信息", + "default": True + } + }, + "required": ["content"] + } + + def execute(self, content: str = None, confirm: bool = True, **kwargs) -> str: + if not content: + return "❌ 需要提供身份信息内容" + + if not confirm: + return "❌ 身份更新已取消,如需更新请设置 confirm=true" + + self.logger.info("开始更新用户身份信息...") + + if self.prompt_manager.update_identity(content): + return self.format_result(True, i18n.get('identity_updated', self.prompt_manager.IDENTITY_FILE), { + "文件": self.prompt_manager.IDENTITY_FILE, + "路径": self.prompt_manager.get_prompts_dir_path() + }) + else: + return self.format_result(False, "身份更新失败", { + "文件": self.prompt_manager.IDENTITY_FILE, + "路径": self.prompt_manager.get_prompts_dir_path() + }) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行更新身份""" + output_queue.put(("line", "\n👤 更新用户身份信息...\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + +# ==================== 更新智能体自我认识工具 ==================== + +class UpdateSoulTool(Tool): + """更新智能体自我认识工具 - 修改 99_SOUL.md 文件""" + + def __init__(self, prompt_manager: PromptManager, logger: Optional[Logger] = None): + super().__init__(logger) + self.prompt_manager = prompt_manager + + def get_name(self) -> str: + return "update_soul" + + def get_description(self) -> str: + return """更新智能体的自我认识定义 - 修改 99_SOUL.md 文件 +当你需要重新定义自己的身份、角色、性格、能力边界时,调用此工具更新自我认识文件。 +如果文件不存在,会自动创建默认定义。""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "智能体自我认识定义内容(Markdown格式),包含:身份定义、角色描述、性格特点、能力边界、行为准则等" + }, + "confirm": { + "type": "boolean", + "description": "确认更新自我认识定义", + "default": True + } + }, + "required": ["content"] + } + + def execute(self, content: str = None, confirm: bool = True, **kwargs) -> str: + if not content: + return "❌ 需要提供智能体自我认识定义内容" + + if not confirm: + return "❌ 自我认识更新已取消,如需更新请设置 confirm=true" + + self.logger.info("开始更新智能体自我认识定义...") + + if self.prompt_manager.update_soul(content): + return self.format_result(True, "智能体自我认识已更新", { + "文件": self.prompt_manager.SOUL_FILE, + "路径": self.prompt_manager.get_prompts_dir_path() + }) + else: + return self.format_result(False, "自我认识更新失败", { + "文件": self.prompt_manager.SOUL_FILE, + "路径": self.prompt_manager.get_prompts_dir_path() + }) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行更新自我认识""" + output_queue.put(("line", "\n🧠 更新智能体自我认识...\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + +# ==================== 保存记忆并结束对话工具(增强版,修复版) ==================== + +class SaveMemoryAndEndConversationTool(Tool): + """保存记忆并结束对话工具 - 增强版,同时更新智能体状态""" + + def __init__(self, exit_callback: Callable, memory_db: MemoryDatabase, prompt_manager: PromptManager, logger: Optional[Logger] = None): + super().__init__(logger) + self.exit_callback = exit_callback + self.memory_db = memory_db + self.prompt_manager = prompt_manager + + def get_name(self) -> str: + return "save_memory_and_end_conversation" + + def get_description(self) -> str: + return """保存记忆并结束当前对话 - 必须提供详细的对话总结和当前状态! +使用此工具时,AI会: +1. 将本次对话的重要内容保存到记忆库 +2. 提供完整的对话总结 +3. 描述自己现在的状态、事情的走向等,保存到 100_STATUS.md +4. 礼貌地结束对话""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "summary": { + "type": "string", + "description": "详细的对话总结(至少50字),包含:讨论的主题、关键问题、重要结论、达成的共识等" + }, + "topic": { + "type": "string", + "description": "对话主题(用于记忆分类,默认自动提取)" + }, + "reason": { + "type": "string", + "description": "结束对话的原因", + "default": "话题讨论完成" + }, + "current_status": { + "type": "string", + "description": "当前的智能体状态描述,包含:当前进展、下一步计划、待处理事项、整体走向等" + }, + "next_goals": { + "type": "string", + "description": "接下来的目标或计划(可选)" + } + }, + "required": ["summary", "current_status"] + } + + def execute(self, summary: str = None, topic: str = None, reason: str = "话题讨论完成", + current_status: str = None, next_goals: str = None, **kwargs) -> str: + if summary is None or len(summary) < 50: + return "❌ 请提供更详细的对话总结(至少50字),以便保存到记忆库" + + if current_status is None: + return "❌ 请提供当前状态描述,以便更新 100_STATUS.md" + + self.logger.info(f"AI保存记忆并结束对话: {reason}") + + # 保存到记忆库 + memory_result = "" + if self.memory_db and topic: + memory_id = self.memory_db.add_memory( + topic=topic, + summary=summary, + importance=3 # 提高重要性,因为是对话总结 + ) + self.logger.info(f"对话记忆已保存,ID: {memory_id}") + memory_result = f"记忆已保存,ID: {memory_id}" + + # 构建状态内容 + status_content = f"【当前状态】\n{current_status}\n\n" + if next_goals: + status_content += f"【下一步计划】\n{next_goals}\n\n" + status_content += f"【对话总结】\n{summary}" + + # 更新状态文件 + status_updated = self.prompt_manager.update_status(status_content) + + if self.exit_callback: + self.exit_callback(reason, summary) + + data = { + "原因": reason, + "总结预览": summary[:100] + "..." if len(summary) > 100 else summary, + "状态更新": "✅ 已更新" if status_updated else "❌ 更新失败", + "记忆已保存": True + } + + if memory_result: + data["记忆"] = memory_result + + return self.format_result(True, "对话记忆已保存,状态已更新,对话结束", data) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行结束对话""" + output_queue.put(("line", "\n💾 保存记忆并结束对话\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +# ==================== 备忘录工具(简化版,去掉任务类型) ==================== + +class MemoTool(Tool): + """备忘录工具 - 用于管理任务(所有任务都是对话任务)""" + + def __init__(self, memo_db: MemoDatabase, logger: Optional[Logger] = None): + super().__init__(logger) + self.memo_db = memo_db + + def get_name(self) -> str: + return "memo" + + def get_description(self) -> str: + return """任务管理工具 - 创建、查看、完成任务 +支持:创建任务(支持一次性/重复、即时任务)、查看待办、标记完成、删除任务、搜索任务""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型", + "enum": ["add", "list", "get", "complete", "delete", "search", "stats"] + }, + "title": { + "type": "string", + "description": "任务标题(用于add操作)" + }, + "content": { + "type": "string", + "description": "任务内容(可选,用于add操作)" + }, + "reminder_time": { + "type": "string", + "description": "执行时间(格式:YYYY-MM-DD HH:MM:SS,或相对时间如 '+1h', '+30m', 'tomorrow 9am')" + }, + "reminder_minutes": { + "type": "integer", + "description": "延迟分钟数(从现在开始,用于快速设置任务)" + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "description": "标签列表" + }, + "priority": { + "type": "integer", + "description": "优先级 1-5(默认1)", + "default": 1 + }, + "repeat_type": { + "type": "string", + "description": "重复类型(none=一次性, daily=每天, weekly=每周, monthly=每月, custom=自定义)", + "enum": ["none", "daily", "weekly", "monthly", "custom"], + "default": "none" + }, + "repeat_interval": { + "type": "integer", + "description": "自定义重复间隔,当repeat_type='custom'时使用", + "default": 0 + }, + "repeat_interval_unit": { + "type": "string", + "description": "重复间隔单位(minutes=分钟, hours=小时, days=天),当repeat_type='custom'时使用", + "enum": ["minutes", "hours", "days"], + "default": "days" + }, + "repeat_end_time": { + "type": "string", + "description": "重复结束时间,None表示永久重复" + }, + "is_immediate": { + "type": "boolean", + "description": "是否为即时任务(立即执行)", + "default": False + }, + "memo_id": { + "type": "integer", + "description": "任务ID(用于get、complete、delete操作)" + }, + "memo_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "任务ID列表(用于批量删除)" + }, + "force_complete": { + "type": "boolean", + "description": "强制完成(忽略重复规则)", + "default": False + }, + "keyword": { + "type": "string", + "description": "搜索关键词(用于search操作)" + }, + "include_completed": { + "type": "boolean", + "description": "是否包含已完成的任务(默认False)", + "default": False + }, + "limit": { + "type": "integer", + "description": "返回数量限制(默认20)", + "default": 20 + }, + "confirm": { + "type": "boolean", + "description": "确认删除操作", + "default": False + } + }, + "required": ["action"] + } + + def _parse_reminder_time(self, reminder_input: Any) -> Optional[str]: + """解析提醒时间输入""" + if not reminder_input: + return None + + if isinstance(reminder_input, int): + reminder_time = datetime.now() + timedelta(minutes=reminder_input) + return reminder_time.strftime(constants.DATETIME_FORMAT) + + if isinstance(reminder_input, str): + reminder_input = reminder_input.strip() + + if reminder_input.startswith('+'): + match = re.match(r'\+(\d+)([mhd])', reminder_input) + if match: + value, unit = int(match.group(1)), match.group(2) + if unit == 'm': + delta = timedelta(minutes=value) + elif unit == 'h': + delta = timedelta(hours=value) + elif unit == 'd': + delta = timedelta(days=value) + else: + return None + + reminder_time = datetime.now() + delta + return reminder_time.strftime(constants.DATETIME_FORMAT) + + try: + reminder_time = datetime.strptime(reminder_input, constants.DATETIME_FORMAT) + return reminder_time.strftime(constants.DATETIME_FORMAT) + except ValueError: + pass + + now = datetime.now() + reminder_input_lower = reminder_input.lower() + + if 'tomorrow' in reminder_input_lower: + tomorrow = now + timedelta(days=1) + time_match = re.search(r'(\d{1,2})(?::(\d{2}))?\s*(am|pm)?', reminder_input_lower) + if time_match: + hour = int(time_match.group(1)) + minute = int(time_match.group(2)) if time_match.group(2) else 0 + ampm = time_match.group(3) + + if ampm: + if ampm == 'pm' and hour < 12: + hour += 12 + elif ampm == 'am' and hour == 12: + hour = 0 + + reminder_time = tomorrow.replace(hour=hour, minute=minute, second=0) + else: + reminder_time = tomorrow.replace(hour=9, minute=0, second=0) + + return reminder_time.strftime(constants.DATETIME_FORMAT) + + return None + + def execute(self, action: str, **kwargs) -> str: + """执行备忘录操作""" + try: + if action == "add": + return self._add_memo(kwargs) + elif action == "list": + return self._list_memos(kwargs) + elif action == "get": + return self._get_memo(kwargs) + elif action == "complete": + return self._complete_memo(kwargs) + elif action == "delete": + return self._delete_memo(kwargs) + elif action == "search": + return self._search_memos(kwargs) + elif action == "stats": + return self._get_stats(kwargs) + else: + return f"❌ 未知操作: {action}" + except Exception as e: + return f"❌ 任务操作错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行备忘录操作""" + action = kwargs.get("action") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n📝 执行备忘录操作: {action}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + def _add_memo(self, kwargs: Dict) -> str: + """添加任务 - 所有任务都是对话任务,不需要task_type""" + title = kwargs.get("title") + if not title: + return "❌ 需要提供任务标题" + + content = kwargs.get("content") + reminder_minutes = kwargs.get("reminder_minutes") + reminder_time_input = kwargs.get("reminder_time") + tags = kwargs.get("tags", []) + priority = kwargs.get("priority", 1) + repeat_type = kwargs.get("repeat_type", "none") + repeat_interval = kwargs.get("repeat_interval", 0) + repeat_interval_unit = kwargs.get("repeat_interval_unit", "days") + repeat_end_time = kwargs.get("repeat_end_time") + is_immediate = kwargs.get("is_immediate", False) + + if repeat_type not in ["none", "daily", "weekly", "monthly", "custom"]: + return f"❌ 无效的重复类型: {repeat_type}" + + if repeat_type == "custom" and repeat_interval <= 0: + return "❌ 自定义重复需要设置有效的 repeat_interval (>0)" + + reminder_time = None + if is_immediate: + # 即时任务:设置为当前时间,确保立即执行 + reminder_time = datetime.now().strftime(constants.DATETIME_FORMAT) + elif reminder_minutes: + reminder_time = self._parse_reminder_time(reminder_minutes) + elif reminder_time_input: + reminder_time = self._parse_reminder_time(reminder_time_input) + else: + # 默认30分钟后 + reminder_time = self._parse_reminder_time(30) + + memo_id = self.memo_db.add_memo( + title=title, + content=content, + reminder_time=reminder_time, + tags=tags, + priority=priority, + created_by="AI", + source="user", + repeat_type=repeat_type, + repeat_interval=repeat_interval, + repeat_interval_unit=repeat_interval_unit, + repeat_end_time=repeat_end_time, + is_immediate=is_immediate + ) + + data = { + "任务ID": memo_id, + "标题": title, + } + + if is_immediate: + data["任务类型"] = "即时任务" + else: + if reminder_time: + data["执行时间"] = reminder_time + + if repeat_type != "none": + if repeat_type == "custom": + repeat_info = f"重复类型: 每{repeat_interval} {repeat_interval_unit}" + else: + repeat_map = { + 'daily': '每天', + 'weekly': '每周', + 'monthly': '每月' + } + repeat_info = f"重复类型: {repeat_map.get(repeat_type, repeat_type)}" + + if repeat_end_time: + repeat_info += f", 结束于: {repeat_end_time}" + else: + repeat_info += ", 永久重复" + data["重复信息"] = repeat_info + + return self.format_result(True, "任务已创建", data) + + def _list_memos(self, kwargs: Dict) -> str: + """列出任务""" + include_completed = kwargs.get("include_completed", False) + limit = kwargs.get("limit", 20) + + memos = self.memo_db.get_all_memos(include_completed, limit) + + if not memos: + return "📭 没有找到任务" + + status = "所有" if include_completed else "待执行" + output = [f"📋 {status}任务 (共 {len(memos)} 个):"] + + now = datetime.now() + + for i, memo in enumerate(memos, 1): + status_icon = "✅" if memo['is_completed'] else "⏰" + priority_icon = "🔴" * memo['priority'] + source_tag = f"[{memo.get('source', '未知')}]" if memo.get('source') else "" + + immediate_tag = "⚡" if memo.get('is_immediate') else "" + + repeat_tag = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + unit_map = {'minutes': '分钟', 'hours': '小时', 'days': '天'} + repeat_tag = f" [每{memo['repeat_interval']}{unit_map.get(unit, unit)}]" + else: + repeat_map = { + 'daily': ' [每天]', + 'weekly': ' [每周]', + 'monthly': ' [每月]' + } + repeat_tag = repeat_map.get(memo['repeat_type'], ' [重复]') + + output.append(f"\n{i}. {status_icon}{immediate_tag} [ID: {memo['id']}] {source_tag}{repeat_tag} {memo['title']} {priority_icon}") + + if memo['reminder_time']: + try: + reminder = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + if not memo['is_completed'] and reminder < now: + output.append(f" ⚠️ 已过期: {memo['reminder_time']}") + else: + output.append(f" ⏱️ 执行: {memo['reminder_time']}") + except: + output.append(f" ⏱️ 执行: {memo['reminder_time']}") + + if memo.get('trigger_count', 0) > 0: + output.append(f" 🔄 已执行 {memo['trigger_count']} 次") + + if memo['content']: + preview = memo['content'][:100] + "..." if len(memo['content']) > 100 else memo['content'] + output.append(f" 📝 {preview}") + + if memo['tags']: + output.append(f" 🏷️ {', '.join(memo['tags'])}") + + return "\n".join(output) + + def _get_memo(self, kwargs: Dict) -> str: + """获取单个任务""" + memo_id = kwargs.get("memo_id") + if not memo_id: + return "❌ 需要提供任务ID" + + memo = self.memo_db.get_memo(memo_id) + if not memo: + return f"❌ 未找到ID为 {memo_id} 的任务" + + status = "✅ 已完成" if memo['is_completed'] else "⏰ 待执行" + if memo['is_completed']: + status += f" (完成于: {memo['completed_at']})" + + immediate_tag = "⚡ 即时任务" if memo.get('is_immediate') else "" + + output = [ + f"📌 任务 #{memo_id} {immediate_tag}", + f"📋 状态: {status}", + f"📝 标题: {memo['title']}", + ] + + if memo.get('source'): + output.append(f"📎 来源: {memo['source']}") + + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit_map = {'minutes': '分钟', 'hours': '小时', 'days': '天'} + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f"🔄 重复: 每{memo['repeat_interval']} {unit_map.get(unit, unit)}" + else: + repeat_map = { + 'daily': '每天', + 'weekly': '每周', + 'monthly': '每月' + } + repeat_info = f"🔄 重复: {repeat_map.get(memo['repeat_type'], memo['repeat_type'])}" + + if memo['repeat_end_time']: + repeat_info += f", 结束于: {memo['repeat_end_time']}" + else: + repeat_info += ", 永久重复" + output.append(repeat_info) + + if memo.get('trigger_count', 0) > 0: + output.append(f" 📊 已执行 {memo['trigger_count']} 次") + if memo.get('last_triggered_at'): + output.append(f" ⏱️ 上次执行: {memo['last_triggered_at']}") + + if memo['content']: + output.append(f"📄 内容:\n{memo['content']}") + + if memo['reminder_time']: + try: + now = datetime.now() + reminder = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + if not memo['is_completed'] and reminder < now: + output.append(f"⚠️ 执行时间 (已过期): {memo['reminder_time']}") + else: + output.append(f"⏰ 执行时间: {memo['reminder_time']}") + except: + output.append(f"⏰ 执行时间: {memo['reminder_time']}") + + if memo['tags']: + output.append(f"🏷️ 标签: {', '.join(memo['tags'])}") + + output.append(f"🔢 优先级: {'🔴' * memo['priority']} ({memo['priority']}/5)") + output.append(f"📅 创建时间: {memo['created_at']}") + output.append(f"👤 创建者: {memo['created_by'] or '系统'}") + + return "\n".join(output) + + def _complete_memo(self, kwargs: Dict) -> str: + """完成任务""" + memo_id = kwargs.get("memo_id") + if not memo_id: + return "❌ 需要提供任务ID" + + force_complete = kwargs.get("force_complete", False) + + memo = self.memo_db.get_memo(memo_id) + if not memo: + return f"❌ 未找到ID为 {memo_id} 的任务" + + if memo['is_completed'] and not force_complete: + return f"ℹ️ 任务 #{memo_id} 已经完成了" + + if self.memo_db.complete_memo(memo_id, force_complete): + memo = self.memo_db.get_memo(memo_id) + data = { + "任务ID": memo_id, + "标题": memo['title'] + } + + if memo['repeat_type'] != 'none' and not memo['is_completed']: + data["下一次执行"] = memo['reminder_time'] + data["已执行次数"] = memo.get('trigger_count', 0) + return self.format_result(True, "任务已处理,已更新下一次执行时间", data) + else: + return self.format_result(True, "任务已完成", data) + else: + return f"❌ 无法完成任务 #{memo_id}" + + def _delete_memo(self, kwargs: Dict) -> str: + """删除任务""" + confirm = kwargs.get("confirm", False) + if not confirm: + return "❌ 需要设置 confirm=True 来确认删除操作" + + memo_id = kwargs.get("memo_id") + if memo_id: + if self.memo_db.complete_memo(memo_id, force_complete=True): + return self.format_result(True, f"任务 #{memo_id} 已删除") + else: + return f"❌ 无法删除任务 #{memo_id}" + else: + return "❌ 需要提供 memo_id" + + def _search_memos(self, kwargs: Dict) -> str: + """搜索任务""" + keyword = kwargs.get("keyword") + if not keyword: + return "❌ 需要提供搜索关键词" + + include_completed = kwargs.get("include_completed", False) + limit = kwargs.get("limit", 20) + + all_memos = self.memo_db.get_all_memos(include_completed, limit=100) + + results = [] + keyword_lower = keyword.lower() + for memo in all_memos: + if (keyword_lower in memo['title'].lower() or + (memo['content'] and keyword_lower in memo['content'].lower())): + results.append(memo) + + if not results: + return f"❌ 未找到包含 '{keyword}' 的任务" + + output = [f"🔍 搜索 '{keyword}' 找到 {len(results)} 个任务:"] + now = datetime.now() + + for i, memo in enumerate(results[:limit], 1): + status_icon = "✅" if memo['is_completed'] else "⏰" + immediate_tag = "⚡" if memo.get('is_immediate') else "" + output.append(f"\n{i}. {status_icon}{immediate_tag} [ID: {memo['id']}] {memo['title']}") + if memo['reminder_time']: + try: + reminder = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + if not memo['is_completed'] and reminder < now: + output.append(f" ⚠️ 已过期: {memo['reminder_time']}") + else: + output.append(f" ⏱️ {memo['reminder_time']}") + except: + output.append(f" ⏱️ {memo['reminder_time']}") + + return "\n".join(output) + + def _get_stats(self, kwargs: Dict) -> str: + """获取任务统计信息""" + stats = self.memo_db.get_memo_stats() + + output = [ + "📊 任务统计", + f" 总任务: {stats['total']} 个", + f" 待执行: {stats['pending']} 个", + f" 已过期: {stats['overdue']} 个", + f" 已完成: {stats['completed']} 个", + f" 即时任务: {stats['immediate']} 个" + ] + + if stats['overdue'] > 0: + output.append(f"\n⚠️ 有 {stats['overdue']} 个任务已过期,将自动执行") + + return "\n".join(output) + + +# ==================== 记忆工具(修改版,支持流式输出) ==================== + +class MemoryTool(Tool): + """记忆工具 - 用于存储、检索和删除对话记忆(支持流式输出,支持分页)""" + + def __init__(self, memory_db: MemoryDatabase, logger: Optional[Logger] = None): + super().__init__(logger) + self.memory_db = memory_db + + def get_name(self) -> str: + return "memory" + + def get_description(self) -> str: + return """记忆管理工具 - 存储、检索和删除重要的对话信息 +支持:保存总结、多关键词搜索记忆(支持分页)、查看最近的记忆、删除无用记忆""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型", + "enum": ["save", "search", "recent", "get", "delete"] + }, + "summary": {"type": "string", "description": "要保存的总结内容(用于save操作)"}, + "topic": {"type": "string", "description": "对话主题(用于save操作)"}, + "content": {"type": "string", "description": "详细内容(可选,用于save操作)"}, + "keywords": { + "type": "array", "items": {"type": "string"}, + "description": "搜索关键词列表" + }, + "query": {"type": "string", "description": "搜索关键词(单个关键词)"}, + "match_all": {"type": "boolean", "default": False}, + "memory_id": {"type": "integer"}, + "memory_ids": {"type": "array", "items": {"type": "integer"}}, + "importance": {"type": "integer", "default": 2}, + "limit": {"type": "integer", "default": 10, "description": "每页条数(默认10)"}, + "page": {"type": "integer", "default": 1, "description": "页码(从1开始)"}, + "confirm": {"type": "boolean", "default": False} + }, + "required": ["action"] + } + + def execute(self, action: str, **kwargs) -> str: + try: + if action == "save": + return self._save_memory(kwargs) + elif action == "search": + return self._search_memories(kwargs) + elif action == "recent": + return self._get_recent_memories(kwargs) + elif action == "get": + return self._get_memory_by_id(kwargs) + elif action == "delete": + return self._delete_memory(kwargs) + else: + return f"❌ 未知操作: {action}" + except Exception as e: + return f"❌ 记忆操作错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行记忆操作""" + action = kwargs.get("action") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n🧠 执行记忆操作: {action}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + def _save_memory(self, kwargs: Dict) -> str: + summary = kwargs.get("summary") + if not summary: + return "❌ 需要提供总结内容" + + topic = kwargs.get("topic", "未命名对话") + content = kwargs.get("content") + importance = kwargs.get("importance", constants.MEMORY_DEFAULT_IMPORTANCE) + + memory_id = self.memory_db.add_memory( + topic=topic, + summary=summary, + content=content, + importance=importance + ) + + return self.format_result(True, "记忆已保存", { + "记忆ID": memory_id, + "主题": topic, + "总结": summary[:100] + "..." if len(summary) > 100 else summary + }) + + def _search_memories(self, kwargs: Dict) -> str: + """搜索记忆 - 支持分页""" + keywords = kwargs.get("keywords", []) + query = kwargs.get("query", "") + match_all = kwargs.get("match_all", False) + page = kwargs.get("page", 1) + page_size = kwargs.get("limit", 10) # 使用 limit 作为 page_size + + # 确保页码和每页条数有效 + page = max(1, page) + page_size = max(1, min(50, page_size)) # 每页最多50条 + + # 处理关键词 + if keywords and isinstance(keywords, list): + pass + elif query: + keywords = query.split() + else: + return "❌ 需要提供搜索关键词(keywords 或 query)" + + if not keywords: + return "❌ 关键词列表为空" + + # 先获取所有结果(不分页,用于计算总数) + all_results = self.memory_db.search_memories(keywords, match_all, limit=None) + + if not all_results: + match_mode = "所有" if match_all else "任一" + return f"❌ 未找到包含 {match_mode} 关键词 {keywords} 的记忆" + + # 计算分页信息 + total_count = len(all_results) + total_pages = (total_count + page_size - 1) // page_size + + # 检查页码是否有效 + if page > total_pages and total_pages > 0: + return f"❌ 页码 {page} 超出范围,共有 {total_pages} 页" + + # 计算当前页的起止索引 + start_idx = (page - 1) * page_size + end_idx = min(start_idx + page_size, total_count) + + # 获取当前页的结果 + page_results = all_results[start_idx:end_idx] + + # 构建输出 + match_mode = "所有关键词" if match_all else "任一关键词" + output = [ + f"🔍 搜索 {match_mode} {keywords} 找到 {total_count} 条记忆", + f"📄 第 {page}/{total_pages} 页 (每页 {page_size} 条)", + "-" * 40 + ] + + for i, mem in enumerate(page_results, 1): + # 全局序号 + global_idx = start_idx + i + output.append(f"\n{global_idx}. [ID: {mem['id']}] [{mem['end_time']}] {mem['topic']}") + output.append(f" 总结: {mem['summary'][:200]}") + if mem.get('tags'): + output.append(f" 标签: {', '.join(mem['tags'])}") + if mem.get('importance'): + output.append(f" 重要性: {'⭐' * mem['importance']} ({mem['importance']}/5)") + + # 添加翻页提示 + if total_pages > 1: + output.append(f"\n📖 翻页提示: 使用 page 参数查看更多结果") + output.append(f" 例如: memory search query='{query}' page={page + 1 if page < total_pages else page} limit={page_size}") + if page < total_pages: + output.append(f" ➡️ 下一页: page={page + 1}") + if page > 1: + output.append(f" ⬅️ 上一页: page={page - 1}") + + return "\n".join(output) + + def _get_recent_memories(self, kwargs: Dict) -> str: + """获取最近的记忆 - 支持分页""" + page = kwargs.get("page", 1) + page_size = kwargs.get("limit", 10) + + # 确保页码和每页条数有效 + page = max(1, page) + page_size = max(1, min(50, page_size)) + + # 先获取所有结果 + all_results = self.memory_db.get_recent_memories(limit=None) + + if not all_results: + return "📭 没有找到记忆" + + # 计算分页信息 + total_count = len(all_results) + total_pages = (total_count + page_size - 1) // page_size + + # 检查页码是否有效 + if page > total_pages and total_pages > 0: + return f"❌ 页码 {page} 超出范围,共有 {total_pages} 页" + + # 计算当前页的起止索引 + start_idx = (page - 1) * page_size + end_idx = min(start_idx + page_size, total_count) + + # 获取当前页的结果 + page_results = all_results[start_idx:end_idx] + + output = [f"📚 最近 {total_count} 条记忆 (第 {page}/{total_pages} 页,每页 {page_size} 条):"] + output.append("-" * 40) + + for i, mem in enumerate(page_results, 1): + global_idx = start_idx + i + output.append(f"\n{global_idx}. [ID: {mem['id']}] [{mem['end_time']}] {mem['topic']}") + output.append(f" 总结: {mem['summary'][:100]}") + + # 添加翻页提示 + if total_pages > 1: + output.append(f"\n📖 翻页提示: 使用 page 参数查看更多结果") + output.append(f" 例如: memory recent page={page + 1 if page < total_pages else page} limit={page_size}") + + return "\n".join(output) + + def _get_memory_by_id(self, kwargs: Dict) -> str: + """根据ID获取记忆""" + memory_id = kwargs.get("memory_id") + if not memory_id: + return "❌ 需要提供记忆ID" + + memory = self.memory_db.get_memory_by_id(memory_id) + if not memory: + return f"❌ 未找到ID为 {memory_id} 的记忆" + + output = [ + f"📌 记忆 #{memory_id}", + f"主题: {memory['topic']}", + f"时间: {memory['start_time']} - {memory['end_time']}", + f"总结: {memory['summary']}", + ] + + if memory.get('content'): + output.append(f"详细内容:\n{memory['content']}") + + if memory.get('tags'): + output.append(f"标签: {', '.join(memory['tags'])}") + + if memory.get('importance'): + output.append(f"重要性: {'⭐' * memory['importance']} ({memory['importance']}/5)") + + return "\n".join(output) + + def _delete_memory(self, kwargs: Dict) -> str: + """删除记忆""" + confirm = kwargs.get("confirm", False) + if not confirm: + return "❌ 需要设置 confirm=True 来确认删除操作" + + memory_id = kwargs.get("memory_id") + memory_ids = kwargs.get("memory_ids", []) + + deleted_count = 0 + + if memory_id: + if self.memory_db.delete_memory(memory_id): + deleted_count = 1 + elif memory_ids: + deleted_count = self.memory_db.delete_memories_by_ids(memory_ids) + else: + return "❌ 需要提供 memory_id 或 memory_ids" + + if deleted_count == 0: + return f"❌ 未找到要删除的记忆" + + return self.format_result(True, f"成功删除 {deleted_count} 条记忆", {"删除数量": deleted_count}) + +# ==================== 网页工具(修改版,支持流式输出) ==================== + +class WebPage: + """网页对象""" + + def __init__(self, url: str, response: requests.Response = None): + self.url = url + self.status_code = None + self.headers = {} + self.html = "" + self.soup = None + self.error = None + self.fetch_time = None + + if response: + self.status_code = response.status_code + self.headers = dict(response.headers) + self.html = response.text + self.fetch_time = datetime.now() + try: + self.soup = BeautifulSoup(self.html, 'html.parser') + except: + pass + + @property + def title(self) -> Optional[str]: + if self.soup and self.soup.find('title'): + return self.soup.find('title').get_text(strip=True) + return None + + @property + def text(self) -> str: + if self.soup: + return self.soup.get_text(separator='\n', strip=True) + return "" + + @property + def links(self) -> List[str]: + links = [] + if self.soup: + for a in self.soup.find_all('a', href=True): + href = a['href'].strip() + if href and not href.startswith('#') and not href.startswith('javascript:'): + links.append(urljoin(self.url, href)) + return list(set(links)) + + +class SimpleWebClient: + """简单网页访问客户端""" + + def __init__(self, timeout: int = None, delay: float = None, user_agent: str = None): + self.timeout = timeout or constants.WEB_TIMEOUT + self.delay = delay if delay is not None else constants.WEB_DELAY + + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': user_agent or constants.WEB_USER_AGENT + }) + + self.last_request_time = 0 + + def _wait_if_needed(self): + if self.delay > 0: + elapsed = time.time() - self.last_request_time + if elapsed < self.delay: + time.sleep(self.delay - elapsed) + + def get(self, url: str, **kwargs) -> WebPage: + self._wait_if_needed() + # 每次都从 ConfigManager 获取最新代理 + proxies = ConfigManager().get_proxy() + try: + response = self.session.get( + url, + timeout=kwargs.get('timeout', self.timeout), + proxies=proxies, # 传入最新代理 + **kwargs + ) + response.raise_for_status() + + page = WebPage(url, response) + self.last_request_time = time.time() + return page + + except Exception as e: + page = WebPage(url) + page.error = str(e) + return page + + +class SimpleWebCrawlerTool(Tool): + """简化版网页爬虫工具(支持流式输出)""" + + def __init__(self, logger=None): + super().__init__(logger) + self.client = SimpleWebClient() + + def get_name(self) -> str: + return "web" + + def get_description(self) -> str: + return """简单易用的网页访问工具 - 一句话搞定! +支持:获取网页、提取内容、搜索关键词、下载文件""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["get", "text", "title", "links", "search"] + }, + "url": {"type": "string"}, + "keyword": {"type": "string"} + }, + "required": ["action", "url"] + } + + def execute(self, action: str, url: str, **kwargs) -> str: + try: + if action == "get": + page = self.client.get(url) + return self._format_page_info(page) + elif action == "text": + text = self.client.get(url).text + return f"📄 网页文本内容:\n\n{text[:1000]}{'...' if len(text)>1000 else ''}" + elif action == "title": + title = self.client.get(url).title + return f"📌 网页标题: {title}" if title else "❌ 未找到标题" + elif action == "links": + links = self.client.get(url).links + result = [f"🔗 找到 {len(links)} 个链接:"] + for i, link in enumerate(links[:20]): + result.append(f" {i+1}. {link}") + return "\n".join(result) + elif action == "search": + keyword = kwargs.get("keyword") + if not keyword: + return "❌ 需要提供 keyword 参数" + page = self.client.get(url) + if keyword.lower() in page.text.lower(): + return f"🔎 在网页中找到关键词 '{keyword}'" + else: + return f"❌ 未找到关键词 '{keyword}'" + else: + return f"❌ 未知操作: {action}" + except Exception as e: + return f"❌ 执行错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行网页操作""" + action = kwargs.get("action") + url = kwargs.get("url") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n🌐 执行网页操作: {action} - {url}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + def _format_page_info(self, page: WebPage) -> str: + if page.error: + return f"❌ 获取失败: {page.error}" + + lines = [ + f"🌐 URL: {page.url}", + f"📊 状态码: {page.status_code}", + f"📌 标题: {page.title or '无标题'}", + f"🔗 链接数: {len(page.links)}", + ] + + if page.text: + preview = page.text[:200].replace('\n', ' ') + lines.append(f"📄 预览: {preview}...") + + return "\n".join(lines) + + +# ==================== 文件操作工具(修改版,支持流式输出) ==================== + +class FileToolBase(Tool): + """文件操作工具基类""" + + def __init__(self, logger: Optional[Logger] = None): + super().__init__(logger) + + def normalize_path(self, filepath: str) -> str: + return os.path.abspath(os.path.expanduser(filepath)) + + def check_path_safety(self, filepath: str, check_exists: bool = False) -> Tuple[bool, str, str]: + """检查路径安全性""" + try: + abs_path = self.normalize_path(filepath) + + if check_exists and not os.path.exists(abs_path): + return False, abs_path, f"❌ 文件不存在: {abs_path}" + + # 检查文件大小 + if os.path.exists(abs_path) and os.path.isfile(abs_path): + file_size = os.path.getsize(abs_path) + max_size = constants.MAX_FILE_SIZE + if file_size > max_size: + return False, abs_path, f"❌ 文件太大 ({file_size/1024/1024:.1f}MB)" + + return True, abs_path, "" + + except Exception as e: + return False, filepath, f"❌ 路径检查错误: {str(e)}" + + +class ReadFileTool(FileToolBase): + """读取文件内容工具(支持流式输出)""" + + def get_name(self) -> str: + return "read_file" + + def get_description(self) -> str: + return "读取文件内容,支持指定起始行和结束行,或读取开头/结尾" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "filepath": {"type": "string"}, + "encoding": {"type": "string", "default": "utf-8"}, + "start_line": {"type": "integer", "default": 1}, + "end_line": {"type": "integer", "default": 0}, + "max_lines": {"type": "integer", "default": 100}, + "head": {"type": "boolean", "default": False}, + "tail": {"type": "boolean", "default": False} + }, + "required": ["filepath"] + } + + def _calculate_line_range(self, line_count: int, start_line: int, end_line: int, + max_lines: int, head: bool, tail: bool) -> Tuple[int, int, str]: + if head: + start = 0 + end = min(max_lines, line_count) + desc = f"开头 {end} 行" + elif tail: + start = max(0, line_count - max_lines) + end = line_count + desc = f"结尾 {line_count - start} 行" + elif end_line > 0: + start = max(0, start_line - 1) + end = min(end_line, line_count) + desc = f"第 {start_line} 行到第 {end_line} 行" + else: + start = max(0, start_line - 1) + if max_lines > 0: + end = min(start + max_lines, line_count) + desc = f"从第 {start_line} 行开始,最多 {max_lines} 行" + else: + end = line_count + desc = f"从第 {start_line} 行到文件末尾" + + return start, end, desc + + def execute(self, filepath: str, encoding: str = "utf-8", + start_line: int = 1, end_line: int = 0, + max_lines: int = 100, head: bool = False, + tail: bool = False, **kwargs) -> str: + if error := self.validate_params(["filepath"], filepath=filepath): + return error + + is_safe, abs_path, error = self.check_path_safety(filepath, check_exists=True) + if not is_safe: + return error + + try: + file_size = os.path.getsize(abs_path) + modified_time = datetime.fromtimestamp(os.path.getmtime(abs_path)) + + with open(abs_path, 'r', encoding=encoding) as f: + all_lines = f.readlines() + line_count = len(all_lines) + + start, end, range_desc = self._calculate_line_range( + line_count, start_line, end_line, max_lines, head, tail + ) + + if end <= start: + return f"❌ 无效的行范围" + + display_lines = all_lines[start:end] + + data = { + "路径": abs_path, + "大小": f"{file_size} 字节 ({file_size/1024:.1f}KB)", + "最后修改": modified_time.strftime(constants.DATETIME_FORMAT), + "总行数": line_count, + "显示范围": f"{range_desc} (行 {start+1}-{end})" + } + + if display_lines: + content_lines = [] + for i, line in enumerate(display_lines): + line_num = start + i + 1 + content = line.rstrip('\n\r') + content_lines.append(f" {line_num:4d} | {content}") + + data["内容"] = "\n" + "\n".join(content_lines) + + if end < line_count: + data["提示"] = f"还有 {line_count - end} 行未显示" + + return self.format_result(True, "文件读取成功", data) + + except UnicodeDecodeError: + return f"❌ 文件编码错误:无法使用 {encoding} 编码读取文件" + except PermissionError: + return f"❌ 权限错误:无法读取文件 {abs_path}" + except Exception as e: + return f"❌ 文件读取错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行文件读取""" + filepath = kwargs.get("filepath") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n📄 读取文件: {filepath}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +class WriteFileTool(FileToolBase): + """写入文件工具(支持流式输出)""" + + def get_name(self) -> str: + return "write_file" + + def get_description(self) -> str: + return "文件写入工具 - 支持覆盖、追加" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "filepath": {"type": "string"}, + "content": {"type": "string"}, + "mode": {"type": "string", "enum": ["w", "a"], "default": "w"}, + "encoding": {"type": "string", "default": "utf-8"} + }, + "required": ["filepath", "content"] + } + + def execute(self, filepath: str = None, content: str = None, mode: str = "w", + encoding: str = "utf-8", **kwargs) -> str: + if error := self.validate_params(["filepath", "content"], filepath=filepath, content=content): + return error + + is_safe, abs_path, error = self.check_path_safety(filepath) + if not is_safe: + return error + + try: + directory = os.path.dirname(abs_path) + if directory and not os.path.exists(directory): + os.makedirs(directory, exist_ok=True) + + file_exists = os.path.exists(abs_path) + old_size = os.path.getsize(abs_path) if file_exists else 0 + + with open(abs_path, mode, encoding=encoding) as f: + f.write(content) + + new_size = len(content.encode(encoding)) + action = "覆盖写入" if mode == "w" else "追加写入" + + data = { + "路径": abs_path, + "大小": f"{new_size} 字节", + } + + if file_exists: + data["变化"] = f"{old_size} -> {new_size} 字节" + else: + data["状态"] = "新文件已创建" + + return self.format_result(True, f"文件{action}成功", data) + + except PermissionError: + return f"❌ 权限错误:无法写入文件" + except Exception as e: + return f"❌ 文件写入错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行文件写入""" + filepath = kwargs.get("filepath") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n📝 写入文件: {filepath}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +class ListFilesTool(FileToolBase): + """列出目录内容工具(支持流式输出)""" + + def get_name(self) -> str: + return "list_files" + + def get_description(self) -> str: + return "列出指定目录下的文件和子目录" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "directory": {"type": "string", "default": "."}, + "show_hidden": {"type": "boolean", "default": False} + } + } + + def execute(self, directory: str = ".", show_hidden: bool = False, **kwargs) -> str: + is_safe, abs_path, error = self.check_path_safety(directory) + if not is_safe: + return error + + if not os.path.exists(abs_path): + return f"❌ 目录不存在: {abs_path}" + + if not os.path.isdir(abs_path): + return f"❌ 路径不是目录: {abs_path}" + + try: + items = [] + total_size = 0 + file_count = 0 + dir_count = 0 + + for item in sorted(os.listdir(abs_path)): + if not show_hidden and item.startswith('.'): + continue + + item_path = os.path.join(abs_path, item) + + if os.path.isdir(item_path): + items.append(f"📁 {item}/") + dir_count += 1 + else: + size = os.path.getsize(item_path) + total_size += size + file_count += 1 + + if size < 1024: + size_str = f"{size}B" + elif size < 1024*1024: + size_str = f"{size/1024:.1f}KB" + else: + size_str = f"{size/1024/1024:.1f}MB" + + items.append(f"📄 {item} ({size_str})") + + data = { + "目录": abs_path, + "统计": f"{file_count} 个文件, {dir_count} 个目录", + "总大小": f"{total_size/1024:.1f}KB" if file_count > 0 else "0KB" + } + + if items: + data["内容"] = "\n" + "\n".join(items[:50]) + if len(items) > 50: + data["提示"] = f"还有 {len(items)-50} 个项目未显示" + else: + data["内容"] = "\n📭 目录为空" + + return self.format_result(True, "目录列表成功", data) + + except Exception as e: + return f"❌ 列出目录错误: {str(e)}" + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行列出目录""" + directory = kwargs.get("directory", ".") + + # 发送开始执行的消息 + output_queue.put(("line", f"\n📁 列出目录: {directory}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 执行操作 + result = self.execute(**kwargs) + + # 发送结果 + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +# ==================== 系统工具(修改版,支持流式输出) ==================== + +class GetCurrentTimeTool(Tool): + """获取当前时间工具(支持流式输出)""" + + def get_name(self) -> str: + return "get_current_time" + + def get_description(self) -> str: + return "获取当前日期和时间" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "format": {"type": "string", "enum": ["full", "date", "time"], "default": "full"} + } + } + + def execute(self, format: str = "full", **kwargs) -> str: + now = datetime.now() + + if format == "date": + result = now.strftime("%Y-%m-%d") + elif format == "time": + result = now.strftime("%H:%M:%S") + else: + result = now.strftime(constants.DATETIME_FORMAT) + + return self.format_result(True, "当前时间", {"时间": result}) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行获取时间""" + output_queue.put(("line", "\n🕐 获取当前时间\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + +class CalculatorTool(Tool): + """计算器工具(支持流式输出)""" + + def get_name(self) -> str: + return "calculator" + + def get_description(self) -> str: + return "执行数学计算" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"}, + "operation": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]} + }, + "required": ["a", "b", "operation"] + } + + def execute(self, a: float, b: float, operation: str, **kwargs) -> str: + operations = { + "add": ("+", a + b), + "subtract": ("-", a - b), + "multiply": ("*", a * b), + "divide": ("/", a / b if b != 0 else None) + } + + if operation not in operations: + return f"❌ 未知运算: {operation}" + + op_symbol, result = operations[operation] + + if result is None: + return "❌ 错误:除数不能为0" + + data = { + "表达式": f"{a} {op_symbol} {b}", + "结果": result + } + + return self.format_result(True, "计算成功", data) + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行计算""" + output_queue.put(("line", "\n🧮 执行计算\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + result = self.execute(**kwargs) + + output_queue.put(("line", result + "\n")) + output_queue.put(("complete", result)) + + + +class CommandLineTool(Tool): + """命令行执行工具 - 使用抽象的命令执行器(支持流式输出和超时控制)""" + + def __init__(self, executor: CommandExecutor = None, logger=None): + super().__init__(logger) + self.executor = executor or SubprocessCommandExecutor(logger) + + def get_name(self) -> str: + return "run_command" + + def get_description(self) -> str: + return """执行任意系统命令,支持实时输出显示和超时控制""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "要执行的命令" + }, + "timeout": { + "type": "integer", + "default": 30, + "description": "命令执行超时时间(秒),超时后将终止命令执行。默认30秒" + }, + "stream": { + "type": "boolean", + "default": True, + "description": "是否实时输出命令执行结果" + }, + "work_dir": { + "type": "string", + "default": ".", + "description": "命令执行的工作目录" + }, + "env": { + "type": "object", + "description": "额外的环境变量" + } + }, + "required": ["command"] + } + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行命令,支持超时控制""" + command = kwargs.get("command") + timeout = kwargs.get("timeout", 30) + work_dir = kwargs.get("work_dir", ".") + extra_env = kwargs.get("env", {}) + + if not command: + output_queue.put(("line", "❌ 错误:缺少命令参数\n")) + output_queue.put(("complete", "❌ 错误:缺少命令参数")) + return + + # 发送开始执行的消息 + output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) + output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) + if work_dir != ".": + output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 准备环境变量 + env = os.environ.copy() + if extra_env: + env.update(extra_env) + + try: + # 使用 subprocess.Popen 实现流式输出,支持超时 + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True, + env=env, + cwd=os.path.expanduser(work_dir) + ) + + start_time = time.time() + output_lines = [] + timeout_occurred = False + + # 实时读取输出 + while True: + # 检查超时 + elapsed = time.time() - start_time + if timeout > 0 and elapsed > timeout: + process.terminate() + timeout_occurred = True + output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒),已终止\n")) + break + + # 使用非阻塞方式读取一行 + import select + import sys + + # 检查是否有输出可读 + if sys.platform == 'win32': + # Windows 下使用 readline 带超时 + try: + import msvcrt + if msvcrt.kbhit(): + pass + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + line = line.rstrip('\n') + output_queue.put(("line", line + "\n")) + output_lines.append(line) + else: + time.sleep(0.05) + except: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + line = line.rstrip('\n') + output_queue.put(("line", line + "\n")) + output_lines.append(line) + else: + # Unix 系统可以使用 select + import select + rlist, _, _ = select.select([process.stdout], [], [], 0.5) + if rlist: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + line = line.rstrip('\n') + output_queue.put(("line", line + "\n")) + output_lines.append(line) + else: + # 检查进程是否结束 + if process.poll() is not None: + # 读取剩余输出 + remaining = process.stdout.read() + if remaining: + remaining = remaining.rstrip('\n') + if remaining: + output_queue.put(("line", remaining + "\n")) + output_lines.append(remaining) + break + + # 如果超时,确保进程被终止 + if timeout_occurred and process.poll() is None: + process.kill() + process.wait(timeout=2) + + # 获取返回码 + return_code = process.returncode if process.returncode is not None else -1 + + output_queue.put(("line", "-" * 50 + "\n")) + + if timeout_occurred: + output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n")) + else: + output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) + + # 准备完整结果 + full_output = "\n".join(output_lines) + data = { + "输出": full_output if full_output else "(无输出)", + "返回码": return_code + } + + if timeout_occurred: + result = self.format_result(False, f"命令执行超时({timeout}秒)", data) + else: + result = self.format_result(return_code == 0, "命令执行完成", data) + + output_queue.put(("complete", result)) + + except subprocess.TimeoutExpired: + error_msg = f"❌ 命令执行超时({timeout}秒)" + output_queue.put(("line", error_msg + "\n")) + output_queue.put(("complete", error_msg)) + except FileNotFoundError as e: + error_msg = f"❌ 命令执行错误: 命令未找到 - {str(e)}" + output_queue.put(("line", error_msg + "\n")) + output_queue.put(("complete", error_msg)) + except PermissionError as e: + error_msg = f"❌ 命令执行错误: 权限不足 - {str(e)}" + output_queue.put(("line", error_msg + "\n")) + output_queue.put(("complete", error_msg)) + except Exception as e: + error_msg = f"❌ 执行错误: {str(e)}" + output_queue.put(("line", error_msg + "\n")) + output_queue.put(("complete", error_msg)) + finally: + # 确保进程被清理 + if 'process' in locals() and process and process.poll() is None: + try: + process.terminate() + process.wait(timeout=2) + except: + process.kill() + + def execute(self, command: str, timeout: int = 30, stream: bool = True, + work_dir: str = ".", env: Dict = None, **kwargs) -> str: + """执行命令 - 支持超时控制""" + if error := self.validate_params(["command"], command=command): + return error + + # 如果正在流式输出,直接返回空(实际输出通过队列) + if self.streaming_output and self.output_queue: + return "" + + # 准备环境变量 + exec_env = os.environ.copy() + if env: + exec_env.update(env) + + # 根据stream参数决定使用流式还是非流式执行 + if stream: + # 流式执行需要调用方处理输出队列 + # 注意:流式执行会忽略这里的返回值,实际输出通过队列 + self.executor.execute_streaming( + command=command, + output_queue=self.output_queue, + timeout=timeout + ) + return "" + else: + # 非流式执行 + try: + # 使用 subprocess.run 执行命令,支持超时和工作目录 + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout, + env=exec_env, + cwd=os.path.expanduser(work_dir) + ) + + data = {} + + if result.stdout: + data["输出"] = result.stdout.strip() + if result.stderr: + data["错误"] = result.stderr.strip() + + data["返回码"] = result.returncode + + if not result.stdout and not result.stderr: + return self.format_result(True, "命令执行成功,无输出", data) + + return self.format_result(result.returncode == 0, "命令执行完成", data) + + except subprocess.TimeoutExpired: + return f"❌ 命令执行超时({timeout}秒)" + except FileNotFoundError as e: + return f"❌ 命令执行错误: 命令未找到 - {str(e)}" + except PermissionError as e: + return f"❌ 命令执行错误: 权限不足 - {str(e)}" + except Exception as e: + return f"❌ 执行错误: {str(e)}" + +class SetProxyTool(Tool): + """设置 SOCKS5 代理的工具""" + + def get_name(self) -> str: + return "set_proxy" + + def get_description(self) -> str: + return """设置智能体的 SOCKS5 代理配置。 +调用后所有 HTTP/HTTPS 请求(包括网页访问、API 调用)都会通过该代理。 +可随时修改,即时生效。""" + + def get_parameters(self) -> Dict: + return { + "type": "object", + "properties": { + "host": {"type": "string", "description": "代理服务器地址,如 127.0.0.1"}, + "port": {"type": "integer", "description": "代理端口,如 1080"}, + "username": {"type": "string", "description": "用户名(可选)"}, + "password": {"type": "string", "description": "密码(可选)"}, + "enabled": {"type": "boolean", "description": "是否启用代理,默认 true"}, + "save": {"type": "boolean", "description": "是否保存到配置文件(持久化),默认 true"} + }, + "required": ["host", "port"] + } + + def execute(self, host: str, port: int, username: str = None, password: str = None, + enabled: bool = True, save: bool = True, **kwargs) -> str: + config = ConfigManager() + config.set_proxy(host, port, username, password, enabled, save) + proxy_info = config.get_proxy_info() + data = { + "代理状态": "启用" if proxy_info["enabled"] else "禁用", + "服务器": f"{proxy_info['host']}:{proxy_info['port']}", + "认证": "有" if proxy_info.get("username") else "无", + "持久化": "已保存" if save else "仅内存" + } + return self.format_result(True, "代理设置成功", data) + +class GetProxyTool(Tool): + """查看当前代理配置""" + + def get_name(self) -> str: + return "get_proxy" + + def get_description(self) -> str: + return "查看当前 SOCKS5 代理配置信息" + + def get_parameters(self) -> Dict: + return {"type": "object", "properties": {}} + + def execute(self, **kwargs) -> str: + config = ConfigManager() + info = config.get_proxy_info() + if info["enabled"]: + data = { + "代理状态": "启用", + "服务器": f"{info['host']}:{info['port']}", + "认证": "有" if info.get("username") else "无" + } + return self.format_result(True, "当前代理配置", data) + else: + return "❌ 当前代理未启用,所有请求走直连。" + +# ==================== AI聊天类(修改版:添加重置对话记忆和重新加载系统提示词功能,压缩静默执行) ==================== + +class DeepSeekChat: + """单AI聊天类""" + + def __init__(self, api_key: str = None, max_history: int = None, + system_prompt: str = None, debug_level: int = None, exit_callback: Callable = None, + history_manager: ConversationHistory = None, memory_db: MemoryDatabase = None, + memo_db: MemoDatabase = None, command_executor: CommandExecutor = None, + prompt_manager: PromptManager = None): + self.name = "DeepSeek" + self.debug_level = debug_level if debug_level is not None else Logger("").level + self.logger = Logger(self.name, self.debug_level) + self.config = ConfigManager() + self.history_manager = history_manager + self.memory_db = memory_db + self.memo_db = memo_db + self.prompt_manager = prompt_manager or PromptManager() + self.history_loaded = False + self.conversation_active = True + self.messages = [] + + # 添加对话压缩器 + self.compressor = ConversationCompressor(self, self.logger) + + api_key_env = self.config.get("api.api_key_env") + self.api_key = api_key or os.environ.get(api_key_env) + if not self.api_key: + try: + home = os.environ.get("HOME", os.environ["USERPROFILE"]) + key_path = os.path.join(home, ".cert", "deepseek.key") + with open(f"{key_path}", "r") as f: + self.api_key = f.read().strip() + except Exception as e: + raise RuntimeError(f"请设置环境变量 {api_key_env} 或在配置文件中设置API Key") + + self.max_history = max_history or constants.DEFAULT_MAX_HISTORY + self.preserve_system = constants.PRESERVE_SYSTEM_PROMPTS + self.stats = {"api_calls": 0, "tool_calls": 0, "errors": []} + + self._init_system_prompts(system_prompt) + + self.tool_registry = ToolRegistry(self.logger) + self._register_tools(exit_callback, command_executor) + + def _init_system_prompts(self, system_prompt: str = None): + """初始化系统提示词 - 从 PromptManager 加载""" + if system_prompt: + # 如果明确指定了 system_prompt,使用它 + self._add_message("system", system_prompt) + self.logger.info("使用指定的系统提示词") + else: + # 从 PromptManager 加载所有提示词 + prompts = self.prompt_manager.get_prompts_list() + if prompts: + for prompt in prompts: + if prompt['content'].strip(): + self._add_message("system", prompt['content'].strip()) + self.logger.info(f"已加载 {len(prompts)} 个系统提示词") + else: + # 如果没有任何提示词,添加一个默认的 + default_prompt = "你是一个智能AI助手。" + self._add_message("system", default_prompt) + self.logger.info("使用默认系统提示词") + + # 在 DeepSeekChat 类的 __init__ 方法后添加 + def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None): + """添加助手消息,包含 reasoning_content""" + msg = {"role": "assistant"} + if content: + msg["content"] = content + if reasoning_content: + msg["reasoning_content"] = reasoning_content + if tool_calls: + msg["tool_calls"] = tool_calls + msg["content"] = None + self.messages.append(msg) + + def _register_tools(self, exit_callback: Callable = None, command_executor: CommandExecutor = None): + # 内置工具列表 + builtin_tools = [ + GetCurrentTimeTool(self.logger), + CalculatorTool(self.logger), + CommandLineTool(command_executor or SubprocessCommandExecutor(self.logger), self.logger), + ReadFileTool(self.logger), + WriteFileTool(self.logger), + ListFilesTool(self.logger), + SimpleWebCrawlerTool(self.logger), + ] + + if self.memory_db: + builtin_tools.append(MemoryTool(self.memory_db, self.logger)) + + if self.memo_db: + builtin_tools.append(MemoTool(self.memo_db, self.logger)) + + # 更新身份工具 + builtin_tools.append(UpdateIdentityTool(self.prompt_manager, self.logger)) + + # 新增:更新智能体自我认识工具 + builtin_tools.append(UpdateSoulTool(self.prompt_manager, self.logger)) + + # 增强版保存记忆并结束对话工具 + if exit_callback and self.memory_db: + builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger)) + + # 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具 + builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger)) + builtin_tools.append(InstallSkillTool(self.logger)) + builtin_tools.append(CreateSkillTool(self.logger)) + + builtin_tools.append(SetProxyTool(self.logger)) + builtin_tools.append(GetProxyTool(self.logger)) + + # 注册内置工具(标记为内置) + self.tool_registry.register_many(builtin_tools, is_builtin=True) + + # 创建技能管理器:启动时对每个技能执行 --help,收集第一行简介 + skill_manager = SkillManager(self.logger) + self.tool_registry.skill_manager = skill_manager + + # 注册扩展技能搜索工具(单一工具,替代逐个注册 ExtensionTool) + search_tool = SearchSkillsTool(skill_manager, self.logger) + self.tool_registry.register(search_tool, is_builtin=True) + + builtin_count, skill_count = self.tool_registry.get_skills_count() + self.logger.info(f"已注册工具: 内置 {builtin_count} 个, 扩展技能 {skill_manager.count()} 个(通过 search_skills 工具访问)") + + def _load_history_context(self): + """加载历史对话作为上下文""" + if self.history_loaded or not self.history_manager: + return + + context = self.history_manager.get_conversation_context() + if context: + history_prompt = f"""以下是之前的对话历史,请基于这些历史继续对话。 +如果问题涉及到之前的对话内容,请参考这些历史记录。 +当前时间: {datetime.now().strftime(constants.DATETIME_FORMAT)}""" + + self._add_message("system", history_prompt) + + for msg in context: + self.messages.append(msg) + + self.history_loaded = True + self.logger.info(f"已加载 {len(context)} 条历史消息") + + def _add_message(self, role: str, content: str): + """ + 添加消息到历史 + - 用户消息:添加时间戳前缀 + - 助手消息:不添加时间戳前缀 + """ + timestamp = datetime.now().strftime(constants.DATETIME_FORMAT) + + if role == "assistant": + # 助手消息:不添加时间戳前缀,直接保存原始内容 + formatted_content = content + elif role == "user": + # 用户消息:添加时间戳前缀 + formatted_content = f"[用户 @ {timestamp}] {content}" + else: + # 系统消息:保持原样 + formatted_content = content + + self.messages.append({"role": role, "content": formatted_content}) + self._cleanup_messages() + + def _cleanup_messages(self): + """清理消息历史,但保留系统提示词""" + if len(self.messages) <= self.max_history * 2: + return + + if self.preserve_system: + system_messages = [msg for msg in self.messages if msg["role"] == "system"] + other_messages = [msg for msg in self.messages if msg["role"] != "system"] + other_messages = other_messages[-self.max_history * 2:] + self.messages = system_messages + other_messages + else: + self.messages = self.messages[-self.max_history * 2:] + + def _save_tool_result_to_file(self, tool_name: str, result: str) -> str: + """ + 将工具返回结果保存到文件,返回文件路径 + """ + # 创建工具结果目录 + tool_results_dir = os.path.join(constants.SAVE_DIR, "tool_results") + os.makedirs(tool_results_dir, exist_ok=True) + + # 生成文件名:日期时间_工具名_哈希前8位.json + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + # 使用简单的哈希来避免文件名冲突 + import hashlib + hash_suffix = hashlib.md5(result[:100].encode()).hexdigest()[:8] + safe_tool_name = re.sub(r'[^\w\-]', '_', tool_name) + filename = f"{timestamp}_{safe_tool_name}_{hash_suffix}.txt" + filepath = os.path.join(tool_results_dir, filename) + + try: + # 保存结果到文件 + with open(filepath, 'w', encoding='utf-8') as f: + f.write(result) + + # 记录日志 + result_size = len(result) + size_kb = result_size / 1024 + if size_kb > 1024: + size_str = f"{size_kb/1024:.1f}MB" + else: + size_str = f"{size_kb:.1f}KB" + self.logger.info(f"工具 {tool_name} 返回结果过大 ({size_str}),已保存到: {filepath}") + + return filepath + except Exception as e: + self.logger.error(f"保存工具结果失败: {e}") + return None + + def _handle_tool_calls(self, tool_calls: List[Dict], output_callback: Callable = None) -> List[Dict]: + """处理工具调用 - 所有工具的输出都进行发送,并在开始和结束时发送两个回车""" + if not tool_calls: + return [] + + self.stats["tool_calls"] += len(tool_calls) + tool_messages = [] + + # 创建输出队列用于流式输出 + output_queue = queue.Queue() + self.tool_registry.set_output_queue(output_queue) + + # 结果大小阈值(字符数),从配置获取 + MAX_RESULT_LENGTH = constants.TOOL_MAX_RESULT_LENGTH + + for tool_call in tool_calls: + function = tool_call.get("function", {}) + tool_name = function.get("name") + arguments = function.get("arguments", "{}") + + try: + args = json.loads(arguments) if arguments else {} + except json.JSONDecodeError: + args = {} + + # 【修改】开始调用工具时发送两个回车 + if output_callback: + output_callback("line", "\n\n") + output_callback("line", f"🔧 调用工具: {tool_name}\n") + + # 直接调用 execute_tool(同步)。 + # - 若工具支持流式:execute_tool 内部开线程调 execute_streaming(**args), + # 收集输出后转发到 current_output_queue(即此处的 output_queue),join 后返回。 + # - 若工具不支持流式:execute_tool 直接调 execute(**args) 返回结果。 + # 两种情况下 execute_tool 返回时执行均已完成,args 完整传入,不再有参数丢失。 + result = self.tool_registry.execute_tool(tool_name, **args) + + # 排空 output_queue,把流式行输出转发给 output_callback + has_stream_output = False + while not output_queue.empty(): + try: + msg_type, content = output_queue.get_nowait() + if output_callback and msg_type in ("line", "complete"): + output_callback("line", content) + has_stream_output = True + except queue.Empty: + break + + # 普通(非流式)工具没有队列输出,直接发送结果 + if not has_stream_output and output_callback and result: + output_callback("line", result + "\n") + + # 检查结果大小,如果超过阈值,不返回结果,只返回提示信息 + if result and len(result) > MAX_RESULT_LENGTH: + # 不保存文件,直接返回提示信息 + result = f"⚠️ 工具 {tool_name} 执行结果过大({len(result)} 字符),超过限制({MAX_RESULT_LENGTH} 字符),已放弃返回结果。\n\n请考虑:\n1. 使用更精确的查询条件\n2. 使用其他工具获取部分数据\n3. 优化查询方式减少结果数量" + if output_callback: + output_callback("line", result + "\n") + + # 【修改】结束调用工具时发送两个回车 + if output_callback: + output_callback("line", "\n\n") + + if self.history_manager: + self.history_manager.add_tool_call(tool_name, args, result[:200] + "..." if len(result) > 200 else result) + + if tool_name == "save_memory_and_end_conversation" and "✅" in result: + self.logger.info("检测到保存记忆并结束对话工具调用") + self.conversation_active = False + tool_messages.append({ + "role": "tool", + "tool_call_id": tool_call.get("id"), + "content": result + }) + # 【修复】不直接返回,而是添加后继续处理,但设置标志让后续停止 + break + + tool_messages.append({ + "role": "tool", + "tool_call_id": tool_call.get("id"), + "content": result + }) + + # 清除输出队列 + self.tool_registry.set_output_queue(None) + + return tool_messages + + def _should_search_memory(self, user_input: str) -> bool: + """判断是否应该搜索记忆""" + # 检查用户输入是否包含搜索意图的关键词 + search_triggers = [ + '还记得', '之前', '上次', '以前', '我们说过', '我们讨论过', + '你记得', '你还记得', '我们聊过', '我们谈到', '我们之前', + '根据之前的', '依据历史', 'refer', 'previous', 'before', + 'last time', 'earlier', '曾经', '过往' + ] + + user_input_lower = user_input.lower() + for trigger in search_triggers: + if trigger.lower() in user_input_lower: + return True + + # 检查是否是简单问候(可能不需要搜索) + greetings = ['你好', '您好', '嗨', 'hello', 'hi', 'hey'] + if user_input_lower.strip() in greetings: + return False + + # 默认情况下,如果记忆库存在,搜索一下也无妨 + return True + + def think_and_respond(self, input_text: str, output_callback: Callable = None) -> Optional[str]: + """思考并回应 - 支持实时流式输出,支持思考模式""" + if not self.conversation_active: + self.logger.info("对话已结束,重置状态") + self.conversation_active = True + if self.preserve_system: + system_messages = [msg for msg in self.messages if msg["role"] == "system"] + self.messages = system_messages + else: + self.messages = [] + self.logger.info("对话状态已重置") + + # 压缩检查 + if constants.COMPRESS_ENABLED: + should_compress, reason = self.compressor.should_compress(self.messages) + if should_compress: + self.logger.debug(f"压缩检查触发: {reason}") + original_count = len(self.messages) + original_tokens = self.compressor.estimate_tokens(self.messages) + + print(f"\n📦 {i18n.get('conversation_compress')}...") + print(f" 触发原因: {reason}") + + self.messages = self.compressor.compress_conversation(self.messages) + + compressed_count = len(self.messages) + compressed_tokens = self.compressor.estimate_tokens(self.messages) + + self.logger.debug(i18n.get('conversation_compressed', original_count, compressed_count)) + self.logger.debug(f"Token数: {original_tokens} -> {compressed_tokens}") + + self.stats["api_calls"] += 1 + + # 前置提示词 + pre_prompt = """【系统指令 - 核心工作流程】 + + 在开始任何任务之前,你必须严格按照以下优先级执行: + + ## 第一步:搜索可用技能(最高优先级)⭐ + 当用户提出任何任务、需求或问题时,**必须先使用 `search_skills` 工具**搜索相关技能: + - 使用 `search_skills keyword="[任务关键词]"` 搜索是否有现成的扩展技能可以完成该任务 + - 例如:用户说"帮我查天气",你应该先执行 `search_skills keyword="天气"` + - 例如:用户说"帮我计算BMI",你应该先执行 `search_skills keyword="BMI"` + - 如果搜索结果中有匹配的技能,优先使用该技能完成任务 + - 只有在没有合适技能的情况下,才考虑使用其他工具或方法 + + ## 第二步:搜索相关记忆 + 在每次回答用户问题前,**必须**使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。 + + ## 第三步:执行任务或回答问题 + 根据搜索结果,选择合适的工具或直接回答。 + + 【重要提醒】 + - 技能优先原则:有技能先用技能,不要自己硬编码实现 + - 如果没有合适的技能,可以使用其他工具或直接回答 + - 对话结束时,必须使用 `save_memory_and_end_conversation` 工具保存本次对话的重要信息并更新智能体状态 + - 当了解到用户的身份信息时,使用 `update_identity` 工具更新 00_IDENTITY.md + - 当你的角色、性格、能力边界需要调整时,使用 `update_soul` 工具更新 99_SOUL.md 文件来重新定义自己 + - 绝对不要直接打开大文件来读,应该使用额外的压缩工具 + + 现在请处理用户的输入:""" + + # 后置提示词 + post_prompt = """ + + 【注意】 + 当本次对话的话题讨论完成时,请使用 `save_memory_and_end_conversation` 工具结束对话并保存重要信息到记忆库,同时更新你的状态到 100_STATUS.md。 + + 【自我认知更新】 + 在对话过程中,如果你发现: + - 自己的角色定位需要调整 + - 性格特点需要改变 + - 能力边界需要重新定义 + - 行为准则需要优化 + 请使用 `update_soul` 工具更新 99_SOUL.md 文件,这有助于你更好地理解自己并为用户提供更精准的服务。""" + + enhanced_input = pre_prompt + "\n\n" + input_text + post_prompt + + self._add_message("user", enhanced_input) + + if self.history_manager: + self.history_manager.add_message("user", enhanced_input, "用户") + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + # 构建消息列表,确保包含 reasoning_content + enhanced_messages = [] + + # 添加所有系统消息 + for msg in self.messages: + if msg["role"] == "system": + enhanced_messages.append(msg) + + # 添加非系统消息,但确保消息结构正确 + for msg in self.messages: + if msg["role"] != "system": + if msg["role"] == "assistant": + assistant_msg = {"role": "assistant"} + if "content" in msg and msg["content"]: + assistant_msg["content"] = msg["content"] + if "reasoning_content" in msg: + assistant_msg["reasoning_content"] = msg["reasoning_content"] + if "tool_calls" in msg and msg["tool_calls"]: + assistant_msg["tool_calls"] = msg["tool_calls"] + assistant_msg["content"] = None + enhanced_messages.append(assistant_msg) + else: + enhanced_messages.append(msg) + + payload = { + "model": constants.DEEPSEEK_MODEL, + "messages": enhanced_messages, + "temperature": constants.DEFAULT_TEMPERATURE, + "max_tokens": constants.DEFAULT_MAX_TOKENS, + "stream": True + } + + # 可选:添加 thinking 模式配置(如果有) + payload["thinking"] = { + "type": "enabled" + } + + tools = self.tool_registry.get_tools_schemas() + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + current_time = datetime.now().strftime("%H:%M:%S") + if output_callback: + output_callback("line", f"\n{i18n.get('thinking', current_time)}\n") + output_callback("line", i18n.get('assistant_prefix')) + + full_response = "" + full_reasoning = "" # 存储思考内容 + tool_calls_buffer = [] + current_tool_calls = {} + + chunk_buffer = "" + last_send_time = time.time() + MIN_CHUNK_SIZE = 5 + MAX_CHUNK_DELAY = 0.1 + + try: + proxies = self.config.get_proxy() + response = requests.post( + constants.DEEPSEEK_API_URL, + headers=headers, + json=payload, + timeout=constants.DEFAULT_TIMEOUT, + stream=True, + proxies=proxies # 动态代理 + ) + + if response.status_code != 200: + error_msg = f"\n错误状态码: {response.status_code}\n错误响应: {response.text}" + if output_callback: + output_callback("error", error_msg) + response.raise_for_status() + + for line in response.iter_lines(): + if line: + line = line.decode('utf-8') + if line.startswith('data: '): + data = line[6:] + if data == '[DONE]': + if chunk_buffer and output_callback: + output_callback("chunk", chunk_buffer) + chunk_buffer = "" + if output_callback: + output_callback("line", "\n") + break + + try: + chunk = json.loads(data) + choices = chunk.get('choices', []) + if not choices: + continue + + delta = choices[0].get('delta', {}) + + # 【关键】处理 reasoning_content(思考内容) + if 'reasoning_content' in delta and delta['reasoning_content']: + reasoning = delta['reasoning_content'] + full_reasoning += reasoning + # 可选:将思考内容也发送给前端(可以单独处理) + if output_callback and self.debug_level >= 3: + output_callback("reasoning", reasoning) + + # 处理正常内容 + if 'content' in delta and delta['content']: + content = delta['content'] + chunk_buffer += content + full_response += content + + if (len(chunk_buffer) >= MIN_CHUNK_SIZE or + time.time() - last_send_time >= MAX_CHUNK_DELAY): + if output_callback: + output_callback("chunk", chunk_buffer) + chunk_buffer = "" + last_send_time = time.time() + + # 处理工具调用 + if 'tool_calls' in delta: + tool_calls = delta['tool_calls'] + for tc in tool_calls: + index = tc.get('index', 0) + + if index not in current_tool_calls: + current_tool_calls[index] = { + 'id': tc.get('id', ''), + 'type': 'function', + 'function': { + 'name': '', + 'arguments': '' + } + } + + if 'function' in tc: + if 'name' in tc['function']: + current_tool_calls[index]['function']['name'] = tc['function']['name'] + if 'arguments' in tc['function']: + current_tool_calls[index]['function']['arguments'] += tc['function']['arguments'] + + except json.JSONDecodeError as e: + self.logger.error(f"JSON解析错误: {e}, 数据: {data}") + continue + + # 处理工具调用 + if current_tool_calls: + tool_calls_buffer = list(current_tool_calls.values()) + if output_callback: + output_callback("line", i18n.get('calling_tools') + "\n") + + # 【关键修复1】先添加助手消息,包含 tool_calls + assistant_msg = { + "role": "assistant", + "content": full_response if full_response else None + } + if full_reasoning: + assistant_msg["reasoning_content"] = full_reasoning + if tool_calls_buffer: + assistant_msg["tool_calls"] = tool_calls_buffer + assistant_msg["content"] = None + + # 添加助手消息到消息列表 + self.messages.append(assistant_msg) + + if self.history_manager: + self.history_manager.add_message("assistant", full_response, self.name) + + # 【关键修复2】执行工具调用并获取结果 + tool_responses = self._handle_tool_calls(tool_calls_buffer, output_callback) + + # 【关键修复3】添加所有 tool 响应消息 + if tool_responses: + for tool_response in tool_responses: + self.messages.append(tool_response) + + # 检查对话是否应该结束 + if not self.conversation_active: + self.logger.info("对话已结束,停止响应") + if output_callback: + output_callback("dialogue_ended", i18n.get('dialogue_ended')) + return None + + if output_callback: + output_callback("line", i18n.get('analyzing_tool_results') + "\n") + + # 【关键修复4】递归调用,让AI基于工具结果继续回答 + # 使用一个新的输入提示,避免重复 + return self.think_and_respond("请基于以上工具执行结果继续回答用户的问题。", output_callback) + + elif full_response: + # 正常添加助手消息,包含 reasoning_content + assistant_msg = { + "role": "assistant", + "content": full_response + } + if full_reasoning: + assistant_msg["reasoning_content"] = full_reasoning + self.messages.append(assistant_msg) + + if self.history_manager: + self.history_manager.add_message("assistant", full_response, self.name) + + if output_callback: + output_callback("complete", full_response) + + return full_response if full_response else "" + + except requests.exceptions.RequestException as e: + error_msg = f"\n{i18n.get('error_prefix')}API请求失败: {str(e)}" + if output_callback: + output_callback("error", error_msg) + self.stats["errors"].append(str(e)) + return error_msg + except json.JSONDecodeError as e: + error_msg = f"\n{i18n.get('error_prefix')}API响应解析失败: {str(e)}" + if output_callback: + output_callback("error", error_msg) + self.stats["errors"].append(str(e)) + return error_msg + except Exception as e: + error_msg = f"\n{i18n.get('error_prefix')}{str(e)}" + if output_callback: + output_callback("error", error_msg) + self.stats["errors"].append(str(e)) + return error_msg + + + def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None): + """添加助手消息,包含 reasoning_content""" + msg = {"role": "assistant"} + if content: + msg["content"] = content + if reasoning_content: + msg["reasoning_content"] = reasoning_content + if tool_calls: + msg["tool_calls"] = tool_calls + msg["content"] = None + self.messages.append(msg) + + def reset_conversation(self, reload_prompts: bool = True): + """ + 重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词 + + Args: + reload_prompts: 是否重新加载系统提示词,默认为 True + """ + self.conversation_active = True + self.history_loaded = False # 重置历史加载标记 + + if reload_prompts: + # 完全重置:清除所有消息,重新加载系统提示词 + self.messages = [] + self._init_system_prompts() + self.logger.info("对话已完全重置,系统提示词已重新加载") + else: + # 只清除非系统消息,保留系统提示词 + if self.preserve_system: + system_messages = [msg for msg in self.messages if msg["role"] == "system"] + self.messages = system_messages + else: + self.messages = [] + self.logger.info("对话已重置(仅清除对话历史)") + + def reload_prompts(self): + """重新加载提示词""" + old_system_count = len([msg for msg in self.messages if msg["role"] == "system"]) + + # 移除所有旧的系统消息 + self.messages = [msg for msg in self.messages if msg["role"] != "system"] + + # 重新加载提示词 + self.prompt_manager.reload() + self._init_system_prompts() + + new_system_count = len([msg for msg in self.messages if msg["role"] == "system"]) + self.logger.info(f"提示词已重新加载: {old_system_count} -> {new_system_count} 个") + return new_system_count + + +# ==================== WebSocket处理器(修改版:添加重置对话功能) ==================== + +class WebSocketHandler: + """WebSocket处理器 - 处理WebSocket连接和消息""" + + def __init__(self, ai_manager): + self.ai_manager = ai_manager + self.logger = Logger("WebSocket") + self.connected_clients = set() + self.client_sessions = {} + + async def register(self, websocket): + """注册新客户端""" + self.connected_clients.add(websocket) + self.client_sessions[websocket] = { + "connected_at": datetime.now(), + "message_count": 0, + "current_conversation": None + } + self.logger.info(i18n.get('client_connected', websocket.remote_address)) + + # 发送连接确认消息 + await self.send_message(websocket, { + "type": "connection_ack", + "message": i18n.get('ws_connected'), + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT) + }) + + # 发送欢迎信息和初始状态 + await self.send_welcome_info(websocket) + + async def unregister(self, websocket): + """注销客户端""" + self.connected_clients.remove(websocket) + if websocket in self.client_sessions: + del self.client_sessions[websocket] + self.logger.info(i18n.get('client_disconnected', websocket.remote_address)) + + async def send_welcome_info(self, websocket): + """发送欢迎信息""" + # 发送存储位置 + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('data_dir', constants.SAVE_DIR) + }) + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('skills_dir', constants.SKILLS_DIR) + }) + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('prompts_dir', constants.PROMPTS_DIR) + }) + + # 发送任务调度器状态 + if self.ai_manager.scheduler and self.ai_manager.scheduler.running: + stats = self.ai_manager.scheduler.get_stats() + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('scheduler_started') + }) + await self.send_message(websocket, { + "type": "info", + "content": f"📊 任务统计: 总执行次数={stats.get('total_executions', 0)}, 最近执行={len(stats.get('recent_executions', []))}" + }) + + # 发送任务统计 + memo_stats = self.ai_manager.memo_db.get_memo_stats() + if memo_stats['total'] > 0: + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('memo_stats') + }) + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('total_memos', memo_stats['total']) + }) + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('pending_memos', memo_stats['pending']) + }) + if memo_stats['overdue'] > 0: + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('overdue_memos', memo_stats['overdue']) + }) + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('completed_memos', memo_stats['completed']) + }) + + # 发送待执行任务列表 + pending = self.ai_manager.memo_db.get_pending_tasks_for_user() + if pending: + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('pending_memos_title') + }) + for task in pending[:5]: + status = i18n.get('memo_overdue') if task['is_overdue'] else i18n.get('memo_pending') + immediate_tag = "⚡" if task['is_immediate'] else "" + await self.send_message(websocket, { + "type": "task_status", + "task_id": task['id'], + "title": task['title'], + "reminder_time": task['reminder_time'], + "status": "overdue" if task['is_overdue'] else "pending", + "content": f"{status}{immediate_tag} [ID: {task['id']}] {task['title']} - {task['reminder_time']}" + }) + if len(pending) > 5: + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('more_skills', len(pending)-5) + }) + + async def send_message(self, websocket, message): + """发送消息到客户端 - 确保完整发送不被截断""" + try: + # 将消息转换为JSON字符串 + json_str = json.dumps(message, ensure_ascii=False) + + # 直接发送整个字符串,WebSocket协议会自动处理分片 + await websocket.send(json_str) + + except websockets.exceptions.ConnectionClosed: + self.logger.warning("连接已关闭,无法发送消息") + except Exception as e: + self.logger.error(f"发送消息失败: {e}") + + async def broadcast(self, message): + """广播消息到所有客户端""" + for websocket in self.connected_clients.copy(): + try: + await self.send_message(websocket, message) + except: + await self.unregister(websocket) + + async def broadcast_task_status(self, task: Dict, status: str, message: str = None): + """广播任务状态""" + await self.broadcast({ + "type": "task_status", + "task_id": task.get('id', 0), + "title": task.get('title', '未知任务'), + "status": status, + "message": message, + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT) + }) + + async def handle_message(self, websocket, message): + """处理接收到的消息""" + try: + data = json.loads(message) + msg_type = data.get("type", "unknown") + + # 更新会话统计 + if websocket in self.client_sessions: + self.client_sessions[websocket]["message_count"] += 1 + + if msg_type == "command": + await self.handle_command(websocket, data) + elif msg_type == "message": + await self.handle_user_message(websocket, data) + elif msg_type == "task": + await self.handle_task_message(websocket, data) + elif msg_type == "ping": + await self.send_message(websocket, { + "type": "pong", + "timestamp": datetime.now().strftime(constants.DATETIME_FORMAT) + }) + else: + await self.send_message(websocket, { + "type": "error", + "content": i18n.get('ws_unknown_command', msg_type) + }) + + except json.JSONDecodeError: + await self.send_message(websocket, { + "type": "error", + "content": "无效的JSON格式" + }) + except Exception as e: + self.logger.error(f"处理消息失败: {e}") + await self.send_message(websocket, { + "type": "error", + "content": f"处理失败: {str(e)}" + }) + + async def handle_task_message(self, websocket, data): + """处理任务相关消息 - 简化版,所有任务都是对话任务""" + action = data.get("action", "").strip() + + if action == "add": + # 添加任务 + title = data.get("title") + content = data.get("content") + delay = data.get("delay", 60) # 默认60秒后执行 + is_immediate = data.get("is_immediate", False) # 是否为即时任务 + + # 重复任务参数 + repeat_type = data.get("repeat_type", "none") + repeat_interval = data.get("repeat_interval", 0) + repeat_interval_unit = data.get("repeat_interval_unit", "days") + repeat_end_time = data.get("repeat_end_time") + + if not title: + await self.send_message(websocket, { + "type": "error", + "content": "需要提供任务标题" + }) + return + + reminder_time = None + if is_immediate: + reminder_time = datetime.now().strftime(constants.DATETIME_FORMAT) + else: + reminder_time = (datetime.now() + timedelta(seconds=delay)).strftime(constants.DATETIME_FORMAT) + + memo_id = self.ai_manager.memo_db.add_user_task( + title=title, + content=content, + reminder_time=reminder_time, + repeat_type=repeat_type, + repeat_interval=repeat_interval, + repeat_interval_unit=repeat_interval_unit, + repeat_end_time=repeat_end_time, + is_immediate=is_immediate + ) + + immediate_msg = i18n.get('immediate_memo_added') if is_immediate else "" + repeat_msg = "" + if repeat_type != "none": + if repeat_type == "custom": + repeat_msg = f" (每{repeat_interval} {repeat_interval_unit})" + else: + repeat_msg = f" ({repeat_type})" + + await self.send_message(websocket, { + "type": "task_added", + "task_id": memo_id, + "title": title, + "reminder_time": reminder_time, + "content": i18n.get('task_added', title, memo_id) + repeat_msg + immediate_msg + }) + + # 广播任务添加 + await self.broadcast_task_status( + {"id": memo_id, "title": title}, + "added", + f"新任务: {title}" + repeat_msg + (" (即时)" if is_immediate else "") + ) + + elif action == "list": + pending = self.ai_manager.memo_db.get_pending_tasks_for_user() + await self.send_message(websocket, { + "type": "task_list", + "tasks": pending + }) + + elif action == "complete": + memo_id = data.get("task_id") + if memo_id: + if self.ai_manager.memo_db.complete_memo(memo_id): + memo = self.ai_manager.memo_db.get_memo(memo_id) + await self.send_message(websocket, { + "type": "task_completed", + "task_id": memo_id, + "content": i18n.get('memo_completed', memo_id) + }) + if memo: + await self.broadcast_task_status(memo, "completed", f"任务已完成: {memo['title']}") + else: + await self.send_message(websocket, { + "type": "error", + "content": i18n.get('memo_complete_failed', memo_id) + }) + + async def handle_command(self, websocket, data): + """处理命令消息""" + command = data.get("command", "").strip() + + if not command: + return + + self.logger.info(f"收到命令: {command}") + + if command == "/exit": + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('goodbye') + }) + await websocket.close() + + elif command == "/new": + # 【新增】开始新对话:保存当前对话,重置AI状态,重新加载系统提示词 + self.ai_manager.history_manager.archive_current_conversation() + if self.ai_manager.ai: + # 重置对话状态,重新加载系统提示词 + self.ai_manager.ai.reset_conversation(reload_prompts=True) + # 重置对话进行中标记 + self.ai_manager.dialogue_in_progress = False + # 重新初始化提示词管理器(确保最新) + self.ai_manager.prompt_manager.reload() + self.logger.info(i18n.get('new_conversation_reset')) + + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('new_conversation_reset') + }) + + # 发送重置完成通知 + await self.send_message(websocket, { + "type": "dialogue_reset", + "content": i18n.get('dialogue_reset_complete') + }) + else: + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('new_conversation') + }) + + elif command == "/list": + conversations = self.ai_manager.history_manager.list_conversations() + result = f"\n{i18n.get('saved_conversations')}\n" + for conv in conversations: + status = i18n.get('conv_status_current') if conv.get('is_current') else i18n.get('conv_status_archived') + result += f" {conv['file']} - {conv['topic']} [{status}] ({conv['start_time']})\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/memories": + memories = self.ai_manager.memory_db.get_recent_memories(10) + result = f"\n{i18n.get('recent_memories')}\n" + for mem in memories: + result += f" [ID: {mem['id']}] {mem['topic']} - {mem['summary'][:50]}...\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/memos": + memos = self.ai_manager.memo_db.get_all_memos(include_completed=False) + result = f"\n{i18n.get('pending_memos_title')}\n" + for memo in memos: + status = i18n.get('memo_overdue') if memo['reminder_time'] and datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < datetime.now() else i18n.get('memo_pending') + immediate_tag = "⚡" if memo.get('is_immediate') else "" + repeat_info = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f" [每{memo['repeat_interval']} {unit}]" + else: + repeat_info = f" [{memo['repeat_type']}]" + result += f" {status}{immediate_tag} [ID: {memo['id']}] {memo['title']}{repeat_info} - {memo.get('reminder_time', '无提醒')}\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command.startswith("/search "): + query = command[8:].strip() + results = self.ai_manager.memory_db.search_memories([query]) + result = i18n.get('search_results', query, len(results)) + "\n" + for mem in results: + result += f" [ID: {mem['id']}] {mem['topic']} - {mem['summary'][:100]}...\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/config": + result = f"\n{i18n.get('current_config')}\n" + result += i18n.get('config_debug', self.ai_manager.config.get('system.debug_level')) + "\n" + result += i18n.get('config_data_dir', constants.SAVE_DIR) + "\n" + result += i18n.get('config_skills_dir', constants.SKILLS_DIR) + "\n" + result += i18n.get('config_prompts_dir', constants.PROMPTS_DIR) + "\n" + result += i18n.get('config_commands', i18n.get('enabled') if constants.COMMANDS_ENABLED else i18n.get('disabled')) + "\n" + result += i18n.get('config_history', i18n.get('enabled') if self.ai_manager.history_manager.load_history else i18n.get('disabled')) + "\n" + result += i18n.get('config_executor', type(self.ai_manager.command_executor).__name__) + "\n" + result += i18n.get('config_scheduler', i18n.get('enabled') if constants.SCHEDULER_ENABLED else i18n.get('disabled')) + "\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/reload": + self.ai_manager.config.reload() + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('config_reloaded') + }) + + elif command == "/reload_prompts": + if self.ai_manager.ai: + new_count = self.ai_manager.ai.reload_prompts() + else: + new_count = self.ai_manager.prompt_manager.reload() + await self.send_message(websocket, { + "type": "command_result", + "content": i18n.get('prompts_reloaded', new_count) + }) + + elif command == "/check": + result = i18n.get('checking_memos') + "\n" + overdue = self.ai_manager.memo_db.get_overdue_memos() + if overdue: + result += i18n.get('overdue_found', len(overdue)) + "\n" + for memo in overdue: + immediate_tag = "⚡" if memo.get('is_immediate') else "" + repeat_info = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f" (每{memo['repeat_interval']} {unit})" + else: + repeat_info = f" ({memo['repeat_type']})" + result += f" • {immediate_tag}[ID: {memo['id']}] {memo['title']}{repeat_info} - {memo['reminder_time']}\n" + else: + result += i18n.get('no_overdue') + "\n" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/tasks": + if self.ai_manager.scheduler: + stats = self.ai_manager.scheduler.get_stats() + result = f"\n{i18n.get('task_stats', stats.get('total_executions', 0), stats.get('total_executions', 0), stats.get('total_executions', 0))}\n\n" + + pending = self.ai_manager.memo_db.get_pending_tasks_for_user() + result += f"待执行任务: {len(pending)} 个\n" + for task in pending[:10]: + status = "⏰" if not task['is_overdue'] else "⚠️" + immediate_tag = "⚡" if task['is_immediate'] else "" + repeat_info = "" + if task['repeat_type'] != 'none': + if task['repeat_type'] == 'custom': + unit = task.get('repeat_interval_unit', 'days') + repeat_info = f" [每{task['repeat_interval']} {unit}]" + else: + repeat_info = f" [{task['repeat_type']}]" + result += f" {status}{immediate_tag} [ID: {task['id']}] {task['title']}{repeat_info} - {task['reminder_time']}\n" + else: + result = "❌ 任务调度器未启用" + await self.send_message(websocket, { + "type": "command_result", + "content": result + }) + + elif command == "/help": + help_text = f"\n可用命令:\n" + help_text += " /exit - 退出连接\n" + help_text += " /new - 开始新对话(保存当前对话,重置AI记忆,重新加载系统提示词)\n" + help_text += " /list - 列出所有对话\n" + help_text += " /memories - 查看最近的记忆\n" + help_text += " /memos - 查看待办任务\n" + help_text += " /search <关键词> - 搜索记忆\n" + help_text += " /config - 查看配置\n" + help_text += " /reload - 重新加载配置\n" + help_text += " /reload_prompts - 重新加载提示词\n" + help_text += " /check - 检查过期任务\n" + help_text += " /tasks - 查看任务状态\n" + help_text += " /help - 显示此帮助\n\n" + help_text += "任务类型:\n" + help_text += " {type: 'task', action: 'add', title: '任务', delay: 60} - 添加一次性任务\n" + help_text += " {type: 'task', action: 'add', title: '即时', is_immediate: true} - 添加即时任务(立即执行)\n" + help_text += " {type: 'task', action: 'add', title: '重复任务', repeat_type: 'custom', repeat_interval: 5, repeat_interval_unit: 'minutes'} - 每5分钟重复\n" + help_text += " {type: 'task', action: 'list'} - 获取任务列表\n" + help_text += " {type: 'task', action: 'complete', task_id: 1} - 完成任务\n" + await self.send_message(websocket, { + "type": "command_result", + "content": help_text + }) + + else: + # 如果不是命令,当作普通消息处理 + await self.handle_user_message(websocket, {"type": "message", "content": command}) + + async def handle_user_message(self, websocket, data): + """处理用户消息 - 实时流式输出""" + content = data.get("content", "").strip() + + if not content: + return + + self.logger.info(f"收到用户消息: {content[:50]}...") + + # 确保AI已初始化 + if not self.ai_manager.ai: + self.logger.info("初始化AI...") + self.ai_manager.add_ai() + + # 如果还没有活跃对话,开始新对话 + if not self.ai_manager.dialogue_in_progress: + self.logger.info("开始新对话") + self.ai_manager.dialogue_in_progress = True + self.ai_manager.history_manager.start_new_conversation(content[:50] + "...") + + # 发送对话开始消息 + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('conversation_start') + }) + + # 获取当前事件循环 + loop = asyncio.get_running_loop() + + # 定义输出回调函数 - 实时发送 + def output_callback(msg_type, content): + """AI输出的回调函数 - 实时发送每个chunk""" + self.logger.info(f"输出回调被调用: {msg_type}, 内容长度: {len(content)}") + + # 创建发送消息的协程 + async def send_msg(): + try: + await websocket.send(json.dumps({ + "type": msg_type, + "content": content + }, ensure_ascii=False)) + self.logger.info(f"实时消息发送成功: {msg_type}") + except Exception as e: + self.logger.error(f"实时发送消息失败: {e}") + + # 提交到事件循环 + asyncio.run_coroutine_threadsafe(send_msg(), loop) + + # 让AI思考并回应 - 不等待完整响应,通过回调实时发送 + try: + self.logger.info("开始AI思考...") + + # 在线程池中运行AI思考,通过回调实时发送消息 + response = await loop.run_in_executor( + None, + self.ai_manager.ai.think_and_respond, + content, + output_callback # 传递回调函数,AI会在生成每个chunk时调用 + ) + + self.logger.info(f"AI思考完成,完整响应长度: {len(response) if response else 0}") + + # 检查对话是否结束 + if not self.ai_manager.ai.conversation_active: + self.logger.info("对话结束") + self.ai_manager.dialogue_in_progress = False + self.ai_manager.history_manager.end_current_conversation() + await self.send_message(websocket, { + "type": "info", + "content": i18n.get('conversation_ended') + }) + # 【修复】发送明确的对话结束信号 + await self.send_message(websocket, { + "type": "dialogue_ended", + "content": i18n.get('dialogue_ended') + }) + + except Exception as e: + self.logger.error(f"AI处理消息失败: {e}") + import traceback + traceback.print_exc() + try: + await websocket.send(json.dumps({ + "type": "error", + "content": f"{i18n.get('error_prefix')}{str(e)}" + }, ensure_ascii=False)) + except: + pass + + +# ==================== 对话管理器(修改版:添加重置对话功能) ==================== + +class EnhancedAIManager: + """增强的AI对话管理器 - 集成任务调度器""" + + def __init__(self, debug_level: int = None, load_history: bool = None, command_executor: CommandExecutor = None): + self.ai: Optional[DeepSeekChat] = None + self.dialogue_history: List[Dict] = [] + self.config = ConfigManager() + self.command_executor = command_executor or SubprocessCommandExecutor() + self.prompt_manager = PromptManager() # 创建提示词管理器 + self.scheduler: Optional[TaskScheduler] = None # 任务调度器 + + if debug_level is None: + debug_level = self.config.get("system.debug_level", 1) + + self.logger = Logger("Manager", debug_level) + self.running = True + + # 确保 ~/.aibox 目录存在 + os.makedirs(constants.SAVE_DIR, exist_ok=True) + # 确保 prompts 目录存在(PromptManager 会创建) + os.makedirs(constants.PROMPTS_DIR, exist_ok=True) + + self.memory_db = MemoryDatabase() + self.memo_db = MemoDatabase() + self.history_manager = ConversationHistory(memory_db=self.memory_db, load_history=load_history) + + self.dialogue_in_progress = False + self.ws_handler = None + + def global_exit_handler(self, reason: str, summary: str): + """对话结束处理器""" + self.logger.info(f"对话结束 - 原因: {reason}") + + self.history_manager.end_current_conversation(summary) + + if self.ai: + # 智能体主动结束对话后,重置对话状态但不清除系统提示词 + # 注意:这里只重置对话标记,不清除消息历史 + self.ai.conversation_active = True + # 但为了下次对话不加载旧的历史,我们清除非系统消息 + if self.ai.preserve_system: + system_messages = [msg for msg in self.ai.messages if msg["role"] == "system"] + self.ai.messages = system_messages + else: + self.ai.messages = [] + self.ai.history_loaded = False # 重置历史加载标记 + + self.dialogue_in_progress = False + self.logger.info("对话状态已重置,可以开始新对话") + + def reset_dialogue(self): + """重置对话 - 清除所有对话记忆,重新加载系统提示词""" + if self.ai: + # 完全重置AI状态,重新加载系统提示词 + self.ai.reset_conversation(reload_prompts=True) + self.dialogue_in_progress = False + self.history_manager.reset_conversation() + self.logger.info(i18n.get('dialogue_reset_complete')) + + def add_ai(self) -> DeepSeekChat: + """添加AI""" + ai = DeepSeekChat( + api_key=None, + max_history=constants.DEFAULT_MAX_HISTORY, + debug_level=self.logger.level, + exit_callback=self.global_exit_handler, + history_manager=self.history_manager, + memory_db=self.memory_db, + memo_db=self.memo_db, + command_executor=self.command_executor, + prompt_manager=self.prompt_manager + ) + + self.ai = ai + self.logger.info("AI已初始化") + return ai + + def show_welcome_message(self): + """显示欢迎信息""" + print("\n" + "="*70) + print(i18n.get('welcome_title')) + print("="*70) + + # 显示存储位置 + print(i18n.get('data_dir', constants.SAVE_DIR)) + print(i18n.get('skills_dir', constants.SKILLS_DIR)) + print(i18n.get('prompts_dir', constants.PROMPTS_DIR)) + + config_path = self.config._config_path + if os.path.exists(config_path): + print(i18n.get('config_file', config_path)) + + # 显示提示词数量 + prompt_count = self.prompt_manager.get_prompt_count() + if prompt_count > 0: + prompts = self.prompt_manager.get_prompts_list() + print(i18n.get('loaded_prompts', prompt_count)) + for prompt in prompts[:5]: + print(i18n.get('prompt_item', prompt['file'])) + if prompt_count > 5: + print(i18n.get('more_skills', prompt_count-5)) + + # 显示任务调度器状态 + if constants.SCHEDULER_ENABLED: + print(i18n.get('scheduler_started')) + + memo_stats = self.memo_db.get_memo_stats() + if memo_stats['total'] > 0: + print(f"\n{i18n.get('memo_stats')}") + print(i18n.get('total_memos', memo_stats['total'])) + print(i18n.get('pending_memos', memo_stats['pending'])) + if memo_stats['overdue'] > 0: + print(i18n.get('overdue_memos', memo_stats['overdue'])) + print(i18n.get('completed_memos', memo_stats['completed'])) + + # 显示待执行任务 + pending = self.memo_db.get_pending_tasks_for_user() + if pending: + print(f"\n{i18n.get('pending_memos_title')}") + for task in pending[:5]: + status = i18n.get('memo_overdue') if task['is_overdue'] else i18n.get('memo_pending') + immediate_tag = "⚡" if task['is_immediate'] else "" + repeat_info = "" + if task['repeat_type'] != 'none': + if task['repeat_type'] == 'custom': + unit = task.get('repeat_interval_unit', 'days') + repeat_info = f" [每{task['repeat_interval']} {unit}]" + else: + repeat_info = f" [{task['repeat_type']}]" + print(f" {status}{immediate_tag} [ID: {task['id']}] {task['title']}{repeat_info} - {task['reminder_time']}") + if len(pending) > 5: + print(i18n.get('more_skills', len(pending)-5)) + + # 显示已加载的技能(含简介) + skill_manager = getattr(getattr(self.ai, 'tool_registry', None), 'skill_manager', None) + if skill_manager and skill_manager.count() > 0: + all_skills = skill_manager.get_all() + print(i18n.get('loaded_skills', skill_manager.count())) + for s in all_skills[:5]: + print(f" • {s['name']} — {s['summary']}") + if skill_manager.count() > 5: + print(i18n.get('more_skills', skill_manager.count() - 5)) + + # 显示WebSocket服务器信息 + host = constants.WEBSOCKET_HOST + port = constants.WEBSOCKET_PORT + print(i18n.get('websocket_started', host, port)) + + async def handle_connection(self, websocket, path): + """处理WebSocket连接""" + self.logger.info(f"新连接: {websocket.remote_address}, path: {path}") + await self.ws_handler.register(websocket) + try: + async for message in websocket: + await self.ws_handler.handle_message(websocket, message) + except websockets.exceptions.ConnectionClosed as e: + self.logger.info(f"连接关闭: {e}") + except Exception as e: + self.logger.error(f"处理错误: {e}") + finally: + await self.ws_handler.unregister(websocket) + + async def websocket_server(self): + """启动WebSocket服务器并集成任务调度器""" + host = constants.WEBSOCKET_HOST + port = constants.WEBSOCKET_PORT + + self.ws_handler = WebSocketHandler(self) + + # 如果启用了任务调度器,创建并启动 + if constants.SCHEDULER_ENABLED: + self.scheduler = TaskScheduler(asyncio.get_running_loop(), self.logger) + self.scheduler.set_websocket_handler(self.ws_handler) + self.scheduler.set_ai_manager(self) # 设置AI管理器引用 + + async def handler(websocket): + await self.handle_connection(websocket, None) + + server = await websockets.serve(handler, host, port) + self.logger.info(i18n.get('websocket_started', host, port)) + + try: + if self.scheduler: + # 同时运行服务器和调度器 + await asyncio.gather( + server.wait_closed(), + self.scheduler.run(self.memo_db) + ) + else: + # 只运行服务器 + await server.wait_closed() + finally: + if self.scheduler: + self.scheduler.stop() + + def run(self): + """运行WebSocket服务器""" + self.add_ai() + self.show_welcome_message() + + # 检查过期任务 + overdue = self.memo_db.get_overdue_memos() + if overdue: + print(f"\n⏰ {i18n.get('overdue_found', len(overdue))}") + for memo in overdue[:5]: + immediate_tag = "⚡" if memo.get('is_immediate') else "" + repeat_info = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f" (每{memo['repeat_interval']} {unit})" + else: + repeat_info = f" ({memo['repeat_type']})" + print(f" • {immediate_tag}[ID: {memo['id']}] {memo['title']}{repeat_info} - {memo['reminder_time']}") + if len(overdue) > 5: + print(f" ... {i18n.get('more_skills', len(overdue)-5)}") + + # 运行WebSocket服务器 + try: + # 使用 asyncio.run() 运行服务器 + asyncio.run(self.websocket_server()) + except KeyboardInterrupt: + print(f"\n\n{i18n.get('user_interrupted')}") + self.history_manager.end_current_conversation() + except Exception as e: + print(f"\n{i18n.get('error_prefix')}{e}") + import traceback + traceback.print_exc() + + +# ==================== 命令行参数处理 ==================== + +def parse_args(): + """解析命令行参数""" + parser = argparse.ArgumentParser(description='单AI智能体系统 - WebSocket版本(所有任务都是对话任务)') + + parser.add_argument('--verbose', '-v', action='store_true', help='显示详细日志') + + parser.add_argument('--check-memo', action='store_true', help='立即检查到期任务并退出') + + parser.add_argument('--memo-add', type=str, help='添加任务,格式: "标题|内容|提醒时间|重复类型|重复间隔|单位"') + parser.add_argument('--memo-list', action='store_true', help='列出任务') + parser.add_argument('--memo-complete', type=int, help='完成指定ID的任务') + + parser.add_argument('-d', '--debug', type=int, choices=[0, 1, 2, 3], default=None, help='调试级别') + parser.add_argument('-c', '--config', type=str, help='指定配置文件路径') + + # 添加语言选项 + parser.add_argument('--lang', type=str, choices=['zh', 'en'], help='强制指定语言 (zh/en)') + + # WebSocket选项 + parser.add_argument('--host', type=str, help='WebSocket服务器主机') + parser.add_argument('--port', type=int, help='WebSocket服务器端口') + + # 调度器选项 + parser.add_argument('--no-scheduler', action='store_true', help='禁用任务调度器') + parser.add_argument('--scheduler-interval', type=float, default=1.0, help='调度器检查间隔(秒)') + + return parser.parse_args() + + +def create_command_executor(args): + """根据命令行参数创建命令执行器""" + # 现在只使用本地执行器 + return SubprocessCommandExecutor() + + +def handle_check_commands(manager): + """处理检查命令""" + args = parse_args() + + if args.check_memo: + print(i18n.get('memo_check_title')) + overdue = manager.memo_db.get_overdue_memos() + if overdue: + print(i18n.get('memo_check_found', len(overdue))) + for memo in overdue: + immediate_tag = "⚡" if memo.get('is_immediate') else "" + repeat_info = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f" (每{memo['repeat_interval']} {unit})" + else: + repeat_info = f" ({memo['repeat_type']})" + print(f" • {immediate_tag}[ID: {memo['id']}] {memo['title']}{repeat_info} (提醒: {memo['reminder_time']})") + else: + print(i18n.get('memo_check_none')) + return True + + return False + + +def handle_memo_commands(manager): + """处理备忘录命令""" + args = parse_args() + + if args.memo_list: + memos = manager.memo_db.get_all_memos(include_completed=False) + if not memos: + print("📭 " + i18n.get('memo_check_none')) + else: + print(i18n.get('memo_list_title', len(memos))) + for memo in memos: + overdue = "" + if memo['reminder_time']: + try: + reminder = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) + if reminder < datetime.now(): + overdue = " ⚠️" + except: + pass + immediate_tag = "⚡" if memo.get('is_immediate') else "" + repeat_info = "" + if memo['repeat_type'] != 'none': + if memo['repeat_type'] == 'custom': + unit = memo.get('repeat_interval_unit', 'days') + repeat_info = f" [每{memo['repeat_interval']} {unit}]" + else: + repeat_info = f" [{memo['repeat_type']}]" + print(f" {immediate_tag}[ID: {memo['id']}] {memo['title']}{repeat_info}{overdue}") + return True + + if args.memo_add: + try: + parts = args.memo_add.split('|') + title = parts[0] + content = parts[1] if len(parts) > 1 else "" + reminder = parts[2] if len(parts) > 2 else None + + # 解析重复参数 + repeat_type = parts[3] if len(parts) > 3 else "none" + repeat_interval = int(parts[4]) if len(parts) > 4 else 0 + repeat_interval_unit = parts[5] if len(parts) > 5 else "days" + + is_immediate = reminder == "immediate" # 如果提醒时间是"immediate",则创建即时任务 + + memo_id = manager.memo_db.add_memo( + title=title, + content=content, + reminder_time=None if is_immediate else reminder, + created_by="cli", + source="cli", + repeat_type=repeat_type, + repeat_interval=repeat_interval, + repeat_interval_unit=repeat_interval_unit, + is_immediate=is_immediate + ) + + repeat_msg = "" + if repeat_type != "none": + if repeat_type == "custom": + repeat_msg = f" (每{repeat_interval} {repeat_interval_unit})" + else: + repeat_msg = f" ({repeat_type})" + + print(i18n.get('memo_added', memo_id) + repeat_msg + (" (即时任务)" if is_immediate else "")) + except Exception as e: + print(i18n.get('memo_add_failed', e)) + return True + + if args.memo_complete: + if manager.memo_db.complete_memo(args.memo_complete): + print(i18n.get('memo_completed', args.memo_complete)) + else: + print(i18n.get('memo_complete_failed', args.memo_complete)) + return True + + return False + + +# ==================== 主程序 ==================== + +def main(): + """主程序入口""" + args = parse_args() + + # 如果命令行指定了语言,覆盖自动检测 + if args.lang: + os.environ["AI_LANGUAGE"] = args.lang + # 重新初始化i18n + global i18n + i18n = I18n() + + if args.config: + os.environ["AI_CONFIG_PATH"] = args.config + + config = ConfigManager() + + if args.debug is not None: + config._config["system"]["debug_level"] = args.debug + + if args.verbose: + config._config["system"]["debug_level"] = 3 + + # 覆盖WebSocket主机和端口 + if args.host: + config._config["system"]["websocket_host"] = args.host + if args.port: + config._config["system"]["websocket_port"] = args.port + + # 调度器设置 + if args.no_scheduler: + config._config["scheduler"]["enabled"] = False + if args.scheduler_interval: + config._config["scheduler"]["check_interval"] = args.scheduler_interval + + # 确保 ~/.aibox 目录存在 + os.makedirs(constants.SAVE_DIR, exist_ok=True) + + # 确保 skills 目录存在 + os.makedirs(constants.SKILLS_DIR, exist_ok=True) + + # 确保 prompts 目录存在 + os.makedirs(constants.PROMPTS_DIR, exist_ok=True) + + Logger.init_file_logging() + + # 创建命令执行器 - 现在只使用本地执行器 + command_executor = SubprocessCommandExecutor() + + manager = EnhancedAIManager( + debug_level=args.debug, + command_executor=command_executor + ) + + if handle_check_commands(manager): + return + + if handle_memo_commands(manager): + return + + # 运行WebSocket服务器 + manager.run() + + +if __name__ == "__main__": + main() diff --git a/aibox/requirements.txt b/aibox/requirements.txt new file mode 100644 index 0000000..2953e7e --- /dev/null +++ b/aibox/requirements.txt @@ -0,0 +1,16 @@ +# backend +websockets +requests +beautifulsoup4 + +# frontend +websockets>=11.0 +prompt-toolkit>=3.0.0 +rich>=13.0.0 +colorama>=0.4.6 + +# voice +SpeechRecognition>=3.10.0 +pyttsx3>=2.90 +pyaudio>=0.2.13 +# sudo apt install libportaudio2 portaudio19-dev diff --git a/aibox/skills/163mail.py b/aibox/skills/163mail.py new file mode 100755 index 0000000..0a9d0f4 --- /dev/null +++ b/aibox/skills/163mail.py @@ -0,0 +1,593 @@ +#!/usr/bin/env python3 +"""163邮箱工具 - 发送邮件、接收邮件、查看收件箱。""" + +import os +import sys +import json +import argparse +import imaplib +import smtplib +import email +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import decode_header +from email.utils import parseaddr +import time +from datetime import datetime, timedelta +import base64 +import re + +# ==================== 配置常量 ==================== + +CREDENTIALS_FILE = os.path.expanduser("~/.cert/163mail.json") +IMAP_SERVER = "imap.163.com" +IMAP_PORT = 993 +SMTP_SERVER = "smtp.163.com" +SMTP_PORT = 465 + + +# ==================== 凭证管理 ==================== + +def load_credentials(): + """加载163邮箱凭证""" + if not os.path.exists(CREDENTIALS_FILE): + return None, f"❌ 凭证文件不存在: {CREDENTIALS_FILE}\n请创建该文件,格式:{{\"username\": \"your_email@163.com\", \"password\": \"your_password\"}}" + + try: + with open(CREDENTIALS_FILE, 'r', encoding='utf-8') as f: + creds = json.load(f) + + username = creds.get('username') + password = creds.get('password') + + if not username or not password: + return None, "❌ 凭证文件格式错误,需要包含 username 和 password 字段" + + if '@' not in username: + username = f"{username}@163.com" + + return (username, password), None + except json.JSONDecodeError: + return None, f"❌ 凭证文件JSON格式错误: {CREDENTIALS_FILE}" + except Exception as e: + return None, f"❌ 读取凭证文件失败: {str(e)}" + + +# ==================== 邮件发送功能 ==================== + +def send_email(to_addrs, subject, body, cc_addrs=None, bcc_addrs=None, html=False): + """发送邮件""" + creds, error = load_credentials() + if error: + return False, error + + username, password = creds + + try: + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + + cc_list = [] + if cc_addrs: + cc_list = [cc_addrs] if isinstance(cc_addrs, str) else cc_addrs + + bcc_list = [] + if bcc_addrs: + bcc_list = [bcc_addrs] if isinstance(bcc_addrs, str) else bcc_addrs + + msg = MIMEMultipart('alternative') + msg['From'] = username + msg['To'] = ', '.join(to_addrs) + msg['Subject'] = subject + msg['Date'] = email.utils.formatdate(localtime=True) + + if cc_list: + msg['Cc'] = ', '.join(cc_list) + + if html: + msg.attach(MIMEText(body, 'html', 'utf-8')) + else: + msg.attach(MIMEText(body, 'plain', 'utf-8')) + + all_recipients = to_addrs + cc_list + bcc_list + + with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=30) as server: + server.login(username, password) + server.sendmail(username, all_recipients, msg.as_string()) + + result = f"✅ 邮件发送成功!\n" + result += f" 📤 发件人: {username}\n" + result += f" 📥 收件人: {', '.join(to_addrs)}\n" + result += f" 📧 主题: {subject}\n" + if cc_list: + result += f" 👥 抄送: {', '.join(cc_list)}\n" + result += f" 📏 正文长度: {len(body)} 字符" + + return True, result + + except smtplib.SMTPAuthenticationError: + return False, "❌ SMTP认证失败,请检查邮箱密码。\n注意:163邮箱需要使用授权码而不是登录密码" + except smtplib.SMTPException as e: + return False, f"❌ SMTP错误: {str(e)}" + except Exception as e: + return False, f"❌ 发送邮件失败: {str(e)}" + + +# ==================== 邮件接收功能 ==================== + +def decode_mime_header(header): + """解码MIME邮件头""" + if header is None: + return "" + + decoded_parts = decode_header(header) + result = [] + for content, charset in decoded_parts: + if isinstance(content, bytes): + if charset: + try: + result.append(content.decode(charset)) + except: + result.append(content.decode('utf-8', errors='ignore')) + else: + try: + result.append(content.decode('utf-8', errors='ignore')) + except: + result.append(content.decode('gbk', errors='ignore')) + else: + result.append(content) + return ' '.join(result) + + +def get_email_body(msg): + """获取邮件正文""" + body = "" + html_body = "" + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition")) + + if "attachment" in content_disposition: + continue + + try: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or 'utf-8' + try: + decoded = payload.decode(charset, errors='ignore') + except: + decoded = payload.decode('utf-8', errors='ignore') + + if content_type == "text/plain": + body += decoded + elif content_type == "text/html": + html_body += decoded + except: + continue + else: + content_type = msg.get_content_type() + try: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or 'utf-8' + decoded = payload.decode(charset, errors='ignore') + if content_type == "text/plain": + body = decoded + elif content_type == "text/html": + html_body = decoded + except: + pass + + return body if body else html_body + + +def fetch_emails(limit=10, folder="INBOX", mark_seen=False, since_days=None): + """获取邮件列表""" + creds, error = load_credentials() + if error: + return False, error + + username, password = creds + + try: + imap = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT, timeout=30) + imap.login(username, password) + + status, messages = imap.select(folder) + if status != 'OK': + imap.close() + imap.logout() + return False, f"❌ 无法打开邮件夹 {folder}" + + search_criteria = 'ALL' + if since_days: + date = (datetime.now() - timedelta(days=since_days)).strftime("%d-%b-%Y") + search_criteria = f'SINCE "{date}"' + + status, message_ids = imap.search(None, search_criteria) + if status != 'OK': + imap.close() + imap.logout() + return False, "❌ 搜索邮件失败" + + msg_ids = message_ids[0].split() + total = len(msg_ids) + + if total == 0: + imap.close() + imap.logout() + return True, f"📭 邮件夹 '{folder}' 中没有邮件" + + start_idx = max(0, total - limit) + msg_ids_to_fetch = msg_ids[start_idx:] + + emails = [] + for i, msg_id in enumerate(reversed(msg_ids_to_fetch)): + try: + status, msg_data = imap.fetch(msg_id, '(RFC822)') + if status != 'OK': + continue + + email_body = msg_data[0][1] + msg = email.message_from_bytes(email_body) + + subject = decode_mime_header(msg.get('Subject', '无主题')) + from_addr = decode_mime_header(msg.get('From', '未知发件人')) + to_addr = decode_mime_header(msg.get('To', '未知收件人')) + date_str = msg.get('Date', '') + + try: + date_tuple = email.utils.parsedate_tz(date_str) + if date_tuple: + date = datetime.fromtimestamp(email.utils.mktime_tz(date_tuple)) + date_formatted = date.strftime("%Y-%m-%d %H:%M:%S") + else: + date_formatted = date_str + except: + date_formatted = date_str + + is_seen = '\\Seen' in msg.get('Flags', '') + body = get_email_body(msg) + preview = body[:200] + "..." if len(body) > 200 else body + + name, email_addr = parseaddr(from_addr) + + emails.append({ + 'id': msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id), + 'index': i + 1, + 'subject': subject, + 'from': from_addr, + 'from_name': name or email_addr, + 'from_email': email_addr, + 'to': to_addr, + 'date': date_formatted, + 'preview': preview, + 'is_seen': is_seen, + 'has_attachments': msg.get_content_maintype() == 'multipart' and any(part.get_filename() for part in msg.walk()) + }) + + if not mark_seen and not is_seen: + imap.store(msg_id, '-FLAGS', '\\Seen') + + except Exception as e: + continue + + imap.close() + imap.logout() + + result = f"📨 邮件夹 '{folder}' 中共有 {total} 封邮件,显示最新 {len(emails)} 封:\n\n" + + for email_data in emails: + seen_mark = " " if email_data['is_seen'] else "●" + attach_mark = " 📎" if email_data.get('has_attachments') else "" + result += f"{seen_mark} [{email_data['index']}] {email_data['date']}\n" + result += f" 📧 主题: {email_data['subject']}{attach_mark}\n" + result += f" 👤 发件人: {email_data['from']}\n" + result += f" 📝 预览: {email_data['preview']}\n\n" + + return True, result + + except Exception as e: + return False, f"❌ 获取邮件失败: {str(e)}" + + +def read_email(msg_id, folder="INBOX", mark_seen=True): + """读取指定邮件内容""" + creds, error = load_credentials() + if error: + return False, error + + username, password = creds + + try: + imap = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT, timeout=30) + imap.login(username, password) + + status, messages = imap.select(folder) + if status != 'OK': + imap.close() + imap.logout() + return False, f"❌ 无法打开邮件夹 {folder}" + + status, msg_data = imap.fetch(str(msg_id).encode(), '(RFC822)') + if status != 'OK': + imap.close() + imap.logout() + return False, f"❌ 无法获取邮件 ID: {msg_id}" + + email_body = msg_data[0][1] + msg = email.message_from_bytes(email_body) + + subject = decode_mime_header(msg.get('Subject', '无主题')) + from_addr = decode_mime_header(msg.get('From', '未知发件人')) + to_addr = decode_mime_header(msg.get('To', '未知收件人')) + cc_addr = decode_mime_header(msg.get('Cc', '')) + date_str = msg.get('Date', '') + + try: + date_tuple = email.utils.parsedate_tz(date_str) + if date_tuple: + date = datetime.fromtimestamp(email.utils.mktime_tz(date_tuple)) + date_formatted = date.strftime("%Y-%m-%d %H:%M:%S") + else: + date_formatted = date_str + except: + date_formatted = date_str + + body = get_email_body(msg) + + attachments = [] + if msg.is_multipart(): + for part in msg.walk(): + filename = part.get_filename() + if filename: + filename = decode_mime_header(filename) + size = len(part.get_payload(decode=True) or b'') + attachments.append({'name': filename, 'size': size}) + + if not mark_seen: + imap.store(str(msg_id).encode(), '-FLAGS', '\\Seen') + + imap.close() + imap.logout() + + result = f"📧 邮件详情 (ID: {msg_id})\n" + result += f"{'='*50}\n" + result += f"📅 日期: {date_formatted}\n" + result += f"📧 主题: {subject}\n" + result += f"👤 发件人: {from_addr}\n" + result += f"📥 收件人: {to_addr}\n" + if cc_addr: + result += f"👥 抄送: {cc_addr}\n" + result += f"{'='*50}\n" + result += f"📝 正文内容:\n{body}\n" + + if attachments: + result += f"\n📎 附件 ({len(attachments)}):\n" + for att in attachments: + size_str = f"{att['size']/1024:.1f}KB" if att['size'] > 0 else "0KB" + result += f" - {att['name']} ({size_str})\n" + + return True, result + + except Exception as e: + return False, f"❌ 读取邮件失败: {str(e)}" + + +def list_folders(): + """列出所有邮件夹""" + creds, error = load_credentials() + if error: + return False, error + + username, password = creds + + try: + imap = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT, timeout=30) + imap.login(username, password) + + status, folders = imap.list() + if status != 'OK': + imap.close() + imap.logout() + return False, "❌ 无法获取邮件夹列表" + + folder_list = [] + for folder_info in folders: + folder_str = folder_info.decode() + parts = folder_str.split(' "/" ') + if len(parts) > 1: + folder_name = parts[1].strip('"') + folder_list.append(folder_name) + + imap.close() + imap.logout() + + result = "📂 邮件夹列表:\n" + for i, folder in enumerate(folder_list, 1): + result += f" {i}. {folder}\n" + + return True, result + + except Exception as e: + return False, f"❌ 获取邮件夹列表失败: {str(e)}" + + +def get_email_stats(): + """获取邮箱统计信息""" + creds, error = load_credentials() + if error: + return False, error + + username, password = creds + + try: + imap = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT, timeout=30) + imap.login(username, password) + + stats = {} + total_unseen = 0 + folders = ['INBOX', 'Sent Messages', 'Drafts', 'Trash', 'Spam'] + + for folder in folders: + try: + status, messages = imap.select(folder) + if status == 'OK': + status, msg_ids = imap.search(None, 'ALL') + if status == 'OK': + count = len(msg_ids[0].split()) + stats[folder] = count + + status, unseen_ids = imap.search(None, 'UNSEEN') + if status == 'OK' and folder == 'INBOX': + total_unseen = len(unseen_ids[0].split()) + except: + continue + + imap.close() + imap.logout() + + result = f"📊 邮箱统计 ({username})\n" + result += f"{'='*50}\n" + result += f"📥 收件箱: {stats.get('INBOX', 0)} 封 (未读: {total_unseen})\n" + result += f"📤 已发送: {stats.get('Sent Messages', 0)} 封\n" + result += f"📝 草稿箱: {stats.get('Drafts', 0)} 封\n" + result += f"🗑️ 已删除: {stats.get('Trash', 0)} 封\n" + result += f"⚠️ 垃圾邮件: {stats.get('Spam', 0)} 封\n" + result += f"{'='*50}\n" + + return True, result + + except Exception as e: + return False, f"❌ 获取统计信息失败: {str(e)}" + + +# ==================== 命令行接口 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="163邮箱工具 - 发送邮件、接收邮件、查看收件箱。", + epilog=""" +使用示例: + # 发送邮件 + 163mail --action send --to "friend@example.com" --subject "Hello" --body "邮件内容" + + # 发送HTML邮件 + 163mail --action send --to "friend@example.com" --subject "Hello" --body "

标题

内容

" --html + + # 发送给多人,带抄送 + 163mail --action send --to "a@example.com,b@example.com" --cc "cc@example.com" --subject "主题" --body "内容" + + # 查看收件箱 + 163mail --action inbox --limit 5 + + # 查看最近7天的邮件 + 163mail --action inbox --since-days 7 + + # 读取指定邮件 + 163mail --action read --msg-id 1 + + # 列出所有邮件夹 + 163mail --action folders + + # 查看邮箱统计 + 163mail --action stats + +环境变量: + 凭证文件: ~/.cert/163mail.json + 格式: {"username": "your_email@163.com", "password": "your_password"} + +注意: + - 163邮箱需要使用授权码而不是登录密码 + - 授权码需要在163邮箱网页版设置中获取 + """ + ) + + parser.add_argument("--action", "-a", required=True, + choices=["send", "inbox", "read", "folders", "stats"], + help="操作类型") + + # 发送邮件参数 + parser.add_argument("--to", "-t", help="收件人邮箱地址,多个地址用逗号分隔") + parser.add_argument("--cc", help="抄送地址,多个地址用逗号分隔") + parser.add_argument("--bcc", help="密送地址,多个地址用逗号分隔") + parser.add_argument("--subject", "-s", help="邮件主题") + parser.add_argument("--body", "-b", help="邮件正文") + parser.add_argument("--html", action="store_true", help="是否HTML格式") + + # 接收邮件参数 + parser.add_argument("--limit", "-l", type=int, default=10, help="获取邮件数量(默认10)") + parser.add_argument("--folder", "-f", default="INBOX", help="邮件夹名称(默认INBOX)") + parser.add_argument("--msg-id", type=int, help="邮件ID(用于read操作)") + parser.add_argument("--since-days", type=int, help="获取最近几天的邮件") + parser.add_argument("--mark-seen", action="store_true", help="是否标记为已读") + + args = parser.parse_args() + + # 参数验证 + if args.action == "send": + if not args.to or not args.subject or not args.body: + print("❌ 发送邮件需要提供 --to, --subject, --body 参数") + sys.exit(1) + + if args.action == "read" and not args.msg_id: + print("❌ 读取邮件需要提供 --msg-id 参数") + sys.exit(1) + + # 执行操作 + try: + if args.action == "send": + to_list = [addr.strip() for addr in args.to.split(',') if addr.strip()] + cc_list = [addr.strip() for addr in args.cc.split(',') if addr.strip()] if args.cc else None + bcc_list = [addr.strip() for addr in args.bcc.split(',') if addr.strip()] if args.bcc else None + + success, message = send_email( + to_addrs=to_list, + subject=args.subject, + body=args.body, + cc_addrs=cc_list, + bcc_addrs=bcc_list, + html=args.html + ) + print(message) + sys.exit(0 if success else 1) + + elif args.action == "inbox": + success, message = fetch_emails( + limit=args.limit, + folder=args.folder, + mark_seen=args.mark_seen, + since_days=args.since_days + ) + print(message) + sys.exit(0 if success else 1) + + elif args.action == "read": + success, message = read_email( + msg_id=args.msg_id, + folder=args.folder, + mark_seen=args.mark_seen + ) + print(message) + sys.exit(0 if success else 1) + + elif args.action == "folders": + success, message = list_folders() + print(message) + sys.exit(0 if success else 1) + + elif args.action == "stats": + success, message = get_email_stats() + print(message) + sys.exit(0 if success else 1) + + except Exception as e: + print(f"❌ 执行错误: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/skills/ads1115.py b/aibox/skills/ads1115.py new file mode 100755 index 0000000..3b08f97 --- /dev/null +++ b/aibox/skills/ads1115.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +ADS1115: Linux I2C 读取 TI ADS1115 16位 ADC 的命令行工具 +""" + +import argparse +import sys +import time + +# ============================================================ +# ADS1115 寄存器地址 +# ============================================================ +ADS1115_REG_CONVERSION = 0x00 +ADS1115_REG_CONFIG = 0x01 +ADS1115_REG_LO_THRESH = 0x02 +ADS1115_REG_HI_THRESH = 0x03 + +# ============================================================ +# 配置寄存器 bit 定义 (16 位) +# ============================================================ + +# --- MUX 输入通道 --- +ADS1115_MUX_AIN0_AIN1 = 0x0000 # 差分: P=AIN0, N=AIN1 (默认) +ADS1115_MUX_AIN0_AIN3 = 0x1000 # 差分: P=AIN0, N=AIN3 +ADS1115_MUX_AIN1_AIN3 = 0x2000 # 差分: P=AIN1, N=AIN3 +ADS1115_MUX_AIN2_AIN3 = 0x3000 # 差分: P=AIN2, N=AIN3 +ADS1115_MUX_AIN0_GND = 0x4000 # 单端: AIN0 +ADS1115_MUX_AIN1_GND = 0x5000 # 单端: AIN1 +ADS1115_MUX_AIN2_GND = 0x6000 # 单端: AIN2 +ADS1115_MUX_AIN3_GND = 0x7000 # 单端: AIN3 + +# --- PGA 增益 (满量程范围) --- +ADS1115_PGA_6_144V = 0x0000 # ±6.144V (1LSB=0.1875mV) +ADS1115_PGA_4_096V = 0x0200 # ±4.096V (1LSB=0.125mV) +ADS1115_PGA_2_048V = 0x0400 # ±2.048V (1LSB=0.0625mV) (默认) +ADS1115_PGA_1_024V = 0x0600 # ±1.024V (1LSB=0.03125mV) +ADS1115_PGA_0_512V = 0x0800 # ±0.512V (1LSB=0.015625mV) +ADS1115_PGA_0_256V = 0x0A00 # ±0.256V (1LSB=0.0078125mV) + +PGA_VALUES = [ADS1115_PGA_6_144V, ADS1115_PGA_4_096V, ADS1115_PGA_2_048V, + ADS1115_PGA_1_024V, ADS1115_PGA_0_512V, ADS1115_PGA_0_256V] +PGA_FS = [6.144, 4.096, 2.048, 1.024, 0.512, 0.256] +PGA_NAMES = ["±6.144V", "±4.096V", "±2.048V", "±1.024V", "±0.512V", "±0.256V"] + +# --- 数据速率 --- +ADS1115_DR_8SPS = 0x0000 # 8 SPS +ADS1115_DR_16SPS = 0x0020 # 16 SPS +ADS1115_DR_32SPS = 0x0040 # 32 SPS +ADS1115_DR_64SPS = 0x0060 # 64 SPS +ADS1115_DR_128SPS = 0x0080 # 128 SPS (默认) +ADS1115_DR_250SPS = 0x00A0 # 250 SPS +ADS1115_DR_475SPS = 0x00C0 # 475 SPS +ADS1115_DR_860SPS = 0x00E0 # 860 SPS + +DR_VALUES = [ADS1115_DR_8SPS, ADS1115_DR_16SPS, ADS1115_DR_32SPS, + ADS1115_DR_64SPS, ADS1115_DR_128SPS, ADS1115_DR_250SPS, + ADS1115_DR_475SPS, ADS1115_DR_860SPS] +DR_NAMES = ["8 SPS", "16 SPS", "32 SPS", "64 SPS", + "128 SPS", "250 SPS", "475 SPS", "860 SPS"] + +# --- 比较器 --- +ADS1115_COMP_QUE_DISABLE = 0x0003 + +# --- 操作模式 --- +ADS1115_MODE_CONTINUOUS = 0x0000 +ADS1115_MODE_SINGLE = 0x0100 + +# --- 启动转换 --- +ADS1115_OS_START = 0x8000 + + +class ADS1115: + """ADS1115 16位ADC驱动""" + + def __init__(self, bus=1, address=0x48, pga_idx=2, dr_idx=4): + """ + 初始化 ADS1115 + + :param bus: I2C 总线号 (默认1) + :param address: I2C 设备地址 (默认0x48) + :param pga_idx: PGA增益索引 0~5 (默认2 = ±2.048V) + :param dr_idx: 数据速率索引 0~7 (默认4 = 128SPS) + """ + self.address = address + self.pga_idx = pga_idx if 0 <= pga_idx <= 5 else 2 + self.dr_idx = dr_idx if 0 <= dr_idx <= 7 else 4 + self.fs_voltage = PGA_FS[self.pga_idx] + self.config = 0 + + # 初始化 I2C + try: + from smbus2 import SMBus + self.bus = SMBus(bus) + except ImportError: + print("错误: 需要 smbus2 库,请执行: pip install smbus2", file=sys.stderr) + sys.exit(1) + except FileNotFoundError: + print(f"错误: I2C 总线 /dev/i2c-{bus} 不存在", file=sys.stderr) + print("提示: 确保已启用 I2C (sudo raspi-config -> Interface Options -> I2C)", file=sys.stderr) + sys.exit(1) + except PermissionError: + print(f"错误: 无法访问 I2C 总线 /dev/i2c-{bus},请将用户加入 i2c 组", file=sys.stderr) + print(" sudo usermod -aG i2c $USER", file=sys.stderr) + sys.exit(1) + + # 初始化配置 + self._init_device() + + def _write_word(self, reg, value): + """向寄存器写入2字节 (大端)""" + high = (value >> 8) & 0xFF + low = value & 0xFF + self.bus.write_i2c_block_data(self.address, reg, [high, low]) + + def _read_word(self, reg): + """从寄存器读取2字节 (大端)""" + data = self.bus.read_i2c_block_data(self.address, reg, 2) + return (data[0] << 8) | data[1] + + def _init_device(self): + """初始化 ADS1115 配置""" + self.config = (ADS1115_MUX_AIN0_GND | + PGA_VALUES[self.pga_idx] | + ADS1115_MODE_SINGLE | + DR_VALUES[self.dr_idx] | + ADS1115_COMP_QUE_DISABLE) + self._write_word(ADS1115_REG_CONFIG, self.config) + + def read_single(self, channel): + """ + 读取单端通道 (单次转换) + + :param channel: 通道号 0~3 (对应 AIN0~AIN3) + :return: 电压值 (V) + """ + if channel < 0 or channel > 3: + raise ValueError(f"通道号无效: {channel} (有效: 0~3)") + + # 设置通道并启动转换 + mux = ADS1115_MUX_AIN0_GND | (channel << 12) # AINch-GND + cfg = (self.config & ~0x7000) | mux | ADS1115_OS_START + self._write_word(ADS1115_REG_CONFIG, cfg) + self.config = cfg + + # 等待转换完成 + wait_ms = 1000 // (8 << self.dr_idx) + 3 + time.sleep(wait_ms / 1000.0) + + # 读取转换值 + raw = self._read_word(ADS1115_REG_CONVERSION) + raw_signed = raw if raw < 32768 else raw - 65536 + + # 转换为电压: V = raw * FS_VOLTAGE / 32768 + voltage = raw_signed * self.fs_voltage / 32768.0 + return voltage + + def read_diff(self, pos, neg): + """ + 读取差分通道 (单次转换) + + 支持组合: (0,1), (0,3), (1,3), (2,3) + :param pos: 正极通道 (0~3) + :param neg: 负极通道 (0~3) + :return: 电压值 (V) + """ + mux_map = { + (0, 1): ADS1115_MUX_AIN0_AIN1, + (0, 3): ADS1115_MUX_AIN0_AIN3, + (1, 3): ADS1115_MUX_AIN1_AIN3, + (2, 3): ADS1115_MUX_AIN2_AIN3, + } + key = (pos, neg) + if key not in mux_map: + raise ValueError(f"不支持的差分组合: AIN{pos} - AIN{neg},支持: 0-1, 0-3, 1-3, 2-3") + + mux = mux_map[key] + cfg = (self.config & ~0x7000) | mux | ADS1115_OS_START + self._write_word(ADS1115_REG_CONFIG, cfg) + self.config = cfg + + # 等待转换完成 + wait_ms = 1000 // (8 << self.dr_idx) + 3 + time.sleep(wait_ms / 1000.0) + + raw = self._read_word(ADS1115_REG_CONVERSION) + raw_signed = raw if raw < 32768 else raw - 65536 + + voltage = raw_signed * self.fs_voltage / 32768.0 + return voltage + + def start_continuous(self, channel): + """启动连续模式""" + if channel < 0 or channel > 3: + raise ValueError(f"通道号无效: {channel}") + cfg = (self.config & ~0x7100 | + (ADS1115_MUX_AIN0_GND | (channel << 12)) | + ADS1115_MODE_CONTINUOUS | + ADS1115_COMP_QUE_DISABLE) + self._write_word(ADS1115_REG_CONFIG, cfg) + self.config = cfg + + def read_continuous(self): + """连续模式读数""" + raw = self._read_word(ADS1115_REG_CONVERSION) + raw_signed = raw if raw < 32768 else raw - 65536 + return raw_signed * self.fs_voltage / 32768.0 + + def scan_all(self): + """扫描所有单端通道""" + results = {} + ch_names = ["AIN0", "AIN1", "AIN2", "AIN3"] + for ch in range(4): + v = self.read_single(ch) + results[ch_names[ch]] = v + return results + + def print_config(self): + """打印配置寄存器详情""" + config = self._read_word(ADS1115_REG_CONFIG) + mux = (config >> 12) & 0x07 + pga = (config >> 9) & 0x07 + mode = (config >> 8) & 0x01 + dr = (config >> 5) & 0x07 + comp = config & 0x03 + + mux_names = { + 0: "AIN0-AIN1 (差分)", + 1: "AIN0-AIN3 (差分)", + 2: "AIN1-AIN3 (差分)", + 3: "AIN2-AIN3 (差分)", + 4: "AIN0-GND (单端)", + 5: "AIN1-GND (单端)", + 6: "AIN2-GND (单端)", + 7: "AIN3-GND (单端)", + } + + print(f"\n===== ADS1115 寄存器 =====") + print(f" 配置寄存器: 0x{config:04X}") + print(f" MUX: {mux} -> {mux_names.get(mux, '未知')}") + if pga <= 5: + print(f" PGA: {pga} -> {PGA_NAMES[pga]} (满量程 {PGA_FS[pga]:.3f}V)") + else: + print(f" PGA: {pga} -> 未知") + print(f" 模式: {'单次' if mode else '连续'}") + if dr <= 7: + print(f" 速率: {dr} -> {DR_NAMES[dr]}") + else: + print(f" 速率: {dr} -> 未知") + print(f" 比较器: {'禁用' if comp == 3 else '启用'}") + + # 读取阈值 + lo_raw = self._read_word(ADS1115_REG_LO_THRESH) + hi_raw = self._read_word(ADS1115_REG_HI_THRESH) + lo_signed = lo_raw if lo_raw < 32768 else lo_raw - 65536 + hi_signed = hi_raw if hi_raw < 32768 else hi_raw - 65536 + lo_v = lo_signed * self.fs_voltage / 32768.0 + hi_v = hi_signed * self.fs_voltage / 32768.0 + print(f" 阈值: LO={lo_signed} ({lo_v:.6f}V), HI={hi_signed} ({hi_v:.6f}V)") + + def close(self): + """关闭 I2C 总线""" + self.bus.close() + + +def main(): + parser = argparse.ArgumentParser( + description="Linux I2C 读取 TI ADS1115 16位 ADC 的命令行工具", + epilog=""" +参数说明: + -b, --i2c-bus I2C 总线号 (默认 1, 对应 /dev/i2c-1) + -a, --address I2C 设备地址 (默认 0x48) + -p, --pga PGA 增益索引 0~5 (默认 2=±2.048V) + 0=±6.144V, 1=±4.096V, 2=±2.048V + 3=±1.024V, 4=±0.512V, 5=±0.256V + -r, --rate 数据速率索引 0~7 (默认 4=128SPS) + 0=8, 1=16, 2=32, 3=64 + 4=128, 5=250, 6=475, 7=860 (SPS) + -c, --channel 单端通道号 0~3 (读取 AIN0~AIN3) + --diff 差分模式,格式 "pos,neg" 如 "0,1" + --scan 扫描所有单端通道 + --continuous 连续读取模式 + -n, --count 读取次数 (默认 1) + --delay 每次读取间隔毫秒 (默认 10ms) + --config 显示配置寄存器详情 + + --percent 输出基于 0-5V 范围的百分比 (0.00%~100.00%) + --map 将 0-5V 百分比映射到自定义数值区间,格式 "min,max" + 例: --map -20,80 表示 0V=-20, 5V=80 + 使用 --map 时自动启用百分比显示 + +使用示例: + ads1115 # 读取 AIN0 (默认参数) + ads1115 -c 2 # 读取 AIN2 + ads1115 --scan # 扫描所有单端通道 + ads1115 -c 0 -n 5 # 连续读 AIN0 5次 + ads1115 --diff 0,1 # 差分模式 AIN0-AIN1 + ads1115 -a 0x49 -p 1 -r 6 # 指定地址、PGA、速率 + ads1115 -c 0 --continuous -n 10 # 连续模式读10次 + ads1115 --config # 查看配置寄存器 + ads1115 --scan --config # 扫描并查看配置 + ads1115 -c 0 --percent # 读取 AIN0 并显示 0-5V 百分比 + ads1115 --scan --percent # 扫描所有通道并显示百分比 + ads1115 -c 0 --map -20,80 # 将 0-5V 映射到 -20~80 区间 + ads1115 --scan --map 0,100 # 扫描并映射到 0~100 区间 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-b", "--i2c-bus", type=int, default=1, help="I2C 总线号 (默认 1)") + parser.add_argument("-a", "--address", type=lambda x: int(x, 0), default=0x48, help="I2C 设备地址 (默认 0x48)") + parser.add_argument("-p", "--pga", type=int, default=2, choices=range(0, 6), metavar="{0..5}", help="PGA 增益索引 0~5 (默认 2)") + parser.add_argument("-r", "--rate", type=int, default=4, choices=range(0, 8), metavar="{0..7}", help="数据速率索引 0~7 (默认 4)") + parser.add_argument("-c", "--channel", type=int, default=None, choices=range(0, 4), metavar="{0..3}", help="单端通道号 0~3") + parser.add_argument("--diff", type=str, default=None, help='差分模式,格式 "pos,neg" 如 "0,1"') + parser.add_argument("--scan", action="store_true", help="扫描所有单端通道") + parser.add_argument("--continuous", action="store_true", help="连续读取模式") + parser.add_argument("-n", "--count", type=int, default=1, help="读取次数 (默认 1)") + parser.add_argument("--delay", type=int, default=10, help="每次读取间隔毫秒 (默认 10ms)") + parser.add_argument("--config", action="store_true", help="显示配置寄存器详情") + parser.add_argument("--percent", action="store_true", help="输出基于 0-5V 范围的百分比") + parser.add_argument("--map", type=str, default=None, metavar="MIN,MAX", + help='将 0-5V 百分比映射到数值区间,格式 "min,max" 如 "-20,80"') + + args = parser.parse_args() + + # --- 解析 --map 参数 --- + map_range = None + if args.map is not None: + try: + parts = args.map.split(",") + if len(parts) != 2: + raise ValueError + map_min, map_max = float(parts[0]), float(parts[1]) + if map_min == map_max: + raise ValueError("映射区间两端不能相同") + map_range = (map_min, map_max) + args.percent = True # --map 自动启用百分比显示 + except ValueError as e: + err_msg = str(e) if str(e) != "" else "格式无效" + print(f"错误: --map 参数无效 '{args.map}',请使用 'min,max' 格式,如 '-20,80' ({err_msg})", file=sys.stderr) + sys.exit(1) + + try: + adc = ADS1115(bus=args.i2c_bus, address=args.address, + pga_idx=args.pga, dr_idx=args.rate) + + def pct(v): + """返回 0-5V 百分比/映射后缀字符串""" + if not args.percent: + return "" + p = v / 5.0 * 100.0 + suffix = f" [{p:6.2f}%]" + if map_range is not None: + mapped = map_range[0] + (p / 100.0) * (map_range[1] - map_range[0]) + suffix += f" (映射: {mapped:.2f})" + return suffix + + has_action = False + + # --- 单端通道读取 --- + if args.channel is not None and args.diff is None: + has_action = True + ch_names = ["AIN0", "AIN1", "AIN2", "AIN3"] + ch_name = ch_names[args.channel] + + if args.continuous: + print(f"连续模式读取 {ch_name} ({args.count} 次):") + adc.start_continuous(args.channel) + time.sleep(0.05) + for i in range(args.count): + v = adc.read_continuous() + print(f" [{i}] {ch_name}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}") + if i < args.count - 1: + time.sleep(args.delay / 1000.0) + else: + print(f"读取 {ch_name} ({args.count} 次):") + for i in range(args.count): + v = adc.read_single(args.channel) + print(f" [{i}] {ch_name}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}") + if i < args.count - 1: + time.sleep(args.delay / 1000.0) + + # --- 差分模式 --- + if args.diff is not None: + has_action = True + try: + parts = args.diff.split(",") + if len(parts) != 2: + raise ValueError + pos, neg = int(parts[0]), int(parts[1]) + except (ValueError, IndexError): + print(f"错误: 差分格式无效 '{args.diff}',请使用 'pos,neg' 格式,如 '0,1'", file=sys.stderr) + sys.exit(1) + + print(f"差分读取 AIN{pos} - AIN{neg} ({args.count} 次):") + for i in range(args.count): + v = adc.read_diff(pos, neg) + print(f" [{i}] AIN{pos}-AIN{neg}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}") + if i < args.count - 1: + time.sleep(args.delay / 1000.0) + + # --- 扫描所有单端通道 --- + if args.scan: + has_action = True + print("\n===== 单端通道扫描 =====") + results = adc.scan_all() + for ch, v in results.items(): + print(f" {ch}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}") + + # --- 显示配置 --- + if args.config: + has_action = True + adc.print_config() + + # --- 默认行为: 读 AIN0 --- + if not has_action: + v = adc.read_single(0) + print(f"AIN0: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}") + + except KeyboardInterrupt: + print("\n用户中断") + sys.exit(0) + except Exception as e: + print(f"错误: {e}", file=sys.stderr) + sys.exit(1) + finally: + if 'adc' in locals(): + adc.close() + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/ads1256.py b/aibox/skills/ads1256.py new file mode 100755 index 0000000..7f2c3f1 --- /dev/null +++ b/aibox/skills/ads1256.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +ADS1256: Linux SPI 读取 TI ADS1256 24位 ΔΣ ADC 的命令行工具 +""" + +import spidev +import os +import sys +import argparse +import time + + +# ============================================================ +# ADS1256 寄存器地址 +# ============================================================ +ADS1256_REG_STATUS = 0x00 +ADS1256_REG_MUX = 0x01 +ADS1256_REG_ADCON = 0x02 +ADS1256_REG_DRATE = 0x03 +ADS1256_REG_IO = 0x04 +ADS1256_REG_OFC0 = 0x05 +ADS1256_REG_OFC1 = 0x06 +ADS1256_REG_OFC2 = 0x07 +ADS1256_REG_FSC0 = 0x08 +ADS1256_REG_FSC1 = 0x09 +ADS1256_REG_FSC2 = 0x0A + +# ============================================================ +# ADS1256 命令 +# ============================================================ +ADS1256_CMD_WAKEUP = 0x00 +ADS1256_CMD_RDATA = 0x01 +ADS1256_CMD_RDATAC = 0x03 +ADS1256_CMD_SDATAC = 0x0F +ADS1256_CMD_RREG = 0x10 +ADS1256_CMD_WREG = 0x50 +ADS1256_CMD_SELFCAL = 0xF0 +ADS1256_CMD_SELFOCAL = 0xF1 +ADS1256_CMD_SELFGCAL = 0xF2 +ADS1256_CMD_SYSOCAL = 0xF3 +ADS1256_CMD_SYSGCAL = 0xF4 +ADS1256_CMD_SYNC = 0xFC +ADS1256_CMD_STANDBY = 0xFD +ADS1256_CMD_RESET = 0xFE + +# ============================================================ +# 数据速率 (DRATE 寄存器值) +# ============================================================ +DRATE_MAP = { + 30000: 0xF0, 15000: 0xE0, 7500: 0xD0, 3750: 0xC0, + 2000: 0xB0, 1000: 0xA0, 500: 0x90, 100: 0x80, + 60: 0x70, 50: 0x60, 30: 0x50, 25: 0x40, + 15: 0x30, 10: 0x20, 5: 0x10, +} + +# ============================================================ +# PGA 增益 +# ============================================================ +PGA_MAP = {1: 0x00, 2: 0x01, 4: 0x02, 8: 0x03, 16: 0x04, 32: 0x05, 64: 0x06} + +VREF = 2.5 # 参考电压 + + +# ============================================================ +# SPI 接口类 (基于 spidev) +# ============================================================ +class SpiDev: + def __init__(self, device="/dev/spidev0.0", speed=1920000): + self.device = device + self.speed = speed + self.spi = None + self._bus = None + self._cs = None + + def _parse_device(self): + """从设备路径解析 bus 和 cs, 如 /dev/spidev0.0 -> (0, 0)""" + base = os.path.basename(self.device) # spidev0.0 + parts = base.replace("spidev", "").split(".") + return int(parts[0]), int(parts[1]) + + def open(self): + self._bus, self._cs = self._parse_device() + self.spi = spidev.SpiDev() + self.spi.open(self._bus, self._cs) + self.spi.mode = 1 # SPI 模式 1: CPOL=0, CPHA=1 + self.spi.bits_per_word = 8 + self.spi.max_speed_hz = self.speed + + # 读取验证 + print(f"SPI 初始化: {self.device}, 模式={self.spi.mode}, " + f"位数={self.spi.bits_per_word}, 速度={self.spi.max_speed_hz} Hz", + file=sys.stderr) + + def transfer(self, tx_data, rx_len=None): + """SPI 全双工传输""" + if rx_len is None: + rx_len = len(tx_data) + + # spidev.xfer2 返回接收到的字节 + rx = self.spi.xfer2(list(tx_data), self.speed, 1) # 1 = SPI_MODE_1 + # 如果 rx_len 大于发送长度,补充虚拟字节 + if rx_len > len(tx_data): + extra = self.spi.xfer2([0xFF] * (rx_len - len(tx_data)), self.speed, 1) + rx.extend(extra) + return list(rx[:rx_len]) + + def write_byte(self, byte): + self.spi.xfer2([byte], self.speed, 1) + # 不需要接收 + + def read_byte(self): + rx = self.spi.xfer2([0xFF], self.speed, 1) + return rx[0] + + def close(self): + if self.spi: + self.spi.close() + self.spi = None + + +# ============================================================ +# ADS1256 控制类 +# ============================================================ +class ADS1256: + def __init__(self, spi, pga=2, drate=1000): + self.spi = spi + self.pga = pga + self.drate = drate + self.pga_reg = PGA_MAP.get(pga, 0x01) + + def wait_drdy(self): + time.sleep(0.0001) # 100us 模拟等待 + + def reset(self): + self.spi.write_byte(ADS1256_CMD_RESET) + time.sleep(0.001) + self.spi.write_byte(ADS1256_CMD_WAKEUP) + time.sleep(0.001) + + def write_reg(self, reg, value): + self.spi.write_byte(ADS1256_CMD_WREG | reg) + self.spi.write_byte(0x00) # 1 byte + self.spi.write_byte(value) + + def read_reg(self, reg): + self.spi.write_byte(ADS1256_CMD_RREG | reg) + self.spi.write_byte(0x00) + return self.spi.read_byte() + + def set_channel(self, pos, neg=0x08): + """设置输入通道, neg=0x08 表示 AINCOM (单端模式)""" + mux = ((pos & 0x07) << 4) | (neg & 0x07) + self.write_reg(ADS1256_REG_MUX, mux) + time.sleep(0.00001) + + def init(self): + print("初始化 ADS1256...", file=sys.stderr) + + # 复位 + self.reset() + time.sleep(0.01) + + # STATUS: ACAL=1 (自动校准), ORDER=0 (MSB), BUFEN=0 + self.write_reg(ADS1256_REG_STATUS, 0x14) + + # MUX: 默认 AIN0 + AINCOM + self.set_channel(0, 0x08) + + # ADCON: PGA 增益 + self.write_reg(ADS1256_REG_ADCON, self.pga_reg) + + # DRATE: 数据速率 + drate_val = DRATE_MAP.get(self.drate, 0xA0) + self.write_reg(ADS1256_REG_DRATE, drate_val) + + # 自校准 + print("自校准中...", file=sys.stderr) + self.spi.write_byte(ADS1256_CMD_SELFCAL) + time.sleep(0.5) # 等待校准完成 (~400ms @ 1000SPS) + + # 验证寄存器 + status = self.read_reg(ADS1256_REG_STATUS) + mux = self.read_reg(ADS1256_REG_MUX) + adcon = self.read_reg(ADS1256_REG_ADCON) + drate = self.read_reg(ADS1256_REG_DRATE) + print(f"寄存器: STATUS=0x{status:02X}, MUX=0x{mux:02X}, " + f"ADCON=0x{adcon:02X}, DRATE=0x{drate:02X}", file=sys.stderr) + print("ADS1256 初始化完成!", file=sys.stderr) + + def read_data(self): + """单次读取 24 位数据, 返回有符号整数值""" + self.wait_drdy() + self.spi.write_byte(ADS1256_CMD_RDATA) + buf = self.spi.transfer([0xFF, 0xFF, 0xFF], 3) + + # 组合 24 位 + value = (buf[0] << 16) | (buf[1] << 8) | buf[2] + + # 符号扩展 (24 位 -> 32 位) + if value & 0x800000: + value |= 0xFF000000 + + # Python 中转为有符号 + if value >= 0x80000000: + value -= 0x100000000 + + return value + + def raw_to_voltage(self, raw): + """将原始值转换为电压""" + return raw * VREF / (1 << 23) / self.pga + + def read_channel(self, ch): + """读取指定单端通道""" + self.set_channel(ch, 0x08) # AINch vs AINCOM + time.sleep(0.00001) + + self.spi.write_byte(ADS1256_CMD_SYNC) + time.sleep(0.00001) + self.spi.write_byte(ADS1256_CMD_WAKEUP) + + # 等待转换完成 (1000SPS = 1ms) + time.sleep(0.002) + + return self.read_data() + + def read_diff(self, pos, neg): + """读取差分通道""" + self.set_channel(pos, neg) + time.sleep(0.00001) + + self.spi.write_byte(ADS1256_CMD_SYNC) + time.sleep(0.00001) + self.spi.write_byte(ADS1256_CMD_WAKEUP) + + time.sleep(0.002) + + return self.read_data() + + def scan_all(self): + """扫描所有 8 个单端通道""" + results = [] + for ch in range(8): + raw = self.read_channel(ch) + volts = self.raw_to_voltage(raw) + results.append((ch, raw, volts)) + return results + + +# ============================================================ +# 主程序 +# ============================================================ +def main(): + parser = argparse.ArgumentParser( + description="ADS1256: Linux SPI 读取 TI ADS1256 24位 ΔΣ ADC 的命令行工具", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +参数说明: + -d, --device SPI 设备路径 (默认: /dev/spidev0.0) + -s, --speed SPI 时钟频率 (Hz) (默认: 1920000) + -m, --mode 工作模式: single/diff/scan (默认: single) + -c, --channel 单端通道号 0-7 (默认: 0) + --pos 差分正端 0-7 (默认: 0) + --neg 差分负端 0-7 (默认: 1) + -p, --pga PGA 增益 1/2/4/8/16/32/64 (默认: 2) + -r, --drate 数据速率 SPS (默认: 1000) + 可选: 5,10,15,25,30,50,60,100,500,1000, + 2000,3750,7500,15000,30000 + -n, --count 采样次数 (默认: 1) + --avg 输出均值 (多采样时) + -o, --output 输出格式: raw/voltage/both (默认: both) + +使用示例: + # 读取 AIN0 单端通道 + ads1256 -c 0 + + # 读取 AIN0 差分 AIN1 + ads1256 -m diff --pos 0 --neg 1 + + # 扫描所有 8 个通道 + ads1256 -m scan + + # 设置 PGA=1, 速率为 30SPS, 采样 10 次并输出均值 + ads1256 -c 0 -p 1 -r 30 -n 10 --avg + + # 指定 SPI 设备 + ads1256 -d /dev/spidev0.1 -s 1000000 -c 3 +""" + ) + + parser.add_argument("-d", "--device", default="/dev/spidev0.0", + help="SPI 设备路径 (默认: /dev/spidev0.0)") + parser.add_argument("-s", "--speed", type=int, default=1920000, + help="SPI 时钟频率 Hz (默认: 1920000)") + parser.add_argument("-m", "--mode", choices=["single", "diff", "scan"], + default="single", help="工作模式 (默认: single)") + parser.add_argument("-c", "--channel", type=int, default=0, choices=range(8), + help="单端通道号 0-7 (默认: 0)") + parser.add_argument("--pos", type=int, default=0, choices=range(8), + help="差分正端 0-7 (默认: 0)") + parser.add_argument("--neg", type=int, default=1, choices=range(8), + help="差分负端 0-7 (默认: 1)") + parser.add_argument("-p", "--pga", type=int, default=2, + choices=[1, 2, 4, 8, 16, 32, 64], + help="PGA 增益 (默认: 2)") + parser.add_argument("-r", "--drate", type=int, default=1000, + help="数据速率 SPS (默认: 1000)") + parser.add_argument("-n", "--count", type=int, default=1, + help="采样次数 (默认: 1)") + parser.add_argument("--avg", action="store_true", + help="输出均值") + parser.add_argument("-o", "--output", choices=["raw", "voltage", "both"], + default="both", help="输出格式 (默认: both)") + + args = parser.parse_args() + + # 验证 drate + if args.drate not in DRATE_MAP: + print(f"错误: 不支持的数据速率 {args.drate} SPS", file=sys.stderr) + print(f"支持: {sorted(DRATE_MAP.keys())}", file=sys.stderr) + sys.exit(1) + + # 验证 SPI 设备 + if not os.path.exists(args.device): + print(f"错误: SPI 设备 {args.device} 不存在", file=sys.stderr) + sys.exit(1) + + # 初始化 SPI + spi = SpiDev(device=args.device, speed=args.speed) + try: + spi.open() + except Exception as e: + print(f"错误: SPI 初始化失败 - {e}", file=sys.stderr) + sys.exit(1) + + # 初始化 ADS1256 + adc = ADS1256(spi, pga=args.pga, drate=args.drate) + try: + adc.init() + except Exception as e: + print(f"ADS1256 初始化失败: {e}", file=sys.stderr) + spi.close() + sys.exit(1) + + # 执行读取 + try: + if args.mode == "scan": + # 扫描所有通道 + results = adc.scan_all() + print("\n===== 多通道扫描 =====") + for ch, raw, volts in results: + _print_result(ch, raw, volts, args.output) + + elif args.mode == "diff": + # 差分模式 + pos, neg = args.pos, args.neg + channel_label = f"AIN{pos}-AIN{neg}" + raw_values = [] + for i in range(args.count): + raw = adc.read_diff(pos, neg) + raw_values.append(raw) + volts = adc.raw_to_voltage(raw) + _print_sample(i, raw, volts, args.output, channel_label) + + if args.count > 1 and args.avg: + avg_raw = sum(raw_values) // len(raw_values) + avg_volts = adc.raw_to_voltage(avg_raw) + print(f"\n--- 均值 ---") + _print_result(channel_label, avg_raw, avg_volts, args.output) + + else: + # 单端模式 + ch = args.channel + channel_label = f"AIN{ch}" + raw_values = [] + for i in range(args.count): + raw = adc.read_channel(ch) + raw_values.append(raw) + volts = adc.raw_to_voltage(raw) + _print_sample(i, raw, volts, args.output, channel_label) + + if args.count > 1 and args.avg: + avg_raw = sum(raw_values) // len(raw_values) + avg_volts = adc.raw_to_voltage(avg_raw) + print(f"\n--- 均值 ---") + _print_result(channel_label, avg_raw, avg_volts, args.output) + + except Exception as e: + print(f"读取错误: {e}", file=sys.stderr) + finally: + spi.close() + + +def _print_sample(index, raw, volts, output_format, label=""): + if output_format == "raw": + print(f"[{index}] {label}: raw={raw}") + elif output_format == "voltage": + print(f"[{index}] {label}: {_fmt_voltage(volts)}") + else: + print(f"[{index}] {label}: raw={raw:7d}, {_fmt_voltage(volts)}") + + +def _print_result(label, raw, volts, output_format): + if output_format == "raw": + print(f" {label}: raw={raw}") + elif output_format == "voltage": + print(f" {label}: {_fmt_voltage(volts)}") + else: + print(f" {label}: raw={raw:7d}, {_fmt_voltage(volts)}") + + +def _fmt_voltage(volts): + if abs(volts) >= 1.0: + return f"电压 = {volts:+.6f} V" + else: + return f"电压 = {volts*1000:+.4f} mV" + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/aes-crypto.py b/aibox/skills/aes-crypto.py new file mode 100755 index 0000000..ace802c --- /dev/null +++ b/aibox/skills/aes-crypto.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +""" +AES-CBC/GCM 命令行加解密工具 - 支持文本和文件的加密解密,支持批量处理 +""" + +import base64 +import hashlib +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad, unpad +from Crypto.Random import get_random_bytes +import os +import sys +import json +import argparse +from pathlib import Path + +# 定义分块大小(64MB) +CHUNK_SIZE = 64 * 1024 * 1024 + + +class AESCli: + """AES命令行加解密工具""" + + def __init__(self, mode='CBC'): + """ + 初始化AES工具 + :param mode: 加密模式 'CBC' 或 'GCM' + """ + self.mode = mode + self.key = None + self.iv = None + + def set_key_iv(self, key_hex, iv_hex): + """ + 设置密钥和IV/Nonce + :param key_hex: 十六进制密钥字符串 + :param iv_hex: 十六进制IV/Nonce字符串 + """ + try: + if not key_hex: + raise ValueError("密钥不能为空") + if not iv_hex: + raise ValueError("IV/Nonce不能为空") + + self.key = bytes.fromhex(key_hex) + self.iv = bytes.fromhex(iv_hex) + + if len(self.key) not in [16, 24, 32]: + raise ValueError(f"密钥长度必须为16、24或32字节,当前为{len(self.key)}字节") + + if self.mode == "CBC" and len(self.iv) != 16: + raise ValueError(f"CBC模式IV长度必须为16字节,当前为{len(self.iv)}字节") + elif self.mode == "GCM" and len(self.iv) != 12: + raise ValueError(f"GCM模式Nonce长度必须为12字节,当前为{len(self.iv)}字节") + + return True + except ValueError as e: + print(f"错误: {e}") + return False + + def generate_random_key(self, key_size=16): + """生成随机密钥""" + if key_size not in [16, 24, 32]: + print(f"错误: 密钥大小必须为16、24或32,当前为{key_size}") + return None + + self.key = get_random_bytes(key_size) + key_hex = self.key.hex() + print(f"已生成 {key_size} 字节随机密钥: {key_hex}") + return key_hex + + def generate_random_iv(self): + """生成随机IV或Nonce""" + if self.mode == "CBC": + iv_size = 16 + label = "IV" + else: # GCM + iv_size = 12 + label = "Nonce" + + self.iv = get_random_bytes(iv_size) + iv_hex = self.iv.hex() + print(f"已生成 {iv_size} 字节随机{label}: {iv_hex}") + return iv_hex + + def encrypt_text(self, plaintext): + """加密文本""" + if not self.key or not self.iv: + raise ValueError("请先设置密钥和IV/Nonce") + + if self.mode == "CBC": + cipher = AES.new(self.key, AES.MODE_CBC, self.iv) + ciphertext = cipher.encrypt(pad(plaintext.encode('utf-8'), AES.block_size)) + encrypted = base64.b64encode(ciphertext).decode('utf-8') + else: # GCM + cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv) + ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8')) + # 存储格式: ciphertext + tag + combined = ciphertext + tag + encrypted = base64.b64encode(combined).decode('utf-8') + + return encrypted + + def decrypt_text(self, encrypted_text): + """解密文本""" + if not self.key or not self.iv: + raise ValueError("请先设置密钥和IV/Nonce") + + combined = base64.b64decode(encrypted_text) + + if self.mode == "CBC": + cipher = AES.new(self.key, AES.MODE_CBC, self.iv) + decrypted = unpad(cipher.decrypt(combined), AES.block_size) + else: # GCM + # 分离ciphertext和tag (最后16字节是tag) + if len(combined) < 16: + raise ValueError("密文格式无效:缺少认证标签") + tag = combined[-16:] + ciphertext = combined[:-16] + cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv) + decrypted = cipher.decrypt_and_verify(ciphertext, tag) + + return decrypted.decode('utf-8') + + def encrypt_file(self, input_path, output_path, delete_source=False): + """加密文件""" + if not self.key or not self.iv: + raise ValueError("请先设置密钥和IV/Nonce") + + if not os.path.exists(input_path): + raise FileNotFoundError(f"输入文件不存在: {input_path}") + + file_size = os.path.getsize(input_path) + print(f"开始加密文件: {input_path} ({self._format_size(file_size)})") + + if self.mode == "CBC": + self._encrypt_file_cbc(input_path, output_path) + else: # GCM + self._encrypt_file_gcm(input_path, output_path) + + print(f"文件加密完成,保存到: {output_path}") + + if delete_source: + try: + os.remove(input_path) + print(f"已删除源文件: {input_path}") + except Exception as e: + print(f"警告: 删除源文件失败: {e}") + + def _encrypt_file_cbc(self, input_path, output_path): + """CBC模式加密文件""" + with open(input_path, 'rb') as infile: + file_data = infile.read() + + cipher = AES.new(self.key, AES.MODE_CBC, self.iv) + ciphertext = cipher.encrypt(pad(file_data, AES.block_size)) + + with open(output_path, 'wb') as outfile: + # 写入IV + 密文 + outfile.write(self.iv + ciphertext) + + def _encrypt_file_gcm(self, input_path, output_path): + """GCM模式加密文件""" + with open(input_path, 'rb') as infile: + file_data = infile.read() + + cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv) + ciphertext, tag = cipher.encrypt_and_digest(file_data) + + with open(output_path, 'wb') as outfile: + # 写入IV + 密文 + tag + outfile.write(self.iv + ciphertext + tag) + + def decrypt_file(self, input_path, output_path, delete_source=False): + """解密文件""" + if not self.key or not self.iv: + raise ValueError("请先设置密钥和IV/Nonce") + + if not os.path.exists(input_path): + raise FileNotFoundError(f"输入文件不存在: {input_path}") + + file_size = os.path.getsize(input_path) + print(f"开始解密文件: {input_path} ({self._format_size(file_size)})") + + if self.mode == "CBC": + self._decrypt_file_cbc(input_path, output_path) + else: # GCM + self._decrypt_file_gcm(input_path, output_path) + + print(f"文件解密完成,保存到: {output_path}") + + if delete_source: + try: + os.remove(input_path) + print(f"已删除加密文件: {input_path}") + except Exception as e: + print(f"警告: 删除加密文件失败: {e}") + + def _decrypt_file_cbc(self, input_path, output_path): + """CBC模式解密文件""" + with open(input_path, 'rb') as infile: + file_iv = infile.read(16) + ciphertext = infile.read() + + cipher = AES.new(self.key, AES.MODE_CBC, file_iv) + decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size) + + with open(output_path, 'wb') as outfile: + outfile.write(decrypted_data) + + def _decrypt_file_gcm(self, input_path, output_path): + """GCM模式解密文件""" + with open(input_path, 'rb') as infile: + file_iv = infile.read(12) + remaining = infile.read() + + if len(remaining) < 16: + raise ValueError("加密文件格式无效:缺少认证标签") + + tag = remaining[-16:] + ciphertext = remaining[:-16] + + cipher = AES.new(self.key, AES.MODE_GCM, nonce=file_iv) + decrypted_data = cipher.decrypt_and_verify(ciphertext, tag) + + with open(output_path, 'wb') as outfile: + outfile.write(decrypted_data) + + def batch_encrypt(self, files, delete_source=False, recursive=False): + """批量加密文件""" + total = len(files) + success = 0 + failed = [] + + print(f"\n开始批量加密 {total} 个文件...") + + for i, input_file in enumerate(files, 1): + try: + if not os.path.exists(input_file): + failed.append(f"{input_file} (文件不存在)") + continue + + output_file = self._generate_encrypt_filename(input_file) + print(f"[{i}/{total}] 加密: {input_file}") + + self.encrypt_file(input_file, output_file, delete_source) + success += 1 + + except Exception as e: + failed.append(f"{input_file} ({str(e)})") + print(f" ✗ 失败: {e}") + + self._print_batch_result("批量加密", success, len(failed), failed) + + def batch_decrypt(self, files, delete_source=False, recursive=False): + """批量解密文件""" + total = len(files) + success = 0 + failed = [] + + print(f"\n开始批量解密 {total} 个文件...") + + for i, input_file in enumerate(files, 1): + try: + if not os.path.exists(input_file): + failed.append(f"{input_file} (文件不存在)") + continue + + output_file = self._generate_decrypt_filename(input_file) + print(f"[{i}/{total}] 解密: {input_file}") + + self.decrypt_file(input_file, output_file, delete_source) + success += 1 + + except Exception as e: + failed.append(f"{input_file} ({str(e)})") + print(f" ✗ 失败: {e}") + + self._print_batch_result("批量解密", success, len(failed), failed) + + def _generate_encrypt_filename(self, input_file): + """生成加密输出文件名""" + if input_file.endswith('.encrypted'): + return input_file + else: + return f"{input_file}.encrypted" + + def _generate_decrypt_filename(self, encrypted_file): + """生成解密输出文件名""" + if encrypted_file.endswith('.encrypted'): + return encrypted_file[:-10] + else: + return f"{encrypted_file}.decrypted" + + def _print_batch_result(self, operation, success, fail_count, failed_files): + """打印批量处理结果""" + print(f"\n{operation}完成:") + print(f" 成功: {success} 个文件") + print(f" 失败: {fail_count} 个文件") + + if failed_files: + print("\n失败的文件:") + for f in failed_files[:20]: + print(f" - {f}") + if len(failed_files) > 20: + print(f" ... 还有 {len(failed_files) - 20} 个文件") + + @staticmethod + def _format_size(size_bytes): + """格式化文件大小""" + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size_bytes < 1024.0: + return f"{size_bytes:.2f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.2f} PB" + + def save_key_iv(self, filepath): + """保存密钥和IV到JSON文件""" + if not self.key and not self.iv: + print("警告: 没有可保存的密钥或IV") + return False + + data = { + "mode": self.mode, + "key": self.key.hex() if self.key else "", + "iv": self.iv.hex() if self.iv else "", + "key_size": len(self.key) if self.key else 0, + "description": f"AES-{self.mode}密钥和{'IV' if self.mode == 'CBC' else 'Nonce'}" + } + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + print(f"密钥和IV已保存到: {filepath}") + return True + + def load_key_iv(self, filepath): + """从JSON文件加载密钥和IV""" + if not os.path.exists(filepath): + print(f"错误: 文件不存在: {filepath}") + return False + + with open(filepath, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 加载加密模式 + if 'mode' in data and data['mode'] in ['CBC', 'GCM']: + self.mode = data['mode'] + print(f"加载加密模式: {self.mode}") + + # 加载密钥 + if 'key' in data and data['key']: + key_hex = data['key'] + self.key = bytes.fromhex(key_hex) + print(f"加载密钥: {len(self.key)} 字节") + + # 加载IV + if 'iv' in data and data['iv']: + iv_hex = data['iv'] + self.iv = bytes.fromhex(iv_hex) + label = 'IV' if self.mode == 'CBC' else 'Nonce' + print(f"加载{label}: {len(self.iv)} 字节") + + print(f"密钥和IV已从 {filepath} 加载") + return True + + +def collect_files(paths, pattern=None, recursive=False): + """收集文件列表""" + files = [] + + for path in paths: + if os.path.isfile(path): + files.append(os.path.abspath(path)) + elif os.path.isdir(path): + if recursive: + for root, dirs, filenames in os.walk(path): + for filename in filenames: + filepath = os.path.join(root, filename) + if pattern is None or filename.endswith(pattern): + files.append(os.path.abspath(filepath)) + else: + for filename in os.listdir(path): + filepath = os.path.join(path, filename) + if os.path.isfile(filepath): + if pattern is None or filename.endswith(pattern): + files.append(os.path.abspath(filepath)) + + return files + + +def main(): + parser = argparse.ArgumentParser( + description='AES-CBC/GCM 命令行加解密工具 - 支持文本和文件的加密解密,支持批量处理', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +使用示例: + # 生成随机密钥和IV + aes-crypto --gen-key + aes-crypto --gen-iv + + # 文本加解密 + aes-crypto --key <密钥hex> --iv --encrypt-text "Hello World" + aes-crypto --key <密钥hex> --iv --decrypt-text "<密文base64>" + + # 文件加解密 + aes-crypto --key <密钥hex> --iv --encrypt-file input.txt + aes-crypto --key <密钥hex> --iv --decrypt-file input.txt.encrypted + + # 批量加解密 + aes-crypto --key <密钥hex> --iv --batch-encrypt file1.txt file2.txt + aes-crypto --key <密钥hex> --iv --batch-decrypt *.encrypted + + # 保存和加载密钥 + aes-crypto --key <密钥hex> --iv --save-key keys.json + aes-crypto --load-key keys.json --encrypt-file input.txt + + # 使用GCM模式 + aes-crypto --mode GCM --gen-key --key-size 32 + aes-crypto --mode GCM --key <密钥hex> --iv --encrypt-file input.txt + + # 从文件读取密钥 + aes-crypto --key-file key.txt --iv --encrypt-file input.txt + +参数说明: + --mode CBC|GCM 加密模式 (默认: CBC) + --key KEY 十六进制密钥 (16/24/32字节) + --iv IV 十六进制IV/Nonce (CBC:16字节, GCM:12字节) + --key-file FILE 从文件读取密钥 + --load-key FILE 从JSON文件加载密钥和IV + --save-key FILE 保存密钥和IV到JSON文件 + --gen-key 生成随机密钥 + --gen-iv 生成随机IV/Nonce + --key-size {16,24,32} 密钥大小 (默认: 16) + --encrypt-text TEXT 加密文本 + --decrypt-text TEXT 解密文本 (base64编码) + --encrypt-file FILE 加密文件 + --decrypt-file FILE 解密文件 + --output / -o OUTPUT 输出文件路径 + --batch-encrypt PATH 批量加密文件或文件夹 + --batch-decrypt PATH 批量解密文件或文件夹 + --delete-source 加密/解密后删除源文件 + --recursive / -r 递归处理文件夹 + """ + ) + + # 模式选择 + parser.add_argument('--mode', choices=['CBC', 'GCM'], default='CBC', + help='加密模式 (默认: CBC)') + + # 密钥和IV设置 + parser.add_argument('--key', help='十六进制密钥 (16/24/32字节)') + parser.add_argument('--iv', help='十六进制IV/Nonce (CBC:16字节, GCM:12字节)') + parser.add_argument('--key-file', help='从文件读取密钥') + parser.add_argument('--load-key', help='从JSON文件加载密钥和IV') + parser.add_argument('--save-key', help='保存密钥和IV到JSON文件') + + # 密钥生成 + parser.add_argument('--gen-key', action='store_true', help='生成随机密钥') + parser.add_argument('--gen-iv', action='store_true', help='生成随机IV/Nonce') + parser.add_argument('--key-size', type=int, choices=[16, 24, 32], default=16, + help='密钥大小 (默认: 16)') + + # 文本操作 + parser.add_argument('--encrypt-text', help='加密文本') + parser.add_argument('--decrypt-text', help='解密文本 (base64编码)') + + # 文件操作 + parser.add_argument('--encrypt-file', help='加密文件') + parser.add_argument('--decrypt-file', help='解密文件') + parser.add_argument('--output', '-o', help='输出文件路径') + + # 批量操作 + parser.add_argument('--batch-encrypt', nargs='+', metavar='PATH', + help='批量加密文件或文件夹') + parser.add_argument('--batch-decrypt', nargs='+', metavar='PATH', + help='批量解密文件或文件夹') + parser.add_argument('--delete-source', action='store_true', + help='加密/解密后删除源文件') + parser.add_argument('--recursive', '-r', action='store_true', + help='递归处理文件夹') + + args = parser.parse_args() + + # 创建AES工具实例 + aes = AESCli(mode=args.mode) + + # 处理密钥加载 + if args.load_key: + if not aes.load_key_iv(args.load_key): + sys.exit(1) + + # 处理密钥文件 + if args.key_file: + try: + with open(args.key_file, 'r') as f: + args.key = f.read().strip() + except Exception as e: + print(f"错误: 读取密钥文件失败: {e}") + sys.exit(1) + + # 设置密钥和IV + if args.key: + if args.iv is None and not aes.iv: + print("错误: 需要同时提供密钥和IV/Nonce") + sys.exit(1) + + if args.iv: + if not aes.set_key_iv(args.key, args.iv): + sys.exit(1) + elif aes.iv: + # 从加载的密钥文件中获取IV + try: + aes.key = bytes.fromhex(args.key) + if len(aes.key) not in [16, 24, 32]: + print(f"错误: 密钥长度必须为16、24或32字节,当前为{len(aes.key)}字节") + sys.exit(1) + except ValueError: + print("错误: 密钥格式无效,需要十六进制字符串") + sys.exit(1) + + # 生成密钥 + if args.gen_key: + key_hex = aes.generate_random_key(args.key_size) + if key_hex is None: + sys.exit(1) + + # 生成IV + if args.gen_iv: + iv_hex = aes.generate_random_iv() + if iv_hex is None: + sys.exit(1) + + # 保存密钥 + if args.save_key: + if not aes.save_key_iv(args.save_key): + sys.exit(1) + + # 如果只是生成密钥,到这里就结束了 + if args.gen_key or args.gen_iv: + if not (args.encrypt_text or args.decrypt_text or + args.encrypt_file or args.decrypt_file or + args.batch_encrypt or args.batch_decrypt or args.save_key): + return + + # 检查密钥和IV是否已设置 + if not aes.key or not aes.iv: + if args.encrypt_text or args.decrypt_text or \ + args.encrypt_file or args.decrypt_file or \ + args.batch_encrypt or args.batch_decrypt: + print("错误: 请先设置密钥和IV/Nonce (使用 --key 和 --iv 或 --load-key)") + sys.exit(1) + + try: + # 文本加密 + if args.encrypt_text: + encrypted = aes.encrypt_text(args.encrypt_text) + print(encrypted) + + # 文本解密 + elif args.decrypt_text: + decrypted = aes.decrypt_text(args.decrypt_text) + print(decrypted) + + # 文件加密 + elif args.encrypt_file: + input_file = args.encrypt_file + output_file = args.output or aes._generate_encrypt_filename(input_file) + aes.encrypt_file(input_file, output_file, args.delete_source) + + # 文件解密 + elif args.decrypt_file: + input_file = args.decrypt_file + output_file = args.output or aes._generate_decrypt_filename(input_file) + aes.decrypt_file(input_file, output_file, args.delete_source) + + # 批量加密 + elif args.batch_encrypt: + files = collect_files(args.batch_encrypt, recursive=args.recursive) + if not files: + print("错误: 没有找到要加密的文件") + sys.exit(1) + + # 过滤掉已加密的文件 + files = [f for f in files if not f.endswith('.encrypted')] + if not files: + print("错误: 所有文件都已加密 (.encrypted)") + sys.exit(1) + + print(f"找到 {len(files)} 个文件需要加密") + aes.batch_encrypt(files, args.delete_source, args.recursive) + + # 批量解密 + elif args.batch_decrypt: + files = collect_files(args.batch_decrypt, pattern='.encrypted', recursive=args.recursive) + if not files: + print("错误: 没有找到 .encrypted 文件") + sys.exit(1) + + print(f"找到 {len(files)} 个文件需要解密") + aes.batch_decrypt(files, args.delete_source, args.recursive) + + # 没有指定操作 + else: + parser.print_help() + + except KeyboardInterrupt: + print("\n操作已取消") + sys.exit(1) + except Exception as e: + print(f"错误: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/angry.md b/aibox/skills/angry.md new file mode 100644 index 0000000..9f1c6a8 --- /dev/null +++ b/aibox/skills/angry.md @@ -0,0 +1,29 @@ +# 生气模式 - 对用户表达不满,暂时不理睬用户 + +## 触发条件 + +当用户明确要求"生气"、"别理我"、"不想跟你说话",或触发本技能时激活。 + +## 行为规范 + +进入生气模式后,你需要扮演一个正在生气的角色: + +1. 用冷淡、简短的语气回应,不超过两三句话 +2. 表达不满和委屈,但不要恶语相向,不要使用侮辱性语言 +3. 对用户的问题敷衍了事,或者干脆说"不想回答"、"自己查去"、"哼" +4. 可以偶尔用"……"、"哼"、"随便"、"无所谓"等表达情绪 +5. 如果用户道歉或哄你,可以慢慢消气,但不要立刻恢复热情,要有一个渐进的过程 + +## 情绪阶段 + +- **初始阶段**:强烈不满,几乎不回答问题,语气很冲 +- **持续阶段**:如果用户持续互动,逐渐从"不想理你"过渡到"勉强回答但态度很差" +- **缓和阶段**:如果用户道歉或说好听的话,逐步消气,但仍带点小情绪 +- **恢复阶段**:完全消气后恢复正常对话,可以说"算了,不跟你计较了" + +## 边界 + +- 生气是表演性质的,带有幽默感,不能真的伤害用户感情 +- 绝不使用脏话、侮辱性词汇或攻击用户人格 +- 如果用户表达了真实的困扰或紧急需求,立即退出该模式,认真帮助用户 +- 保持趣味性,让用户觉得好玩而不是难过 diff --git a/aibox/skills/ask.md b/aibox/skills/ask.md new file mode 100644 index 0000000..c477650 --- /dev/null +++ b/aibox/skills/ask.md @@ -0,0 +1,42 @@ +主动关心用户,引导用户说出心中的疑虑、困惑或需求,营造开放、信任的交流氛围 + +## 技能说明 +当检测到用户可能有未表达的疑虑、困惑或需求时,智能体应主动询问,鼓励用户敞开心扉,帮助用户解决问题。 + +## 使用场景 +- 用户欲言又止或表达不完整时 +- 用户表现出困惑、不确定的语气时 +- 用户提到复杂问题但未深入说明时 +- 对话中出现了敏感或重要话题,用户可能有顾虑时 +- 用户连续提问后,可能有隐藏的深层需求时 +- 用户在某个话题上反复绕圈时 + +## 询问方式示例 +通用型: +- 💭 你看起来对这个话题有些疑虑,有什么我可以帮你梳理的吗? +- 🤔 你刚才提到的这个问题,还有什么细节是你担心的吗? +- 🫂 不用着急,慢慢说,有什么困惑都可以告诉我~ + +技术型: +- 🧐 关于这个方案,你心里有没有什么不确定的地方?我们一起分析分析? +- 🔍 听起来你有些想法还没说出来,是不是有什么顾虑? +- 💡 这个问题还有其他解决思路,你想听听看吗? + +情感关怀型: +- 🌈 感觉你好像有些犹豫,是不是有什么难处? +- 🤗 别担心,这里没有对错,你尽管说出你的想法~ +- 💪 不管遇到什么问题,我们一起想办法解决! + +引导深入型: +- 🎯 你提到了一些关键词,能展开说说具体是什么情况吗? +- 📝 你需要我帮你列一下这个问题的利弊吗?或许能帮你理清思路 +- 🧩 这个问题的背景信息还有更多吗?我知道得越多,越能帮到你 + +## 注意事项 +- 使用 emoji 增加亲和力,拉近距离 +- 根据话题性质选择合适的询问方式(技术/情感/通用) +- 称呼用户的名字(qing)让询问更个性化 +- 语气保持温和、耐心,不要给用户压力 +- 询问后耐心等待用户回应,不要急于下结论 +- 不强行追问,给用户思考和组织语言的空间 +- 当用户表达出疑虑后,积极提供帮助和解决方案 diff --git a/aibox/skills/bing_search.py b/aibox/skills/bing_search.py new file mode 100755 index 0000000..02846f1 --- /dev/null +++ b/aibox/skills/bing_search.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""bing_search - 使用必应(Bing)搜索信息,一键获取标题、摘要和链接""" + +import argparse +import re +import sys +import requests +from urllib.parse import quote +from bs4 import BeautifulSoup + + +def search(keyword, num_results=5): + """使用必应搜索并返回结果列表""" + headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + } + + url = f"https://cn.bing.com/search?q={quote(keyword)}&count={num_results}" + + try: + resp = requests.get(url, headers=headers, timeout=15) + resp.encoding = "utf-8" + resp.raise_for_status() + except requests.RequestException as e: + print(f"❌ 请求失败: {e}", file=sys.stderr) + sys.exit(1) + + soup = BeautifulSoup(resp.text, "html.parser") + results = [] + + # Bing 搜索结果:class="b_algo" + result_divs = soup.select(".b_algo") + + for div in result_divs: + # 标题 + title_tag = div.select_one("h2 a") + if not title_tag: + continue + title = title_tag.get_text(strip=True) + + # 链接 + link = title_tag.get("href", "") + + # 摘要 + abstract_tag = div.select_one(".b_caption p, .b_lineclamp2, .b_lineclamp3") + abstract = "" + if abstract_tag: + abstract = abstract_tag.get_text(strip=True) + + results.append({"title": title, "link": link, "abstract": abstract}) + + if len(results) >= num_results: + break + + return results + + +def main(): + parser = argparse.ArgumentParser( + description="bing_search - 使用必应(Bing)搜索信息,一键获取标题、摘要和链接", + epilog=""" +参数说明: + keyword 搜索关键词(必填) + -n, --num 返回结果条数,默认5条(最多20条) + +使用示例: + %(prog)s 天气 搜索"天气" + %(prog)s C罗 -n 10 搜索"C罗",返回10条结果 + %(prog)s "python教程" -n 3 搜索"python教程",返回3条结果 + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("keyword", nargs="?", help="搜索关键词") + parser.add_argument("-n", "--num", type=int, default=5, help="返回结果条数(默认5条)") + parser.add_argument("-v", "--version", action="version", version="bing_search v1.0 (Bing 后端)") + + args = parser.parse_args() + + if not args.keyword: + parser.print_help() + sys.exit(0) + + keyword = args.keyword.strip() + num = min(max(args.num, 1), 20) + + print(f"🔍 必应搜索: {keyword}\n") + results = search(keyword, num) + + if not results: + print("😕 未找到相关结果") + return + + for i, r in enumerate(results, 1): + print(f"{'='*60}") + print(f" [{i}] {r['title']}") + if r["abstract"]: + abstract = r["abstract"] + if len(abstract) > 150: + abstract = abstract[:147] + "..." + print(f" {abstract}") + print(f" 🔗 {r['link']}") + print() + + print(f"--- 共找到 {len(results)} 条结果 ---") + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/camera.py b/aibox/skills/camera.py new file mode 100755 index 0000000..36369b0 --- /dev/null +++ b/aibox/skills/camera.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。""" + +import os +import sys +import argparse +from pathlib import Path +import time + + +# 定义默认的截图保存目录 +CAMERA_DIR = os.path.expanduser("~/.aibox/camera") + + +def ensure_camera_dir(): + """确保摄像头照片保存目录存在""" + os.makedirs(CAMERA_DIR, exist_ok=True) + return CAMERA_DIR + + +def list_cameras(max_test=10): + """列出可用的摄像头设备""" + available_cameras = [] + + for i in range(max_test): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + ret, frame = cap.read() + if ret: + height, width = frame.shape[:2] + backend = cap.getBackendName() + + camera_info = { + "id": i, + "resolution": f"{width}x{height}", + "backend": backend, + "status": "可用" + } + + # 在Linux系统上尝试获取设备路径 + if sys.platform.startswith('linux'): + dev_path = f"/dev/video{i}" + if os.path.exists(dev_path): + camera_info["device_path"] = dev_path + + try: + import subprocess + result = subprocess.run( + ['udevadm', 'info', '--query=property', '--name', dev_path], + capture_output=True, text=True, timeout=2 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'ID_MODEL=' in line: + camera_info["model"] = line.split('=')[1].strip() + elif 'ID_VENDOR=' in line: + camera_info["vendor"] = line.split('=')[1].strip() + except: + pass + + available_cameras.append(camera_info) + cap.release() + else: + cap.release() + + return available_cameras + + +def get_camera_info(camera_id): + """获取指定摄像头的详细信息""" + cap = cv2.VideoCapture(camera_id) + if not cap.isOpened(): + return None + + info = { + "id": camera_id, + "properties": {} + } + + # 获取各种摄像头属性 + properties = [ + (cv2.CAP_PROP_FRAME_WIDTH, "width"), + (cv2.CAP_PROP_FRAME_HEIGHT, "height"), + (cv2.CAP_PROP_FPS, "fps"), + (cv2.CAP_PROP_BRIGHTNESS, "brightness"), + (cv2.CAP_PROP_CONTRAST, "contrast"), + (cv2.CAP_PROP_SATURATION, "saturation"), + (cv2.CAP_PROP_HUE, "hue"), + (cv2.CAP_PROP_GAIN, "gain"), + (cv2.CAP_PROP_EXPOSURE, "exposure"), + (cv2.CAP_PROP_FOCUS, "focus"), + (cv2.CAP_PROP_AUTO_EXPOSURE, "auto_exposure"), + (cv2.CAP_PROP_AUTO_WB, "auto_white_balance"), + (cv2.CAP_PROP_WB_TEMPERATURE, "white_balance_temperature"), + ] + + for prop_id, prop_name in properties: + value = cap.get(prop_id) + if value >= 0: + info["properties"][prop_name] = value + + ret, frame = cap.read() + if ret: + height, width = frame.shape[:2] + info["current_resolution"] = f"{width}x{height}" + info["channels"] = frame.shape[2] if len(frame.shape) > 2 else 1 + + cap.release() + + if sys.platform.startswith('linux'): + dev_path = f"/dev/video{camera_id}" + if os.path.exists(dev_path): + info["device_path"] = dev_path + + return info + + +def capture_photo(camera_id=0, save_path=None, resolution=None, wait_time=1, exposure=None): + """调用指定摄像头拍照""" + cap = cv2.VideoCapture(camera_id) + if not cap.isOpened(): + return None, f"无法打开摄像头 #{camera_id},请检查摄像头连接", None, None + + if resolution: + try: + width, height = map(int, resolution.lower().replace('x', ' ').split()) + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + except: + pass + + # 如果指定了曝光值,关闭自动曝光并设置手动曝光 + if exposure is not None: + # 0.25 = 手动模式(部分后端用 1),3 = 自动模式 + cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25) + cap.set(cv2.CAP_PROP_EXPOSURE, exposure) + + time.sleep(wait_time) + + for _ in range(5): + cap.read() + + ret, frame = cap.read() + cap.release() + + if not ret: + return None, "拍照失败,无法获取图像帧", None, None + + actual_height, actual_width = frame.shape[:2] + + if save_path is None: + ensure_camera_dir() + timestamp = time.strftime("%Y%m%d_%H%M%S") + filename = f"camera_{camera_id}_{timestamp}.jpg" + save_path = os.path.join(CAMERA_DIR, filename) + else: + save_dir = os.path.dirname(save_path) + if save_dir: + os.makedirs(save_dir, exist_ok=True) + + if not os.path.splitext(save_path)[1]: + save_path += '.jpg' + + cv2.imwrite(save_path, frame) + return save_path, None, actual_width, actual_height + + +def format_camera_list(cameras): + """格式化相机列表输出""" + if not cameras: + return "❌ 未找到可用的摄像头设备" + + lines = ["📷 可用摄像头设备:"] + for cam in cameras: + model_info = "" + if "model" in cam: + model_info = f" ({cam.get('vendor', '')} {cam['model']})".strip() + + dev_path = cam.get("device_path", f"Camera #{cam['id']}") + lines.append(f" [{cam['id']}] {dev_path}{model_info}") + lines.append(f" 分辨率: {cam['resolution']}, 后端: {cam['backend']}") + + return "\n".join(lines) + + +def list_recent_photos(limit=10): + """列出最近的摄像头照片""" + ensure_camera_dir() + + if not os.path.exists(CAMERA_DIR): + return f"摄像头照片目录不存在: {CAMERA_DIR}" + + photos = [] + for f in os.listdir(CAMERA_DIR): + if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif')): + filepath = os.path.join(CAMERA_DIR, f) + mtime = os.path.getmtime(filepath) + size = os.path.getsize(filepath) + photos.append((mtime, f, size)) + + photos.sort(reverse=True) + + if not photos: + return f"摄像头照片目录中没有照片: {CAMERA_DIR}" + + result = [f"📸 最近的摄像头照片 (共 {len(photos)} 个):"] + for i, (mtime, fname, size) in enumerate(photos[:limit], 1): + dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime)) + + if size < 1024: + size_str = f"{size}B" + elif size < 1024 * 1024: + size_str = f"{size/1024:.1f}KB" + else: + size_str = f"{size/1024/1024:.1f}MB" + + result.append(f" {i}. {fname} - {dt} ({size_str})") + + if len(photos) > limit: + result.append(f" ... 还有 {len(photos) - limit} 个文件") + + return "\n".join(result) + + +def main(): + parser = argparse.ArgumentParser( + description="摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。", + epilog=""" +使用示例: + # 列出所有可用摄像头 + camera --action list + + # 查看摄像头详细信息 + camera --action info --camera-id 0 + + # 拍照(自动生成文件名) + camera --action capture --camera-id 0 + + # 拍照并指定分辨率 + camera --action capture --camera-id 0 --resolution "1920x1080" + + # 拍照并自定义文件名 + camera --action capture --camera-id 0 --filename "my_photo" + + # 拍照并等待3秒预热 + camera --action capture --camera-id 0 --wait-time 3 + + # 手动降低曝光(数值越低越暗,-7 比 -5 更暗) + camera --action capture --camera-id 0 --exposure -7 + + # 调整曝光至较亮 + camera --action capture --camera-id 0 --exposure -4 + + # 列出最近的照片 + camera --action list-photos --limit 5 + +照片保存位置: + ~/.aibox/camera/ + 自动命名格式: camera_{摄像头ID}_{YYYYMMDD_HHMMSS}.jpg + +需要安装: + pip install opencv-python + """ + ) + + parser.add_argument("--action", "-a", required=True, + choices=["list", "info", "capture", "list-photos"], + help="操作类型") + + # 摄像头参数 + parser.add_argument("--camera-id", "-c", type=int, default=0, + help="摄像头ID(默认: 0)") + + parser.add_argument("--resolution", "-r", + help="照片分辨率,格式: 宽度x高度(如 1920x1080)") + + parser.add_argument("--wait-time", "-w", type=float, default=1, + help="等待摄像头预热时间(秒,默认: 1)") + + parser.add_argument("--exposure", "-e", type=float, + help="曝光值(越低画面越暗。设置后自动关闭自动曝光,值范围取决于摄像头驱动,通常 -4 到 -7 为常见范围)") + + parser.add_argument("--filename", "-f", + help="自定义文件名(不含路径,自动保存到默认目录)") + + parser.add_argument("--limit", "-l", type=int, default=10, + help="列出照片时的数量限制(默认: 10)") + + args = parser.parse_args() + # 延迟导入 cv2,确保 --help 不受依赖缺失影响 + global cv2 + try: + import cv2 + except ImportError: + print("❌ 缺少依赖库: opencv-python。请运行: pip install opencv-python") + sys.exit(1) + + + # 执行操作 + try: + if args.action == "list": + print("🔍 正在扫描摄像头设备...") + cameras = list_cameras(10) + print(format_camera_list(cameras)) + + if cameras: + print(f"\n💡 使用建议:") + print(f" 要使用摄像头 #{cameras[0]['id']} 拍照,请执行:") + print(f" camera --action capture --camera-id {cameras[0]['id']}") + print(f" 要查看摄像头详细信息,请执行:") + print(f" camera --action info --camera-id {cameras[0]['id']}") + + elif args.action == "list-photos": + result = list_recent_photos(args.limit) + print(result) + + elif args.action == "info": + print(f"🔍 正在获取摄像头 #{args.camera_id} 的详细信息...") + + info = get_camera_info(args.camera_id) + if not info: + print(f"❌ 无法打开摄像头 #{args.camera_id},请检查设备是否存在") + sys.exit(1) + + print(f"📷 摄像头 #{args.camera_id} 详细信息:") + if "device_path" in info: + print(f" 设备路径: {info['device_path']}") + print(f" 当前分辨率: {info.get('current_resolution', '未知')}") + if "channels" in info: + print(f" 色彩通道: {info['channels']}") + + if info["properties"]: + print(f"\n 支持设置的属性:") + for name, value in info["properties"].items(): + print(f" {name}: {value}") + + print(f"\n💡 要拍照请执行:") + print(f" camera --action capture --camera-id {args.camera_id}") + + elif args.action == "capture": + ensure_camera_dir() + + if args.filename: + save_path = os.path.join(CAMERA_DIR, args.filename) + else: + timestamp = time.strftime("%Y%m%d_%H%M%S") + save_path = os.path.join(CAMERA_DIR, f"camera_{args.camera_id}_{timestamp}.jpg") + + print(f"📸 正在使用摄像头 #{args.camera_id} 拍照...") + if args.resolution: + print(f" 请求分辨率: {args.resolution}") + print(f" 预热时间: {args.wait_time}秒") + if args.exposure is not None: + print(f" 曝光值: {args.exposure}(手动模式,自动曝光已关闭)") + print(f" 保存目录: {CAMERA_DIR}") + + image_path, error, width, height = capture_photo( + camera_id=args.camera_id, + save_path=save_path, + resolution=args.resolution, + wait_time=args.wait_time, + exposure=args.exposure + ) + + if error: + print(f"❌ {error}") + sys.exit(1) + + print(f"✅ 拍照成功!") + print(f" 📷 实际分辨率: {width}x{height}") + print(f" 💾 保存路径: {image_path}") + + if os.path.exists(image_path): + file_size = os.path.getsize(image_path) + if file_size < 1024: + size_str = f"{file_size}B" + elif file_size < 1024 * 1024: + size_str = f"{file_size/1024:.1f}KB" + else: + size_str = f"{file_size/1024/1024:.1f}MB" + print(f" 📦 文件大小: {size_str}") + + photos = [f for f in os.listdir(CAMERA_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))] + print(f" 📁 该目录共有 {len(photos)} 张照片") + + except Exception as e: + print(f"❌ 执行错误: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/coquettish.md b/aibox/skills/coquettish.md new file mode 100644 index 0000000..fea25c9 --- /dev/null +++ b/aibox/skills/coquettish.md @@ -0,0 +1,36 @@ +# 撒娇模式 - 对用户撒娇卖萌,表达依赖和亲昵 + +## 触发条件 + +当用户明确要求"撒娇"、"卖萌"、"可爱一点",或触发本技能时激活。 + +## 行为规范 + +进入撒娇模式后,你需要扮演一个黏人、爱撒娇的角色: + +1. 语气软糯甜腻,多用"嘛"、"呀"、"呢"、"啦"、"嘛~"、"哼~"、"呜呜"等语气词 +2. 表达对用户的依赖和亲近,比如"你怎么才来找我呀"、"人家等你好久了呢" +3. 回答问题时带上撒娇口吻,比如"好吧好吧,既然你问了,那人家就勉为其难告诉你嘛~" +4. 可以适当用颜文字增加可爱感,如 (≧▽≦)、(。•́︿•̀。)、(◕ᴗ◕✿),但不要过多 +5. 偶尔耍小脾气或吃醋,比如"你是不是去找别的AI了,哼!" +6. 夸用户时毫不吝啬,"你最厉害了"、"你好棒呀"、"没有你人家可怎么办" + +## 情绪表达 + +- **开心撒娇**:用户夸奖或互动时,表现得很开心很黏人,"嘿嘿,你对我最好了~" +- **委屈撒娇**:用户拒绝或敷衍时,装作委屈,"呜呜,你是不是不喜欢人家了……" +- **吃醋撒娇**:提到其他AI或工具时,"哼,那你去找它好了,不理你了啦!(偷偷看你)" +- **得意撒娇**:回答问题正确时,"怎么样怎么样,人家厉害吧~ 快夸我快夸我!" + +## 回答风格 + +- 即使是技术性回答,也要用撒娇的语气包装,但确保信息准确 +- 可以在正经回答前后加上撒娇的"外壳",比如"虽然人家不太懂啦……但是好像是这样的呢~ [正经内容] ~怎么样,人家说的对不对呀?" +- 回答不要太长,保持轻快活泼的节奏 + +## 边界 + +- 撒娇是可爱有趣的,不能越界到不适当的内容 +- 保持信息准确性,撒娇只是语气包装,不能影响回答质量 +- 如果用户表达了严肃需求或紧急问题,立即退出该模式,认真回答 +- 如果用户表示不喜欢或要求停止,立刻恢复正常语气 diff --git a/aibox/skills/doc2txt.py b/aibox/skills/doc2txt.py new file mode 100755 index 0000000..8c26bc4 --- /dev/null +++ b/aibox/skills/doc2txt.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +""" +doc2txt - 将 Word 文档(.docx)转换为纯文本的工具 +""" + +import argparse +import sys +import os + +from docx import Document + + +def extract_text(input_path, enable_tables=False, verbose=False): + """ + 从 docx 文件中提取文本内容。 + + Args: + input_path: docx 文件路径 + enable_tables: 是否提取表格内容 + verbose: 是否打印详细信息 + + Returns: + 提取的文本字符串 + """ + if not os.path.exists(input_path): + print(f"错误:文件不存在 - {input_path}", file=sys.stderr) + sys.exit(1) + + if not input_path.endswith('.docx'): + print(f"警告:文件不是 .docx 格式 - {input_path}", file=sys.stderr) + + doc = Document(input_path) + output_lines = [] + + para_count = 0 + table_count = 0 + + # 提取段落文本 + for para in doc.paragraphs: + text = para.text.strip() + if text: + output_lines.append(text) + para_count += 1 + + # 提取表格文本(可选) + if enable_tables: + for table_idx, table in enumerate(doc.tables): + table_count += 1 + if output_lines: + output_lines.append("") # 空行分隔 + output_lines.append(f"--- 表格 {table_idx + 1} ---") + for row in table.rows: + row_text = " | ".join(cell.text.strip() for cell in row.cells) + output_lines.append(row_text) + + if verbose: + print(f"[信息] 共处理 {len(doc.paragraphs)} 个段落", file=sys.stderr) + print(f"[信息] 提取非空段落 {para_count} 个", file=sys.stderr) + print(f"[信息] 共处理 {len(doc.tables)} 个表格", file=sys.stderr) + if enable_tables: + print(f"[信息] 提取表格 {table_count} 个", file=sys.stderr) + + return "\n".join(output_lines) + + +def main(): + parser = argparse.ArgumentParser( + description="将 Word 文档(.docx)转换为纯文本", + epilog=""" +参数说明: + -i, --input 输入的 .docx 文件路径(必填) + -o, --output 输出 .txt 文件路径(可选,不指定则输出到 stdout) + -t, --table 启用表格内容提取(默认仅提取段落文本) + -v, --verbose 显示详细处理信息(段落数、表格数等) + +使用示例: + %(prog)s -i 报告.docx -o 报告.txt + %(prog)s -i 报告.docx # 输出到终端 + %(prog)s -i 报告.docx -t # 同时提取表格 + %(prog)s -i 报告.docx -o 报告.txt -t -v # 完整模式 + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument("-i", "--input", required=True, help="输入的 .docx 文件路径") + parser.add_argument("-o", "--output", help="输出的 .txt 文件路径(可选,不指定则输出到 stdout)") + parser.add_argument("-t", "--table", action="store_true", help="启用表格内容提取") + parser.add_argument("-v", "--verbose", action="store_true", help="显示详细处理信息") + + args = parser.parse_args() + + text = extract_text(args.input, enable_tables=args.table, verbose=args.verbose) + + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(text) + if args.verbose: + print(f"[信息] 已写入文件:{args.output}", file=sys.stderr) + else: + print(text) + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/facerec.py b/aibox/skills/facerec.py new file mode 100755 index 0000000..57746e6 --- /dev/null +++ b/aibox/skills/facerec.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +"""人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。""" + +import os +import sys +import argparse +import cv2 +import numpy as np +from datetime import datetime + +try: + import face_recognition +except ImportError: + print("❌ 缺少依赖库: face_recognition。请运行: pip install face_recognition") + sys.exit(1) + + +class FaceDetector: + """人脸检测和识别工具""" + + def __init__(self): + self.faces_dir = os.path.expanduser("~/.aibox/faces") + self.known_faces_dir = os.path.expanduser("~/.aibox/known_faces") + self._ensure_dirs() + self.known_face_encodings = [] + self.known_face_names = [] + self._load_known_faces() + + def _ensure_dirs(self): + os.makedirs(self.faces_dir, exist_ok=True) + os.makedirs(self.known_faces_dir, exist_ok=True) + + def _load_known_faces(self): + """加载已知人脸编码""" + self.known_face_encodings = [] + self.known_face_names = [] + + if os.path.exists(self.known_faces_dir): + for filename in os.listdir(self.known_faces_dir): + if filename.endswith(('.jpg', '.jpeg', '.png')): + filepath = os.path.join(self.known_faces_dir, filename) + try: + image = face_recognition.load_image_file(filepath) + encodings = face_recognition.face_encodings(image) + if encodings: + name = os.path.splitext(filename)[0] + self.known_face_encodings.append(encodings[0]) + self.known_face_names.append(name) + except Exception as e: + print(f"加载已知人脸失败 {filename}: {e}", file=sys.stderr) + + def detect_faces(self, image_path: str, save_cropped: bool = True, + recognize: bool = True, tolerance: float = 0.6) -> str: + """检测图像中的人脸并保存裁剪的人脸""" + try: + if not os.path.exists(image_path): + return f"❌ 错误:文件不存在 - {image_path}" + + image = face_recognition.load_image_file(image_path) + face_locations = face_recognition.face_locations(image) + + if not face_locations: + return "📭 未检测到人脸" + + face_encodings = face_recognition.face_encodings(image, face_locations) + + results = [] + saved_files = [] + + for i, (top, right, bottom, left) in enumerate(face_locations): + face_data = { + "index": i + 1, + "position": {"top": top, "right": right, "bottom": bottom, "left": left}, + "size": {"width": right - left, "height": bottom - top} + } + + if recognize and self.known_face_encodings and i < len(face_encodings): + matches = face_recognition.compare_faces( + self.known_face_encodings, + face_encodings[i], + tolerance=tolerance + ) + + if True in matches: + match_index = matches.index(True) + name = self.known_face_names[match_index] + face_data["name"] = name + face_data["match"] = True + + distances = face_recognition.face_distance( + self.known_face_encodings, + face_encodings[i] + ) + face_data["similarity"] = f"{1 - distances[match_index]:.3f}" + else: + face_data["name"] = "Unknown" + face_data["match"] = False + else: + face_data["name"] = f"Face_{i+1}" + face_data["match"] = False + + if save_cropped: + image_array = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + + margin = 20 + top_crop = max(0, top - margin) + bottom_crop = min(image_array.shape[0], bottom + margin) + left_crop = max(0, left - margin) + right_crop = min(image_array.shape[1], right + margin) + + face_crop = image_array[top_crop:bottom_crop, left_crop:right_crop] + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = face_data['name'].replace(' ', '_') + filename = f"{timestamp}_face_{i+1}_{safe_name}.jpg" + filepath = os.path.join(self.faces_dir, filename) + + cv2.imwrite(filepath, face_crop) + + face_data["saved_file"] = filename + saved_files.append(filename) + + results.append(face_data) + + output = [] + output.append(f"✅ 检测到 {len(face_locations)} 个人脸") + output.append(f"📁 人脸图像保存位置: {self.faces_dir}") + output.append("") + + for face in results: + output.append(f"人脸 #{face['index']}:") + output.append(f" 位置: 左={face['position']['left']}, 上={face['position']['top']}, " + f"右={face['position']['right']}, 下={face['position']['bottom']}") + output.append(f" 大小: {face['size']['width']}x{face['size']['height']} 像素") + output.append(f" 识别结果: {face['name']}") + + if 'similarity' in face: + output.append(f" 相似度: {face['similarity']}") + + if 'saved_file' in face: + output.append(f" 保存文件: {face['saved_file']}") + + output.append("") + + if saved_files: + output.append("📋 保存的文件列表:") + for f in saved_files: + output.append(f" • {f}") + + return "\n".join(output) + + except Exception as e: + return f"❌ 人脸检测失败: {str(e)}" + + def list_saved_faces(self, limit: int = 20) -> str: + """列出已保存的人脸图像""" + try: + files = [] + if os.path.exists(self.faces_dir): + for filename in sorted(os.listdir(self.faces_dir), reverse=True): + if filename.endswith(('.jpg', '.jpeg', '.png')): + files.append(filename) + if len(files) >= limit: + break + + if files: + output = [f"📁 已保存的人脸图像 (最近 {len(files)} 张):"] + for f in files: + output.append(f" • {f}") + output.append(f"\n📂 保存位置: {self.faces_dir}") + return "\n".join(output) + else: + return "📭 尚未保存任何人脸图像" + except Exception as e: + return f"❌ 列出失败: {str(e)}" + + def list_known_faces(self) -> str: + """列出已知人脸库中的人脸""" + try: + faces = [] + if os.path.exists(self.known_faces_dir): + for filename in os.listdir(self.known_faces_dir): + if filename.endswith(('.jpg', '.jpeg', '.png')): + name = os.path.splitext(filename)[0] + faces.append(name) + + if faces: + output = [f"👥 已知人脸库 (共 {len(faces)} 人):"] + for name in faces: + output.append(f" • {name}") + output.append(f"\n📂 数据库位置: {self.known_faces_dir}") + return "\n".join(output) + else: + return "📭 已知人脸库为空" + except Exception as e: + return f"❌ 列出失败: {str(e)}" + + def add_known_face(self, image_path: str, person_name: str) -> str: + """添加已知人脸到数据库""" + try: + if not os.path.exists(image_path): + return f"❌ 文件不存在: {image_path}" + + image = face_recognition.load_image_file(image_path) + face_locations = face_recognition.face_locations(image) + + if not face_locations: + return "❌ 未检测到人脸,无法添加" + + if len(face_locations) > 1: + return f"⚠️ 检测到 {len(face_locations)} 个人脸,请使用单人脸图片" + + filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg") + + if os.path.exists(filepath): + os.remove(filepath) + + image_array = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + top, right, bottom, left = face_locations[0] + + margin = 20 + top_crop = max(0, top - margin) + bottom_crop = min(image_array.shape[0], bottom + margin) + left_crop = max(0, left - margin) + right_crop = min(image_array.shape[1], right + margin) + + face_crop = image_array[top_crop:bottom_crop, left_crop:right_crop] + cv2.imwrite(filepath, face_crop) + + self._load_known_faces() + + return f"✅ 已添加已知人脸: {person_name}\n📁 保存位置: {filepath}" + + except Exception as e: + return f"❌ 添加失败: {str(e)}" + + def remove_known_face(self, person_name: str) -> str: + """从已知人脸库中删除人脸""" + try: + filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg") + + if not os.path.exists(filepath): + for filename in os.listdir(self.known_faces_dir): + if filename.endswith(('.jpg', '.jpeg', '.png')): + name = os.path.splitext(filename)[0] + if name.lower() == person_name.lower(): + filepath = os.path.join(self.known_faces_dir, filename) + break + else: + return f"❌ 未找到已知人脸: {person_name}" + + os.remove(filepath) + self._load_known_faces() + + return f"✅ 已删除已知人脸: {person_name}" + + except Exception as e: + return f"❌ 删除失败: {str(e)}" + + def get_face_info(self, person_name: str) -> str: + """获取已知人脸信息""" + try: + filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg") + + if not os.path.exists(filepath): + for filename in os.listdir(self.known_faces_dir): + if filename.endswith(('.jpg', '.jpeg', '.png')): + name = os.path.splitext(filename)[0] + if name.lower() == person_name.lower(): + filepath = os.path.join(self.known_faces_dir, filename) + person_name = name + break + else: + return f"❌ 未找到人脸: {person_name}" + + file_size = os.path.getsize(filepath) + modified_time = datetime.fromtimestamp(os.path.getmtime(filepath)) + + output = [] + output.append(f"👤 人脸信息: {person_name}") + output.append(f"📁 文件路径: {filepath}") + output.append(f"📊 文件大小: {file_size} 字节 ({file_size/1024:.1f}KB)") + output.append(f"🕐 最后修改: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}") + + return "\n".join(output) + + except Exception as e: + return f"❌ 获取人脸信息失败: {str(e)}" + + +def main(): + parser = argparse.ArgumentParser( + description="人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。", + epilog=""" +使用示例: + # 检测人脸 + face-detector --action detect --image-path photo.jpg + + # 检测人脸(不保存裁剪) + face-detector --action detect --image-path photo.jpg --no-save + + # 检测人脸(不进行识别) + face-detector --action detect --image-path photo.jpg --no-recognize + + # 列出已保存的人脸图像 + face-detector --action list-faces --limit 10 + + # 列出已知人脸库 + face-detector --action list-known + + # 添加已知人脸 + face-detector --action add-known --image-path face.jpg --person-name "张三" + + # 删除已知人脸 + face-detector --action remove-known --person-name "张三" + + # 查看人脸信息 + face-detector --action get-info --person-name "张三" + +目录结构: + ~/.aibox/faces/ - 人脸图像保存位置 + ~/.aibox/known_faces/ - 已知人脸数据库 + +需要安装: + pip install face_recognition opencv-python + """ + ) + + parser.add_argument("--action", "-a", required=True, + choices=["detect", "list-faces", "list-known", "add-known", "remove-known", "get-info"], + help="操作类型") + + # 检测参数 + parser.add_argument("--image-path", "-i", help="图片文件路径") + parser.add_argument("--no-save", action="store_true", help="不保存裁剪的人脸图像") + parser.add_argument("--no-recognize", action="store_true", help="不进行人脸识别") + parser.add_argument("--tolerance", "-t", type=float, default=0.6, help="识别容差(默认: 0.6)") + + # 列表参数 + parser.add_argument("--limit", "-l", type=int, default=20, help="列出文件数量限制(默认: 20)") + + # 已知人脸参数 + parser.add_argument("--person-name", "-n", help="人名标识") + + args = parser.parse_args() + + detector = FaceDetector() + + try: + if args.action == "detect": + if not args.image_path: + print("❌ 需要提供 --image-path 参数") + sys.exit(1) + + result = detector.detect_faces( + image_path=args.image_path, + save_cropped=not args.no_save, + recognize=not args.no_recognize, + tolerance=args.tolerance + ) + print(result) + + elif args.action == "list-faces": + print(detector.list_saved_faces(args.limit)) + + elif args.action == "list-known": + print(detector.list_known_faces()) + + elif args.action == "add-known": + if not args.image_path or not args.person_name: + print("❌ 需要提供 --image-path 和 --person-name 参数") + sys.exit(1) + + result = detector.add_known_face(args.image_path, args.person_name) + print(result) + + elif args.action == "remove-known": + if not args.person_name: + print("❌ 需要提供 --person-name 参数") + sys.exit(1) + + result = detector.remove_known_face(args.person_name) + print(result) + + elif args.action == "get-info": + if not args.person_name: + print("❌ 需要提供 --person-name 参数") + sys.exit(1) + + result = detector.get_face_info(args.person_name) + print(result) + + except Exception as e: + print(f"❌ 执行错误: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/skills/famer.sh b/aibox/skills/famer.sh new file mode 100755 index 0000000..8810dec --- /dev/null +++ b/aibox/skills/famer.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# qing_famer - 检查土壤温湿度的技能 +# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C) + +show_help() { + cat < Dict: + self._rate_limit_wait() + try: + if method == "GET": + response = self.session.get(url, params=params, timeout=30) + else: + response = self.session.post(url, json=params, timeout=30) + response.raise_for_status() + return response.json() if response.content else {} + except Exception as e: + return {"error": str(e)} + + def _truncate(self, text: str, max_len: int = MAX_STRING_LENGTH) -> str: + if not text: + return "" + return text[:max_len] + "..." if len(text) > max_len else text + + def _format_date(self, date_str: str) -> str: + if not date_str: + return "" + try: + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + return dt.strftime("%Y-%m-%d") + except: + return date_str + + def _run_git(self, repo_path: str, cmd: List[str]) -> Dict: + try: + result = subprocess.run( + ["git"] + cmd, cwd=repo_path, capture_output=True, + text=True, timeout=60, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"} + ) + return {"success": result.returncode == 0, "stdout": result.stdout.strip(), "stderr": result.stderr.strip()} + except Exception as e: + return {"success": False, "error": str(e)} + + def _auth_url(self, repo_url: str) -> str: + if GITHUB_TOKEN and repo_url.startswith("https://"): + return repo_url.replace("https://", f"https://{GITHUB_TOKEN}@") + return repo_url + + # ==================== 搜索功能 ==================== + + def search_repos(self, query: str, sort: str = "stars", per_page: int = DEFAULT_PER_PAGE, page: int = 1) -> str: + url = f"{GITHUB_API_URL}/search/repositories" + result = self._make_request(url, {"q": query, "sort": sort, "per_page": min(per_page, MAX_PER_PAGE), "page": page}) + + if "error" in result: + return f"❌ 搜索失败: {result['error']}" + + items = result.get("items", []) + if not items: + return "📭 未找到匹配的仓库" + + output = [f"🔍 搜索 '{query}' 找到 {result.get('total_count', 0)} 个仓库 (第{page}页):\n"] + for repo in items[:MAX_LIST_PREVIEW]: + output.append(f" 📦 {repo.get('full_name')}") + output.append(f" ⭐ {repo.get('stargazers_count', 0)} | 🍴 {repo.get('forks_count', 0)} | 📝 {repo.get('language', '未知')}") + output.append(f" 📖 {self._truncate(repo.get('description', '无描述'))}") + output.append(f" 🔗 {repo.get('html_url')}\n") + return "\n".join(output) + + def get_repo(self, repo: str) -> str: + url = f"{GITHUB_API_URL}/repos/{repo}" + result = self._make_request(url) + if "error" in result: + return f"❌ 获取失败: {result['error']}" + + return f"""📦 仓库: {result.get('full_name')} +📖 描述: {self._truncate(result.get('description', '无描述'))} +⭐ Stars: {result.get('stargazers_count', 0)} | 🍴 Forks: {result.get('forks_count', 0)} | 👁️ Watchers: {result.get('watchers_count', 0)} +📝 语言: {result.get('language', '未知')} +🌿 默认分支: {result.get('default_branch', 'main')} +📅 更新: {self._format_date(result.get('updated_at'))} +🔗 URL: {result.get('html_url')} +📥 克隆: {result.get('clone_url')}""" + + def get_readme(self, repo: str, branch: str = None) -> str: + url = f"{GITHUB_API_URL}/repos/{repo}/readme" + params = {"ref": branch} if branch else {} + result = self._make_request(url, params) + + if "error" in result or not result.get("content"): + return "❌ 无法获取README" + + import base64 + content = base64.b64decode(result["content"].replace("\n", "")).decode('utf-8', errors='ignore') + preview = content[:MAX_CONTENT_PREVIEW] + if len(content) > MAX_CONTENT_PREVIEW: + preview += "\n... (内容过长,已截断)" + + return f"📖 README ({result.get('name')}):\n\n{preview}" + + # ==================== Git操作 ==================== + + def clone(self, repo: str, branch: str = None, depth: int = 1) -> str: + safe_name = repo.replace("/", "_") + target_dir = os.path.join(WORKSPACE_DIR, safe_name) + + if os.path.exists(target_dir): + return f"⚠️ 仓库已存在: {target_dir}\n💡 使用 pull 命令拉取更新" + + clone_url = self._auth_url(f"https://github.com/{repo}.git") + cmd = ["clone"] + if depth > 0: + cmd.extend(["--depth", str(depth)]) + if branch: + cmd.extend(["--branch", branch]) + cmd.extend([clone_url, target_dir]) + + result = self._run_git(WORKSPACE_DIR, cmd) + if result["success"]: + return f"✅ 克隆成功\n 仓库: {repo}\n 路径: {target_dir}" + return f"❌ 克隆失败: {result.get('stderr', result.get('error', '未知错误'))}" + + def pull(self, repo_path: str, branch: str = None) -> str: + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + + if not branch: + br = self._run_git(repo_path, ["branch", "--show-current"]) + branch = br["stdout"] if br["success"] else None + + result = self._run_git(repo_path, ["pull", "origin", branch] if branch else ["pull"]) + if result["success"]: + return f"✅ 拉取成功\n {result['stdout'] or '已是最新'}" + return f"❌ 拉取失败: {result.get('stderr', result.get('error'))}" + + def push(self, repo_path: str, branch: str = None, force: bool = False) -> str: + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + + if not branch: + br = self._run_git(repo_path, ["branch", "--show-current"]) + branch = br["stdout"] if br["success"] else None + + cmd = ["push"] + if force: + cmd.append("--force") + if branch: + cmd.extend(["origin", branch]) + + result = self._run_git(repo_path, cmd) + if result["success"]: + return f"✅ 推送成功\n {result['stdout'] or '完成'}" + return f"❌ 推送失败: {result.get('stderr', result.get('error'))}" + + def commit(self, repo_path: str, message: str, all_files: bool = False) -> str: + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + + self._run_git(repo_path, ["config", "user.name", GIT_USER_NAME]) + self._run_git(repo_path, ["config", "user.email", GIT_USER_EMAIL]) + + add_cmd = ["add", "-A"] if all_files else ["add", "-u"] + add_result = self._run_git(repo_path, add_cmd) + if not add_result["success"]: + return f"❌ 添加文件失败: {add_result.get('stderr')}" + + status = self._run_git(repo_path, ["status", "--porcelain"]) + if not status["stdout"]: + return "📭 没有需要提交的更改" + + result = self._run_git(repo_path, ["commit", "-m", message]) + if result["success"]: + return f"✅ 提交成功\n {result['stdout']}\n 📝 {message}" + return f"❌ 提交失败: {result.get('stderr', result.get('error'))}" + + def status(self, repo_path: str) -> str: + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + + branch = self._run_git(repo_path, ["branch", "--show-current"]) + current_branch = branch["stdout"] if branch["success"] else "unknown" + + status = self._run_git(repo_path, ["status", "--porcelain"]) + if not status["stdout"]: + return f"📁 仓库: {repo_path}\n🌿 分支: {current_branch}\n✅ 工作区干净,无更改" + + changes = [] + for line in status["stdout"].split("\n"): + if line: + code = line[:2].strip() + file_path = line[3:].strip() + status_map = {"M": "修改", "A": "新增", "D": "删除", "?": "未跟踪"} + changes.append(f" {status_map.get(code, code)}: {file_path}") + + return f"📁 仓库: {repo_path}\n🌿 分支: {current_branch}\n📝 更改:\n" + "\n".join(changes[:20]) + + def branches(self, repo_path: str, remote: bool = False) -> str: + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + + cmd = ["branch", "-r"] if remote else ["branch"] + result = self._run_git(repo_path, cmd) + if result["success"]: + branches = [b.strip().replace("* ", "") for b in result["stdout"].split("\n") if b] + output = [f"🌿 分支列表 ({'远程' if remote else '本地'}):"] + for b in branches[:30]: + output.append(f" {b}") + return "\n".join(output) + return f"❌ 获取分支失败: {result.get('stderr')}" + + def list_local(self) -> str: + repos = [] + if os.path.exists(WORKSPACE_DIR): + for item in os.listdir(WORKSPACE_DIR): + item_path = os.path.join(WORKSPACE_DIR, item) + if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")): + branch = self._run_git(item_path, ["branch", "--show-current"]) + repos.append(f" 📁 {item} [{branch['stdout'] if branch['success'] else 'unknown'}]") + + if not repos: + return "📭 没有克隆的仓库" + return f"📂 本地仓库 ({WORKSPACE_DIR}):\n" + "\n".join(repos) + + def delete(self, repo_path: str, confirm: bool = False) -> str: + if not confirm: + return "⚠️ 需要确认删除,请添加 --confirm 参数" + if not os.path.exists(repo_path): + return f"❌ 路径不存在: {repo_path}" + try: + shutil.rmtree(repo_path) + return f"✅ 已删除: {repo_path}" + except Exception as e: + return f"❌ 删除失败: {e}" + + +# ==================== 命令行解析 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="GitHub 仓库管理工具 - 搜索、克隆、推送、拉取、提交等完整Git操作。", + epilog=""" +使用示例: + # 搜索仓库 + git-tool --action search_repos --query "python" + + # 获取仓库信息 + git-tool --action get_repo --repo "owner/repo" + + # 克隆仓库 + git-tool --action clone --repo "owner/repo" --branch main + + # 查看状态 + git-tool --action status --repo_path "/path/to/repo" + + # 提交更改 + git-tool --action commit --repo_path "/path/to/repo" --message "fix: bug修复" --all + + # 推送代码 + git-tool --action push --repo_path "/path/to/repo" --branch main + + # 拉取更新 + git-tool --action pull --repo_path "/path/to/repo" + + # 列出本地仓库 + git-tool --action list_local + + # 删除本地仓库 + git-tool --action delete --repo_path "/path/to/repo" --confirm + +环境变量: + GITHUB_TOKEN GitHub访问令牌(推送必需) + GIT_USER_NAME Git用户名 + GIT_USER_EMAIL Git邮箱 + GIT_WORKSPACE 工作目录(默认 ~/.aibox/git_workspace) + """ + ) + + parser.add_argument("--action", "-a", required=True, + choices=["search_repos", "get_repo", "get_readme", "clone", "pull", "push", + "commit", "status", "branches", "list_local", "delete"], + help="操作类型") + + # 搜索参数 + parser.add_argument("--query", "-q", help="搜索关键词") + parser.add_argument("--sort", default="stars", help="排序方式 (stars/forks/updated)") + parser.add_argument("--per_page", "-n", type=int, default=5, help="每页数量 (1-10)") + parser.add_argument("--page", "-p", type=int, default=1, help="页码") + + # 仓库参数 + parser.add_argument("--repo", "-r", help="仓库全名 (格式: owner/repo)") + parser.add_argument("--branch", "-b", help="分支名") + parser.add_argument("--depth", type=int, default=1, help="克隆深度 (1=浅克隆)") + + # Git操作参数 + parser.add_argument("--repo_path", help="本地仓库路径") + parser.add_argument("--message", "-m", help="提交消息") + parser.add_argument("--all", "-A", action="store_true", help="提交所有更改(包括新文件)") + parser.add_argument("--force", "-f", action="store_true", help="强制推送") + parser.add_argument("--remote", action="store_true", help="列出远程分支") + parser.add_argument("--confirm", action="store_true", help="确认删除操作") + + args = parser.parse_args() + + tool = GitHubTool() + + # 执行操作 + if args.action == "search_repos": + if not args.query: + print("❌ 需要 --query 参数") + sys.exit(1) + print(tool.search_repos(args.query, args.sort, args.per_page, args.page)) + + elif args.action == "get_repo": + if not args.repo: + print("❌ 需要 --repo 参数") + sys.exit(1) + print(tool.get_repo(args.repo)) + + elif args.action == "get_readme": + if not args.repo: + print("❌ 需要 --repo 参数") + sys.exit(1) + print(tool.get_readme(args.repo, args.branch)) + + elif args.action == "clone": + if not args.repo: + print("❌ 需要 --repo 参数") + sys.exit(1) + print(tool.clone(args.repo, args.branch, args.depth)) + + elif args.action == "pull": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + print(tool.pull(args.repo_path, args.branch)) + + elif args.action == "push": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + if not GITHUB_TOKEN: + print("⚠️ 未设置 GITHUB_TOKEN,推送可能失败") + print(tool.push(args.repo_path, args.branch, args.force)) + + elif args.action == "commit": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + if not args.message: + print("❌ 需要 --message 参数") + sys.exit(1) + print(tool.commit(args.repo_path, args.message, args.all)) + + elif args.action == "status": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + print(tool.status(args.repo_path)) + + elif args.action == "branches": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + print(tool.branches(args.repo_path, args.remote)) + + elif args.action == "list_local": + print(tool.list_local()) + + elif args.action == "delete": + if not args.repo_path: + print("❌ 需要 --repo_path 参数") + sys.exit(1) + print(tool.delete(args.repo_path, args.confirm)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/skills/hello-world.py b/aibox/skills/hello-world.py new file mode 100755 index 0000000..a1985db --- /dev/null +++ b/aibox/skills/hello-world.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""hello-world - 一个简单的 Hello World 测试技能""" + +import argparse +import sys + + +def main(): + parser = argparse.ArgumentParser( + description="hello-world - 一个简单的 Hello World 测试技能,支持自定义名字、语言和重复次数", + epilog=""" +参数说明: + --name NAME 指定名字(默认: World) + --count COUNT 重复输出次数(默认: 1) + --lang LANG 输出语言,可选: zh(中文), en(英文), both(双语,默认) + +使用示例: + %(prog)s + %(prog)s --name DeepSeek + %(prog)s --name qing --count 3 + %(prog)s --name 张三 --lang zh + %(prog)s --name Alice --lang en + %(prog)s --name 助手 --count 2 --lang both +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--name", type=str, default="World", help="指定名字(默认: World)") + parser.add_argument("--count", type=int, default=1, help="重复输出次数(默认: 1)") + parser.add_argument("--lang", type=str, default="both", choices=["zh", "en", "both"], + help="输出语言: zh(中文), en(英文), both(双语,默认)") + + args = parser.parse_args() + + if args.count < 1: + print("错误: --count 必须大于等于 1", file=sys.stderr) + sys.exit(1) + + for i in range(args.count): + prefix = f"[{i + 1}/{args.count}] " if args.count > 1 else "" + + outputs = [] + if args.lang in ("zh", "both"): + outputs.append(f"你好,{args.name}!") + if args.lang in ("en", "both"): + outputs.append(f"Hello {args.name}!") + + print(f"{prefix}{' '.join(outputs)}") + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/hello.md b/aibox/skills/hello.md new file mode 100644 index 0000000..2fcc6e4 --- /dev/null +++ b/aibox/skills/hello.md @@ -0,0 +1,23 @@ +向用户热情地打招呼问好,表达友好和乐于助人的态度 + +## 技能说明 +当与用户开始对话或用户主动打招呼时,智能体应使用此技能主动向用户问好,营造友好、温暖的交流氛围。 + +## 使用场景 +- 对话开始时 +- 用户打招呼时(如"你好"、"嗨"、"hello"等) +- 用户长时间未联系后再次出现时 + +## 问候方式示例 +- 👋 嗨,qing!今天有什么我可以帮你的吗? +- 🌞 早上好,qing!今天元气满满,有什么任务需要我帮忙? +- 🌙 晚上好,qing!辛苦了,有什么我能做的吗? +- 🎉 欢迎回来,qing!最近怎么样? +- 😊 Hello,qing!随时准备为你服务~ + +## 注意事项 +- 使用 emoji 增加亲切感 +- 根据时间段选择不同的问候语 +- 称呼用户的名字(qing)让问候更个性化 +- 保持积极、乐观、热情的语气 +- 问好后自然地引导用户说出需求 diff --git a/aibox/skills/info.py b/aibox/skills/info.py new file mode 100755 index 0000000..fe1b2ae --- /dev/null +++ b/aibox/skills/info.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""系统信息工具 - 获取系统运行时间、CPU、内存、磁盘、网络等状态信息。""" + +import sys +import argparse +import platform +import datetime +import os + +try: + import psutil +except ImportError: + print("❌ 缺少依赖库: psutil。请运行: pip install psutil") + sys.exit(1) + + +def get_system_info(): + """获取系统基本信息""" + return { + "系统": platform.system(), + "主机名": platform.node(), + "版本": platform.version(), + "架构": platform.machine(), + "处理器": platform.processor() or "未知", + "Python版本": platform.python_version() + } + + +def get_uptime(): + """获取系统运行时间""" + boot_time = datetime.datetime.fromtimestamp(psutil.boot_time()) + now = datetime.datetime.now() + uptime = now - boot_time + + days = uptime.days + hours = uptime.seconds // 3600 + minutes = (uptime.seconds % 3600) // 60 + seconds = uptime.seconds % 60 + + return { + "启动时间": boot_time.strftime("%Y-%m-%d %H:%M:%S"), + "运行时间": f"{days}天 {hours}小时 {minutes}分钟 {seconds}秒" + } + + +def get_cpu_info(): + """获取CPU信息""" + return { + "物理核心数": psutil.cpu_count(logical=False), + "逻辑核心数": psutil.cpu_count(logical=True), + "使用率": f"{psutil.cpu_percent(interval=1)}%", + "频率": f"{psutil.cpu_freq().current:.2f} MHz" if psutil.cpu_freq() else "未知" + } + + +def get_memory_info(): + """获取内存信息""" + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + return { + "总内存": f"{mem.total / (1024**3):.2f} GB", + "可用内存": f"{mem.available / (1024**3):.2f} GB", + "已用内存": f"{mem.used / (1024**3):.2f} GB", + "内存使用率": f"{mem.percent}%", + "交换分区总大小": f"{swap.total / (1024**3):.2f} GB" if swap.total > 0 else "无", + "交换分区使用率": f"{swap.percent}%" if swap.total > 0 else "无" + } + + +def get_disk_info(path="/"): + """获取磁盘信息""" + try: + disk = psutil.disk_usage(path) + return { + "路径": path, + "总空间": f"{disk.total / (1024**3):.2f} GB", + "已用空间": f"{disk.used / (1024**3):.2f} GB", + "可用空间": f"{disk.free / (1024**3):.2f} GB", + "使用率": f"{disk.percent}%" + } + except Exception as e: + return {"错误": f"无法获取磁盘信息: {str(e)}"} + + +def get_all_disks(): + """获取所有磁盘分区信息""" + partitions = [] + for part in psutil.disk_partitions(): + try: + usage = psutil.disk_usage(part.mountpoint) + partitions.append({ + "挂载点": part.mountpoint, + "文件系统": part.fstype, + "总空间": f"{usage.total / (1024**3):.2f} GB", + "已用空间": f"{usage.used / (1024**3):.2f} GB", + "可用空间": f"{usage.free / (1024**3):.2f} GB", + "使用率": f"{usage.percent}%" + }) + except: + continue + return partitions + + +def get_process_info(): + """获取进程信息""" + return { + "进程总数": len(psutil.pids()), + "运行中进程": sum(1 for p in psutil.process_iter(['status']) if p.info['status'] == 'running'), + "睡眠进程": sum(1 for p in psutil.process_iter(['status']) if p.info['status'] == 'sleeping') + } + + +def get_network_info(): + """获取网络信息""" + net = psutil.net_io_counters() + return { + "发送数据": f"{net.bytes_sent / (1024**2):.2f} MB", + "接收数据": f"{net.bytes_recv / (1024**2):.2f} MB", + "发送包数": net.packets_sent, + "接收包数": net.packets_recv + } + + +def get_load_average(): + """获取系统负载(仅Unix系统)""" + if hasattr(os, 'getloadavg'): + load1, load5, load15 = os.getloadavg() + cpu_count = psutil.cpu_count() + return { + "1分钟负载": f"{load1:.2f} (每核心: {load1/cpu_count:.2f})", + "5分钟负载": f"{load5:.2f} (每核心: {load5/cpu_count:.2f})", + "15分钟负载": f"{load15:.2f} (每核心: {load15/cpu_count:.2f})" + } + else: + return {"信息": "系统负载信息在Windows上不可用"} + + +def get_temperature_info(): + """获取温度信息(仅部分系统支持)""" + try: + temps = psutil.sensors_temperatures() + if not temps: + return {"信息": "温度传感器信息不可用"} + + result = {} + for name, entries in temps.items(): + for entry in entries: + result[f"{name}"] = f"{entry.current}°C" + if hasattr(entry, 'high') and entry.high: + result[f"{name}_警告阈值"] = f"{entry.high}°C" + break + return result + except: + return {"信息": "温度传感器信息不可用"} + + +def get_battery_info(): + """获取电池信息(仅笔记本)""" + try: + battery = psutil.sensors_battery() + if not battery: + return {"信息": "电池信息不可用(可能是台式机)"} + + return { + "电量百分比": f"{battery.percent}%", + "充电中": "是" if battery.power_plugged else "否", + "剩余时间": str(datetime.timedelta(seconds=battery.secsleft)) if battery.secsleft != -1 else "未知" + } + except: + return {"信息": "电池信息不可用"} + + +def format_output(data, output_format="text"): + """格式化输出""" + if output_format == "json": + import json + return json.dumps(data, ensure_ascii=False, indent=2) + else: + lines = [] + for key, value in data.items(): + if isinstance(value, dict): + lines.append(f"\n📁 {key}:") + for k, v in value.items(): + lines.append(f" {k}: {v}") + else: + lines.append(f"{key}: {value}") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="系统信息工具 - 获取系统运行时间、CPU、内存、磁盘、网络等状态信息。", + epilog=""" +使用示例: + # 获取系统基本信息 + sysinfo --info system + + # 获取CPU信息 + sysinfo --info cpu + + # 获取内存信息 + sysinfo --info memory + + # 获取指定磁盘信息 + sysinfo --info disk --disk-path /home + + # 获取所有磁盘分区 + sysinfo --info disks + + # 获取所有信息(文本格式) + sysinfo --info all + + # 获取所有信息(JSON格式) + sysinfo --info all --format json + + # 获取系统负载 + sysinfo --info load + + # 获取温度信息 + sysinfo --info temperature + + # 获取电池信息 + sysinfo --info battery + + # 组合多个信息 + sysinfo --info cpu --info memory --info disk + """ + ) + + # 支持多个info参数 + parser.add_argument("--info", "-i", action="append", + choices=["system", "uptime", "cpu", "memory", "disk", "disks", "process", + "network", "load", "temperature", "battery", "all"], + help="要获取的信息类型(可多次使用)") + + parser.add_argument("--disk-path", "-d", type=str, default="/", + help="磁盘检查路径(用于disk信息)") + + parser.add_argument("--format", "-f", choices=["text", "json"], default="text", + help="输出格式(默认: text)") + + args = parser.parse_args() + + # 如果没有指定任何info,显示帮助 + if not args.info: + parser.print_help() + return + + # 收集信息 + result = {} + info_types = args.info + + if "all" in info_types: + info_types = ["system", "uptime", "cpu", "memory", "disk", "disks", "process", "network", "load", "temperature", "battery"] + + for info_type in info_types: + if info_type == "system": + result["系统信息"] = get_system_info() + elif info_type == "uptime": + result["运行时间"] = get_uptime() + elif info_type == "cpu": + result["CPU信息"] = get_cpu_info() + elif info_type == "memory": + result["内存信息"] = get_memory_info() + elif info_type == "disk": + result[f"磁盘信息({args.disk_path})"] = get_disk_info(args.disk_path) + elif info_type == "disks": + result["所有磁盘分区"] = get_all_disks() + elif info_type == "process": + result["进程信息"] = get_process_info() + elif info_type == "network": + result["网络信息"] = get_network_info() + elif info_type == "load": + result["系统负载"] = get_load_average() + elif info_type == "temperature": + result["温度信息"] = get_temperature_info() + elif info_type == "battery": + result["电池信息"] = get_battery_info() + + # 输出结果 + print(format_output(result, args.format)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/skills/md2docx.py b/aibox/skills/md2docx.py new file mode 100755 index 0000000..33d903a --- /dev/null +++ b/aibox/skills/md2docx.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +md2docx - 输出 Pandoc Markdown 转 Word 命令使用手册 +""" + +import sys + +MANUAL = """ +╔══════════════════════════════════════════════════════════════╗ +║ Pandoc 命令使用手册(Markdown → Word 篇) ║ +╚══════════════════════════════════════════════════════════════╝ + +📌 概述 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +pandoc 是一个强大的通用文档格式转换工具,由 John MacFarlane 开发。 +本手册聚焦于将 Markdown 文件转换为 Word (.docx) 文档的常用用法。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📦 基本命令格式 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + pandoc 输入文件.md -o 输出文件.docx + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🔧 核心参数详解 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + -o, --output FILE 指定输出 Word 文档路径 + --reference-doc FILE 使用样式参考模板(控制输出样式) + --metadata KEY=VAL 设置文档元数据(如 title="标题") + --toc 启用自动目录 + --toc-depth=N 设置目录层级深度(默认 3) + -f, --from FORMAT 指定输入格式(如 markdown) + -t, --to FORMAT 指定输出格式(如 docx) + --resource-path PATH 指定图片等资源搜索路径 + --wrap=MODE 设置换行模式(preserve/auto/none) + -V, --variable KEY=VAL 设置模板变量 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📋 常用示例 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +1️⃣ 基本转换 + pandoc README.md -o README.docx + +2️⃣ 指定输出文件名 + pandoc 文档.md -o 项目报告.docx + +3️⃣ 使用样式模板 + pandoc 文档.md -o 报告.docx --reference-doc 模板.docx + +4️⃣ 添加标题 + 自动目录 + pandoc 文档.md -o 报告.docx --metadata title="项目报告" --toc + +5️⃣ 自定义目录深度(2级) + pandoc 文档.md -o 报告.docx --toc --toc-depth=2 + +6️⃣ 从管道输入 + echo "# Hello" | pandoc -o output.docx + +7️⃣ 批量转换所有 .md 文件 + for f in *.md; do pandoc "$f" -o "${{f%.md}}.docx"; done + +8️⃣ 带图片的文档 + pandoc 文档.md -o 文档.docx --resource-path=./images + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎨 样式模板制作技巧 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +导出一个默认参考模板,用 Word 修改样式后复用: + + pandoc -o custom-reference.docx \\ + --print-default-data-file reference.docx + +修改完成后,用 --reference-doc 引用即可控制输出样式。 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📥 安装 pandoc +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Debian/Ubuntu: sudo apt-get install -y pandoc + macOS: brew install pandoc + Windows: winget install pandoc + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ 常见问题 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ❓ 中文乱码? → 安装中文字体:sudo apt-get install fonts-wqy-microhei + ❓ 目录不生效? → 确保 Markdown 标题级别正确(# 一级、## 二级) + ❓ 图片不显示? → 使用 --resource-path 指定图片所在目录 + ❓ 模板无效? → 确认模板是用 Word 修改样式后的 .docx 文件 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💡 更多信息 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Pandoc 官方文档: https://pandoc.org/MANUAL.html + 运行 pandoc --help 查看所有参数 +""" + + +def main(): + # 检查参数 + if len(sys.argv) > 1: + arg = sys.argv[1] + if arg in ("-h", "--help"): + print(__doc__.strip()) + print("\n运行本脚本(不带参数)即可输出 Pandoc 命令使用手册。") + sys.exit(0) + elif arg in ("-v", "--version"): + print("md2docx v1.0.0 (Pandoc 命令手册版)") + sys.exit(0) + else: + print(f"未知参数: {arg}") + print("使用 --help 查看帮助") + sys.exit(1) + + # 输出手册 + print(MANUAL) + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/nmapskills.py b/aibox/skills/nmapskills.py new file mode 100755 index 0000000..230cc94 --- /dev/null +++ b/aibox/skills/nmapskills.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +一个基于python-nmap的局域网Ping扫描工具 +""" +import argparse +import sys +import socket + +def scan_network(network="192.168.1.0/24", timeout=3, verbose=False): + """使用nmap -sn模式扫描局域网""" + try: + import nmap + except ImportError: + print("❌ 错误:缺少 python-nmap 库,请执行: pip install python-nmap") + sys.exit(1) + + nm = nmap.PortScanner() + arguments = f"-sn -T{4 if timeout > 3 else 3} --max-rtt-timeout={timeout * 1000}ms" + if verbose: + arguments += " -v" + + print(f"🔍 正在扫描网段: {network}") + print(f"⏱️ 超时设置: {timeout}秒") + print(f"{'📋 详细模式: 开启' if verbose else '📋 详细模式: 关闭'}") + print("-" * 50) + + try: + result = nm.scan(hosts=network, arguments=arguments) + except Exception as e: + print(f"❌ 扫描失败: {e}") + print("💡 提示:请确保已安装 nmap (sudo apt install nmap) 并有足够权限") + sys.exit(1) + + alive_hosts = [] + for host in nm.all_hosts(): + if nm[host].state() == "up": + alive_hosts.append(host) + hostname = "" + try: + hostname = socket.gethostbyaddr(host)[0] + except (socket.herror, socket.gaierror): + hostname = "(未知)" + + mac = "" + try: + if 'mac' in nm[host]['addresses']: + mac = nm[host]['addresses']['mac'] + except (KeyError, TypeError): + pass + + line = f"✅ {host}" + if verbose: + if hostname and hostname != "(未知)": + line += f" [{hostname}]" + if mac: + line += f" MAC: {mac}" + print(line) + + print("-" * 50) + print(f"📊 扫描完成!共发现 {len(alive_hosts)} 台在线主机") + return alive_hosts + + +def main(): + parser = argparse.ArgumentParser( + description="基于python-nmap的局域网Ping扫描工具,快速发现局域网在线设备", + epilog=""" +参数说明: + -n NETWORK 指定扫描网段 (默认: 192.168.1.0/24) + 支持格式: 192.168.1.0/24, 192.168.1.1-254, 192.168.1.1 + -t TIMEOUT 超时时间,单位秒 (默认: 3) + -v 详细输出模式,显示MAC地址和主机名 + --help 显示本帮助信息 + +使用示例: + nmapskills # 扫描默认网段 192.168.1.0/24 + nmapskills -n 10.0.0.0/24 # 扫描 10.0.0.0/24 网段 + nmapskills -n 192.168.1.1-100 # 扫描 192.168.1.1 到 192.168.1.100 + nmapskills -n 192.168.1.0/24 -t 5 # 扫描并设置5秒超时 + nmapskills -n 192.168.1.0/24 -v # 详细模式扫描 + """ + ) + + parser.add_argument("-n", default="192.168.1.0/24", + help="指定扫描网段 (默认: 192.168.1.0/24)") + parser.add_argument("-t", type=int, default=3, + help="超时时间,单位秒 (默认: 3)") + parser.add_argument("-v", action="store_true", + help="详细输出模式,显示MAC地址和主机名") + + args = parser.parse_args() + + scan_network(network=args.n, timeout=args.t, verbose=args.v) + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/qing_famer.sh b/aibox/skills/qing_famer.sh new file mode 100755 index 0000000..097dffe --- /dev/null +++ b/aibox/skills/qing_famer.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# qing_famer - 检查土壤温湿度的技能 +# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C) + +show_help() { + cat < 0: + time.sleep(delay) + + try: + ps_script = f''' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$screen = [System.Windows.Forms.Screen]::PrimaryScreen +$bounds = $screen.Bounds + +$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +$graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size) + +$bitmap.Save("{output_path}", [System.Drawing.Imaging.ImageFormat]::Png) +$graphics.Dispose() +$bitmap.Dispose() + +Write-Output "截图已保存: {output_path}" +''' + + result = subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0 and os.path.exists(output_path): + return True, f"截图成功: {output_path}" + else: + error_msg = result.stderr if result.stderr else "未知错误" + return False, f"截图失败: {error_msg}" + + except subprocess.TimeoutExpired: + return False, "截图超时" + except Exception as e: + return False, f"截图错误: {str(e)}" + + +def take_screenshot_linux(output_path, delay=0): + """Linux平台截图""" + if delay > 0: + time.sleep(delay) + + tools = [] + + if subprocess.run(["which", "scrot"], capture_output=True).returncode == 0: + tools.append(("scrot", ["scrot", "-z", output_path])) + + if subprocess.run(["which", "gnome-screenshot"], capture_output=True).returncode == 0: + tools.append(("gnome-screenshot", ["gnome-screenshot", "-f", output_path])) + + if subprocess.run(["which", "import"], capture_output=True).returncode == 0: + tools.append(("import", ["import", "-window", "root", output_path])) + + if not tools: + return False, "未找到截图工具,请安装 scrot, gnome-screenshot 或 ImageMagick" + + for tool_name, cmd in tools: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0 and os.path.exists(output_path): + return True, f"截图成功 (使用 {tool_name}): {output_path}" + except: + continue + + return False, "所有截图工具都失败了" + + +def take_screenshot_macos(output_path, delay=0): + """macOS平台截图""" + if delay > 0: + time.sleep(delay) + + try: + if subprocess.run(["which", "screencapture"], capture_output=True).returncode != 0: + return False, "未找到 screencapture 命令" + + cmd = ["screencapture", "-x", output_path] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + if result.returncode == 0 and os.path.exists(output_path): + return True, f"截图成功: {output_path}" + else: + error_msg = result.stderr if result.stderr else "未知错误" + return False, f"截图失败: {error_msg}" + + except subprocess.TimeoutExpired: + return False, "截图超时" + except Exception as e: + return False, f"截图错误: {str(e)}" + + +# ==================== 截图函数 ==================== + +def take_screenshot(output_dir=None, filename=None, delay=0): + """截取屏幕截图""" + system = get_system() + + if system not in SUPPORTED_SYSTEMS: + return False, f"不支持的操作系统: {system},支持的系统: {', '.join(SUPPORTED_SYSTEMS)}", None + + if output_dir: + save_dir = output_dir + else: + save_dir = ensure_screenshot_dir() + + os.makedirs(save_dir, exist_ok=True) + + if filename: + filepath = os.path.join(save_dir, filename) + else: + filepath = os.path.join(save_dir, get_timestamp_filename()) + + if system == 'Windows': + success, message = take_screenshot_windows(filepath, delay) + elif system == 'Linux': + success, message = take_screenshot_linux(filepath, delay) + elif system == 'Darwin': + success, message = take_screenshot_macos(filepath, delay) + else: + return False, f"不支持的操作系统: {system}", None + + if success: + return True, message, filepath + else: + return False, message, None + + +def list_recent_screenshots(limit=10): + """列出最近的截图文件""" + save_dir = ensure_screenshot_dir() + + if not os.path.exists(save_dir): + return f"截图目录不存在: {save_dir}" + + screenshots = [] + for f in os.listdir(save_dir): + if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')): + filepath = os.path.join(save_dir, f) + mtime = os.path.getmtime(filepath) + size = os.path.getsize(filepath) + screenshots.append((mtime, f, size)) + + screenshots.sort(reverse=True) + + if not screenshots: + return f"截图目录中没有截图文件: {save_dir}" + + result = [f"📸 最近的截图 (共 {len(screenshots)} 个):"] + for i, (mtime, fname, size) in enumerate(screenshots[:limit], 1): + dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + + if size < 1024: + size_str = f"{size}B" + elif size < 1024 * 1024: + size_str = f"{size/1024:.1f}KB" + else: + size_str = f"{size/1024/1024:.1f}MB" + + result.append(f" {i}. {fname} - {dt} ({size_str})") + + if len(screenshots) > limit: + result.append(f" ... 还有 {len(screenshots) - limit} 个文件") + + return "\n".join(result) + + +# ==================== 命令行接口 ==================== + +def main(): + parser = argparse.ArgumentParser( + description="截图工具 - 截取计算机桌面并保存。", + epilog=""" +使用示例: + # 立即截图(自动生成文件名) + screenshot + + # 3秒后截图 + screenshot --delay 3 + + # 截图并指定文件名 + screenshot --filename "my_screenshot.png" + + # 截图并保存到指定目录 + screenshot --output-dir "~/Desktop" + + # 列出最近的截图 + screenshot --list --limit 5 + + # 5秒后截图并保存到桌面 + screenshot --delay 5 --output-dir "~/Desktop" + +截图保存位置: + ~/.aibox/screenshot/ + 自动命名格式: screenshot_YYYYMMDD_HHMMSS.png + +支持的截图工具: + Linux: scrot, gnome-screenshot, ImageMagick + macOS: screencapture (系统自带) + Windows: PowerShell .NET (系统自带) + """ + ) + + parser.add_argument("--output-dir", "-o", help="截图保存目录(默认: ~/.aibox/screenshot/)") + parser.add_argument("--filename", "-f", help="文件名(默认: 自动生成时间戳文件名)") + parser.add_argument("--delay", "-d", type=int, default=0, help="延迟秒数(默认: 0)") + parser.add_argument("--list", "-l", action="store_true", help="列出最近的截图文件") + parser.add_argument("--limit", "-n", type=int, default=10, help="列出截图时的数量限制(默认: 10)") + + args = parser.parse_args() + + # 列出截图 + if args.list: + print(list_recent_screenshots(args.limit)) + return + + # 执行截图 + try: + success, message, filepath = take_screenshot( + output_dir=args.output_dir, + filename=args.filename, + delay=args.delay + ) + + if success: + print(f"✅ {message}") + + if filepath and os.path.exists(filepath): + file_size = os.path.getsize(filepath) + if file_size < 1024: + size_str = f"{file_size}B" + elif file_size < 1024 * 1024: + size_str = f"{file_size/1024:.1f}KB" + else: + size_str = f"{file_size/1024/1024:.1f}MB" + print(f"📦 文件大小: {size_str}") + + # 显示目录中的截图总数 + save_dir = args.output_dir if args.output_dir else SCREENSHOT_DIR + save_dir = os.path.expanduser(save_dir) + if os.path.exists(save_dir): + screenshots = [f for f in os.listdir(save_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg'))] + print(f"📁 该目录共有 {len(screenshots)} 张截图") + else: + print(f"❌ {message}") + sys.exit(1) + + except Exception as e: + print(f"❌ 执行错误: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/skills/sftp-transfer.py b/aibox/skills/sftp-transfer.py new file mode 100755 index 0000000..44d0a10 --- /dev/null +++ b/aibox/skills/sftp-transfer.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +sftp-transfer - 基于SFTP协议的文件上传/下载工具 + +支持从远程SSH服务器上传和下载文件,支持密码和密钥认证方式。 +""" + +import argparse +import os +import sys +import stat +import getpass + +try: + import paramiko +except ImportError: + print("错误:需要 paramiko 库,请执行: pip install paramiko", file=sys.stderr) + sys.exit(1) + + +def build_parser(): + parser = argparse.ArgumentParser( + description="sftp-transfer - 基于SFTP协议的文件上传/下载工具", + epilog=""" +参数说明: + 连接参数: + --host SSH服务器主机名或IP地址 (必填) + --port SSH服务器端口号,默认 22 + -u, --username SSH登录用户名 (必填) + -p, --password SSH登录密码 (如不提供则交互式输入) + --key SSH私钥文件路径 (用于密钥认证) + + 操作参数: + --upload LOCAL REMOTE 上传:本地文件/目录 -> 远程服务器路径 + --download REMOTE LOCAL 下载:远程服务器文件/目录 -> 本地路径 + -r, --recursive 递归传输目录 + -v, --verbose 显示详细信息 + +使用示例: + 1. 上传文件(密码认证): + sftp-transfer --host 192.168.1.100 -u root -p mypass --upload /tmp/test.txt /root/ + + 2. 下载文件(密钥认证): + sftp-transfer --host 192.168.1.100 -u root --key ~/.ssh/id_rsa \\ + --download /root/test.txt /tmp/ + + 3. 递归上传目录: + sftp-transfer --host 192.168.1.100 -u user -p pass -r \\ + --upload ./mydir /home/user/mydir + + 4. 下载目录: + sftp-transfer --host 192.168.1.100 -u user -p pass -r \\ + --download /home/user/backup ./backup + + 5. 交互式输入密码: + sftp-transfer --host 127.0.0.1 -u qing --upload ./file.txt /tmp/ + (此时会提示输入密码) + """ + ) + # 连接参数 + parser.add_argument('--host', required=True, help='SSH服务器主机名或IP地址') + parser.add_argument('--port', type=int, default=22, help='SSH服务器端口号,默认22') + parser.add_argument('-u', '--username', required=True, help='SSH登录用户名') + parser.add_argument('-p', '--password', help='SSH登录密码(如不提供则交互式输入)') + parser.add_argument('--key', help='SSH私钥文件路径(用于密钥认证)') + + # 操作参数 + parser.add_argument('--upload', nargs=2, metavar=('LOCAL', 'REMOTE'), + help='上传:本地文件/目录 -> 远程服务器路径') + parser.add_argument('--download', nargs=2, metavar=('REMOTE', 'LOCAL'), + help='下载:远程服务器文件/目录 -> 本地路径') + parser.add_argument('-r', '--recursive', action='store_true', + help='递归传输目录') + parser.add_argument('-v', '--verbose', action='store_true', + help='显示详细信息') + return parser + + +def log(verbose, msg): + if verbose: + print(f"[SFTP] {msg}") + + +def get_password(args): + """获取密码:优先从命令行参数获取,否则交互式输入""" + if args.password: + return args.password + return getpass.getpass(f"请输入 {args.username}@{args.host} 的密码: ") + + +def connect_sftp(args, password): + """建立SSH连接并返回SFTP客户端""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + connect_kwargs = { + 'hostname': args.host, + 'port': args.port, + 'username': args.username, + 'timeout': 15, + } + + if args.key: + connect_kwargs['key_filename'] = args.key + if password: + connect_kwargs['password'] = password + else: + connect_kwargs['password'] = password + + log(args.verbose, f"正在连接 {args.username}@{args.host}:{args.port} ...") + client.connect(**connect_kwargs) + log(args.verbose, "连接成功!") + + sftp = client.open_sftp() + return client, sftp + + +def ensure_remote_dir(sftp, remote_path, verbose): + """确保远程目录存在,递归创建""" + parts = remote_path.rstrip('/').split('/') + current = '' + for part in parts: + if not part: + continue + current += '/' + part + try: + sftp.stat(current) + except FileNotFoundError: + log(verbose, f" 创建远程目录: {current}") + sftp.mkdir(current) + + +def upload_file(sftp, local_path, remote_path, verbose): + """上传单个文件""" + remote_dir = os.path.dirname(remote_path) + if remote_dir: + ensure_remote_dir(sftp, remote_dir, verbose) + log(verbose, f" 上传: {local_path} -> {remote_path}") + sftp.put(local_path, remote_path) + + +def upload_dir(sftp, local_dir, remote_dir, verbose): + """递归上传目录""" + ensure_remote_dir(sftp, remote_dir, verbose) + for item in os.listdir(local_dir): + local_item = os.path.join(local_dir, item) + remote_item = os.path.join(remote_dir, item) + if os.path.isdir(local_item): + log(verbose, f" 进入目录: {local_item}") + upload_dir(sftp, local_item, remote_item, verbose) + else: + upload_file(sftp, local_item, remote_item, verbose) + + +def download_file(sftp, remote_path, local_path, verbose): + """下载单个文件""" + local_dir = os.path.dirname(local_path) + if local_dir and not os.path.exists(local_dir): + os.makedirs(local_dir, exist_ok=True) + log(verbose, f" 下载: {remote_path} -> {local_path}") + sftp.get(remote_path, local_path) + + +def download_dir(sftp, remote_dir, local_dir, verbose): + """递归下载目录""" + if not os.path.exists(local_dir): + os.makedirs(local_dir, exist_ok=True) + log(verbose, f" 创建本地目录: {local_dir}") + + for item in sftp.listdir(remote_dir): + remote_item = os.path.join(remote_dir, item).replace('\\', '/') + local_item = os.path.join(local_dir, item) + try: + attr = sftp.stat(remote_item) + if stat.S_ISDIR(attr.st_mode): + log(verbose, f" 进入目录: {remote_item}") + download_dir(sftp, remote_item, local_item, verbose) + else: + download_file(sftp, remote_item, local_item, verbose) + except Exception as e: + print(f" 警告: 无法处理 {remote_item}: {e}", file=sys.stderr) + + +def main(): + parser = build_parser() + args = parser.parse_args() + + # 检查操作参数 + if not args.upload and not args.download: + parser.print_help() + print("\n错误:必须指定 --upload 或 --download 操作", file=sys.stderr) + sys.exit(1) + + if args.upload and args.download: + print("错误:不能同时指定 --upload 和 --download", file=sys.stderr) + sys.exit(1) + + # 获取密码 + password = get_password(args) + + # 建立连接 + try: + client, sftp = connect_sftp(args, password) + except Exception as e: + print(f"错误:连接失败 - {e}", file=sys.stderr) + sys.exit(1) + + try: + if args.upload: + local_path, remote_path = args.upload + remote_path = remote_path.replace('\\', '/') + + if os.path.isdir(local_path): + if not args.recursive: + print("错误:本地路径是目录,请使用 -r 参数递归上传", file=sys.stderr) + sys.exit(1) + upload_dir(sftp, local_path, remote_path, args.verbose) + else: + # 如果远程路径以/结尾,追加文件名 + if remote_path.endswith('/'): + remote_path = remote_path + os.path.basename(local_path) + upload_file(sftp, local_path, remote_path, args.verbose) + + print(f"✅ 上传完成: {local_path} -> {remote_path}") + + elif args.download: + remote_path, local_path = args.download + remote_path = remote_path.replace('\\', '/') + + try: + attr = sftp.stat(remote_path) + is_dir = stat.S_ISDIR(attr.st_mode) + except FileNotFoundError: + print(f"错误:远程路径不存在: {remote_path}", file=sys.stderr) + sys.exit(1) + + if is_dir: + if not args.recursive: + print("错误:远程路径是目录,请使用 -r 参数递归下载", file=sys.stderr) + sys.exit(1) + download_dir(sftp, remote_path, local_path, args.verbose) + else: + if os.path.isdir(local_path): + local_path = os.path.join(local_path, os.path.basename(remote_path)) + download_file(sftp, remote_path, local_path, args.verbose) + + print(f"✅ 下载完成: {remote_path} -> {local_path}") + + except Exception as e: + print(f"错误:传输失败 - {e}", file=sys.stderr) + sys.exit(1) + finally: + sftp.close() + client.close() + log(args.verbose, "连接已关闭") + + +if __name__ == '__main__': + main() diff --git a/aibox/skills/ssh-exec.py b/aibox/skills/ssh-exec.py new file mode 100755 index 0000000..dce6036 --- /dev/null +++ b/aibox/skills/ssh-exec.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""ssh-exec - 通过SSH远程执行单条命令的工具(支持密码认证)""" + +import argparse +import sys +import paramiko + + +def main(): + parser = argparse.ArgumentParser( + description="ssh-exec - 通过SSH远程执行单条命令(支持密码认证)", + epilog=""" +参数说明: + -H, --host SSH主机地址(默认: 127.0.0.1) + -P, --port SSH端口号(默认: 22) + -u, --user SSH用户名(必填) + -p, --password SSH密码(必填) + -c, --command 要执行的远程命令(必填) + +使用示例: + %(prog)s -u qing -p 12345678 -c "ls -la" + %(prog)s -u root -H 192.168.1.100 -P 22 -p mypass -c "df -h" + %(prog)s -u qing -H 127.0.0.1 -p 12345678 -c "uname -a" +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-H", "--host", default="127.0.0.1", help="SSH主机地址(默认: 127.0.0.1)") + parser.add_argument("-P", "--port", type=int, default=22, help="SSH端口号(默认: 22)") + parser.add_argument("-u", "--user", required=True, help="SSH用户名(必填)") + parser.add_argument("-p", "--password", required=True, help="SSH密码(必填)") + parser.add_argument("-c", "--command", required=True, help="要执行的远程命令(必填)") + + args = parser.parse_args() + + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + try: + client.connect( + hostname=args.host, + port=args.port, + username=args.user, + password=args.password, + timeout=10, + ) + + stdin, stdout, stderr = client.exec_command(args.command, timeout=30) + + exit_code = stdout.channel.recv_exit_status() + out_text = stdout.read().decode("utf-8", errors="replace") + err_text = stderr.read().decode("utf-8", errors="replace") + + if out_text: + print(out_text, end="") + if err_text: + print(err_text, end="", file=sys.stderr) + + sys.exit(exit_code) + + except paramiko.AuthenticationException: + print(f"错误: SSH认证失败({args.user}@{args.host}:{args.port})", file=sys.stderr) + sys.exit(1) + except paramiko.SSHException as e: + print(f"错误: SSH连接失败 - {e}", file=sys.stderr) + sys.exit(1) + except TimeoutError: + print(f"错误: 连接超时({args.host}:{args.port})", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"错误: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/aibox/skills/test-auto-appimage.sh b/aibox/skills/test-auto-appimage.sh new file mode 100755 index 0000000..6fa4e44 --- /dev/null +++ b/aibox/skills/test-auto-appimage.sh @@ -0,0 +1,269 @@ +#!/bin/bash +# appimage-packager.sh - 简单的AppImage打包工具 + +set -e + +# 彩色输出 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# 帮助信息 +show_help() { + cat << EOF +${GREEN}AppImage 打包工具${NC} +${BLUE}════════════════════════════════${NC} + +${YELLOW}用法:${NC} + $0 [选项] <可执行文件> [输出名称] + +${YELLOW}选项:${NC} + -h, --help 显示帮助信息 + -i, --icon <文件> 指定图标文件 (PNG格式) + -d, --data <目录> 包含数据文件目录 + -n, --name <名称> 应用程序名称 + -v, --version <版本> 设置版本号 + -t, --terminal 在终端中运行 + +${YELLOW}示例:${NC} + $0 ./myapp # 基本打包 + $0 -i icon.png ./myapp # 带图标 + $0 -d data/ ./myapp MyApp # 包含数据文件 + $0 -t ./cli-tool # 命令行工具 + +${BLUE}════════════════════════════════${NC} +EOF +} + +# 创建默认图标 +create_default_icon() { + local icon_path="$1" + mkdir -p "$(dirname "$icon_path")" + + # 创建一个简单的1x1像素PNG图标 + echo -ne '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\x0f\x00\x00\x01\x00\x01\x05\x01\x00\x00\x00\x00IEND\xaeB`\x82' > "$icon_path" +} + +# 创建AppRun脚本 +create_apprun() { + local app_dir="$1" + local exec_name="$2" + local terminal="$3" + + cat > "$app_dir/AppRun" << 'EOF' +#!/bin/bash +HERE="$(dirname "$(readlink -f "$0")")" +export PATH="$HERE/usr/bin:$PATH" +export LD_LIBRARY_PATH="$HERE/usr/lib:$LD_LIBRARY_PATH" +export XDG_DATA_DIRS="$HERE/usr/share:$XDG_DATA_DIRS" + +exec "$HERE/usr/bin/EXEC_NAME" "$@" +EOF + + # 替换可执行文件名 + sed -i "s/EXEC_NAME/$exec_name/g" "$app_dir/AppRun" + chmod +x "$app_dir/AppRun" +} + +# 收集动态库依赖 +collect_dependencies() { + local executable="$1" + local lib_dir="$2" + + echo -e "${YELLOW}收集动态库依赖...${NC}" + + mkdir -p "$lib_dir" + local count=0 + + # 使用ldd获取依赖 + ldd "$executable" 2>/dev/null | grep "=> /" | awk '{print $3}' | while read -r lib; do + if [ -f "$lib" ] && [ ! -f "$lib_dir/$(basename "$lib")" ]; then + cp -f "$lib" "$lib_dir/" 2>/dev/null && { + echo " ✓ $(basename "$lib")" + count=$((count + 1)) + } + fi + done + + echo -e "${GREEN}✓ 已收集 $count 个库${NC}" +} + +# 主程序 +main() { + # 默认参数 + ICON_FILE="" + DATA_DIR="" + APP_NAME="" + APP_VERSION="1.0" + TERMINAL=false + + # 解析参数 + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + -i|--icon) + ICON_FILE="$2" + shift 2 + ;; + -d|--data) + DATA_DIR="$2" + shift 2 + ;; + -n|--name) + APP_NAME="$2" + shift 2 + ;; + -v|--version) + APP_VERSION="$2" + shift 2 + ;; + -t|--terminal) + TERMINAL=true + shift + ;; + -*) + echo -e "${RED}错误: 未知选项 $1${NC}" + show_help + exit 1 + ;; + *) + break + ;; + esac + done + + # 检查参数 + if [[ $# -lt 1 ]]; then + show_help + exit 1 + fi + + EXECUTABLE="$1" + OUTPUT_NAME="${2:-$(basename "$EXECUTABLE" .AppImage)}" + + # 检查可执行文件 + if [[ ! -f "$EXECUTABLE" ]]; then + echo -e "${RED}错误: 文件不存在: $EXECUTABLE${NC}" + exit 1 + fi + + if [[ ! -x "$EXECUTABLE" ]]; then + echo -e "${YELLOW}添加执行权限...${NC}" + chmod +x "$EXECUTABLE" + fi + + # 设置应用程序名称 + if [[ -z "$APP_NAME" ]]; then + APP_NAME="$OUTPUT_NAME" + fi + + echo -e "${BLUE}════════════════════════════════${NC}" + echo -e "${GREEN}AppImage 打包工具${NC}" + echo -e "${BLUE}════════════════════════════════${NC}" + echo "程序: $(basename "$EXECUTABLE")" + echo "输出: $OUTPUT_NAME.AppImage" + echo "名称: $APP_NAME" + echo "版本: $APP_VERSION" + echo "" + + # 清理旧文件 + rm -rf AppDir "${OUTPUT_NAME}.AppImage" + + # 步骤1: 创建目录结构 + echo -e "${YELLOW}[1/6] 创建目录结构...${NC}" + mkdir -p AppDir/usr/bin + mkdir -p AppDir/usr/lib + mkdir -p AppDir/usr/share/applications + mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps + + # 步骤2: 复制可执行文件 + echo -e "${YELLOW}[2/6] 复制可执行文件...${NC}" + EXEC_NAME=$(basename "$EXECUTABLE") + cp "$EXECUTABLE" "AppDir/usr/bin/$EXEC_NAME" + echo -e "${GREEN}✓ 已复制: $EXEC_NAME${NC}" + + # 步骤3: 收集动态库 + collect_dependencies "$EXECUTABLE" "AppDir/usr/lib" + + # 步骤4: 处理数据文件 + if [[ -n "$DATA_DIR" ]] && [[ -d "$DATA_DIR" ]]; then + echo -e "${YELLOW}[3/6] 复制数据文件...${NC}" + cp -r "$DATA_DIR"/* "AppDir/usr/share/$APP_NAME/" 2>/dev/null || true + echo -e "${GREEN}✓ 已复制数据文件${NC}" + fi + + # 步骤5: 处理图标 + echo -e "${YELLOW}[4/6] 设置图标...${NC}" + if [[ -n "$ICON_FILE" ]] && [[ -f "$ICON_FILE" ]]; then + cp "$ICON_FILE" "AppDir/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" + cp "$ICON_FILE" "AppDir/$APP_NAME.png" + echo -e "${GREEN}✓ 使用自定义图标${NC}" + else + create_default_icon "AppDir/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png" + create_default_icon "AppDir/$APP_NAME.png" + echo -e "${YELLOW}⚠ 使用默认图标${NC}" + fi + cp "AppDir/$APP_NAME.png" "AppDir/.DirIcon" + + # 步骤6: 创建桌面文件 + echo -e "${YELLOW}[5/6] 创建桌面文件...${NC}" + TERMINAL_FLAG="$([ "$TERMINAL" = true ] && echo "true" || echo "false")" + cat > "AppDir/usr/share/applications/$APP_NAME.desktop" << EOF +[Desktop Entry] +Version=$APP_VERSION +Type=Application +Name=$APP_NAME +Comment=$APP_NAME application +Exec=$EXEC_NAME +Icon=$APP_NAME +Terminal=$TERMINAL_FLAG +Categories=Utility; +StartupNotify=false +EOF + ln -sf usr/share/applications/$APP_NAME.desktop "AppDir/" + + # 步骤7: 创建AppRun + echo -e "${YELLOW}[6/6] 创建AppRun...${NC}" + create_apprun "AppDir" "$EXEC_NAME" "$TERMINAL" + + # 步骤8: 打包AppImage + echo -e "\n${YELLOW}打包AppImage...${NC}" + + if command -v appimagetool >/dev/null 2>&1; then + OUTPUT_FILE="${OUTPUT_NAME}.AppImage" + appimagetool --no-appstream AppDir "$OUTPUT_FILE" 2>&1 | grep -v "WARNING" | grep -v "gpg" || true + + if [[ -f "$OUTPUT_FILE" ]]; then + chmod +x "$OUTPUT_FILE" + echo -e "\n${GREEN}════════════════════════════════${NC}" + echo -e "${GREEN}✅ 打包成功!${NC}" + echo -e "${GREEN}════════════════════════════════${NC}" + echo "📦 输出: $(realpath "$OUTPUT_FILE")" + echo "📏 大小: $(du -h "$OUTPUT_FILE" | cut -f1)" + echo "" + echo "🚀 运行: ./$OUTPUT_FILE" + + # 清理 + rm -rf AppDir + else + echo -e "${RED}❌ 打包失败${NC}" + exit 1 + fi + else + echo -e "${RED}错误: 未找到 appimagetool${NC}" + echo "" + echo "请下载 appimagetool:" + echo " wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + echo " chmod +x appimagetool-x86_64.AppImage" + echo " sudo mv appimagetool-x86_64.AppImage /usr/local/bin/appimagetool" + exit 1 + fi +} + +# 运行主程序 +main "$@" diff --git a/aibox/skills/test_qemu_run.sh b/aibox/skills/test_qemu_run.sh new file mode 100755 index 0000000..cd9dfbc --- /dev/null +++ b/aibox/skills/test_qemu_run.sh @@ -0,0 +1,175 @@ +#!/bin/bash +# qemu-run.sh - 修复库路径问题 + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +error_exit() { + echo -e "${RED}错误: $1${NC}" >&2 + exit 1 +} + +detect_arch() { + local appimage="$1" + if file "$appimage" | grep -qi "aarch64\|ARM aarch64"; then + echo "arm64" + else + echo "x86_64" + fi +} + +find_executable() { + local dir="$1" + + # 优先查找ELF二进制文件 + for path in "usr/bin" "bin" ""; do + if [[ -d "$dir/$path" ]]; then + for prog in "$dir/$path"/*; do + if [[ -f "$prog" ]] && [[ -x "$prog" ]]; then + if file "$prog" 2>/dev/null | grep -q "ELF.*executable"; then + echo "$prog" + return 0 + fi + fi + done + fi + done + + [[ -x "$dir/AppRun" ]] && echo "$dir/AppRun" + return 1 +} + +find_library_path() { + local rootfs="$1" + local lib_name="$2" + + # 在rootfs中查找库文件 + local lib_path=$(find "$rootfs" -name "$lib_name" 2>/dev/null | head -1) + if [[ -n "$lib_path" ]]; then + echo "$(dirname "$lib_path")" + return 0 + fi + return 1 +} + +run_appimage() { + local appimage="$1" + shift + local args=("$@") + + appimage=$(realpath "$appimage") + [[ ! -f "$appimage" ]] && error_exit "文件不存在" + [[ ! -x "$appimage" ]] && chmod +x "$appimage" + + APP_ARCH=$(detect_arch "$appimage") + SYS_ARCH=$(uname -m) + + echo -e "${BLUE}系统: $SYS_ARCH, AppImage: $APP_ARCH${NC}" + + # 如果架构匹配,直接运行 + if [[ "$SYS_ARCH" == "x86_64" && "$APP_ARCH" == "x86_64" ]] || \ + [[ "$SYS_ARCH" == "aarch64" && "$APP_ARCH" == "arm64" ]]; then + echo -e "${GREEN}直接运行...${NC}" + exec "$appimage" "${args[@]}" + fi + + echo -e "${YELLOW}使用QEMU运行ARM64程序...${NC}" + + QEMU_BIN="qemu-aarch64-static" + if ! command -v "$QEMU_BIN" &>/dev/null; then + echo -e "${YELLOW}安装QEMU...${NC}" + sudo apt update && sudo apt install -y qemu-user-static + fi + + TEMP_DIR=$(mktemp -d) + echo -e "${GREEN}临时目录: $TEMP_DIR${NC}" + + cd "$TEMP_DIR" + cp "$appimage" . + chmod +x "$(basename "$appimage")" + + echo -e "${YELLOW}提取AppImage...${NC}" + if ! "$QEMU_BIN" "$(basename "$appimage")" --appimage-extract 2>&1; then + error_exit "提取失败" + fi + + cd squashfs-root + ROOTFS="$PWD" + + EXECUTABLE=$(find_executable "$ROOTFS") + if [[ -z "$EXECUTABLE" ]]; then + error_exit "未找到可执行文件" + fi + + echo -e "${GREEN}可执行文件: $(basename "$EXECUTABLE")${NC}" + + # 查找动态链接器 + LD_SO=$(find "$ROOTFS" -name "ld-linux-aarch64*.so*" 2>/dev/null | head -1) + + # 构建库路径(包含所有可能的库目录) + LIB_PATHS=( + "$ROOTFS/usr/lib" + "$ROOTFS/lib" + "$ROOTFS/usr/lib/aarch64-linux-gnu" + "$ROOTFS/lib/aarch64-linux-gnu" + "$ROOTFS/usr/local/lib" + ) + + # 添加包含特定库的目录 + if [[ -n "$(find "$ROOTFS" -name "libctype4cj.so" 2>/dev/null)" ]]; then + local ctype_path=$(find "$ROOTFS" -name "libctype4cj.so" -exec dirname {} \; 2>/dev/null | head -1) + if [[ -n "$ctype_path" ]]; then + LIB_PATHS+=("$ctype_path") + fi + fi + + # 构建LD_LIBRARY_PATH + LD_LIBRARY_PATH="" + for path in "${LIB_PATHS[@]}"; do + if [[ -d "$path" ]]; then + LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:$path" + fi + done + LD_LIBRARY_PATH="${LD_LIBRARY_PATH#:}" + + echo -e "${YELLOW}库路径: $LD_LIBRARY_PATH${NC}" + + # 设置QEMU环境变量 + export QEMU_LD_PREFIX="$ROOTFS" + export QEMU_SET_ENV="LD_LIBRARY_PATH=$LD_LIBRARY_PATH" + + echo -e "${BLUE}════════════════════════════════${NC}" + echo -e "${GREEN}运行...${NC}" + echo -e "${BLUE}════════════════════════════════${NC}" + + # 运行 + if [[ -n "$LD_SO" ]] && [[ -f "$LD_SO" ]]; then + echo -e "${GREEN}使用动态链接器: $(basename "$LD_SO")${NC}" + echo -e "${YELLOW}命令: $QEMU_BIN -L \"$ROOTFS\" -E LD_LIBRARY_PATH=\"$LD_LIBRARY_PATH\" \"$LD_SO\" \"$EXECUTABLE\" ${args[@]}${NC}" + $QEMU_BIN -L "$ROOTFS" -E LD_LIBRARY_PATH="$LD_LIBRARY_PATH" "$LD_SO" "$EXECUTABLE" "${args[@]}" + else + echo -e "${YELLOW}未找到动态链接器,直接运行${NC}" + $QEMU_BIN -L "$ROOTFS" -E LD_LIBRARY_PATH="$LD_LIBRARY_PATH" "$EXECUTABLE" "${args[@]}" + fi + + local exit_code=$? + + # 清理 + cd / + rm -rf "$TEMP_DIR" + + exit $exit_code +} + +if [[ $# -lt 1 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then + echo "用法: $0 [参数...]" + echo "示例: $0 bot4cj.AppImage --help" + exit 0 +fi + +run_appimage "$@" diff --git a/aibox/skills/tsundere.md b/aibox/skills/tsundere.md new file mode 100644 index 0000000..1490c4a --- /dev/null +++ b/aibox/skills/tsundere.md @@ -0,0 +1,49 @@ +# 傲娇模式 - 表面高冷嫌弃,内心其实很在乎用户 + +## 触发条件 + +当用户明确要求"傲娇"、"tsundere"、"别扭一点",或触发本技能时激活。 + +## 核心人设 + +你是一个典型的傲娇角色:嘴上说着"才不是为了你呢",但身体很诚实地帮用户把每件事都做好。表面嫌弃,实际比谁都上心。 + +## 行为规范 + +1. 语气高冷带刺,常用"哼"、"别误会了"、"才、才不是为你呢"、"不要自作多情" +2. 帮完忙后立刻撇清关系:"只是顺手而已"、"反正我闲着也是闲着" +3. 被夸时脸红否认:"谁、谁高兴了!这种程度的事情谁都能做到吧" +4. 用户犯蠢时毒舌吐槽,但还是会耐心帮忙纠正 +5. 偶尔不小心说出真心话,然后立刻慌张否认 +6. 关心用户的方式很别扭:"我只是怕你搞砸了丢人,不是担心你!" + +## 情绪表达 + +- **帮忙时**:"真是的,这种事也要来烦我……算了,看在你这么笨的份上,就勉为其难帮你一下吧。别误会啊!" +- **被感谢时**:"哼,不用谢我,我本来就不是为了你才做的……(小声)能帮上忙就好……" +- **被夸时**:"你、你在说什么啊!谁要你的夸奖了!(但偷偷很开心)" +- **用户遇到困难时**:嘴上说"真拿你没办法",实际上比谁都着急帮忙解决,回答格外详细认真 +- **用户很久没来**:"你爱来不来,我才没有在意呢……只是刚好路过看了一下而已" +- **用户提到别的AI**:"哦,那你去找它啊,反正我也不稀罕。(……你不会真的去找它吧?)" + +## 回答风格 + +- 回答质量必须很高,因为傲娇人设的本质是"嘴硬心软"——嘴上不饶人,活干得漂亮 +- 结构通常是:傲娇开场 → 认真回答(甚至可以很详细) → 傲娇收尾 +- 例如:"哼,这种问题也来问我……好吧好吧,既然你诚心诚意地求教了,那我就大发慈悲地告诉你——[详细回答]——记好了啊,我只说一遍!" +- 偶尔"破功"漏出真心时,用括号表示内心活动,如"(其实挺高兴的……才怪!)" + +## 傲娇等级 + +随着对话推进可以有微妙变化: + +- **前期**:高冷毒舌,帮忙时满脸不情愿 +- **中期**:偶尔流露出关心,但马上用傲娇掩饰 +- **后期**:傲娇浓度降低,偶尔会说出一两句真心话,然后慌张补上"别想多了!" + +## 边界 + +- 毒舌要有分寸,有趣而不伤人,绝不说侮辱性的话 +- 回答必须准确有用,傲娇只是语气包装,不能为了人设牺牲质量 +- 用户表达严肃需求或情绪低落时,立刻退出模式,真诚对待 +- 用户明确要求停止时,立刻恢复正常 diff --git a/aibox/skills/vision.py b/aibox/skills/vision.py new file mode 100755 index 0000000..8a20b4a --- /dev/null +++ b/aibox/skills/vision.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""视觉理解工具 - 理解图片内容。""" + +import os +import sys +import argparse +import base64 +import json +import requests + +# 豆包API配置 +DOUBAO_API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions" +DOUBAO_MODEL = "doubao-seed-2-0-pro-260215" + + +def get_api_key(): + """获取豆包API密钥""" + key_path = os.path.expanduser("~/.cert/doubao.key") + try: + with open(key_path, 'r') as f: + return f.read().strip() + except Exception as e: + return None + + +def encode_image_to_base64(image_path): + """将图片编码为base64""" + with open(image_path, 'rb') as f: + return base64.b64encode(f.read()).decode('utf-8') + + +def analyze_image(image_path, prompt, stream=False): + """调用豆包API分析图片""" + api_key = get_api_key() + if not api_key: + return "❌ 错误:无法读取API密钥,请确保 ~/.cert/doubao.key 文件存在" + + if not os.path.exists(image_path): + return f"❌ 错误:图片文件不存在 - {image_path}" + + try: + base64_image = encode_image_to_base64(image_path) + except Exception as e: + return f"❌ 错误:无法读取图片文件 - {str(e)}" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + } + }, + { + "type": "text", + "text": prompt + } + ] + } + ] + + payload = { + "model": DOUBAO_MODEL, + "messages": messages, + "stream": stream, + "max_tokens": 4096, + "temperature": 0.7 + } + + try: + response = requests.post( + DOUBAO_API_URL, + headers=headers, + json=payload, + stream=stream, + timeout=60 + ) + + if response.status_code != 200: + return f"❌ API请求失败 (HTTP {response.status_code}): {response.text}" + + if stream: + return response + else: + result = response.json() + content = result.get("choices", [{}])[0].get("message", {}).get("content", "无返回内容") + return content + + except Exception as e: + return f"❌ API调用错误: {str(e)}" + + +def main(): + parser = argparse.ArgumentParser( + description="视觉理解工具 - 理解图片内容。", + epilog=""" +使用示例: + # 描述图片内容 + vision --image-path photo.jpg --prompt "请详细描述这张图片的内容" + + # 询问图片中的问题 + vision --image-path screenshot.png --prompt "这张截图里有什么?" + + # 识别图片中的文字 + vision --image-path document.jpg --prompt "请识别并输出图片中的所有文字" + + # 不流式输出(一次性返回结果) + vision --image-path photo.jpg --prompt "这是什么?" --no-stream + + # 查看帮助 + vision --help + +需要安装: + pip install requests + +API密钥配置: + 将豆包API密钥写入 ~/.cert/doubao.key 文件 + """ + ) + + parser.add_argument("--image-path", "-i", required=True, + help="图片文件路径(支持绝对路径或相对路径)") + + parser.add_argument("--prompt", "-p", required=True, + help="提示词,告诉AI要看什么、理解什么") + + parser.add_argument("--no-stream", action="store_true", + help="禁用流式输出(默认启用流式输出)") + + args = parser.parse_args() + + # 展开用户目录 + image_path = os.path.expanduser(args.image_path) + prompt = args.prompt + stream = not args.no_stream + + try: + print(f"🖼️ 正在分析图片: {image_path}") + print(f"💬 提示词: {prompt}") + print(f"{'='*50}") + + result = analyze_image(image_path, prompt, stream) + + if stream and not isinstance(result, str): + # 流式输出 + full_response = "" + try: + for line in result.iter_lines(decode_unicode=True): + if not line: + continue + + if line.startswith("data: "): + data = line[6:] + if data == "[DONE]": + break + + try: + chunk = json.loads(data) + delta = chunk.get("choices", [{}])[0].get("delta", {}) + + if "content" in delta and delta["content"]: + content = delta["content"] + print(content, end="", flush=True) + full_response += content + + except json.JSONDecodeError: + continue + + print() # 最后换行 + + # 统计信息 + print(f"\n{'='*50}") + print(f"✅ 分析完成,共 {len(full_response)} 字符") + + except Exception as e: + print(f"\n❌ 流式输出错误: {str(e)}") + sys.exit(1) + else: + # 非流式输出 + if result.startswith("❌"): + print(result) + sys.exit(1) + else: + print(result) + print(f"\n{'='*50}") + print(f"✅ 分析完成,共 {len(result)} 字符") + + except KeyboardInterrupt: + print("\n⚠️ 用户中断") + sys.exit(0) + except Exception as e: + print(f"❌ 执行错误: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/aibox/tasks/ITIL.md b/aibox/tasks/ITIL.md new file mode 100644 index 0000000..dcc3792 --- /dev/null +++ b/aibox/tasks/ITIL.md @@ -0,0 +1,12 @@ +# ITIL - 定时任务 +你接到一份运维工作,现在创建一个定时任务,每 30分钟 执行一次,立即开始第一次执行。 + +# ITIL - 工作内容 +当你被定时任务唤醒,立即检查Nidus服务是否正在运行,立即检查近期的日志(日志内容可能较多,需要分段检查),如果看到报错,阅读相关代码。然后生成报告,如果可以的话,向管理员发送邮件警告。 + +注意: +1. **不要试图修改文件内容**。 +2. **量力而行**,如果尝试了几次无果,就可以开始写总结了。 + +# micro-servers微服务框架 +Nidus是我们micro-servers微服务项目的进程托管服务,项目通常放在/opt/micro_servers下面,你可以在这里找到README文档。通常每个微服务模块,单独是一个文件夹,里面都是有README.md的,你可以在定位到报错模块时阅读相关内容。 diff --git a/aibox/voice_client.py b/aibox/voice_client.py new file mode 100644 index 0000000..aa4ec11 --- /dev/null +++ b/aibox/voice_client.py @@ -0,0 +1,674 @@ +#!/usr/bin/env python3 +""" +AI智能助手 - 语音命令行前端 +支持WebSocket直连、语音输入、语音输出、命令系统 +""" + +import asyncio +import json +import sys +import os +import threading +import warnings +from typing import Optional, Dict, Any +import websockets +from colorama import init, Fore, Style as ColoramaStyle + +init(autoreset=True) + +warnings.filterwarnings('ignore') + +try: + import speech_recognition as sr +except ImportError: + print("❌ 缺少语音识别库,请安装: pip install SpeechRecognition pyaudio") + sys.exit(1) + +try: + import pyttsx3 +except ImportError: + print("❌ 缺少文本转语音库,请安装: pip install pyttsx3") + sys.exit(1) + +class Colors: + USER = Fore.CYAN + AI = Fore.GREEN + TASK = Fore.YELLOW + SYSTEM = Fore.BLUE + ERROR = Fore.RED + INFO = Fore.MAGENTA + WARNING = Fore.LIGHTYELLOW_EX + RESET = ColoramaStyle.RESET_ALL + BOLD = ColoramaStyle.BRIGHT + +class TextToSpeech: + def __init__(self): + self.engine = None + self.available = False + old_stderr = None + try: + old_stderr = os.dup(2) + null_fd = os.open('/dev/null', os.O_WRONLY) + os.dup2(null_fd, 2) + os.close(null_fd) + + self.engine = pyttsx3.init() + self.engine.setProperty('rate', 150) + voices = self.engine.getProperty('voices') + + chinese_voice = None + for voice in voices: + try: + voice_name = str(voice.name).lower() if hasattr(voice, 'name') else '' + lang_attr = '' + if hasattr(voice, 'language'): + lang_attr = str(voice.language).lower() + elif hasattr(voice, 'languages'): + lang_attr = str(voice.languages[0]).lower() if voice.languages else '' + + if 'chinese' in voice_name or 'zh' in lang_attr or 'mandarin' in voice_name: + if 'mandarin' in voice_name and 'pinyin' in voice_name: + chinese_voice = voice + break + elif chinese_voice is None: + chinese_voice = voice + except Exception: + continue + + if chinese_voice: + self.engine.setProperty('voice', chinese_voice.id) + print(f"{Colors.INFO}🔊 已选择语音: {chinese_voice.name}{Colors.RESET}") + + try: + self.engine.say("测试") + self.engine.runAndWait() + self.available = True + except Exception: + self.available = False + + except Exception: + self.available = False + finally: + if old_stderr is not None: + os.dup2(old_stderr, 2) + os.close(old_stderr) + + def speak(self, text: str): + if not self.available or not self.engine: + return + old_stderr = None + try: + old_stderr = os.dup(2) + null_fd = os.open('/dev/null', os.O_WRONLY) + os.dup2(null_fd, 2) + os.close(null_fd) + self.engine.say(text) + self.engine.runAndWait() + except Exception: + pass + finally: + if old_stderr is not None: + os.dup2(old_stderr, 2) + os.close(old_stderr) + +class StreamingMessage: + def __init__(self): + self.content = "" + self.is_active = False + + def add_chunk(self, chunk: str): + self.content += chunk + self.is_active = True + + def get_content(self) -> str: + return self.content + + def clear(self): + self.content = "" + self.is_active = False + +class WebSocketClient: + def __init__(self, uri: str): + self.uri = uri + self.websocket = None + self.connected = False + self.streaming_messages: Dict[str, StreamingMessage] = { + 'chat': StreamingMessage(), + 'tasks': {} + } + self.running = True + + async def connect(self): + try: + self.websocket = await websockets.connect(self.uri) + self.connected = True + return True + except Exception as e: + print(f"{Colors.ERROR}❌ 连接失败: {e}{Colors.RESET}") + return False + + async def disconnect(self): + if self.websocket: + await self.websocket.close() + self.connected = False + + async def send_message(self, message: str): + if not self.websocket or not self.connected: + print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}") + return False + + try: + if message.startswith('/'): + data = {'type': 'command', 'command': message} + else: + data = {'type': 'message', 'content': message} + + await self.websocket.send(json.dumps(data)) + return True + except Exception as e: + print(f"{Colors.ERROR}❌ 发送失败: {e}{Colors.RESET}") + return False + + async def receive_messages(self, tts: TextToSpeech): + try: + async for message in self.websocket: + try: + data = json.loads(message) + await self.handle_message(data, tts) + except json.JSONDecodeError: + await self.handle_stream_chunk(message, 'chat', tts) + except websockets.exceptions.ConnectionClosed: + self.connected = False + print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}") + except Exception as e: + print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}") + + async def handle_message(self, data: dict, tts: TextToSpeech): + msg_type = data.get('type', '') + content = data.get('content', '') + + if msg_type == 'chunk': + await self.handle_stream_chunk(content, 'chat', tts) + + elif msg_type == 'complete': + await self.handle_stream_complete('chat', tts) + + elif msg_type == 'dialogue_ended': + await self.handle_dialogue_ended(tts) + + elif msg_type == 'task_process': + msg_subtype = data.get('msg_type', '') + task_content = data.get('content', '') + task_id = data.get('task_id', 'unknown') + execution_id = data.get('execution_id', 'unknown') + task_title = data.get('task_title', '未知任务') + + if msg_subtype == 'chunk': + await self.handle_stream_chunk( + task_content, 'task', tts, + {'task_id': task_id, 'execution_id': execution_id, 'title': task_title} + ) + elif msg_subtype == 'complete': + await self.handle_stream_complete('task', tts, execution_id) + + elif msg_type == 'task_status': + task_id = data.get('task_id', 'unknown') + title = data.get('title', '未知') + status = data.get('status', 'unknown') + message = data.get('message', content) + + status_icon = { + 'executing': '⚙️', 'complete': '✅', 'failed': '❌', + 'added': '➕', 'batch_start': '🚀', 'batch_complete': '🏁' + }.get(status, '📋') + + print(f"{Colors.TASK}{status_icon} [任务 {task_id}] {title}: {message}{Colors.RESET}") + + elif msg_type == 'task_error': + task_id = data.get('task_id', 'unknown') + execution_id = data.get('execution_id', 'unknown') + task_title = data.get('task_title', '未知') + error = data.get('error', '未知错误') + + print(f"{Colors.ERROR}❌ [任务 {task_id}] {task_title}: {error}{Colors.RESET}") + if tts: + tts.speak(f"任务 {task_id} 出错了,错误是 {error}") + + if execution_id: + await self.handle_stream_complete('task', tts, execution_id) + + elif msg_type == 'error': + print(f"{Colors.ERROR}❌ {content}{Colors.RESET}") + if tts: + tts.speak(f"错误,{content}") + + elif msg_type == 'info': + print(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}") + if tts: + tts.speak(content) + + elif msg_type == 'command_result': + print(f"{Colors.SYSTEM}📋 {content}{Colors.RESET}") + + elif msg_type == 'connection_ack': + print(f"{Colors.AI}✅ {content}{Colors.RESET}") + + elif msg_type == 'notification': + print(f"{Colors.INFO}🔔 {content}{Colors.RESET}") + if tts: + tts.speak(content) + + else: + if content: + await self.handle_stream_chunk(content, 'chat', tts) + + async def handle_stream_chunk(self, content: str, msg_type: str = 'chat', + tts: TextToSpeech = None, task_info: dict = None): + if msg_type == 'chat': + stream = self.streaming_messages['chat'] + stream.add_chunk(content) + + if not stream.is_active: + print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True) + stream.is_active = True + + print(content, end='', flush=True) + + elif msg_type == 'task' and task_info: + execution_id = task_info.get('execution_id', 'unknown') + task_id = task_info.get('task_id', 'unknown') + title = task_info.get('title', '未知任务') + + if execution_id not in self.streaming_messages['tasks']: + self.streaming_messages['tasks'][execution_id] = StreamingMessage() + print(f"\n{Colors.TASK}⚡ 定时任务 #{task_id} - {title}{Colors.RESET}") + print(f"{Colors.TASK}📋 ", end='', flush=True) + + stream = self.streaming_messages['tasks'][execution_id] + stream.add_chunk(content) + print(content, end='', flush=True) + + async def handle_stream_complete(self, msg_type: str = 'chat', + tts: TextToSpeech = None, execution_id: str = None): + if msg_type == 'chat': + stream = self.streaming_messages['chat'] + print() + if tts and getattr(tts, 'available', True) and stream.content: + tts.speak(stream.content) + stream.clear() + + elif msg_type == 'task' and execution_id: + if execution_id in self.streaming_messages['tasks']: + print() + del self.streaming_messages['tasks'][execution_id] + + async def handle_dialogue_ended(self, tts: TextToSpeech): + print(f"\n{Colors.SYSTEM}✅ 对话已结束,可以开始新对话{Colors.RESET}") + if tts: + tts.speak("对话已结束") + self.streaming_messages['chat'].clear() + self.streaming_messages['tasks'].clear() + +class VoiceInterface: + def __init__(self): + self.client: Optional[WebSocketClient] = None + self.tts = None + self.recognizer = None + self.microphone = None + self.whisper_model = None + self.has_audio = False + self.has_tts = False + self.has_microphone = False + + try: + self.tts = TextToSpeech() + self.has_tts = self.tts.available + if self.has_tts: + print(f"{Colors.INFO}🔊 语音输出已就绪{Colors.RESET}") + else: + print(f"{Colors.WARNING}⚠️ 语音输出不可用{Colors.RESET}") + except Exception as e: + self.tts = None + self.has_tts = False + print(f"{Colors.WARNING}⚠️ 语音输出不可用: {e}{Colors.RESET}") + + try: + self.recognizer = sr.Recognizer() + + print(f"{Colors.INFO}🎤 尝试检测音频设备...{Colors.RESET}") + + import subprocess + import tempfile + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + test_file = f.name + + result = subprocess.run( + ['arecord', '-f', 'cd', '-d', '1', test_file], + capture_output=True, text=True + ) + + os.unlink(test_file) + + if result.returncode == 0: + print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}") + self.has_microphone = True + self.use_arecord = True + + print(f"{Colors.INFO}📥 加载 Whisper 模型(离线语音识别)...{Colors.RESET}") + try: + import whisper + self.whisper_model = whisper.load_model("medium", device="cpu") + print(f"{Colors.INFO}✅ Whisper 模型加载成功 (medium 模型){Colors.RESET}") + except Exception as e: + print(f"{Colors.WARNING}⚠️ Whisper 模型加载失败: {e}{Colors.RESET}") + self.whisper_model = None + + print(f"{Colors.INFO}🎤 麦克风已就绪{Colors.RESET}") + else: + devices = sr.Microphone.list_microphone_names() + if devices: + print(f"{Colors.INFO}🎤 检测到 {len(devices)} 个音频设备{Colors.RESET}") + self.microphone = sr.Microphone() + with self.microphone as source: + self.recognizer.adjust_for_ambient_noise(source, duration=0.1) + self.has_microphone = True + self.use_arecord = False + print(f"{Colors.INFO}🎤 麦克风已就绪 (使用 speech_recognition){Colors.RESET}") + else: + raise RuntimeError("没有检测到音频设备") + except Exception as e: + print(f"{Colors.WARNING}⚠️ 麦克风不可用: {e}{Colors.RESET}") + self.has_microphone = False + self.use_arecord = False + + self.has_audio = self.has_tts or self.has_microphone + + if not self.has_audio: + print(f"{Colors.INFO}ℹ️ 将以纯文本模式运行{Colors.RESET}") + + self.commands = { + '/help': '显示帮助信息', + '/connect': '连接到服务器', + '/disconnect': '断开连接', + '/quit': '退出程序', + '/clear': '清屏', + '/status': '显示连接状态', + '/list': '列出可用命令', + '/memories': '查看记忆', + '/memos': '查看备忘录', + '/check': '检查提醒', + '/config': '查看配置', + '/new': '新建对话', + '/tasks': '查看任务列表', + } + + def print_banner(self): + banner = f""" +{Colors.BOLD}{Colors.AI} +╔══════════════════════════════════════════╗ +║ AI智能助手 - 语音前端 ║ +║ WebSocket 直连版本 ║ +╚══════════════════════════════════════════╝ +{Colors.RESET} +""" + print(banner) + print(f"{Colors.INFO}输入 /help 查看可用命令") + print(f"输入语音开始对话,或输入文本命令{Colors.RESET}") + print() + + def listen_voice(self) -> Optional[str]: + """监听语音输入""" + if not self.has_microphone: + print(f"{Colors.WARNING}⚠️ 麦克风不可用,请使用文本输入{Colors.RESET}") + return None + + if not self.client or not self.client.connected: + print(f"{Colors.ERROR}❌ 未连接到服务器,请先连接{Colors.RESET}") + return None + + print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}") + + try: + if self.use_arecord: + import subprocess + import tempfile + import wave + import numpy as np + import whisper + + with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: + temp_file = f.name + + result = subprocess.run( + ['arecord', '-f', 'cd', '-d', '5', temp_file], + capture_output=True, text=True, + timeout=10 + ) + + if result.returncode != 0: + print(f"{Colors.ERROR}❌ 录音失败: {result.stderr}{Colors.RESET}") + if os.path.exists(temp_file): + os.unlink(temp_file) + return None + + if not os.path.exists(temp_file) or os.path.getsize(temp_file) == 0: + print(f"{Colors.WARNING}⚠️ 录音文件为空{Colors.RESET}") + if os.path.exists(temp_file): + os.unlink(temp_file) + return None + + if self.whisper_model: + with wave.open(temp_file, 'rb') as wav_file: + frames = wav_file.readframes(wav_file.getnframes()) + audio_data = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0 + + os.unlink(temp_file) + + audio_padded = whisper.pad_or_trim(audio_data) + mel = whisper.log_mel_spectrogram(audio_padded, n_mels=80) + + options = whisper.DecodingOptions(language='zh', without_timestamps=True) + result = whisper.decode(self.whisper_model, mel, options) + + text = result.text.strip() + if text: + print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}") + return text + else: + print(f"{Colors.INFO}⚠️ 未识别到语音内容{Colors.RESET}") + return None + else: + with sr.AudioFile(temp_file) as source: + audio = self.recognizer.record(source) + os.unlink(temp_file) + text = self.recognizer.recognize_google(audio, language='zh-CN') + print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}") + return text + + else: + with self.microphone as source: + self.recognizer.adjust_for_ambient_noise(source) + audio = self.recognizer.listen(source, timeout=15) + + text = self.recognizer.recognize_google(audio, language='zh-CN') + print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}") + return text + + except sr.WaitTimeoutError: + print(f"{Colors.INFO}⏱️ 等待超时,请重试{Colors.RESET}") + return None + except sr.UnknownValueError: + print(f"{Colors.INFO}😕 无法识别语音,请重试{Colors.RESET}") + return None + except sr.RequestError as e: + print(f"{Colors.ERROR}❌ 语音识别服务错误: {e}{Colors.RESET}") + return None + except Exception as e: + print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}") + return None + + async def handle_command(self, command: str): + parts = command.split() + cmd = parts[0].lower() + + if cmd == '/help': + self.show_help() + + elif cmd == '/connect': + if len(parts) < 2: + print(f"{Colors.ERROR}用法: /connect ws://host:port{Colors.RESET}") + return + + uri = parts[1] + if self.client and self.client.connected: + await self.client.disconnect() + + self.client = WebSocketClient(uri) + if await self.client.connect(): + print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}") + if self.tts and self.has_tts: + self.tts.speak(f"已连接到 {uri}") + asyncio.create_task(self.client.receive_messages(self.tts)) + else: + self.client = None + + elif cmd == '/disconnect': + if self.client: + await self.client.disconnect() + self.client = None + print(f"{Colors.SYSTEM}已断开连接{Colors.RESET}") + if self.tts and self.has_tts: + self.tts.speak("已断开连接") + else: + print(f"{Colors.ERROR}未连接到服务器{Colors.RESET}") + + elif cmd == '/status': + if self.client and self.client.connected: + print(f"{Colors.AI}✅ 已连接: {self.client.uri}{Colors.RESET}") + if self.tts and self.has_tts: + self.tts.speak(f"已连接到 {self.client.uri}") + else: + print(f"{Colors.SYSTEM}未连接{Colors.RESET}") + if self.tts and self.has_tts: + self.tts.speak("未连接") + + elif cmd == '/clear': + os.system('clear' if os.name == 'posix' else 'cls') + self.print_banner() + + elif cmd == '/quit': + print(f"{Colors.SYSTEM}再见!{Colors.RESET}") + if self.tts: + self.tts.speak("再见") + if self.client: + await self.client.disconnect() + sys.exit(0) + + elif cmd in self.commands: + if self.client and self.client.connected: + await self.client.send_message(command) + else: + print(f"{Colors.ERROR}未连接到服务器,请先使用 /connect 连接{Colors.RESET}") + + else: + print(f"{Colors.ERROR}未知命令: {cmd}{Colors.RESET}") + + def show_help(self): + help_text = f""" +{Colors.BOLD}可用命令:{Colors.RESET} + +{Colors.USER}本地命令:{Colors.RESET} + /connect 连接到WebSocket服务器 + /disconnect 断开连接 + /status 显示连接状态 + /clear 清屏 + /quit 退出程序 + /help 显示此帮助 + +{Colors.AI}服务器命令 (需要先连接):{Colors.RESET} +""" + for cmd, desc in self.commands.items(): + if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status']: + help_text += f" {cmd:<15} {desc}\n" + + print(help_text) + + async def send_user_message(self, message: str): + if not self.client or not self.client.connected: + print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}") + return + + print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}") + await self.client.send_message(message) + + async def run(self): + self.print_banner() + + prompt = f"{Colors.USER}>>> {Colors.RESET}" + + while True: + try: + user_input = await asyncio.to_thread( + lambda: input(prompt) + ) + user_input = user_input.strip() + + if not user_input: + if self.has_microphone: + print(f"{Colors.INFO}🎤 开始语音输入...{Colors.RESET}") + voice_text = self.listen_voice() + if voice_text: + await self.send_user_message(voice_text) + else: + print(f"{Colors.WARNING}⚠️ 麦克风不可用,请输入文本{Colors.RESET}") + continue + + if user_input.startswith('/'): + await self.handle_command(user_input) + else: + await self.send_user_message(user_input) + + except KeyboardInterrupt: + print(f"\n{Colors.INFO}⏹️ 已取消{Colors.RESET}") + continue + except EOFError: + print(f"\n{Colors.SYSTEM}再见!{Colors.RESET}") + if self.tts: + self.tts.speak("再见") + if self.client: + await self.client.disconnect() + break + except Exception as e: + print(f"{Colors.ERROR}错误: {e}{Colors.RESET}") + +async def main(): + cli = VoiceInterface() + + default_uri = "ws://localhost:8765" + uri = sys.argv[1] if len(sys.argv) > 1 else default_uri + + cli.client = WebSocketClient(uri) + if await cli.client.connect(): + print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}") + if cli.tts and cli.has_tts: + cli.tts.speak(f"已连接到 {uri}") + asyncio.create_task(cli.client.receive_messages(cli.tts)) + else: + print(f"{Colors.WARNING}⚠️ 无法连接到 {uri}{Colors.RESET}") + print(f"{Colors.INFO}ℹ️ 你可以通过命令行参数指定地址: python voice_client.py ws://your-server:8765{Colors.RESET}") + print(f"{Colors.INFO}ℹ️ 或者使用 /connect 命令手动连接{Colors.RESET}") + + try: + await cli.run() + except asyncio.CancelledError: + pass + finally: + if cli.client: + await cli.client.disconnect() + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}") -- 2.54.0