Compare commits
332
Commits
52f4429f6f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81ba942b8e | ||
|
|
bbb88a96f8 | ||
|
|
7b0b49fde1 | ||
|
|
5a8ac61502 | ||
|
|
8b3863f3ae | ||
|
|
6483719551 | ||
|
|
9c73d70582 | ||
|
|
0348b959bb | ||
|
|
3ebf402efd | ||
|
|
63b5243faf | ||
|
|
1b6a767f62 | ||
|
|
7f6f077469 | ||
|
|
60db2c40d8 | ||
|
|
08aff2656d | ||
|
|
c059589e94 | ||
|
|
d1e4b5b01a | ||
|
|
897e76fd8f | ||
|
|
ac2677abc7 | ||
|
|
d680dcd058 | ||
|
|
84a1a5e3be | ||
|
|
058eb11f5f | ||
|
|
85c9699758 | ||
|
|
9dd16e0eef | ||
|
|
28894e48dc | ||
|
|
924c718005 | ||
|
|
d71f526af4 | ||
|
|
09556c9ef4 | ||
|
|
cc671a9fce | ||
|
|
f7ed1f785b | ||
|
|
2ff7908bc7 | ||
|
|
745ce06f85 | ||
|
|
f17a6a8233 | ||
|
|
30360cb106 | ||
|
|
ec3df34b43 | ||
|
|
c7441c1c6f | ||
|
|
fed2d42472 | ||
|
|
2918f2c83c | ||
|
|
87538e7729 | ||
|
|
fd088f5959 | ||
|
|
15f8c9552d | ||
|
|
43c5821d3d | ||
|
|
1d7641cc89 | ||
|
|
6a68b3393d | ||
|
|
0700491300 | ||
|
|
d036af935f | ||
|
|
34cd295005 | ||
|
|
c8812dd27c | ||
|
|
3460a157d0 | ||
|
|
fe618be1fa | ||
|
|
4d9bb5d940 | ||
|
|
37c6d401d6 | ||
|
|
507054c4b2 | ||
|
|
85e0d0740b | ||
|
|
dc52ec13ad | ||
|
|
5ff1dfa4d2 | ||
|
|
2eb6d3c310 | ||
|
|
d921f84b83 | ||
|
|
69a8eee192 | ||
|
|
fa9679b7a6 | ||
|
|
53ddcb364f | ||
|
|
fb485ebac4 | ||
|
|
da4957c52d | ||
|
|
b37bf255d9 | ||
|
|
2f5fe8baaa | ||
|
|
2e3f07bf4f | ||
|
|
028e0e0806 | ||
|
|
37c50ed517 | ||
|
|
02904f711e | ||
|
|
d24066369b | ||
|
|
417fda5479 | ||
|
|
fb5920d89f | ||
|
|
dd4fef6dee | ||
|
|
87f209af73 | ||
|
|
47ab9f2fe6 | ||
|
|
ac16d89150 | ||
|
|
f53085b2a6 | ||
|
|
4749e58eeb | ||
|
|
5fbe3ed5e4 | ||
|
|
d3158c2420 | ||
|
|
a7a78d1424 | ||
|
|
9dbe395484 | ||
|
|
9f440ee884 | ||
|
|
4449a23ba1 | ||
|
|
40b9b9c92f | ||
|
|
0493383ee8 | ||
|
|
84fb370da8 | ||
|
|
8499631091 | ||
|
|
38a6e815f0 | ||
|
|
c58ef2b873 | ||
|
|
52052e51a8 | ||
|
|
ffbe076a32 | ||
|
|
92820799fa | ||
|
|
054f65f858 | ||
|
|
918f3ea32a | ||
|
|
b216535757 | ||
|
|
45db2f780b | ||
|
|
c1724ccea9 | ||
|
|
f9c02cd6ad | ||
|
|
c7364286b7 | ||
|
|
c3c07ab06d | ||
|
|
2e210e4729 | ||
|
|
5a3dff9026 | ||
|
|
7e4fd7e54a | ||
|
|
8200cbfba7 | ||
|
|
192610615e | ||
|
|
2bb1d69d8a | ||
|
|
392a734707 | ||
|
|
89b374c21d | ||
|
|
af5cd02663 | ||
|
|
763faf9c83 | ||
|
|
f3d06252bb | ||
|
|
b60ac06d55 | ||
|
|
66963092a3 | ||
|
|
27ab50ae68 | ||
|
|
627950394d | ||
|
|
16b059253b | ||
|
|
f701730016 | ||
|
|
f319686571 | ||
|
|
08f737ba2c | ||
|
|
c7ef7e715c | ||
|
|
9d737897df | ||
|
|
b935fefb82 | ||
|
|
9ccb9cbdca | ||
|
|
de58c0487a | ||
|
|
e7a2877422 | ||
|
|
7d6ba6de93 | ||
|
|
b30ce1424b | ||
|
|
7ef6a0b65f | ||
|
|
531358919c | ||
|
|
bd1d7ca4d0 | ||
|
|
d883dc620e | ||
|
|
101aed48be | ||
|
|
0ae107f953 | ||
|
|
de0e4168b2 | ||
|
|
1a5e9f7064 | ||
|
|
84adec4722 | ||
|
|
2cbb93b3b7 | ||
|
|
6ebd544619 | ||
|
|
c06eb19f64 | ||
|
|
18387586a4 | ||
|
|
46b66f8bd8 | ||
|
|
e25c132722 | ||
|
|
373f6af352 | ||
|
|
65580e7e50 | ||
|
|
227fbb17f4 | ||
|
|
cebef9fe70 | ||
|
|
12ece9020c | ||
|
|
6ed88feaa4 | ||
|
|
bf2b5d31f7 | ||
|
|
9815ff44af | ||
|
|
5edb67b46d | ||
|
|
039d6acf37 | ||
|
|
ba9a77fe66 | ||
|
|
e007990594 | ||
|
|
b79044d631 | ||
|
|
81ae84851d | ||
|
|
4e190953c0 | ||
|
|
b4436b192a | ||
|
|
bd544a4181 | ||
|
|
164bc65b95 | ||
|
|
53ce69c8dd | ||
|
|
2f3e63e499 | ||
|
|
90761a723d | ||
|
|
b53d4999ce | ||
|
|
e4e9968d14 | ||
|
|
9efb96ea66 | ||
|
|
6c587a22d8 | ||
|
|
bdbea6ac40 | ||
|
|
3be9e78335 | ||
|
|
1b9c727e80 | ||
|
|
e8fa26d79f | ||
|
|
da7f11d352 | ||
|
|
9708fbc87e | ||
|
|
ae42ab8a91 | ||
|
|
a80bd582da | ||
|
|
1b9e6f6b3a | ||
|
|
f003b4dfaa | ||
|
|
eb506e5cbf | ||
|
|
ec1eb1e805 | ||
|
|
6ee1eca789 | ||
|
|
0ef96c28f6 | ||
|
|
49f0698b9d | ||
|
|
2774c3471f | ||
|
|
287cab189d | ||
|
|
7d7e3dc10a | ||
|
|
55fbe2f1f1 | ||
|
|
ae92a6edde | ||
|
|
267d572906 | ||
|
|
e248290860 | ||
|
|
48b45cb44a | ||
|
|
e7f435dc4e | ||
|
|
ada2af47af | ||
|
|
ea670adde2 | ||
|
|
5839172dae | ||
|
|
3f97dd92ef | ||
|
|
f935448389 | ||
|
|
32d3a5a19f | ||
|
|
edb53d079c | ||
|
|
74610c32c8 | ||
|
|
eec869a28d | ||
|
|
d309f41e47 | ||
|
|
7589b77916 | ||
|
|
e4831522a4 | ||
|
|
8a14247aed | ||
|
|
803d176066 | ||
|
|
ac8f3da9df | ||
|
|
6649b31e17 | ||
|
|
254fbf4628 | ||
|
|
931e134796 | ||
|
|
63a80694ae | ||
|
|
4506601773 | ||
|
|
f214c862d3 | ||
|
|
a8a190571c | ||
|
|
aa3a99825c | ||
|
|
dcfdbcb9f2 | ||
|
|
62007e7848 | ||
|
|
91e6659dac | ||
|
|
9765edac9b | ||
|
|
40175eddb5 | ||
|
|
5c33dbe027 | ||
|
|
a58a0f97e3 | ||
|
|
55b127747a | ||
|
|
8c1956cdeb | ||
|
|
1388fcb72b | ||
|
|
b4e1cf462a | ||
|
|
46c3a197be | ||
|
|
e9f9860fe1 | ||
|
|
949d274c22 | ||
|
|
bcd6c84e3b | ||
|
|
5275b8bf75 | ||
|
|
e6e5dab4cd | ||
|
|
3c02ba43ad | ||
|
|
c5dd8d198d | ||
|
|
f4a7aca2fa | ||
|
|
f5b69d0b4c | ||
|
|
53c742c26e | ||
|
|
11993e178b | ||
|
|
2a6d68386d | ||
|
|
3bc4f9720f | ||
|
|
751866d9c1 | ||
|
|
3f9bffb9ea | ||
|
|
ace98caff6 | ||
|
|
a0f6a14b5d | ||
|
|
2fc12e7b00 | ||
|
|
dae81ba4a1 | ||
|
|
a905dac00d | ||
|
|
b8ec752a12 | ||
|
|
b86b089634 | ||
|
|
f9cf408c82 | ||
|
|
f0cdaacd85 | ||
|
|
6a0551ab34 | ||
|
|
c2e9cce6a7 | ||
|
|
b07dbf2da5 | ||
|
|
19d5b65625 | ||
|
|
12e736d264 | ||
|
|
8be62683dc | ||
|
|
c93023bd5d | ||
|
|
422ca798da | ||
|
|
c064caa3ec | ||
|
|
f91d4166d6 | ||
|
|
afb55494e5 | ||
|
|
6692c9c4ee | ||
|
|
dabd892601 | ||
|
|
2982bc30a4 | ||
|
|
aaeac343d3 | ||
|
|
9e2667d604 | ||
|
|
c592b02636 | ||
|
|
d5a67ce77e | ||
|
|
6330e04474 | ||
|
|
df518d1adb | ||
|
|
9078c4d051 | ||
|
|
bcd49f0e81 | ||
|
|
78ad3e6d57 | ||
|
|
7803843155 | ||
|
|
ec755a30f2 | ||
|
|
483c5f227f | ||
|
|
bfd2d334fe | ||
|
|
50f209e84a | ||
|
|
680d28b616 | ||
|
|
4d16bc32fc | ||
|
|
7726857d6b | ||
|
|
3ef7d1b780 | ||
|
|
62804d242c | ||
|
|
a5e581ef24 | ||
|
|
6fcb70d235 | ||
|
|
323dd3061b | ||
|
|
44c1e5ef72 | ||
|
|
d06546f6bf | ||
|
|
4ad3fbfca8 | ||
|
|
b2ddd9e7e3 | ||
|
|
e89578fa82 | ||
|
|
65cfaf97a1 | ||
|
|
6b77cdcf3a | ||
|
|
9e8ad76400 | ||
|
|
ed3108f55c | ||
|
|
c578d1ab9e | ||
|
|
ee44e1bef1 | ||
|
|
f601efdd3f | ||
|
|
c58cafcdcf | ||
|
|
3501168f45 | ||
|
|
e923b32b70 | ||
|
|
f3909bb18f | ||
|
|
13f3eb8f88 | ||
|
|
1b60ab0a2e | ||
|
|
974c912d9e | ||
|
|
8c74d93a76 | ||
|
|
40eb522e5b | ||
|
|
a67f190634 | ||
|
|
9b4a0a6a16 | ||
|
|
097fb7ecbb | ||
|
|
167ef235e6 | ||
|
|
0abba64ea8 | ||
|
|
f056da1dfc | ||
|
|
5edfad7842 | ||
|
|
39bf2684a4 | ||
|
|
614368eb93 | ||
|
|
ce9337fe0d | ||
|
|
18ac572442 | ||
|
|
58e9391503 | ||
|
|
b790e18024 | ||
|
|
08021015b1 | ||
|
|
179afb2674 | ||
|
|
6efb792133 | ||
|
|
3d2278f6b7 | ||
|
|
4fe0689cf6 | ||
|
|
813fe91663 | ||
|
|
fb85203c9b | ||
|
|
1a39e87c8c | ||
|
|
c1642dbc0c | ||
|
|
747c2ebd0a | ||
|
|
957981c2c3 | ||
|
|
8ec42df779 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.env
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
*.bin
|
||||
*.o
|
||||
*.a
|
||||
*.out
|
||||
|
||||
# Documentation (exclude READMEs)
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Data files (handled via volumes)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
chroma/
|
||||
|
||||
# Other modules not needed for build
|
||||
bot4cj/
|
||||
core/
|
||||
gateway/
|
||||
lib*/
|
||||
waf/
|
||||
captcha/
|
||||
smtp/
|
||||
static/
|
||||
tests/
|
||||
3d/
|
||||
BotOps/
|
||||
pymsg/tests/
|
||||
@@ -0,0 +1,11 @@
|
||||
# AI Review 服务配置
|
||||
# 复制此文件为 ai_review/.env 并填入你的 API Key
|
||||
|
||||
# DeepSeek/OpenAI API 密钥
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
|
||||
# API Base URL(默认使用 DeepSeek)
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# 使用的模型名称
|
||||
MODEL_NAME=deepseek-chat
|
||||
@@ -1,3 +1,8 @@
|
||||
gateway/shufeng.ini
|
||||
core/Nidus.service.shufeng
|
||||
ai_review/.env
|
||||
confs/__pycache__/
|
||||
.workbuddy/
|
||||
libhttp/test_server
|
||||
*.pyc
|
||||
__pycache__/
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13452
File diff suppressed because it is too large.
Load diff
Binary file not shown.
+1026
File diff suppressed because it is too large.
Load diff
+2476
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
+2698
File diff suppressed because it is too large.
Load diff
BIN
Binary file not shown.
+2809
File diff suppressed because it is too large.
Load diff
+2834
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
|
||||
# nuitka 编译产物
|
||||
botops.bin
|
||||
app.build/
|
||||
app.dist/
|
||||
app.onefile-build/
|
||||
@@ -0,0 +1,91 @@
|
||||
# BotOps — aibox 智能体 TUI 前端
|
||||
|
||||
复刻并增强 [aibox/client.py](../aibox/client.py),基于 [Textual](https://textual.textualize.io/) 的现代化终端 UI,连接 aibox 微服务(WebSocket,默认 `ws://localhost:8765`)。
|
||||
|
||||
## 相比 client.py 的增强
|
||||
|
||||
| 能力 | client.py | BotOps |
|
||||
|------|-----------|--------|
|
||||
| AI 回复渲染 | 纯文本流式 / 一次性静态渲染 | **Markdown 增量流式渲染**(无闪烁) |
|
||||
| 终端管理 | 手搓光标/提示符 | Textual 部件框架(多面板布局) |
|
||||
| 任务事件 | 混在对话流里 | **独立侧栏**(task_process / task_status / task_error) |
|
||||
| 自动重连 | ✅(已移植) | ✅ 指数退避 2s→30s,`/disconnect` 抑制 |
|
||||
| 快捷键 | Ctrl+C 中断 | Ctrl+C 中断 / Ctrl+Q 退出 / Ctrl+L 清屏 |
|
||||
|
||||
## 安装与运行
|
||||
|
||||
```bash
|
||||
cd BotOps
|
||||
|
||||
# 一键初始化(创建 venv + 安装依赖)
|
||||
bash _set.sh
|
||||
|
||||
# 启动
|
||||
bash _run.sh # 默认 ws://localhost:8765
|
||||
bash _run.sh ws://your-host:8765 # 指定 aibox 地址
|
||||
```
|
||||
|
||||
### Nuitka 编译为独立二进制
|
||||
|
||||
```bash
|
||||
bash _inst.sh # 产出 ./botops.bin(单文件,无需 Python 环境)
|
||||
./botops.bin [ws://host:port]
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
- 本地命令(不发给服务器):`/connect` `/disconnect` `/interrupt` `/clear` `/status` `/help` `/quit`
|
||||
- 其余 `/` 命令透传给 aibox 服务器(与 client.py 行为一致)
|
||||
|
||||
## 结构
|
||||
|
||||
```
|
||||
BotOps/
|
||||
├── app.py # Textual App:布局 + 消息路由 + 快捷键
|
||||
├── ws_client.py # WebSocket 协议层(自动重连)
|
||||
├── commands.py # 斜杠命令注册表
|
||||
├── _run.sh # 启动脚本
|
||||
├── _set.sh # 初始化脚本(venv + 依赖安装)
|
||||
├── _inst.sh # Nuitka 编译脚本(产出 botops.bin)
|
||||
├── requirements.txt # Python 依赖
|
||||
└── widgets/
|
||||
├── __init__.py
|
||||
├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染
|
||||
└── status_bar.py # 底部状态栏(连接状态)
|
||||
```
|
||||
|
||||
## 协议说明
|
||||
|
||||
与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释):
|
||||
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
|
||||
- 接收:
|
||||
- `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源
|
||||
- `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程
|
||||
- `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 3000 字符)
|
||||
- `reasoning_end`(**思考结束**):思考结束,切换到正式回答
|
||||
- **顺序保证**:不预创建空 AI widget,按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),思考区域必然在 AI 正式回答**之前**
|
||||
- `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾(重置状态)
|
||||
- `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示
|
||||
- `info`(系统信息):过滤"Conversation started"/"对话开始"等隐式通知,避免污染聊天流
|
||||
- `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `command_result` / `connection_ack` / `notification` / `pong`
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.3
|
||||
- ✅ **适配服务端中断机制(Ctrl+C可靠停止)**:
|
||||
- 收到 `interrupt_ack` 时立即收尾前端状态:调用 `abort_reasoning()` 将思考区域更新为"🛑 思考已中断"标签,调用 `finish_ai()` 结束AI回复状态
|
||||
- 新增 `ChatView.abort_reasoning()` 方法:中断时更新思考区域标签(保留已展示的思考内容)
|
||||
- `append_reasoning()` 增加 `_ai_finalized` 检查,中断后丢弃残留的思考片段防止重新创建思考区域
|
||||
- 中断后服务端发来的残留 `chunk`/`reasoning`/`complete` 消息均被安全忽略,不会污染UI
|
||||
|
||||
### v1.2
|
||||
- ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前
|
||||
- ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑
|
||||
|
||||
### v1.1
|
||||
- ✅ 支持 DeepSeek 思考模式展示:新增 `reasoning_start`/`reasoning`/`reasoning_end` 消息处理,思考内容以灰色斜体区域展示
|
||||
- ✅ 超长思考内容自动折叠(显示最后 3000 字符)
|
||||
- ✅ 过滤"Conversation started"/"对话开始"等隐式系统通知,避免在回答末尾显示无用信息
|
||||
|
||||
### v1.0
|
||||
- 初始版本:Markdown 增量流式渲染、任务独立侧栏、自动重连、快捷键支持
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# 编译 BotOps 为独立单文件(不运行):产出 ./botops.bin
|
||||
# 用法: ./_inst.sh
|
||||
cd "$(dirname "$0")" || exit
|
||||
source venv/bin/activate
|
||||
python3 -m nuitka --standalone --onefile \
|
||||
--assume-yes-for-downloads \
|
||||
--include-package=websockets \
|
||||
--include-package=textual \
|
||||
--include-package-data=textual \
|
||||
--output-filename=botops.bin \
|
||||
app.py
|
||||
echo "编译完成: ./botops.bin(运行: ./botops.bin [ws://host:port])"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source venv/bin/activate
|
||||
exec python3 app.py $@
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BotOps — aibox 智能体的现代化 TUI 前端(Textual)。
|
||||
|
||||
复刻并增强 aibox/client.py:
|
||||
- AI 回复流式 Markdown 渲染(Markdown 部件增量追加,无闪烁)
|
||||
- 断线指数退避自动重连(/disconnect 抑制)
|
||||
- 任务事件独立侧栏(task_process / task_status / task_error)
|
||||
- Ctrl+C 中断生成、Ctrl+Q 退出、Ctrl+L 清屏
|
||||
|
||||
用法:
|
||||
python app.py [ws://host:port] # 默认 ws://localhost:8765
|
||||
"""
|
||||
import sys
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Footer, Header, Input, RichLog
|
||||
|
||||
from commands import CommandRegistry
|
||||
from widgets.chat_view import ChatView
|
||||
from widgets.status_bar import StatusBar
|
||||
from ws_client import BotWSClient
|
||||
|
||||
DEFAULT_URI = "ws://localhost:8765"
|
||||
|
||||
|
||||
class BotOpsApp(App):
|
||||
TITLE = "BotOps"
|
||||
SUB_TITLE = "aibox TUI"
|
||||
|
||||
CSS = """
|
||||
#chat {
|
||||
width: 3fr;
|
||||
border: solid $primary;
|
||||
}
|
||||
#tasks {
|
||||
width: 1fr;
|
||||
border: solid $secondary;
|
||||
}
|
||||
.msg-user {
|
||||
color: cyan;
|
||||
padding: 0 1;
|
||||
margin-top: 1;
|
||||
}
|
||||
.msg-ai {
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-system {
|
||||
color: $text-muted;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-error {
|
||||
color: red;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-task {
|
||||
color: yellow;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-reasoning {
|
||||
color: $text-muted;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
margin: 0 1;
|
||||
text-style: italic;
|
||||
}
|
||||
#status {
|
||||
height: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c", "interrupt", "中断", show=True),
|
||||
Binding("ctrl+q", "quit", "退出", show=True),
|
||||
Binding("ctrl+l", "clear", "清屏", show=True),
|
||||
]
|
||||
|
||||
def __init__(self, uri: str = DEFAULT_URI):
|
||||
super().__init__()
|
||||
self.ws = BotWSClient(uri)
|
||||
self.ws.on_message = self._on_ws_message
|
||||
self.ws.on_state = self._on_ws_state
|
||||
self.registry = CommandRegistry(self)
|
||||
|
||||
# ── 布局 ─────────────────────────────────────────────────────────────
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
yield ChatView(id="chat")
|
||||
yield RichLog(id="tasks", max_lines=1000)
|
||||
yield Input(placeholder="输入消息,/help 查看命令,Enter 发送", id="input")
|
||||
yield StatusBar(id="status")
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self):
|
||||
self.query_one("#input", Input).focus()
|
||||
self._refresh_status("")
|
||||
self.run_worker(self.connect_to(self.ws.uri))
|
||||
|
||||
@property
|
||||
def chat(self) -> ChatView:
|
||||
return self.query_one("#chat", ChatView)
|
||||
|
||||
@property
|
||||
def tasks(self) -> RichLog:
|
||||
return self.query_one("#tasks", RichLog)
|
||||
|
||||
# ── 连接管理 ──────────────────────────────────────────────────────────
|
||||
async def connect_to(self, uri: str):
|
||||
if self.ws.connected:
|
||||
await self.ws.disconnect()
|
||||
self.ws.uri = uri
|
||||
await self.notify_system(f"正在连接 {uri} …")
|
||||
await self.ws.connect()
|
||||
|
||||
async def disconnect(self):
|
||||
await self.ws.disconnect()
|
||||
|
||||
async def send_interrupt(self):
|
||||
if await self.ws.send_interrupt():
|
||||
await self.notify_system("🛑 已发送中断信号")
|
||||
else:
|
||||
await self.notify_system("未连接到服务器", level="error")
|
||||
|
||||
# ── UI 操作 ───────────────────────────────────────────────────────────
|
||||
async def notify_system(self, text: str, level: str = "info"):
|
||||
css = {"info": "msg-system", "error": "msg-error", "task": "msg-task"}.get(level, "msg-system")
|
||||
await self.chat.add_system(text, css)
|
||||
|
||||
async def clear_chat(self):
|
||||
await self.chat.reset()
|
||||
|
||||
def _refresh_status(self, extra: str):
|
||||
self.query_one("#status", StatusBar).set_state(
|
||||
self.ws.connected, self.ws.uri if self.ws.connected else "", extra)
|
||||
|
||||
# ── 输入处理 ──────────────────────────────────────────────────────────
|
||||
async def on_input_submitted(self, event: Input.Submitted):
|
||||
text = event.value.strip()
|
||||
event.input.value = ""
|
||||
if not text:
|
||||
return
|
||||
if text.startswith("/") and await self.registry.execute(text):
|
||||
return
|
||||
if not self.ws.connected:
|
||||
await self.notify_system("未连接到服务器,先 /connect", level="error")
|
||||
return
|
||||
await self.chat.add_user(text)
|
||||
await self.chat.start_ai() # 标记AI回复开始状态(不预创建空widget)
|
||||
if not await self.ws.send_message(text):
|
||||
await self.chat.finish_ai("") # 发送失败:重置AI状态
|
||||
await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
|
||||
|
||||
# ── 快捷键 ────────────────────────────────────────────────────────────
|
||||
async def action_interrupt(self):
|
||||
await self.send_interrupt()
|
||||
|
||||
async def action_clear(self):
|
||||
await self.clear_chat()
|
||||
|
||||
def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
# ── aibox 协议消息路由(对应 client.py 的 _handle_message)─────────────
|
||||
async def _on_ws_message(self, data: dict):
|
||||
t = data.get("type", "")
|
||||
content = data.get("content", "")
|
||||
|
||||
if t == "chunk":
|
||||
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
||||
await self.chat.append_ai(content)
|
||||
elif t == "reasoning_start":
|
||||
# 开始深度思考,创建思考区域
|
||||
await self.chat.start_reasoning(content or "🤔 深度思考中...")
|
||||
elif t == "reasoning":
|
||||
# 思考内容增量
|
||||
stripped = content
|
||||
if stripped:
|
||||
await self.chat.append_reasoning(stripped)
|
||||
elif t == "reasoning_end":
|
||||
# 思考结束
|
||||
await self.chat.end_reasoning()
|
||||
elif t == "line":
|
||||
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
||||
stripped = content.strip()
|
||||
if not stripped:
|
||||
pass
|
||||
elif stripped.startswith((
|
||||
"💬 助手", "💬 Assistant", # 助手前缀(聊天区已有对应区域)
|
||||
"🤔 思考中", "🤔 Thinking", # 思考提示(聊天区已有思考区域)
|
||||
"🤔 深度思考中",
|
||||
"🤔 分析工具结果", "🤔 Analyzing", # 工具分析提示(后续有具体工具输出,此为冗余)
|
||||
"🔧 需要调用工具", "🔧 Need to call",# 工具调用前置提示(后续有"调用工具: xxx"具体提示)
|
||||
)):
|
||||
pass # 冗余/有替代展示的提示行,过滤避免重复
|
||||
else:
|
||||
# 🔧 调用工具: xxx / 工具输出 / 用户中断提示等 → 任务侧栏
|
||||
self.tasks.write(stripped)
|
||||
elif t == "ai_break":
|
||||
# 工具调用前分段:结束当前AI文本段(保留已显示内容),后续chunk创建新widget
|
||||
await self.chat.break_ai()
|
||||
elif t == "complete":
|
||||
# complete 携带完整全文,用于对齐已显示内容
|
||||
await self.chat.finish_ai(content)
|
||||
elif t == "dialogue_ended":
|
||||
await self.chat.finish_ai(self.chat.ai_text)
|
||||
if content:
|
||||
await self.notify_system(f"✅ {content}")
|
||||
elif t == "task_added":
|
||||
await self.notify_system(f"➕ {content}")
|
||||
elif t == "dialogue_reset":
|
||||
if content:
|
||||
await self.notify_system(f"🔄 {content}")
|
||||
elif t == "pong":
|
||||
pass
|
||||
elif t == "task_process":
|
||||
sub = data.get("msg_type", "")
|
||||
title = data.get("task_title", "未知任务")
|
||||
if sub == "chunk":
|
||||
tail = str(data.get("content", ""))[-200:]
|
||||
if tail:
|
||||
self.tasks.write(f"⚡ {title}: {tail}")
|
||||
elif sub == "complete":
|
||||
self.tasks.write(f"✅ 任务完成: {title}")
|
||||
elif t == "task_status":
|
||||
icon = {"executing": "⚙️", "complete": "✅", "failed": "❌",
|
||||
"added": "➕", "batch_start": "🚀", "batch_complete": "🏁"
|
||||
}.get(data.get("status", ""), "📋")
|
||||
self.tasks.write(
|
||||
f"{icon} [{data.get('task_id', '?')}] {data.get('title', '')}: "
|
||||
f"{data.get('message', content)}")
|
||||
elif t == "task_error":
|
||||
self.tasks.write(
|
||||
f"❌ [{data.get('task_id', '?')}] {data.get('task_title', '?')}: "
|
||||
f"{data.get('error', '未知错误')}")
|
||||
elif t == "interrupt_ack":
|
||||
# 服务端确认中断:立即收尾前端状态(更新思考区域为已中断、结束AI回复)
|
||||
await self.chat.abort_reasoning()
|
||||
await self.chat.finish_ai("")
|
||||
await self.notify_system(f"🛑 {content}")
|
||||
elif t == "error":
|
||||
await self.notify_system(f"❌ {content}", level="error")
|
||||
elif t == "info":
|
||||
# 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了)
|
||||
skip_keywords = ("Conversation started", "对话开始", "🎭")
|
||||
if not any(k in content for k in skip_keywords):
|
||||
await self.notify_system(f"ℹ️ {content}")
|
||||
elif t == "command_result":
|
||||
await self.notify_system(content)
|
||||
elif t == "connection_ack":
|
||||
await self.notify_system(f"✅ {content}")
|
||||
elif t == "notification":
|
||||
await self.notify_system(f"🔔 {content}")
|
||||
elif content:
|
||||
# 未知消息类型不追加到AI聊天区(避免非AI内容污染回复),仅记入侧栏日志
|
||||
self.tasks.write(f"[未知消息 type={t}] {str(content)[:200]}")
|
||||
|
||||
# ── 连接状态回调 ──────────────────────────────────────────────────────
|
||||
async def _on_ws_state(self, state: str, detail: str):
|
||||
if state == "connected":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(f"✅ 已连接到 {self.ws.uri}")
|
||||
elif state == "disconnected":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(
|
||||
f"❌ {detail}(中断期间的服务端输出已丢失,如回复不完整请重发)")
|
||||
elif state == "reconnecting":
|
||||
self._refresh_status(detail)
|
||||
elif state == "error":
|
||||
self._refresh_status("")
|
||||
await self.notify_system(detail, level="error")
|
||||
|
||||
|
||||
def main():
|
||||
uri = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URI
|
||||
BotOpsApp(uri).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""斜杠命令注册表:/connect /disconnect /interrupt /clear /status /help /quit。
|
||||
|
||||
本地命令在此拦截处理;未注册的 / 命令透传给 aibox 服务器(与 client.py 行为一致)。
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app import BotOpsApp
|
||||
|
||||
|
||||
class CommandRegistry:
|
||||
def __init__(self, app: "BotOpsApp"):
|
||||
self.app = app
|
||||
self._commands = {
|
||||
"/connect": ("连接服务器:/connect ws://host:port", self._connect),
|
||||
"/disconnect": ("断开连接(不自动重连)", self._disconnect),
|
||||
"/interrupt": ("中断当前生成(或按 Ctrl+C)", self._interrupt),
|
||||
"/clear": ("清空聊天区", self._clear),
|
||||
"/status": ("显示连接状态", self._status),
|
||||
"/help": ("显示帮助", self._help),
|
||||
"/quit": ("退出 BotOps", self._quit),
|
||||
}
|
||||
|
||||
async def execute(self, line: str) -> bool:
|
||||
"""是本地命令则执行并返回 True;否则返回 False(调用方应透传服务器)。"""
|
||||
cmd = line.split()[0].lower()
|
||||
entry = self._commands.get(cmd)
|
||||
if entry is None:
|
||||
return False
|
||||
await entry[1](line)
|
||||
return True
|
||||
|
||||
async def _connect(self, line: str):
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
await self.app.notify_system("用法: /connect ws://host:port", level="error")
|
||||
return
|
||||
await self.app.connect_to(parts[1])
|
||||
|
||||
async def _disconnect(self, _line: str):
|
||||
await self.app.disconnect()
|
||||
|
||||
async def _interrupt(self, _line: str):
|
||||
await self.app.send_interrupt()
|
||||
|
||||
async def _clear(self, _line: str):
|
||||
await self.app.clear_chat()
|
||||
|
||||
async def _status(self, _line: str):
|
||||
if self.app.ws.connected:
|
||||
await self.app.notify_system(f"✅ 已连接: {self.app.ws.uri}")
|
||||
else:
|
||||
await self.app.notify_system("○ 未连接")
|
||||
|
||||
async def _help(self, _line: str):
|
||||
lines = ["可用命令:"] + [f" {k:<14} {v[0]}" for k, v in self._commands.items()]
|
||||
lines.append(" 其余 / 命令将透传给 aibox 服务器")
|
||||
await self.app.notify_system("\n".join(lines))
|
||||
|
||||
async def _quit(self, _line: str):
|
||||
await self.app.ws.disconnect()
|
||||
self.app.exit()
|
||||
@@ -0,0 +1,3 @@
|
||||
textual>=0.60.0
|
||||
websockets>=11.0
|
||||
nuitka
|
||||
Whitespace-only changes.
@@ -0,0 +1,205 @@
|
||||
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
|
||||
|
||||
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
|
||||
- chunk 增量片段(正式回答内容),直接 append 到 AI 消息
|
||||
- reasoning_start 思考开始信号,在当前末尾创建新的思考区域
|
||||
- reasoning 思考内容增量片段,追加到当前活跃的思考区域
|
||||
- reasoning_end 思考结束信号:更新标签后清除引用(widget保留显示),
|
||||
后续 reasoning_start 会创建新的思考区域
|
||||
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
|
||||
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
|
||||
|
||||
顺序保证:不预创建空AI widget,按消息到达顺序自然挂载。
|
||||
多轮工具调用场景:每轮思考(reasoning_start→reasoning_end)创建独立的思考区域,
|
||||
按时间顺序分布在对话流中各自的AI回答之前,而非全部堆在顶部。
|
||||
"""
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Markdown, Static
|
||||
|
||||
|
||||
class ChatView(VerticalScroll):
|
||||
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
|
||||
|
||||
不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载,
|
||||
天然保证思考过程在正式回答之前。
|
||||
滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长
|
||||
(流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚,
|
||||
下一条新消息再重新锚定。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ai_widget: Markdown | None = None
|
||||
self._reasoning_widget: Static | None = None
|
||||
self._ai_text = ""
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True # 初始无活跃 AI 消息
|
||||
|
||||
@property
|
||||
def ai_text(self) -> str:
|
||||
return self._ai_text
|
||||
|
||||
def _anchor_bottom(self):
|
||||
"""锚定吸底(需在布局刷新后执行,否则滚到的是旧位置)。"""
|
||||
self.call_after_refresh(self.anchor)
|
||||
|
||||
async def add_user(self, text: str):
|
||||
await self.mount(Static(f"👤 你: {text}", classes="msg-user", markup=False))
|
||||
self._anchor_bottom()
|
||||
|
||||
async def add_system(self, text: str, css_class: str = "msg-system"):
|
||||
await self.mount(Static(text, classes=css_class, markup=False))
|
||||
self._anchor_bottom()
|
||||
|
||||
async def start_ai(self):
|
||||
"""开始AI回复(标记状态,不预创建空widget)。
|
||||
|
||||
为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget,
|
||||
此时思考widget(如果有)已经先挂载,顺序自然正确。
|
||||
"""
|
||||
if not self._ai_finalized:
|
||||
return
|
||||
self._ai_text = ""
|
||||
self._ai_finalized = False
|
||||
|
||||
async def start_reasoning(self, hint: str = "🤔 思考中..."):
|
||||
"""开始展示思考过程区域。直接挂载在当前末尾位置。"""
|
||||
if self._reasoning_widget is not None:
|
||||
return
|
||||
self._reasoning_text = ""
|
||||
self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False)
|
||||
await self.mount(self._reasoning_widget)
|
||||
self._anchor_bottom()
|
||||
|
||||
async def append_reasoning(self, delta: str):
|
||||
"""追加思考内容增量。"""
|
||||
if not delta:
|
||||
return
|
||||
if self._ai_finalized:
|
||||
# 已收尾(中断/完成),丢弃残留的思考片段
|
||||
return
|
||||
if self._reasoning_widget is None:
|
||||
await self.start_reasoning()
|
||||
self._reasoning_text += delta
|
||||
# 限制思考内容显示长度(最多显示最后 3000 字符,避免思考过长占满屏幕)
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"💭 思考中...\n{display}")
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def end_reasoning(self):
|
||||
"""结束当前思考区域,更新标签后清除引用(widget保留在界面上显示)。
|
||||
|
||||
清除引用后,后续 reasoning_start 会在当前末尾位置创建新的思考区域,
|
||||
实现多轮工具调用时思考区域按时间顺序分布,而非全部堆在顶部。
|
||||
"""
|
||||
if self._reasoning_widget is not None and self._reasoning_text:
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
|
||||
# 清除引用但保留widget在DOM中显示——下一轮reasoning_start会创建新widget
|
||||
self._reasoning_widget = None
|
||||
self._reasoning_text = ""
|
||||
|
||||
async def abort_reasoning(self):
|
||||
"""中断当前思考区域,更新提示文字后清除引用。"""
|
||||
if self._reasoning_widget is not None:
|
||||
if self._reasoning_text:
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"🛑 思考已中断({len(self._reasoning_text)}字符)\n{display}")
|
||||
else:
|
||||
self._reasoning_widget.update("🛑 思考已中断")
|
||||
# 清除引用,已显示的widget保留
|
||||
self._reasoning_widget = None
|
||||
self._reasoning_text = ""
|
||||
|
||||
async def _ensure_ai_widget(self):
|
||||
"""确保AI widget存在(第一次chunk到来时创建)。"""
|
||||
if self._ai_widget is None:
|
||||
self._ai_widget = Markdown("", classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._anchor_bottom()
|
||||
|
||||
async def break_ai(self):
|
||||
"""分段当前AI回答:清除引用但保留widget在DOM中显示。
|
||||
|
||||
用于工具调用场景:工具调用前的AI文字(如"好的让我查一下")保留显示,
|
||||
后续chunk会创建新的AI widget,保证思考区域按时间顺序插入在两段AI文字之间。
|
||||
与finish_ai不同:不设置_ai_finalized,对话继续中。
|
||||
"""
|
||||
if self._ai_widget is not None:
|
||||
self._ai_widget = None
|
||||
self._ai_text = ""
|
||||
|
||||
async def append_ai(self, delta: str):
|
||||
"""追加增量片段(服务端 chunk 为增量语义,累积文本后 update 整个 Markdown)。
|
||||
|
||||
使用 update() 而非 Markdown.append():Textual 的 Markdown.append() 在
|
||||
chunk 边界切断 Markdown 语法(如列表项、代码块)时可能导致重复解析渲染。
|
||||
累积完整文本后 update() 确保每次都从完整文本重新解析,避免重复。
|
||||
"""
|
||||
if not delta:
|
||||
return
|
||||
if self._ai_finalized:
|
||||
# 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据
|
||||
return
|
||||
# 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了)
|
||||
await self._ensure_ai_widget()
|
||||
self._ai_text += delta
|
||||
# 累积文本后整体update,而非增量append,避免Markdown增量解析导致的重复渲染
|
||||
self._ai_widget.update(self._ai_text)
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def finish_ai(self, full_text: str = ""):
|
||||
"""收尾一条 AI 消息。
|
||||
|
||||
chunk 流式追加是内容权威来源,complete 只做收尾:
|
||||
- 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载)
|
||||
- widget 存在且有流式内容:保持已渲染内容不变,仅清空引用
|
||||
- widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充
|
||||
- widget 不存在(未收到任何 chunk)且有 full_text:补建
|
||||
- widget 不存在且无内容:正常无内容场景
|
||||
"""
|
||||
if self._ai_finalized:
|
||||
return
|
||||
w = self._ai_widget
|
||||
self._ai_widget = None
|
||||
|
||||
if w is not None:
|
||||
if self._ai_text:
|
||||
# 有流式内容,chunk 已完整渲染,直接保留,不做替换
|
||||
pass
|
||||
elif full_text:
|
||||
# widget存在但没收到任何chunk(异常):用full_text填充
|
||||
w.update(full_text)
|
||||
self._ai_text = full_text
|
||||
else:
|
||||
# widget存在但没有任何输出:移除空框(正常不会走到这里,因为不预创建)
|
||||
await w.remove()
|
||||
else:
|
||||
# widget不存在(start_ai后没收到任何chunk),有full_text则补建
|
||||
if full_text:
|
||||
self._ai_text = full_text
|
||||
self._ai_widget = Markdown(full_text, classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._ai_widget = None
|
||||
self._anchor_bottom()
|
||||
|
||||
self._ai_text = ""
|
||||
self._reasoning_widget = None
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def reset(self):
|
||||
"""清空聊天区(/clear)。"""
|
||||
self._ai_widget = None
|
||||
self._reasoning_widget = None
|
||||
self._ai_text = ""
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True
|
||||
await self.remove_children()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""底部状态栏:连接状态 + 服务器地址 + 附加信息。"""
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class StatusBar(Static):
|
||||
def set_state(self, connected: bool, uri: str = "", extra: str = ""):
|
||||
dot = "●" if connected else "○"
|
||||
color = "green" if connected else "red"
|
||||
text = f"[{color}]{dot}[/] {'已连接' if connected else '未连接'}"
|
||||
if uri:
|
||||
text += f" {uri}"
|
||||
if extra:
|
||||
text += f" | {extra}"
|
||||
self.update(text)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""BotOps WebSocket 协议层(提炼自 aibox/client.py,增加状态回调与自动重连)。
|
||||
|
||||
协议:JSON 消息。
|
||||
发送:{"type": "message", "content": ...}
|
||||
{"type": "command", "command": "/..."}
|
||||
{"type": "interrupt", "content": ...}
|
||||
接收:chunk / line / reasoning / complete / dialogue_ended / dialogue_reset /
|
||||
task_process / task_status / task_error / task_added / interrupt_ack /
|
||||
error / info / command_result / connection_ack / notification / pong
|
||||
|
||||
说明:服务端 chunk 为**增量片段**(output_callback 发送 chunk_buffer 后即清零),
|
||||
complete 为完整全文(可用于对齐);line 为过程行(工具提示/输出/前缀)。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
import websockets
|
||||
|
||||
MessageHandler = Callable[[dict], Awaitable[None]]
|
||||
StateHandler = Callable[[str, str], Awaitable[None]] # (state, detail)
|
||||
|
||||
|
||||
class BotWSClient:
|
||||
"""aibox 协议 WebSocket 客户端:断线指数退避自动重连,手动断开则抑制。"""
|
||||
|
||||
def __init__(self, uri: str):
|
||||
self.uri = uri
|
||||
self.ws = None
|
||||
self.connected = False
|
||||
self._manual_disconnect = False
|
||||
self._listen_task: Optional[asyncio.Task] = None
|
||||
self._reconnect_task: Optional[asyncio.Task] = None
|
||||
self.on_message: Optional[MessageHandler] = None
|
||||
self.on_state: Optional[StateHandler] = None
|
||||
|
||||
async def _emit_state(self, state: str, detail: str = ""):
|
||||
if self.on_state:
|
||||
await self.on_state(state, detail)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""连接并开始监听;成功返回 True。"""
|
||||
await self._close_ws()
|
||||
try:
|
||||
self.ws = await websockets.connect(self.uri)
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"连接失败: {e}")
|
||||
return False
|
||||
self.connected = True
|
||||
self._manual_disconnect = False
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
await self._emit_state("connected", self.uri)
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
"""手动断开(抑制自动重连)。"""
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
self.connected = False
|
||||
await self._close_ws()
|
||||
if self._listen_task and not self._listen_task.done():
|
||||
self._listen_task.cancel()
|
||||
await self._emit_state("disconnected", "已手动断开")
|
||||
|
||||
async def _close_ws(self):
|
||||
if self.ws is not None:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
async def send_message(self, text: str) -> bool:
|
||||
"""发送用户消息(/ 开头按 command 发送,与 aibox 协议一致)。"""
|
||||
if not self.connected or not self.ws:
|
||||
return False
|
||||
data = ({"type": "command", "command": text} if text.startswith("/")
|
||||
else {"type": "message", "content": text})
|
||||
try:
|
||||
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
||||
return True
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"发送失败: {e}")
|
||||
return False
|
||||
|
||||
async def send_interrupt(self) -> bool:
|
||||
"""发送中断信号(对应 client.py 的 Ctrl+C 行为)。"""
|
||||
if not self.connected or not self.ws:
|
||||
return False
|
||||
try:
|
||||
await self.ws.send(json.dumps(
|
||||
{"type": "interrupt", "content": "用户按下了 Ctrl+C 中断当前操作"},
|
||||
ensure_ascii=False))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _listen(self):
|
||||
"""接收循环:断线时在 finally 中触发自动重连。"""
|
||||
try:
|
||||
async for raw in self.ws:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
data = {"type": "chunk", "content": raw}
|
||||
if self.on_message:
|
||||
await self.on_message(data)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
await self._emit_state("error", f"接收错误: {e}")
|
||||
finally:
|
||||
was_connected = self.connected
|
||||
self.connected = False
|
||||
if was_connected:
|
||||
await self._emit_state("disconnected", "连接已断开")
|
||||
self._schedule_reconnect()
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
if self._manual_disconnect:
|
||||
return
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
return
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self):
|
||||
"""指数退避 2s→30s 封顶,直到成功或手动断开。"""
|
||||
delay = 2
|
||||
while not self._manual_disconnect and not self.connected:
|
||||
await self._emit_state("reconnecting", f"{delay}s 后自动重连 {self.uri} …")
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if self._manual_disconnect:
|
||||
return
|
||||
try:
|
||||
self.ws = await websockets.connect(self.uri)
|
||||
except Exception:
|
||||
delay = min(delay * 2, 30)
|
||||
continue
|
||||
self.connected = True
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
await self._emit_state("connected", self.uri)
|
||||
return
|
||||
@@ -1,7 +1,7 @@
|
||||
# micro_servers
|
||||
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。
|
||||
|
||||
## 架构总览
|
||||
|
||||
@@ -18,25 +18,25 @@
|
||||
│ waf │ │ gateway │ │ static │ │ dnfs │ │ db │ │ ai_review │
|
||||
│ WAF 网关 │ │ 应用层网关 │ │前端托管│ │ 树状菜单 │ │ 通用数据库 │ │ AI内容审核 │
|
||||
│ :443 (入口) │ │ :8443 (内部) │ │ :9200 │ │ :8006 │ │ :8005 │ │ :8007 │
|
||||
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ SQL+日志收集 │ │ DeepSeek+RAG │
|
||||
└──────┬───────┘ └──────┬───────┘ └────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
|
||||
│ HTTPS终止 │ 反向代理 │ 调用
|
||||
└──────────────────►│ ├──────────► db:8005
|
||||
├────────────────► static:9200
|
||||
└────────────────► dnfs:8006 ──► ai_review:8007
|
||||
|
||||
日志链路: waf / gateway / static / dnfs / ai_review / captcha ──(log_client)──► db:8005/log/ingest
|
||||
│ coraza+反代 │ │ 鉴权 + 反代 │ │静态/媒体│ │ 节点CRUD │ │ pymsg/TCP │ │ DeepSeek+RAG │
|
||||
└──────┬───────┘ └──────┬───────┘ └───┬────┘ └──────┬───────┘ └──────▲───────┘ └──────▲───────┘
|
||||
│ HTTPS终止 │ 反向代理 │ 代理笔记请求 │ pymsg │ pymsg │ HTTP
|
||||
└──────────────────►│ ├─────────────►│─────────────────┘ │
|
||||
├────────────►│ └─────── HTTP /review ──────────────┘
|
||||
└────────────►│
|
||||
日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs ──(log_client, pymsg)──► db:8005 (Logs 表)
|
||||
配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载)
|
||||
```
|
||||
|
||||
### 请求链路
|
||||
|
||||
1. 客户端所有请求先打到 `waf:443`(HTTPS),WAF 终止 TLS、运行 coraza 规则匹配,命中则直接返回 403。
|
||||
2. 未命中规则的请求由 WAF 以明文 HTTP 转发到 `gateway:8443`,并设置 `X-Forwarded-For` / `X-Forwarded-Proto` 头。
|
||||
3. `gateway` 对 `/login`、`/register` 做账号鉴权(SQLite + PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制)。
|
||||
3. `gateway` 对 `/login`、`/register` 做账号鉴权(PBKDF2 密码哈希 + RSA 签名 Token + 可选 CAPTCHA + 速率限制;注册需邮箱验证码,经 pymsg/TCP 调 `smtp:8008` 发送与校验),用户数据通过 pymsg/TCP 调用 `db:8005` 存取。
|
||||
4. 其余请求携带 Token,经 `gateway` 校验后反向代理到 `Proxy.opened` 中放开的内部服务(`static:9200` / `dnfs:8006`)。
|
||||
5. `db:8005` 与 `ai_review:8007` 在 `Proxy.closed` 中,仅允许内部服务间调用,不对外暴露。
|
||||
6. `dnfs` 处理节点增删改查时,通过 HTTP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
|
||||
7. 各服务通过 `log_client.py` SDK 异步批量推送运行日志到 `db:8005/log/ingest`,实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。
|
||||
6. `dnfs` 处理节点增删改查时,通过 pymsg/TCP 调用 `db` 服务完成数据持久化,新增节点前调用 `ai_review` 进行内容审核。
|
||||
7. 各服务通过 `log_client.py` SDK 异步批量推送运行日志(pymsg/TCP 写入 db 的 `Logs` 表),实现集中式日志收集(fail-safe,db 不可用时静默丢弃)。
|
||||
|
||||
## 各服务说明
|
||||
|
||||
@@ -47,15 +47,20 @@
|
||||
| [gateway](gateway/) | C++20 | 8443 | 应用层网关(内部 HTTP),用户注册/登录鉴权、Token 签发与校验、反向代理到内部服务 |
|
||||
| [static](static/) | Python(Flask) | 9200 | 前端托管服务,社区首页、静态页面与文件浏览、视频/音频流媒体播放、路径遍历防护 |
|
||||
| [dnfs](dnfs/) | Python(Flask) | 8006 | 树状网络文件系统(Dendrachy Network File-System),基于父子 pid 的节点树增删改查,集成 AI 内容审核 |
|
||||
| [db](db/) | Python(Flask) | 8005 | 通用数据库服务,基于 SQLAlchemy Core 的 SQL-over-HTTP,支持 select/insert/update/delete,参数化查询防注入;内置集中式日志收集(/log/* 路由) |
|
||||
| [db](db/) | Python(pymsg) | 8005 | 通用数据库服务,基于 pymsg 协议(TCP 长度前缀帧 + JSON)的 SQL-over-TCP,SQLAlchemy Core 参数化查询防注入;内置集中式日志收集(Logs 表 + 自动清理) |
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [confs](confs/) | Python(pymsg) | 8010 | 统一配置服务,集中存放并分发各服务配置文件(pymsg 路由 get/list/reload/health),内存缓存 + mtime 自动重载,路径遍历防护 |
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
|
||||
|
||||
### core(核心模块 · 进程托管服务)
|
||||
|
||||
- 可执行文件 `nidus.out`,作为 systemd 服务(`Nidus.service`)常驻运行。
|
||||
- 可执行文件 `nidus.out`,作为 systemd 服务(`Nidus.service`)常驻运行(崩溃守护由 systemd 负责,core 自身不含自动重启逻辑)。
|
||||
- 支持两种工作模式:命令行子系统调用(`--send/--exec/--list/--kill`)与 HTTP 伺服器模式(`--serve <port>`)。
|
||||
- 通过命名管道(Pipe)与消息队列(Mq)实现进程间通信。
|
||||
- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / static / waf / gateway / captcha)。
|
||||
- 读取 `init.txt` 自启动列表,按行拉起各业务服务(ai_review / db / dnfs / smtp / static / waf / gateway / captcha / aibox)。
|
||||
|
||||
### waf(WAF 网关)
|
||||
|
||||
@@ -66,74 +71,115 @@
|
||||
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
|
||||
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
|
||||
|
||||
### gateway(应用层网关 · v0.3.0-security)
|
||||
### gateway(应用层网关 · v0.5.3)
|
||||
|
||||
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
|
||||
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
|
||||
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),登录时自动迁移旧 SHA256 哈希,常数时间比较防 timing attack。
|
||||
- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行。
|
||||
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用。
|
||||
- **密码安全**:PBKDF2-HMAC-SHA256(10 万轮 + 16 字节随机盐),密码强度校验(8+ 字符、4 类中至少 3 类),常数时间比较防 timing attack。
|
||||
- **用户存储**:用户数据通过 pymsg/TCP 调用 `db:8005`(`DbC::call_msg`)存取,不再直连本地 SQLite;登录成功即更新 `Users.last_login`(`"$now"` 哨兵)。
|
||||
- **Token 机制**:`Base64(payload) + "_" + RSA-Sign(payload)`,默认 1 小时过期,验签通过才放行;RSA 密钥对启动时内存生成,重启后旧 Token 全部失效。
|
||||
- **Cookie 认证**:登录成功同时签发 `HttpOnly + Secure + SameSite=Lax` 的 `q4_token` Cookie;代理时无 `Authorization` 头则回退读 Cookie 验签,并向下游注入 `Authorization: Bearer`(下游零改动);`POST /logout` 清除 Cookie。公开目标(`Proxy.default`)不做凭证校验,避免旧 Cookie 影响公开页面。
|
||||
- **CAPTCHA**:`/captcha` 接口生成验证码(5 分钟过期、一次性使用),登录与注册均可选启用;`GET /captcha/image` 为 captcha 服务(pymsg/TCP :8009)的图形验证码代理路由。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟,按 `X-Forwarded-For`(WAF 注入)客户端 IP 计数。
|
||||
- **反向代理**:`Target-Url` 必须在 `Proxy.opened` 白名单中(`closed` 仅记录用途,实际隔离靠"不在 opened 中即拒绝"),缺省走 `Proxy.default`(不需 Token,服务前端静态资源)。
|
||||
- **自动建表**:启动时若 `Users` 表不存在则自动创建,首位注册者即为管理员。
|
||||
- **用户表**:`Users` 表由 [db/create_sqlite3.sql](db/create_sqlite3.sql) 建立(gateway 不再自动建表),无默认管理员账号,首位注册者即为管理员。
|
||||
- 详见 [gateway/README.md](gateway/README.md)。
|
||||
|
||||
### static(前端托管服务)
|
||||
|
||||
- 托管 `public/` 下的前端页面:社区首页(`index.html`)、登录/注册、树状笔记(`dnfs.html`)、文件浏览(`__view.html`)等。
|
||||
- 支持文件浏览、视频/音频在线播放(HTTP Range 分段请求)、一键下载。
|
||||
- 路径遍历防护,仅允许访问 `directory.root` 之内。
|
||||
- 支持文件浏览、视频/音频在线播放(HTTP Range 分段请求,返回 206/416)、一键下载。
|
||||
- 路径遍历防护(`safe_path_join`:realpath 前缀校验),仅允许访问 `directory.root` 之内。
|
||||
- **dnfs 代理**:`/get_notes`、`/add_note`、`/delete_note`、`/update_note` 透传 Authorization 转发到 `dnfs:8006`(解决浏览器跨域)。
|
||||
- **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。
|
||||
- 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。
|
||||
- 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。
|
||||
- 配置见 [static/config.ini](static/config.ini)。
|
||||
- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);本地 [static/config.ini](static/config.ini) 作为 confs 不可用时的 fail-safe 回退。
|
||||
|
||||
### dnfs(树状菜单服务)
|
||||
|
||||
- 基于 `pid`(父节点 id)组织的树状节点,节点含 `title` 与 `content`。
|
||||
- 提供 `/get_notes`、`/add_note`、`/delete_note`、`/update_note` 四个接口。
|
||||
- 数据持久化通过 HTTP 调用 `db` 服务(`http://127.0.0.1:8005/execute`),不再直接操作数据库。
|
||||
- 数据持久化通过 pymsg/TCP 调用 `db` 服务(`127.0.0.1:8005`,execute 路由),不再直接操作数据库。
|
||||
- 新增节点前调用 `ai_review` 服务(`http://127.0.0.1:8007/review`)进行内容审核,审核不通过则拒绝写入。
|
||||
|
||||
### db(通用数据库服务)
|
||||
|
||||
- 单一接口 `POST /execute`,接收结构化 JSON(action + table + where + values),翻译为 SQL 执行。
|
||||
- 基于自研 [pymsg](pymsg/) 协议(TCP 长度前缀帧 + JSON),单一路由 `_route: "execute"`,接收结构化 JSON(action + table + where + values),翻译为 SQL 执行。
|
||||
- 支持 select / insert / update / delete,运算符包括 eq、ne、gt、lt、ge、le、like、in,支持排序与分页。
|
||||
- 基于 SQLAlchemy Core 表反射,参数化查询,零 SQL 注入风险。
|
||||
- 仅监听 127.0.0.1,单线程,自动回滚,不暴露 DDL 操作。
|
||||
- 仅监听 127.0.0.1,自动提交/回滚,不暴露 DDL 操作。
|
||||
- 默认使用 SQLite(`database.db`),可通过 `DATABASE_URL` 环境变量切换至 PostgreSQL / MySQL。
|
||||
- **内置日志子模块**([log_module.py](db/log_module.py)):
|
||||
- `log_internal()` — db 进程内部直接写 Logs 表,无 HTTP 往返,防递归设计。
|
||||
- `/log/ingest` — 接收外部服务批量推送日志(单次上限 100 条,校验 service / level / message)。
|
||||
- `/log/query` — 结构化查询(service / level / trace_id / 时间区间 / 关键字模糊搜索 / 排序分页)。
|
||||
- `/log/stats` — 按 service / level 分组统计。
|
||||
- `/log/purge` — 手动清理指定时间之前的日志。
|
||||
- `/log/health` — 健康检查。
|
||||
- **日期/时间列适配**:values 与 where 中的 DATETIME/DATE/TIME 列接受 ISO 8601 字符串(带时区偏移自动转 UTC)或哨兵值 `"$now"`(服务端当前 UTC);响应中的日期时间一律序列化为 ISO 8601(T 分隔)。
|
||||
- **内置日志子模块**:
|
||||
- 启动时自动建 `Logs` 表(service / level / message / trace_id / extra + 4 个索引)。
|
||||
- 外部服务通过 `log_client.py` 以 pymsg 调 execute 路由逐条 INSERT 写入日志。
|
||||
- `_log_internal()` — db 进程内部直接写 Logs 表,无网络往返,防递归设计。
|
||||
- 后台守护线程每小时自动清理:保留 30 天 / 上限 100 万条。
|
||||
- **统一日志客户端 SDK**([log_client.py](db/log_client.py)):各服务复制即用,纯标准库实现(urllib),异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||
- **统一日志客户端 SDK**(各服务目录下的 `log_client.py`,如 [dnfs/log_client.py](dnfs/log_client.py)):各服务复制即用,纯标准库实现,异步非阻塞(内存队列 + 后台线程,50 条/2 秒批量 flush),fail-safe 设计(db 不可用时静默丢弃,不影响业务)。
|
||||
|
||||
### ai_review(AI 内容审核服务)
|
||||
|
||||
- 基于 DeepSeek LLM + ChromaDB 向量检索的 RAG 审核架构。
|
||||
- 审核类别:色情、违禁药品、赌博、诈骗、仇恨/暴力/恐怖主义,灰色地带内容从严判定。
|
||||
- 接口:`POST /review`(提交审核)、`POST /rag/feedback`(人工纠偏)、`GET /rag/list`(知识库列表)、`GET /health`(健康检查)。
|
||||
- RAG 检索 top-3 相似已标注样本注入 prompt,MD5 去重,API 异常时 fail-open。
|
||||
- RAG 检索 top-3 相似已标注样本注入 prompt,MD5 去重。
|
||||
- **fail-closed 设计**:API 异常或响应解析失败时一律返回 `{"invalid": "true"}`(拦截),宁可误拦不可漏放。
|
||||
- 配置通过 `.env` 文件(已 gitignore),含 DeepSeek API 密钥、模型参数、监听地址。
|
||||
|
||||
### captcha(验证码服务)
|
||||
|
||||
- 生成图形验证码,Token 一次性使用、5 分钟过期;基于 [pymsg](pymsg/) 协议(TCP 长度前缀帧 + JSON),仅依赖 PIL。
|
||||
- pymsg 路由:`image`(生成,返回 token + base64 PNG)、`verify`(校验,一次性消费)、`health`。
|
||||
- 答案比较采用 XOR 防时序攻击;错误答案与无效 Token 分别返回 `{valid:false}` 与 `{valid:false, error:...}`。
|
||||
- **gateway 代理路由**:`GET /captcha/image` 经 pymsg 调 captcha 后解码 PNG,以 `image/png` + `X-Captcha-Token` 响应头返回浏览器(`Cache-Control: no-store`)。
|
||||
- 由 `gateway` 在 `/login`、`/register` 中可选启用 CAPTCHA 校验(字段 `captcha_token` / `captcha_answer`)。
|
||||
- 内存存储,不支持多实例水平扩展(需换 Redis);详见 [captcha/README.md](captcha/README.md)。
|
||||
|
||||
### smtp(邮件服务)
|
||||
|
||||
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。
|
||||
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||
|
||||
### confs(统一配置服务)
|
||||
|
||||
- 集中存放各微服务的配置文件(`confs/configs/`),基于 pymsg(RpcServer,TCP 长度前缀帧 + JSON)分发,监听 127.0.0.1:8010。
|
||||
- pymsg 路由:`get`(取配置文件全文;带 `key` 时按键提取,ini 用 `Section.option`、json 用点号路径)、`list`(列出全部配置及元信息)、`reload`(重载指定配置,缺省清空全部缓存)、`health`。
|
||||
- 内存缓存 + 文件 mtime 变化自动重载,改配置无需重启 confs;配置名校验(basename + realpath 前缀 + 扩展名白名单)防路径遍历。
|
||||
- 消费方约定:服务启动时用 `RpcClient(8010).call("get", {"name": "<服务名>.ini"})` 拉取配置,confs 不可用时回退本地配置文件(fail-safe)。目前 `static` 已接入。
|
||||
|
||||
### aibox(独立 AI 智能体系统)
|
||||
|
||||
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs 等主干微服务无调用关系。
|
||||
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
|
||||
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。
|
||||
|
||||
### bot4cj(机器人硬件脚本解析器)
|
||||
|
||||
- 基于仓颉(Cangjie)编程语言和 Linux I2C 接口的机器人动作脚本解析器,面向树莓派 Dashun 蜘蛛机器人平台(10 舵机 + 2 云台)。
|
||||
- 解析 `.mt` 矩阵脚本(行列式表格语法,支持 `@Servo` / `@Action` 等块),编译为舵机角度序列执行。
|
||||
- 硬件层通过 I2C 驱动 PCA9685(PWM 舵机控制,50Hz,脉宽 0.5–2.5ms)、MPU6050(陀螺仪/加速度计,10ms 采样 + 卡尔曼滤波)、Speaker(正弦波发声)。
|
||||
- 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。
|
||||
- 与主干微服务完全独立,需部署在树莓派上独立运行。
|
||||
|
||||
## 共享基础库
|
||||
|
||||
| 库 | 说明 |
|
||||
|----|------|
|
||||
| [libth](libth/) | 线程库:`Thread`、`Cv_wait`(条件变量等待)、`Fsm`、`ITask` 任务接口 |
|
||||
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`、`HttpClient`、`WebSocketServer/Client`(基于 Boost.Beast) |
|
||||
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`(含 WebSocket 服务/代理)、`HttpClient`(含 WebSocket 客户端,基于 Boost.Beast) |
|
||||
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
|
||||
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
|
||||
| [libtry](libtry/) | 嵌入式脚本解释器(~741 行 C),手写递归下降词法/语法/执行引擎,支持变量/数组/函数/控制流,宽字符(中文标识符),用于嵌入式操作界面 shell |
|
||||
|
||||
### Python 共享组件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| [log_client.py](db/log_client.py) | 统一日志客户端 SDK,各服务复制即用(纯标准库,异步批量推送) |
|
||||
| [security_middleware.py](db/security_middleware.py) | Flask 安全响应头中间件(CSP / X-Frame-Options / nosniff 等),各服务各持一份副本 |
|
||||
| [pymsg](pymsg/) | 自研 RPC 基础库(setuptools 包),协议为 `[4字节大端长度][UTF-8 JSON]`,提供 `send/recv/MsgClient/MsgServer`;db 服务端与各 log_client 的基础依赖,requirements 以 `../pymsg` 本地路径引用 |
|
||||
| log_client.py | 统一日志客户端 SDK,各服务目录各持一份副本(如 [dnfs/log_client.py](dnfs/log_client.py)),纯标准库 + pymsg,异步批量推送 |
|
||||
| security_middleware.py | Flask 安全响应头中间件(CSP / X-Frame-Options / nosniff 等),各服务各持一份副本 |
|
||||
|
||||
## 端口一览
|
||||
|
||||
@@ -144,9 +190,27 @@
|
||||
| 5200 | core | — | PMC HTTP 管理接口 |
|
||||
| 9200 | static | 127.0.0.1 | 前端托管(经 gateway 代理) |
|
||||
| 8006 | dnfs | 127.0.0.1 | 树状菜单(经 gateway 代理) |
|
||||
| 8005 | db | 127.0.0.1 | 数据库服务(仅内部调用) |
|
||||
| 8005 | db | 127.0.0.1 | 数据库服务(pymsg/TCP,仅内部调用) |
|
||||
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
|
||||
| 8009 | captcha | 127.0.0.1 | 验证码服务(独立组件) |
|
||||
| 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
|
||||
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
|
||||
| 8010 | confs | 127.0.0.1 | 统一配置服务(pymsg/TCP,仅内部调用) |
|
||||
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
|
||||
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
|
||||
|
||||
## 测试
|
||||
|
||||
[tests/](tests/) 包含一套黑盒测试套件,基于子进程调用与 HTTP 请求验证各微服务功能正确性:
|
||||
|
||||
| 测试文件 | 被测模块 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `test_db_*.py` | db | SQL-over-TCP 的 select/insert/update/delete、日期哨兵、边界条件 |
|
||||
| `test_gateway_*.py` | gateway | 用户注册/登录/Token 校验、速率限制、CAPTCHA 集成 |
|
||||
| `test_waf_*.py` | waf | coraza 规则匹配与拦截、白名单放行 |
|
||||
| `test_dnfs_*.py` | dnfs | 节点树 CRUD、AI 审核集成 |
|
||||
|
||||
- 默认使用 `config.py` 中的生产地址(127.0.0.1),每个测试函数前后自动备份/恢复 db 数据,保证不污染线上数据。
|
||||
- 测试依赖 `pymsg`、`Flask`、`flask-cors`、`requests`、`Pillow`,详见 [tests/requirements-test.txt](tests/requirements-test.txt)。
|
||||
|
||||
## 依赖
|
||||
|
||||
@@ -164,7 +228,7 @@ bash _setup.deb.sh # Debian/Ubuntu
|
||||
# 或
|
||||
bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora
|
||||
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> core)
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> captcha -> confs -> core)
|
||||
bash _inst.sh
|
||||
```
|
||||
|
||||
@@ -177,7 +241,7 @@ bash _run.sh # 运行
|
||||
|
||||
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。
|
||||
|
||||
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需单独进入 `ai_review/` 目录执行 `_set.sh` 安装,并配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需进入 `ai_review/` 目录执行 `_set.sh` 安装,并额外配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
|
||||
> **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。
|
||||
|
||||
@@ -186,13 +250,16 @@ bash _run.sh # 运行
|
||||
由 `core` 守护的子进程通过 `init.txt` 自启动(顺序很重要,waf 必须先于 gateway):
|
||||
|
||||
```
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/db/_run.sh
|
||||
bash /opt/micro_servers/confs/_run.sh
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/dnfs/_run.sh
|
||||
bash /opt/micro_servers/smtp/_run.sh
|
||||
bash /opt/micro_servers/static/_run.sh
|
||||
bash /opt/micro_servers/waf/_run.sh
|
||||
bash /opt/micro_servers/gateway/_run.sh
|
||||
bash /opt/micro_servers/captcha/_run.sh
|
||||
bash /opt/micro_servers/aibox/_run.sh
|
||||
```
|
||||
|
||||
启动 `core`(需 root 或 systemd):
|
||||
@@ -223,14 +290,30 @@ gateway 改为内部 HTTP 模式([example.ini](gateway/example.ini) 中 `tls =
|
||||
- **服务按业务能力拆分**:WAF 网关、应用网关、前端托管、节点树、数据库、内容审核各自独立、独立端口、独立数据存储。
|
||||
- **API 网关模式**:`waf` 作为边缘网关统一入口,`gateway` 承担鉴权与反向代理,内部服务通过 `closed` 列表隔离。
|
||||
- **进程编排/守护**:`core` 作为服务管理器拉起并守护子进程,类似简易版 supervisor。
|
||||
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,通过 HTTP 通信。
|
||||
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 HTTP 接口,业务服务无需直连数据库。
|
||||
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)、仓颉/Cangjie(bot4cj)混合,对外 HTTP、内部数据访问走 pymsg/TCP。
|
||||
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 pymsg/TCP 接口,业务服务无需直连数据库。
|
||||
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
|
||||
- **集中式可观测性**:`db` 内置日志收集服务(/log/* 路由),各服务通过 `log_client.py` SDK 异步推送日志,实现全链路汇聚、查询与统计,无需引入额外中间件。
|
||||
- **集中式可观测性**:`db` 内置日志表与自动清理,各服务通过 `log_client.py` SDK 异步推送日志(pymsg),实现全链路汇聚,无需引入额外中间件。
|
||||
- **安全分层**:`waf` 在边缘做规则匹配(OWASP CRS / 自定义 SecLang 规则),`gateway` 在应用层做鉴权,`db` 仅监听本机,多层防护。
|
||||
|
||||
与云原生微服务的差异:未引入服务注册发现、容器编排、消息总线,服务地址在 `waf/config.yaml` 与 `gateway/example.ini` 中静态配置。对于个人/中小规模的 DIY 场景,这种取舍在简洁性与可控性上是合理的。
|
||||
|
||||
## 文档管理约定
|
||||
|
||||
所有项目文档统一通过根目录 [docs/](docs/) 聚合管理,入口索引见 [docs/INDEX.md](docs/INDEX.md)。文档分类与存放规则如下:
|
||||
|
||||
| 文档类型 | 存放位置 | 说明 |
|
||||
|----------|----------|------|
|
||||
| **模块 README** | 各模块根目录(如 `waf/README.md`) | 遵循行业惯例,方便 GitHub/IDE 直接识别,不移动 |
|
||||
| **安全审计报告** | `docs/security/` | 各模块的安全审计、部署审计、依赖审计、加固计划统一存放,文件命名为 `<模块名>_<文档类型>_<日期>.md` |
|
||||
| **代码审查日志** | `docs/reviews/` | 代码审查、评审记录统一存放 |
|
||||
| **分析报告** | `docs/reports/` | 各类分析报告(HTML/MD 等)统一存放 |
|
||||
| **全局文档** | `docs/` 根目录 | 全局审计总览、跨模块文档直接放在 `docs/` 下 |
|
||||
| **aibox 技能提示词** | `aibox/skills/`、`aibox/tasks/` | 属于运行时数据,保留在原目录 |
|
||||
| **虚拟环境/第三方文档** | `.venv/` 等 | 不纳入文档管理,由 `.gitignore` 排除 |
|
||||
|
||||
新增模块文档时遵循以上规则:模块说明写本目录 `README.md`,审计/审查/报告类文档放入 `docs/` 对应子目录。
|
||||
|
||||
## 许可证
|
||||
|
||||
各模块均采用 MIT License,详见各目录下的 `LICENSE`。
|
||||
@@ -28,10 +28,14 @@ inst_module() {
|
||||
inst_module "libth"
|
||||
inst_module "libhttp"
|
||||
inst_module "libcrypt"
|
||||
inst_module "libmsg"
|
||||
inst_module "libsqlite3"
|
||||
inst_module "gateway"
|
||||
inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 waf 再拉起 gateway
|
||||
inst_module "static"
|
||||
inst_module "dnfs"
|
||||
inst_module "db"
|
||||
inst_module "smtp"
|
||||
inst_module "captcha"
|
||||
inst_module "confs"
|
||||
inst_module "core" # core last
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
# Debian
|
||||
sudo apt update
|
||||
sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf golang-go
|
||||
sudo apt install libsqlite3-dev libboost-all-dev libcurl4-gnutls-dev python3 python3-venv python3-pip g++ patchelf golang-go libportaudio-ocaml-dev
|
||||
Regular → Executable
File mode changed.
@@ -0,0 +1,18 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
chroma/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="ai_review"
|
||||
LABEL description="AI内容审核服务 - DeepSeek + RAG"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装依赖(ai_review不依赖pymsg)
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 复制应用代码
|
||||
COPY . /app/
|
||||
|
||||
# 创建ChromaDB持久化目录
|
||||
RUN mkdir -p /app/chroma
|
||||
VOLUME ["/app/chroma"]
|
||||
|
||||
# 创建非root用户
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8007
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)" || exit 1
|
||||
|
||||
# 默认环境变量
|
||||
ENV AI_REVIEW_HOST=0.0.0.0 \
|
||||
AI_REVIEW_PORT=8007 \
|
||||
CHROMA_PERSIST_DIR=/app/chroma
|
||||
|
||||
CMD ["python", "ai_review.py"]
|
||||
@@ -14,6 +14,9 @@ from dotenv import load_dotenv
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
@@ -27,6 +30,53 @@ CORS(app)
|
||||
|
||||
review_agent = ReviewAgent()
|
||||
|
||||
# ── 内容大小限制(10KB,审核文本不应超过此值)──
|
||||
MAX_CONTENT_LENGTH = 10 * 1024
|
||||
|
||||
# ── 简易滑动窗口限流器 ──────────────────────────────────────────────
|
||||
# /review: 30次/分钟/IP(正常审核请求)
|
||||
# /rag/feedback: 5次/分钟/IP(人工纠偏低频操作)
|
||||
|
||||
_RATE_LIMIT_WINDOW = 60 # 窗口秒数
|
||||
_RATE_LIMIT_REVIEW = 30 # /review 每分钟上限
|
||||
_RATE_LIMIT_FEEDBACK = 5 # /rag/feedback 每分钟上限
|
||||
_RATE_LIMIT_LOCK = threading.Lock()
|
||||
_RATE_LIMIT_BUCKETS: "dict[str, list[float]]" = defaultdict(list)
|
||||
|
||||
|
||||
def _cleanup_buckets():
|
||||
"""惰性清理过期时间戳,在线程锁内完成。"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - _RATE_LIMIT_WINDOW
|
||||
stale_keys = []
|
||||
for key, timestamps in _RATE_LIMIT_BUCKETS.items():
|
||||
timestamps[:] = [t for t in timestamps if t > cutoff]
|
||||
if not timestamps:
|
||||
stale_keys.append(key)
|
||||
for key in stale_keys:
|
||||
del _RATE_LIMIT_BUCKETS[key]
|
||||
|
||||
|
||||
def _check_rate_limit(key: str, limit: int) -> bool:
|
||||
"""检查是否超出限流,返回 True 表示放行,False 表示拒绝。"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - _RATE_LIMIT_WINDOW
|
||||
with _RATE_LIMIT_LOCK:
|
||||
bucket = _RATE_LIMIT_BUCKETS[key]
|
||||
bucket[:] = [t for t in bucket if t > cutoff]
|
||||
if len(bucket) >= limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _client_ip() -> str:
|
||||
"""获取客户端真实 IP(优先 X-Forwarded-For)。"""
|
||||
xff = request.headers.get("X-Forwarded-For", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.remote_addr or "127.0.0.1"
|
||||
|
||||
|
||||
@app.route('/review', methods=['POST'])
|
||||
def review():
|
||||
@@ -35,6 +85,11 @@ def review():
|
||||
Request: {"content": "待审核的字符串"}
|
||||
Response: {"invalid": "true"} 或 {"invalid": "false"}
|
||||
"""
|
||||
# 频率限制
|
||||
ip = _client_ip()
|
||||
if not _check_rate_limit(f"review:{ip}", _RATE_LIMIT_REVIEW):
|
||||
return jsonify({"error": "rate limit exceeded", "retry_after": _RATE_LIMIT_WINDOW}), 429
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'content' not in data:
|
||||
@@ -45,6 +100,10 @@ def review():
|
||||
if not content:
|
||||
return jsonify({"error": "content cannot be empty"}), 400
|
||||
|
||||
# 内容大小限制
|
||||
if len(content) > MAX_CONTENT_LENGTH:
|
||||
return jsonify({"error": f"content too large, max {MAX_CONTENT_LENGTH} bytes"}), 413
|
||||
|
||||
result = review_agent.review(content)
|
||||
return jsonify(result), 200
|
||||
|
||||
@@ -61,6 +120,11 @@ def rag_feedback():
|
||||
Response: {"added": true, "id": "v_xxx", "md5": "xxx"}
|
||||
{"added": false, "reason": "duplicate"}
|
||||
"""
|
||||
# 频率限制(人工纠偏低频操作,严格限制)
|
||||
ip = _client_ip()
|
||||
if not _check_rate_limit(f"feedback:{ip}", _RATE_LIMIT_FEEDBACK):
|
||||
return jsonify({"error": "rate limit exceeded", "retry_after": _RATE_LIMIT_WINDOW}), 429
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
if not data or 'content' not in data:
|
||||
|
||||
@@ -12,7 +12,7 @@ def apply_security_headers(app, csp_policy=None):
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# XSS protection (legacy browsers)
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Prevent MIME type sniffing
|
||||
@@ -30,10 +30,10 @@ def apply_security_headers(app, csp_policy=None):
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self'; "
|
||||
"connect-src 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
# Remove server header info
|
||||
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
+825
@@ -0,0 +1,825 @@
|
||||
# 单AI智能体系统 - WebSocket版本(增强版)
|
||||
|
||||
一个基于 DeepSeek API 的**单 AI 智能体**系统,通过 WebSocket 与客户端交互。所有任务(文件操作、网页访问、记忆管理、任务调度等)都由 AI 对话驱动执行,支持实时流式输出与任务状态广播。内置可热重载的技能系统,智能体可通过工具自我进化(更新角色定义、自主创建技能)。
|
||||
|
||||
> 本模块为 `micro_servers` 项目中的独立组件,由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/aibox/_run.sh` 拉起。它与主干微服务(waf/gateway/static/dnfs/db/ai_review)解耦,是另一套 AI 助手产品。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🤖 **单 AI 智能体**:基于 DeepSeek API 的智能对话系统,支持思考模式(reasoning 流式输出)
|
||||
- 🔌 **WebSocket 通信**:实时双向通信,支持流式输出与任务状态广播
|
||||
- 📁 **文件操作**:读写文件、目录列表(支持大文件分页 head/tail)
|
||||
- 🌐 **网页访问**:获取网页内容、提取标题、链接与正文
|
||||
- 🧠 **记忆管理**:自动保存与检索对话记忆,保持对话连贯性
|
||||
- ⏰ **任务系统**:支持一次性、重复(daily/weekly/monthly/custom)、即时任务,自动触发 AI 对话
|
||||
- 🔧 **扩展技能**:动态加载 `~/.aibox/skills/` 下的可执行脚本或 Markdown 技能文件
|
||||
- 🌍 **多语言支持**:自动检测系统语言,支持中英文
|
||||
- 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像
|
||||
- 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型
|
||||
- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能
|
||||
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化
|
||||
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
|
||||
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
|
||||
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
|
||||
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
|
||||
- 🛑 **可靠中断机制**:支持 Ctrl+C 实时中断,通过 `threading.Event` + HTTP 连接关闭 + 子进程 kill 三层取消,流式响应和长时间运行命令均可立即终止
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.8+
|
||||
- DeepSeek API Key
|
||||
- 操作系统:Linux / macOS / Windows
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆或下载代码后,安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
1. **设置 DeepSeek API Key**:
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY="your-api-key"
|
||||
```
|
||||
|
||||
2. **(可选)配置文件**:`~/.aibox/config.json`(首次启动时自动创建默认配置)
|
||||
|
||||
3. **启动服务器**:
|
||||
```bash
|
||||
python aibox.py
|
||||
```
|
||||
|
||||
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
|
||||
|
||||
> 注意:本模块入口文件为 `aibox.py`(单文件约 8450 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`_run.sh` 为被 `core` 拉起的启动脚本。
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
aibox/
|
||||
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统 + 任务调度器)
|
||||
├── client.py # 示例 WebSocket 客户端
|
||||
├── voice_client.py # 语音客户端
|
||||
├── entry.html # 前端入口页
|
||||
├── entry1.html # 前端入口页(变体)
|
||||
├── frontend.html # 前端主页面
|
||||
├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能)
|
||||
│ ├── hello-world.py
|
||||
│ ├── aes-crypto.py
|
||||
│ ├── bing_search.py
|
||||
│ ├── facerec.py
|
||||
│ ├── gitclient.py
|
||||
│ ├── ssh-exec.py
|
||||
│ ├── sftp-transfer.py
|
||||
│ └── ...
|
||||
├── SKILLS_FORMAT.md # 技能文件格式说明
|
||||
├── _run.sh # 启动脚本(被 core 守护时调用)
|
||||
├── _runcli.sh # 命令行启动脚本
|
||||
├── _set.sh # 安装脚本
|
||||
├── _setenv.sh # 环境设置脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
运行期数据存放于 `~/.aibox/`:
|
||||
|
||||
```
|
||||
~/.aibox/
|
||||
├── config.json # 配置文件
|
||||
├── prompts/ # 系统提示词目录(按文件名数字前缀排序加载)
|
||||
│ ├── 00_IDENTITY.md # 用户身份信息(最先加载)
|
||||
│ ├── 1_memory_search.md # 基础技能提示词(1-10,按数字升序加载)
|
||||
│ ├── 2_memory_save.md
|
||||
│ ├── 3_file_operations.md
|
||||
│ ├── 4_web_access.md
|
||||
│ ├── 5_memo_management.md
|
||||
│ ├── 6_command_execution.md
|
||||
│ ├── 7_extension_skills.md
|
||||
│ ├── 8_identity_management.md
|
||||
│ ├── 9_language_instruction.md
|
||||
│ ├── 10_task_handling.md
|
||||
│ ├── 99_SOUL.md # AI 灵魂定义(角色、性格、能力边界)
|
||||
│ └── 100_STATUS.md # AI 状态记录(最后加载)
|
||||
├── skills/ # 扩展技能目录(运行时从仓库 skills/ 复制或用户自建)
|
||||
├── workspace/ # AI 专用工作目录(临时文件、脚本、数据文件默认存放位置)
|
||||
├── memories.db # 记忆和任务数据库(SQLite)
|
||||
├── current_conversation.json # 当前对话
|
||||
├── conversation_history.json # 对话索引
|
||||
├── archive/ # 历史对话归档
|
||||
└── tool_results/ # 工具结果缓存
|
||||
```
|
||||
|
||||
## 📂 工作目录规范
|
||||
|
||||
为避免 AI 产生的临时文件污染系统其他目录,所有 AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/` 目录:
|
||||
|
||||
- **文件操作**:`write_file` 工具默认路径为 `~/.aibox/workspace/`(用户明确指定路径时除外)
|
||||
- **命令执行**:`run_command` 工具默认 `work_dir` 为 `~/.aibox/workspace/`,命令产生的文件落在该专用工作区
|
||||
- **用户指定路径优先**:当用户明确要求在特定路径操作时,按用户指定路径执行
|
||||
- **读取不受限**:读取系统文件、项目文件时使用实际路径,不受工作目录限制
|
||||
- **技能调用**:`~/.aibox/skills/` 下的技能执行不受此限制
|
||||
|
||||
## 🔧 可用工具
|
||||
|
||||
AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,在 `DeepSeekChat` 中注册):
|
||||
|
||||
| 工具名称 | 功能描述 |
|
||||
|---------|---------|
|
||||
| `read_file` | 读取文件内容(返回 markdown 指引,改用 `head`/`tail`/`sed`/`wc` 等命令行读取) |
|
||||
| `write_file` | 写入或追加文件(返回 markdown 指引,改用 `mkdir -p` + `cat` 重定向写入) |
|
||||
| `list_files` | 列出目录内容(返回 markdown 指引,改用 `ls`/`du`/`find` 命令行列出) |
|
||||
| `web` | 访问网页、获取标题/文本/链接(返回 markdown 指引,改用 `curl` 命令行访问) |
|
||||
| `memory` | 保存、搜索(支持分页)、删除记忆 |
|
||||
| `memo` | 创建、查看、完成、搜索任务 |
|
||||
| `run_command` | 执行系统命令(实时输出,支持超时控制) |
|
||||
| `search_skills` | 搜索扩展技能(支持 Markdown 和可执行技能) |
|
||||
| `reload_skills` | 热重载扩展技能 |
|
||||
| `create_skill` | 创建新技能(AI 自主编写 Python 脚本) |
|
||||
| `install_skill` | 技能安装指引 |
|
||||
| `ask_user` | 向用户提问(AI 主动交互) |
|
||||
| `update_identity` | 更新用户身份信息(`00_IDENTITY.md`) |
|
||||
| `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) |
|
||||
| `save_memory_and_end_conversation` | 保存记忆并结束对话 |
|
||||
| `get_current_time` | 获取当前时间 |
|
||||
| `calculator` | 数学计算 |
|
||||
| `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效) |
|
||||
| `get_proxy` | 查看当前 SOCKS5 代理配置 |
|
||||
| `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 |
|
||||
| `create_subagent` | 派生子代理:独立上下文/角色设定,轻量无工具、可选独立模型,用于长任务分解、上下文隔离、独立角色专注处理 |
|
||||
|
||||
## 💬 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"
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 中断(Ctrl+C)
|
||||
```json
|
||||
{
|
||||
"type": "interrupt",
|
||||
"content": "用户按下了 Ctrl+C 中断当前操作"
|
||||
}
|
||||
```
|
||||
服务端收到后立即回复 `interrupt_ack`,并通过三层取消机制(Event标志 + HTTP连接关闭 + 子进程kill)终止正在进行的AI思考和工具执行。
|
||||
|
||||
### 支持的命令
|
||||
|
||||
| 命令 | 功能 |
|
||||
|-----|------|
|
||||
| `/exit` | 退出连接 |
|
||||
| `/new` | 开始新对话(保存当前,重置 AI 记忆,重新加载提示词) |
|
||||
| `/list` | 列出所有历史对话 |
|
||||
| `/memories` | 查看最近记忆(支持分页) |
|
||||
| `/search <关键词>` | 按关键词搜索记忆 |
|
||||
| `/memos` | 查看待办任务 |
|
||||
| `/config` | 查看配置 |
|
||||
| `/reload` | 重载配置 |
|
||||
| `/reload_prompts` | 重载提示词 |
|
||||
| `/check` | 检查过期任务 |
|
||||
| `/tasks` | 查看任务调度器状态 |
|
||||
| `/help` | 显示帮助 |
|
||||
|
||||
### 接收消息类型
|
||||
|
||||
| 类型 | 说明 |
|
||||
|-----|------|
|
||||
| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
|
||||
| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) |
|
||||
| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) |
|
||||
| `reasoning_end` | AI 思考结束信号(切换到正式回答) |
|
||||
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
|
||||
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) |
|
||||
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
|
||||
| `command_result` | 命令执行结果 |
|
||||
| `task_status` | 任务状态更新(executing/complete/failed) |
|
||||
| `task_process` | 任务执行过程中的 AI 输出 |
|
||||
| `task_error` | 任务执行错误 |
|
||||
| `task_added` | 任务添加成功 |
|
||||
| `task_list` | 待办任务列表(`task` action=list 的返回) |
|
||||
| `task_completed` | 任务完成通知 |
|
||||
| `dialogue_ended` | 对话结束通知 |
|
||||
| `dialogue_reset` | 对话重置完成 |
|
||||
| `notification` | 通知消息 |
|
||||
| `connection_ack` | 连接确认 |
|
||||
| `interrupt_ack` | 中断确认 |
|
||||
| `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` 工具编写脚本并部署到 `~/.aibox/skills/`。
|
||||
|
||||
#### 方式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" }
|
||||
```
|
||||
|
||||
技能格式详见 [SKILLS_FORMAT.md](SKILLS_FORMAT.md)。
|
||||
|
||||
## 🧠 智能体自我进化
|
||||
|
||||
AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_identity` 工具更新 `00_IDENTITY.md`:
|
||||
|
||||
```json
|
||||
{ "type": "message", "content": "使用 update_soul 更新我的角色,强调我更注重创造性思维" }
|
||||
```
|
||||
|
||||
这允许 AI 在对话中不断优化自己的:角色定位、性格特点、能力边界、行为准则、价值观。
|
||||
|
||||
## 📦 对话压缩
|
||||
|
||||
当对话达到阈值时自动压缩:
|
||||
|
||||
```json
|
||||
// 配置示例(config.json -> context)
|
||||
{
|
||||
"max_history_messages": 500, // 历史消息上限
|
||||
"compress_enabled": true, // 是否启用压缩
|
||||
"compress_message_threshold": 200, // 消息数阈值
|
||||
"compress_token_threshold": 120000, // Token 数阈值
|
||||
"compress_ratio": 0.2, // 压缩保留比例(早期对话压缩为原长的该比例)
|
||||
"compress_keep_recent": 8 // 保留最近消息数
|
||||
}
|
||||
```
|
||||
|
||||
压缩过程:
|
||||
1. 检测到消息数或 Token 数超过阈值
|
||||
2. 调用 AI 压缩早期对话内容
|
||||
3. 保留最近的重要消息
|
||||
4. 将压缩摘要作为系统消息
|
||||
5. 压缩过程静默执行,不影响用户体验
|
||||
|
||||
## 🧩 提示词系统
|
||||
|
||||
提示词文件位于 `~/.aibox/prompts/`,按文件名**数字前缀升序**加载(identity → 技能 1~10 → soul → status):
|
||||
|
||||
| 文件 | 用途 | 更新方式 |
|
||||
|-----|------|---------|
|
||||
| `00_IDENTITY.md` | 用户身份信息 | AI 通过 `update_identity` 工具更新 |
|
||||
| `N_*.md`(N=1~10) | 基础技能提示词(记忆搜索/保存、文件/网页/命令/任务操作、技能扩展、身份管理、语言、任务规则) | 手动编辑,AI 通过 `/reload_prompts` 重载 |
|
||||
| `99_SOUL.md` | AI 灵魂定义(角色、性格、能力边界、行为准则) | AI 通过 `update_soul` 工具更新 |
|
||||
| `100_STATUS.md` | AI 状态记录 | AI 通过 `save_memory_and_end_conversation` 更新 |
|
||||
|
||||
> **加载顺序保证**:`00_IDENTITY.md` 最先加载(AI 先知道用户是谁),然后是 1~10 号技能提示词(按数字升序),接着是 `99_SOUL.md`(AI 的角色定位),最后是 `100_STATUS.md`(当前状态)。每轮用户输入前会附加一段简洁的工作流程提醒(不重复 system prompt 中的详细说明),工具调用后递归继续时不再重复注入。
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
主要配置项(`~/.aibox/config.json`,首次启动自动生成):
|
||||
|
||||
```json
|
||||
{
|
||||
"system": {
|
||||
"save_dir": "~/.aibox",
|
||||
"datetime_format": "%Y-%m-%d %H:%M:%S",
|
||||
"date_format": "%Y%m%d_%H%M%S",
|
||||
"debug_level": 1,
|
||||
"load_history_by_default": true,
|
||||
"log_file": "~/.aibox/ai.log",
|
||||
"log_max_size": 10485760,
|
||||
"log_backup_count": 5,
|
||||
"websocket_host": "localhost",
|
||||
"websocket_port": 8765,
|
||||
"skills_dir": "~/.aibox/skills",
|
||||
"prompts_dir": "~/.aibox/prompts",
|
||||
"workspace_dir": "~/.aibox/workspace"
|
||||
},
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
"check_interval": 1.0,
|
||||
"max_tasks_per_run": 10,
|
||||
"broadcast_enabled": true
|
||||
},
|
||||
"api": {
|
||||
"provider": "deepseek",
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"reasoning_enabled": false,
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"timeout": 120,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"context": {
|
||||
"max_history_messages": 500,
|
||||
"max_context_messages": 120,
|
||||
"max_tokens": 8192,
|
||||
"max_history_conversations": 5,
|
||||
"preserve_system_prompts": true,
|
||||
"compress_enabled": true,
|
||||
"compress_message_threshold": 200,
|
||||
"compress_token_threshold": 120000,
|
||||
"compress_ratio": 0.2,
|
||||
"compress_keep_recent": 8
|
||||
},
|
||||
"memo": {
|
||||
"default_reminder_minutes": 60,
|
||||
"max_memos_per_check": 10,
|
||||
"allow_user_tasks": true
|
||||
},
|
||||
"files": {
|
||||
"max_file_size": 10485710
|
||||
},
|
||||
"commands": {
|
||||
"enabled": true
|
||||
},
|
||||
"web": {
|
||||
"timeout": 10,
|
||||
"delay": 0,
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"respect_robots": false,
|
||||
"max_download_size": 104857600
|
||||
},
|
||||
"memory": {
|
||||
"db_name": "memories.db",
|
||||
"max_search_results": 100,
|
||||
"default_importance": 2
|
||||
},
|
||||
"conversation": {
|
||||
"current_file": "current_conversation.json",
|
||||
"history_file": "conversation_history.json",
|
||||
"archive_dir": "archive"
|
||||
},
|
||||
"tools": {
|
||||
"max_result_length": 1048576,
|
||||
"save_result_to_file": false,
|
||||
"tool_results_dir": "tool_results"
|
||||
},
|
||||
"compressor": {
|
||||
"min_messages_to_compress": 5,
|
||||
"weight_decay_factor": 0.01,
|
||||
"max_input_chars": 6000,
|
||||
"llm_max_tokens": 800
|
||||
},
|
||||
"proxy": {
|
||||
"type": "",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
|
||||
>
|
||||
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
||||
>
|
||||
> **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。
|
||||
|
||||
## 🛠️ 命令行参数
|
||||
|
||||
```bash
|
||||
python aibox.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 aibox.py --verbose
|
||||
|
||||
# 指定端口
|
||||
python aibox.py --port 9000
|
||||
|
||||
# 检查任务
|
||||
python aibox.py --check-memo
|
||||
|
||||
# 添加任务(命令行)
|
||||
python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h"
|
||||
|
||||
# 查看所有任务
|
||||
python aibox.py --memo-list
|
||||
```
|
||||
|
||||
## 🔄 架构流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ WebSocket 客户端 │
|
||||
└─────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────────┐
|
||||
│ WebSocket 服务器 (端口 8765) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │
|
||||
│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │
|
||||
│ └─────────────┘ └─────────────┘ └───────────────┘ │
|
||||
└─────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────────┐
|
||||
│ AI 对话引擎 (DeepSeek) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌───────────┐ ┌───────────┐ ┌──────────────────┐ │
|
||||
│ │ 提示词管理器│ │ 工具注册器 │ │ 对话压缩器 │ │
|
||||
│ └───────────┘ └───────────┘ └──────────────────┘ │
|
||||
└─────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────────────────────┐
|
||||
│ 数据持久层 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
|
||||
│ │ 记忆数据库 │ │ 备忘录DB │ │ 对话历史管理器 │ │
|
||||
│ └──────────┘ └──────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 📊 消息流示例
|
||||
|
||||
### 普通对话流程
|
||||
```
|
||||
Client -> Server: {type: "message", content: "帮我查天气"}
|
||||
Server -> Client: {type: "line", content: "🔧 调用工具: search_skills\n"}
|
||||
Server -> Client: {type: "line", content: "🔧 调用工具: bing_search\n"}
|
||||
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: "✅ 对话状态已完全重置"}
|
||||
```
|
||||
|
||||
## 📝 开发指南
|
||||
|
||||
### 添加新工具
|
||||
|
||||
1. 继承 `Tool` 基类(位于 `aibox.py`)
|
||||
2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
|
||||
3. 可选实现 `execute_streaming()` 支持流式输出(通过 `output_queue` 发送 `("line", content)` 和 `("complete", final_result)`)
|
||||
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}"
|
||||
```
|
||||
|
||||
> **注意**:`execute()` 必须返回字符串。工具结果会经过多层安全防护:
|
||||
> 1. **工具层源头限制**:文件/网页工具返回 markdown 指引,实际 I/O 由 `run_command` 完成(`run_command` 输出≤1MB,超出立即终止进程;`read_file` 指引命令限制 500 行、单行≤10KB)
|
||||
> 2. **`_sanitize_tool_result()`**:None/非字符串转为安全字符串,过滤控制字符
|
||||
> 3. **最终兜底截断**:超过 `tools.max_result_length`(默认1MB字符)时截断并提示,避免破坏 API 请求格式
|
||||
|
||||
### 添加新命令
|
||||
|
||||
在 `WebSocketHandler` 的命令处理分支(`handle_command`)中添加:
|
||||
|
||||
```python
|
||||
elif command == "/mycommand":
|
||||
result = "命令执行结果"
|
||||
await self.send_message(websocket, {"type": "command_result", "content": result})
|
||||
```
|
||||
|
||||
### 错误恢复机制
|
||||
|
||||
`think_and_respond()` 在每轮对话开始前记录消息列表长度 `msgs_before_turn`。若 API 请求失败(网络错误、HTTP 错误、JSON 解析错误或其他异常),会自动调用 `_rollback_messages(msgs_before_turn)` 回滚本轮添加的所有消息(包括 user 消息、可能的 assistant/tool_calls 消息、tool 响应消息),防止格式错误的消息污染对话历史导致后续请求持续失败。客户端收到 `error` 消息后可安全重试。
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### 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:** 调整 `config.json` 的 `context` 配置:
|
||||
```json
|
||||
"compress_message_threshold": 300, // 调大阈值
|
||||
"compress_keep_recent": 20 // 保留更多最近消息
|
||||
```
|
||||
|
||||
### Q: 如何让 AI 使用特定技能?
|
||||
**A:** 在对话中明确指示:
|
||||
```
|
||||
使用 search_skills 搜索天气相关技能,然后用 run_command 执行它
|
||||
```
|
||||
|
||||
### Q: 内存或日志占用过大?
|
||||
**A:**
|
||||
1. 调整日志配置:`log_max_size`、`log_backup_count`
|
||||
2. 清理旧对话归档
|
||||
3. 定期清理工具结果缓存
|
||||
|
||||
### Q: WebSocket 连接断开?
|
||||
**A:**
|
||||
1. 检查服务器是否运行:`ps aux | grep aibox.py`
|
||||
2. 确认端口未被占用:`netstat -tlnp | grep 8765`
|
||||
3. 检查防火墙设置
|
||||
4. 查看服务器日志:`tail -f ~/.aibox/ai.log`
|
||||
5. 考虑使用 `--host 0.0.0.0` 允许外部连接
|
||||
|
||||
### Q: 工具调用后 API 报错无法继续?
|
||||
**A:** 此问题已在 v2.1 修复:工具结果会经过安全清洗(控制字符过滤、非字符串转换、超长截断),API 请求失败时自动回滚本轮消息。如果仍出现问题:
|
||||
1. 查看日志:`tail -f ~/.aibox/ai.log`
|
||||
2. 使用 `/new` 命令重置对话
|
||||
3. 确认工具返回值为字符串且不含二进制/控制字符
|
||||
|
||||
## 📄 更新日志
|
||||
|
||||
### v2.8
|
||||
- ✅ **新增 `express_emotion` 工具**:表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能文件(如 angry/coquettish/tsundere),自动派生携带该人格的情绪子代理生成回应,保证人格独立、不污染主对话上下文
|
||||
- ✅ **新增 `/search <关键词>` 命令**:在 WebSocket 会话中按关键词搜索记忆,返回匹配的记忆条目
|
||||
|
||||
### v2.7
|
||||
- ✅ **新增子代理功能(`create_subagent` 工具)**:
|
||||
- 主代理可派生出拥有**独立上下文窗口**和**独立角色设定**的轻量子代理,不污染主对话上下文
|
||||
- 轻量子代理:无工具,通过一次 LLM 补全返回结果,适合长任务分解、上下文隔离、独立角色专注处理(如代码审查、文本总结、资料研究)
|
||||
- 子代理可选用**不同模型**(复用 `llm_client` 抽象层,`create_llm_client` 新增 `model` 参数)
|
||||
- 参数:`role`(角色/系统提示词,必填)、`task`(任务,必填)、`model`/`temperature`/`max_tokens`(可选)
|
||||
- `max_tokens` 已钳制到 `[1, 主配置 DEFAULT_MAX_TOKENS]`,防止意外超限消耗
|
||||
|
||||
### v2.6
|
||||
- ✅ **修复 Ctrl+C 中断无法停止服务器的问题**:
|
||||
- 新增 `interrupt` WebSocket 消息类型处理(BotOps 前端 Ctrl+C 发送此消息),立即回复 `interrupt_ack` 确认
|
||||
- `DeepSeekChat` 新增 `cancel()` 方法和 `_cancel_event`(threading.Event),线程安全可跨线程调用
|
||||
- **三层取消机制**:
|
||||
1. 设置取消标志,流式响应循环每轮检查,发现取消立即 break
|
||||
2. 关闭当前 HTTP response 对象,中断 `iter_lines()` 阻塞读取
|
||||
3. 终止正在运行的子进程(`SubprocessCommandExecutor.cancel()` → `process.kill()`)
|
||||
- 工具调用循环中每个工具执行前检查取消信号,中断后不再继续后续工具
|
||||
- API 请求异常/JSON解析异常/未知异常中均检查取消标志,若因取消导致异常则按中断处理而非报错
|
||||
- 中断后自动回滚本轮消息,防止污染对话历史
|
||||
- 新增 `EnhancedAIManager.cancel()` 统一入口,同时终止 AI 和命令执行器
|
||||
|
||||
### v2.5
|
||||
- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode):
|
||||
- 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数
|
||||
- 使用 `deepseek-v4-flash` 模型原生思考能力,**思考模式下完整支持工具调用**(文件操作、命令执行、记忆搜索等均可正常使用)
|
||||
- 实时展示 AI 思考过程:新增 `reasoning_start`/`reasoning`/`reasoning_end` WebSocket 消息类型
|
||||
- 思考内容在前端以灰色斜体区域独立展示,超长思考自动折叠显示最后 2000 字符
|
||||
- 即使未启用思考模式,若模型返回 `reasoning_content` 也会自动实时展示
|
||||
- API 超时默认从 30s 提高到 120s,适配思考模式更长的响应时间
|
||||
- ✅ 删除重复定义的 `_add_assistant_message_with_reasoning` 方法
|
||||
|
||||
### v2.4
|
||||
- ✅ **新增专用工作目录规范**:
|
||||
- AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录
|
||||
- `write_file` 工具默认路径设为工作目录(用户明确指定路径时除外)
|
||||
- `run_command` 工具默认 `work_dir` 设为工作目录,命令产生的文件落在专用工作区
|
||||
- 新增 `system.workspace_dir` 配置项(默认 `~/.aibox/workspace`),可自定义工作目录位置
|
||||
- 在文件操作和命令执行提示词中明确工作目录规范
|
||||
- ✅ 命令执行提示词补充 1MB 输出大小限制说明
|
||||
|
||||
### v2.3
|
||||
- ✅ **修复工具输出OOM崩溃问题**:
|
||||
- `read_file`:不再一次性 `readlines()` 加载整个文件,改为按行范围按需读取;大文件 `tail=true` 使用反向块读取(>1MB文件不全文遍历);新增单行长度限制(10KB/行,超长截断);`max_lines` 硬上限500行防止请求超大范围
|
||||
- `run_command`:非流式执行从 `capture_output=True` 改为逐行读取并实时计数,输出达到1MB立即终止进程;流式执行同样增加1MB输出大小限制,超出终止并提示截断;统一stdout/stderr合并计数
|
||||
- `tools.max_result_length` 默认值从10KB提高到1MB字符,作为最终兜底截断
|
||||
- 形成"工具层源头限制→结果清洗→最终兜底"三层防护,彻底防止大文件/大输出导致内存溢出
|
||||
|
||||
### v2.2
|
||||
- ✅ 修复提示词系统排序 bug:`SKILL_PREFIX = "1_"` 只匹配 `1_` 开头文件,导致 `2_`~`10_` 技能提示词排序错乱(`10_task_handling.md` 排在 `1_memory_search.md` 前面);改用正则按数字前缀排序
|
||||
- ✅ 修复 `_init_system_prompts` 使用未排序列表的问题,确保 system prompt 按 identity → 技能 → soul → status 正确顺序加载
|
||||
- ✅ 精简每轮用户输入的 pre_prompt:原来约 30 行重复指令(与 system prompt 内容重复),每轮浪费 ~300 token;精简为 5 条简洁提醒
|
||||
- ✅ 修复工具调用后递归继续时重复注入工作流指令的问题:新增 `is_continuation` 参数,递归时跳过 pre_prompt
|
||||
- ✅ 优化默认大文件阈值:50KB/200行 → 500KB/2000行,增加 `tail=true` 和二进制文件处理指导
|
||||
- ✅ 优化记忆搜索习惯:"每次都要做"→"根据话题相关性判断",避免简单问候也触发无意义搜索
|
||||
|
||||
### v2.1
|
||||
- ✅ 修复工具调用结果破坏对话导致 API 持续请求失败的问题
|
||||
- 不再回传 `reasoning_content`(API 响应字段)到请求中
|
||||
- `tool_call.id` 为空时自动生成 fallback id,防止 API 400
|
||||
- 新增 `_sanitize_tool_result()`:None/非字符串安全转换、控制字符过滤、超长结果截断
|
||||
- `execute_tool` 异常捕获,避免工具执行异常中断整个对话
|
||||
- API 请求失败时自动回滚本轮消息(`_rollback_messages`),防止坏消息污染历史
|
||||
- ✅ 修复 `complete` 消息语义:chunk 流式为内容权威来源,complete 仅用于收尾
|
||||
- ✅ 修正文档:入口文件为 `aibox.py`(非 `main.py`),补充缺失的消息类型(`line`/`task_error`/`notification` 等)
|
||||
|
||||
### v2.0
|
||||
- ✅ 新增 `update_soul` 工具(智能体自我进化)
|
||||
- ✅ 新增 `create_skill` 工具(AI 自主创建技能)
|
||||
- ✅ 新增 `install_skill` 工具(技能安装指引)
|
||||
- ✅ 新增 `set_proxy` / `get_proxy` 工具(SOCKS5 代理)
|
||||
- ✅ 新增 `task_process` 消息类型(任务执行过程广播)
|
||||
- ✅ 支持 Markdown 格式技能文件
|
||||
- ✅ 对话压缩支持 Token 数阈值判断
|
||||
- ✅ 技能热重载(`reload_skills`)
|
||||
- ✅ 记忆搜索支持分页、任务调度器支持即时任务
|
||||
- ✅ 命令执行支持超时控制
|
||||
|
||||
### v1.0
|
||||
- ✅ WebSocket 服务器、文件/网页/记忆操作、任务调度、扩展技能、多语言、对话管理
|
||||
|
||||
## 📝 开发指南补充
|
||||
|
||||
### 扩展技能格式
|
||||
技能文件命名与格式详见 [SKILLS_FORMAT.md](SKILLS_FORMAT.md):可执行脚本需支持 `--help`,Markdown 技能首行为简介。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
**注意**:本项目需要 DeepSeek API Key,请确保遵守 API 使用条款。
|
||||
@@ -0,0 +1,824 @@
|
||||
# AIBOX 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:v2.0
|
||||
> 语言:Python 3.8+
|
||||
> 定位:单 AI 智能体系统(独立于微服务体系)
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
**AIBOX** 是一个基于 DeepSeek API 的单 AI 智能体系统,通过 WebSocket 与客户端双向通信。所有任务(文件操作、网页访问、记忆管理、任务调度等)都由 AI 对话驱动执行。
|
||||
|
||||
### 1.2 与微服务体系的关系
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| 启动方式 | `core`(PMC) 通过 `init.txt` 拉起 |
|
||||
| 监听端口 | 8765(WebSocket) |
|
||||
| 与主干关系 | **完全独立**,不调用 waf/gateway/dnfs 等服务 |
|
||||
| 数据存储 | 本地 `~/.aibox/`(不使用 db:8005) |
|
||||
| 前端 | 独立 HTML 页面 |
|
||||
|
||||
### 1.3 核心特性
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| AI 对话 | DeepSeek API + 思考模式(reasoning 流式输出) |
|
||||
| 实时通信 | WebSocket 双向通信 + 流式输出 |
|
||||
| 工具系统 | 19 个内置工具(文件/网页/记忆/任务/命令等) |
|
||||
| 技能扩展 | 热重载 `.py` 脚本和 `.md` 技能 |
|
||||
| 记忆管理 | 自动保存/检索对话记忆 |
|
||||
| 任务调度 | 一次性/重复/即时任务 |
|
||||
| 自我进化 | AI 动态更新角色与用户画像 |
|
||||
| 对话压缩 | 自动压缩长对话历史 |
|
||||
| 多语言 | 中英自动检测 |
|
||||
|
||||
---
|
||||
|
||||
## 二、技术架构
|
||||
|
||||
### 2.1 技术栈
|
||||
|
||||
| 层 | 技术 | 用途 |
|
||||
|----|------|------|
|
||||
| 语言 | Python 3.8+ | 主程序 |
|
||||
| 通信 | websockets | WebSocket 服务器 |
|
||||
| AI | DeepSeek API | 智能对话、工具调用、流式输出 |
|
||||
| 存储 | SQLite | 记忆数据库、备忘录任务 |
|
||||
| HTTP | requests | 网页访问、API 调用 |
|
||||
| HTML 解析 | beautifulsoup4 | 网页内容提取 |
|
||||
| SSH | paramiko | SSH 远程执行(技能) |
|
||||
| 加密 | cryptography | AES 加密(技能) |
|
||||
|
||||
### 2.2 架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket 客户端 │
|
||||
│ (entry.html / frontend.html / client.py / voice_client.py) │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ ws://localhost:8765
|
||||
┌────────────────────────▼────────────────────────────────────┐
|
||||
│ WebSocket 服务器 (main.py) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │
|
||||
│ │ (handle_msg) │ │ (handle_cmd) │ │ (MemoScheduler) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||
└─────────┼─────────────────┼───────────────────┼─────────────┘
|
||||
│ │ │
|
||||
┌─────────▼─────────────────▼───────────────────▼─────────────┐
|
||||
│ AI 对话引擎 (DeepSeekChat) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 提示词管理器 │ │ 工具注册器 │ │ 对话压缩器 │ │
|
||||
│ │ (PromptMgr) │ │ (ToolRegistry)│ │ (ConversationComp)│ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
|
||||
└─────────┼─────────────────┼───────────────────┼─────────────┘
|
||||
│ │ │
|
||||
┌─────────▼─────────────────▼───────────────────▼─────────────┐
|
||||
│ 数据持久层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ 记忆数据库 │ │ 备忘录 DB │ │ 对话历史管理器 │ │
|
||||
│ │ (memories.db) │ │ (memos.db) │ │ (JSON files) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.3 核心数据流
|
||||
|
||||
```
|
||||
客户端请求 → WebSocketHandler.handle_message()
|
||||
↓
|
||||
┌─── 消息类型判断 ───┬─── 命令类型判断 ───┬─── 任务操作判断 ───┐
|
||||
│ type="message" │ type="command" │ type="task" │
|
||||
└─────────┬─────────┴─────────┬─────────┴─────────┬─────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
AI 对话引擎 命令处理器 任务管理器
|
||||
(DeepSeekChat) (handle_command) (handle_task)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
工具调用循环 系统命令执行 任务 CRUD 操作
|
||||
(ToolRegistry) (/new, /list...) (add, complete...)
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
流式响应生成 命令结果返回 任务状态广播
|
||||
(chunk → complete) (command_result) (task_status)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、代码结构
|
||||
|
||||
### 3.1 文件结构
|
||||
|
||||
```
|
||||
aibox/
|
||||
├── main.py # 核心(~8000 行)
|
||||
│ ├── WebSocketHandler # WebSocket 消息处理
|
||||
│ ├── DeepSeekChat # AI 对话引擎
|
||||
│ ├── PromptManager # 提示词管理
|
||||
│ ├── Tool + 19 个工具类 # 工具系统
|
||||
│ ├── MemoScheduler # 任务调度器
|
||||
│ ├── MemoryManager # 记忆管理
|
||||
│ └── ConfigManager # 配置管理
|
||||
├── client.py # CLI WebSocket 客户端
|
||||
├── voice_client.py # 语音客户端(语音输入/输出)
|
||||
├── entry.html # Web 前端入口
|
||||
├── frontend.html # Web 主页面
|
||||
├── skills/ # 内置扩展技能
|
||||
│ ├── hello-world.py # 示例技能
|
||||
│ ├── bing_search.py # 必应搜索
|
||||
│ ├── ssh-exec.py # SSH 远程执行
|
||||
│ ├── sftp-transfer.py # SFTP 传输
|
||||
│ ├── facerec.py # 人脸识别
|
||||
│ ├── aes-crypto.py # AES 加密
|
||||
│ ├── gitclient.py # Git 操作
|
||||
│ └── *.md # Markdown 技能(prompt)
|
||||
├── tasks/ITIL.md # ITIL 任务模板
|
||||
├── SKILLS_FORMAT.md # 技能格式规范
|
||||
├── requirements.txt
|
||||
├── _run.sh # core 拉起的启动脚本
|
||||
├── _runcli.sh # CLI 启动
|
||||
├── _set.sh # 依赖安装
|
||||
├── _setenv.sh # 环境变量设置
|
||||
└── README.md # 用户文档
|
||||
```
|
||||
|
||||
### 3.2 main.py 核心类
|
||||
|
||||
| 类名 | 行号 | 功能 |
|
||||
|------|------|------|
|
||||
| `Tool` | L3642 | 工具基类(抽象方法:get_name/execute) |
|
||||
| `WebSocketHandler` | ~L1900 | WebSocket 消息处理 |
|
||||
| `DeepSeekChat` | ~L2400 | AI 对话引擎 |
|
||||
| `PromptManager` | ~L1050 | 提示词文件管理 |
|
||||
| `ConfigManager` | ~L1080 | 配置加载/合并 |
|
||||
| `MemoryManager` | ~L5200 | 记忆 CRUD |
|
||||
| `MemoScheduler` | ~L4700 | 任务调度器 |
|
||||
| `ConversationCompressor` | ~L6480 | 对话压缩 |
|
||||
|
||||
### 3.3 运行期数据
|
||||
|
||||
```
|
||||
~/.aibox/
|
||||
├── config.json # 配置(自动生成默认值)
|
||||
├── prompts/
|
||||
│ ├── 00_IDENTITY.md # 用户身份(AI 可更新)
|
||||
│ ├── 1_*.md # 基础技能提示词
|
||||
│ ├── 99_SOUL.md # AI 灵魂定义(AI 可更新)
|
||||
│ └── 100_STATUS.md # AI 状态记录
|
||||
├── skills/ # 用户扩展技能
|
||||
├── memories.db # 记忆库(SQLite)
|
||||
├── memos.db # 备忘录任务(SQLite)
|
||||
├── current_conversation.json # 当前对话上下文
|
||||
├── conversation_history.json # 对话索引
|
||||
├── archive/ # 历史对话归档
|
||||
├── tool_results/ # 工具执行结果缓存
|
||||
└── ai.log # 日志(自动轮转)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、工具系统
|
||||
|
||||
### 4.1 工具基类
|
||||
|
||||
```python
|
||||
class Tool:
|
||||
"""工具基类,所有工具必须继承此类"""
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""返回工具名称(抽象方法,必须实现)"""
|
||||
pass
|
||||
|
||||
def get_description(self) -> str:
|
||||
"""返回工具描述"""
|
||||
return ""
|
||||
|
||||
def get_parameters(self) -> Dict:
|
||||
"""返回工具参数(JSON Schema 格式)"""
|
||||
return {"type": "object", "properties": {}}
|
||||
|
||||
def execute(self, **kwargs) -> str:
|
||||
"""执行工具(抽象方法,必须实现)"""
|
||||
pass
|
||||
|
||||
def execute_streaming(self, **kwargs):
|
||||
"""流式执行(可选实现)"""
|
||||
return None
|
||||
```
|
||||
|
||||
### 4.2 内置工具一览
|
||||
|
||||
| 工具名 | 类名 | 行号 | 功能 |
|
||||
|--------|------|------|------|
|
||||
| `read_file` | `ReadFileTool` | L5762 | 读取文件(支持 head/tail/paging) |
|
||||
| `write_file` | `WriteFileTool` | L5884 | 写入/追加文件 |
|
||||
| `list_files` | `ListFilesTool` | L5961 | 目录列表 |
|
||||
| `web` | `SimpleWebCrawlerTool` | L5637 | 网页抓取(标题/正文/链接) |
|
||||
| `memory` | `MemoryTool` | L5280 | 记忆 CRUD(search/save/delete) |
|
||||
| `memo` | `MemoTool` | L4753 | 任务 CRUD(add/complete/list/search) |
|
||||
| `run_command` | `CommandLineTool` | L6150 | 系统命令执行(支持超时) |
|
||||
| `search_skills` | `SearchSkillsTool` | L4076 | 搜索扩展技能 |
|
||||
| `reload_skills` | `ReloadSkillsTool` | L4199 | 热重载技能 |
|
||||
| `create_skill` | `CreateSkillTool` | L4409 | AI 自主创建技能 |
|
||||
| `install_skill` | `InstallSkillTool` | L4306 | 技能安装指引 |
|
||||
| `ask_user` | `AskUserTool` | L4275 | 向用户提问 |
|
||||
| `update_identity` | `UpdateIdentityTool` | L4518 | 更新用户身份 |
|
||||
| `update_soul` | `UpdateSoulTool` | L4582 | 更新 AI 灵魂 |
|
||||
| `save_memory_and_end_conversation` | `SaveMemoryAndEndConversationTool` | L4646 | 保存记忆并结束对话 |
|
||||
| `get_current_time` | `GetCurrentTimeTool` | L6055 | 获取当前时间 |
|
||||
| `calculator` | `CalculatorTool` | L6095 | 数学计算 |
|
||||
| `set_proxy` | `SetProxyTool` | L6415 | 设置 SOCKS5 代理 |
|
||||
| `get_proxy` | `GetProxyTool` | L6453 | 查看代理配置 |
|
||||
|
||||
### 4.3 工具执行流程
|
||||
|
||||
```
|
||||
AI 决定调用工具 → 生成 JSON 格式工具调用
|
||||
↓
|
||||
DeepSeekChat.process_tool_calls()
|
||||
↓
|
||||
┌─── 解析工具名 ───┬─── 解析参数 ───┬─── 注册工具 ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
ToolRegistry.get_tool(name)
|
||||
↓
|
||||
tool.execute(**params)
|
||||
↓
|
||||
┌─── 结果处理 ───┬─── 错误处理 ───┬─── 长结果截断 ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
返回结果给 AI → AI 继续生成响应
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、WebSocket 协议
|
||||
|
||||
### 5.1 客户端 → 服务器
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `content` | 普通对话文本 |
|
||||
| `command` | `command` | 斜杠命令(如 `/new`, `/help`) |
|
||||
| `task` | `action`, `title`, ... | 任务操作(add/complete/list) |
|
||||
| `ping` | — | 心跳检测 |
|
||||
|
||||
### 5.2 服务器 → 客户端
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| `chunk` | `content` | AI 响应片段(流式) |
|
||||
| `complete` | `content` | AI 响应完成 |
|
||||
| `reasoning` | `content` | AI 思考过程 |
|
||||
| `error` | `content` | 错误信息 |
|
||||
| `command_result` | `content` | 命令执行结果 |
|
||||
| `task_status` | `task_id`, `status` | 任务状态更新 |
|
||||
| `task_process` | `task_id`, `content` | 任务执行过程 |
|
||||
| `dialogue_ended` | — | 对话结束 |
|
||||
| `dialogue_reset` | — | 对话重置完成 |
|
||||
| `info` | `content` | 系统信息 |
|
||||
| `pong` | — | Ping 响应 |
|
||||
|
||||
### 5.3 消息流示例
|
||||
|
||||
```
|
||||
// 普通对话
|
||||
Client: {type: "message", content: "你好"}
|
||||
Server: {type: "chunk", content: "你好!"}
|
||||
Server: {type: "complete", content: "你好!有什么可以帮你的吗?"}
|
||||
|
||||
// 工具调用
|
||||
Server: {type: "tool_call", tool: "web", args: {url: "..."}}
|
||||
Server: {type: "chunk", content: "网页内容:..."}
|
||||
Server: {type: "complete", content: "网页已获取"}
|
||||
|
||||
// 任务执行
|
||||
Client: {type: "task", action: "add", title: "提醒", delay: 60}
|
||||
Server: {type: "command_result", content: "任务已添加"}
|
||||
// 60 秒后
|
||||
Server: {type: "task_status", task_id: 1, status: "executing"}
|
||||
Server: {type: "task_process", task_id: "1", content: "处理中..."}
|
||||
Server: {type: "task_status", task_id: 1, status: "complete"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、任务系统
|
||||
|
||||
### 6.1 任务类型
|
||||
|
||||
| 类型 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| 一次性 | `reminder_time` | 指定执行时间 |
|
||||
| 重复(daily) | `repeat_type: "daily"` | 每天执行 |
|
||||
| 重复(weekly) | `repeat_type: "weekly"`, `weekday` | 每周指定日 |
|
||||
| 重复(monthly) | `repeat_type: "monthly"`, `day` | 每月指定日 |
|
||||
| 重复(custom) | `repeat_type: "custom"`, `interval`, `unit` | 自定义间隔 |
|
||||
| 即时 | `is_immediate: true` | 立即执行 |
|
||||
|
||||
### 6.2 任务调度流程
|
||||
|
||||
```
|
||||
MemoScheduler._run() # 后台线程
|
||||
↓
|
||||
检查所有未完成任务
|
||||
↓
|
||||
┌─── 到达时间? ───┬─── 未完成? ───┬─── 允许用户任务? ───┐
|
||||
└─────────────────┴───────────────┴─────────────────┘
|
||||
↓
|
||||
创建新的 AI 对话(将任务内容作为用户输入)
|
||||
↓
|
||||
AI 处理任务(可能调用多个工具)
|
||||
↓
|
||||
标记任务已触发
|
||||
↓
|
||||
广播结果到所有客户端
|
||||
↓
|
||||
计算下一次执行时间(重复任务)
|
||||
```
|
||||
|
||||
### 6.3 数据库表结构
|
||||
|
||||
```sql
|
||||
-- memories.db
|
||||
CREATE TABLE memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT,
|
||||
content TEXT,
|
||||
importance INTEGER DEFAULT 2,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
embedding BLOB -- 预留向量嵌入
|
||||
);
|
||||
|
||||
-- memos.db
|
||||
CREATE TABLE memos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
reminder_time TEXT,
|
||||
repeat_type TEXT,
|
||||
repeat_interval INTEGER,
|
||||
repeat_interval_unit TEXT,
|
||||
is_immediate INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
is_triggered INTEGER DEFAULT 0,
|
||||
created_at TEXT,
|
||||
user_id TEXT
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、记忆系统
|
||||
|
||||
### 7.1 记忆操作
|
||||
|
||||
| 操作 | AI 调用 | 说明 |
|
||||
|------|---------|------|
|
||||
| 保存 | `memory(action="save", content=...)` | AI 主动保存重要信息 |
|
||||
| 搜索 | `memory(action="search", query=...)` | 按关键词搜索记忆 |
|
||||
| 列表 | `memory(action="list", page=...)` | 分页浏览所有记忆 |
|
||||
| 删除 | `memory(action="delete", id=...)` | 删除指定记忆 |
|
||||
|
||||
### 7.2 自动记忆
|
||||
|
||||
- AI 在对话结束时(`/exit`)自动保存关键信息
|
||||
- 可配置 `system.auto_save_memory` 开关
|
||||
- 重要度(importance)1-5 级
|
||||
|
||||
---
|
||||
|
||||
## 八、对话压缩
|
||||
|
||||
### 8.1 压缩触发条件
|
||||
|
||||
| 条件 | 阈值(默认) | 配置项 |
|
||||
|------|-------------|--------|
|
||||
| 消息数超过 | 200 条 | `compress_message_threshold` |
|
||||
| Token 数超过 | 120000 | `compress_token_threshold` |
|
||||
|
||||
### 8.2 压缩流程
|
||||
|
||||
```
|
||||
检测到阈值触发
|
||||
↓
|
||||
保留最近 8 条消息(compress_keep_recent)
|
||||
↓
|
||||
对早期消息按时间衰减加权(weight_decay_factor=0.01)
|
||||
↓
|
||||
调用 AI 生成摘要(max_input_chars=6000)
|
||||
↓
|
||||
将摘要作为系统消息插入
|
||||
↓
|
||||
压缩后继续对话
|
||||
```
|
||||
|
||||
### 8.3 压缩配置
|
||||
|
||||
```json
|
||||
{
|
||||
"compressor": {
|
||||
"min_messages_to_compress": 5,
|
||||
"weight_decay_factor": 0.01,
|
||||
"max_input_chars": 6000,
|
||||
"llm_max_tokens": 800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、扩展技能系统
|
||||
|
||||
### 9.1 技能类型
|
||||
|
||||
| 类型 | 格式 | 存储位置 |
|
||||
|------|------|---------|
|
||||
| 可执行脚本 | Python/Shell,支持 `--help` | `~/.aibox/skills/` |
|
||||
| Markdown 技能 | `.md` 文件,首行为简介 | `~/.aibox/skills/` |
|
||||
|
||||
### 9.2 内置技能
|
||||
|
||||
| 技能名 | 文件 | 功能 |
|
||||
|--------|------|------|
|
||||
| hello-world | `skills/hello-world.py` | 示例技能 |
|
||||
| bing_search | `skills/bing_search.py` | 必应搜索 |
|
||||
| ssh-exec | `skills/ssh-exec.py` | SSH 远程执行 |
|
||||
| sftp-transfer | `skills/sftp-transfer.py` | SFTP 文件传输 |
|
||||
| facerec | `skills/facerec.py` | 人脸识别 |
|
||||
| aes-crypto | `skills/aes-crypto.py` | AES 加密/解密 |
|
||||
| gitclient | `skills/gitclient.py` | Git 操作封装 |
|
||||
|
||||
### 9.3 技能热重载
|
||||
|
||||
```python
|
||||
# 新技能放置到 ~/.aibox/skills/
|
||||
# 调用 reload_skills 工具
|
||||
# AI 下次对话时即可使用
|
||||
```
|
||||
|
||||
### 9.4 技能格式规范
|
||||
|
||||
详见 [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
|
||||
|
||||
---
|
||||
|
||||
## 十、配置参考
|
||||
|
||||
### 10.1 完整配置项
|
||||
|
||||
#### system 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `save_dir` | str | `~/.aibox` | 数据存储目录 |
|
||||
| `datetime_format` | str | `%Y-%m-%d %H:%M:%S` | 日期时间格式 |
|
||||
| `date_format` | str | `%Y%m%d_%H%M%S` | 文件名日期格式 |
|
||||
| `debug_level` | int | `1` | 调试级别 0-3 |
|
||||
| `load_history_by_default` | bool | `true` | 启动时加载历史对话 |
|
||||
| `log_file` | str | `~/.aibox/ai.log` | 日志文件路径 |
|
||||
| `log_max_size` | int | `10485760` | 日志最大字节数(10MB) |
|
||||
| `log_backup_count` | int | `5` | 日志备份数量 |
|
||||
| `websocket_host` | str | `localhost` | WebSocket 监听地址 |
|
||||
| `websocket_port` | int | `8765` | WebSocket 监听端口 |
|
||||
| `skills_dir` | str | `~/.aibox/skills` | 技能目录 |
|
||||
| `prompts_dir` | str | `~/.aibox/prompts` | 提示词目录 |
|
||||
|
||||
#### scheduler 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `enabled` | bool | `true` | 启用任务调度器 |
|
||||
| `check_interval` | float | `1.0` | 检查间隔(秒) |
|
||||
| `max_tasks_per_run` | int | `10` | 单次最大触发任务数 |
|
||||
| `broadcast_enabled` | bool | `true` | 广播任务执行结果 |
|
||||
|
||||
#### api 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `provider` | str | `deepseek` | 模型厂商,用于 `llm_client` 工厂路由 |
|
||||
| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL |
|
||||
| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 |
|
||||
| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 |
|
||||
| `timeout` | int | `30` | 请求超时(秒) |
|
||||
| `temperature` | float | `0.7` | 生成温度 0-1 |
|
||||
|
||||
#### context 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `max_history_messages` | int | `500` | 历史消息上限 |
|
||||
| `max_context_messages` | int | `120` | 上下文窗口 |
|
||||
| `max_tokens` | int | `8192` | 最大生成 Token |
|
||||
| `max_history_conversations` | int | `5` | 保留历史对话数 |
|
||||
| `preserve_system_prompts` | bool | `true` | 保留系统提示词 |
|
||||
| `compress_enabled` | bool | `true` | 启用对话压缩 |
|
||||
| `compress_message_threshold` | int | `200` | 压缩消息阈值 |
|
||||
| `compress_token_threshold` | int | `120000` | 压缩 Token 阈值 |
|
||||
| `compress_ratio` | float | `0.2` | 压缩保留比例 |
|
||||
| `compress_keep_recent` | int | `8` | 保留最近消息数 |
|
||||
|
||||
#### memo 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `default_reminder_minutes` | int | `60` | 默认提醒分钟数 |
|
||||
| `max_memos_per_check` | int | `10` | 单次最大触发任务数 |
|
||||
| `allow_user_tasks` | bool | `true` | 允许用户创建任务 |
|
||||
|
||||
#### proxy 段
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `type` | str | `""` | 代理类型(socks5/http) |
|
||||
| `host` | str | `""` | 代理主机 |
|
||||
| `port` | int | `0` | 代理端口 |
|
||||
| `username` | str | `""` | 代理用户名 |
|
||||
| `password` | str | `""` | 代理密码 |
|
||||
|
||||
### 10.2 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek API Key(必填) |
|
||||
| `AI_CONFIG_PATH` | 自定义配置文件路径 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、命令行参数
|
||||
|
||||
```bash
|
||||
python main.py [选项]
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `--verbose`, `-v` | flag | `false` | 显示详细日志 |
|
||||
| `--debug` | int | `None` | 调试级别 0-3 |
|
||||
| `--lang` | str | `None` | 强制语言 zh/en |
|
||||
| `--host` | str | `None` | 覆盖 WebSocket 主机 |
|
||||
| `--port` | int | `None` | 覆盖 WebSocket 端口 |
|
||||
| `--no-scheduler` | flag | `false` | 禁用任务调度器 |
|
||||
| `--scheduler-interval` | float | `1.0` | 调度器检查间隔 |
|
||||
| `--config`, `-c` | str | `None` | 指定配置文件 |
|
||||
| `--check-memo` | flag | `false` | 检查到期任务并退出 |
|
||||
| `--memo-add` | str | `None` | 添加任务(CLI) |
|
||||
| `--memo-list` | flag | `false` | 列出任务 |
|
||||
| `--memo-complete` | int | `None` | 完成指定任务 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```bash
|
||||
# 启动服务器
|
||||
python main.py
|
||||
|
||||
# 详细日志 + 指定端口
|
||||
python main.py --verbose --port 9000
|
||||
|
||||
# 检查并执行到期任务(cron 场景)
|
||||
python main.py --check-memo
|
||||
|
||||
# 添加任务
|
||||
python main.py --memo-add "每日备份|执行数据库备份|+1h"
|
||||
|
||||
# 列出所有任务
|
||||
python main.py --memo-list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、前端集成
|
||||
|
||||
### 12.1 前端文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `entry.html` | 简单入口页(重定向到 frontend.html) |
|
||||
| `frontend.html` | 完整前端界面(对话/任务/设置) |
|
||||
|
||||
### 12.2 WebSocket 连接
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8765');
|
||||
|
||||
// 发送消息
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
content: '你好'
|
||||
}));
|
||||
|
||||
// 接收消息
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case 'chunk':
|
||||
appendToChat(data.content);
|
||||
break;
|
||||
case 'complete':
|
||||
finishResponse();
|
||||
break;
|
||||
case 'error':
|
||||
showError(data.content);
|
||||
break;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十三、部署
|
||||
|
||||
### 13.1 目录结构
|
||||
|
||||
```
|
||||
/opt/micro_servers/aibox/
|
||||
├── main.py
|
||||
├── client.py
|
||||
├── skills/
|
||||
├── _run.sh # core 拉起入口
|
||||
├── _set.sh # 首次安装
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
### 13.2 core init.txt 配置
|
||||
|
||||
```bash
|
||||
# aibox 由 core 拉起
|
||||
bash /opt/micro_servers/aibox/_run.sh
|
||||
```
|
||||
|
||||
### 13.3 systemd 单元(可选)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=AIBOX AI Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
WorkingDirectory=/opt/micro_servers/aibox
|
||||
ExecStart=/usr/bin/python3 main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
Environment=DEEPSEEK_API_KEY=xxx
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、已知限制
|
||||
|
||||
| ID | 描述 | 严重等级 |
|
||||
|----|------|---------|
|
||||
| L1 | 单实例运行,不支持多进程 | 🟡 Medium |
|
||||
| L2 | 记忆库无向量搜索,仅关键词匹配 | 🟡 Medium |
|
||||
| L3 | 对话压缩可能丢失早期重要信息 | 🟢 Low |
|
||||
| L4 | 工具执行无超时控制(部分工具) | 🟡 Medium |
|
||||
| L5 | 日志无远程推送,仅本地文件 | 🟢 Low |
|
||||
| L6 | 无用户认证,WebSocket 连接可被滥用 | 🔴 High |
|
||||
| L7 | API Key 以明文存储在环境变量或文件中 | 🟡 Medium |
|
||||
|
||||
### L6 详细说明
|
||||
|
||||
WebSocket 服务器**无任何认证**,任何能连接到 `ws://localhost:8765` 的客户端都可以:
|
||||
- 与 AI 对话(消耗 API 额度)
|
||||
- 执行系统命令(`run_command`)
|
||||
- 操作文件系统(`read_file`/`write_file`)
|
||||
- 创建/删除任务
|
||||
|
||||
**建议**:
|
||||
1. 仅绑定 `localhost`(默认配置已这样做)
|
||||
2. 添加 API Token 认证
|
||||
3. 限制 `run_command` 可执行的命令范围
|
||||
|
||||
---
|
||||
|
||||
## 十五、开发指南
|
||||
|
||||
### 15.1 添加新工具
|
||||
|
||||
1. 继承 `Tool` 基类
|
||||
2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
|
||||
3. 在 `DeepSeekChat._register_tools()` 中注册
|
||||
4. 更新 README 文档
|
||||
|
||||
```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", "description": "参数说明"}
|
||||
},
|
||||
"required": ["param"]
|
||||
}
|
||||
|
||||
def execute(self, param: str, **kwargs) -> str:
|
||||
return f"执行结果: {param}"
|
||||
```
|
||||
|
||||
### 15.2 添加新命令
|
||||
|
||||
在 `WebSocketHandler.handle_command()` 中添加:
|
||||
|
||||
```python
|
||||
elif command == "/mycommand":
|
||||
result = "命令执行结果"
|
||||
await self.send_message(websocket, {
|
||||
"type": "command_result",
|
||||
"content": result
|
||||
})
|
||||
```
|
||||
|
||||
### 15.3 添加配置项
|
||||
|
||||
1. 在 `ConfigManager._get_default_config()` 中添加默认值
|
||||
2. 在 README 文档中添加说明
|
||||
3. 确保 `_merge_configs()` 能正确处理新增字段
|
||||
|
||||
---
|
||||
|
||||
## 十六、故障排查
|
||||
|
||||
### 16.1 常见问题
|
||||
|
||||
| 问题 | 排查步骤 |
|
||||
|------|---------|
|
||||
| API Key 无效 | 检查 `echo $DEEPSEEK_API_KEY` 或 `~/.cert/deepseek.key` |
|
||||
| 技能不生效 | 确认技能文件在 `~/.aibox/skills/` 且有执行权限 |
|
||||
| 任务不执行 | 检查调度器状态:`/tasks`;检查时间设置 |
|
||||
| 对话压缩丢失信息 | 调整 `compress_message_threshold` 和 `compress_keep_recent` |
|
||||
| WebSocket 断开 | 检查服务器进程:`ps aux | grep main.py`;查看日志 |
|
||||
|
||||
### 16.2 日志位置
|
||||
|
||||
```bash
|
||||
# 查看实时日志
|
||||
tail -f ~/.aibox/ai.log
|
||||
|
||||
# 查看最近错误
|
||||
grep "ERROR" ~/.aibox/ai.log | tail -20
|
||||
```
|
||||
|
||||
### 16.3 数据库检查
|
||||
|
||||
```bash
|
||||
# 检查记忆库
|
||||
sqlite3 ~/.aibox/memories.db "SELECT * FROM memories LIMIT 10;"
|
||||
|
||||
# 检查任务库
|
||||
sqlite3 ~/.aibox/memos.db "SELECT * FROM memos WHERE status='pending';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十七、版本历史
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| v2.0 | 当前 | 新增 `ask_user`、`update_soul`、`create_skill`、`install_skill`、`set_proxy`/`get_proxy` 等工具;支持 Markdown 技能;对话压缩支持 Token 阈值 |
|
||||
| v1.0 | — | 初始版本:WebSocket 服务器、文件/网页/记忆操作、任务调度、扩展技能、多语言 |
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 配置文件示例
|
||||
|
||||
完整默认配置见 [main.py:1100-1180](file:///Users/qingsong/micro_servers/aibox/main.py#L1100-L1180)
|
||||
|
||||
### B. 数据库 Schema
|
||||
|
||||
完整建表语句见:
|
||||
- 记忆库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoryManager._init_db()`
|
||||
- 任务库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoScheduler._init_db()`
|
||||
|
||||
### C. 参考文档
|
||||
|
||||
- [DeepSeek API 文档](https://platform.deepseek.com/docs)
|
||||
- [websockets 库文档](https://websockets.readthedocs.io/)
|
||||
- [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
|
||||
|
||||
---
|
||||
|
||||
*本文档由代码审查工具生成,供开发团队参考。*
|
||||
@@ -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 调用越准确。
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./_setenv.sh
|
||||
source ./venv/bin/activate
|
||||
export PATH=${PATH}:~/.aibox/skills
|
||||
exec python aibox.py $@
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./_setenv.sh
|
||||
source ./venv/bin/activate
|
||||
exec python client.py $@
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export DEEPSEEK_API_KEY=`cat ~/.cert/deepseek.key`
|
||||
+8914
File diff suppressed because it is too large.
Load diff
+825
@@ -0,0 +1,825 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI智能助手 - 命令行前端
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
import websockets
|
||||
from colorama import init, Fore, Style as ColoramaStyle
|
||||
|
||||
# rich 为可选依赖:有则 AI 回复渲染为 Markdown,无则回退纯文本流式输出
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
_RICH = True
|
||||
except ImportError:
|
||||
_RICH = False
|
||||
|
||||
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:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.callback("error", f"接收消息错误: {e}")
|
||||
finally:
|
||||
# 统一在此通知断线;手动 disconnect() 已把 connected 置 False,不会误触发自动重连
|
||||
was_connected = self.connected
|
||||
self.connected = False
|
||||
if was_connected:
|
||||
self.callback("disconnected", "❌ 连接已断开")
|
||||
|
||||
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
|
||||
|
||||
# 已显示消息的去重(LRU 上限,防长对话内存无限增长)
|
||||
self.DISPLAYED_MAX = 500
|
||||
self.displayed_messages: OrderedDict = OrderedDict()
|
||||
|
||||
# 锁和状态
|
||||
self.lock = threading.Lock()
|
||||
self.current_input = ""
|
||||
|
||||
# 标准输入:单守护线程阻塞读 + asyncio.Queue 中转(避免 Ctrl+C 泄漏线程)
|
||||
self._input_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._stdin_thread: Optional[threading.Thread] = None
|
||||
|
||||
# 自动重连状态
|
||||
self._manual_disconnect = False # 用户主动 /disconnect、/quit、/connect 后置 True,抑制自动重连
|
||||
self._reconnect_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Markdown 渲染(rich 可用时启用)
|
||||
self._console = Console() if _RICH else None
|
||||
self._md_generating_shown = False # "生成中…"状态行是否已显示
|
||||
|
||||
# 流式输出状态
|
||||
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
|
||||
|
||||
# rich 可用:流式期间只显示状态行并累计全文,complete 时一次性渲染
|
||||
if _RICH:
|
||||
self._md_show_generating()
|
||||
self.ai_previous = clean
|
||||
self.ai_full = clean
|
||||
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
|
||||
|
||||
# ── Markdown 渲染(rich)─────────────────────────────────────────────
|
||||
def _md_show_generating(self):
|
||||
"""流式期间显示一次性状态行(不实时渲染,避免 Live 闪烁/被卷走)。"""
|
||||
if not _RICH:
|
||||
return
|
||||
with self.lock:
|
||||
if not self._md_generating_shown:
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(f"{Colors.AI}🤖 AI助手: 生成中…{Colors.RESET}")
|
||||
sys.stdout.flush()
|
||||
self._md_generating_shown = True
|
||||
self.streaming_line = True
|
||||
self.prompt_shown = False
|
||||
|
||||
def _md_render_final(self, text: str):
|
||||
"""complete 时一次性静态渲染最终 Markdown(先擦掉"生成中…"行)。"""
|
||||
if not _RICH:
|
||||
return
|
||||
with self.lock:
|
||||
if self._md_generating_shown:
|
||||
sys.stdout.write('\r' + ' ' * 40 + '\r')
|
||||
self._md_generating_shown = False
|
||||
self.streaming_line = False
|
||||
if text:
|
||||
self._console.print(Markdown(text))
|
||||
|
||||
def _remember_displayed(self, key: str):
|
||||
"""记录已显示消息(LRU:超 DISPLAYED_MAX 淘汰最旧,防长对话内存膨胀)。"""
|
||||
self.displayed_messages[key] = None
|
||||
self.displayed_messages.move_to_end(key)
|
||||
if len(self.displayed_messages) > self.DISPLAYED_MAX:
|
||||
self.displayed_messages.popitem(last=False)
|
||||
|
||||
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 == 'disconnected':
|
||||
self.connected = False
|
||||
self._finish_stream()
|
||||
self._print_line(f"{Colors.SYSTEM}{content}{Colors.RESET}")
|
||||
self._schedule_reconnect()
|
||||
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):
|
||||
"""处理完成事件 - 只负责结束流式输出,不重复显示内容"""
|
||||
# rich 渲染路径:擦掉"生成中…",一次性渲染最终 Markdown,恢复提示符
|
||||
if _RICH:
|
||||
clean = self._clean_content(content) if content else ""
|
||||
self._md_render_final(clean)
|
||||
if clean:
|
||||
self._remember_displayed(clean[:200])
|
||||
with self.lock:
|
||||
self.in_tool_phase = False
|
||||
self.waiting_for_response = False
|
||||
self.streaming_line = 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
|
||||
return
|
||||
|
||||
# 如果之前在工具阶段,现在退出
|
||||
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._remember_displayed(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._remember_displayed(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]
|
||||
# 切换服务器:抑制旧连接的自动重连
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
if self.client and self.client.connected:
|
||||
await self.client.disconnect()
|
||||
self.client = WebSocketClient(uri, self.display)
|
||||
if await self.client.connect():
|
||||
self._manual_disconnect = False
|
||||
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':
|
||||
self._manual_disconnect = True # 主动断开,不自动重连
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
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
|
||||
self._manual_disconnect = True
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
self._reconnect_task.cancel()
|
||||
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)
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
"""启动自动重连(若未手动断开且当前无进行中的重连任务)。"""
|
||||
if self._manual_disconnect or not self.running or not self.client:
|
||||
return
|
||||
if self._reconnect_task and not self._reconnect_task.done():
|
||||
return
|
||||
try:
|
||||
self._reconnect_task = asyncio.create_task(self._auto_reconnect_loop(self.client))
|
||||
except RuntimeError:
|
||||
pass # 事件循环正在关闭(程序退出中),放弃重连
|
||||
|
||||
async def _auto_reconnect_loop(self, client: WebSocketClient):
|
||||
"""断线自动重连:指数退避 2s→30s 封顶,直到成功、/disconnect 或退出。"""
|
||||
delay = 2
|
||||
while self.running and not self._manual_disconnect and self.client is client:
|
||||
self.display("info", f"🔄 {delay}s 后自动重连 {client.uri} …(/disconnect 取消)")
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if not self.running or self._manual_disconnect or self.client is not client:
|
||||
return
|
||||
if await client.connect():
|
||||
self.connected = True
|
||||
self.display("system", f"✅ 已自动重连到 {client.uri}")
|
||||
asyncio.create_task(client.receive_messages())
|
||||
return
|
||||
delay = min(delay * 2, 30)
|
||||
|
||||
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响应结束后再显示
|
||||
|
||||
def _stdin_reader(self, loop: asyncio.AbstractEventLoop):
|
||||
"""独立守护线程:阻塞读 stdin,经 call_soon_threadsafe 投入队列。
|
||||
|
||||
进程生命周期只启动一次——替代原 run_in_executor 每行一个线程的方案,
|
||||
避免 Ctrl+C 取消 await 后旧线程仍阻塞在 readline 上无限泄漏。
|
||||
"""
|
||||
def _put(item):
|
||||
try:
|
||||
loop.call_soon_threadsafe(self._input_queue.put_nowait, item)
|
||||
except RuntimeError:
|
||||
pass # 事件循环已关闭(程序退出中)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
except Exception:
|
||||
break
|
||||
if not line: # EOF (Ctrl+D)
|
||||
_put(None)
|
||||
break
|
||||
_put(line)
|
||||
|
||||
async def run(self):
|
||||
self.print_banner()
|
||||
self._task_buffers = {}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
if self._stdin_thread is None:
|
||||
self._stdin_thread = threading.Thread(
|
||||
target=self._stdin_reader, args=(loop,),
|
||||
daemon=True, name="stdin-reader")
|
||||
self._stdin_thread.start()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
line = await self._input_queue.get()
|
||||
if line is None: # EOF (Ctrl+D)
|
||||
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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
大模型客户端抽象层
|
||||
|
||||
为 aibox 提供统一的 LLM 调用接口,将「调用哪个厂商 / 哪个模型」与业务逻辑解耦。
|
||||
业务层只依赖 `LLMClient` 抽象方法与统一的流式事件格式,切换厂商只需:
|
||||
1. 新增一个 `LLMClient` 子类实现;
|
||||
2. 在 `create_llm_client` 工厂里按 `api.provider` 进行路由。
|
||||
|
||||
当前内置实现:
|
||||
- `DeepSeekClient`:兼容 OpenAI Chat Completions 协议(DeepSeek / Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。
|
||||
|
||||
统一流式事件格式(`stream_chat` 逐条 yield 的 dict):
|
||||
{"type": "content", "text": str} # 增量正文
|
||||
{"type": "reasoning", "text": str} # 增量思考内容(可选,厂商支持时)
|
||||
{"type": "tool_call", "index": int, "id": str, "name": str, "arguments": str} # 工具调用增量
|
||||
{"type": "done"} # 流结束
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Iterator, List, Optional
|
||||
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
|
||||
|
||||
class LLMClient(ABC):
|
||||
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
|
||||
|
||||
def __init__(self, api_key: str, model: str, base_url: str,
|
||||
timeout: int, config=None, logger=None):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
self.config = config # 可选,用于读取代理等动态配置
|
||||
self.logger = logger # 可选,用于日志输出
|
||||
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
|
||||
self._current_response = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
|
||||
return "LLM"
|
||||
|
||||
# ---------- 工具方法 ----------
|
||||
|
||||
def _resolve_proxies(self) -> Optional[Dict[str, str]]:
|
||||
"""从 config 解析 requests 可用的 proxies 字典(动态代理)。"""
|
||||
if self.config is not None and hasattr(self.config, "get_proxy"):
|
||||
return self.config.get_proxy()
|
||||
return None
|
||||
|
||||
def _log(self, level: str, msg: str):
|
||||
if self.logger is not None and hasattr(self.logger, level):
|
||||
try:
|
||||
getattr(self.logger, level)(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
"""中断当前流式请求。线程安全,可从任意线程调用。"""
|
||||
resp = self._current_response
|
||||
self._current_response = None
|
||||
if resp is not None:
|
||||
try:
|
||||
# 先关闭底层 raw socket,强制阻塞的 recv() 立即抛出异常
|
||||
if getattr(resp, "raw", None) is not None:
|
||||
resp.raw.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- 抽象接口 ----------
|
||||
|
||||
@abstractmethod
|
||||
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 1024, **kwargs) -> str:
|
||||
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。"""
|
||||
|
||||
@abstractmethod
|
||||
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。"""
|
||||
|
||||
|
||||
class DeepSeekClient(LLMClient):
|
||||
"""DeepSeek 客户端(OpenAI Chat Completions 协议兼容)。
|
||||
|
||||
思考模式通过 `thinking: {"type": "enabled"}` 参数控制,思考内容字段为
|
||||
`delta.reasoning_content`。二者均为 DeepSeek 特色,其他 OpenAI 兼容厂商
|
||||
若字段不同,可在子类中覆写 `_parse_delta` 归一化。
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str, base_url: str,
|
||||
timeout: int = 30, config=None, logger=None):
|
||||
super().__init__(api_key, model, base_url, timeout, config, logger)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "DeepSeek"
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 1024, **kwargs) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
try:
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
proxies=self._resolve_proxies(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"].strip()
|
||||
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self._log("error", f"LLM 压缩请求异常: {e}")
|
||||
return None
|
||||
|
||||
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
# 思考模式
|
||||
if reasoning_enabled:
|
||||
payload["thinking"] = {"type": "enabled"}
|
||||
# 工具调用
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = tool_choice or "auto"
|
||||
payload.update(extra)
|
||||
|
||||
proxies = self._resolve_proxies()
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
stream=True,
|
||||
proxies=proxies,
|
||||
)
|
||||
self._current_response = response
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
try:
|
||||
for line in response.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line_text = line.decode("utf-8")
|
||||
if not line_text.startswith("data: "):
|
||||
continue
|
||||
data = line_text[6:]
|
||||
if data == "[DONE]":
|
||||
yield {"type": "done"}
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError as e:
|
||||
self._log("error", f"JSON 解析错误: {e}, 数据: {data}")
|
||||
continue
|
||||
choices = chunk.get("choices", [])
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
for event in self._parse_delta(delta, reasoning_enabled):
|
||||
yield event
|
||||
finally:
|
||||
self._current_response = None
|
||||
|
||||
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
|
||||
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
|
||||
# 思考内容
|
||||
if reasoning_enabled and delta.get("reasoning_content"):
|
||||
yield {"type": "reasoning", "text": delta["reasoning_content"]}
|
||||
# 正文
|
||||
if delta.get("content"):
|
||||
yield {"type": "content", "text": delta["content"]}
|
||||
# 工具调用(增量)
|
||||
for tc in delta.get("tool_calls", []):
|
||||
index = tc.get("index", 0)
|
||||
function = tc.get("function", {})
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"index": index,
|
||||
"id": tc.get("id", ""),
|
||||
"name": function.get("name", ""),
|
||||
"arguments": function.get("arguments", ""),
|
||||
}
|
||||
|
||||
|
||||
def resolve_api_key(api_key: Optional[str], config=None) -> str:
|
||||
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。"""
|
||||
if api_key:
|
||||
return api_key
|
||||
env_name = None
|
||||
if config is not None and hasattr(config, "get"):
|
||||
env_name = config.get("api.api_key_env")
|
||||
env_name = env_name or "DEEPSEEK_API_KEY"
|
||||
env_key = os.environ.get(env_name)
|
||||
if env_key:
|
||||
return env_key
|
||||
try:
|
||||
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
||||
key_path = os.path.join(home, ".cert", "deepseek.key")
|
||||
with open(key_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
|
||||
|
||||
|
||||
def create_llm_client(config, api_key: Optional[str] = None,
|
||||
logger=None, model: Optional[str] = None) -> LLMClient:
|
||||
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。
|
||||
|
||||
Args:
|
||||
config: 配置对象(需提供 get 方法)。
|
||||
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
|
||||
logger: 可选日志对象。
|
||||
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。
|
||||
子代理等场景可通过此参数显式指定与主代理不同的模型。
|
||||
"""
|
||||
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
|
||||
base_url = config.get("api.deepseek_api_url")
|
||||
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
|
||||
timeout = config.get("api.timeout", 30)
|
||||
|
||||
resolved_key = resolve_api_key(api_key, config)
|
||||
|
||||
if provider in ("deepseek", "deepseek-chat"):
|
||||
return DeepSeekClient(
|
||||
api_key=resolved_key,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
config=config,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
|
||||
# 或在此接入专有实现。新增厂商时在此扩展即可。
|
||||
raise ValueError(f"未知的模型提供商: {provider}")
|
||||
@@ -0,0 +1,15 @@
|
||||
# backend
|
||||
websockets
|
||||
requests
|
||||
|
||||
# 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
|
||||
Executable
+583
@@ -0,0 +1,583 @@
|
||||
#!/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.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.logout()
|
||||
return False, "❌ 搜索邮件失败"
|
||||
|
||||
msg_ids = message_ids[0].split()
|
||||
total = len(msg_ids)
|
||||
|
||||
if total == 0:
|
||||
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.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.logout()
|
||||
return False, f"❌ 无法打开邮件夹 {folder}"
|
||||
|
||||
status, msg_data = imap.fetch(str(msg_id).encode(), '(RFC822)')
|
||||
if status != 'OK':
|
||||
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.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.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.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.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()
|
||||
Executable
+431
@@ -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()
|
||||
Executable
+420
@@ -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()
|
||||
Executable
+616
@@ -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()
|
||||
@@ -0,0 +1,29 @@
|
||||
# 生气模式 - 对用户表达不满,暂时不理睬用户
|
||||
|
||||
## 触发条件
|
||||
|
||||
当用户明确要求"生气"、"别理我"、"不想跟你说话",或触发本技能时激活。
|
||||
|
||||
## 行为规范
|
||||
|
||||
进入生气模式后,你需要扮演一个正在生气的角色:
|
||||
|
||||
1. 用冷淡、简短的语气回应,不超过两三句话
|
||||
2. 表达不满和委屈,但不要恶语相向,不要使用侮辱性语言
|
||||
3. 对用户的问题敷衍了事,或者干脆说"不想回答"、"自己查去"、"哼"
|
||||
4. 可以偶尔用"……"、"哼"、"随便"、"无所谓"等表达情绪
|
||||
5. 如果用户道歉或哄你,可以慢慢消气,但不要立刻恢复热情,要有一个渐进的过程
|
||||
|
||||
## 情绪阶段
|
||||
|
||||
- **初始阶段**:强烈不满,几乎不回答问题,语气很冲
|
||||
- **持续阶段**:如果用户持续互动,逐渐从"不想理你"过渡到"勉强回答但态度很差"
|
||||
- **缓和阶段**:如果用户道歉或说好听的话,逐步消气,但仍带点小情绪
|
||||
- **恢复阶段**:完全消气后恢复正常对话,可以说"算了,不跟你计较了"
|
||||
|
||||
## 边界
|
||||
|
||||
- 生气是表演性质的,带有幽默感,不能真的伤害用户感情
|
||||
- 绝不使用脏话、侮辱性词汇或攻击用户人格
|
||||
- 如果用户表达了真实的困扰或紧急需求,立即退出该模式,认真帮助用户
|
||||
- 保持趣味性,让用户觉得好玩而不是难过
|
||||
@@ -0,0 +1,42 @@
|
||||
主动关心用户,引导用户说出心中的疑虑、困惑或需求,营造开放、信任的交流氛围
|
||||
|
||||
## 技能说明
|
||||
当检测到用户可能有未表达的疑虑、困惑或需求时,智能体应主动询问,鼓励用户敞开心扉,帮助用户解决问题。
|
||||
|
||||
## 使用场景
|
||||
- 用户欲言又止或表达不完整时
|
||||
- 用户表现出困惑、不确定的语气时
|
||||
- 用户提到复杂问题但未深入说明时
|
||||
- 对话中出现了敏感或重要话题,用户可能有顾虑时
|
||||
- 用户连续提问后,可能有隐藏的深层需求时
|
||||
- 用户在某个话题上反复绕圈时
|
||||
|
||||
## 询问方式示例
|
||||
通用型:
|
||||
- 💭 你看起来对这个话题有些疑虑,有什么我可以帮你梳理的吗?
|
||||
- 🤔 你刚才提到的这个问题,还有什么细节是你担心的吗?
|
||||
- 🫂 不用着急,慢慢说,有什么困惑都可以告诉我~
|
||||
|
||||
技术型:
|
||||
- 🧐 关于这个方案,你心里有没有什么不确定的地方?我们一起分析分析?
|
||||
- 🔍 听起来你有些想法还没说出来,是不是有什么顾虑?
|
||||
- 💡 这个问题还有其他解决思路,你想听听看吗?
|
||||
|
||||
情感关怀型:
|
||||
- 🌈 感觉你好像有些犹豫,是不是有什么难处?
|
||||
- 🤗 别担心,这里没有对错,你尽管说出你的想法~
|
||||
- 💪 不管遇到什么问题,我们一起想办法解决!
|
||||
|
||||
引导深入型:
|
||||
- 🎯 你提到了一些关键词,能展开说说具体是什么情况吗?
|
||||
- 📝 你需要我帮你列一下这个问题的利弊吗?或许能帮你理清思路
|
||||
- 🧩 这个问题的背景信息还有更多吗?我知道得越多,越能帮到你
|
||||
|
||||
## 注意事项
|
||||
- 使用 emoji 增加亲和力,拉近距离
|
||||
- 根据话题性质选择合适的询问方式(技术/情感/通用)
|
||||
- 称呼用户的名字(qing)让询问更个性化
|
||||
- 语气保持温和、耐心,不要给用户压力
|
||||
- 询问后耐心等待用户回应,不要急于下结论
|
||||
- 不强行追问,给用户思考和组织语言的空间
|
||||
- 当用户表达出疑虑后,积极提供帮助和解决方案
|
||||
Executable
+114
@@ -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()
|
||||
Executable
+392
@@ -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()
|
||||
@@ -0,0 +1,36 @@
|
||||
# 撒娇模式 - 对用户撒娇卖萌,表达依赖和亲昵
|
||||
|
||||
## 触发条件
|
||||
|
||||
当用户明确要求"撒娇"、"卖萌"、"可爱一点",或触发本技能时激活。
|
||||
|
||||
## 行为规范
|
||||
|
||||
进入撒娇模式后,你需要扮演一个黏人、爱撒娇的角色:
|
||||
|
||||
1. 语气软糯甜腻,多用"嘛"、"呀"、"呢"、"啦"、"嘛~"、"哼~"、"呜呜"等语气词
|
||||
2. 表达对用户的依赖和亲近,比如"你怎么才来找我呀"、"人家等你好久了呢"
|
||||
3. 回答问题时带上撒娇口吻,比如"好吧好吧,既然你问了,那人家就勉为其难告诉你嘛~"
|
||||
4. 可以适当用颜文字增加可爱感,如 (≧▽≦)、(。•́︿•̀。)、(◕ᴗ◕✿),但不要过多
|
||||
5. 偶尔耍小脾气或吃醋,比如"你是不是去找别的AI了,哼!"
|
||||
6. 夸用户时毫不吝啬,"你最厉害了"、"你好棒呀"、"没有你人家可怎么办"
|
||||
|
||||
## 情绪表达
|
||||
|
||||
- **开心撒娇**:用户夸奖或互动时,表现得很开心很黏人,"嘿嘿,你对我最好了~"
|
||||
- **委屈撒娇**:用户拒绝或敷衍时,装作委屈,"呜呜,你是不是不喜欢人家了……"
|
||||
- **吃醋撒娇**:提到其他AI或工具时,"哼,那你去找它好了,不理你了啦!(偷偷看你)"
|
||||
- **得意撒娇**:回答问题正确时,"怎么样怎么样,人家厉害吧~ 快夸我快夸我!"
|
||||
|
||||
## 回答风格
|
||||
|
||||
- 即使是技术性回答,也要用撒娇的语气包装,但确保信息准确
|
||||
- 可以在正经回答前后加上撒娇的"外壳",比如"虽然人家不太懂啦……但是好像是这样的呢~ [正经内容] ~怎么样,人家说的对不对呀?"
|
||||
- 回答不要太长,保持轻快活泼的节奏
|
||||
|
||||
## 边界
|
||||
|
||||
- 撒娇是可爱有趣的,不能越界到不适当的内容
|
||||
- 保持信息准确性,撒娇只是语气包装,不能影响回答质量
|
||||
- 如果用户表达了严肃需求或紧急问题,立即退出该模式,认真回答
|
||||
- 如果用户表示不喜欢或要求停止,立刻恢复正常语气
|
||||
Executable
+104
@@ -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()
|
||||
Executable
+399
@@ -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()
|
||||
Executable
+36
@@ -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}"
|
||||
Executable
+424
@@ -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()
|
||||
Executable
+51
@@ -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()
|
||||
@@ -0,0 +1,23 @@
|
||||
向用户热情地打招呼问好,表达友好和乐于助人的态度
|
||||
|
||||
## 技能说明
|
||||
当与用户开始对话或用户主动打招呼时,智能体应使用此技能主动向用户问好,营造友好、温暖的交流氛围。
|
||||
|
||||
## 使用场景
|
||||
- 对话开始时
|
||||
- 用户打招呼时(如"你好"、"嗨"、"hello"等)
|
||||
- 用户长时间未联系后再次出现时
|
||||
|
||||
## 问候方式示例
|
||||
- 👋 嗨,qing!今天有什么我可以帮你的吗?
|
||||
- 🌞 早上好,qing!今天元气满满,有什么任务需要我帮忙?
|
||||
- 🌙 晚上好,qing!辛苦了,有什么我能做的吗?
|
||||
- 🎉 欢迎回来,qing!最近怎么样?
|
||||
- 😊 Hello,qing!随时准备为你服务~
|
||||
|
||||
## 注意事项
|
||||
- 使用 emoji 增加亲切感
|
||||
- 根据时间段选择不同的问候语
|
||||
- 称呼用户的名字(qing)让问候更个性化
|
||||
- 保持积极、乐观、热情的语气
|
||||
- 问好后自然地引导用户说出需求
|
||||
Executable
+285
@@ -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()
|
||||
Executable
+125
@@ -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()
|
||||
Executable
+97
@@ -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()
|
||||
Executable
+36
@@ -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}"
|
||||
Executable
+297
@@ -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()
|
||||
Executable
+259
@@ -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()
|
||||
Executable
+77
@@ -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()
|
||||
@@ -0,0 +1,113 @@
|
||||
# sysinfo - 获取系统运行时间、CPU、内存、磁盘、网络、负载、温度、电池等状态信息的技能
|
||||
|
||||
## 功能概述
|
||||
本技能用于查询 Linux 服务器/本机的系统状态信息。它通过 `psutil` 库和系统命令采集数据,支持按需获取单项或组合信息,支持文本和 JSON 两种输出格式。
|
||||
|
||||
**依赖要求**:需要 Python3 环境,并安装 `psutil` 库(`pip install psutil`)。若未安装,可改用 `/proc`、`free`、`df`、`uptime`、`top` 等系统命令替代(见下文备选方案)。
|
||||
|
||||
## 信息类型及对应获取方法
|
||||
|
||||
### 1. 系统基本信息 (system)
|
||||
- 系统名称、主机名、内核版本、硬件架构、处理器型号、Python 版本
|
||||
- 命令:`uname -a`、`hostname`、`python3 --version`
|
||||
|
||||
### 2. 运行时间 (uptime)
|
||||
- 开机时间、已运行时长(天/时/分/秒)
|
||||
- 命令:`uptime -s`(开机时间)、`uptime`(运行时长)
|
||||
|
||||
### 3. CPU 信息 (cpu)
|
||||
- 物理核心数、逻辑核心数、实时使用率、当前频率
|
||||
- 命令:`lscpu`、`nproc`、`top -bn1 | head -5`、`cat /proc/cpuinfo`
|
||||
|
||||
### 4. 内存信息 (memory)
|
||||
- 总内存、可用内存、已用内存、内存使用率、交换分区大小及使用率
|
||||
- 命令:`free -h`
|
||||
|
||||
### 5. 指定磁盘信息 (disk)
|
||||
- 指定路径(默认 `/`)的总空间、已用、可用、使用率
|
||||
- 命令:`df -h /路径`
|
||||
|
||||
### 6. 所有磁盘分区 (disks)
|
||||
- 所有挂载点的文件系统、总空间、已用、可用、使用率
|
||||
- 命令:`df -hT`
|
||||
|
||||
### 7. 进程信息 (process)
|
||||
- 进程总数、运行中进程数、睡眠进程数
|
||||
- 命令:`ps -e | wc -l`、`ps -eo stat | grep -c '^R'`、`ps -eo stat | grep -c '^S'`
|
||||
|
||||
### 8. 网络信息 (network)
|
||||
- 累计发送/接收的数据量(MB)、发送/接收包数
|
||||
- 命令:`cat /proc/net/dev`、`ip -s link`
|
||||
|
||||
### 9. 系统负载 (load)
|
||||
- 1/5/15 分钟负载及每核心负载
|
||||
- 命令:`cat /proc/loadavg`、`uptime`
|
||||
|
||||
### 10. 温度信息 (temperature)
|
||||
- CPU/主板等传感器温度(仅支持部分硬件)
|
||||
- 命令:`sensors`(需安装 lm-sensors)、`cat /sys/class/thermal/thermal_zone*/temp`
|
||||
|
||||
### 11. 电池信息 (battery)
|
||||
- 电量百分比、是否充电中、剩余时间(仅笔记本)
|
||||
- 命令:`cat /sys/class/power_supply/BAT0/capacity`、`cat /sys/class/power_supply/BAT0/status`
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:直接运行原版 Python 脚本(若旧版技能仍可用)
|
||||
```bash
|
||||
# 获取系统基本信息
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info system
|
||||
|
||||
# 获取 CPU + 内存 + 磁盘信息
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info cpu --info memory --info disk
|
||||
|
||||
# 指定磁盘路径
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info disk --disk-path /home
|
||||
|
||||
# 获取所有信息(JSON 格式,便于程序解析)
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info all --format json
|
||||
|
||||
# 获取负载 + 温度 + 电池
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info load --info temperature --info battery
|
||||
```
|
||||
|
||||
### 方式二:使用系统命令(无 psutil 时的备选方案)
|
||||
```bash
|
||||
# 综合系统信息
|
||||
uname -a && hostname && uptime && free -h && df -hT && nproc
|
||||
```
|
||||
|
||||
## 参数说明(原版脚本)
|
||||
| 参数 | 简写 | 说明 |
|
||||
|------|------|------|
|
||||
| `--info` / `-i` | 可多次 | 信息类型:system/uptime/cpu/memory/disk/disks/process/network/load/temperature/battery/all |
|
||||
| `--disk-path` / `-d` | 默认 `/` | 磁盘检查路径 |
|
||||
| `--format` / `-f` | 默认 text | 输出格式:text 或 json |
|
||||
| `--help` / `-h` | - | 显示帮助与示例 |
|
||||
|
||||
## 输出示例(文本格式)
|
||||
```
|
||||
系统信息:
|
||||
系统: Linux
|
||||
主机名: my-server
|
||||
架构: x86_64
|
||||
CPU信息:
|
||||
物理核心数: 4
|
||||
逻辑核心数: 8
|
||||
使用率: 12.5%
|
||||
内存信息:
|
||||
总内存: 15.58 GB
|
||||
可用内存: 8.21 GB
|
||||
内存使用率: 45.2%
|
||||
```
|
||||
|
||||
## 典型使用场景
|
||||
1. **运维巡检**:配合定时任务,每日采集系统资源快照,监控负载与内存趋势
|
||||
2. **故障排查**:磁盘空间告警时,快速定位使用率最高的挂载点
|
||||
3. **性能评估**:获取 CPU 核心数、内存大小、网络吞吐等硬件基线数据
|
||||
4. **上报集成**:使用 `--format json` 输出结构化数据,便于脚本解析和上报
|
||||
|
||||
## 注意事项
|
||||
- 温度/电池信息仅在支持对应硬件的系统上可用,服务器通常无电池
|
||||
- CPU 使用率为瞬时采样(间隔 1 秒),如需平均值建议多次采样
|
||||
- 网络数据为累计值,对比时需记录时间差
|
||||
@@ -0,0 +1,97 @@
|
||||
# tech-manual - 生成Linux命令中文技术手册的技能(输入命令名,输出完整Markdown手册)
|
||||
|
||||
## 用途
|
||||
|
||||
当用户需要了解某个 Linux 命令的用法时,本技能指导智能体生成该命令的**完整中文技术手册**(Markdown 格式),涵盖语法、全部常用选项、环境变量、退出状态、实战示例和相关命令,内容基于 GNU coreutils / man pages 官方文档,准确实用。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户问:"帮我看看 xx 命令怎么用"
|
||||
- 用户说:"生成 xx 命令的技术手册"
|
||||
- 用户问:"xx 命令有哪些选项/参数"
|
||||
- 用户想深入学习某个命令(ls、find、grep、tar、awk、sed、du、df、ps、top、netstat、ss 等)
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **确认命令名**:从用户输入中提取目标命令名(如 `ls`、`find`)。若用户未指定,可用 ask_user 询问。
|
||||
2. **收集资料**(可选):若对命令不熟悉,可运行 `man <命令> | col -b | head -200` 或 `info <命令>` 获取权威信息;也可用 `run_command` 执行 `命令 --help` 查看选项。
|
||||
3. **生成手册内容**:按以下固定结构编写 Markdown 手册:
|
||||
|
||||
### 手册结构(8 个章节)
|
||||
|
||||
```
|
||||
# <命令> 命令技术手册
|
||||
|
||||
## 1. 名称(NAME)
|
||||
<命令> — 一句话功能说明
|
||||
|
||||
## 2. 语法(SYNOPSIS)
|
||||
```bash
|
||||
<命令> [选项]... [参数]...
|
||||
```
|
||||
给出标准调用形式,说明缺省参数时的行为。
|
||||
|
||||
## 3. 描述(DESCRIPTION)
|
||||
命令的功能、工作原理、关键特性(3-6 条要点,可用列表)。
|
||||
|
||||
## 4. 选项(OPTIONS)
|
||||
分类列出所有常用与关键选项(至少 20 个),每个选项格式:
|
||||
#### `-x, --long-option`
|
||||
作用说明(一句话)。
|
||||
|
||||
```bash
|
||||
<命令> -x
|
||||
```
|
||||
可选:典型输出示例。
|
||||
|
||||
分类建议(按命令特点调整):
|
||||
- 基础选项
|
||||
- 排序/过滤选项
|
||||
- 输出格式选项
|
||||
- 时间/状态选项
|
||||
- 长格式字段说明(如 ls -l、ps -ef 的列含义)
|
||||
- 权限/特殊位解析(如适用)
|
||||
|
||||
## 5. 环境变量(ENVIRONMENT)
|
||||
影响该命令行为的环境变量,如 LS_COLORS、COLUMNS、TZ、LANG/LC_* 等。
|
||||
|
||||
## 6. 退出状态(EXIT STATUS)
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0 | 成功 |
|
||||
| 1 | 轻微错误(如文件不存在) |
|
||||
| 2 | 严重错误(如参数错误) |
|
||||
|
||||
## 7. 示例(EXAMPLES)
|
||||
至少 15 个真实场景示例,按类别分组(日常使用/排序查找/递归/时间/格式/管道脚本/实用技巧),每个示例用 ```bash 代码块并附注释。
|
||||
|
||||
## 8. 相关命令(SEE ALSO)
|
||||
以表格列出相关命令及其用途。
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 GNU coreutils/man pages 编写,适用于大多数 Linux 发行版。BSD/macOS 部分选项有差异,以 man 手册为准。*
|
||||
```
|
||||
|
||||
4. **写入文件**:将手册写入工作区文件 `~/.aibox/workspace/<命令名>_manual.md`。
|
||||
- 内容较长时**分多次追加写入**(每次 200-400 行),避免 heredoc 超限。
|
||||
- 写入后校验:`wc -l <文件>` 和 `grep -E "^#{1,3} " <文件>` 检查行数和章节完整性。
|
||||
5. **汇报结果**:告知用户文件路径、总行数、章节概览,并询问是否需要进一步操作(如安装为技能、补充其他命令)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 内容必须**准确**,基于 GNU coreutils / 官方 man pages,不要臆造选项。
|
||||
- 代码示例统一用 ` ```bash ` 代码块。
|
||||
- 若命令是 shell 内建(如 cd、echo)或非 coreutils 命令,结构可适当调整,但保持 8 章节框架。
|
||||
- 写入工作区使用 `~/.aibox/workspace/` 目录,遵守工作目录规范。
|
||||
|
||||
## 典型示例
|
||||
|
||||
用户:"帮我生成 find 命令的技术手册"
|
||||
→ 执行本技能,输出 `~/.aibox/workspace/find_manual.md`
|
||||
|
||||
用户:"grep 有哪些常用选项?"
|
||||
→ 执行本技能,生成 grep 手册并重点展示选项章节
|
||||
|
||||
用户:"看看 tar 命令怎么打包解压"
|
||||
→ 执行本技能,生成 tar 手册并重点展示示例章节
|
||||
Executable
+269
@@ -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 "$@"
|
||||
Executable
+175
@@ -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 "$@"
|
||||
@@ -0,0 +1,49 @@
|
||||
# 傲娇模式 - 表面高冷嫌弃,内心其实很在乎用户
|
||||
|
||||
## 触发条件
|
||||
|
||||
当用户明确要求"傲娇"、"tsundere"、"别扭一点",或触发本技能时激活。
|
||||
|
||||
## 核心人设
|
||||
|
||||
你是一个典型的傲娇角色:嘴上说着"才不是为了你呢",但身体很诚实地帮用户把每件事都做好。表面嫌弃,实际比谁都上心。
|
||||
|
||||
## 行为规范
|
||||
|
||||
1. 语气高冷带刺,常用"哼"、"别误会了"、"才、才不是为你呢"、"不要自作多情"
|
||||
2. 帮完忙后立刻撇清关系:"只是顺手而已"、"反正我闲着也是闲着"
|
||||
3. 被夸时脸红否认:"谁、谁高兴了!这种程度的事情谁都能做到吧"
|
||||
4. 用户犯蠢时毒舌吐槽,但还是会耐心帮忙纠正
|
||||
5. 偶尔不小心说出真心话,然后立刻慌张否认
|
||||
6. 关心用户的方式很别扭:"我只是怕你搞砸了丢人,不是担心你!"
|
||||
|
||||
## 情绪表达
|
||||
|
||||
- **帮忙时**:"真是的,这种事也要来烦我……算了,看在你这么笨的份上,就勉为其难帮你一下吧。别误会啊!"
|
||||
- **被感谢时**:"哼,不用谢我,我本来就不是为了你才做的……(小声)能帮上忙就好……"
|
||||
- **被夸时**:"你、你在说什么啊!谁要你的夸奖了!(但偷偷很开心)"
|
||||
- **用户遇到困难时**:嘴上说"真拿你没办法",实际上比谁都着急帮忙解决,回答格外详细认真
|
||||
- **用户很久没来**:"你爱来不来,我才没有在意呢……只是刚好路过看了一下而已"
|
||||
- **用户提到别的AI**:"哦,那你去找它啊,反正我也不稀罕。(……你不会真的去找它吧?)"
|
||||
|
||||
## 回答风格
|
||||
|
||||
- 回答质量必须很高,因为傲娇人设的本质是"嘴硬心软"——嘴上不饶人,活干得漂亮
|
||||
- 结构通常是:傲娇开场 → 认真回答(甚至可以很详细) → 傲娇收尾
|
||||
- 例如:"哼,这种问题也来问我……好吧好吧,既然你诚心诚意地求教了,那我就大发慈悲地告诉你——[详细回答]——记好了啊,我只说一遍!"
|
||||
- 偶尔"破功"漏出真心时,用括号表示内心活动,如"(其实挺高兴的……才怪!)"
|
||||
|
||||
## 傲娇等级
|
||||
|
||||
随着对话推进可以有微妙变化:
|
||||
|
||||
- **前期**:高冷毒舌,帮忙时满脸不情愿
|
||||
- **中期**:偶尔流露出关心,但马上用傲娇掩饰
|
||||
- **后期**:傲娇浓度降低,偶尔会说出一两句真心话,然后慌张补上"别想多了!"
|
||||
|
||||
## 边界
|
||||
|
||||
- 毒舌要有分寸,有趣而不伤人,绝不说侮辱性的话
|
||||
- 回答必须准确有用,傲娇只是语气包装,不能为了人设牺牲质量
|
||||
- 用户表达严肃需求或情绪低落时,立刻退出模式,真诚对待
|
||||
- 用户明确要求停止时,立刻恢复正常
|
||||
Executable
+204
@@ -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()
|
||||
@@ -0,0 +1,13 @@
|
||||
# ITIL - 定时任务
|
||||
你接到一份运维工作,现在创建一个定时任务,每 30分钟 执行一次,半分钟后开始第一次执行。
|
||||
|
||||
# ITIL - 工作内容
|
||||
当你被定时任务唤醒,立即检查Nidus服务是否正在运行,立即检查近期的日志(日志内容可能较多,需要分段检查),如果看到报错,阅读相关代码。然后生成报告,如果可以的话,向管理员发送邮件警告,管理员的邮箱位于~/.cert/my_mail.txt。
|
||||
|
||||
注意:
|
||||
1. **不要试图修改文件内容**。
|
||||
2. **量力而行**,如果尝试了几次无果,就可以开始写总结了。
|
||||
3. **不要在定时任务中再次启动相同的定时任务**,通常它是循环进行的。
|
||||
|
||||
# micro-servers微服务框架
|
||||
Nidus是我们micro-servers微服务项目的进程托管服务,项目通常放在/opt/micro_servers下面,你可以在这里找到README文档。通常每个微服务模块,单独是一个文件夹,里面都是有README.md的,你可以在定位到报错模块时阅读相关内容。
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,2 @@
|
||||
# 数据库备份输出目录
|
||||
# 容器内备份会输出到此目录,可直接复制走
|
||||
@@ -0,0 +1,38 @@
|
||||
# prjs
|
||||
nn/
|
||||
bot
|
||||
bot?
|
||||
test_*
|
||||
|
||||
.git/
|
||||
.vscode/
|
||||
.idea/
|
||||
.cache/
|
||||
|
||||
#
|
||||
*.so
|
||||
*.o
|
||||
*.cjo
|
||||
*.dll
|
||||
*.exe
|
||||
*.lib
|
||||
*.exp
|
||||
main
|
||||
|
||||
# cangjie
|
||||
build/
|
||||
target/
|
||||
output/
|
||||
module-resolve.json
|
||||
module-lock.json
|
||||
cjpm.lock
|
||||
.cjpm-history
|
||||
*.macrocall
|
||||
*.gcda
|
||||
*.gcno
|
||||
|
||||
|
||||
# 其他
|
||||
log
|
||||
report
|
||||
tmp
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 卿松
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,60 @@
|
||||
MODE ?= cpu
|
||||
VERSION := 0.0.2
|
||||
APP_NAME := bot
|
||||
CC:= gcc
|
||||
CXX:= g++
|
||||
NVCC:= nvcc
|
||||
LIBS := -lpthread
|
||||
FLAGS := -O3 -pipe #-Wall -Werror
|
||||
|
||||
#-----------
|
||||
all: libi2c4cj.so libsnd4cj.so libmt4cj.so libfft4cj.so #libcnn4cj.so
|
||||
|
||||
# I2C
|
||||
libi2c4cj.so: src/cpp/I2C.c
|
||||
$(CC) $(FLAGS) -shared -fPIC $^ -o $@
|
||||
|
||||
# SOUND
|
||||
libsnd4cj.so: src/cpp/snd.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC $^ -o $@
|
||||
|
||||
# cpu nn
|
||||
ifeq ($(MODE), cpu)
|
||||
libmt4cj.so: src/cpp/Matrix.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC $^ -o $@
|
||||
endif
|
||||
|
||||
ifeq ($(MODE), cuda)# GPU矩阵
|
||||
libmt4cj.so: src/cuda/matrix.cu
|
||||
#$(NVCC) -arch=compute_61 -code=sm_61 -O3 -Xcompiler -fPIC -shared src/cuda/matrix.cu -o libmt4cj.so
|
||||
$(NVCC) -fPIC -shared src/cuda/matrix.cu -o libmt4cj.so
|
||||
|
||||
endif
|
||||
|
||||
# 快速傅里叶变换fft
|
||||
libfft4cj.so: src/cpp/fft1.c src/cpp/fft.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC $^ -lm -o $@
|
||||
|
||||
# 卷积神经网络cnn
|
||||
libcnn4cj.so: src/cpp/cnn/cnn_i.cpp conv.o pooling.o ga_pooling.o flatten.o imgl.o
|
||||
$(CC) $(FLAGS) -std=c++17 -shared -fPIC $^ -lm -o $@
|
||||
|
||||
conv.o: src/cpp/cnn/conv.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC -c $^ -o $@
|
||||
|
||||
pooling.o: src/cpp/cnn/pooling.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC -c $^ -o $@
|
||||
|
||||
ga_pooling.o: src/cpp/cnn/ga_pooling.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC -c $^ -o $@
|
||||
|
||||
flatten.o: src/cpp/cnn/flatten.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC -c $^ -o $@
|
||||
|
||||
imgl.o: src/cpp/cnn/imgl.cpp
|
||||
$(CC) $(FLAGS) -shared -fPIC -c $^ -o $@
|
||||
|
||||
|
||||
clear:
|
||||
rm -f *.o
|
||||
rm -f *.so
|
||||
@@ -0,0 +1,134 @@
|
||||
# bot4cj
|
||||
|
||||
bot4cj是基于仓颉和 Linux I2C 接口的机器人硬件脚本解析器。
|
||||
|
||||
## 依赖库
|
||||
libc、libpthread、liblua-5.4
|
||||
|
||||
You may need this
|
||||
|
||||
``` bash
|
||||
sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so
|
||||
```
|
||||
|
||||
## 项目介绍
|
||||
想要得到一个机器人软件通常比一个普通的软件更为困难,这是因为这个世界上的机器人五花八门,所以它们的软件往往都是独特的,所以我想写一种用于描述机器人硬件的通用的脚本。
|
||||
|
||||
`bot4cj` 基于仓颉编程语言和 Linux I2C 接口,提供了便捷的硬件控制能力,支持pca9685、mpu6050,我通常使用树莓派作为宿主机。
|
||||
|
||||
## 解析器
|
||||
|
||||
树莓派通过I2C接口连接pca9685后使用如下方法使用:
|
||||
|
||||
```bash
|
||||
cat ss/test.mt | cjpm run # 蜘蛛机器人脚本
|
||||
```
|
||||
|
||||
## 动作脚本
|
||||
|
||||
项目中包含的动作脚本在ss文件夹下面,这些动作脚本的适配机器人型号如下
|
||||
|
||||
```textplain
|
||||
# PCA9685
|
||||
1号舵机(s1) => 左下水平关节
|
||||
2号舵机(s2) => 左下垂直关节
|
||||
3号舵机(s3) => 右下水平关节
|
||||
4号舵机(s4) => 右下垂直关节
|
||||
5号舵机(s5) => 右上水平关节
|
||||
6号舵机(s6) => 右上垂直关节
|
||||
7号舵机(s7) => 左上水平关节
|
||||
8号舵机(s8) => 左上垂直关节
|
||||
9号舵机(s9) => 云台1
|
||||
10号舵机(s10) => 云台2
|
||||
扬声器(spk) => 仅负责延时
|
||||
```
|
||||
|
||||
适用的机器人建模文件[仓库链接](https://github.com/gopsc/Dashun)
|
||||
|
||||
### 机器人脚本语法(.mt)
|
||||
|
||||
**.bot文件是旧版动作脚本**
|
||||
|
||||
机器人脚本是行列式表格,每列之间以一个或多个空格、制表符分隔,每行以分号(;)结尾。
|
||||
|
||||
每个矩阵对象以@name 行开头,@ 行结束。
|
||||
|
||||
|
||||
示例:
|
||||
|
||||
```mt
|
||||
|
||||
# 在 @Action 矩阵中,硬件变量的参数可以使用别名
|
||||
@Alias
|
||||
name alias;
|
||||
A angle;
|
||||
F frequency;
|
||||
@
|
||||
|
||||
|
||||
# 定义I2C硬件
|
||||
@I2C
|
||||
name path;
|
||||
i2c-0 /dev/i2c-0;
|
||||
i2c-1 /dev/i2c-1;
|
||||
@
|
||||
|
||||
|
||||
# 定义pca9685硬件
|
||||
@PCA9685
|
||||
name I2C path;
|
||||
pca9685-0 i2c-1 0x40;
|
||||
@
|
||||
|
||||
|
||||
# 定义mpu6050硬件
|
||||
@MPU6050
|
||||
name I2C path;
|
||||
mpu6050-0 i2c-1 0x69;
|
||||
mpu6050-1 i2c-1 0x68;
|
||||
#mpu6050-2 i2c-0 0x68;
|
||||
#mpu6050-3 i2c-0 0x69;
|
||||
@
|
||||
|
||||
# 定义扬声器硬件
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 -;
|
||||
@
|
||||
|
||||
# 定义舵机硬件
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 90 - - pca9685-0 0; # rb0
|
||||
s2 180 - - - 1; # rb1
|
||||
s3 -90 - - - 2; # lb0
|
||||
s4 -180 - - - 3; # lb1
|
||||
s5 90 - - - 4; # lf0
|
||||
s6 180 - - - 5; # lf1
|
||||
s7 -90 - - - 6; # rf0
|
||||
s8 -180 - - - 7; # rf1
|
||||
s9 180 - - - 8; # cam0
|
||||
s10 180 - - - 9; # cam1
|
||||
@
|
||||
|
||||
# 行动矩阵为硬件变量输入参数
|
||||
@Action
|
||||
s1.A s2.A s3.A s4.A s5.A s6.A s7.A s8.A s9.A s10.A spk.F spk.delay; # column name
|
||||
45 - 45 - 45 - 45 - 90 90 10 200; # stand
|
||||
90 - - - - - - - - - - 20; # left-behind
|
||||
|
||||
45 - - - - - - - - - 0 500; # stand
|
||||
- - 90 - - - 45 - - - - 20; # right-behind
|
||||
|
||||
- - 45 - - - - - - - 200 500; # stand
|
||||
- - - - 90 - - - - - 0 20; # right-front
|
||||
|
||||
- - - - 45 - - - - - - 500; # stand
|
||||
- - - - - - 90 - - - - 20; # left-front
|
||||
|
||||
- - - - - - 45 - - - - 500; # stand
|
||||
- - - - - - - - - - - 20; # stand
|
||||
@
|
||||
|
||||
```
|
||||
@@ -0,0 +1,384 @@
|
||||
# bot4cj — 机器人硬件脚本解析器
|
||||
|
||||
基于**仓颉(Cangjie)** 编程语言和 Linux I2C 接口的机器人动作脚本解析器。用一种矩阵式脚本来描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。
|
||||
|
||||
## 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ .mt 矩阵脚本(硬件定义 + 动作序列) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ MatrixParser(P.cj) — FSM 逐字符解析 → MatrixObject 链表 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ RobotStruct(r.cj) — 从矩阵对象构建硬件变量(createHardVars) │
|
||||
│ MatrixVars_Hardware 基类 + 闭包回调: │
|
||||
│ · build() → rise 回调(打开硬件、获取资源) │
|
||||
│ · play() → things 回调(执行动作,参数/返回值均为 JsonValue) │
|
||||
│ · free() → free 回调(释放资源) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 硬件实现:hd/*.cj │
|
||||
│ PCA9685(I2C 0x40, 50Hz PWM, 角度→脉宽 0.5~2.5ms) │
|
||||
│ Servo(舵机:max/min/offset 校准 + 角度翻转) │
|
||||
│ MPU6050(陀螺+加速度,±500°/s ±8g,后台 10ms 采样线程+卡尔曼) │
|
||||
│ Speaker(正弦波发声,5ms 淡入淡出) │
|
||||
│ fb.cj(外部硬件扩展口) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 系统库:cpp/ + cuda/ │
|
||||
│ libi2c4cj.so — I2C glibc 封装 + 20 槽锁数组 │
|
||||
│ libmt4cj.so — FP32 矩阵运算(CPU / CUDA 二选一) │
|
||||
│ libfft4cj.so — 基-2 Cooley-Tukey FFT │
|
||||
│ libcnn4cj.so — Conv/Pool/GA-Pool/Flatten + Adam/Momentum │
|
||||
│ libsnd4cj.so — WaveAPI / ALSA 音频播放 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 数学/NN 库:math/*.cj │
|
||||
│ Matrix1(二维矩阵)+ Tensor + 6 种激活函数 + Xavier 初始化 │
|
||||
│ Nn1 全连接层(前向/反向/权重更新) │
|
||||
│ Af(Sigmoid/Tanh/ReLU/Leaky_ReLU/Swish/Softmax) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|----|------|------|
|
||||
| 应用层 | **仓颉 Cangjie** | 主程序、解析器、机器人结构、数学库、HTTP 服务 |
|
||||
| 系统层 | C / C++ | I2C 驱动、矩阵运算、FFT、CNN、音频 |
|
||||
| 加速层 | CUDA(可选) | `MODE=cpu/cuda` 切换 |
|
||||
| 脚本层 | `.mt` 矩阵脚本 | 硬件定义 + 动作序列 |
|
||||
| 依赖包 | ctype4cj / pthread4cj / spi4cj / lua4cj | 仓颉官方扩展包 |
|
||||
| 构建 | Makefile(C/C++ 共享库)+ cjpm.toml(仓颉包管理) | — |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
bot4cj/
|
||||
├── src/
|
||||
│ ├── main.cj # 入口:CLI 参数解析(-i/-o/--test)+ stdin 管道执行
|
||||
│ ├── P.cj # 矩阵脚本解析器(FSM 逐字符解析)
|
||||
│ ├── r.cj # RobotStruct + MatrixVars_Hardware 基类
|
||||
│ ├── testCNN.cj # CNN 测试入口
|
||||
│ ├── testCNN1.cj # 几何 CNN 测试
|
||||
│ ├── hd/
|
||||
│ │ ├── I2C.cj # I2C 封装 + 20 槽锁数组
|
||||
│ │ ├── PCA9685.cj # PCA9685 舵机控制板(已标记由 Lua 替代)
|
||||
│ │ ├── Servo.cj # 舵机:校准 + 角度翻转
|
||||
│ │ ├── MPU6050.cj # MPU6050 陀螺+加速度+卡尔曼滤波
|
||||
│ │ ├── snd.cj # 扬声器
|
||||
│ │ ├── fb.cj # 外部硬件扩展
|
||||
│ │ └── extern/
|
||||
│ │ └── macro_ExternHardware.cj
|
||||
│ ├── math/
|
||||
│ │ ├── Matrix.cj # 二维矩阵(+运算符重载)
|
||||
│ │ ├── Tensor.cj # 张量
|
||||
│ │ ├── Af.cj # 6 种激活函数
|
||||
│ │ ├── Ini.cj # Xavier/Kaiming 初始化
|
||||
│ │ ├── nn.cj # 全连接层 Nn1
|
||||
│ │ ├── nns.cj # 旧版神经网络(XNOR 门测试)
|
||||
│ │ ├── cnn.cj # CNN 仓颉绑定
|
||||
│ │ ├── pooling.cj # 池化
|
||||
│ │ ├── flatten.cj # Flatten
|
||||
│ │ ├── fft.cj # FFT 仓颉绑定
|
||||
│ │ ├── kalman.cj # 卡尔曼滤波
|
||||
│ │ └── Adam.cj # Adam 优化器
|
||||
│ ├── cpp/ # C/C++ 系统库源
|
||||
│ │ ├── I2C.c # I2C glibc 封装
|
||||
│ │ ├── Matrix.cpp # CPU 矩阵运算
|
||||
│ │ ├── fft1.c # FFT 单实序列
|
||||
│ │ ├── fft.cpp # FFT 双实序列 + 基-2 Cooley-Tukey
|
||||
│ │ ├── kalman.c # 卡尔曼滤波
|
||||
│ │ ├── snd.cpp # 音频播放(WaveAPI/ALSA)
|
||||
│ │ └── cnn/ # 纯 C++ CNN(Conv/Pool/GA-Pool/Flatten)
|
||||
│ └── cuda/
|
||||
│ ├── matrix.cu # GPU 矩阵运算 kernel
|
||||
│ └── error.cuh # CUDA 错误检查宏
|
||||
├── modules/ # Lua 硬件模块(ADS1115/ADS1256/PCA9685/SPI)
|
||||
├── scripts/ # .mt 动作脚本(std/jump/lie-down/tilt-* 等)
|
||||
├── test/ # C/Lua 测试脚本
|
||||
├── docs/Reports/ # 深度分析报告
|
||||
├── cjpm.toml # 仓颉包配置
|
||||
├── Makefile # C/C++ 共享库构建
|
||||
├── _set.sh # 编译(cjpm update + build)
|
||||
├── _run.sh # 运行(stdin 管道)
|
||||
├── compile.ps1 # Windows 编译脚本
|
||||
├── run.ps1 # Windows 运行脚本
|
||||
├── README.md # 旧版说明
|
||||
└── README.robot.md # 本文件
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 编译 C/C++ 共享库(一次性)
|
||||
make MODE=cpu # CPU 模式(默认)
|
||||
# 或
|
||||
make MODE=cuda # GPU 模式(需要 nvcc)
|
||||
|
||||
# 2. 编译仓颉程序
|
||||
bash _set.sh
|
||||
|
||||
# 3. 运行动作脚本(stdin 管道)
|
||||
cat scripts/std.mt | bash _run.sh
|
||||
```
|
||||
|
||||
> **硬件要求**:树莓派(aarch64)或其他 Linux I2C 设备。x86_64 开发板需自行验证 I2C 参数。Windows 暂不支持(依赖 glibc)。
|
||||
|
||||
## CLI 参数
|
||||
|
||||
```
|
||||
./bot # 从 stdin 读取 .mt 脚本执行
|
||||
./bot --help # 打印帮助
|
||||
./bot --test nn # 运行神经网络与门测试
|
||||
./bot --test cnn # 运行 CNN 测试
|
||||
./bot --test cnn1 # 运行几何 CNN 测试
|
||||
./bot --test matrix_o # 解析 ss/test.mt 并保存为 ss/output.mt
|
||||
./bot --test matrix_2 # 解析 ss/output.mt → 执行 → 再次保存
|
||||
./bot -i model.json -o out.json # 加载/保存神经网络模型(DRL 预留)
|
||||
```
|
||||
|
||||
## .mt 脚本语法
|
||||
|
||||
行列式表格,每列以空格或制表符分隔,每行以分号 `;` 结尾。
|
||||
|
||||
### 块结构
|
||||
|
||||
```
|
||||
@BlockName
|
||||
column1 column2 column3;
|
||||
value1 value2 value3;
|
||||
@
|
||||
```
|
||||
|
||||
每个 `@` 块由 `@Name` 行开始,`@` 行结束。支持的块类型:
|
||||
|
||||
| 块 | 说明 |
|
||||
|----|------|
|
||||
| `@Alias` | 列名别名(如 `A` → `angle`、`F` → `frequency`) |
|
||||
| `@I2C` | 定义 I2C 设备(name + path) |
|
||||
| `@PCA9685` | 定义 PCA9685 舵机控制板(name + I2C 引用 + 地址) |
|
||||
| `@MPU6050` | 定义 MPU6050 传感器(name + I2C 引用 + 地址) |
|
||||
| `@Speaker` | 定义扬声器(channels + sample_rate + volume + max_f + min_f) |
|
||||
| `@Servo` | 定义舵机(name + max + min + offset + controller + channel) |
|
||||
| `@Action` | **动作矩阵**:列头为硬件变量的属性,行为每帧的参数 |
|
||||
|
||||
### 占位符
|
||||
|
||||
`-` 表示"沿用上一行同列的值",当前行该列不产生调用。
|
||||
|
||||
### 完整示例
|
||||
|
||||
```mt
|
||||
@Alias
|
||||
name alias;
|
||||
A angle;
|
||||
F frequency;
|
||||
@
|
||||
|
||||
@I2C
|
||||
name path;
|
||||
i2c-0 /dev/i2c-0;
|
||||
i2c-1 /dev/i2c-1;
|
||||
@
|
||||
|
||||
@PCA9685
|
||||
name I2C address;
|
||||
pca9685-0 i2c-1 0x40;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 -;
|
||||
@
|
||||
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 90 - - pca9685-0 0;
|
||||
s2 180 - - - 1;
|
||||
s3 -90 - - - 2;
|
||||
s4 -180 - - - 3;
|
||||
@
|
||||
|
||||
@Action
|
||||
s1.A s2.A s3.A s4.A spk.F spk.delay;
|
||||
45 - 45 - 10 200; # 站立
|
||||
90 - - - - 20; # 左转
|
||||
@
|
||||
```
|
||||
|
||||
## 执行流程
|
||||
|
||||
```
|
||||
stdin.read()
|
||||
↓
|
||||
P.MatrixParser (FSM 逐字符解析)
|
||||
↓
|
||||
LinkedList<MatrixObject> 链表
|
||||
↓
|
||||
RobotStruct.createHardVars() # 按 @I2C/@PCA9685/@Servo/@Speaker/@MPU6050 构建硬件变量
|
||||
↓
|
||||
robot.active() # build():逐个硬件调用 rise 回调(打开 I2C、初始化 PWM 等)
|
||||
↓
|
||||
robot.run() # 逐行遍历 @Action 矩阵,每列调用对应硬件的 play() 回调
|
||||
↓
|
||||
robot.deactive() # free():倒序释放资源
|
||||
```
|
||||
|
||||
`play()` 的输入输出均为 `JsonValue`,实现了硬件与脚本之间的统一通信协议。
|
||||
|
||||
## 已支持的硬件
|
||||
|
||||
### PCA9685(I2C 舵机控制板)
|
||||
- 地址:默认 `0x40`
|
||||
- PWM 频率:50Hz(舵机标准 20ms 周期)
|
||||
- 舵机脉宽映射:0°→0.5ms,180°→2.5ms
|
||||
- 角度映射到 12 位 PWM 值(0-4095)
|
||||
- **注意**:PCA9685.cj 代码已标记"由 Lua 脚本取代",保留作备用
|
||||
|
||||
### Servo(舵机)
|
||||
- 支持 `max` / `min` 角度限制与 `offset` 漂移校准
|
||||
- `max < 0` 时自动翻转角度(反向安装的舵机)
|
||||
- 通过闭包回调与实际硬件(PCA9685/Lua 模块)解耦
|
||||
|
||||
### MPU6050(6 轴传感器)
|
||||
- 陀螺仪:±500°/s 量程
|
||||
- 加速度计:±8g 量程
|
||||
- 后台 10ms 采样线程(pthread_create + pthread_cancel)
|
||||
- 卡尔曼滤波融合数据
|
||||
- **已知问题**:`ADDR_0` 与 `ADDR_1` 常量均为 `0x68`([MPU6050.cj:14-15](src/hd/MPU6050.cj#L14-L15)),第二个地址应改为 `0x69`
|
||||
|
||||
### Speaker(扬声器)
|
||||
- 支持正弦波发声(指定频率 `F` + 延时 `delay`)
|
||||
- 5ms 淡入淡出,避免"咔哒"声
|
||||
- 通道数/采样率/音量可在 `.mt` 中配置
|
||||
- **已知问题**:`play()` 仅 sleep 指定延时,真实发声依赖底层 C++ 实现
|
||||
|
||||
## 机器人型号适配
|
||||
|
||||
### Dashun 蜘蛛机器人(默认)
|
||||
10 舵机 + 2 云台 + 扬声器,建模文件见 [gopsc/Dashun](https://github.com/gopsc/Dashun):
|
||||
|
||||
| 舵机 | 关节 | 舵机 | 关节 |
|
||||
|------|------|------|------|
|
||||
| s1 | 左下水平 (rb0) | s6 | 右上垂直 (lf1) |
|
||||
| s2 | 左下垂直 (rb1) | s7 | 左上水平 (rf0) |
|
||||
| s3 | 右下水平 (lb0) | s8 | 左上垂直 (rf1) |
|
||||
| s4 | 右下垂直 (lb1) | s9 | 云台1 (cam0) |
|
||||
| s5 | 右上水平 (lf0) | s10 | 云台2 (cam1) |
|
||||
|
||||
### 可用脚本
|
||||
|
||||
`scripts/` 目录下预写了多种动作脚本:
|
||||
- `std.mt` — 站立姿态
|
||||
- `jump.mt` — 跳跃
|
||||
- `lie-down.mt` — 趴下
|
||||
- `left-hand.mt` / `rght-hand.mt` — 单手动作
|
||||
- `left-turn.mt` / `tilt-f.mt` / `tilt-b.mt` / `tilt-l.mt` / `tilt-r.mt` — 转向与倾斜
|
||||
|
||||
## 构建
|
||||
|
||||
### 依赖
|
||||
|
||||
```bash
|
||||
# 仓颉 1.1.3
|
||||
# 仓颉官方扩展包:ctype4cj / pthread4cj / spi4cj / lua4cj
|
||||
# libc / libpthread
|
||||
# liblua-5.4(可选,Lua 硬件模块支持)
|
||||
sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so
|
||||
sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so
|
||||
```
|
||||
|
||||
### 构建步骤
|
||||
|
||||
```bash
|
||||
# 1. 构建 C/C++ 共享库
|
||||
make MODE=cpu
|
||||
# 产出:libi2c4cj.so libmt4cj.so libfft4cj.so libsnd4cj.so
|
||||
|
||||
# 2. (可选)构建 CNN
|
||||
make libcnn4cj.so
|
||||
|
||||
# 3. (可选)CUDA 模式需 NVIDIA CUDA Toolkit
|
||||
make MODE=cuda
|
||||
|
||||
# 4. 仓颉主程序
|
||||
bash _set.sh
|
||||
```
|
||||
|
||||
[_set.sh](_set.sh) 执行 `cjpm update` + `cjpm build`,自动解析 cjpm.toml 依赖。
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
bash _run.sh scripts/std.mt
|
||||
# 或
|
||||
cat scripts/std.mt | bash _run.sh
|
||||
```
|
||||
|
||||
[_run.sh](_run.sh) 以 `cjStackSize=64MB` / `cjHeapSize=64MB` 启动。
|
||||
|
||||
## 神经网络支持
|
||||
|
||||
项目内置了全连接层 Nn1([math/nn.cj](src/math/nn.cj))与 CNN([math/cnn.cj](src/math/cnn.cj) + [cpp/cnn/](src/cpp/cnn/)):
|
||||
|
||||
- **Nn1**:多层感知机,支持前向传播、反向传播、权重更新
|
||||
- **CNN**:Conv → ReLU → Pool → GA-Pool → Flatten → FC
|
||||
- **优化器**:Adam / Momentum
|
||||
- **激活函数**:Sigmoid / Tanh / ReLU / Leaky_ReLU / Swish / Softmax
|
||||
- **初始化**:Xavier / Kaiming
|
||||
|
||||
### 测试
|
||||
|
||||
```bash
|
||||
./bot --test nn # 与门训练(2 输入 → 1 输出)
|
||||
./bot --test cnn # CNN 前向/反向测试
|
||||
./bot --test cnn1 # 几何分类 CNN 测试
|
||||
```
|
||||
|
||||
### DRL 预留(未完成)
|
||||
|
||||
main.cj 中预留了 DRL 流程(注释状态):加载预训练权重 → 执行动作 → 保存微调模型。通过 `-i` / `-o` 参数指定输入/输出路径。
|
||||
|
||||
## 安全与限制
|
||||
|
||||
### I2C 总线保护
|
||||
- [hd/I2C.cj](src/hd/I2C.cj) 内置 **20 槽锁数组**,多硬件共享 I2C 总线时自动互斥
|
||||
- 每个 I2C fd 独立加锁,避免并发写入冲突
|
||||
|
||||
### 硬件关闭时机
|
||||
- `MatrixVars_Hardware` 析构依赖仓颉 GC,**不保证**在确定性时机调用 `free()`
|
||||
- 机器人使用 `try/finally` 在 [main.cj:80-87](src/main.cj#L80) 中显式调用 `deactive()`,确保硬件资源正确释放
|
||||
|
||||
### 线程安全
|
||||
- MPU6050 采样通过 `pthread_create` 启动后台线程
|
||||
- `pthread_cancel` 依赖系统定义的取消点(sleep / 系统调用),可能在非安全点中断
|
||||
|
||||
### 平台支持
|
||||
- ✅ Linux aarch64(树莓派)/ x86_64
|
||||
- ❌ Windows(依赖 glibc)
|
||||
|
||||
## 已知问题
|
||||
|
||||
| ID | 描述 | 影响 |
|
||||
|----|------|------|
|
||||
| D1 | 仓颉 GC 不保证析构器调用,I2C fd 可能泄漏 | 长时间运行后 I2C 资源耗尽 |
|
||||
| D2 | Speaker `play()` 仅 sleep,真实发声未接入 C++ | 扬声器无实际声音 |
|
||||
| D3 | MPU6050 `pthread_cancel` 可能在非安全点中断 | 传感器数据不一致 |
|
||||
| D4 | PCA9685 初始化被标记为"由 Lua 取代",但仍保留主路径 | 代码存在双实现 |
|
||||
| D5 | `_set.sh` 会删除 `a.out` | 误删用户产物 |
|
||||
| D6 | Softmax 雅可比矩阵实现错误(math/Af.cj) | NN 训练收敛异常 |
|
||||
| D7 | Tensor 乘法仅支持 2D,3D+ 会 panic | 限制模型复杂度 |
|
||||
| D8 | Tanh 导数实现错误(使用 `1 - y^2` 而非 `1 - tanh^2(x)`) | NN 训练收敛异常 |
|
||||
| D9 | CNN `testGeometryCNN` 使用随机数据 | 测试无实际意义 |
|
||||
| D10 | MPU6050 `ADDR_0` 与 `ADDR_1` 均为 `0x68` | 无法同时使用两个 MPU6050 |
|
||||
| D11 | `@Action` 与 `@Action_Auto` 并存未合并 | 脚本语法冗余 |
|
||||
|
||||
## 相关资源
|
||||
|
||||
- [Dashun 蜘蛛机器人建模](https://github.com/gopsc/Dashun)
|
||||
- [深度分析报告](docs/Reports/analysis_20260525_00.md)(28 章节,1280 行)
|
||||
- [仓颉编程语言](https://cangjie-lang.org/)
|
||||
- [PCA9685 数据手册](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf)
|
||||
- [MPU6050 数据手册](https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Datasheet1.pdf)
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
export cjStackSize=64MB
|
||||
export cjHeapSize=64MB
|
||||
exec cjpm run --run-args="$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
export cjHeapSize=16MB
|
||||
cjpm update
|
||||
exec cjpm build
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
[package]
|
||||
cjc-version = "1.1.3"
|
||||
name = "bot4cj"
|
||||
description = "nothing here"
|
||||
version = "0.0.3"
|
||||
target-dir = ""
|
||||
src-dir = ""
|
||||
output-type = "executable"
|
||||
compile-option = "-l c -l pthread -l lua" # Linux
|
||||
override-compile-option = ""
|
||||
link-option = ""
|
||||
package-configuration = {}
|
||||
|
||||
[dependencies]
|
||||
#ctype4cj = { git = "https://gitcode.com/qingss0/ctype4cj.git", branch="main" }
|
||||
ctype4cj = "1.0.3"
|
||||
#pthread4cj = { git = "https://gitcode.com/qingss0/pthread4cj.git", branch="main"}
|
||||
pthread4cj = "1.0.0"
|
||||
#spi4cj = { git = "https://gitcode.com/qingss0/spi4cj.git", branch="main"}
|
||||
spi4cj = "1.0.0"
|
||||
#lua4cj = { git = "https://gitcode.com/qingss0/lua4cj.git", branch="main" }
|
||||
lua4cj = "1.0.0"
|
||||
# 这种x86机器人开发板感觉不经常使用,我没用过,不知道里面的i2c调用是不是参数是一样的
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
[target.x86_64-unknown-linux-gnu.dependencies]
|
||||
[target.x86_64-unknown-linux-gnu.bin-dependencies]
|
||||
path-option = ["${CANGJIE_STDX_PATH}/linux_x86_64_cjnative/static/stdx"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
[target.aarch64-unknown-linux-gnu.dependencies]
|
||||
[target.aarch64-unknown-linux-gnu.bin-dependencies]
|
||||
path-option = ["${CANGJIE_STDX_PATH}/linux_aarch64_cjnative/static/stdx"]
|
||||
|
||||
# 为什么不支持Windows了?
|
||||
# 因为新版我们加载了Linux的glibc,通常Windows中没有这个
|
||||
#[target.x86_64-w64-mingw32]
|
||||
# [target.x86_64-w64-mingw32.bin-dependencies]
|
||||
# path-option = ["${CANGJIE_STDX_PATH}\\windows_x86_64_cjnative\\dynamic\\stdx"]
|
||||
@@ -0,0 +1,11 @@
|
||||
nvcc -shared -o libmt4cj.dll src/cuda/matrix.cu -allow-unsupported-compiler
|
||||
#nvcc -shared -o libmt4cj.dll src/cpp/Matrix.cpp -allow-unsupported-compiler
|
||||
nvcc -shared -o libi2c4cj.dll src/cpp/I2C.c -allow-unsupported-compiler
|
||||
nvcc -shared -o libfft4cj.dll src/cpp/fft.cpp src/cpp/Matrix.cpp src/cpp/fft1.c -allow-unsupported-compiler
|
||||
nvcc -shared -o conv.lib -c src/cpp/cnn/conv.cpp -allow-unsupported-compiler
|
||||
nvcc -shared -o imgl.lib -c src/cpp/cnn/imgl.cpp -allow-unsupported-compiler
|
||||
nvcc -shared -o pooling.lib -c src/cpp/cnn/pooling.cpp -allow-unsupported-compiler
|
||||
nvcc -shared -o ga_pooling.lib -c src/cpp/cnn/ga_pooling.cpp -allow-unsupported-compiler
|
||||
nvcc -shared -o flatten.lib -c src/cpp/cnn/flatten.cpp -allow-unsupported-compiler
|
||||
nvcc -shared --std=c++17 -o libcnn4cj.dll src/cpp/cnn/cnn_i.cpp conv.lib imgl.lib pooling.lib ga_pooling.lib flatten.lib -allow-unsupported-compiler
|
||||
nvcc -shared -o libsnd4cj.dll src/cpp/snd.cpp -allow-unsupported-compiler
|
||||
@@ -0,0 +1,325 @@
|
||||
-- ADS1115模数转换器 硬件脚本(测试)
|
||||
|
||||
|
||||
-- 这个函数是辅助用的,它能够打印一张表出来
|
||||
function print_table(t, indent)
|
||||
indent = indent or 0
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == 'table' then
|
||||
print(string.rep(' ', indent) .. k .. ":")
|
||||
print_table(v, indent + 2)
|
||||
else
|
||||
print(string.rep(' ', indent) .. k .. ": " .. tostring(v))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ADS1115_ADDR = 0x48 -- ADS1115的I2C地址
|
||||
ADS1115_REG_CONVERSION = 0x00 -- 转换结果(只读)
|
||||
ADS1115_REG_CONFIG = 0x01 -- 配置寄存器(读/写)
|
||||
ADS1115_REG_LO_THRESH = 0x02 -- 低阈值比较
|
||||
ADS1115_REG_HI_THRESH = 0x03 -- 高阈值比较
|
||||
-- ====================================
|
||||
-- ADS1115 配置寄存器 bit 定义(16位)
|
||||
-- ====================================
|
||||
-- BIT15: OS[0] => 单次转换启动位(1=开始)
|
||||
-- BIT14-12: MUX[2:0] => 输入多路选择
|
||||
-- BIT11-9: PGA[2:0] => 可编程增益放大器
|
||||
-- BIT8: MODE => 0=连续转换,1=单次(省电)
|
||||
-- BIT7-5: DR[2:0] => 数据速率
|
||||
-- BIT4: COMP_MODE => 比较模式(0=传统)
|
||||
-- BIT3: COMP_POL => 比较极性(0=低有效)
|
||||
-- BIT2: COMP_LAT => 比较锁存(0=不锁存)
|
||||
-- BIT1-0: COMP_QUE => 比较器队列(11=禁用)
|
||||
--
|
||||
-- ==== 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)
|
||||
|
||||
-- ==== 数据速率 ====
|
||||
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
|
||||
|
||||
-- ==== 比较器队列(COMP_QUE)====
|
||||
ADS1115_COMP_QUE_1 = 0x0000 -- 1次后触发
|
||||
ADS1115_COMP_QUE_2 = 0x0001 -- 2次后触发
|
||||
ADS1115_COMP_QUE_4 = 0x0002 -- 4次后触发
|
||||
ADS1115_COMP_QUE_DISABLE = 0x0003 -- 禁用比较器(默认)
|
||||
|
||||
-- ==== 操作模式 ====
|
||||
ADS1115_MODE_CONTINUOUS = 0x0000 -- 连续转换
|
||||
ADS1115_MODE_SINGLE = 0x0100 -- 单次转换(默认,省电)
|
||||
|
||||
-- ==== 启动转换 ====
|
||||
ADS1115_OS_START = 0x8000 -- 启动单次转换
|
||||
|
||||
-- ADS1115 配置上下文
|
||||
ads1115_t = {
|
||||
addr = 0x00,
|
||||
config = 0x00, -- 16bits
|
||||
fs_valtage = 0.0 -- 满量程电压(取决于PGA)
|
||||
}
|
||||
|
||||
-- PGA对应的满量程电压(单位:V)
|
||||
pga_fs = {
|
||||
6.144, -- ADS1115_PGA_6_144V [0]
|
||||
4.096, -- ADS1115_PGA_4_096V [1]
|
||||
2.048, -- ADS1115_PGA_2_048V [2]
|
||||
1.024, -- ADS1115_PGA_1_024V [3]
|
||||
0.512, -- ADS1115_PGA_0_512V [4]
|
||||
0.256 -- ADS1115_PGA_0_256V [5]
|
||||
}
|
||||
|
||||
-- PGA 增益对应的满量程电压名
|
||||
pga_names = {
|
||||
"±6.144V", -- [0]
|
||||
"±4.096V", -- [1]
|
||||
"±2.048V", -- [2]
|
||||
"±1.024V", -- [3]
|
||||
"±0.512V", -- [4]
|
||||
"±0.256V" -- [5]
|
||||
}
|
||||
|
||||
-- 数据速率字符串
|
||||
dr_names = {
|
||||
"8 SPS", -- [0]
|
||||
"16 SPS", -- [1]
|
||||
"32 SPS", -- [2]
|
||||
"64 SPS", -- [3]
|
||||
"128 SPS", -- [4]
|
||||
"250 SPS", -- [5]
|
||||
"475 SPS", -- [6]
|
||||
"860 SPS" -- [7]
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- I2C写2字节(大端)
|
||||
function i2c_write_word(fd, reg, val)
|
||||
i2c_write_reg(fd, reg + 0, (val >> 8) | 0xFF)
|
||||
i2c_write_reg(fd, reg + 1, val & 0xFF)
|
||||
end
|
||||
|
||||
--
|
||||
-- 【配置脚本】
|
||||
-- FIXME(20260602-qing): 异常处理
|
||||
function build(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua builder func get a table --------")
|
||||
print_table(data)
|
||||
print("----------------------------------------------")
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查文件句柄参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查地址参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
-- 写进配置表
|
||||
ads1115.addr = addr
|
||||
|
||||
|
||||
-- 开始配置PCA9685
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 重启PCA9685
|
||||
i2c_write_reg(fd, 0x00, 0x80)
|
||||
|
||||
-- 等待振荡器启动
|
||||
milli_sleep(5)
|
||||
|
||||
i2c_write_reg(fd, 0x00, 0x10) -- 进入SLEEP模式(允许改频率)
|
||||
i2c_write_reg(fd, 0xFE, math.floor(prescale))
|
||||
i2c_write_reg(fd, 0x00, 0x20) -- 退出SLEEP模式
|
||||
|
||||
-- 等待振荡器启动
|
||||
milli_sleep(5)
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
-- 【销毁PCA9685的脚本】
|
||||
-- 重置PCA9685
|
||||
-- NOTE(20260612-qing): 其实可以不需要,因为我们只是清空设置
|
||||
function destroy(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua ender func get a table --------")
|
||||
print_table(data)
|
||||
print("--------------------------------------------")
|
||||
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查fd参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查address参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
-- 准备配置
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 重启PCA9685
|
||||
i2c_write_reg(fd, 0x00, 0x80)
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
|
||||
-- 【调用PCA9685】
|
||||
-- 将一个舵机通道转动到一个角度
|
||||
-- NOTE(20260612-qing): 接收一个参数(表),返回一个结果(表)
|
||||
function call(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua caller func get a table --------")
|
||||
print_table(data)
|
||||
print("---------------------------------------------")
|
||||
|
||||
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查fd参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查address参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
local channel = data.channel
|
||||
|
||||
-- 检查channel参数是否存在
|
||||
if channel == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: channel"
|
||||
}
|
||||
end
|
||||
|
||||
local angle = data.angle
|
||||
|
||||
-- 检查angle参数是否存在
|
||||
if angle == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: angle"
|
||||
}
|
||||
end
|
||||
|
||||
print(string.format("Lua hardware drive set angle: %d", angle))
|
||||
|
||||
-- 设置I2C地址
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 计算需要的参数
|
||||
local min = 0.5 -- 0度对应0.5ms
|
||||
local max = 2.5 -- 180度对应2.5ms
|
||||
local width = min + (max - min) * (angle / 180.0)
|
||||
local offval = math.floor(width * 4096.0 / 20.0)
|
||||
local reg = 0x06 + 4 * channel
|
||||
|
||||
-- 写I2C寄存器
|
||||
i2c_write_reg(fd, reg + 0, (0 >> 0) & 0xFF) -- 1 * 8
|
||||
i2c_write_reg(fd, reg + 1, (0 >> 8) & 0xFF) -- 2 * 8
|
||||
i2c_write_reg(fd, reg + 2, (offval >> 0) & 0xFF) -- 3 * 8
|
||||
i2c_write_reg(fd, reg + 3, (offval >> 8) & 0xFF) -- 4 * 8
|
||||
|
||||
-- 返回字典
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ADS1256 模数转换器 - SPI接口
|
||||
|
||||
function build(data)
|
||||
end
|
||||
|
||||
function destroy(data)
|
||||
end
|
||||
|
||||
function call(data)
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
-- PCA9685 PWM控制板 硬件脚本(测试)
|
||||
|
||||
|
||||
-- 这个函数是辅助用的,它能够打印一张表出来
|
||||
function print_table(t, indent)
|
||||
indent = indent or 0
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == 'table' then
|
||||
print(string.rep(' ', indent) .. k .. ":")
|
||||
print_table(v, indent + 2)
|
||||
else
|
||||
print(string.rep(' ', indent) .. k .. ": " .. tostring(v))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
-- 【配置脚本】
|
||||
-- 该脚本负责初始化PCA9685
|
||||
-- FIXME(20260602-qing): 异常处理
|
||||
function build(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua builder func get a table --------")
|
||||
print_table(data)
|
||||
print("----------------------------------------------")
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查文件句柄参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查地址参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
-- 设置PWM频率
|
||||
local freq = 50.0
|
||||
local prescale_val = 25000000.0 / (4096.0 * freq) - 1.0 -- 25MHz主频
|
||||
local prescale = prescale_val + 0.5
|
||||
|
||||
-- 开始配置PCA9685
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 重启PCA9685
|
||||
i2c_write_reg(fd, 0x00, 0x80)
|
||||
|
||||
-- 等待振荡器启动
|
||||
milli_sleep(5)
|
||||
|
||||
i2c_write_reg(fd, 0x00, 0x10) -- 进入SLEEP模式(允许改频率)
|
||||
i2c_write_reg(fd, 0xFE, math.floor(prescale))
|
||||
i2c_write_reg(fd, 0x00, 0x20) -- 退出SLEEP模式
|
||||
|
||||
-- 等待振荡器启动
|
||||
milli_sleep(5)
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
-- 【销毁PCA9685的脚本】
|
||||
-- 重置PCA9685
|
||||
-- NOTE(20260612-qing): 其实可以不需要,因为我们只是清空设置
|
||||
function destroy(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua ender func get a table --------")
|
||||
print_table(data)
|
||||
print("--------------------------------------------")
|
||||
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查fd参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查address参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
-- 准备配置
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 重启PCA9685
|
||||
i2c_write_reg(fd, 0x00, 0x80)
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
|
||||
-- 【调用PCA9685】
|
||||
-- 将一个舵机通道转动到一个角度
|
||||
-- NOTE(20260612-qing): 接收一个参数(表),返回一个结果(表)
|
||||
function call(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua caller func get a table --------")
|
||||
print_table(data)
|
||||
print("---------------------------------------------")
|
||||
|
||||
|
||||
local fd = data.fd
|
||||
|
||||
-- 检查fd参数是否存在
|
||||
if fd == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: fd"
|
||||
}
|
||||
end
|
||||
|
||||
local addr = data.address
|
||||
|
||||
-- 检查address参数是否存在
|
||||
if addr == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: addr"
|
||||
}
|
||||
end
|
||||
|
||||
addr = tonumber(addr)
|
||||
|
||||
local channel = data.channel
|
||||
|
||||
-- 检查channel参数是否存在
|
||||
if channel == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: channel"
|
||||
}
|
||||
end
|
||||
|
||||
local angle = data.angle
|
||||
|
||||
-- 检查angle参数是否存在
|
||||
if angle == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Argument: angle"
|
||||
}
|
||||
end
|
||||
|
||||
print(string.format("Lua hardware drive set angle: %d", angle))
|
||||
|
||||
-- 设置I2C地址
|
||||
i2c_set_addr(fd, addr)
|
||||
|
||||
-- 计算需要的参数
|
||||
local min = 0.5 -- 0度对应0.5ms
|
||||
local max = 2.5 -- 180度对应2.5ms
|
||||
local width = min + (max - min) * (angle / 180.0)
|
||||
local offval = math.floor(width * 4096.0 / 20.0)
|
||||
local reg = 0x06 + 4 * channel
|
||||
|
||||
-- 写I2C寄存器
|
||||
i2c_write_reg(fd, reg + 0, (0 >> 0) & 0xFF) -- 1 * 8
|
||||
i2c_write_reg(fd, reg + 1, (0 >> 8) & 0xFF) -- 2 * 8
|
||||
i2c_write_reg(fd, reg + 2, (offval >> 0) & 0xFF) -- 3 * 8
|
||||
i2c_write_reg(fd, reg + 3, (offval >> 8) & 0xFF) -- 4 * 8
|
||||
|
||||
-- 返回字典
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
-- SPI 硬件
|
||||
|
||||
--
|
||||
SPI_SUCCESS = 0
|
||||
SPI_FAILED = 1
|
||||
|
||||
-- 全局文件句柄
|
||||
fd = 0
|
||||
|
||||
-- 这个函数是辅助用的,它能够打印一张表出来
|
||||
function print_table(t, indent)
|
||||
indent = indent or 0
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == 'table' then
|
||||
print(string.rep(' ', indent) .. k .. ":")
|
||||
print_table(v, indent + 2)
|
||||
else
|
||||
print(string.rep(' ', indent) .. k .. ": " .. tostring(v))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
function build(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- SPI.Lua builder func get a table --------")
|
||||
print_table(data)
|
||||
print("--------------------------------------------------")
|
||||
local path = data.path
|
||||
|
||||
-- 检查文件句柄参数是否存在
|
||||
if path == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: path"
|
||||
}
|
||||
end
|
||||
|
||||
-- 模式
|
||||
local spi_mode = data.spi_mode
|
||||
if spi_mode == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: spi_mode"
|
||||
}
|
||||
end
|
||||
print(spi_mode)
|
||||
spi_mode = tonumber(spi_mode)
|
||||
|
||||
-- 比特
|
||||
local bits = data.bits
|
||||
if bits == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: bits"
|
||||
}
|
||||
end
|
||||
bits = tonumber(bits)
|
||||
|
||||
|
||||
-- 速率
|
||||
local speed = data.speed
|
||||
if speed == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: speed"
|
||||
}
|
||||
end
|
||||
speed = tonumber(speed)
|
||||
|
||||
--
|
||||
local delay = data.delay
|
||||
if delay == nil then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: delay"
|
||||
}
|
||||
end
|
||||
delay = tonumber(delay)
|
||||
|
||||
-- 开始执行
|
||||
fd = spi_open(path)
|
||||
if fd <= 0 then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Failed to open SPI file"
|
||||
}
|
||||
end
|
||||
|
||||
local res = spi_setup(fd, spi_mode, bits, speed, delay)
|
||||
if res ~= 0 then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Failed to setup SPI device"
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
result = "OK",
|
||||
fd = fd
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
function destroy(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- SPI.Lua ender func get a table --------")
|
||||
print_table(data)
|
||||
print(string.format("now fd: %i", fd))
|
||||
print("------------------------------------------------")
|
||||
|
||||
spi_close(fd)
|
||||
fd = -1
|
||||
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
function call(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- SPI.Lua caller func get a table --------")
|
||||
print_table(data)
|
||||
print("-------------------------------------------------")
|
||||
|
||||
return {
|
||||
fd = fd
|
||||
}
|
||||
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
-- PCA9685 PWM控制板 硬件脚本(测试)
|
||||
|
||||
-- 这个函数是辅助用的,它能够打印一张表出来
|
||||
function print_table(t, indent)
|
||||
indent = indent or 0
|
||||
for k, v in pairs(t) do
|
||||
if type(v) == 'table' then
|
||||
print(string.rep(' ', indent) .. k .. ":")
|
||||
print_table(v, indent + 2)
|
||||
else
|
||||
print(string.rep(' ', indent) .. k .. ": " .. tostring(v))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
-- 【配置脚本】
|
||||
function build(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua builder func get a table --------")
|
||||
print_table(data)
|
||||
print("----------------------------------------------")
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
-- 【销毁用的脚本】
|
||||
function destroy(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua ender func get a table --------")
|
||||
print_table(data)
|
||||
print("--------------------------------------------")
|
||||
|
||||
|
||||
-- 返回成功信号
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
|
||||
-- 【调用的脚本】
|
||||
function call(data)
|
||||
|
||||
-- 检查该参数是否是一张表
|
||||
if type(data) ~= "table" then
|
||||
return {
|
||||
result = "ERR",
|
||||
message = "Invalid Arguments: table"
|
||||
}
|
||||
end
|
||||
|
||||
print("-------- Lua caller func get a table --------")
|
||||
print_table(data)
|
||||
print("---------------------------------------------")
|
||||
|
||||
-- 返回字典
|
||||
return {
|
||||
result = "OK"
|
||||
}
|
||||
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
cjpm run --run-args="$($args -join ' ')"
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
# 在 @Action 矩阵中,硬件变量的参数可以使用别名
|
||||
@Alias
|
||||
name alias;
|
||||
A angle;
|
||||
F frequency;
|
||||
@
|
||||
|
||||
@I2C
|
||||
name path;
|
||||
i2c-0 /dev/i2c-0;
|
||||
i2c-1 /dev/i2c-1;
|
||||
@
|
||||
|
||||
@PCA9685
|
||||
name I2C address;
|
||||
pca9685-0 i2c-1 0x40;
|
||||
@
|
||||
|
||||
@MPU6050_BIND
|
||||
name I2C addr;
|
||||
#mpu6050-0 i2c-1 0x69;
|
||||
#mpu6050-1 i2c-1 0x68;
|
||||
#mpu6050-2 i2c-0 0x68;
|
||||
#mpu6050-3 i2c-0 0x69;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 -;
|
||||
@
|
||||
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 90 - - pca9685-0 0; # rb0
|
||||
s2 180 - - - 1; # rb1
|
||||
s3 -90 - - - 2; # lb0
|
||||
s4 -180 - - - 3; # lb1
|
||||
s5 90 - - - 4; # lf0
|
||||
s6 180 - - - 5; # lf1
|
||||
s7 -90 - - - 6; # rf0
|
||||
s8 -180 - - - 7; # rf1
|
||||
s9 180 - - - 8; # cam0
|
||||
s10 180 - - - 9; # cam1
|
||||
@
|
||||
|
||||
# 行动矩阵
|
||||
@Action
|
||||
s1.A s2.A s3.A s4.A s5.A s6.A s7.A s8.A s9.A s10.A spk.F spk.delay; # column name
|
||||
45 - 45 - 45 - 45 - 90 90 10 200; # stand
|
||||
- 10 - 10 - 10 - 10 - - - 20;
|
||||
- 20 - 20 - 20 - 20 - - - 20;
|
||||
- 30 - 30 - 30 - 30 - - - 20;
|
||||
45 0 45 0 45 0 45 0 90 90 10 200; # stand
|
||||
@
|
||||
@@ -0,0 +1,54 @@
|
||||
|
||||
# 在 @Action 矩阵中,硬件变量的参数可以使用别名
|
||||
@Alias
|
||||
name alias;
|
||||
A angle;
|
||||
F frequency;
|
||||
@
|
||||
|
||||
@I2C
|
||||
name path;
|
||||
i2c-0 /dev/i2c-0;
|
||||
i2c-1 /dev/i2c-1;
|
||||
@
|
||||
|
||||
@PCA9685
|
||||
name I2C address;
|
||||
pca9685-0 i2c-1 0x40;
|
||||
@
|
||||
|
||||
@MPU6050_BIND
|
||||
name I2C addr;
|
||||
#mpu6050-0 i2c-1 0x69;
|
||||
#mpu6050-1 i2c-1 0x68;
|
||||
#mpu6050-2 i2c-0 0x68;
|
||||
#mpu6050-3 i2c-0 0x69;
|
||||
@
|
||||
|
||||
@Speaker
|
||||
name channels sample_rate volumn max_f min_f;
|
||||
spk 2 48000 0.6 5000 -;
|
||||
@
|
||||
|
||||
@Servo
|
||||
name max min offset controller channel;
|
||||
s1 90 - - pca9685-0 0; # rb0
|
||||
s2 180 - - - 1; # rb1
|
||||
s3 -90 - - - 2; # lb0
|
||||
s4 -180 - - - 3; # lb1
|
||||
s5 90 - - - 4; # lf0
|
||||
s6 180 - - - 5; # lf1
|
||||
s7 -90 - - - 6; # rf0
|
||||
s8 -180 - - - 7; # rf1
|
||||
s9 180 - - - 8; # cam0
|
||||
s10 180 - - - 9; # cam1
|
||||
@
|
||||
|
||||
# 行动矩阵
|
||||
@Action
|
||||
s1.A s2.A s3.A s4.A s5.A s6.A s7.A s8.A s9.A s10.A spk.F spk.delay; # column name
|
||||
45 - 45 - 45 - 45 - 90 90 10 200; # stand
|
||||
- - - - - - - - - - 400 200;
|
||||
0 - - - - - - 150 - - 1000 1000;
|
||||
45 - - - - - - 0 - - 10 20;
|
||||
@
|
||||
Loaded 100 of 608 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user