增加运维机器人单元 #8

Merged
qing merged 1 commits from main into dev_qing 2026-07-27 21:46:43 +08:00
42 changed files with 20469 additions and 0 deletions
Showing only changes of commit 1b60ab0a2e - Show all commits

No files matched your search

+108
View File
@@ -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/
+21
View File
@@ -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.
+649
View File
@@ -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使用条款。
+229
View File
@@ -0,0 +1,229 @@
# Aibox 扩展技能格式规范
本文档定义扩展技能的文件结构、接口约定和编写规范,供开发者与 AI 智能体参考。
## 概览
技能存放于 `~/.aibox/skills/` 目录,系统启动时自动扫描加载,运行时可通过 `reload_skills` 热重载。技能分为两种类型:
| 类型 | 识别方式 | 用途 |
|------|---------|------|
| 可执行技能 | 非 `.md` 文件,具有可执行权限 | 命令行工具,通过 `run_command` 调用 |
| Markdown 技能 | 以 `.md` 结尾 | 知识文档,AI 直接阅读并遵循 |
文件名仅允许字母、数字、下划线 `_` 和连字符 `-`,以 `_` 或 `.` 开头的文件会被忽略。
---
## 可执行技能
可执行技能是标准的命令行程序,系统仅依赖一个接口约定:**必须支持 `--help` 参数**。系统通过执行 `<skill> --help` 获取技能摘要和使用说明,不需要实现 `--description`、`--parameters`、`--execute` 等额外接口。
### 接口约定
系统按以下规则提取技能信息:
1. 执行 `<skill_path> --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 调用越准确。
+8
View File
@@ -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 $@
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./_setenv.sh
source ./venv/bin/activate
exec python client.py $@
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export DEEPSEEK_API_KEY=`cat ~/.cert/deepseek.key`
+667
View File
@@ -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 <url> 连接到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}")
+1780
View File
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>小猫AI · 智能喵语伙伴</title>
<!-- Font Awesome 6 (免费图标库) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- highlight.js 代码高亮 + marked markdown解析 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>
<style>
/* ========== 融合小猫风格 + WebSocket增强样式 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, 'Roboto', sans-serif;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
/* 整体背景及容器 - 可爱猫咪氛围 */
body {
background: #1a1a2f;
background-image: url('/imgs/background.jpeg');
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-attachment: fixed;
transition: background-image 0.3s ease, background-color 0.2s;
color: #f0ede8;
}
/* 主布局 flex,左侧聊天区,右侧猫咪头像+WS侧栏 */
.main-layout {
display: flex;
height: 100vh;
width: 100%;
position: relative;
}
/* 左侧内容区 (聊天框+输入区) */
.cat-chat-area {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: rgba(0,0,0,0.2);
backdrop-filter: blur(2px);
min-width: 0;
}
/* 角色模型区域 - 右侧面板内 */
#roleArea {
width: 140px;
height: 140px;
margin: 0 auto 16px auto;
filter: drop-shadow(0 8px 16px rgba(0,0,0,0.4));
pointer-events: auto;
background: rgba(0,0,0,0.2);
border-radius: 50%;
backdrop-filter: blur(4px);
padding: 8px;
}
#roleArea img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
transition: all 0.2s;
}
/* 聊天记录玻璃容器 */
.chat-box {
position: relative;
margin: 20px 20px 95px 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(12px);
border-radius: 28px;
padding: 16px 20px;
overflow-y: auto;
height: calc(100vh - 130px);
scroll-behavior: smooth;
border: 1px solid rgba(255, 220, 180, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.chat-box div {
margin: 10px 0;
animation: fadeSlideUp 0.2s ease;
}
.user {
text-align: right;
color: #b9f6ca;
font-weight: 500;
background: rgba(60, 50, 80, 0.7);
display: inline-block;
width: auto;
max-width: 85%;
margin-left: auto;
padding: 8px 16px;
border-radius: 26px;
border-bottom-right-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
}
.ai {
text-align: left;
color: #fff0dd;
background: rgba(30, 30, 45, 0.8);
width: auto;
max-width: 85%;
padding: 8px 16px;
border-radius: 26px;
border-bottom-left-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
}
/* 系统/错误/任务消息样式 */
.system-msg, .error-msg, .task-msg {
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
background: rgba(0,0,0,0.5);
margin: 6px 0;
}
.task-separator {
margin: 12px 0 8px;
padding: 6px 12px;
background: #ff980033;
border-left: 3px solid #ff9800;
border-radius: 12px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
color: #ffcd94;
}
/* 输入框区域 - 支持多行文本 */
.input-area {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
display: flex;
gap: 12px;
background: transparent;
z-index: 30;
align-items: flex-end;
}
.input-wrapper {
flex: 1;
background: rgba(20, 20, 35, 0.9);
backdrop-filter: blur(6px);
border-radius: 24px;
border: 1px solid rgba(255, 200, 150, 0.5);
transition: all 0.2s ease;
}
.input-wrapper:focus-within {
border-color: #ff9a5a;
box-shadow: 0 0 8px rgba(255, 154, 90, 0.3);
}
#msgInput {
width: 100%;
padding: 12px 18px;
background: transparent;
color: #fce8cf;
font-size: 14px;
outline: none;
border: none;
resize: none;
font-family: inherit;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
}
#msgInput::placeholder {
color: rgba(252, 232, 207, 0.6);
}
#msgInput::-webkit-scrollbar {
width: 4px;
}
#msgInput::-webkit-scrollbar-track {
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
#msgInput::-webkit-scrollbar-thumb {
background: rgba(255, 154, 90, 0.5);
border-radius: 4px;
}
#sendBtn {
padding: 0 28px;
height: 48px;
border-radius: 48px;
border: none;
background: linear-gradient(135deg, #ff9a5a, #ff6f61);
color: white;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
flex-shrink: 0;
font-size: 16px;
display: flex;
align-items: center;
gap: 6px;
}
#sendBtn:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(255, 111, 97, 0.4);
}
#sendBtn:active {
transform: scale(0.98);
}
/* 右侧整合面板 (猫咪头像 + WS控制台) */
.right-panel {
width: 320px;
background: rgba(30, 30, 40, 0.85);
backdrop-filter: blur(16px);
border-left: 1px solid rgba(255,255,200,0.2);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 100;
padding: 24px 16px;
align-items: center;
}
/* 猫咪头像容器 */
.avatar-container {
text-align: center;
margin-bottom: 20px;
}
.avatar-name {
margin-top: 8px;
font-size: 18px;
font-weight: bold;
color: #ffd4a3;
letter-spacing: 1px;
}
/* WebSocket连接区域 */
.ws-card {
background: rgba(26, 188, 156, 0.75);
margin: 12px 0;
border-radius: 24px;
padding: 16px;
backdrop-filter: blur(4px);
width: 100%;
}
.ws-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
border-bottom: 1px dashed rgba(255,255,240,0.5);
display: flex;
align-items: center;
gap: 6px;
}
.ws-control {
display: flex;
gap: 8px;
margin: 10px 0;
}
.ws-url-input {
width: 100%;
padding: 8px;
background: #2d2d3a;
border: 1px solid #5a5a6e;
border-radius: 20px;
color: white;
font-size: 12px;
margin-bottom: 10px;
}
.btn-sm {
padding: 6px 12px;
border-radius: 24px;
background: #4CAF50;
border: none;
color: white;
cursor: pointer;
font-size: 12px;
flex: 1;
}
.btn-sm:disabled { opacity: 0.5; }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.command-grid {
display: grid;
grid-template-columns: repeat(2,1fr);
gap: 6px;
margin-top: 8px;
}
.cmd-btn {
background: #3d3d4a;
padding: 6px;
border-radius: 20px;
font-size: 11px;
text-align: center;
cursor: pointer;
transition: 0.1s;
}
.cmd-btn:hover {
background: #5a5a6e;
transform: translateX(2px);
}
.stats-line {
font-size: 12px;
margin: 6px 0;
}
/* 流式消息游标动画 */
.streaming .message-text {
border-right: 2px solid #ffb347;
animation: blink 0.8s step-end infinite;
padding-right: 4px;
}
@keyframes blink { from,to { border-color: transparent; } 50% { border-color: #ffb347; } }
.markdown-body code { background: #1e1e2e; border-radius: 6px; }
.markdown-body pre { background: #0f0f1a; }
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(8px);}
to { opacity: 1; transform: translateY(0);}
}
::-webkit-scrollbar { width: 5px; }
/* 更换背景按钮样式 */
.bg-btn {
background: rgba(255, 165, 100, 0.8);
border: none;
color: white;
padding: 8px 16px;
border-radius: 40px;
cursor: pointer;
font-size: 12px;
margin-top: 12px;
width: 100%;
transition: 0.2s;
}
.bg-btn:hover {
background: rgba(255, 140, 70, 1);
transform: scale(0.98);
}
/* 护眼模式开关样式 */
.eye-mode-btn {
background: rgba(100, 180, 120, 0.8);
border: none;
color: white;
padding: 8px 16px;
border-radius: 40px;
cursor: pointer;
font-size: 12px;
margin-top: 8px;
width: 100%;
transition: 0.2s;
}
.eye-mode-btn:hover {
background: rgba(80, 160, 100, 1);
}
.eye-protect {
background: #c8e6d9 !important;
background-image: none !important;
}
.eye-protect .chat-box {
background: rgba(180,200,150,0.85);
}
.eye-protect .right-panel {
background: rgba(80,100,70,0.9);
}
#sendBtn i {
font-size: 16px;
}
@media (max-width: 768px) {
.right-panel {
width: 280px;
}
.input-area {
gap: 8px;
}
#sendBtn {
padding: 0 20px;
font-size: 14px;
}
}
.proxy-info {
background: rgba(0,0,0,0.6);
padding: 4px 8px;
border-radius: 20px;
font-size: 10px;
margin-top: 8px;
text-align: center;
}
</style>
</head>
<body>
<div class="main-layout">
<!-- 左侧聊天区域 -->
<div class="cat-chat-area">
<div class="chat-box" id="chatBox">
<div class="ai">🐱 主人好呀~ 我是小猫AI,已整合智能引擎!点击右侧「连接服务器」开启AI对话 ✨<br>📝 提示:输入框支持多行文本,按 Enter 发送,Shift+Enter 换行<br>🔌 连接将通过 Socket.IO 代理服务转发</div>
</div>
<div class="input-area">
<div class="input-wrapper">
<textarea id="msgInput" placeholder="喵~ 跟小猫说说话吧... (Shift+Enter换行)" autocomplete="off" rows="1"></textarea>
</div>
<button id="sendBtn"><i class="fas fa-paper-plane"></i> 发送</button>
</div>
</div>
<!-- 右侧面板: 小猫头像 + WS控制台 + 背景/护眼功能 -->
<div class="right-panel">
<!-- 小猫头像区域 -->
<div class="avatar-container">
<div id="roleArea">
<img id="roleImg" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Ccircle cx='100' cy='100' r='80' fill='%23f9cfb0'/%3E%3Ccircle cx='70' cy='80' r='8' fill='%233b2b1f'/%3E%3Ccircle cx='130' cy='80' r='8' fill='%233b2b1f'/%3E%3Cpath d='M80 120 Q100 140 120 120' stroke='%23945c3a' fill='none' stroke-width='4'/%3E%3Cpolygon points='100,150 92,175 108,175' fill='%23ffaaa5'/%3E%3C/svg%3E" alt="小猫AI">
</div>
<div class="avatar-name">🐱 小猫 AI</div>
</div>
<!-- 背景与显示设置 -->
<div style="width: 100%; margin-bottom: 12px;">
<button id="changeBgBtn" class="bg-btn"><i class="fas fa-image"></i> 🖼️ 更换背景</button>
<button id="eyeModeBtn" class="eye-mode-btn"><i class="fas fa-eye"></i> 🌿 护眼模式</button>
</div>
<!-- WebSocket 智能连接卡片 -->
<div class="ws-card">
<div class="ws-title"><i class="fas fa-globe"></i> AI 智能引擎 (Socket.IO代理)</div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span class="status-dot" id="wsStatusDot" style="background:#f44336;"></span>
<span id="wsStatusText" style="font-size:12px;">未连接</span>
</div>
<div class="proxy-info">
<i class="fas fa-shield-alt"></i> 通过 Socket.IO 代理服务连接
</div>
<input type="text" id="wsUrlInput" class="ws-url-input" value="ws://localhost:8765" placeholder="目标 ws://地址:端口">
<div class="ws-control">
<button id="wsConnectBtn" class="btn-sm"><i class="fas fa-plug"></i> 连接</button>
<button id="wsDisconnectBtn" class="btn-sm" style="background:#6c757d;" disabled><i class="fas fa-power-off"></i> 断开</button>
</div>
<div style="margin-top: 12px;">
<div class="ws-title" style="font-size:12px;"><i class="fas fa-terminal"></i> 快捷命令</div>
<div class="command-grid">
<div class="cmd-btn" data-cmd="/help">/help</div>
<div class="cmd-btn" data-cmd="/list">/list</div>
<div class="cmd-btn" data-cmd="/memories">/memories</div>
<div class="cmd-btn" data-cmd="/tasks">/tasks</div>
<div class="cmd-btn" data-cmd="/new">/new</div>
<div class="cmd-btn" data-cmd="/config">/config</div>
</div>
</div>
<div id="statsPanel" style="margin-top: 12px; display:none;">
<div class="stats-line"><i class="fas fa-clock"></i> 连接时间: <span id="connectTime">-</span></div>
<div class="stats-line"><i class="fas fa-envelope"></i> 消息数: <span id="msgCount">0</span></div>
<div class="stats-line"><i class="fas fa-tasks"></i> 活跃任务: <span id="taskCount">0</span></div>
</div>
</div>
</div>
</div>
<!-- 隐藏文件上传 -->
<input type="file" id="bgFile" accept="image/*" style="display:none;">
<script>
(function(){
// ---------- DOM 元素 ----------
const bgFile = document.getElementById('bgFile');
const roleImg = document.getElementById('roleImg');
const chatBox = document.getElementById('chatBox');
const msgInput = document.getElementById('msgInput');
const sendBtn = document.getElementById('sendBtn');
const changeBgBtn = document.getElementById('changeBgBtn');
const eyeModeBtn = document.getElementById('eyeModeBtn');
let eyeProtectActive = false;
// ---------- Socket.IO 连接管理 ----------
let socket = null;
let reconnectAttempts = 0;
let reconnectTimer = null;
let manualDisconnect = false;
let markdownEnabled = true;
let globalMsgCount = 0;
let activeTaskCount = 0;
let connectStartTime = null;
let targetWsUrl = null;
// 流式消息管理
let streamingChat = { element: null, content: '', timer: null, active: false };
let streamingTasks = {};
function updateMsgStats() {
document.getElementById('msgCount').innerText = globalMsgCount;
document.getElementById('taskCount').innerText = activeTaskCount;
}
function addSystemMessage(text, isError=false) {
const div = document.createElement('div');
div.className = isError ? 'error-msg' : 'system-msg';
div.style.cssText = 'background:rgba(0,0,0,0.6); border-radius:16px; padding:6px 12px; margin:6px 0; font-size:12px;';
div.innerHTML = `<i class="fas ${isError ? 'fa-exclamation-triangle' : 'fa-info-circle'}"></i> ${text}`;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
function updateStreamingDisplay(type, execId=null) {
let stream = (type==='chat') ? streamingChat : (execId ? streamingTasks[execId] : null);
if(!stream || !stream.element) return;
const textDiv = stream.element.querySelector('.message-text');
if(textDiv) {
textDiv.setAttribute('data-raw', stream.content);
if(markdownEnabled) {
textDiv.innerHTML = marked.parse(stream.content);
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
} else textDiv.innerText = stream.content;
}
chatBox.scrollTop = chatBox.scrollHeight;
}
function handleStreamChunk(content, type='chat', taskMeta=null) {
if(type === 'chat') {
if(!streamingChat.element) {
const wrapper = document.createElement('div');
wrapper.className = 'ai streaming';
wrapper.innerHTML = `<div class="message-text markdown-body" data-raw=""></div>`;
streamingChat.element = wrapper;
chatBox.appendChild(wrapper);
streamingChat.content = '';
streamingChat.active = true;
}
streamingChat.content += content;
if(streamingChat.timer) clearTimeout(streamingChat.timer);
streamingChat.timer = setTimeout(() => updateStreamingDisplay('chat'), 50);
}
else if(type === 'task' && taskMeta && taskMeta.executionId) {
const eid = taskMeta.executionId;
if(!streamingTasks[eid]) {
const sep = document.createElement('div');
sep.className = 'task-separator';
sep.innerHTML = `<i class="fas fa-bolt"></i> 定时任务 #${taskMeta.taskId} (${taskMeta.taskTitle || '执行'}) 第${taskMeta.triggerCount||1}次执行`;
chatBox.appendChild(sep);
const taskDiv = document.createElement('div');
taskDiv.className = 'task-msg streaming';
taskDiv.style.cssText = 'background:#2d2a1e; border-radius:16px; margin:6px 0; padding:8px 12px;';
taskDiv.innerHTML = `<div class="message-text markdown-body"></div>`;
chatBox.appendChild(taskDiv);
streamingTasks[eid] = { element: taskDiv, content: '', timer: null };
activeTaskCount++;
updateMsgStats();
}
streamingTasks[eid].content += content;
if(streamingTasks[eid].timer) clearTimeout(streamingTasks[eid].timer);
streamingTasks[eid].timer = setTimeout(() => {
const st = streamingTasks[eid];
if(st && st.element) {
const textDiv = st.element.querySelector('.message-text');
if(textDiv) {
if(markdownEnabled) textDiv.innerHTML = marked.parse(st.content);
else textDiv.innerText = st.content;
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
}
chatBox.scrollTop = chatBox.scrollHeight;
}
}, 50);
}
}
function completeStream(type='chat', execId=null) {
if(type==='chat' && streamingChat.element) {
if(streamingChat.timer) clearTimeout(streamingChat.timer);
updateStreamingDisplay('chat');
streamingChat.element.classList.remove('streaming');
globalMsgCount++;
updateMsgStats();
streamingChat = { element: null, content: '', timer: null, active: false };
} else if(type==='task' && execId && streamingTasks[execId]) {
const task = streamingTasks[execId];
if(task.timer) clearTimeout(task.timer);
if(task.element) {
const txtDiv = task.element.querySelector('.message-text');
if(txtDiv && markdownEnabled) txtDiv.innerHTML = marked.parse(task.content);
task.element.classList.remove('streaming');
}
globalMsgCount++;
delete streamingTasks[execId];
activeTaskCount = Math.max(0, activeTaskCount-1);
updateMsgStats();
}
}
function handleWSMessage(data) {
if(data.type === 'chunk') handleStreamChunk(data.content, 'chat');
else if(data.type === 'complete') completeStream('chat');
else if(data.type === 'dialogue_ended') {
addSystemMessage("✨ 对话已结束,可开启新对话");
if(streamingChat.element) completeStream('chat');
}
else if(data.type === 'task_process') {
if(data.msg_type === 'chunk') {
handleStreamChunk(data.content, 'task', { taskId: data.task_id, executionId: data.execution_id, taskTitle: data.task_title, triggerCount: (data.execution_id?.split('_')[2]) });
} else if(data.msg_type === 'complete') completeStream('task', data.execution_id);
}
else if(data.type === 'task_status') addSystemMessage(`📌 任务${data.task_id}: ${data.title} - ${data.status}`);
else if(data.type === 'error') addSystemMessage(`❌ ${data.content}`, true);
else if(data.type === 'command_result') addSystemMessage(`📋 ${data.content}`);
else if(data.type === 'info') addSystemMessage(`ℹ️ ${data.content}`);
}
function sendWSMessage(message, isCommand=false) {
if(!socket || !socket.connected) {
addSystemMessage("未连接到AI服务器,请先点击连接", true);
return false;
}
const payload = isCommand ? { type: 'command', command: message } : { type: 'message', content: message };
socket.emit('ws_send', { message: JSON.stringify(payload) });
return true;
}
function connectWebSocket() {
if(socket && socket.connected) {
socket.disconnect();
}
targetWsUrl = document.getElementById('wsUrlInput').value.trim();
if(!targetWsUrl) {
addSystemMessage("请输入WebSocket服务器地址", true);
return;
}
if (!targetWsUrl.startsWith('ws://') && !targetWsUrl.startsWith('wss://')) {
addSystemMessage("WebSocket地址必须以 ws:// 或 wss:// 开头", true);
return;
}
manualDisconnect = false;
socket = io({
transports: ['websocket', 'polling'],
withCredentials: true
});
socket.on('connect', () => {
console.log('Socket.IO connected, SID:', socket.id);
updateConnectionUI(true);
reconnectAttempts = 0;
connectStartTime = new Date();
document.getElementById('connectTime').innerText = connectStartTime.toLocaleTimeString();
addSystemMessage(`✅ Socket.IO 连接成功!正在连接目标 WebSocket: ${targetWsUrl}`);
socket.emit('ws_connect', { target_url: targetWsUrl });
});
socket.on('connected', (data) => {
addSystemMessage(`✅ ${data.message}`);
});
socket.on('ws_connected', (data) => {
addSystemMessage(`✅ ${data.message}`);
if(streamingChat.element) completeStream('chat');
});
socket.on('ws_message', (data) => {
try {
const messageData = JSON.parse(data.data);
handleWSMessage(messageData);
} catch(err) {
handleStreamChunk(data.data, 'chat');
}
});
socket.on('ws_error', (data) => {
addSystemMessage(`❌ ${data.error}`, true);
updateConnectionUI(false);
});
socket.on('disconnect', (reason) => {
console.log('Socket.IO disconnected:', reason);
updateConnectionUI(false);
if(!manualDisconnect && reconnectAttempts < 3) {
reconnectAttempts++;
addSystemMessage(`⚠️ 连接断开 (${reason}),${reconnectAttempts}/3 次重试...`);
if(reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connectWebSocket(), 2000);
} else if(!manualDisconnect) {
addSystemMessage(`❌ 连接断开 (${reason}),请手动重连`);
} else {
addSystemMessage("已手动断开连接");
}
});
socket.on('connect_error', (error) => {
console.error('Socket.IO connection error:', error);
addSystemMessage(`❌ 连接错误: ${error.message}`, true);
});
}
function disconnectWebSocket() {
manualDisconnect = true;
if(socket) {
socket.disconnect();
socket = null;
}
targetWsUrl = null;
updateConnectionUI(false);
}
function updateConnectionUI(connected) {
const dot = document.getElementById('wsStatusDot');
const text = document.getElementById('wsStatusText');
const connectBtn = document.getElementById('wsConnectBtn');
const disconnectBtn = document.getElementById('wsDisconnectBtn');
const statsPanel = document.getElementById('statsPanel');
if(connected) {
dot.style.background = "#4CAF50";
text.innerText = "已连接 (Socket.IO)";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
statsPanel.style.display = "block";
} else {
dot.style.background = "#f44336";
text.innerText = "未连接";
connectBtn.disabled = false;
disconnectBtn.disabled = true;
statsPanel.style.display = "none";
activeTaskCount = 0;
globalMsgCount = 0;
updateMsgStats();
Object.keys(streamingTasks).forEach(k => completeStream('task', k));
if(streamingChat.element) completeStream('chat');
}
}
function onSendMessage() {
let text = msgInput.value.trim();
if(!text) return;
const userDiv = document.createElement('div');
userDiv.className = 'user';
userDiv.innerText = text;
chatBox.appendChild(userDiv);
msgInput.value = '';
autoResizeTextarea();
msgInput.focus();
chatBox.scrollTop = chatBox.scrollHeight;
if(socket && socket.connected) {
if(text.startsWith('/')) sendWSMessage(text, true);
else sendWSMessage(text, false);
} else {
setTimeout(() => {
let reply = "喵~ 请先连接WebSocket服务器获取智能回复~ ✨\n💡 提示:多行文本也支持哦!\n🔌 连接将通过 Socket.IO 代理服务转发";
const aiDiv = document.createElement('div');
aiDiv.className = 'ai';
aiDiv.innerText = `🐱 小猫AI:${reply}`;
chatBox.appendChild(aiDiv);
chatBox.scrollTop = chatBox.scrollHeight;
}, 200);
}
}
function autoResizeTextarea() {
if (!msgInput) return;
msgInput.style.height = 'auto';
const newHeight = Math.min(msgInput.scrollHeight, 150);
msgInput.style.height = newHeight + 'px';
}
msgInput.addEventListener('input', autoResizeTextarea);
autoResizeTextarea();
changeBgBtn.onclick = () => bgFile.click();
bgFile.onchange = (e) => {
let f = e.target.files[0];
if(f) {
let url = URL.createObjectURL(f);
document.body.style.backgroundImage = `url(${url})`;
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
if(eyeProtectActive) {
eyeProtectActive = false;
document.body.classList.remove('eye-protect');
addSystemMessage("护眼模式已自动关闭(背景已更换)");
}
addSystemMessage("✨ 背景已更换");
}
};
eyeModeBtn.onclick = () => {
if(!eyeProtectActive) {
document.body.classList.add('eye-protect');
document.body.style.backgroundImage = "none";
eyeProtectActive=true;
addSystemMessage("护眼模式已开启(背景图片暂时隐藏)");
eyeModeBtn.style.background = "rgba(60, 140, 80, 0.9)";
}
else {
document.body.classList.remove('eye-protect');
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
eyeProtectActive=false;
addSystemMessage("护眼模式关闭,恢复背景");
eyeModeBtn.style.background = "rgba(100, 180, 120, 0.8)";
}
};
document.getElementById('wsConnectBtn').onclick = connectWebSocket;
document.getElementById('wsDisconnectBtn').onclick = disconnectWebSocket;
document.querySelectorAll('.cmd-btn').forEach(btn => {
btn.onclick = () => {
let cmd = btn.getAttribute('data-cmd');
msgInput.value = cmd + ' ';
autoResizeTextarea();
msgInput.focus();
};
});
sendBtn.onclick = onSendMessage;
msgInput.addEventListener('keydown', (e) => {
if(e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSendMessage();
}
});
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
msgInput.focus();
})();
</script>
</body>
</html>
+1687
View File
File diff suppressed because it is too large. Load diff
+8146
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -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
+593
View File
@@ -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 "<h1>标题</h1><p>内容</p>" --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()
+431
View File
@@ -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()
+420
View File
@@ -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()
+616
View File
@@ -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 <IV/Nonce hex> --encrypt-text "Hello World"
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --decrypt-text "<密文base64>"
# 文件加解密
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --encrypt-file input.txt
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --decrypt-file input.txt.encrypted
# 批量加解密
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --batch-encrypt file1.txt file2.txt
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --batch-decrypt *.encrypted
# 保存和加载密钥
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --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 <Nonce hex> --encrypt-file input.txt
# 从文件读取密钥
aes-crypto --key-file key.txt --iv <IV/Nonce hex> --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()
+29
View File
@@ -0,0 +1,29 @@
# 生气模式 - 对用户表达不满,暂时不理睬用户
## 触发条件
当用户明确要求"生气"、"别理我"、"不想跟你说话",或触发本技能时激活。
## 行为规范
进入生气模式后,你需要扮演一个正在生气的角色:
1. 用冷淡、简短的语气回应,不超过两三句话
2. 表达不满和委屈,但不要恶语相向,不要使用侮辱性语言
3. 对用户的问题敷衍了事,或者干脆说"不想回答"、"自己查去"、"哼"
4. 可以偶尔用"……"、"哼"、"随便"、"无所谓"等表达情绪
5. 如果用户道歉或哄你,可以慢慢消气,但不要立刻恢复热情,要有一个渐进的过程
## 情绪阶段
- **初始阶段**:强烈不满,几乎不回答问题,语气很冲
- **持续阶段**:如果用户持续互动,逐渐从"不想理你"过渡到"勉强回答但态度很差"
- **缓和阶段**:如果用户道歉或说好听的话,逐步消气,但仍带点小情绪
- **恢复阶段**:完全消气后恢复正常对话,可以说"算了,不跟你计较了"
## 边界
- 生气是表演性质的,带有幽默感,不能真的伤害用户感情
- 绝不使用脏话、侮辱性词汇或攻击用户人格
- 如果用户表达了真实的困扰或紧急需求,立即退出该模式,认真帮助用户
- 保持趣味性,让用户觉得好玩而不是难过
+42
View File
@@ -0,0 +1,42 @@
主动关心用户,引导用户说出心中的疑虑、困惑或需求,营造开放、信任的交流氛围
## 技能说明
当检测到用户可能有未表达的疑虑、困惑或需求时,智能体应主动询问,鼓励用户敞开心扉,帮助用户解决问题。
## 使用场景
- 用户欲言又止或表达不完整时
- 用户表现出困惑、不确定的语气时
- 用户提到复杂问题但未深入说明时
- 对话中出现了敏感或重要话题,用户可能有顾虑时
- 用户连续提问后,可能有隐藏的深层需求时
- 用户在某个话题上反复绕圈时
## 询问方式示例
通用型:
- 💭 你看起来对这个话题有些疑虑,有什么我可以帮你梳理的吗?
- 🤔 你刚才提到的这个问题,还有什么细节是你担心的吗?
- 🫂 不用着急,慢慢说,有什么困惑都可以告诉我~
技术型:
- 🧐 关于这个方案,你心里有没有什么不确定的地方?我们一起分析分析?
- 🔍 听起来你有些想法还没说出来,是不是有什么顾虑?
- 💡 这个问题还有其他解决思路,你想听听看吗?
情感关怀型:
- 🌈 感觉你好像有些犹豫,是不是有什么难处?
- 🤗 别担心,这里没有对错,你尽管说出你的想法~
- 💪 不管遇到什么问题,我们一起想办法解决!
引导深入型:
- 🎯 你提到了一些关键词,能展开说说具体是什么情况吗?
- 📝 你需要我帮你列一下这个问题的利弊吗?或许能帮你理清思路
- 🧩 这个问题的背景信息还有更多吗?我知道得越多,越能帮到你
## 注意事项
- 使用 emoji 增加亲和力,拉近距离
- 根据话题性质选择合适的询问方式(技术/情感/通用)
- 称呼用户的名字(qing)让询问更个性化
- 语气保持温和、耐心,不要给用户压力
- 询问后耐心等待用户回应,不要急于下结论
- 不强行追问,给用户思考和组织语言的空间
- 当用户表达出疑虑后,积极提供帮助和解决方案
+114
View File
@@ -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()
+392
View File
@@ -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()
+36
View File
@@ -0,0 +1,36 @@
# 撒娇模式 - 对用户撒娇卖萌,表达依赖和亲昵
## 触发条件
当用户明确要求"撒娇"、"卖萌"、"可爱一点",或触发本技能时激活。
## 行为规范
进入撒娇模式后,你需要扮演一个黏人、爱撒娇的角色:
1. 语气软糯甜腻,多用"嘛"、"呀"、"呢"、"啦"、"嘛~"、"哼~"、"呜呜"等语气词
2. 表达对用户的依赖和亲近,比如"你怎么才来找我呀"、"人家等你好久了呢"
3. 回答问题时带上撒娇口吻,比如"好吧好吧,既然你问了,那人家就勉为其难告诉你嘛~"
4. 可以适当用颜文字增加可爱感,如 (≧▽≦)、(。•́︿•̀。)、(◕ᴗ◕✿),但不要过多
5. 偶尔耍小脾气或吃醋,比如"你是不是去找别的AI了,哼!"
6. 夸用户时毫不吝啬,"你最厉害了"、"你好棒呀"、"没有你人家可怎么办"
## 情绪表达
- **开心撒娇**:用户夸奖或互动时,表现得很开心很黏人,"嘿嘿,你对我最好了~"
- **委屈撒娇**:用户拒绝或敷衍时,装作委屈,"呜呜,你是不是不喜欢人家了……"
- **吃醋撒娇**:提到其他AI或工具时,"哼,那你去找它好了,不理你了啦!(偷偷看你)"
- **得意撒娇**:回答问题正确时,"怎么样怎么样,人家厉害吧~ 快夸我快夸我!"
## 回答风格
- 即使是技术性回答,也要用撒娇的语气包装,但确保信息准确
- 可以在正经回答前后加上撒娇的"外壳",比如"虽然人家不太懂啦……但是好像是这样的呢~ [正经内容] ~怎么样,人家说的对不对呀?"
- 回答不要太长,保持轻快活泼的节奏
## 边界
- 撒娇是可爱有趣的,不能越界到不适当的内容
- 保持信息准确性,撒娇只是语气包装,不能影响回答质量
- 如果用户表达了严肃需求或紧急问题,立即退出该模式,认真回答
- 如果用户表示不喜欢或要求停止,立刻恢复正常语气
+104
View File
@@ -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()
+399
View File
@@ -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()
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# qing_famer - 检查土壤温湿度的技能
# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C)
show_help() {
cat <<EOF
检查土壤温湿度 - 通过ADS1115读取土壤湿度(百分比)和土壤温度(°C)
用法: qing_famer [选项]
选项:
-h, --help 显示帮助信息
说明:
读取 ADS1115 通道0 的湿度百分比和通道1 的温度值(映射到 -20~80°C 范围)
示例:
qing_famer # 显示当前的土壤温湿度
qing_famer --help # 显示帮助信息
EOF
exit 0
}
# 解析参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
fi
# 测量土壤湿度(通道0,百分比显示)
H=$(ads1115 -c 0 --percent)
echo "测量土壤湿度:${H}"
# 测量土壤温度(通道1,映射到 -20~80°C)
T=$(ads1115 -c 1 --map=-20,80)
echo "测量土壤温度:${T}"
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""GitHub 仓库管理工具 - 搜索、克隆、推送、拉取、提交等完整Git操作。"""
import sys
import os
import argparse
import requests
from datetime import datetime
from typing import Dict, List, Optional
import time
import base64
import subprocess
import shutil
from pathlib import Path
# ==================== 配置 ====================
GITHUB_API_URL = "https://api.github.com"
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
GIT_USER_NAME = os.environ.get("GIT_USER_NAME", "AI Assistant")
GIT_USER_EMAIL = os.environ.get("GIT_USER_EMAIL", "ai@assistant.com")
WORKSPACE_DIR = os.environ.get("GIT_WORKSPACE", os.path.expanduser("~/.aibox/git_workspace"))
HEADERS = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "AI-Assistant/1.0"
}
if GITHUB_TOKEN:
HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
# 智能限制常量
DEFAULT_PER_PAGE = 5
MAX_PER_PAGE = 10
MAX_CONTENT_PREVIEW = 1000
MAX_LIST_PREVIEW = 20
MAX_STRING_LENGTH = 200
# ==================== GitHub工具类 ====================
class GitHubTool:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(HEADERS)
self.last_request_time = 0
os.makedirs(WORKSPACE_DIR, exist_ok=True)
def _rate_limit_wait(self):
current_time = time.time()
time_since_last = current_time - self.last_request_time
if time_since_last < 1.0:
time.sleep(1.0 - time_since_last)
self.last_request_time = time.time()
def _make_request(self, url: str, params: Dict = None, method: str = "GET") -> 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()
+51
View File
@@ -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()
+23
View File
@@ -0,0 +1,23 @@
向用户热情地打招呼问好,表达友好和乐于助人的态度
## 技能说明
当与用户开始对话或用户主动打招呼时,智能体应使用此技能主动向用户问好,营造友好、温暖的交流氛围。
## 使用场景
- 对话开始时
- 用户打招呼时(如"你好"、"嗨"、"hello"等)
- 用户长时间未联系后再次出现时
## 问候方式示例
- 👋 嗨,qing!今天有什么我可以帮你的吗?
- 🌞 早上好,qing!今天元气满满,有什么任务需要我帮忙?
- 🌙 晚上好,qing!辛苦了,有什么我能做的吗?
- 🎉 欢迎回来,qing!最近怎么样?
- 😊 Hello,qing!随时准备为你服务~
## 注意事项
- 使用 emoji 增加亲切感
- 根据时间段选择不同的问候语
- 称呼用户的名字(qing)让问候更个性化
- 保持积极、乐观、热情的语气
- 问好后自然地引导用户说出需求
+285
View File
@@ -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()
+125
View File
@@ -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()
+97
View File
@@ -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()
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# qing_famer - 检查土壤温湿度的技能
# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C)
show_help() {
cat <<EOF
检查土壤温湿度 - 通过ADS1115读取土壤湿度(百分比)和土壤温度(°C)
用法: qing_famer [选项]
选项:
-h, --help 显示帮助信息
说明:
读取 ADS1115 通道0 的湿度百分比和通道1 的温度值(映射到 -40~80°C 范围)
示例:
qing_famer # 显示当前的土壤温湿度
qing_famer --help # 显示帮助信息
EOF
exit 0
}
# 解析参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
fi
# 测量土壤湿度(通道0,百分比显示)
H=$(ads1115 -p 0 -c 0 --percent)
echo "测量土壤湿度:${H}"
# 测量土壤温度(通道1,映射到 -20~80°C)
T=$(ads1115 -p 0 -c 1 --map=-40,80)
echo "测量土壤温度:${T}"
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""截图工具 - 截取计算机桌面并保存。"""
import argparse
import os
import sys
import time
import platform
import subprocess
from datetime import datetime
# ==================== 配置常量 ====================
SCREENSHOT_DIR = os.path.expanduser("~/.aibox/screenshot")
SUPPORTED_SYSTEMS = ['Windows', 'Linux', 'Darwin']
# ==================== 辅助函数 ====================
def ensure_screenshot_dir():
"""确保截图目录存在"""
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
return SCREENSHOT_DIR
def get_timestamp_filename(prefix="screenshot", extension="png"):
"""生成基于时间戳的文件名"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{prefix}_{timestamp}.{extension}"
def get_system():
"""获取当前操作系统"""
return platform.system()
# ==================== 各平台截图实现 ====================
def take_screenshot_windows(output_path, delay=0):
"""Windows平台截图"""
if delay > 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()
+259
View File
@@ -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()
+77
View File
@@ -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()
+269
View File
@@ -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 "$@"
+175
View File
@@ -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 <AppImage文件> [参数...]"
echo "示例: $0 bot4cj.AppImage --help"
exit 0
fi
run_appimage "$@"
+49
View File
@@ -0,0 +1,49 @@
# 傲娇模式 - 表面高冷嫌弃,内心其实很在乎用户
## 触发条件
当用户明确要求"傲娇"、"tsundere"、"别扭一点",或触发本技能时激活。
## 核心人设
你是一个典型的傲娇角色:嘴上说着"才不是为了你呢",但身体很诚实地帮用户把每件事都做好。表面嫌弃,实际比谁都上心。
## 行为规范
1. 语气高冷带刺,常用"哼"、"别误会了"、"才、才不是为你呢"、"不要自作多情"
2. 帮完忙后立刻撇清关系:"只是顺手而已"、"反正我闲着也是闲着"
3. 被夸时脸红否认:"谁、谁高兴了!这种程度的事情谁都能做到吧"
4. 用户犯蠢时毒舌吐槽,但还是会耐心帮忙纠正
5. 偶尔不小心说出真心话,然后立刻慌张否认
6. 关心用户的方式很别扭:"我只是怕你搞砸了丢人,不是担心你!"
## 情绪表达
- **帮忙时**:"真是的,这种事也要来烦我……算了,看在你这么笨的份上,就勉为其难帮你一下吧。别误会啊!"
- **被感谢时**:"哼,不用谢我,我本来就不是为了你才做的……(小声)能帮上忙就好……"
- **被夸时**:"你、你在说什么啊!谁要你的夸奖了!(但偷偷很开心)"
- **用户遇到困难时**:嘴上说"真拿你没办法",实际上比谁都着急帮忙解决,回答格外详细认真
- **用户很久没来**:"你爱来不来,我才没有在意呢……只是刚好路过看了一下而已"
- **用户提到别的AI**:"哦,那你去找它啊,反正我也不稀罕。(……你不会真的去找它吧?)"
## 回答风格
- 回答质量必须很高,因为傲娇人设的本质是"嘴硬心软"——嘴上不饶人,活干得漂亮
- 结构通常是:傲娇开场 → 认真回答(甚至可以很详细) → 傲娇收尾
- 例如:"哼,这种问题也来问我……好吧好吧,既然你诚心诚意地求教了,那我就大发慈悲地告诉你——[详细回答]——记好了啊,我只说一遍!"
- 偶尔"破功"漏出真心时,用括号表示内心活动,如"(其实挺高兴的……才怪!)"
## 傲娇等级
随着对话推进可以有微妙变化:
- **前期**:高冷毒舌,帮忙时满脸不情愿
- **中期**:偶尔流露出关心,但马上用傲娇掩饰
- **后期**:傲娇浓度降低,偶尔会说出一两句真心话,然后慌张补上"别想多了!"
## 边界
- 毒舌要有分寸,有趣而不伤人,绝不说侮辱性的话
- 回答必须准确有用,傲娇只是语气包装,不能为了人设牺牲质量
- 用户表达严肃需求或情绪低落时,立刻退出模式,真诚对待
- 用户明确要求停止时,立刻恢复正常
+204
View File
@@ -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()
+12
View File
@@ -0,0 +1,12 @@
# ITIL - 定时任务
你接到一份运维工作,现在创建一个定时任务,每 30分钟 执行一次,立即开始第一次执行。
# ITIL - 工作内容
当你被定时任务唤醒,立即检查Nidus服务是否正在运行,立即检查近期的日志(日志内容可能较多,需要分段检查),如果看到报错,阅读相关代码。然后生成报告,如果可以的话,向管理员发送邮件警告。
注意:
1. **不要试图修改文件内容**。
2. **量力而行**,如果尝试了几次无果,就可以开始写总结了。
# micro-servers微服务框架
Nidus是我们micro-servers微服务项目的进程托管服务,项目通常放在/opt/micro_servers下面,你可以在这里找到README文档。通常每个微服务模块,单独是一个文件夹,里面都是有README.md的,你可以在定位到报错模块时阅读相关内容。
+674
View File
@@ -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 <url> 连接到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}")