Compare commits

..
329 Commits
Author SHA1 Message Date
qing 81ba942b8e feat(libmsg): extract pymsg-compatible messaging library as shared lib
- Add libmsg: C++20 header-only pymsg protocol implementation
  - Msg.hpp: pmsg_send/pmsg_recv (blocking + deadline modes) + MsgClient
  - MsgServer.hpp: multi-client TCP server with select event loop + thread pool
  - AsyncQueue<T>: generic async batch processing queue
  - RpcClient/RpcServer: JSON RPC convention layer (requires boost::json)
  - Msg_Basic.hpp/cpp: exception hierarchy, constants, version
- Fix thread safety: MsgClient _sockfd/_running use std::atomic with exchange()
- Fix deadline recv: client call() uses shared deadline for header+payload
- Fix graceful shutdown: thread pool drains queued tasks before exit
- Fix pmsg_send size check: enforce 64MiB limit (was 4GiB no-op check)
- Align AsyncQueue batch semantics with Python: batch_size is threshold
- Refactor gateway/LogC.hpp to use libmsg AsyncQueue<T>
- Update gateway Makefile to link -lus_msg, DbC.hpp to use <us/Msg.hpp>
- Add libmsg to _inst.sh build order
2026-08-06 23:14:13 +08:00
qing bbb88a96f8 fix(gateway): implement dual-track token revocation mechanism (P0-1)
Add thread-safe in-memory token revocation to fix the critical issue
where logged-out or password-reset tokens remain valid until expiry.

- Add jti (uuid v4) to all issued tokens for single-token revocation
- Add per-user version number for bulk revocation on password reset
- Add jti blacklist with TTL-based auto-cleanup to prevent memory leaks
- Introduce unified verify_token() replacing 4 copies of inline logic
- Refactor all handlers (login/logout/refresh/reset_password/proxy/whoami/
  admin_verify/ws_admin) to use the new verification pipeline
- Refresh token now revokes old jti (prevents replay after refresh)
- Bump version to 0.8.0
2026-08-06 19:01:34 +08:00
qing 7b0b49fde1 Merge pull request 'fix: 更新 gateway 管理员白名单并修复 fix_orphan_notes 脚本' (#21) from dev_qing into main
Reviewed-on: #21
2026-08-05 16:40:30 +08:00
qing 5a8ac61502 fix: 更新 gateway 管理员白名单并修复 fix_orphan_notes 脚本
- confs/configs/gateway.ini: 管理员账号从 qing@qsont.me;shufeng@qsont.me 更新为 1776177662@qq.com;277665039@qq.com
- db/fix_orphan_notes.py: 修复 _sys.path 笔误为 sys.path,补充文件末尾换行
2026-08-05 16:38:39 +08:00
qing 8b3863f3ae Merge pull request 'Dev qing' (#20) from dev_qing into main
Reviewed-on: #20
2026-08-05 16:36:10 +08:00
qing 6483719551 docs(dnfs): 同步 README 至审计 08-03 验证后的真实状态
- 主干: Token 改为委托 gateway /whoami 验签描述
- 已知问题: 标注 P0 #1/#3、P1 #5/#10 已修复,P0 #2 基本修复,其余未修复项保留
2026-08-05 16:34:34 +08:00
qing 9c73d70582 docs(gateway): 标注旧哈希自动迁移已被注释禁用
README 安全特性中"旧哈希自动迁移"实际在 main.cpp L1102-1109 被注释,标注其当前未生效。
2026-08-05 16:29:29 +08:00
qing 0348b959bb docs: 同步 static 与 waf README 至审计后真实状态
- static: 移除/标注已禁用的 reset_password 与 yhdet 系列路由,更新 check_user 为经 db 服务查询,补充代理路由 CORS 通配例外说明
- waf: 明确 CRS 需先 _fetch_crs.sh 才生效,未拉取时 WAF 开箱无拦截能力
2026-08-05 16:26:23 +08:00
qing 3ebf402efd Merge pull request 'Dev qing' (#19) from dev_qing into main
Reviewed-on: #19
2026-08-05 12:31:42 +08:00
qing 63b5243faf style(index): 首页移动端响应式优化
触屏加大可点目标,平板/手机收敛光晕与留白,超小屏进一步紧凑排版。
2026-08-05 10:48:09 +08:00
qing 1b6a767f62 feat(dnfs): 节点卡片仅标题可点击进入子节点
将进入子节点的点击事件从整张卡片收窄到标题 h3,内容/统计/操作区不再触发
跳转;同步 theme.css 将手型光标与 hover 高亮集中在标题上。
2026-08-05 10:48:09 +08:00
qing 7f6f077469 feat(dnfs): support in-node rendering for frontend code nodes 2026-08-05 10:30:29 +08:00
qing 60db2c40d8 特效保持全屏视觉,但 iframe 元素挂载到节点容器内 2026-08-05 10:15:21 +08:00
qing 08aff2656d 前端特效节点改为在节点容器内展示,不再全屏铺底 2026-08-05 10:12:47 +08:00
qing c059589e94 style(dnfs): 首页进一步美化,光晕氛围与卡片细节 2026-08-05 10:01:17 +08:00
qing d1e4b5b01a style(dnfs): 打磨首页 index 视觉细节,保持朴素风格 2026-08-05 09:25:43 +08:00
qing 897e76fd8f style(dnfs): 移除左侧用户面板按钮的 emoji 表情,保持文字简洁 2026-08-05 02:02:00 +08:00
qing ac2677abc7 feat(dnfs): 顶部搜索框卡片化美化,整体视觉和谐统一
- dnfs.html:去掉内联样式,重构为「放大镜图标 + 输入框 + 搜索按钮」结构;按钮改 <button> 避免意外提交
- theme.css:新增 .searchbox 胶囊卡片样式,与 .card/.userpanel 统一设计语言(圆角 20px、
  半透明卡片底色、浅阴影、1px 细边、橙色主按钮)
- focus-within 态:边框变橙 + 图标变橙 + 发光阴影,焦点反馈清晰
- 输入框无边框全透明,视觉焦点集中在胶囊容器
- 480px 以下移动端缩紧内边距与字号
2026-08-05 01:58:55 +08:00
qing d680dcd058 fix: 修复 WebSocket 长连接空闲断线问题
三处协同修复:
1. 前端 aibox.js 添加 25s 心跳(ping),防止 gateway 30s 空闲超时
2. gateway libhttp doWsRead 对 WS 代理连接跳过空闲超时
3. aibox.py _handle_tool_calls 启动 drain 线程实时排空输出队列,
   避免 SubAgent 等长时间工具执行期间前端收不到数据
2026-08-05 01:58:19 +08:00
qing 84a1a5e3be feat(dnfs): 顶部搜索框实时过滤当前层节点卡片
- 抽取 renderCards() 复用卡片渲染,load_notes 保存 current_children
- 卡片绑定 __item 引用,搜索时直接切换 .style.display(不重建 DOM、不清特效)
- 按 title/owner/content 子串匹配;信封节点链接/代码/文件名纳入搜索,排除图片/文件的 base64 数据
- 支持实时 input 过滤、回车触发、ESC 清空;无匹配时显示提示
- 切换/返回节点时自动清空搜索关键词
2026-08-05 01:52:52 +08:00
qing 058eb11f5f docs(aibox): 更新 README 补充 express_emotion 工具与 /search 命令(移除过时说明、命令表新增 /search、工具表新增 express_emotion、消息类型表补充 task_list/task_completed、更新日志新增 v2.8) 2026-08-05 01:51:10 +08:00
qing 85c9699758 feat(dnfs): 用户面板折叠时不再显示用户名,仅显示展开按钮 2026-08-05 01:42:51 +08:00
qing 9dd16e0eef feat(dnfs): 进入新节点时先清除上一节点特效再加载当前节点特效 2026-08-05 01:29:49 +08:00
qing 28894e48dc feat(dnfs): 用户面板下移并新增无特效模式开关;管理员增加运维机器人/进程管理器入口 2026-08-05 00:49:30 +08:00
qing 924c718005 fix(waf): WebSocket 劫持后清除 http.Server 读写 deadline,避免长连接被强制断开 2026-08-05 00:37:11 +08:00
qing d71f526af4 feat(aibox): 子代理思考过程实时输出到主对话侧栏
- run_full_subagent 新增 output_callback 参数,转发子代理 reasoning 事件
- SubAgentTool 新增 _subagent_output 回调,将子代理思考过程以 line 流式输出到前端
2026-08-05 00:16:34 +08:00
qing 09556c9ef4 feat(aibox): run_command 强制流式输出;子代理升级为完整工具代理;补充情绪应对策略
- run_command 移除 stream 参数,删除非流式 subprocess.run 执行路径,统一走流式输出
- 子代理 (create_subagent) 从轻量无工具升级为拥有全部工具的完整代理,支持多轮工具调用与 max_turns 限制,结束后自动总结
- 系统提示词补充子代理使用建议与情绪应对策略
2026-08-05 00:09:25 +08:00
qing cc671a9fce feat(dnfs): 含前端内容的节点渲染到当前页面时自动执行(隔离iframe沙箱启动特效) 2026-08-04 23:57:39 +08:00
qing f7ed1f785b fix(dnfs): 前端代码节点改为隔离iframe沙箱运行,特效不再污染父页面导致运行后无法进入节点 2026-08-04 23:51:21 +08:00
qing 2ff7908bc7 feat(db): _view_db -n 改为取最后 N 行
按 rowid 倒序取末尾 N 行,外层再正序展示,便于查看最新日志;
-w 条件放入内层子查询,语义为"最后 N 条匹配记录"。
2026-08-04 23:44:59 +08:00
qing 745ce06f85 fix(dnfs): 前端代码节点运行改为局部输出缓冲+捕获窗口自动恢复console,避免应用自身日志(pid)刷进输出框干扰导航 2026-08-04 22:47:48 +08:00
qing f17a6a8233 fix(dnfs): 前端代码节点注入脚本包IIFE隔离作用域,避免覆盖页面全局导致运行后无法进入节点 2026-08-04 22:29:10 +08:00
qing 30360cb106 feat(dnfs): 前端代码节点支持完整HTML/CSS/JS并注入当前页面
将内联 JS 节点扩展为内联前端代码节点:内容存完整 HTML 文档,
运行后解析注入当前页面——style 注入 head、script 在全局作用域执行
可操作整页加特效、body 渲染到节点下方容器,console 输出捕获到下方。
2026-08-04 22:24:33 +08:00
qing ec3df34b43 feat(db): 新增修复 Notes 表 pid 悬空节点的脚本 2026-08-04 22:19:05 +08:00
qing c7441c1c6f fix(dnfs): JS代码节点改用<script>注入执行以兼容CSP
new Function/eval 被 CSP(script-src self unsafe-inline) 拦截,改用注入内联
<script> 执行,避免引入 unsafe-eval。代码在全局作用域运行可操作页面加特效,
console 输出经 hook 捕获到输出区(保留原 console 行为)。
2026-08-04 22:11:19 +08:00
qing fed2d42472 refactor(dnfs): JS代码节点改为在当前页面执行以支持页面特效
移除 iframe 沙箱方案,改为 new Function 在当前页面上下文执行,
代码可直接操作 document/window 对页面施加特效;console 输出捕获到下方区域。
2026-08-04 22:08:15 +08:00
qing 2918f2c83c feat(dnfs): 特殊节点类型由网页代码(html)改为JS代码(js)
将 _sp 标识从 html 改为 js,运行方式由新窗口 document.write 改为
插入页面 iframe 沙箱执行,捕获 console 输出,postMessage 自适应高度。
后端 dnfs.py 白名单、校验、审核文本同步更新。
2026-08-04 22:05:01 +08:00
qing 87538e7729 fix(waf): 豁免扩展到所有笔记写接口 (add/update/edit/delete_note)
修正正则匹配 query 前缀问题,统一豁免笔记接口的 CRS 误报规则。
2026-08-04 21:52:15 +08:00
qing fd088f5959 fix(waf): 对 /add_note 接口豁免 CRS 误报规则
嵌入式/技术笔记含 C 代码(if()/select/sbit)被 SQLi/命令注入规则误判拦截。
仅移除误报规则(942170/942230/932xxx/934xxx),其余 CRS 规则及接口仍生效。
2026-08-04 21:32:14 +08:00
qing 15f8c9552d fix(core,mgmt): list 返回 running+command 字段,命令列不显示为空
原 list 把 check()(命令字符串)放进 status 字段,前端误当状态显示,
命令列取不存在的 command 字段导致显示为空。

修复:list 返回 running(bool) + command(string),前端对应显示状态与命令。
2026-08-04 21:31:21 +08:00
qing 43c5821d3d fix(gateway): 修复代理 URL 双斜杠导致下游 404
handleProxy 拼接转发 URL 时,target_path 以 '/' 开头,
url = t_url + '/' + target_path 会产生双斜杠(如 //list)。
core 等使用精确路径匹配的下游服务返回 404。

修复:仅当 target_path 不以 '/' 开头时才补斜杠。
2026-08-04 21:20:24 +08:00
qing 1d7641cc89 fix(core): 纯 HTTP 启动 HTTP 服务(不需要 TLS,符合项目约束)
core 的 HTTP 服务仅供 gateway 内部代理,且 WAF 已在前端终止 TLS。
原代码强制加载 server.crt/server.key,但证书不存在,导致 core 无法启动,
所有请求被 gateway 返回 500 'Internal Server Error'。

修复:使用无 TLS 的 3 参数 HttpServer 构造函数,纯 HTTP 启动。
2026-08-04 21:11:35 +08:00
qing 6a68b3393d docs(waf): _fetch_crs.sh 生成的 coraza.conf 模板指向 tuning.conf
避免重跑脚本覆盖调优配置,模板不再包含重复的阈值/偏执规则
2026-08-04 21:11:26 +08:00
qing 0700491300 取消注释 2026-08-04 21:02:08 +08:00
qing d036af935f feat(waf): 启用 OWASP CRS 异常评分制自动拦截
- SecRuleEngine On 自动拦截异常评分达阈值的请求(规则 949110)
- 新增 rules/local/tuning.conf 调优文件(偏执等级 1 + 阈值 5/4)
- tuning.conf 独立于自动生成的 crs/coraza.conf,重跑 fetch 不覆盖
2026-08-04 20:40:25 +08:00
qing 34cd295005 feat(core): mgmt 接入 core 进程管理 + 管理员认证
- gateway 新增 POST /admin/verify 验签接口,供内部服务校验管理员 Token
- core 的 /list /exec /kill 接入管理员认证(调用 gateway 验签,fail-closed)
- gateway 代理 opened 白名单加入 core:5200
- mgmt 管理页显示当前用户与角色,进程页接入 core 进程管理接口
2026-08-04 20:00:24 +08:00
qing c8812dd27c static: 迁移 mgmt 前端到 static/public 并移除静态列表
将 mgmt Jinja2 模板原封不动迁移为纯静态 HTML/CSS,
删除用户列表与模块列表,改为静态提示文字。
2026-08-04 19:23:57 +08:00
qing 3460a157d0 docs: 新增 libth 安全审计与 aibox 开放版开发计划 2026-08-04 19:02:22 +08:00
qing fe618be1fa fix(captcha): 验证码存储容量上限防 OOM (P1-1)
新增 MAX_STORE_SIZE(默认 10000,可配置 max_store/CAPTCHA_MAX_STORE),
_handle_image 在清理过期项后检查容量,超限拒绝生成并返回错误,
防止攻击者高频生成验证码填满内存。
2026-08-04 18:40:36 +08:00
qing 4d9bb5d940 fix(libhttp): WebSocket 发送/代理队列上限防 OOM (P1-6)
- 新增 setMaxWsQueueSize()(default 1000),每个入队点检查
- wsSend / onProxyReadClient / onProxyReadBackend 超限关闭连接
- 更新审计文档 P1-6 状态、README v3.4.3
2026-08-04 18:27:50 +08:00
qing 37c6d401d6 fix(libhttp): 服务器读超时防 Slowloris (P1-4)
- 新增 setHeaderTimeout()(default 10s) / setReadTimeout()(default 30s),对齐 WAF
- doSSLHandshake 设置头超时覆盖握手,doRead 拆分为 async_read_header (头超时) + async_read (体超时)
- doWsRead 每次读取设置空闲超时,自动清理长期空闲连接
- 更新审计文档 P1-4 状态、README v3.4.2
2026-08-04 17:39:27 +08:00
qing 507054c4b2 fix(libhttp): WebSocket 单条消息大小限制防 OOM (P1-3)
- 新增 setMaxWsMessageSize/getMaxWsMessageSize(默认 16MB,与 Beast 默认一致)
- 服务端 doWsUpgrade/doWssUpgrade 与代理流 onProxyConnect/onProxySslHandshake 创建后调用 read_message_max
- 超限消息由 Beast 在分配内存前拒绝并关闭连接
- 更新审计文档 P1-3 状态、README v3.4.1
2026-08-04 16:03:06 +08:00
qing 85e0d0740b fix(libhttp): HttpClient 响应大小限制防 OOM
- request_stream_sync 改为真流式,按块回调不二次缓冲
- 新增 set_max_response_size/get_max_response_size(默认 10MB,对齐 WAF)
- write_callback 超限返回 0 中止传输并抛异常
- 更新审计文档 P1-2 状态、README v3.4.0、INDEX 移除不存在的文档
2026-08-04 15:12:06 +08:00
qing dc52ec13ad fix(aibox): 工具结果超长仅返回提示,隔离各会话命令执行器
- 工具结果超过 max_result_length 时不再保留截断内容,仅返回提示信息,避免超大结果污染 AI 上下文

- 每个任务/WebSocket 连接使用独立 SubprocessCommandExecutor,避免 cancel() 误杀其他任务/用户正在运行的子进程
2026-08-04 13:11:37 +08:00
qing 5ff1dfa4d2 系统状态技能 2026-08-04 12:57:27 +08:00
qing 2eb6d3c310 生成技术手册技能 2026-08-04 12:46:50 +08:00
qing d921f84b83 feat(aibox): WebSocket连接用户独立AI实例,支持多用户并发对话
- 每个连接创建独立DeepSeekChat实例,拥有独立messages/工具注册表/消息锁
- 对话状态与并发控制从全局改为会话级,多用户可同时与AI对话互不干扰
- 中断/重置/重载提示词等命令改为针对当前连接的会话AI
- 技能管理器懒加载共享,避免每个连接重复扫描技能
2026-08-04 11:44:57 +08:00
qing 69a8eee192 feat(aibox): 调度器任务间上下文隔离
- 每个定时任务创建独立DeepSeekChat实例,拥有独立messages/工具注册表/消息锁
- 任务与WebSocket主对话互不干扰,消除并发冲突与上下文污染根因
- 共享技能管理器懒加载一次供所有任务复用,避免重复扫描技能
- DeepSeekChat支持传入skill_manager复用,任务实例history_manager=None不污染历史
2026-08-04 09:23:51 +08:00
qing fa9679b7a6 fix(aibox): 修复并发线程导致对话历史污染引发HTTP 400错误
- 添加线程锁(RLock)保护 self.messages,防止WebSocket与调度器线程并发修改
- 重写 _repair_tool_pairs 方法,支持穿透system消息查找tool响应、去重、清理孤立tool消息
- 新增 _validate_enhanced_messages API调用前最终校验,就地修复消息结构
- 调度器执行定时任务前先cancel+wait锁释放,避免与WebSocket对话冲突
- /new命令和global_exit_handler改用锁保护的reset_conversation,不再裸改messages
- 新增 is_busy() 非阻塞方法供外部判断AI是否正在处理中
2026-08-04 09:11:31 +08:00
qing 53ddcb364f feat(aibox): 创建/安装技能工具统一生成 .md 扩展名 + 下线旧版 smtp.py
- CreateSkillTool/InstallSkillTool 改为生成带 .md 扩展名的技能文件,与人格技能文件规范一致
- 同步更新 7_extension_skills.md 模板与工具描述
- 删除旧版 smtp.py(P0-1)并同步审计文档与 README
2026-08-04 08:26:57 +08:00
qing fb485ebac4 fix(libhttp): 修复P1-5静态文件路径遍历 + 同步审计文档
- 新增 isSafeStaticPath() 校验静态文件路径,拒绝 ..、空字节、非/开头及URL编码遍历(%2e/%2f/%5c)
- handleRequest 静态文件服务前校验 target_path
- 审计文档标记 P1-5 已修复
2026-08-04 00:04:55 +08:00
qing da4957c52d fix(libhttp): 请求体大小限制可配置并超限返回 413 (P1-1) 2026-08-03 23:57:00 +08:00
qing b37bf255d9 fix(libhttp): 删除遗留 WebSocketServer/WebSocketClient 并同步 CMakeLists 与 Makefile 2026-08-03 23:46:10 +08:00
qing 2f5fe8baaa docs(security): 新增 libhttp 模块安全审计报告 2026-08-03 23:42:32 +08:00
qing 2e3f07bf4f docs(gateway): 审计文档行号纠偏并补充 CORS 白名单死代码根因
- 修正 gateway 审计文档中因代码演进漂移的行号引用(handleProxy/handleLogout/wrapperHttp/限流/TLS/路径防护等)

- 补充 P2-3 CORS 根因:_CORS_ORIGINS 白名单已读取但 OPTIONS 预检由 libhttp 处理,白名单未生效

- 修正 README 中 OPTIONS 预检描述(反射请求 Origin 而非通配 *)及 CORS 配置失效提示
2026-08-03 23:37:47 +08:00
qing 028e0e0806 docs(security): 删除全局审计文档并将问题并入各模块文档
- 各模块专属 P0/P1/P2 问题已分发到 docs/security/ 下各模块审计文档

- gateway 补充 P1-6(反向代理响应无大小限制)、P2-3(CORS 通配)、P2-4(HTTP 无超时)

- 删除全局 SECURITY_AUDIT_20260801.md,避免重复
2026-08-03 23:32:28 +08:00
qing 37c50ed517 fix(static): 获取邮箱验证码后保留已输入的图形验证码
refreshCaptcha 增加 preserveInput 参数,获取验证码后的自动刷新不再清空图形验证码输入框,避免误清空用户输入
2026-08-03 23:11:10 +08:00
qing 02904f711e fix(aibox): 修复工具调用配对缺失导致的 insufficient tool messages 400
当 assistant 携带 tool_calls 但对应 tool 响应缺失(工具被中断/压缩丢弃)时,
下一次请求报 400。新增 _repair_tool_pairs 在构建请求前补齐缺失的 tool 响应。
2026-08-03 23:05:28 +08:00
qing d24066369b refactor(aibox): 解耦大模型调用,新增 llm_client 抽象层以支持多厂商切换
将 think_and_respond 与 _compress_via_llm 中的直接 DeepSeek API 调用
收敛到 LLMClient 抽象层(DeepSeekClient 实现 + create_llm_client 工厂),
统一流式事件格式,为子代理/多模型切换预留扩展点;配置新增 api.provider。
2026-08-03 23:03:03 +08:00
qing 417fda5479 refactor(aibox): 文件/网络操作工具改为返回命令行指引并移除beautifulsoup4依赖 2026-08-03 22:52:12 +08:00
qing fb5920d89f feat(gateway): 新增重置密码接口并改造前端 forgot 页对接
- gateway 新增 POST /reset_password:复用注册校验链(限流/密码强度/邮箱格式/CAPTCHA/邮箱验证码),最后一步由创建用户改为 UPDATE Users SET password_hash,用户不存在时统一返回成功防枚举
- gateway 新增 POST /reset/send_code:CAPTCHA 强制 + 邮箱双窗口限流,定制重置密码文案
- 前端 forgot.html 弃用 static 被禁用的 /api/proxy_* 接口,对接 gateway 新接口,并改为中文 register.html 风格
2026-08-03 22:40:43 +08:00
qing dd4fef6dee fix(pymsg): 异常信息脱敏与JSON深度/大数字限制,防信息泄露与DoS 2026-08-03 22:30:17 +08:00
qing 87f209af73 fix(pymsg): 增加连接数与线程池队列上限,防资源耗尽
- P1-1: MsgServer 新增 max_connections(默认1000),_accept_client 超限
  直接拒绝并关闭新连接,防连接耗尽攻击
- P1-2: 新增 _BoundedThreadPoolExecutor + max_queue_size(默认1000),
  慢 handler 时任务排队超限即拒绝并断开连接,防内存无限增长
同步更新审计报告 P1-1/P1-2 状态,修复率 18.75% -> 25%
2026-08-03 22:25:32 +08:00
qing 47ab9f2fe6 fix(pymsg): 使用 deadline 模式修复 recv 总超时绕过(Slowloris DoS)
_recv_exact 从单次 sock.settimeout() 改为整体 deadline 模式:
header 与 payload 共享同一 total deadline,每次 recv_into 前按剩余
时间收紧超时,逐字节发送无法刷新 deadline,半开连接被及时关闭。
同步更新 pymsg 安全审计报告 P0-1 状态,修复率 6.25% -> 12.5%。
2026-08-03 22:15:44 +08:00
qing ac16d89150 docs(aibox): 添加文件/网络操作命令行化开发计划 2026-08-03 22:05:52 +08:00
qing f53085b2a6 fix(static): 禁用 yhdet 系列功能并迁移数据库访问到 db 服务
- 注释禁用 yhdet OAuth 相关常量、辅助函数和视图函数(yhdet_callback/yhdet_login/yhdet_verify/proxy_send_code),消除 P0-2/P0-3/P0-4/NEW-1/NEW-2 风险

- YHDET_SECRET 置空并标注已失效;PRIVATE_SECRET 与 YHDET_PRIVATE_API 一并注释

- 新增 _db_call() 辅助函数,api_check_user 改经 pymsg 调 db 服务,不再直连 SQLite

- 修复注册页验证码:点击获取邮箱验证码时图形验证码输入框被清空的问题

- 更新 static 安全审计报告,修复率 25% → 65%
2026-08-03 22:05:02 +08:00
qing 4749e58eeb fix(static): 禁用无认证的 /api/reset_password 接口 2026-08-03 14:26:06 +08:00
qing 5fbe3ed5e4 docs(security): 更新全部模块审计报告,添加代码验证记录
逐模块对照实际代码验证审计报告中的问题修复状态,添加代码验证记录、逐条验证结果、修复率统计。合并aibox两份审计报告,删除重复文件。修复率:confs 61% > dnfs 60% > ai_review 50% > smtp 37.5% > db 33% > core 12.5% > gateway 12.5% > pymsg 6.25% > static 5% > captcha/aibox/BotOps 0%
2026-08-03 14:12:45 +08:00
qing d3158c2420 docs: add date suffix to remaining documentation files
- Rename SECURITY_HARDENING_PLAN.md -> SECURITY_HARDENING_PLAN_20260803.md
- Rename aibox_SECURITY_AUDIT.md -> aibox_SECURITY_AUDIT_full_20260801.md
- Update INDEX.md links accordingly
2026-08-03 13:26:38 +08:00
qing a7a78d1424 docs: aggregate project documentation under docs/ with unified index
- Move all per-module security audit reports to docs/security/
- Move code review logs to docs/reviews/, analysis reports to docs/reports/
- Add docs/INDEX.md as single entry point for all project documentation
- Add documentation management conventions section to README.md
- Clean up empty per-module docs/ directories
2026-08-03 13:21:48 +08:00
qing 9dbe395484 docs: 添加内网通信安全加固开发计划 2026-08-03 13:13:52 +08:00
qing 9f440ee884 fix(db): 监听地址改为 127.0.0.1 修复内网暴露 2026-08-03 13:10:17 +08:00
qing 4449a23ba1 docs(lci): 完善 LCI 图形系统架构文档 2026-08-02 21:37:02 +08:00
qing 40b9b9c92f Merge pull request 'Dev qing' (#18) from dev_qing into main
Reviewed-on: #18
2026-08-02 21:33:21 +08:00
qing 0493383ee8 遗漏的部分 2026-08-02 21:21:03 +08:00
qing 84fb370da8 docs(lci): 添加 LCI 帧缓冲图形界面能力介绍到 README 2026-08-02 21:18:19 +08:00
qing 8499631091 add old lci module 2026-08-02 20:15:07 +08:00
qing 38a6e815f0 fix(static): __view.html 改为绝对路径,修复子目录下 CSS/JS 404 2026-08-02 16:41:08 +08:00
qing c58ef2b873 fix(static): 修复令牌刷新死循环 + 补全 index/aibox 令牌管理
- expired estimate 改用 created_at 替代 iat,与网关校验逻辑一致

- index.html 和 aibox.html 补全 token_manager.js 引用
2026-08-02 16:11:23 +08:00
qing 52052e51a8 feat(aibox): 在pre_prompt中新增情绪感知提醒
每轮首轮对话的用户输入前,新增【情绪感知提醒】段落,引导AI:
- 识别用户情绪状态并共情回应
- 按情绪状态调整回应策略(安抚/分享/道歉)
- 用户要求特定情绪模式时搜索对应技能
2026-08-02 14:30:20 +08:00
qing ffbe076a32 feat: add execute method to ExpressEmotionTool 2026-08-02 14:17:33 +08:00
qing 92820799fa 补全情绪化工具 2026-08-02 14:02:51 +08:00
qing 054f65f858 增加智能体生气的模板 2026-08-02 13:54:58 +08:00
qing 918f3ea32a fix(aibox): reasoning展示严格遵循配置文件reasoning_enabled开关
之前即使reasoning_enabled=false,模型自发返回的reasoning_content
仍会被动态展示——配置只管了API请求参数,不管展示逻辑。
现在添加constants.REASONING_ENABLED门控,关着时静默忽略。
2026-08-02 13:26:34 +08:00
qing b216535757 comment 2026-08-02 13:21:45 +08:00
qing 45db2f780b refix core initialize list 2026-08-02 12:56:14 +08:00
qing c1724ccea9 fix(redirect80): extract .content from confs RPC response dict
RpcClient.call() returns the data dict, not a raw string.
configparser.read_string() expects str, hence TypeError.
Now uses .get("content") to unwrap the ini text.
2026-08-02 12:52:51 +08:00
qing f9c02cd6ad feat(redirect80): new HTTP→HTTPS redirect service on port 80
Pure Python stdlib http.server + ThreadingMixIn, zero web framework deps.
- GET/HEAD → 301, POST/PUT/DELETE/PATCH → 308
- Reads Host header, strips port, redirects to https://{host}{path}
- Config via confs (retry_on_refused, hard dependency) + env var override
- Logs via log_client → db Logs table (fail-safe async batch)
- Port 80 privilege via setcap cap_net_bind_service in _run.sh
2026-08-02 12:50:23 +08:00
qing c7364286b7 fix(static): getNode仅401跳登录,其他异常提示原因并上抛 2026-08-02 12:19:02 +08:00
qing c3c07ab06d Merge pull request 'Main' (#17) from main into dev_qing
Reviewed-on: #17
2026-08-02 11:51:31 +08:00
qing 2e210e4729 chore(db): gitignore 忽略 SQLite WAL/SHM 运行时临时文件 2026-08-02 11:49:42 +08:00
qing 5a3dff9026 fix(static): 修复dnfs导航栈污染与返回键栈空越界
- 点卡片/返回上一层前先经 confirmLeaveIfDirty 守卫确认,用户选择
  不放弃输入时不再执行 push/pop,stack 保持原样
- 返回键目标 id 判断由 length>=0(恒真,空栈取到 undefined)改为
  length>0,空栈正确回根节点 0
2026-08-02 11:46:51 +08:00
shufeng2012 7e4fd7e54a Add search input border. 2026-08-02 09:04:53 +08:00
qing 8200cbfba7 refix 2026-08-02 01:19:56 +08:00
qing 192610615e feat(captcha): 接入 confs 配置中心并迁移字体路径
- confs/configs/captcha.ini: 新增 captcha 配置 (server + captcha 段, 含 font_path)
- captcha/log_client.py: 新增日志客户端副本 (service_name=captcha)
- captcha/captcha_service.py: 接入统一的 confs 配置加载模式
  (confs 强依赖 retry_on_refused -> 环境变量覆盖 -> 完整性校验)
  - CAPTCHA_LEN/CAPTCHA_TTL 改为可配置
  - 字体路径迁移到 confs, 新增 CAPTCHA_FONT_PATH 环境变量覆盖与 PIL 回退兜底
  - 关键路径 (启动/生成/校验失败) 接入 log_client 日志上报
2026-08-02 01:09:44 +08:00
qing 2bb1d69d8a feat(static/aibox.js): 同步BotOps修复到Web前端
同步以下修复:
1. endReasoning()/abortReasoning() 结束后清除引用(DOM保留显示),
   多轮工具调用时每轮创建独立思考区域,按时间顺序分布
2. 新增 breakAi() 分段函数,处理 ai_break 信号:工具调用前分段
   AI文本,后续chunk创建新气泡,避免思考区域被夹在AI文本中间
3. line消息过滤前缀扩展:增加分析工具结果/需要调用工具等
4. 未知消息类型fallback从appendAi改为addTaskLine,防止非AI内容污染聊天区
2026-08-01 22:19:54 +08:00
qing 392a734707 fix(ai_review): 添加内容大小限制和频率限制
/review: 10KB内容上限(413)、30次/分钟/IP(429);/rag/feedback: 5次/分钟/IP。使用滑动窗口限流器,优先取X-Forwarded-For作为客户端IP。
2026-08-01 22:12:58 +08:00
qing 89b374c21d fix(pymsg): 捕获 UnicodeDecodeError 防止同步模式服务崩溃
recv() payload.decode("utf-8") 抛出 UnicodeDecodeError(ValueError子类)不在 _handle_client 异常捕获列表中,同步模式(workers=0)下收到无效UTF-8字节会导致整个服务进程崩溃。修复:recv()中将UnicodeDecodeError包装为ConnectionError;_handle_client()异常列表增加防御性兜底。
2026-08-01 22:11:38 +08:00
qing af5cd02663 fix(aibox/botops): 思考区域按时间顺序分布,不再全部堆在顶部
问题根因:
1. ChatView.end_reasoning() 不清空 _reasoning_widget 引用,导致
   多轮工具调用时 start_reasoning() 直接 return,所有轮次的思考
   内容都被追加到第一个思考区域(在聊天区最顶部)。
2. AI widget 在工具调用期间不重置,导致后续轮次的思考区域被
   挂载在AI widget中间,顺序混乱。

修复:
1. end_reasoning()/abort_reasoning() 更新标签后清除引用(widget
   保留在DOM显示),下一轮 reasoning_start 创建新的思考区域。
2. 新增 ai_break 协议信号:服务端在工具调用执行前发送,前端
   break_ai() 清除AI widget引用(保留已显示内容),后续chunk
   创建新的AI widget,确保思考区域按时间顺序插入在两段AI文本之间。
3. ai_break 前flush chunk_buffer,防止工具调用前的最后文本片段丢失。
2026-08-01 21:10:42 +08:00
qing 763faf9c83 Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-08-01 21:02:49 +08:00
qing f3d06252bb fix(aibox/botops): 修复两处重复输出根因
1. 侧栏重复:_handle_tool_calls 中 msg_type in ("line", "complete")
   将流式工具末尾的 complete 信号也当作 line 转发,导致所有支持
   流式输出的工具(get_current_time、run_command、read_file等20+个)
   结果在侧栏显示两次。改为仅转发 "line" 类型。

2. 聊天区Markdown列表重复:Textual 的 Markdown.append() 在 chunk
   边界切断 Markdown 语法(如列表项 "- "被拆成两个chunk)时,增量
   解析可能导致内容重复渲染。改为累积完整文本后调用 update() 整体
   重新解析,避免增量解析的边界问题。
2026-08-01 21:01:31 +08:00
qing b60ac06d55 Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-08-01 20:57:02 +08:00
qing 66963092a3 feat(static): 优化社区首页 — Hero首屏+特性卡片+悬浮毛玻璃导航,CTA随登录态切换 2026-08-01 20:56:39 +08:00
qing 27ab50ae68 fix(aibox/botops): 修复重复输出问题
1. 服务端:is_continuation(工具调用后递归继续)时不再重复发送
   "思考中"提示和reasoning_start信号,避免多轮工具调用时侧栏
   出现多条冗余"思考中"消息
2. BotOps:line消息过滤前缀从5个扩展到9个,过滤掉"分析工具结果"
   "需要调用工具"等被后续具体信息覆盖的过渡提示
3. BotOps:移除危险的fallback分支(未知消息类型直接append到AI
   聊天区),改为写入侧栏日志,防止非AI内容污染回复
2026-08-01 20:53:40 +08:00
shufeng2012 627950394d ✨ 添加代码块语法高亮功能 2026-08-01 20:08:51 +08:00
qing 16b059253b Merge pull request 'Dev qing' (#16) from dev_qing into main
Reviewed-on: #16
2026-08-01 16:18:56 +08:00
qing f701730016 fix(aibox): 修复中断信号延迟生效问题,确保Ctrl+C能立即中断AI响应
根因:WebSocket消息接收循环使用await串行处理,导致interrupt消息在
AI整段对话完成后才被读取和处理。

修复内容:
1. 消息接收与处理解耦:用create_task异步处理消息,不再await阻塞接收循环
2. cancel()直接同步调用而非通过run_in_executor,消除线程池调度延迟
3. 强制关闭response.raw底层socket连接,中断阻塞的iter_lines() recv()
4. 添加_is_processing_message标志防止多消息并发污染对话历史
5. 连接关闭时自动取消正在进行的处理任务
2026-08-01 14:55:49 +08:00
qing f319686571 feat(static): aibox Web客户端重写为移动端专版
- 移除桌面端媒体查询与限宽居中,任务面板固定为抽屉形态
- 新增 enterkeyhint=send、overscroll-behavior、tap-highlight 等移动端触控优化
- 配合已提交的中断机制改动,协议与 BotOps 保持兼容
2026-08-01 14:17:36 +08:00
qing 08f737ba2c fix(static): aibox Web客户端适配中断机制(Ctrl+C可靠停止)
问题(与BotOps v1.3修复一致):
- interrupt_ack只显示系统消息,没有立即收尾前端状态
- 思考区域一直停留在"💭 思考中..."状态(服务端中断不发reasoning_end)
- AI状态未重置,残留reasoning片段可能重新创建思考区域

修复:
- 新增abortReasoning()函数:中断时更新思考区域标签为"🛑 思考已中断"(保留已展示内容)
- appendReasoning()增加aiActive===false检查,中断后丢弃残留片段
- interrupt_ack处理改为:先abortReasoning()更新思考区域,再finishAi()收尾AI状态,最后显示通知
- 中断后残留chunk/reasoning/complete均被安全忽略(appendAi已有aiActive检查,finishAi幂等)
2026-08-01 14:14:53 +08:00
qing c7ef7e715c fix(botops): v1.3 - 适配服务端中断机制,Ctrl+C立即可靠停止
问题:收到interrupt_ack时仅显示系统通知,没有立即收尾前端状态:
- 思考区域一直停留在"💭 思考中..."状态,不会更新
- AI回复状态未重置,后续残留chunk可能继续追加
- 服务端不发送reasoning_end,思考区域无法正常结束

修复:
- ChatView新增abort_reasoning()方法:中断时将思考区域标签更新为"🛑 思考已中断"(保留已展示内容)
- append_reasoning()增加_ai_finalized检查,中断后丢弃残留思考片段,防止重新创建思考区域
- app.py收到interrupt_ack时:先abort_reasoning()更新思考区域,再finish_ai()收尾AI状态,最后显示系统通知
- 中断后服务端发来的残留chunk/reasoning/complete均被安全忽略(append_ai已有_finalized检查,finish_ai幂等)
- 更新README v1.3日志
2026-08-01 14:12:27 +08:00
qing 9d737897df fix(aibox): v2.6 - 修复Ctrl+C中断无法停止服务器的问题
问题根因:
1. WebSocketHandler.handle_message 完全没有处理 interrupt 消息类型(前端Ctrl+C发送的消息被丢弃)
2. DeepSeekChat 缺少取消机制,阻塞的 API 请求(iter_lines)和子进程无法被中断

修复方案 - 三层取消机制:
1. threading.Event 取消标志:流式响应循环每轮检查 _is_cancelled(),工具调用前检查
2. HTTP连接关闭:保存 _current_response 引用,cancel() 时调用 response.close() 中断 iter_lines()
3. 子进程终止:SubprocessCommandExecutor 添加 cancel() 方法,保存 _current_process,cancel时kill

修改内容:
- DeepSeekChat: 添加 _cancel_event/_current_response/_command_executor 字段,cancel()/_is_cancelled()/_check_cancelled() 方法
- SubprocessCommandExecutor: 添加 __init__/_process_lock/cancel(),execute()/execute_streaming() 中设置和清除进程引用
- WebSocketHandler: 添加 handle_interrupt() 处理 interrupt 消息,回复 interrupt_ack 后调用 ai_manager.cancel()
- EnhancedAIManager: 添加 cancel() 统一入口,同时取消AI和命令执行器
- think_and_respond: 首轮清除cancel标志,流式循环检查取消,API/JSON/未知异常中均检查取消标志
- _handle_tool_calls: 每个工具调用前检查取消信号
- 更新README v2.6日志和API文档
2026-08-01 14:09:24 +08:00
qing b935fefb82 refactor(gateway): remove local ini fallback, configs only via confs
Follow the same convention as the static module: remove local config.ini
fallback and --config/-i command-line argument entirely. All configuration
is now managed exclusively through confs service.

- Delete gateway/example.ini (moved to confs/configs/gateway.ini).
- Remove -i/--input option from boost::program_options; help (-h) and
  version (-v) are the only CLI flags remaining.
- Simplify main.cpp config loading: remove local-file branch, always fetch
  gateway.ini from confs with retry_on_refused=true waiting for confs ready.
- Update _run.sh: no longer passes any arguments; config comes from confs.
- Update README: remove example.ini references, -i flag docs, local file
  mode description, and example.ini from directory structure tree.
- Clarify changelog entry for v0.7.0.
2026-08-01 14:00:26 +08:00
qing 9ccb9cbdca feat(gateway): migrate configuration to confs service (v0.7.0)
Migrate gateway from local -i ini file to the unified confs configuration
service, following the same two-layer pattern as Python services (dnfs/db/static):

- Add confs/configs/gateway.ini with all existing settings plus new
  [Downstream] section for db/captcha/smtp host:port (previously hardcoded).
- Conf.hpp: add fromString() to parse ini from text (confs response),
  env()/env_int() static helpers for environment variables, public default
  constructor, and empty-filename guard in file constructor.
- DbC.hpp: add init(host, port) static method and static _host/_port members,
  replacing hardcoded "127.0.0.1":8005. Defaults preserved for backward compat.
- main.cpp:
  * Rewrite config loading: bootstrap CONFS_HOST/CONFS_PORT from env
    (default 127.0.0.1:8010), fetch gateway.ini via pymsg from confs
    (retry_on_refused=true), parse with ConfIni::fromString().
  * -i flag is now optional; when provided, reads local ini (dev/debug mode).
  * Add downstream host/port globals (_DB_HOST/_DB_PORT, _CAPTCHA_HOST/_CAPTCHA_PORT,
    _SMTP_HOST/_SMTP_PORT, _CONFS_HOST/_CONFS_PORT), populated from [Downstream].
  * Environment variable overrides (highest priority): LISTEN_ADDR/LISTEN_PORT,
    DB_HOST/DB_PORT, CAPTCHA_HOST/CAPTCHA_PORT, SMTP_HOST/SMTP_PORT.
  * Config integrity validation: required fields (Server.address/port,
    Proxy.default, Downstream.db_host/db_port) cause startup failure if missing.
  * Initialize DbC::init() and logs().set_endpoint() after config load.
  * Replace all hardcoded "127.0.0.1":8005/8008/8009 in captcha/smtp/db calls
    with configurable globals.
  * Add downstream service info to startup log output.
  * Fix: -v/--version flag now actually prints version and exits.
  * Bump version to 0.7.0.
- _run.sh: remove -i example.ini default; now starts without args (confs mode),
  passes "$@" through for optional -i override.
- Update README with new config loading docs, Downstream config section,
  updated directory structure, and v0.7.0 changelog.
2026-08-01 13:55:18 +08:00
qing de58c0487a fix(dnfs): validate token via gateway /whoami with RSA signature verification
- Replace unsafe base64-only decode_token() with HTTP POST to gateway /whoami,
  which performs RSA signature verification and expiry checks internally
- Fail-closed on gateway unreachable: return 503 instead of allowing access
- Add [gateway] config section (url/timeout) with GATEWAY_URL env override
- Add missing @_handle_exp decorator to delete_note and update_note views
- Fix Flask endpoint name collision using @functools.wraps on _handle_exp
- Remove unused base64 import
- Update dnfs.ini with gateway config (http://127.0.0.1:8443/whoami, timeout=5)
- Update docker-compose.yml with GATEWAY_URL and host.docker.internal for containers
2026-08-01 13:49:28 +08:00
qing e7a2877422 feat(dnfs): delegate Token verification to gateway /whoami
Replace the insecure local base64 decode (with FIXME comment about
missing signature verification) with proper remote RSA signature
verification through gateway's /whoami endpoint:

- Add [gateway] config section (url, timeout) to dnfs.ini with defaults.
- Support GATEWAY_URL environment variable override for Docker.
- decode_token() now POSTs to gateway /whoami with Authorization: Bearer
  <token>; gateway verifies RSA signature and expiry, returns login/user/
  is_admin. Network/gateway errors fail-closed with 503.
- Add @_handle_exp decorators to delete_note() and update_note() which
  were missing exception handling wrappers (consistent with other routes).
- docker-compose.yml: add GATEWAY_URL, CONFS_HOST, CONFS_PORT env vars
  plus host.docker.internal extra_hosts so dnfs container can reach
  host-side gateway and confs services.
2026-08-01 13:42:59 +08:00
qing 7d6ba6de93 feat(gateway): add retry_on_refused for pymsg MsgClient (v0.6.1)
- Add MsgConnectionRefusedException to distinguish ECONNREFUSED from other
  connection errors; openConnection() now throws it when connect() returns
  ECONNREFUSED.
- MsgClient::call() adds retry_on_refused/retry_interval parameters (defaults
  false/1.0s), matching Python pymsg.MsgClient.call(retry_on_refused=True).
- Retry loop: only retries on ECONNREFUSED; other exceptions (timeout,
  connection reset, business errors) propagate immediately. Throttled logging:
  first failure prints immediately, subsequent logs every 5 seconds.
- DbC::call_msg() enables retry_on_refused=true (db is a hard dependency;
  gateway blocks waiting for db on startup instead of crashing).
- LogC::_send() explicitly passes retry_on_refused=false (fail-safe; never
  block the background log thread when db is unavailable).
- captcha/smtp auxiliary services keep default retry_on_refused=false
  (fail-fast; blocking in request threads would exhaust the thread pool).
- Update README with pymsg protocol documentation, downstream retry strategy
  table, and v0.6.1 changelog entry.
2026-08-01 13:42:24 +08:00
qing b30ce1424b docs(dnfs): update README with security audit findings and architecture corrections
- Fix architecture diagram to reflect pymsg TCP RPC (not HTTP), add confs dependency
- Update security headers to match actual security_middleware.py values
- Add complete P0/P1/P2 security audit findings (16 issues total):
  - P0: Token without RSA signature verification (forgery risk)
  - P0: review_content() fail-closed broken due to exception handling gaps
  - P0: delete_note/update_note missing @_handle_exp decorator
  - P0: Parent nodes with children can be deleted (no DB foreign key + TOCTOU race)
  - P1: No token expiry check, no request body size limit, log_client config mismatch
  - P2: Error message info leakage, overly permissive CORS, legacy commented code
- Correct file paths for frontend (static/public/), dependencies (pymsg, not nuitka)
- Fix outdated code line references
2026-08-01 13:28:41 +08:00
qing 7ef6a0b65f Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-08-01 13:19:58 +08:00
qing 531358919c fix conf 2026-08-01 13:16:45 +08:00
qing bd1d7ca4d0 refactor(libhttp): split HttpServer into modular TUs for faster incremental builds
Split the 1400-line HttpServer.cpp into 6 focused translation units by
functional area:
- HttpServer.cpp: core lifecycle, SSL init, session management, debug
- HttpServer_Listener.cpp: TCP connection acceptor
- HttpServer_Routes.cpp: route registration, matching, wildcard/query parsing
- HttpServer_Request.cpp: request dispatch, static files, CORS, HTTP I/O
- HttpServer_Ws.cpp: WS route registry, client management, broadcast APIs
- HttpServer_WsSession.cpp: per-session WS frames + bidirectional WS proxy

Add inc/us/detail/HttpServerImpl.hpp for internal shared definitions
(exception classes, full Session/Listener declarations) installed under
/usr/include/us/detail/. Update Makefile with proper dependency tracking.
Update README with Source Layout section and v3.3.0 changelog entry.

Modifying one module now only requires recompiling its corresponding .o
instead of the entire monolithic translation unit.
2026-08-01 13:15:52 +08:00
qing d883dc620e refactor(pymsg): 新增AsyncRpcQueue通用异步队列,log_client基于此重构
- pymsg新增AsyncRpcQueue:提供内存队列+后台线程+攒批处理的通用异步基础设施,
  支持两种模式:简单RPC模式(client+method+serialize)和自定义handler模式
- confs/dnfs/static的log_client.py重构为基于AsyncRpcQueue的薄封装,
  保持API完全向后兼容(push/info/error/warning/debug/critical + 全局logs单例)
- 消除了三份log_client副本中重复的队列/线程/攒批逻辑(约150行/份),
  代码量净减少约50行
- LogClient的行为与旧版一致:逐条INSERT到db、fail-fast网络错误、队列满丢最旧
2026-08-01 12:59:33 +08:00
qing 101aed48be feat(pymsg): RpcClient 新增 retry_on_refused 阻塞等待模式,解决微服务启动顺序依赖
pymsg 客户端:
- MsgClient.call() 新增 retry_on_refused/retry_interval 参数
  - retry_on_refused=True 时,ConnectionRefusedError 不抛出,按 retry_interval(默认1s)阻塞重试
  - 重试期间每5秒向 stderr 打印等待提示,避免日志刷屏
  - 仅对 ConnectionRefusedError 重试;TimeoutError/ConnectionError/RpcError 仍立即抛出
  - 默认 retry_on_refused=False,完全向后兼容
- RpcClient 构造函数新增同名关键字参数,call() 支持单次调用覆盖
- 新增 time 模块导入
- 更新 README 文档(新增"启动等待模式"章节和参数表)

各服务(db/dnfs/static):
- _fetch_from_confs() 改用 retry_on_refused=True,移除手动 ConnectionRefusedError 捕获和 sys.exit
- 保留 RpcError(如配置文件不存在)的友好错误处理和 sys.exit(1)
- 启动时不再因 confs 未就绪而退出:pymsg 自动阻塞等待 confs 启动后自动连接成功
- 启动顺序从"刚性要求 confs 先启动"变为"不严格要求启动顺序"
- log_client 保持默认 retry_on_refused=False,后台线程不阻塞(fail-safe 丢弃日志)
- 更新各服务 README 和 confs README 的启动顺序描述

测试验证:
- 基本 call() 正常工作
- retry_on_refused=False 连接拒绝立即抛 ConnectionRefusedError
- retry_on_refused=True 阻塞等待(延迟1.5s启动服务后成功连接,重试日志正常输出)
2026-08-01 12:47:08 +08:00
qing 0ae107f953 refactor(static): 删除默认配置与本地回退逻辑,confs 改为强依赖
- 配置加载从三层(内置默认值→confs→本地config.ini回退)改为两层(confs 强依赖→环境变量覆盖)
- confs 连接失败直接 sys.exit(1),不再静默使用内置默认值或回退本地 config.ini
- file_config 初始值改为空字符串白名单模板,移除所有内置默认值
- 新增配置完整性校验:server.host/port、directory.root、cors.allowed_origins 必须非空
- ssl 段保留合理默认(enabled=false、cert/key 默认文件名),不强制配置
- 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确错误信息到 stderr
- confs 连接地址改为引导配置(环境变量 CONFS_HOST/CONFS_PORT 优先,默认 127.0.0.1:8010)
- 移除 --config 命令行参数(本地 config.ini 回退已删除)
- 删除 static/config.ini 本地回退文件
- 更新 _run.sh 移除 --config 参数
- 更新 static/README.md 与 confs/README.md 文档
- confs 集成关系描述更新为"confs 最先启动"
2026-08-01 12:38:51 +08:00
qing de0e4168b2 refactor(static): 首页内联样式解耦至 css/index.css
- 新增页面级样式文件 css/index.css(与 view.css/aibox.css 惯例一致)
- index.html 移除内联 <style> 块与头像内联样式,改为外链
- 头像圆角 100px 优化为 border-radius: 50%
2026-08-01 12:37:19 +08:00
qing 1a5e9f7064 feat(static): 首页添加登录态检测与进入笔记入口
- index.html: lang 改为 zh-CN,新增"关于本站"介绍段落,按钮改为有 id 的登录/注册/进入/退出四按钮(默认隐藏进入/退出)
- 新增 index.js: POST /whoami 检测登录态(HttpOnly Cookie 自动携带),已登录则显示用户名+进入笔记/退出按钮,未登录显示登录/注册
- 退出按钮调用 POST /logout 后刷新页面
2026-08-01 12:34:53 +08:00
qing 84adec4722 refactor(dnfs): 删除默认配置逻辑,confs 改为强依赖
- 配置加载从三层(默认值→confs→环境变量)改为两层(confs 强依赖→环境变量覆盖)
- confs 连接失败直接 sys.exit(1),不再静默使用内置默认值
- _dnfs_config 初始值改为空字符串白名单模板,移除内置默认值
- 新增配置完整性校验:confs 返回后检查所有必需 section/key 非空
- 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确错误信息到 stderr
- confs 连接地址改为引导配置(环境变量优先,默认 127.0.0.1:8010),移除 dnfs.ini 中 [confs] 段
- 启动顺序调整为 confs → db → dnfs → ai_review/...(dnfs 同时依赖 confs 和 db)
- 更新 dnfs/README.md 与 confs/README.md 文档
2026-08-01 12:33:51 +08:00
qing 2cbb93b3b7 refactor(db): 删除 confs 连接失败时使用默认配置的 fail-safe 逻辑
- confs 改为 db 启动的强依赖:连接失败直接 sys.exit(1),不再静默使用默认值
- 配置加载从三层(默认值→confs→环境变量)改为两层(confs→环境变量覆盖)
- _db_config 初始值改为空字符串白名单模板,移除内置默认值
- 新增配置完整性校验:confs 返回后检查所有必需 section/key 非空,缺失则报错退出
- 区分错误类型:连接拒绝/RpcError/通用异常分别打印明确的错误信息到 stderr
- confs 连接地址改为引导配置(环境变量优先,默认 127.0.0.1:8010),不再从 db.ini 读取
- 更新 db/README.md 与 confs/README.md,说明启动顺序调整为 confs → db → ...
2026-08-01 12:29:55 +08:00
qing 6ebd544619 feat(db): 迁移 db 配置到 confs 统一配置服务
- 新增 confs/configs/db.ini 集中托管 db 服务配置(server/database/logs/confs)
- dbv2.py 采用三层 fail-safe 加载模式:内置默认值 → confs(db.ini,优先)→ 环境变量覆盖(最高优先级,Docker 兼容)
- _merge_ini() 白名单合并,仅覆盖已知 section/key,ConfigParser(interpolation=None) 禁用插值
- db 先于 confs 启动时连接失败静默 fail-safe,启动时打印配置来源
- db_tools.py 运维工具保持独立,直接读 DATABASE_URL 环境变量,不依赖 confs
- 更新 confs/README.md 与 db/README.md 文档
2026-08-01 12:23:21 +08:00
qing c06eb19f64 refix 2026-08-01 11:47:14 +08:00
qing 18387586a4 Merge pull request 'Dev qing' (#15) from dev_qing into main
Reviewed-on: #15
2026-08-01 06:12:13 +08:00
qing 46b66f8bd8 fix(gateway,static): 配置 /ws/aibox WS 反代路由;aibox 桌面端对话左对齐 2026-08-01 06:08:20 +08:00
qing e25c132722 解耦libhttp/HttpClient 2026-08-01 06:04:36 +08:00
qing 373f6af352 ^-^ 2026-08-01 06:04:27 +08:00
qing 65580e7e50 refactor(static): aibox.html 拆分内联 CSS/JS 为独立文件 2026-08-01 05:27:02 +08:00
qing 227fbb17f4 build(libhttp): lower optimization to -O2 to avoid cc1plus OOM on low-memory machines 2026-08-01 05:26:52 +08:00
qing cebef9fe70 docs: 各模块安全审计报告 + 补充 .gitignore 忽略规则 2026-08-01 05:21:23 +08:00
qing 12ece9020c feat(libhttp,gateway): add admin-only WebSocket reverse proxy
libhttp v3.1:
- add ws_proxy(path, handshake) API with WsProxyHandshakeHandler callback
- implement full-duplex async bidirectional bridging (text+binary frames)
- support ws:// and wss:// backends with SNI, write queues for ordering
- proxy routes take priority over regular ws() routes
- 403 response on handshake rejection, graceful close propagation

gateway v0.6.0:
- add [WsProxy] config section (routes = /path=ws://backend;...)
- handshake callback verifies Token (Authorization/Cookie) is_admin=true
- non-admin / expired / missing token → 403, admin → transparent bridge
- fix LocalTime.hpp C++20 tzdb incompatibility on Apple clang (use strftime/get_time/mktime)
- update example.ini and README docs for both modules
2026-08-01 05:20:14 +08:00
qing 6ed88feaa4 feat(static): 新增 aibox 智能体 Web 客户端(移动端适配)
- aibox.html:自包含移动端聊天页,经 gateway [WsProxy] 反向代理与 aibox 通信
- 协议兼容 BotOps client.py:chunk 流式渲染 / reasoning 思考区 / task 任务面板 / 指数退避重连
- 移动端适配:100dvh 动态视口 + safe-area 刘海屏 + 44px 触控目标 + 任务面板抽屉式
- 安全:握手由 gateway 强制 is_admin 验签,Token 全程 HttpOnly Cookie,CSP 合规(本地 markdown-it)
- README 同步页面清单与功能说明
2026-08-01 05:18:30 +08:00
qing bf2b5d31f7 fix(botops): v1.2 - 彻底修复思考区域与正式回答顺序问题
问题根因:之前预创建空 AI widget,思考 widget 挂载在其后,即使动态重排也可能因 Textual 组件树更新时序导致顺序不稳定。

修复方案:
- 移除 prefab 空 AI widget 预创建逻辑
- start_ai() 仅标记状态,不创建任何 widget
- _ensure_ai_widget() 延迟到第一次 chunk 到来时才创建 AI widget
- 按消息到达顺序自然挂载:reasoning_start → reasoning → chunk → end_reasoning/finish_ai
- 思考 widget 必然先于 AI widget 挂载,顺序天然正确

同时:
- 简化 app.py 发送失败处理,不再需要移除空 widget
- 更新 README v1.2 说明
2026-08-01 05:00:56 +08:00
qing 9815ff44af feat(libhttp): integrate WebSocket client into HttpClient
Merge WebSocket client functionality into HttpClient using Boost.Beast,
enabling a single client instance to handle both HTTP (libcurl) and
WebSocket (ws:// and wss://) protocols. Backward-compatible type aliases
(WebSocketClient, WebSocketClientBase) preserve existing code.

Changes:
- Add synchronous WebSocket client: ws_connect/ws_send/ws_recv/ws_close
- Support ws:// (plain TCP) and wss:// (TLS with SNI) connections
- Layered stream architecture with beast::tcp_stream for timeout support
- Binary message support via ws_send_binary()
- Update Makefile to link libcurl (-lcurl)
- Add README.md documenting unified HttpServer + HttpClient API
2026-08-01 04:59:47 +08:00
qing 5edb67b46d docs(botops): update README with reasoning mode ordering fix and v1.1 changelog
- Update reasoning display limit from 2000 to 3000 chars in protocol docs
- Add widget ordering guarantee note (reasoning always before answer)
- Document info message filtering for conversation start notifications
- Add v1.1 changelog section listing reasoning mode features and fixes
2026-08-01 04:56:25 +08:00
qing 039d6acf37 fix(botops): fix reasoning panel appearing after AI response and filter conversation start notification
- Add _ai_prefab flag to track pre-created empty AI widget
- start_reasoning() now reorders widgets: remove empty AI widget, mount reasoning first, then remount AI widget
- This ensures reasoning always appears BEFORE the actual answer
- Filter out 'Conversation started'/'对话开始' info notifications (implicit event, no need to show)
- Increase reasoning display limit from 2000 to 3000 chars
2026-08-01 04:55:12 +08:00
qing ba9a77fe66 feat(libhttp): integrate WebSocket support into HttpServer
Merge WebSocketServer functionality into HttpServer so a single port can
serve both HTTP(S) and WebSocket(S) endpoints while preserving full backward
compatibility for existing HTTP interfaces.

Key changes:
- Add WebSocket route registration: ws(), onWsConnect(), onWsDisconnect()
- Add client management: sendToWsClient(), broadcastWs(), disconnectWsClient()
- Rewrite Session class using beast::tcp_stream for unified HTTP/WS handling
- Support automatic ws:// and wss:// upgrades on HTTP/HTTPS ports
- Thread-safe message queue with asio::post for cross-thread sends
- Update Makefile to link OpenSSL and pthread for TLS/WebSocket support
- Add test_server.cpp demonstrating HTTP + WebSocket mixed usage
2026-08-01 04:43:33 +08:00
qing e007990594 feat(dnfs,static): 新增管理员特殊节点(链接/网页代码/图片/文件)
- 信封格式:content 为 {"_sp":...} JSON,普通 Markdown 节点不受影响
- dnfs:add_note/update_note 按 is_admin 强制特殊节点发布权(非管理员 403),
  字段与大小校验(url≤2048、code≤512KB、base64≤3.5M 字符),AI 审核剔除 base64 负载
- dnfs:修复存量 bug —— 模块级 _sys.path.insert 未定义导致 import 即 NameError
- static:新增 GET /api/favicon 服务端代理(SSRF 防护:内网地址拦截 + 重定向校验 + 512KB 上限)
- 前端:管理员可见节点类型选择器;链接 favicon 卡片 / 网页可审计可运行 / 图片直显 / 文件下载
- theme.css 补充 select 与特殊节点样式;两个 README 同步更新
2026-08-01 04:34:23 +08:00
qing b79044d631 feat(aibox): add DeepSeek reasoning mode support with tool calling on deepseek-v4-flash
- Add api.reasoning_enabled config (default false) to enable thinking mode
- Uses deepseek-v4-flash native thinking capability (thinking param), supports tool calls while reasoning
- Real-time reasoning content streaming via reasoning_start/reasoning/reasoning_end WS messages
- Frontend shows reasoning in gray italic area, auto-folds long reasoning (>2000 chars)
- Dynamic reasoning detection: shows reasoning even if model returns it without explicit config
- Increase default API timeout to 120s for longer reasoning responses
- Remove duplicate _add_assistant_message_with_reasoning method
- Update docs: config example, message types table, v2.5 changelog
2026-08-01 04:33:55 +08:00
qing 81ae84851d feat(waf): add WebSocket proxy support and fix HTTP/2 proto version
- Implement http.Hijacker on responseRecorder to enable WebSocket upgrade
  (delegates Hijack() to underlying ResponseWriter, marks hijacked state)
- After WebSocket upgrade (101 Switching Protocols), skip HTTP response-phase
  WAF checks since connection becomes a binary frame stream
- Log WebSocket upgrades at Info level for observability
- Fix P2-3: use r.Proto instead of hardcoded "HTTP/1.1" in ProcessResponseHeaders
  for correct HTTP/2 protocol version reporting
- Add Write() method to responseRecorder to ensure WriteHeader is called
  before first Write (complies with http.ResponseWriter contract)
- Add bufio/net imports required by Hijacker interface
- Update README with WebSocket support section and v1.2 changelog
- Update SECURITY_AUDIT to mark P2-3 and P2-4 as fixed
2026-08-01 04:18:30 +08:00
qing 4e190953c0 feat(confs): integrate dnfs service with centralized config management
- Add dnfs.ini to confs/configs/ with server, database, ai_review, confs sections
- Refactor dnfs.py to use three-layer fail-safe config loading (defaults → confs → env vars)
- Unify timeouts for DB RPC and AI review calls via configuration
- Add _merge_ini() whitelist merging to prevent unexpected config injection
- Environment variables have highest priority for Docker networking (db/ai_review hostnames)
- Document config loading mechanism in both confs/README.md and dnfs/README.md

Follows the same pattern as static service integration; confs failure falls back
gracefully to built-in defaults + environment variables.
2026-08-01 04:13:39 +08:00
qing b4436b192a fix(waf): harden against OOM and Slowloris DoS attacks
- P0: add 10MB hard request body size limit with io.LimitReader,
  return 413 Request Entity Too Large when exceeded, prevents OOM
- P0: configure http.Server timeouts: ReadHeaderTimeout=10s (Slowloris),
  ReadTimeout=15s, WriteTimeout=30s, IdleTimeout=120s, MaxHeaderBytes=1MB
- P1: properly handle body read errors, log and return on client disconnect
- P2: drain and close r.Body in defer to ensure TCP connection reuse
- Log errors from ReadRequestBodyFrom and ProcessRequestBody
- Inject security headers on 413 responses
- Add security audit report (docs/SECURITY_AUDIT_20260801.md)
- Update README with timeout/body-limit docs and changelog
2026-08-01 04:13:39 +08:00
qing bd544a4181 docs(aibox): update file line count to ~8450 2026-08-01 03:51:17 +08:00
qing 164bc65b95 feat(aibox): add dedicated workspace directory to prevent file pollution
- Add WORKSPACE_DIR constant pointing to ~/.aibox/workspace by default
- Update file operations prompt to require writing temp files to workspace
- Update command execution prompt to use workspace as default work_dir
- Add command output size limit note in command execution prompt
- Update README with workspace directory structure and specification
- Add system.workspace_dir config item documentation
- Add v2.4 changelog entry
2026-08-01 03:49:25 +08:00
qing 53ce69c8dd docs(confs): update README for P1 fixes and clarify RpcClient usage
- Update title/description: pymsg RpcServer/TCP instead of plain pymsg/TCP
- Add new feature entries: cache copy return, list symlink guard, strict
  param validation, ConfigParser interpolation=None, Slowloris protection
- Fix Python call examples: RpcClient.call() returns data directly, not
  wrapped in {'success':..., 'data':...}; add RpcError try/except example
- Clarify reload semantics: empty string/non-string name returns error;
  only omitted name or explicit null triggers full cache clear
- Add parameter validation rules for get (name/key type checks) and reload
- Document INI % literal behavior (interpolation disabled)
- Add '返回值隔离' to cache mechanism section
- Expand security mechanism table: cache anti-tamper, list symlink guard,
  strict param validation, INI safe parsing
- Add docs/ directory and audit report reference to project structure
- Add notes about correct reload usage, INI % handling, and return copy
- Add audit report cross-reference in 注意事项
2026-08-01 03:46:39 +08:00
qing 2f3e63e499 fix(aibox): prevent OOM from large tool outputs with multi-layer safeguards
- ReadFileTool: replace readlines() with on-demand range reading; add reverse block reading for large file tail; limit single line to 10KB; hard-cap max_lines to 500
- SubprocessCommandExecutor: replace capture_output with line-by-line Popen reading; enforce 1MB output limit with immediate process termination; merge stdout/stderr counting for both sync and streaming modes
- Raise TOOL_MAX_RESULT_LENGTH default from 10KB to 1MB as final safety net
- Add SECURITY_AUDIT.md documenting vulnerabilities and remediation roadmap
- Update README with memory safety features and v2.3 changelog
2026-08-01 03:46:02 +08:00
qing 90761a723d docs(confs): fill in commit hashes for fixed issues in audit report 2026-08-01 03:43:01 +08:00
qing b53d4999ce fix(confs): harden parameter validation and prevent cache/info leaks
P1 fixes:
- P1-1 _handle_reload: empty string/0/false/falsy name no longer clears all
  caches; only explicit null/omitted name triggers full cache clear; non-string
  name raises ConfigError instead of being silently str()-converted
- P1-2 ConfigStore.get(): return dict(entry) shallow copy instead of direct
  cache reference to prevent callers from mutating the internal cache
- P1-3 list(): resolve symlinks via os.path.realpath() and skip entries whose
  real path escapes configs/ root; prevents leaking external file metadata
  (size, mtime) through directory-traversing symlinks
- P1-4 _handle_get key: validate key is a string (isinstance check); reject
  non-string/empty key with clear ConfigError instead of misleading str()
  conversion leading to "key not found" errors

P2 fixes:
- P2-1 ConfigParser: use interpolation=None to disable %(key)s interpolation,
  preventing InterpolationError on config values containing bare % characters
- P2-2 argparse: update description from "pymsg/TCP" to "pymsg RpcServer/TCP"
  to match module docstring

Also update SECURITY_AUDIT_20260801.md to mark issues 9-14 as fixed and
refresh the security mechanisms summary table; all P0/P1 issues are now resolved.
2026-08-01 03:42:46 +08:00
qing e4e9968d14 docs(confs): add security audit report 20260801
- Audit covers confs.py, log_client.py, startup scripts, and pymsg/rpc.py dependency
- Documents 8 issues fixed during this audit cycle (P0-P2) including Slowloris DoS,
  OOM, thread death, TOCTOU race, and log_client hardening
- Lists 4 remaining P1 issues (reload empty string clears all cache, mutable cache
  reference leak, list() symlink metadata leak, key type validation), 4 P2 issues,
  and 6 P3 issues with root cause, risk analysis, and fix suggestions
- Security mechanisms summary table for quick reference
- Follows project convention: gateway/docs/SECURITY_AUDIT_YYYYMMDD.md
2026-08-01 03:37:22 +08:00
qing 9efb96ea66 fix(confs): eliminate TOCTOU race in ConfigStore path resolution
- Split _resolve() into _validate_name() (pure string checks, lock-free
  fast-fail) and _resolve_locked() (filesystem checks, must be called
  under lock)
- Perform all filesystem operations (realpath → isfile → stat → open)
  inside self._lock in get(), eliminating the window between path
  validation and file open where a symlink swap could escape the
  configs directory
- Apply same fix to reload(): validate name outside lock, resolve path
  inside lock to prevent TOCTOU during targeted reload
- Hold lock during list() directory traversal so the `cached` flag
  (which reads self._cache) is consistent with directory state; wrap
  os.stat() in try/except to tolerate files disappearing mid-traversal
- Update docstring and README:
  - Protocol description corrected to pymsg RpcServer/TCP
  - Python client examples updated from legacy MsgClient to RpcClient
  - Security section now documents TOCTOU mitigation and Slowloris
    protection (added by earlier pymsg fix)
2026-08-01 03:32:19 +08:00
qing 6c587a22d8 docs(db): update README for log_client SDK template and hardening features
- Document that example_log_client.py now uses RpcClient (high-level RPC)
  instead of legacy MsgClient (low-level TCP frames)
- Add comprehensive client feature list reflecting all hardening fixes:
  - Thread exception protection (outer try/except prevents thread death)
  - Accurate enqueue-time timestamps
  - Queue maxsize with drop-oldest policy
  - Safe JSON serialization fallback
  - Event-based flush synchronization
  - Non-blocking push() guarantee
- Clarify length truncation: server-side _log_internal() truncates
  internal logs to 8KB/4KB, external clients should control length
- Add db_tools.py to file table (was previously undocumented)
- Update service description to mention RpcServer
2026-08-01 03:28:40 +08:00
qing bdbea6ac40 fix(log_client): update example_log_client template to use RpcClient
- Replace legacy MsgClient (low-level TCP frames) with RpcClient (high-level
  RPC) to match dbv2's RpcServer interface; the old MsgClient-based version
  was incompatible with the current RPC routing protocol
- Apply all hardening fixes from the service copies:
  - Outer try/except in _flush_loop to prevent any exception from killing
    the background thread
  - _safe_json() helper for unserializable extra fields (fallback to str/repr)
  - Timestamp generated at push() (enqueue time) instead of _send() time
  - Event-based flush() synchronization instead of unreliable sleep(0.6)
  - Queue maxsize=10000 with drop-oldest policy to prevent unbounded memory
  - put_nowait with Full handling ensures push() never blocks business threads
  - RpcClient construction wrapped in try/except
- Add explicit service_name parameter example for all 4 services in comments
- This is the template file referenced by db/README.md and copied by new
  services; keeping it in sync prevents propagating old bugs
2026-08-01 03:26:19 +08:00
qing 3be9e78335 docs(aibox): update README for v2.2 prompt system fixes
- Expand prompts directory listing to show all 10 skill files explicitly
- Update prompt system table: '1_*.md' → 'N_*.md (N=1~10)' with numeric ordering
- Document load order guarantee (identity → skills 1-10 → soul → status)
- Document is_continuation behavior (no repeated pre_prompt on recursive calls)
- Add v2.2 changelog: prompt ordering fix, token waste reduction,
  recursion pre_prompt dedup, large-file threshold, memory search tuning
2026-08-01 03:24:39 +08:00
qing 1b9c727e80 fix(log_client): harden background flush thread against exceptions
- Wrap _flush_loop main body in outer try/except to prevent any
  unexpected exception (network errors, serialization errors, etc.)
  from killing the background thread (which would cause unbounded
  queue growth and eventual OOM)
- Wrap _send() call in inner try/except as final safety net
- Wrap RpcClient() construction in try/except (connection failures
  during client creation were previously unhandled)
- Add _safe_json() helper: unserializable extra fields fall back to
  repr()/str() instead of raising TypeError that could kill thread
- Generate timestamp at push() (enqueue time) instead of _send() time,
  so timestamps reflect actual event time even when queue is backed up
- Replace unreliable time.sleep(0.6) in flush() with _flush_done Event
  for proper synchronization between caller and background thread
- Add _QUEUE_MAXSIZE=10000 with drop-oldest policy to prevent unbounded
  memory growth even in pathological cases
- Use put_nowait with Full handling to ensure push() never blocks
  business threads
- Sync fix to all 4 copies: confs/static/smtp/dnfs
2026-08-01 03:23:19 +08:00
qing e8fa26d79f fix(aibox): fix prompt ordering, reduce per-turn token waste, and improve defaults
Prompt system bugs and improvements:

1. **SKILL_PREFIX bug fixed**: SKILL_PREFIX = "1_" only matched files starting
   with "1_", causing 2_ through 10_ skill prompt files to be misclassified
   as "other" files and sorted in wrong order (e.g., 10_task_handling.md
   sorted before 2_memory_save.md due to lexicographic ordering). Replaced
   with regex _SKILL_FILE_RE = re.compile(r'^(\d+)_') and _skill_sort_key()
   that extracts the numeric prefix for proper numeric sorting.

2. **_init_system_prompts used unsorted list**: get_prompts_list() returned
   prompts in os.listdir() lexicographic order, which put 100_STATUS.md
   before 1_memory_search.md and 10_task_handling.md before 1_memory_search.md.
   Now uses get_sorted_prompts_list() for correct ordering:
   identity → skill prompts (1-10 by number) → soul → status.

3. **Verbose pre_prompt injected every turn (~30 lines)**: The pre_prompt
   contained detailed workflow instructions (search_skills first, memory
   search, ask_user, etc.) that duplicated what system skill prompts
   already say, wasting ~300+ tokens per user message. Worse, the same
   verbose prompt was injected on recursive think_and_respond calls after
   tool execution, confusing the model with repeated "第一步搜索技能"
   instructions when it was already mid-conversation with tool results.
   - Reduced pre_prompt to 5 concise bullet points as a reminder
   - Added is_continuation parameter: recursive calls after tool execution
     skip pre_prompt entirely and just pass "请基于以上工具执行结果继续回答。"
   - Removed post_prompt (save_memory/update_soul instructions already in
     2_memory_save.md and 8_identity_management.md)

4. **Unreasonable large-file thresholds**: 3_file_operations.md default
   said >50KB or >200 lines is "too large" — too conservative for code
   files. Raised to 500KB/2000 lines; added tail=true and binary file
   guidance.

5. **Overly rigid memory search instruction**: 1_memory_search.md said
   "每次都要做!" (must do every time), causing unnecessary memory searches
   for simple greetings/acknowledgments. Softened to "根据话题相关性判断",
   with explicit examples of when to skip (greetings, confirmations).

New/changed methods:
- PromptManager._skill_sort_key(cls, filename) - extract numeric prefix
- PromptManager._prompt_sort_key(self, p) - tuple-based sort key
- PromptManager.get_sorted_prompts_list() - returns correctly ordered list
- PromptManager.get_skill_prompts() - uses regex instead of startswith("1_")
- DeepSeekChat.think_and_respond() - added is_continuation parameter
2026-08-01 03:19:58 +08:00
qing da7f11d352 fix(confs): add MAX_CONFIG_SIZE (1 MiB) to prevent OOM on oversized configs
- Add MAX_CONFIG_SIZE constant (1 MiB) to limit single config file size
- Check file size via os.stat() before reading into memory
- Oversized files raise ConfigError with descriptive message instead of
  being fully read (which could cause OOM with multi-GB files)
- Use single os.stat() call to get both mtime and size atomically
- Update README: document size limit in security mechanisms and notes
- Existing small configs (ini/json/yaml, typically <10KB) are unaffected
2026-08-01 03:15:06 +08:00
qing 9708fbc87e fix(pymsg): add recv_timeout to MsgServer to prevent Slowloris DoS
- Add configurable recv_timeout (default 10s) to MsgServer
- _handle_client now uses recv(s, timeout=...) instead of infinite blocking recv(s)
- Catch TimeoutError and close the slow client connection
- This prevents a single stalled connection from blocking the entire main select loop
- Update README with recv_timeout parameter and Slowloris mitigation docs
- Backward compatible: keyword-only arg with sensible default
2026-08-01 03:12:49 +08:00
qing ae42ab8a91 docs(aibox): update README for v2.1 robustness fixes and correct entry filename
- Fix all references from main.py to aibox.py (actual entry file)
- Update installation to use pip install -r requirements.txt
- Expand WebSocket message types table: add line/task_error/task_added/
  notification/connection_ack/interrupt_ack, correct chunk/complete/error semantics
- Fix message flow example: tool calls are reported via 'line' messages,
  not a non-existent 'tool_call' type
- Document _sanitize_tool_result() and error recovery (_rollback_messages)
  in development guide
- Add FAQ entry for tool-call-induced API failures
- Add v2.1 changelog covering all robustness fixes (reasoning_content
  filtering, tool_call id fallback, control character sanitization,
  message rollback on API failure)
2026-08-01 03:09:34 +08:00
qing a80bd582da fix(aibox): prevent tool results from corrupting conversation and breaking API requests
Root causes of API request failures after tool calls:
1. reasoning_content (an API response field) was echoed back in assistant
   messages, which could cause API format errors
2. tool_call id could be empty string when model didn't return one in a
   streaming chunk, producing invalid tool_calls that caused 400 errors
3. Tool execute() could return None or non-string types; direct string
   operations (len(), + "\n", [:200]) raised TypeError and produced
   non-string content in tool messages, breaking JSON serialization
4. Tool output (e.g. from run_command) could contain control characters
   (NULL/BEL/BS) that corrupted JSON payloads
5. When API request failed (network/4xx/5xx/parse error), messages added
   in that turn (user msg, assistant msg with tool_calls, tool responses)
   remained in self.messages, permanently poisoning conversation history
   so all subsequent requests would also fail

Fixes:
- Stop sending reasoning_content back to API in message construction;
  normalize assistant message content: null when tool_calls present, ""
  when neither content nor tool_calls exist, str otherwise
- Sanitize tool messages: ensure tool_call_id is non-empty (fallback id)
  and content is always a string
- Generate fallback tool_call id when model omits it; handle streaming id
  delivery where id arrives in a later chunk
- Add _sanitize_tool_result(): coerce None/non-string to str, strip
  control characters (preserve \n \t \r) to prevent JSON/API corruption
- Wrap execute_tool call in try/except in _handle_tool_calls for double
  safety; ensure history logging never crashes
- Truncate oversized results with an explicit note instead of replacing
  with a generic error that lost all useful output
- Add _rollback_messages(to_length) and record msgs_before_turn at start
  of think_and_respond; on any exception (RequestException, JSONDecodeError,
  or unexpected Exception), rollback self.messages to the pre-turn state
  so the conversation is not left in a broken state
- In _handle_tool_calls, wrap execute_tool in try/except and always
  produce a safe string result
2026-08-01 03:06:34 +08:00
qing 1b9e6f6b3a fix(botops): resolve duplicate AI response rendering in ChatView
Root cause: aibox.py think_and_respond recursively calls itself after tool
execution, and each recursive call's complete callback carries only the
final-round full_response. The old finish_ai logic treated complete as a
full-text alignment signal — when full_text != self._ai_text (which always
happened after recursive tool calls because _ai_text accumulated all chunks
while complete only held the last round), it triggered remove+remount,
causing visible duplicate/flash of AI responses. A secondary issue was
dialogue_ended arriving before complete, which made a second finish_ai call
remount the message a second time.

Changes:
- Remove the full-text replacement branch in finish_ai; chunk streaming is
  now the authoritative content source, complete is a finalization signal only
- Add _ai_finalized flag: start_ai resets it, finish_ai sets it; repeated
  finish_ai calls (dialogue_ended + complete) return early to prevent remount
- append_ai drops late chunks after finalization to avoid dirty appends
- Empty pre-built widget without chunks still gets filled via w.update(full_text)
- Full lost-frame fallback (no widget + full_text) is preserved
- Update BotOps/README.md protocol section to reflect new complete semantics
2026-08-01 02:56:29 +08:00
qing f003b4dfaa style(static): 注册页图形验证码统一为 code-box 布局
- register.html 图形验证码改用 .code-box 行内布局,与邮箱验证码对齐
- theme.css 新增可复用 .captcha-img 类,替代内联样式
- README 功能列表补充表单组件说明
2026-08-01 02:51:55 +08:00
qing eb506e5cbf docs(confs): 更新 README 反映 static 已接入现状
新增"已接入的服务"表格,记录 static 的三层 fail-safe 接入方式
及实现细节;注意事项改为指向集成章节,避免重复描述。
2026-08-01 02:46:58 +08:00
qing ec1eb1e805 docs(gateway): 补充前端接入图形验证码说明
- README.md v0.5.4 变更记录补充前端改动(register.html/register.js 接入图形验证码)
- SECURITY_AUDIT_20260801.md P0-1 修复说明补充第 5 项前端同步,改动文件清单补全
2026-08-01 02:43:06 +08:00
qing 6ee1eca789 feat(static): 注册页获取验证码接入图形验证码
配合 gateway v0.5.4 邮件轰炸防护(/register/send_code 强制 CAPTCHA):

- register.html: 邮箱与邮箱验证码之间新增图形验证码区块(图片+输入框,复用 login 页结构)
- register.js:
  - 新增 refreshCaptcha() 复用 /captcha/image 代理路由(与 login.js 一致)
  - 新增 checkCaptcha() 校验图形验证码非空且 Token 已加载
  - 获取验证码按钮点击时先校验图形验证码,再携带 captcha_token + captcha_answer 发送
  - 无论发送成败都刷新图形验证码(Token 一次性消费)
  - 重置表单时清空图形验证码并刷新

方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加(邮箱验证码已是二次验证)
2026-08-01 02:42:15 +08:00
qing 0ef96c28f6 docs(confs): 新增服务 README
含功能特性、协议路由、缓存/安全机制、集成关系与项目结构
2026-08-01 02:38:20 +08:00
qing 49f0698b9d fix(gateway,smtp): 修复邮件轰炸漏洞(审计 P0-1)
gateway v0.5.3 → v0.5.4:

1. gateway/src/main.cpp
   - /register/send_code 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费,fail-closed)
   - 新增邮箱级双窗口限流(3 次/小时 + 10 次/天,CAPTCHA 通过后才计入配额)
   - 新增 _EMAIL_ADDR_RATE_HOUR / _EMAIL_ADDR_RATE_DAY 限流器实例

2. smtp/smtp_v2.py
   - 新增邮箱级长期限流兜底(EMAIL_HOURLY_LIMIT / EMAIL_DAILY_LIMIT,默认 3/10)
   - 新增全局熔断(SMTP_HOURLY_LIMIT,默认 100 封/小时,防 smtp 账号被封)
   - 预占/回滚与 60s 冷却同模式,发送失败不计入配额

3. gateway/docs/SECURITY_AUDIT_20260801.md
   - 新增带日期的安全审计报告
   - P0-1 标记为已修复 v0.5.4

4. gateway/README.md
   - 更新 /register/send_code 接口文档(CAPTCHA 必填 + 5 层防护)
   - 更新版本变更日志

防护层次:IP 限流 → CAPTCHA → 邮箱级限流(gateway)→ 60s 冷却 → 邮箱级限流(smtp)→ 全局熔断
2026-08-01 02:37:15 +08:00
qing 2774c3471f docs(readme): 补充 confs 统一配置服务说明
- 架构总览/服务表格/端口表新增 confs(8010),日志链路加入 confs,新增配置链路
- 新增 confs 小节:路由说明、mtime 自动重载、消费方接入约定
- static 配置说明更新为 confs 分发 + 本地回退
- 修正安装序列(补 captcha/confs)、init.txt 启动顺序与实际一致
2026-08-01 02:34:51 +08:00
qing 287cab189d feat(static): 配置接入 confs 统一配置服务
- fsys.py 启动时优先从 confs(127.0.0.1:8010)拉取 static.ini,
  支持 CONFS_HOST/CONFS_PORT 环境变量覆盖
- confs 不可用时 fail-safe 回退本地 config.ini
- static.ini 纳入 confs/configs/ 统一分发
2026-08-01 02:30:14 +08:00
qing 7d7e3dc10a fix(confs): 删除错放的 .env 文件
confs.py 不读取 .env(无 dotenv 依赖),该文件实为 SMTP 凭据,
被误放入 confs 目录且会导致 python-dotenv 解析报错。
2026-08-01 02:20:46 +08:00
qing 55fbe2f1f1 feat(confs): 统一配置服务(pymsg/TCP :8010)
- confs.py 重写为统一配置服务:get/list/reload/health 路由,
  内存缓存 + mtime 自动重载,路径遍历防护,ini/json 按键提取
- 复制 log_client.py,新增 configs/example.ini 示例配置
- 注册到 core/init.txt(db 之后启动)与 _inst.sh 安装序列
2026-08-01 02:15:04 +08:00
qing ae92a6edde perf(pymsg): send/recv 零拷贝优化
send: sendmsg 散射-聚集 I/O 替代 header+payload 拼接,内核直接
从两块内存读取,省掉大消息的用户态拷贝。

recv: recv_into 写入预分配 bytearray 替代 bytearray 逐步 extend,
省掉 realloc 和 bytes() 转换。
2026-08-01 02:08:03 +08:00
qing 267d572906 fix(pymsg): 修复消息长度无上限导致 OOM DoS 漏洞
Python/Go 端 recv 缺失消息大小上限检查,恶意客户端发送 4 字节
超大长度头即可触发 OOM。新增 64 MiB 上限(与 C++ 端 Msg.hpp
对齐),在分配内存前拒绝并断开连接。新增 5 个单元测试覆盖。
2026-08-01 01:55:44 +08:00
qing e248290860 统一配置模块 2026-08-01 01:54:25 +08:00
qing 48b45cb44a 修复网关bug 2026-07-31 22:19:56 +08:00
qing e7f435dc4e refix 2026-07-31 21:07:41 +08:00
qing ada2af47af 增加handle_exp包装器 2026-07-31 19:47:27 +08:00
qing ea670adde2 feat(static): dnfs 内容输入框随内容自动调整高度
- textarea 输入时自动增长,最小 120px,最大 400px
- 超过最大高度后出现滚动条,避免页面无限撑开
- 编辑已有节点时自动适配内容高度
- 提交/重置/退出编辑后恢复初始高度
- 禁用手动拖拽 resize,由自动增长统一控制
2026-07-30 22:21:24 +08:00
qing 5839172dae 迁移/whoami视图 2026-07-30 22:05:12 +08:00
qing 3f97dd92ef update 2026-07-30 21:57:04 +08:00
qing f935448389 refix 2026-07-30 21:33:03 +08:00
qing 32d3a5a19f 重构db 2026-07-30 20:15:02 +08:00
qing edb53d079c Merge pull request 'Dev qing' (#14) from dev_qing into main
Reviewed-on: #14
2026-07-30 19:47:21 +08:00
qing 74610c32c8 微调 2026-07-30 19:03:34 +08:00
qing eec869a28d style(dnfs): 增大节点表单输入框尺寸 2026-07-30 15:56:06 +08:00
qing d309f41e47 refix 2026-07-30 15:52:18 +08:00
qing 7589b77916 docs: 更新 README.md - 补充 bot4cj、libtry、tests 等缺失模块说明 2026-07-30 12:44:29 +08:00
qing e4831522a4 docs: 新增 libhttp/libtry/pymsg/README.robot.md 技术手册,更新 BotOps/README.md 2026-07-30 12:38:31 +08:00
qing 8a14247aed docs(BotOps): 安装指引改用脚本 + 补全项目结构树 + 新增 Nuitka 编译说明
- 安装章节改用 bash _set.sh / _run.sh 一键脚本
- 新增 _inst.sh Nuitka 编译为 botops.bin 单文件的说明
- 项目结构树补全 _set.sh / _run.sh / _inst.sh / requirements.txt / __init__.py
2026-07-30 12:38:14 +08:00
qing 803d176066 docs(db): 修正 log_client 描述并补充 message/extra 截断说明
- "纯标准库实现" → "无第三方依赖(仅依赖 pymsg 项目内置库)",更准确
- 新增长度保护说明:message 上限 8KB、extra 上限 4KB,超长自动截断

fix(db): _set.sh Nuitka 注释 main.py → dbv2.py
2026-07-30 12:34:37 +08:00
qing ac8f3da9df docs(libcrypt): 完整 README,覆盖 5 个类 API、构建、示例与注意事项
- 5 个类的完整 API 文档(Crypto_Basic/AES_CBC/Base64/SHA256/MyRSA)
- 架构层级图与 header-only 设计说明
- 构建/安装/链接指引(含 OpenSSL 3.x 依赖说明)
- 可运行的使用示例
- Private_Key 私有继承、链接 -lssl -lcrypto 等注意事项

fix(libcrypt): SHA256.hpp 删除重复 bytes_to_hex,改用基类版本

- 私有 static bytes_to_hex 与 Crypto_Basic::bytes_to_hex 重复,已删除
- include 路径统一为 "Crypto_Basic.hpp"(与其他三文件一致)
- 移除随之无用的 <iomanip> <sstream> 头文件

fix(libcrypt): AES_CBC.hpp 补充缺失的 #include <iomanip>
2026-07-30 12:26:15 +08:00
qing 6649b31e17 feat(static): add frontend token auto-refresh for dnfs page
- New token_manager.js: silent refresh timer (80% lifetime) + global
  fetch 401 interceptor with one-shot retry.
- dnfs.py /whoami now returns iat, created_at, keep_time for expiry
  estimation.
- Dnfs.html loads token_manager.js before dnfs.js (zero change to dnfs.js).
- Graceful fallback: stale tokens without new fields, refresh failures,
  and /refresh_token 401 itself all degrade to the existing 401→login flow.
2026-07-30 12:25:11 +08:00
qing 254fbf4628 docs(aibox): 更新 README.md 并新增 README.robot.md 技术手册
README.md 修复:
- 新增 ask_user 工具到工具列表
- 补充配置表中缺失的 17 个字段(system/scheduler/api/context/memo/files/commands/web/memory/conversation/tools/compressor/proxy)
- 补充对话压缩完整配置项

README.robot.md 新增:
- 17 章节完整技术手册(~500 行)
- 架构图、数据流、协议详情
- 19 个工具的完整实现信息
- 配置参考(含所有字段说明)
- 已知限制与安全建议
- 部署与故障排查指南
2026-07-30 12:08:17 +08:00
qing 931e134796 docs(smtp): 新建完整 README,覆盖协议、路由、配置与安全机制
- 3 个路由的完整请求/响应文档(send_email / verify_email / health)
- .env 配置项说明(4 种格式支持、必填/可选字段)
- 验证码生命周期:生成→冷却→消费→过期→作废→失败回滚
- 安全机制:发送冷却、失败次数上限防枚举、SMTP 失败回滚
- 集成关系:gateway 调用流程、core 守护、log_client 日志推送
- 旧版 smtp.py 的安全风险标注

fix(smtp): _set.sh 中 nuitka 编译目标 smtp.py → smtp_v2.py
2026-07-30 12:02:25 +08:00
qing 63a80694ae docs(core): 新增代码审查日志 CODE_REVIEW_LOG_20260730.md
- 审查日期:2026-07-30 14:30:00 (Asia/Shanghai)
- 发现 16 个问题(4 Critical / 6 Medium / 6 Low)
- 核心风险:命令注入、无鉴权、空指针解引用
- 包含分阶段修复计划与优先级建议
2026-07-30 12:01:04 +08:00
qing 4506601773 docs(libth): 完整的 README 文档,覆盖 API、架构、构建与使用示例
- 新增 4 个类的完整 API 说明(Thread/Fsm/ITask/Cv_wait)
- "回字形"执行循环和两阶段构造的设计说明
- 构建、安装、链接、卸载的完整指引
- 可运行的使用示例代码
- 设计要点与注意事项(死锁风险、单次使用限制等)
- 修正 C++ 标准版本为 C++20(与 Makefile 一致)

fix(libth): Cv_wait.hpp 中 lcok 拼写错误修正为 lock
2026-07-30 11:58:26 +08:00
qing f214c862d3 docs(bot4cj): 新增 README.robot.md 完整技术手册
- 补充 5 层架构分层图(脚本→解析器→结构构建→硬件实现→系统库)
- 详解 .mt 脚本语法、块类型、占位符规则
- 补充 PCA9685/Servo/MPU6050/Speaker 各硬件寄存器与校准参数
- 说明执行流程:stdin→FSM 解析→链表→build/play/free
- 列出 I2C 总线保护、GC 析构、pthread_cancel 等安全限制
- 补充 11 条已知缺陷清单(D1-D11)
- 新增 NN/CNN 测试命令与 DRL 预留接口说明
2026-07-30 11:54:51 +08:00
qing a8a190571c feat(gateway): add /refresh_token API with grace window + refresh limit
- POST /refresh_token: accept current Bearer token (or Cookie fallback),
  verify RSA signature, check grace window (iat + keep_time * multiplier)
  and refresh count limit, then issue a new token.
- Token payload extended with iat, refresh_count, max_refresh fields.
- iat preserves original issue time; created_at updates on each refresh.
- Rate limited: 5 req / 5 min / IP via RateLimiter.
- Config: Token.refresh_grace_multiplier (default 2), Token.max_refresh (default 10).
- Backward compatible: old tokens without new fields still pass proxy checks.
2026-07-30 11:54:31 +08:00
qing aa3a99825c docs(captcha): 项目结构树和安装指引补全遗漏项
- 安装章节新增 bash _set.sh 初始化步骤
- 项目结构树补全 _set.sh 和 requirements.txt
2026-07-30 11:53:30 +08:00
qing dcfdbcb9f2 docs(waf): 更新 README 文档与代码实现对齐
- 新增「安全响应头」章节,列出 7 个 WAF 注入的响应头及说明
- 修正「已知限制」中响应体检测描述,明确响应头检测已启用
- 澄清拦截日志等级区分(WARNING 命中拦截 / ERROR 加载错误)及 extra 字段类型
- 补充规则 ID 分段表的 CRS 可选说明
- 更新目录结构列表补充 logclient 子包
- 请求处理流程新增 ModifyResponse 步骤
2026-07-30 11:49:10 +08:00
qing 62007e7848 docs(static): README 路由表、架构图、配置、依赖与实际代码同步
- 修正根路径 / 描述:index.html → login.html
- 补全全部 19 个路由(原仅 6 个),分组为核心/内部工具/OAuth/代理
- 架构图补全 waf:443 → gateway:8443 → static:9200 完整链路
- 配置示例新增 [cors] 段
- 依赖列表补全 waitress 和 pymsg
- 页面和功能列表补全遗漏项
2026-07-30 11:48:54 +08:00
qing 91e6659dac docs(waf): 拦截日志章节更新为 logclient 推送 db Logs 表的说明 2026-07-30 11:01:34 +08:00
qing 9765edac9b fix(static): 前端脚本识别 WAF 拦截响应并正确提示
- validation.js 新增 WafTip 公共检测:403 + 响应体含 blocked by WAF 双重特征,
  与 gateway 白名单拒绝的 403(JSON 错误消息)精确区分
- login/register/dnfs/view 各请求点在 JSON 解析前识别拦截,统一提示
  「请求被 WAF 安全规则拦截,请检查输入内容」
- 修复 dnfs getNode 被 WAF 拦截时落入 catch 误跳登录页的问题
- 验证码图片加载识别 403 拦截,alt 提示点击重试
- __view.html 补充引入 validation.js
2026-07-30 11:01:34 +08:00
qing 40175eddb5 Revert "style(dnfs): 用户信息面板移到左边中间,与右侧 minimap 镜像对称"
This reverts commit 5c33dbe027.
2026-07-30 09:52:08 +08:00
qing 5c33dbe027 style(dnfs): 用户信息面板移到左边中间,与右侧 minimap 镜像对称 2026-07-30 09:44:15 +08:00
qing a58a0f97e3 feat(dnfs): minimap 与用户面板按视口宽度决定初始折叠态
新增 PANEL_EXPAND_MIN_WIDTH=768 断点:宽屏默认展开、窄屏默认折叠避免
遮挡内容;仅决定初始状态,用户手动折叠/展开不被 resize 覆盖。
2026-07-30 09:40:58 +08:00
qing 55b127747a fix(dnfs): 用户面板用户名/角色改为纯展示,去除手型光标与 hover 变色
新增 .tag.no-hover 修饰类还原交互态;折叠按钮与退出登录保持可点样式。
2026-07-30 09:34:53 +08:00
qing 8c1956cdeb feat(dnfs): 左上角用户信息面板(默认折叠),新增 /whoami 身份接口
dnfs 新增 POST /whoami:解 Token payload 返回 user/is_admin,无或畸形
Token 返回 login=false(HttpOnly Cookie 模式下 JS 无法读 Token,身份
必须由后端下发);static 加 /whoami 代理(Cookie 转 Bearer);前端
dnfs.html 左上新增 userpanel,镜像 minimap 样式默认折叠,展开显示
用户名/角色/退出登录(POST /logout 清 Cookie 后跳登录页)。
2026-07-30 09:31:28 +08:00
qing 1388fcb72b feat(dnfs): 卡片移动按钮一并纳入 can_operate 权限显隐
编辑/移动/删除三个按钮合并为单次权限判断(移动本质也是 update_note),
未登录或无权时不渲染整个按钮组。
2026-07-30 09:21:15 +08:00
qing b4e1cf462a feat(dnfs): get_notes 按当前用户注入 can_operate,前端按权显隐编辑/删除按钮
dnfs get_notes 可选解析 Authorization(畸形 Token try 兜底),为每个节点
附带 can_operate 标记(is_admin 或 owner,口径与 delete/update 一致);
dnfs.js 卡片编辑/删除按钮仅 can_operate === true 时渲染,未登录或无权隐藏。
2026-07-30 09:17:28 +08:00
qing 46c3a197be refactor(auth): 管理员判定上移至 gateway,经 Token payload 下发 is_admin
gateway 登录签发 Token 时,账号命中 [Admin] users 白名单即在 payload
写入 is_admin=true(随 RSA 签名保护);dnfs 移除本地 ADMIN_USERS 硬编码,
delete_note/update_note 豁免判断改读 payload 的 is_admin 字段。
旧 Token 无此字段按非管理员处理(安全方向)。
2026-07-30 09:08:33 +08:00
qing e9f9860fe1 fix(dnfs): review_content 兼容布尔/字符串审核结果,修复安全内容误拦
仅明确判定安全(布尔 false 或字符串 "false")才放行,其余一律拦截,
保持 fail-closed;同时消除 invalid 字段缺失时的 KeyError。
2026-07-30 08:38:14 +08:00
qing 949d274c22 获取节点失败后跳转 2026-07-30 08:22:53 +08:00
qing bcd6c84e3b Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-29 21:57:47 +08:00
qing 5275b8bf75 feat(smtp): 邮箱验证码连续校验失败 5 次自动作废,防枚举 6 位数字码 2026-07-29 21:57:16 +08:00
qing e6e5dab4cd 智能体提问 2026-07-29 21:54:22 +08:00
qing 3c02ba43ad AskUserTool 内置技能 2026-07-29 21:23:04 +08:00
qing c5dd8d198d feat(smtp): 同一邮箱 60 秒发送冷却,gateway 429 透传 + 前端重发倒计时(gateway v0.5.3) 2026-07-29 20:54:56 +08:00
qing f4a7aca2fa update: smtp 验证码有效期 60s 调整为 10 分钟,邮件正文从 CODE_TTL 派生
60 秒对邮件送达延迟过于紧张;正文有效期文案改为按 TTL 自动生成避免漂移,前端提示与 README 同步。同时移除 .env 排查用的凭证明文调试打印。
2026-07-29 19:32:44 +08:00
qing f5b69d0b4c fix: smtp .env 解析兼容 KEY: VALUE 冒号格式,MAIL_FROM 默认跟随 SMTP_USER
服务器 .env 误用 YAML 冒号格式导致凭证全部加载为空;163 等服务商强制 From 头与登录账号一致,写死的 MAIL_FROM 会触发 553,现默认取 SMTP_USER、可用 .env 显式覆盖。
2026-07-29 19:08:13 +08:00
qing 53c742c26e refactor: smtp.py.remote 更名为 smtp_v2.py,恢复 .py 后缀以便工具链识别
_run.sh 同步指向新文件名;start() 新增启动时凭证明文打印(仅 stdout 不落日志库),用于排查 .env 加载问题,调试完成后需移除。
2026-07-29 18:36:21 +08:00
qing 11993e178b smtp dev 2026-07-29 18:22:38 +08:00
qing 2a6d68386d Merge pull request 'Dev qing' (#13) from dev_qing into main
Reviewed-on: #13
2026-07-29 14:39:15 +08:00
qing 3bc4f9720f fix(dnfs): 移动请求期间禁用按钮防止重复提交
后端 update_note 走 AI 审核耗时较长,confirmMove 期间禁用移动条
与表单按钮,finally 统一恢复
2026-07-29 14:37:27 +08:00
qing 751866d9c1 refix 2026-07-29 11:52:46 +08:00
qing 3f9bffb9ea feat(dnfs): 节点移动功能
- dnfs.js: 卡片新增移动按钮,startMove 暂存节点,底部悬浮移动条
  提供移动到当前层/取消;confirmMove 经 update_note 改 pid 完成迁移;
  拦截移动到自身与已在当前层的情况
- dnfs.html: 新增 #movebar 容器
- theme.css: act-move 描边按钮与 movebar 底部悬浮条样式
2026-07-29 11:40:35 +08:00
qing ace98caff6 feat: 卡片编辑/删除按钮 + dnfs 管理员豁免
- dnfs.js: 卡片底部新增编辑/删除按钮,复用下方表单支持编辑模式
  (startEdit/cancelEdit/updateNode/deleteChild),stopPropagation 防误触
- theme.css: 新增 card-actions/act-btn 按钮样式,编辑橙色/删除红色软底
- dnfs.py: ADMIN_USERS 统一管理豁免名单(admin, admin@qsont.me),
  update_note 新增管理员豁免,delete_note 同步改用名单
- dnfs/README.md: 同步更新所有权豁免说明
2026-07-29 11:22:30 +08:00
qing a0f6a14b5d style(dnfs): 笔记卡片尺寸随内容自适应
- recommend-list 改为 flex 纵向布局,gap 统一间距,隐藏 JS 遗留 <br> 分隔符
- 卡片宽度 fit-content 包裹内容,min-width 240px 保底、max-width 100% 封顶
- 内容图片限宽防撑破自适应布局
2026-07-29 11:05:20 +08:00
qing 2fc12e7b00 feat(dnfs): 卡片增加观看/点赞/分享互动统计图标
- dnfs.js: 新增眼睛/爱心/转发 SVG 图标常量与 makeStatItem 构造函数,
  统计行挂载在 owner 胶囊下方,计数缺省显示 0(后端暂无该字段)
- theme.css: 新增 card-stats/stat-item/stat-num 样式
2026-07-29 10:59:40 +08:00
qing dae81ba4a1 style(dnfs): 美化卡片发帖用户显示为头像+昵称胶囊
- dnfs.js: owner 纯文本改为首字符圆形头像 + 昵称,空值兜底匿名
- theme.css: 新增 Owner Chip 样式段,复用主题变量自动适配深色模式
2026-07-29 10:53:29 +08:00
qing a905dac00d display note ownerx 2026-07-29 10:47:20 +08:00
qing b8ec752a12 docs(pymsg): 添加中文 README 文档,涵盖协议、API 参考和实际用例 2026-07-29 09:39:49 +08:00
qing b86b089634 fix(BotOps): 修正 aibox 协议语义错配导致的输出丢失
- 服务端 chunk 为增量片段(chunk_buffer 发送后即清零),原 _delta
  按累计全文假设计算,相邻 chunk 前缀撞字时静默丢字符;改为直接拼接
- complete 携带完整全文,finish_ai 改为全文对齐(不一致时整体替换,
  可自愈丢帧/乱序),修复结尾全文重复 append
- 新增 line/reasoning/task_added/dialogue_reset/pong 消息路由:
  line(🔧 工具调用提示、工具输出)不再落入兜底污染聊天区,改投任务侧栏
- 发送失败时移除预建的空 AI 消息框并提示,避免空等回复
- 断线提示补充重连窗口期服务端输出丢失说明
- 同步修正 ws_client/README 中的协议描述
2026-07-29 09:32:30 +08:00
qing f9cf408c82 static: 切换 fsys.py 到 Waitress WSGI 服务器
- HTTP 场景使用 waitress.serve(threads=8) 替代 Flask 开发服务器
- SSL 场景回退 Flask 内置服务器(Waitress 不支持 TLS)
- requirements.txt 新增 waitress 依赖
2026-07-29 09:10:09 +08:00
qing f0cdaacd85 feat(gateway): 运行日志接入 db 集中式日志(新增 C++ 版 LogC,pymsg 推送 Logs 表)
- 新增 gateway/inc/LogC.hpp:异步批量日志客户端(内存队列+后台线程,50条/2秒 flush),
  对齐 Python 版 log_client.py,fail-safe 设计(db 不可用静默丢弃)
- main.cpp 全部日志输出迁移:wrapperHttp 双异常分支(>=500 ERROR / 其余 WARNING)、
  启动/关闭、登录成功、注册成功、验证码邮件发送成功、代理白名单拒绝
- Makefile 增加 -pthread(LogC 使用 std::thread)
2026-07-29 08:34:14 +08:00
qing 6a0551ab34 fix(gateway): 限流错误码统一429、登录用户不存在改401并拉平时序、pymsg超时常量化(v0.5.2)
- 三处限流错误码统一为 429(login 400→429 并修 attampts 拼写;register/send_code 403→429)
- 登录用户不存在不再兜底 500:空结果统一走 401,dummy PBKDF2 校验拉平时序防用户枚举
- db 查询失败消息误名修正:Login (Update Step) Failed → (Query Step)
- 超时常量化:DbC::DEFAULT_TIMEOUT=5.0;_TIMEOUT_CAPTCHA/_SMTP_VERIFY=5.0、_SMTP_SEND=20.0
- 文档:gateway/README 数据库节 email→username 重写、建表归 db/create_sqlite3.sql;根 README 版本号与自动建表描述同步
2026-07-29 00:21:13 +08:00
qing c2e9cce6a7 fix(BotOps): _inst.sh 补 --include-package=websockets/textual,修复打包后找不到 websockets.asyncio 2026-07-28 23:38:16 +08:00
qing b07dbf2da5 feat(gateway): 登录成功后更新 Users.last_login(v0.5.1)
启用 handleLogin 中被注释的 last_login 更新:经 DbC::call_msg 以
"$now" 哨兵(db 日期适配层转为服务端 UTC 时间)写入,失败按惯例
THROW_EX_CUSTOM 抛 500 由 wrapper 统一处理。README 版本节同步
(原仍停留在 0.3.0-security)。
2026-07-28 23:31:20 +08:00
qing 19d5b65625 fix(db): 新增日期列类型适配层,修复无法经 db 处理 DATETIME 列的问题
JSON 无日期类型,而 SQLite DATETIME 只接受 Python datetime 对象,
导致各服务对日期列的写入与 where 比较全部失败。_coerce_value 按反射
列类型把 ISO 8601 字符串转为 datetime/date/time(带时区偏移统一转
UTC),并支持 "$now" 哨兵取服务端当前时间;响应中日期时间改为一律
序列化为 ISO 8601(T 分隔)。黑盒测试补 3 个日期列场景。
2026-07-28 23:16:17 +08:00
qing 12e736d264 chore: BotOps .gitignore 忽略 nuitka 编译产物 2026-07-28 22:54:06 +08:00
qing 8be62683dc chore: BotOps 添加 _inst.sh 单文件编译脚本(nuitka --onefile) 2026-07-28 22:53:35 +08:00
qing c93023bd5d fix(BotOps): 聊天区改用 anchor 锚定吸底,修复流式时滚动条不跟随 2026-07-28 22:48:45 +08:00
qing 422ca798da chore: BotOps 添加 _set.sh/_run.sh 启动脚本(与 aibox 同款) 2026-07-28 22:44:24 +08:00
qing c064caa3ec chore: BotOps 添加 .gitignore 并移除误提交的 __pycache__ 2026-07-28 22:41:34 +08:00
qing f91d4166d6 feat: 新增 BotOps 子模块——aibox 的 Textual TUI 前端(流式 Markdown 渲染+自动重连+任务侧栏) 2026-07-28 22:41:15 +08:00
qing afb55494e5 interpreter project 2026-07-28 22:25:01 +08:00
qing 6692c9c4ee refactor(db): 清理遗留死代码并对齐文档与测试
删除 dbv2 从未挂载的 log_module.py(Flask /log/* 遗留)与纯 TCP 服务
用不到的 security_middleware.py 副本;db/README.md 按 pymsg 实现重写,
requirements 去掉 flask/waitress;黑盒测试对 pymsg 服务改用 TCP 探测,
修复 check_service HTTP 探测导致的 db 测试整体假 skip。
2026-07-28 22:21:36 +08:00
qing dabd892601 fix(aibox/client): 放弃 rich.Live 改为 complete 时一次性静态渲染,修复内容一闪而过 2026-07-28 22:01:21 +08:00
qing 2982bc30a4 feat(aibox/client): AI 回复流式渲染 Markdown(rich.Live,无 rich 时降级纯文本) 2026-07-28 21:51:53 +08:00
qing aaeac343d3 fix(aibox/client): 修4处——stdin单线程防泄漏、LRU去重上限、自动重连、弃用get_event_loop 2026-07-28 21:44:23 +08:00
shufeng2012 9e2667d604 🦺 扩大验证码颜色生成范围 2026-07-28 20:38:43 +08:00
shufeng2012 c592b02636 🦺 更新验证码混淆程度(我自己都过不了) 2026-07-28 20:17:28 +08:00
qing d5a67ce77e fix: smtp .env 兼容 export 前缀并在启动时报告缺失的配置项名称 2026-07-28 17:42:37 +08:00
qing 6330e04474 fix: smtp .env 手动解析替代 dotenv,同时支持 KEY=VALUE 和 KEY VALUE 格式 2026-07-28 17:34:55 +08:00
qing df518d1adb feat: 注册强制邮箱验证码(gateway v0.5.0 新增 /register/send_code,pymsg 调 smtp) 2026-07-28 17:29:52 +08:00
qing 9078c4d051 refactor: smtp 改造为 pymsg TCP 服务并修复 NameError/finally/KeyError 等已知 bug 2026-07-28 17:12:45 +08:00
qing bcd49f0e81 Merge pull request 'Dev qing' (#12) from dev_qing into main
Reviewed-on: #12
2026-07-28 12:46:26 +08:00
qing 78ad3e6d57 移植bot4cj 2026-07-28 12:44:07 +08:00
qing 7803843155 ^-^ 2026-07-28 12:37:39 +08:00
qing ec755a30f2 3d打印件 2026-07-28 12:34:02 +08:00
qing 483c5f227f fix: 发布节点期间同步禁用重置/删除按钮,防止误触 2026-07-28 12:17:27 +08:00
qing bfd2d334fe fix: 前端按钮防重复点击,避免重复请求(dnfs提交/删除、view刷新、验证码刷新) 2026-07-28 12:10:48 +08:00
qing 50f209e84a fix: X-Frame-Options/frame-ancestors 改为同源以修复条例弹窗空白 2026-07-28 11:53:35 +08:00
qing 680d28b616 ui: 用户使用条例改为弹窗显示并移除 LICENSE.html 返回按钮 2026-07-28 11:46:25 +08:00
qing 4d16bc32fc security: CORS 白名单移除 localhost 并改为 config.ini 驱动
扫描发现生产 CORS 含 http://localhost:9200(硬编码于 fsys.py),且 config.ini 的 cors 段(通配符 *)从未被读取。白名单改为从 config.ini 读取,生产值仅 qsont.me/yhdet.top,localhost 仅限本地开发显式配置。
2026-07-28 11:40:19 +08:00
qing 7726857d6b feat: 新增用户使用条例独立页面并在登录页引用
新增 LICENSE.html(六章条例,开源社区定位,复用主题样式);登录页勾选框文案改为链接新标签页打开,不影响勾选交互与表单状态。
2026-07-28 10:36:06 +08:00
qing 3ef7d1b780 fix: 修复验证码图片被输入框挤换行,改为右侧同一行
theme.css 全局设定文本输入框 display:block;width:100%,导致验证码图片无法与之同行;改用 flex 容器包裹(输入框 flex:1、图片 flex:none 居右),布局与主题风格一致。
2026-07-28 10:29:22 +08:00
qing 62804d242c ui: 验证码改为 4 位,登录页图片移到输入框左侧
captcha 服务 CAPTCHA_LEN 调整为 4 并适配字符间距;登录页验证码图片与输入框换序,同一行展示更紧凑。
2026-07-28 10:24:48 +08:00
qing a5e581ef24 fix: CSP img-src 放行 blob:,修复验证码图片被拦截
登录页验证码用 URL.createObjectURL 生成的 blob: URL 显示,被 img-src 'self' data: 拦截导致图片无法加载;五处 CSP 定义(waf 边缘 + 四个 Flask 中间件副本)同步放宽。
2026-07-28 10:19:47 +08:00
qing 6fcb70d235 feat: 登录页接入图形验证码,登录接口强制校验
登录页展示 captcha 图形码(点击刷新),登录请求携带 captcha_token/captcha_answer;gateway 的 /login 启用验证码校验,经 pymsg 调 captcha verify 路由(fail-closed),补齐防爆破短板。
2026-07-28 10:15:01 +08:00
qing 323dd3061b rewrite captcha 2026-07-28 10:07:32 +08:00
qing 44c1e5ef72 feat: captcha 改造为 pymsg/TCP 服务,gateway 新增图形码代理路由
captcha 参照 dbv2 统一为 pymsg 协议(image/verify/health 路由),与主干 RPC 栈对齐;gateway 新增 GET /captcha/image 做 HTTP→pymsg 转换,解码 base64 PNG 并以原契约(image/png + X-Captcha-Token)返回浏览器。
2026-07-28 09:54:08 +08:00
qing d06546f6bf 测试模块初步完成 2026-07-28 09:39:37 +08:00
qing 4ad3fbfca8 fix: 修复公开页面因旧 Cookie 验签失败误返回 401
浏览器会自动给每个请求携带 q4_token,gateway 重启轮换内存 RSA 密钥后旧凭证验签失败,导致无需登录的公开页面也 401。代理层改为仅对需鉴权目标校验凭证,公开目标跳过;前端遇 401 自动跳转登录页自愈。
2026-07-28 09:31:10 +08:00
qing b2ddd9e7e3 security: waf 边缘统一注入安全响应头
外部扫描发现站点缺少安全响应头。在 waf 反代/403/502 三个出站路径统一注入 XFO/nosniff/Referrer-Policy/Permissions-Policy/HSTS/CSP,下游同名头一律覆盖避免重复;static 补上漏调用的 apply_security_headers 做纵深防御。
2026-07-28 09:19:43 +08:00
qing e89578fa82 security: Token 改为 HttpOnly Cookie 下发,消除 XSS 窃取面
登录时网关签发 HttpOnly+Secure+SameSite=Lax 的 q4_token Cookie,前端不再经手 Token;网关代理时支持从 Cookie 验签并向下游注入 Authorization(dnfs 零改动),新增 /logout 清除 Cookie,static 代理路由兼容 Cookie 转 Bearer。
2026-07-28 09:07:12 +08:00
qing 65cfaf97a1 docs: 按代码实况修正根 README 与实现不符的描述
db 实为 pymsg/TCP(非 HTTP),ai_review 实为 fail-closed,captcha 路由与实现方式、static 的 dnfs 代理与账号旁路、gateway 用户存储与 core 守护机制等均按代码核实结果更正。
2026-07-28 08:33:30 +08:00
qing 6b77cdcf3a feat: 新增俄罗斯方块小游戏(games/game7.html)
纯 HTML 单文件实现,Canvas 渲染,含 7-bag 发牌、暂存、落点虚影、硬降/软降、等级加速与触屏按键,最高分本地持久化。
2026-07-28 01:11:58 +08:00
qing 9e8ad76400 Merge pull request 'docs: 补全根 README 中遗漏的 captcha/smtp/aibox 服务' (#11) from workbuddy/dev-qing-8fdda6eb into dev_qing
Reviewed-on: #11
2026-07-27 23:01:10 +08:00
qing ed3108f55c docs: 补全根 README 中遗漏的 captcha/smtp/aibox 服务
- 各服务说明表新增 captcha/smtp/aibox 三行
- 端口一览新增 smtp(:8008)/aibox(:8765)
- 运行清单新增 smtp/aibox 启动项
- 日志链路与 init.txt 提及补全 smtp/aibox
- 新增 captcha/smtp/aibox 三个子章节(对照 init.txt 与各服务源码)
2026-07-27 23:00:08 +08:00
qing c578d1ab9e Merge pull request 'docs & ui: 完善 captcha/aibox 文档,登录注册改为邮箱制' (#10) from workbuddy/dev-qing-8fdda6eb into dev_qing
Reviewed-on: #10
2026-07-27 22:55:39 +08:00
qing ee44e1bef1 docs & ui: 完善 captcha/aibox 文档,登录注册改为邮箱制
- captcha/README.md: 补充字段级接口文档与错误码表
- aibox/README.md: 对照 main.py 源码核对修正(入口/模型/工具/配置)
- static/public/login.html & register.html: 用户名改为邮箱
- static/public/js/register.js: 合并注册页双邮箱框为单一邮箱字段
2026-07-27 22:51:57 +08:00
qing f601efdd3f refix 2026-07-27 22:27:18 +08:00
qing c58cafcdcf fixbug: 机器人在定时任务中创建重复的定时任务 2026-07-27 22:11:02 +08:00
qing 3501168f45 inst aibo 2026-07-27 22:03:50 +08:00
qing e923b32b70 Merge pull request 'Dev qing' (#9) from dev_qing into main
Reviewed-on: #9
2026-07-27 21:47:16 +08:00
qing f3909bb18f Merge pull request '增加运维机器人单元' (#8) from main into dev_qing
Reviewed-on: #8
2026-07-27 21:46:43 +08:00
qing 13f3eb8f88 DEV 2026-07-27 21:43:56 +08:00
qing 1b60ab0a2e 增加运维机器人单元 2026-07-27 21:39:53 +08:00
shufeng2012 974c912d9e 🦺 更新验证码位数 2026-07-27 19:16:27 +08:00
qing 8c74d93a76 security fixup 2026-07-27 17:28:15 +08:00
qing 40eb522e5b 安全策略 2026-07-27 15:53:09 +08:00
qing a67f190634 “返回上一层”按钮样式 2026-07-27 15:41:14 +08:00
qing 9b4a0a6a16 折叠minimap 2026-07-27 14:51:41 +08:00
qing 097fb7ecbb Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-27 14:47:31 +08:00
qing 167ef235e6 fixbug 100% cpu 2026-07-27 14:47:12 +08:00
shufeng2012 0abba64ea8 🐛 我看还是把等号加上吧 2026-07-27 13:32:06 +08:00
qing f056da1dfc update minimap 2026-07-27 12:36:59 +08:00
qing 5edfad7842 refix 2026-07-27 12:33:27 +08:00
qing 39bf2684a4 update css for dnfs.minimap 2026-07-27 12:29:51 +08:00
qing 614368eb93 fontend update without css 2026-07-27 12:24:06 +08:00
qing ce9337fe0d Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-27 10:23:21 +08:00
qing 18ac572442 更新dbv2、删除旧的db1、为static添加log 2026-07-27 10:22:16 +08:00
qing 58e9391503 refix 2026-07-27 09:28:27 +08:00
qing b790e18024 Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-27 09:21:20 +08:00
qing 08021015b1 更新msg客户端 2026-07-27 09:21:06 +08:00
qing 179afb2674 Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-26 21:53:46 +08:00
qing 6efb792133 C++版本的MsgClient 2026-07-26 21:48:14 +08:00
qing 3d2278f6b7 ^-^ rename 2026-07-26 21:27:40 +08:00
qing 4fe0689cf6 修复db客户端无法推日志的问题 2026-07-26 21:26:17 +08:00
shufeng2012 813fe91663 🐛 修复无法运行的bug 2026-07-26 21:15:23 +08:00
qing fb85203c9b Merge branch 'dev_qing' of https://git.cancerai.top/qing/micro_servers into dev_qing 2026-07-26 21:05:09 +08:00
qing 1a39e87c8c 更新pymsg 2026-07-26 21:04:42 +08:00
qing 8ec42df779 初步开发了新的pymsg,并且应用在了dnfs上面 2026-07-26 17:57:27 +08:00
606 changed files with 147918 additions and 5402 deletions

No files matched your search

+61
View File
@@ -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/
+11
View File
@@ -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
+5
View File
@@ -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.
BIN
View File
Binary file not shown.
+13452
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
Binary file not shown.
+1026
View File
File diff suppressed because it is too large. Load diff
+2476
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
+2698
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.
+2809
View File
File diff suppressed because it is too large. Load diff
+2834
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
venv/
# nuitka 编译产物
botops.bin
app.build/
app.dist/
app.onefile-build/
+91
View File
@@ -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 增量流式渲染、任务独立侧栏、自动重连、快捷键支持
+13
View File
@@ -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
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source venv/bin/activate
exec python3 app.py $@
Executable
+5
View File
@@ -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
View File
@@ -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()
+62
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
textual>=0.60.0
websockets>=11.0
nuitka
View File
Whitespace-only changes.
+205
View File
@@ -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()
+14
View File
@@ -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)
+148
View File
@@ -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
+130 -47
View File
@@ -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`。
+3
View File
@@ -28,6 +28,7 @@ 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
@@ -35,4 +36,6 @@ 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
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
chroma/
.DS_Store
+43
View File
@@ -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"]
+64
View File
@@ -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:
+3 -3
View File
@@ -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
+108
View File
@@ -0,0 +1,108 @@
#PRJ
.arts/
skbin/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 卿松
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+825
View File
@@ -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 使用条款。
+824
View File
@@ -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)
---
*本文档由代码审查工具生成,供开发团队参考。*
+229
View File
@@ -0,0 +1,229 @@
# Aibox 扩展技能格式规范
本文档定义扩展技能的文件结构、接口约定和编写规范,供开发者与 AI 智能体参考。
## 概览
技能存放于 `~/.aibox/skills/` 目录,系统启动时自动扫描加载,运行时可通过 `reload_skills` 热重载。技能分为两种类型:
| 类型 | 识别方式 | 用途 |
|------|---------|------|
| 可执行技能 | 非 `.md` 文件,具有可执行权限 | 命令行工具,通过 `run_command` 调用 |
| Markdown 技能 | 以 `.md` 结尾 | 知识文档,AI 直接阅读并遵循 |
文件名仅允许字母、数字、下划线 `_` 和连字符 `-`,以 `_` 或 `.` 开头的文件会被忽略。
---
## 可执行技能
可执行技能是标准的命令行程序,系统仅依赖一个接口约定:**必须支持 `--help` 参数**。系统通过执行 `<skill> --help` 获取技能摘要和使用说明,不需要实现 `--description`、`--parameters`、`--execute` 等额外接口。
### 接口约定
系统按以下规则提取技能信息:
1. 执行 `<skill_path> --help`,超时上限 10 秒
2. 取 stdout 的第一个非空行作为**摘要**(若 stdout 为空则回退到 stderr)
3. 完整的 `--help` 输出作为技能**内容**,供 `search_skills` 展示
因此,`--help` 输出的第一行必须是一句简洁的功能描述,后续内容是完整的参数说明和使用示例。
### Python 技能模板
```python
#!/usr/bin/env python3
"""
技能模块内部说明(可选,不影响系统识别)
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description="一句话功能摘要 —— 这是 --help 的第一行输出",
epilog="""
参数说明:
-x, --xxx 参数含义说明
-y, --yyy 另一个参数说明
使用示例:
my_skill -x value # 基本用法
my_skill -x value -y 10 # 组合用法
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("-x", "--xxx", type=str, required=True, help="参数说明")
parser.add_argument("-y", "--yyy", type=int, default=10, help="参数说明 (默认 10)")
args = parser.parse_args()
# --- 业务逻辑 ---
print(f"执行结果: xxx={args.xxx}, yyy={args.yyy}")
if __name__ == "__main__":
main()
```
### 关键要素
**Shebang 行**:Python 脚本使用 `#!/usr/bin/env python3`,Shell 脚本使用 `#!/bin/bash`。
**argparse `description`**:这是系统识别的技能摘要,必须是一句话,精准概括技能功能。它直接决定 AI 能否在 `search_skills` 时正确匹配到这个技能。
**argparse `epilog`**:包含两部分内容——参数说明和使用示例。使用 `RawDescriptionHelpFormatter` 保持排版原样输出。
**错误处理**:向 stderr 输出错误信息,以非零退出码退出。正常结果输出到 stdout。
```python
# 正确做法
print("错误: 参数无效", file=sys.stderr)
sys.exit(1)
# 避免
print("错误: 参数无效") # 会混入 stdout,污染正常输出
```
**退出码约定**:
| 退出码 | 含义 |
|--------|------|
| 0 | 执行成功 |
| 1 | 参数错误或一般性失败 |
| 其他 | 自定义错误码 |
### Shell 技能模板
```bash
#!/bin/bash
# 用法通过 --help 输出
if [ "$1" = "--help" ]; then
echo "一句话功能摘要"
echo ""
echo "用法: my_skill [选项]"
echo ""
echo "选项:"
echo " -n NAME 指定名称"
echo " -h 显示帮助"
echo ""
echo "示例:"
echo " my_skill -n hello"
exit 0
fi
# --- 业务逻辑 ---
echo "执行结果"
```
---
## Markdown 技能
Markdown 技能是纯文档型技能,AI 阅读其内容后按指引行事,无需执行。
### 格式约定
```markdown
一句话功能摘要(首行,# 前缀会被自动去除)
## 技能说明
详细的功能描述、使用场景、操作步骤...
## 注意事项
需要特别关注的点...
```
系统提取第一个非空行作为摘要。若首行以 `#` 开头(如 `# 代码审查指南`),会自动去除 `#` 前缀,取 `代码审查指南` 作为摘要。其余全部内容为技能正文。
### 适用场景
Markdown 技能适合不需要代码执行的知识型内容:操作规范、审查清单、决策指引、回答模板等。
---
## 部署流程
### 开发阶段
源码保存在项目的 `skills/` 目录下,保留文件扩展名(`.py`、`.sh`、`.md`)便于开发维护。
### 安装到系统
部署到 `~/.aibox/skills/` 时需要去掉可执行技能的扩展名,并赋予执行权限:
```bash
# 手动安装
cp skills/my_skill.py ~/.aibox/skills/my_skill
chmod +x ~/.aibox/skills/my_skill
# 验证
~/.aibox/skills/my_skill --help
```
Markdown 技能保留 `.md` 扩展名直接复制。
### 通过 AI 安装
告诉 AI 使用 `install_skill` 工具,按指引操作:
```
使用 install_skill 工具安装 /path/to/my_skill.py
```
### 热重载
安装后调用 `reload_skills` 使新技能生效,无需重启服务:
```json
{
"type": "message",
"content": "使用 reload_skills 重载技能"
}
```
---
## 调用方式
AI 智能体通过 `search_skills` 搜索技能,通过 `run_command` 执行技能:
```json
// 1. 搜索
{"tool": "search_skills", "args": {"keyword": "邮件"}}
// 2. 执行
{"tool": "run_command", "args": {"command": "163mail --to user@example.com --subject 标题 --body 内容"}}
```
Markdown 技能不通过命令执行,AI 阅读 `search_skills` 返回的内容后直接遵循其中的指引。
---
## 编写建议
**摘要要精准**。`description` 是 AI 匹配技能的唯一线索。"温度转换工具 - 摄氏度转华氏度" 比 "一个转换工具" 好得多,因为 AI 能通过关键词准确命中。
**参数命名要直观**。优先使用有明确含义的长参数名(`--channel`、`--temperature`),短参数名(`-c`、`-t`)作为补充。
**输出要结构化**。结果输出到 stdout,错误输出到 stderr。多字段输出时每行一个字段,便于 AI 解析:
```
AIN0: +2.500000 V (2500.000 mV)
AIN1: +1.234000 V (1234.000 mV)
```
**依赖要自包含**。尽量使用标准库,若需要第三方库,在启动时检测并给出安装提示:
```python
try:
import requests
except ImportError:
print("错误: 需要 requests 库,请执行: pip install requests", file=sys.stderr)
sys.exit(1)
```
**`--help` 要完备**。包含参数说明和使用示例两部分。AI 通过阅读 `--help` 输出学会如何调用技能,信息越完整,AI 调用越准确。
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./_setenv.sh
source ./venv/bin/activate
export PATH=${PATH}:~/.aibox/skills
exec python aibox.py $@
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./_setenv.sh
source ./venv/bin/activate
exec python client.py $@
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.org/simple
+1
View File
@@ -0,0 +1 @@
export DEEPSEEK_API_KEY=`cat ~/.cert/deepseek.key`
+8914
View File
File diff suppressed because it is too large. Load diff
+825
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>小猫AI · 智能喵语伙伴</title>
<!-- Font Awesome 6 (免费图标库) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<!-- highlight.js 代码高亮 + marked markdown解析 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js"></script>
<style>
/* ========== 融合小猫风格 + WebSocket增强样式 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, 'Roboto', sans-serif;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
/* 整体背景及容器 - 可爱猫咪氛围 */
body {
background: #1a1a2f;
background-image: url('/imgs/background.jpeg');
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-attachment: fixed;
transition: background-image 0.3s ease, background-color 0.2s;
color: #f0ede8;
}
/* 主布局 flex,左侧聊天区,右侧猫咪头像+WS侧栏 */
.main-layout {
display: flex;
height: 100vh;
width: 100%;
position: relative;
}
/* 左侧内容区 (聊天框+输入区) */
.cat-chat-area {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: rgba(0,0,0,0.2);
backdrop-filter: blur(2px);
min-width: 0;
}
/* 角色模型区域 - 右侧面板内 */
#roleArea {
width: 140px;
height: 140px;
margin: 0 auto 16px auto;
filter: drop-shadow(0 8px 16px rgba(0,0,0,0.4));
pointer-events: auto;
background: rgba(0,0,0,0.2);
border-radius: 50%;
backdrop-filter: blur(4px);
padding: 8px;
}
#roleArea img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
transition: all 0.2s;
}
/* 聊天记录玻璃容器 */
.chat-box {
position: relative;
margin: 20px 20px 95px 20px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(12px);
border-radius: 28px;
padding: 16px 20px;
overflow-y: auto;
height: calc(100vh - 130px);
scroll-behavior: smooth;
border: 1px solid rgba(255, 220, 180, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.chat-box div {
margin: 10px 0;
animation: fadeSlideUp 0.2s ease;
}
.user {
text-align: right;
color: #b9f6ca;
font-weight: 500;
background: rgba(60, 50, 80, 0.7);
display: inline-block;
width: auto;
max-width: 85%;
margin-left: auto;
padding: 8px 16px;
border-radius: 26px;
border-bottom-right-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
}
.ai {
text-align: left;
color: #fff0dd;
background: rgba(30, 30, 45, 0.8);
width: auto;
max-width: 85%;
padding: 8px 16px;
border-radius: 26px;
border-bottom-left-radius: 4px;
word-break: break-word;
white-space: pre-wrap;
}
/* 系统/错误/任务消息样式 */
.system-msg, .error-msg, .task-msg {
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
background: rgba(0,0,0,0.5);
margin: 6px 0;
}
.task-separator {
margin: 12px 0 8px;
padding: 6px 12px;
background: #ff980033;
border-left: 3px solid #ff9800;
border-radius: 12px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
color: #ffcd94;
}
/* 输入框区域 - 支持多行文本 */
.input-area {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
display: flex;
gap: 12px;
background: transparent;
z-index: 30;
align-items: flex-end;
}
.input-wrapper {
flex: 1;
background: rgba(20, 20, 35, 0.9);
backdrop-filter: blur(6px);
border-radius: 24px;
border: 1px solid rgba(255, 200, 150, 0.5);
transition: all 0.2s ease;
}
.input-wrapper:focus-within {
border-color: #ff9a5a;
box-shadow: 0 0 8px rgba(255, 154, 90, 0.3);
}
#msgInput {
width: 100%;
padding: 12px 18px;
background: transparent;
color: #fce8cf;
font-size: 14px;
outline: none;
border: none;
resize: none;
font-family: inherit;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
}
#msgInput::placeholder {
color: rgba(252, 232, 207, 0.6);
}
#msgInput::-webkit-scrollbar {
width: 4px;
}
#msgInput::-webkit-scrollbar-track {
background: rgba(255,255,255,0.1);
border-radius: 4px;
}
#msgInput::-webkit-scrollbar-thumb {
background: rgba(255, 154, 90, 0.5);
border-radius: 4px;
}
#sendBtn {
padding: 0 28px;
height: 48px;
border-radius: 48px;
border: none;
background: linear-gradient(135deg, #ff9a5a, #ff6f61);
color: white;
font-weight: bold;
cursor: pointer;
transition: 0.2s;
flex-shrink: 0;
font-size: 16px;
display: flex;
align-items: center;
gap: 6px;
}
#sendBtn:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(255, 111, 97, 0.4);
}
#sendBtn:active {
transform: scale(0.98);
}
/* 右侧整合面板 (猫咪头像 + WS控制台) */
.right-panel {
width: 320px;
background: rgba(30, 30, 40, 0.85);
backdrop-filter: blur(16px);
border-left: 1px solid rgba(255,255,200,0.2);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 100;
padding: 24px 16px;
align-items: center;
}
/* 猫咪头像容器 */
.avatar-container {
text-align: center;
margin-bottom: 20px;
}
.avatar-name {
margin-top: 8px;
font-size: 18px;
font-weight: bold;
color: #ffd4a3;
letter-spacing: 1px;
}
/* WebSocket连接区域 */
.ws-card {
background: rgba(26, 188, 156, 0.75);
margin: 12px 0;
border-radius: 24px;
padding: 16px;
backdrop-filter: blur(4px);
width: 100%;
}
.ws-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
border-bottom: 1px dashed rgba(255,255,240,0.5);
display: flex;
align-items: center;
gap: 6px;
}
.ws-control {
display: flex;
gap: 8px;
margin: 10px 0;
}
.ws-url-input {
width: 100%;
padding: 8px;
background: #2d2d3a;
border: 1px solid #5a5a6e;
border-radius: 20px;
color: white;
font-size: 12px;
margin-bottom: 10px;
}
.btn-sm {
padding: 6px 12px;
border-radius: 24px;
background: #4CAF50;
border: none;
color: white;
cursor: pointer;
font-size: 12px;
flex: 1;
}
.btn-sm:disabled { opacity: 0.5; }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.command-grid {
display: grid;
grid-template-columns: repeat(2,1fr);
gap: 6px;
margin-top: 8px;
}
.cmd-btn {
background: #3d3d4a;
padding: 6px;
border-radius: 20px;
font-size: 11px;
text-align: center;
cursor: pointer;
transition: 0.1s;
}
.cmd-btn:hover {
background: #5a5a6e;
transform: translateX(2px);
}
.stats-line {
font-size: 12px;
margin: 6px 0;
}
/* 流式消息游标动画 */
.streaming .message-text {
border-right: 2px solid #ffb347;
animation: blink 0.8s step-end infinite;
padding-right: 4px;
}
@keyframes blink { from,to { border-color: transparent; } 50% { border-color: #ffb347; } }
.markdown-body code { background: #1e1e2e; border-radius: 6px; }
.markdown-body pre { background: #0f0f1a; }
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(8px);}
to { opacity: 1; transform: translateY(0);}
}
::-webkit-scrollbar { width: 5px; }
/* 更换背景按钮样式 */
.bg-btn {
background: rgba(255, 165, 100, 0.8);
border: none;
color: white;
padding: 8px 16px;
border-radius: 40px;
cursor: pointer;
font-size: 12px;
margin-top: 12px;
width: 100%;
transition: 0.2s;
}
.bg-btn:hover {
background: rgba(255, 140, 70, 1);
transform: scale(0.98);
}
/* 护眼模式开关样式 */
.eye-mode-btn {
background: rgba(100, 180, 120, 0.8);
border: none;
color: white;
padding: 8px 16px;
border-radius: 40px;
cursor: pointer;
font-size: 12px;
margin-top: 8px;
width: 100%;
transition: 0.2s;
}
.eye-mode-btn:hover {
background: rgba(80, 160, 100, 1);
}
.eye-protect {
background: #c8e6d9 !important;
background-image: none !important;
}
.eye-protect .chat-box {
background: rgba(180,200,150,0.85);
}
.eye-protect .right-panel {
background: rgba(80,100,70,0.9);
}
#sendBtn i {
font-size: 16px;
}
@media (max-width: 768px) {
.right-panel {
width: 280px;
}
.input-area {
gap: 8px;
}
#sendBtn {
padding: 0 20px;
font-size: 14px;
}
}
.proxy-info {
background: rgba(0,0,0,0.6);
padding: 4px 8px;
border-radius: 20px;
font-size: 10px;
margin-top: 8px;
text-align: center;
}
</style>
</head>
<body>
<div class="main-layout">
<!-- 左侧聊天区域 -->
<div class="cat-chat-area">
<div class="chat-box" id="chatBox">
<div class="ai">🐱 主人好呀~ 我是小猫AI,已整合智能引擎!点击右侧「连接服务器」开启AI对话 ✨<br>📝 提示:输入框支持多行文本,按 Enter 发送,Shift+Enter 换行<br>🔌 连接将通过 Socket.IO 代理服务转发</div>
</div>
<div class="input-area">
<div class="input-wrapper">
<textarea id="msgInput" placeholder="喵~ 跟小猫说说话吧... (Shift+Enter换行)" autocomplete="off" rows="1"></textarea>
</div>
<button id="sendBtn"><i class="fas fa-paper-plane"></i> 发送</button>
</div>
</div>
<!-- 右侧面板: 小猫头像 + WS控制台 + 背景/护眼功能 -->
<div class="right-panel">
<!-- 小猫头像区域 -->
<div class="avatar-container">
<div id="roleArea">
<img id="roleImg" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Ccircle cx='100' cy='100' r='80' fill='%23f9cfb0'/%3E%3Ccircle cx='70' cy='80' r='8' fill='%233b2b1f'/%3E%3Ccircle cx='130' cy='80' r='8' fill='%233b2b1f'/%3E%3Cpath d='M80 120 Q100 140 120 120' stroke='%23945c3a' fill='none' stroke-width='4'/%3E%3Cpolygon points='100,150 92,175 108,175' fill='%23ffaaa5'/%3E%3C/svg%3E" alt="小猫AI">
</div>
<div class="avatar-name">🐱 小猫 AI</div>
</div>
<!-- 背景与显示设置 -->
<div style="width: 100%; margin-bottom: 12px;">
<button id="changeBgBtn" class="bg-btn"><i class="fas fa-image"></i> 🖼️ 更换背景</button>
<button id="eyeModeBtn" class="eye-mode-btn"><i class="fas fa-eye"></i> 🌿 护眼模式</button>
</div>
<!-- WebSocket 智能连接卡片 -->
<div class="ws-card">
<div class="ws-title"><i class="fas fa-globe"></i> AI 智能引擎 (Socket.IO代理)</div>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span class="status-dot" id="wsStatusDot" style="background:#f44336;"></span>
<span id="wsStatusText" style="font-size:12px;">未连接</span>
</div>
<div class="proxy-info">
<i class="fas fa-shield-alt"></i> 通过 Socket.IO 代理服务连接
</div>
<input type="text" id="wsUrlInput" class="ws-url-input" value="ws://localhost:8765" placeholder="目标 ws://地址:端口">
<div class="ws-control">
<button id="wsConnectBtn" class="btn-sm"><i class="fas fa-plug"></i> 连接</button>
<button id="wsDisconnectBtn" class="btn-sm" style="background:#6c757d;" disabled><i class="fas fa-power-off"></i> 断开</button>
</div>
<div style="margin-top: 12px;">
<div class="ws-title" style="font-size:12px;"><i class="fas fa-terminal"></i> 快捷命令</div>
<div class="command-grid">
<div class="cmd-btn" data-cmd="/help">/help</div>
<div class="cmd-btn" data-cmd="/list">/list</div>
<div class="cmd-btn" data-cmd="/memories">/memories</div>
<div class="cmd-btn" data-cmd="/tasks">/tasks</div>
<div class="cmd-btn" data-cmd="/new">/new</div>
<div class="cmd-btn" data-cmd="/config">/config</div>
</div>
</div>
<div id="statsPanel" style="margin-top: 12px; display:none;">
<div class="stats-line"><i class="fas fa-clock"></i> 连接时间: <span id="connectTime">-</span></div>
<div class="stats-line"><i class="fas fa-envelope"></i> 消息数: <span id="msgCount">0</span></div>
<div class="stats-line"><i class="fas fa-tasks"></i> 活跃任务: <span id="taskCount">0</span></div>
</div>
</div>
</div>
</div>
<!-- 隐藏文件上传 -->
<input type="file" id="bgFile" accept="image/*" style="display:none;">
<script>
(function(){
// ---------- DOM 元素 ----------
const bgFile = document.getElementById('bgFile');
const roleImg = document.getElementById('roleImg');
const chatBox = document.getElementById('chatBox');
const msgInput = document.getElementById('msgInput');
const sendBtn = document.getElementById('sendBtn');
const changeBgBtn = document.getElementById('changeBgBtn');
const eyeModeBtn = document.getElementById('eyeModeBtn');
let eyeProtectActive = false;
// ---------- Socket.IO 连接管理 ----------
let socket = null;
let reconnectAttempts = 0;
let reconnectTimer = null;
let manualDisconnect = false;
let markdownEnabled = true;
let globalMsgCount = 0;
let activeTaskCount = 0;
let connectStartTime = null;
let targetWsUrl = null;
// 流式消息管理
let streamingChat = { element: null, content: '', timer: null, active: false };
let streamingTasks = {};
function updateMsgStats() {
document.getElementById('msgCount').innerText = globalMsgCount;
document.getElementById('taskCount').innerText = activeTaskCount;
}
function addSystemMessage(text, isError=false) {
const div = document.createElement('div');
div.className = isError ? 'error-msg' : 'system-msg';
div.style.cssText = 'background:rgba(0,0,0,0.6); border-radius:16px; padding:6px 12px; margin:6px 0; font-size:12px;';
div.innerHTML = `<i class="fas ${isError ? 'fa-exclamation-triangle' : 'fa-info-circle'}"></i> ${text}`;
chatBox.appendChild(div);
chatBox.scrollTop = chatBox.scrollHeight;
}
function updateStreamingDisplay(type, execId=null) {
let stream = (type==='chat') ? streamingChat : (execId ? streamingTasks[execId] : null);
if(!stream || !stream.element) return;
const textDiv = stream.element.querySelector('.message-text');
if(textDiv) {
textDiv.setAttribute('data-raw', stream.content);
if(markdownEnabled) {
textDiv.innerHTML = marked.parse(stream.content);
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
} else textDiv.innerText = stream.content;
}
chatBox.scrollTop = chatBox.scrollHeight;
}
function handleStreamChunk(content, type='chat', taskMeta=null) {
if(type === 'chat') {
if(!streamingChat.element) {
const wrapper = document.createElement('div');
wrapper.className = 'ai streaming';
wrapper.innerHTML = `<div class="message-text markdown-body" data-raw=""></div>`;
streamingChat.element = wrapper;
chatBox.appendChild(wrapper);
streamingChat.content = '';
streamingChat.active = true;
}
streamingChat.content += content;
if(streamingChat.timer) clearTimeout(streamingChat.timer);
streamingChat.timer = setTimeout(() => updateStreamingDisplay('chat'), 50);
}
else if(type === 'task' && taskMeta && taskMeta.executionId) {
const eid = taskMeta.executionId;
if(!streamingTasks[eid]) {
const sep = document.createElement('div');
sep.className = 'task-separator';
sep.innerHTML = `<i class="fas fa-bolt"></i> 定时任务 #${taskMeta.taskId} (${taskMeta.taskTitle || '执行'}) 第${taskMeta.triggerCount||1}次执行`;
chatBox.appendChild(sep);
const taskDiv = document.createElement('div');
taskDiv.className = 'task-msg streaming';
taskDiv.style.cssText = 'background:#2d2a1e; border-radius:16px; margin:6px 0; padding:8px 12px;';
taskDiv.innerHTML = `<div class="message-text markdown-body"></div>`;
chatBox.appendChild(taskDiv);
streamingTasks[eid] = { element: taskDiv, content: '', timer: null };
activeTaskCount++;
updateMsgStats();
}
streamingTasks[eid].content += content;
if(streamingTasks[eid].timer) clearTimeout(streamingTasks[eid].timer);
streamingTasks[eid].timer = setTimeout(() => {
const st = streamingTasks[eid];
if(st && st.element) {
const textDiv = st.element.querySelector('.message-text');
if(textDiv) {
if(markdownEnabled) textDiv.innerHTML = marked.parse(st.content);
else textDiv.innerText = st.content;
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
}
chatBox.scrollTop = chatBox.scrollHeight;
}
}, 50);
}
}
function completeStream(type='chat', execId=null) {
if(type==='chat' && streamingChat.element) {
if(streamingChat.timer) clearTimeout(streamingChat.timer);
updateStreamingDisplay('chat');
streamingChat.element.classList.remove('streaming');
globalMsgCount++;
updateMsgStats();
streamingChat = { element: null, content: '', timer: null, active: false };
} else if(type==='task' && execId && streamingTasks[execId]) {
const task = streamingTasks[execId];
if(task.timer) clearTimeout(task.timer);
if(task.element) {
const txtDiv = task.element.querySelector('.message-text');
if(txtDiv && markdownEnabled) txtDiv.innerHTML = marked.parse(task.content);
task.element.classList.remove('streaming');
}
globalMsgCount++;
delete streamingTasks[execId];
activeTaskCount = Math.max(0, activeTaskCount-1);
updateMsgStats();
}
}
function handleWSMessage(data) {
if(data.type === 'chunk') handleStreamChunk(data.content, 'chat');
else if(data.type === 'complete') completeStream('chat');
else if(data.type === 'dialogue_ended') {
addSystemMessage("✨ 对话已结束,可开启新对话");
if(streamingChat.element) completeStream('chat');
}
else if(data.type === 'task_process') {
if(data.msg_type === 'chunk') {
handleStreamChunk(data.content, 'task', { taskId: data.task_id, executionId: data.execution_id, taskTitle: data.task_title, triggerCount: (data.execution_id?.split('_')[2]) });
} else if(data.msg_type === 'complete') completeStream('task', data.execution_id);
}
else if(data.type === 'task_status') addSystemMessage(`📌 任务${data.task_id}: ${data.title} - ${data.status}`);
else if(data.type === 'error') addSystemMessage(`❌ ${data.content}`, true);
else if(data.type === 'command_result') addSystemMessage(`📋 ${data.content}`);
else if(data.type === 'info') addSystemMessage(`ℹ️ ${data.content}`);
}
function sendWSMessage(message, isCommand=false) {
if(!socket || !socket.connected) {
addSystemMessage("未连接到AI服务器,请先点击连接", true);
return false;
}
const payload = isCommand ? { type: 'command', command: message } : { type: 'message', content: message };
socket.emit('ws_send', { message: JSON.stringify(payload) });
return true;
}
function connectWebSocket() {
if(socket && socket.connected) {
socket.disconnect();
}
targetWsUrl = document.getElementById('wsUrlInput').value.trim();
if(!targetWsUrl) {
addSystemMessage("请输入WebSocket服务器地址", true);
return;
}
if (!targetWsUrl.startsWith('ws://') && !targetWsUrl.startsWith('wss://')) {
addSystemMessage("WebSocket地址必须以 ws:// 或 wss:// 开头", true);
return;
}
manualDisconnect = false;
socket = io({
transports: ['websocket', 'polling'],
withCredentials: true
});
socket.on('connect', () => {
console.log('Socket.IO connected, SID:', socket.id);
updateConnectionUI(true);
reconnectAttempts = 0;
connectStartTime = new Date();
document.getElementById('connectTime').innerText = connectStartTime.toLocaleTimeString();
addSystemMessage(`✅ Socket.IO 连接成功!正在连接目标 WebSocket: ${targetWsUrl}`);
socket.emit('ws_connect', { target_url: targetWsUrl });
});
socket.on('connected', (data) => {
addSystemMessage(`✅ ${data.message}`);
});
socket.on('ws_connected', (data) => {
addSystemMessage(`✅ ${data.message}`);
if(streamingChat.element) completeStream('chat');
});
socket.on('ws_message', (data) => {
try {
const messageData = JSON.parse(data.data);
handleWSMessage(messageData);
} catch(err) {
handleStreamChunk(data.data, 'chat');
}
});
socket.on('ws_error', (data) => {
addSystemMessage(`❌ ${data.error}`, true);
updateConnectionUI(false);
});
socket.on('disconnect', (reason) => {
console.log('Socket.IO disconnected:', reason);
updateConnectionUI(false);
if(!manualDisconnect && reconnectAttempts < 3) {
reconnectAttempts++;
addSystemMessage(`⚠️ 连接断开 (${reason}),${reconnectAttempts}/3 次重试...`);
if(reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => connectWebSocket(), 2000);
} else if(!manualDisconnect) {
addSystemMessage(`❌ 连接断开 (${reason}),请手动重连`);
} else {
addSystemMessage("已手动断开连接");
}
});
socket.on('connect_error', (error) => {
console.error('Socket.IO connection error:', error);
addSystemMessage(`❌ 连接错误: ${error.message}`, true);
});
}
function disconnectWebSocket() {
manualDisconnect = true;
if(socket) {
socket.disconnect();
socket = null;
}
targetWsUrl = null;
updateConnectionUI(false);
}
function updateConnectionUI(connected) {
const dot = document.getElementById('wsStatusDot');
const text = document.getElementById('wsStatusText');
const connectBtn = document.getElementById('wsConnectBtn');
const disconnectBtn = document.getElementById('wsDisconnectBtn');
const statsPanel = document.getElementById('statsPanel');
if(connected) {
dot.style.background = "#4CAF50";
text.innerText = "已连接 (Socket.IO)";
connectBtn.disabled = true;
disconnectBtn.disabled = false;
statsPanel.style.display = "block";
} else {
dot.style.background = "#f44336";
text.innerText = "未连接";
connectBtn.disabled = false;
disconnectBtn.disabled = true;
statsPanel.style.display = "none";
activeTaskCount = 0;
globalMsgCount = 0;
updateMsgStats();
Object.keys(streamingTasks).forEach(k => completeStream('task', k));
if(streamingChat.element) completeStream('chat');
}
}
function onSendMessage() {
let text = msgInput.value.trim();
if(!text) return;
const userDiv = document.createElement('div');
userDiv.className = 'user';
userDiv.innerText = text;
chatBox.appendChild(userDiv);
msgInput.value = '';
autoResizeTextarea();
msgInput.focus();
chatBox.scrollTop = chatBox.scrollHeight;
if(socket && socket.connected) {
if(text.startsWith('/')) sendWSMessage(text, true);
else sendWSMessage(text, false);
} else {
setTimeout(() => {
let reply = "喵~ 请先连接WebSocket服务器获取智能回复~ ✨\n💡 提示:多行文本也支持哦!\n🔌 连接将通过 Socket.IO 代理服务转发";
const aiDiv = document.createElement('div');
aiDiv.className = 'ai';
aiDiv.innerText = `🐱 小猫AI:${reply}`;
chatBox.appendChild(aiDiv);
chatBox.scrollTop = chatBox.scrollHeight;
}, 200);
}
}
function autoResizeTextarea() {
if (!msgInput) return;
msgInput.style.height = 'auto';
const newHeight = Math.min(msgInput.scrollHeight, 150);
msgInput.style.height = newHeight + 'px';
}
msgInput.addEventListener('input', autoResizeTextarea);
autoResizeTextarea();
changeBgBtn.onclick = () => bgFile.click();
bgFile.onchange = (e) => {
let f = e.target.files[0];
if(f) {
let url = URL.createObjectURL(f);
document.body.style.backgroundImage = `url(${url})`;
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
if(eyeProtectActive) {
eyeProtectActive = false;
document.body.classList.remove('eye-protect');
addSystemMessage("护眼模式已自动关闭(背景已更换)");
}
addSystemMessage("✨ 背景已更换");
}
};
eyeModeBtn.onclick = () => {
if(!eyeProtectActive) {
document.body.classList.add('eye-protect');
document.body.style.backgroundImage = "none";
eyeProtectActive=true;
addSystemMessage("护眼模式已开启(背景图片暂时隐藏)");
eyeModeBtn.style.background = "rgba(60, 140, 80, 0.9)";
}
else {
document.body.classList.remove('eye-protect');
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
eyeProtectActive=false;
addSystemMessage("护眼模式关闭,恢复背景");
eyeModeBtn.style.background = "rgba(100, 180, 120, 0.8)";
}
};
document.getElementById('wsConnectBtn').onclick = connectWebSocket;
document.getElementById('wsDisconnectBtn').onclick = disconnectWebSocket;
document.querySelectorAll('.cmd-btn').forEach(btn => {
btn.onclick = () => {
let cmd = btn.getAttribute('data-cmd');
msgInput.value = cmd + ' ';
autoResizeTextarea();
msgInput.focus();
};
});
sendBtn.onclick = onSendMessage;
msgInput.addEventListener('keydown', (e) => {
if(e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSendMessage();
}
});
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
document.body.style.backgroundSize = "cover";
document.body.style.backgroundPosition = "center center";
document.body.style.backgroundRepeat = "no-repeat";
msgInput.focus();
})();
</script>
</body>
</html>
+1687
View File
File diff suppressed because it is too large. Load diff
+273
View File
@@ -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}")
+15
View File
@@ -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
+583
View File
@@ -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()
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""
ADS1115: Linux I2C 读取 TI ADS1115 16位 ADC 的命令行工具
"""
import argparse
import sys
import time
# ============================================================
# ADS1115 寄存器地址
# ============================================================
ADS1115_REG_CONVERSION = 0x00
ADS1115_REG_CONFIG = 0x01
ADS1115_REG_LO_THRESH = 0x02
ADS1115_REG_HI_THRESH = 0x03
# ============================================================
# 配置寄存器 bit 定义 (16 位)
# ============================================================
# --- MUX 输入通道 ---
ADS1115_MUX_AIN0_AIN1 = 0x0000 # 差分: P=AIN0, N=AIN1 (默认)
ADS1115_MUX_AIN0_AIN3 = 0x1000 # 差分: P=AIN0, N=AIN3
ADS1115_MUX_AIN1_AIN3 = 0x2000 # 差分: P=AIN1, N=AIN3
ADS1115_MUX_AIN2_AIN3 = 0x3000 # 差分: P=AIN2, N=AIN3
ADS1115_MUX_AIN0_GND = 0x4000 # 单端: AIN0
ADS1115_MUX_AIN1_GND = 0x5000 # 单端: AIN1
ADS1115_MUX_AIN2_GND = 0x6000 # 单端: AIN2
ADS1115_MUX_AIN3_GND = 0x7000 # 单端: AIN3
# --- PGA 增益 (满量程范围) ---
ADS1115_PGA_6_144V = 0x0000 # ±6.144V (1LSB=0.1875mV)
ADS1115_PGA_4_096V = 0x0200 # ±4.096V (1LSB=0.125mV)
ADS1115_PGA_2_048V = 0x0400 # ±2.048V (1LSB=0.0625mV) (默认)
ADS1115_PGA_1_024V = 0x0600 # ±1.024V (1LSB=0.03125mV)
ADS1115_PGA_0_512V = 0x0800 # ±0.512V (1LSB=0.015625mV)
ADS1115_PGA_0_256V = 0x0A00 # ±0.256V (1LSB=0.0078125mV)
PGA_VALUES = [ADS1115_PGA_6_144V, ADS1115_PGA_4_096V, ADS1115_PGA_2_048V,
ADS1115_PGA_1_024V, ADS1115_PGA_0_512V, ADS1115_PGA_0_256V]
PGA_FS = [6.144, 4.096, 2.048, 1.024, 0.512, 0.256]
PGA_NAMES = ["±6.144V", "±4.096V", "±2.048V", "±1.024V", "±0.512V", "±0.256V"]
# --- 数据速率 ---
ADS1115_DR_8SPS = 0x0000 # 8 SPS
ADS1115_DR_16SPS = 0x0020 # 16 SPS
ADS1115_DR_32SPS = 0x0040 # 32 SPS
ADS1115_DR_64SPS = 0x0060 # 64 SPS
ADS1115_DR_128SPS = 0x0080 # 128 SPS (默认)
ADS1115_DR_250SPS = 0x00A0 # 250 SPS
ADS1115_DR_475SPS = 0x00C0 # 475 SPS
ADS1115_DR_860SPS = 0x00E0 # 860 SPS
DR_VALUES = [ADS1115_DR_8SPS, ADS1115_DR_16SPS, ADS1115_DR_32SPS,
ADS1115_DR_64SPS, ADS1115_DR_128SPS, ADS1115_DR_250SPS,
ADS1115_DR_475SPS, ADS1115_DR_860SPS]
DR_NAMES = ["8 SPS", "16 SPS", "32 SPS", "64 SPS",
"128 SPS", "250 SPS", "475 SPS", "860 SPS"]
# --- 比较器 ---
ADS1115_COMP_QUE_DISABLE = 0x0003
# --- 操作模式 ---
ADS1115_MODE_CONTINUOUS = 0x0000
ADS1115_MODE_SINGLE = 0x0100
# --- 启动转换 ---
ADS1115_OS_START = 0x8000
class ADS1115:
"""ADS1115 16位ADC驱动"""
def __init__(self, bus=1, address=0x48, pga_idx=2, dr_idx=4):
"""
初始化 ADS1115
:param bus: I2C 总线号 (默认1)
:param address: I2C 设备地址 (默认0x48)
:param pga_idx: PGA增益索引 0~5 (默认2 = ±2.048V)
:param dr_idx: 数据速率索引 0~7 (默认4 = 128SPS)
"""
self.address = address
self.pga_idx = pga_idx if 0 <= pga_idx <= 5 else 2
self.dr_idx = dr_idx if 0 <= dr_idx <= 7 else 4
self.fs_voltage = PGA_FS[self.pga_idx]
self.config = 0
# 初始化 I2C
try:
from smbus2 import SMBus
self.bus = SMBus(bus)
except ImportError:
print("错误: 需要 smbus2 库,请执行: pip install smbus2", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(f"错误: I2C 总线 /dev/i2c-{bus} 不存在", file=sys.stderr)
print("提示: 确保已启用 I2C (sudo raspi-config -> Interface Options -> I2C)", file=sys.stderr)
sys.exit(1)
except PermissionError:
print(f"错误: 无法访问 I2C 总线 /dev/i2c-{bus},请将用户加入 i2c 组", file=sys.stderr)
print(" sudo usermod -aG i2c $USER", file=sys.stderr)
sys.exit(1)
# 初始化配置
self._init_device()
def _write_word(self, reg, value):
"""向寄存器写入2字节 (大端)"""
high = (value >> 8) & 0xFF
low = value & 0xFF
self.bus.write_i2c_block_data(self.address, reg, [high, low])
def _read_word(self, reg):
"""从寄存器读取2字节 (大端)"""
data = self.bus.read_i2c_block_data(self.address, reg, 2)
return (data[0] << 8) | data[1]
def _init_device(self):
"""初始化 ADS1115 配置"""
self.config = (ADS1115_MUX_AIN0_GND |
PGA_VALUES[self.pga_idx] |
ADS1115_MODE_SINGLE |
DR_VALUES[self.dr_idx] |
ADS1115_COMP_QUE_DISABLE)
self._write_word(ADS1115_REG_CONFIG, self.config)
def read_single(self, channel):
"""
读取单端通道 (单次转换)
:param channel: 通道号 0~3 (对应 AIN0~AIN3)
:return: 电压值 (V)
"""
if channel < 0 or channel > 3:
raise ValueError(f"通道号无效: {channel} (有效: 0~3)")
# 设置通道并启动转换
mux = ADS1115_MUX_AIN0_GND | (channel << 12) # AINch-GND
cfg = (self.config & ~0x7000) | mux | ADS1115_OS_START
self._write_word(ADS1115_REG_CONFIG, cfg)
self.config = cfg
# 等待转换完成
wait_ms = 1000 // (8 << self.dr_idx) + 3
time.sleep(wait_ms / 1000.0)
# 读取转换值
raw = self._read_word(ADS1115_REG_CONVERSION)
raw_signed = raw if raw < 32768 else raw - 65536
# 转换为电压: V = raw * FS_VOLTAGE / 32768
voltage = raw_signed * self.fs_voltage / 32768.0
return voltage
def read_diff(self, pos, neg):
"""
读取差分通道 (单次转换)
支持组合: (0,1), (0,3), (1,3), (2,3)
:param pos: 正极通道 (0~3)
:param neg: 负极通道 (0~3)
:return: 电压值 (V)
"""
mux_map = {
(0, 1): ADS1115_MUX_AIN0_AIN1,
(0, 3): ADS1115_MUX_AIN0_AIN3,
(1, 3): ADS1115_MUX_AIN1_AIN3,
(2, 3): ADS1115_MUX_AIN2_AIN3,
}
key = (pos, neg)
if key not in mux_map:
raise ValueError(f"不支持的差分组合: AIN{pos} - AIN{neg},支持: 0-1, 0-3, 1-3, 2-3")
mux = mux_map[key]
cfg = (self.config & ~0x7000) | mux | ADS1115_OS_START
self._write_word(ADS1115_REG_CONFIG, cfg)
self.config = cfg
# 等待转换完成
wait_ms = 1000 // (8 << self.dr_idx) + 3
time.sleep(wait_ms / 1000.0)
raw = self._read_word(ADS1115_REG_CONVERSION)
raw_signed = raw if raw < 32768 else raw - 65536
voltage = raw_signed * self.fs_voltage / 32768.0
return voltage
def start_continuous(self, channel):
"""启动连续模式"""
if channel < 0 or channel > 3:
raise ValueError(f"通道号无效: {channel}")
cfg = (self.config & ~0x7100 |
(ADS1115_MUX_AIN0_GND | (channel << 12)) |
ADS1115_MODE_CONTINUOUS |
ADS1115_COMP_QUE_DISABLE)
self._write_word(ADS1115_REG_CONFIG, cfg)
self.config = cfg
def read_continuous(self):
"""连续模式读数"""
raw = self._read_word(ADS1115_REG_CONVERSION)
raw_signed = raw if raw < 32768 else raw - 65536
return raw_signed * self.fs_voltage / 32768.0
def scan_all(self):
"""扫描所有单端通道"""
results = {}
ch_names = ["AIN0", "AIN1", "AIN2", "AIN3"]
for ch in range(4):
v = self.read_single(ch)
results[ch_names[ch]] = v
return results
def print_config(self):
"""打印配置寄存器详情"""
config = self._read_word(ADS1115_REG_CONFIG)
mux = (config >> 12) & 0x07
pga = (config >> 9) & 0x07
mode = (config >> 8) & 0x01
dr = (config >> 5) & 0x07
comp = config & 0x03
mux_names = {
0: "AIN0-AIN1 (差分)",
1: "AIN0-AIN3 (差分)",
2: "AIN1-AIN3 (差分)",
3: "AIN2-AIN3 (差分)",
4: "AIN0-GND (单端)",
5: "AIN1-GND (单端)",
6: "AIN2-GND (单端)",
7: "AIN3-GND (单端)",
}
print(f"\n===== ADS1115 寄存器 =====")
print(f" 配置寄存器: 0x{config:04X}")
print(f" MUX: {mux} -> {mux_names.get(mux, '未知')}")
if pga <= 5:
print(f" PGA: {pga} -> {PGA_NAMES[pga]} (满量程 {PGA_FS[pga]:.3f}V)")
else:
print(f" PGA: {pga} -> 未知")
print(f" 模式: {'单次' if mode else '连续'}")
if dr <= 7:
print(f" 速率: {dr} -> {DR_NAMES[dr]}")
else:
print(f" 速率: {dr} -> 未知")
print(f" 比较器: {'禁用' if comp == 3 else '启用'}")
# 读取阈值
lo_raw = self._read_word(ADS1115_REG_LO_THRESH)
hi_raw = self._read_word(ADS1115_REG_HI_THRESH)
lo_signed = lo_raw if lo_raw < 32768 else lo_raw - 65536
hi_signed = hi_raw if hi_raw < 32768 else hi_raw - 65536
lo_v = lo_signed * self.fs_voltage / 32768.0
hi_v = hi_signed * self.fs_voltage / 32768.0
print(f" 阈值: LO={lo_signed} ({lo_v:.6f}V), HI={hi_signed} ({hi_v:.6f}V)")
def close(self):
"""关闭 I2C 总线"""
self.bus.close()
def main():
parser = argparse.ArgumentParser(
description="Linux I2C 读取 TI ADS1115 16位 ADC 的命令行工具",
epilog="""
参数说明:
-b, --i2c-bus I2C 总线号 (默认 1, 对应 /dev/i2c-1)
-a, --address I2C 设备地址 (默认 0x48)
-p, --pga PGA 增益索引 0~5 (默认 2=±2.048V)
0=±6.144V, 1=±4.096V, 2=±2.048V
3=±1.024V, 4=±0.512V, 5=±0.256V
-r, --rate 数据速率索引 0~7 (默认 4=128SPS)
0=8, 1=16, 2=32, 3=64
4=128, 5=250, 6=475, 7=860 (SPS)
-c, --channel 单端通道号 0~3 (读取 AIN0~AIN3)
--diff 差分模式,格式 "pos,neg" 如 "0,1"
--scan 扫描所有单端通道
--continuous 连续读取模式
-n, --count 读取次数 (默认 1)
--delay 每次读取间隔毫秒 (默认 10ms)
--config 显示配置寄存器详情
--percent 输出基于 0-5V 范围的百分比 (0.00%~100.00%)
--map 将 0-5V 百分比映射到自定义数值区间,格式 "min,max"
例: --map -20,80 表示 0V=-20, 5V=80
使用 --map 时自动启用百分比显示
使用示例:
ads1115 # 读取 AIN0 (默认参数)
ads1115 -c 2 # 读取 AIN2
ads1115 --scan # 扫描所有单端通道
ads1115 -c 0 -n 5 # 连续读 AIN0 5次
ads1115 --diff 0,1 # 差分模式 AIN0-AIN1
ads1115 -a 0x49 -p 1 -r 6 # 指定地址、PGA、速率
ads1115 -c 0 --continuous -n 10 # 连续模式读10次
ads1115 --config # 查看配置寄存器
ads1115 --scan --config # 扫描并查看配置
ads1115 -c 0 --percent # 读取 AIN0 并显示 0-5V 百分比
ads1115 --scan --percent # 扫描所有通道并显示百分比
ads1115 -c 0 --map -20,80 # 将 0-5V 映射到 -20~80 区间
ads1115 --scan --map 0,100 # 扫描并映射到 0~100 区间
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("-b", "--i2c-bus", type=int, default=1, help="I2C 总线号 (默认 1)")
parser.add_argument("-a", "--address", type=lambda x: int(x, 0), default=0x48, help="I2C 设备地址 (默认 0x48)")
parser.add_argument("-p", "--pga", type=int, default=2, choices=range(0, 6), metavar="{0..5}", help="PGA 增益索引 0~5 (默认 2)")
parser.add_argument("-r", "--rate", type=int, default=4, choices=range(0, 8), metavar="{0..7}", help="数据速率索引 0~7 (默认 4)")
parser.add_argument("-c", "--channel", type=int, default=None, choices=range(0, 4), metavar="{0..3}", help="单端通道号 0~3")
parser.add_argument("--diff", type=str, default=None, help='差分模式,格式 "pos,neg" 如 "0,1"')
parser.add_argument("--scan", action="store_true", help="扫描所有单端通道")
parser.add_argument("--continuous", action="store_true", help="连续读取模式")
parser.add_argument("-n", "--count", type=int, default=1, help="读取次数 (默认 1)")
parser.add_argument("--delay", type=int, default=10, help="每次读取间隔毫秒 (默认 10ms)")
parser.add_argument("--config", action="store_true", help="显示配置寄存器详情")
parser.add_argument("--percent", action="store_true", help="输出基于 0-5V 范围的百分比")
parser.add_argument("--map", type=str, default=None, metavar="MIN,MAX",
help='将 0-5V 百分比映射到数值区间,格式 "min,max" 如 "-20,80"')
args = parser.parse_args()
# --- 解析 --map 参数 ---
map_range = None
if args.map is not None:
try:
parts = args.map.split(",")
if len(parts) != 2:
raise ValueError
map_min, map_max = float(parts[0]), float(parts[1])
if map_min == map_max:
raise ValueError("映射区间两端不能相同")
map_range = (map_min, map_max)
args.percent = True # --map 自动启用百分比显示
except ValueError as e:
err_msg = str(e) if str(e) != "" else "格式无效"
print(f"错误: --map 参数无效 '{args.map}',请使用 'min,max' 格式,如 '-20,80' ({err_msg})", file=sys.stderr)
sys.exit(1)
try:
adc = ADS1115(bus=args.i2c_bus, address=args.address,
pga_idx=args.pga, dr_idx=args.rate)
def pct(v):
"""返回 0-5V 百分比/映射后缀字符串"""
if not args.percent:
return ""
p = v / 5.0 * 100.0
suffix = f" [{p:6.2f}%]"
if map_range is not None:
mapped = map_range[0] + (p / 100.0) * (map_range[1] - map_range[0])
suffix += f" (映射: {mapped:.2f})"
return suffix
has_action = False
# --- 单端通道读取 ---
if args.channel is not None and args.diff is None:
has_action = True
ch_names = ["AIN0", "AIN1", "AIN2", "AIN3"]
ch_name = ch_names[args.channel]
if args.continuous:
print(f"连续模式读取 {ch_name} ({args.count} 次):")
adc.start_continuous(args.channel)
time.sleep(0.05)
for i in range(args.count):
v = adc.read_continuous()
print(f" [{i}] {ch_name}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}")
if i < args.count - 1:
time.sleep(args.delay / 1000.0)
else:
print(f"读取 {ch_name} ({args.count} 次):")
for i in range(args.count):
v = adc.read_single(args.channel)
print(f" [{i}] {ch_name}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}")
if i < args.count - 1:
time.sleep(args.delay / 1000.0)
# --- 差分模式 ---
if args.diff is not None:
has_action = True
try:
parts = args.diff.split(",")
if len(parts) != 2:
raise ValueError
pos, neg = int(parts[0]), int(parts[1])
except (ValueError, IndexError):
print(f"错误: 差分格式无效 '{args.diff}',请使用 'pos,neg' 格式,如 '0,1'", file=sys.stderr)
sys.exit(1)
print(f"差分读取 AIN{pos} - AIN{neg} ({args.count} 次):")
for i in range(args.count):
v = adc.read_diff(pos, neg)
print(f" [{i}] AIN{pos}-AIN{neg}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}")
if i < args.count - 1:
time.sleep(args.delay / 1000.0)
# --- 扫描所有单端通道 ---
if args.scan:
has_action = True
print("\n===== 单端通道扫描 =====")
results = adc.scan_all()
for ch, v in results.items():
print(f" {ch}: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}")
# --- 显示配置 ---
if args.config:
has_action = True
adc.print_config()
# --- 默认行为: 读 AIN0 ---
if not has_action:
v = adc.read_single(0)
print(f"AIN0: {v:+.6f} V ({v*1000:.3f} mV){pct(v)}")
except KeyboardInterrupt:
print("\n用户中断")
sys.exit(0)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
finally:
if 'adc' in locals():
adc.close()
if __name__ == "__main__":
main()
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""
ADS1256: Linux SPI 读取 TI ADS1256 24位 ΔΣ ADC 的命令行工具
"""
import spidev
import os
import sys
import argparse
import time
# ============================================================
# ADS1256 寄存器地址
# ============================================================
ADS1256_REG_STATUS = 0x00
ADS1256_REG_MUX = 0x01
ADS1256_REG_ADCON = 0x02
ADS1256_REG_DRATE = 0x03
ADS1256_REG_IO = 0x04
ADS1256_REG_OFC0 = 0x05
ADS1256_REG_OFC1 = 0x06
ADS1256_REG_OFC2 = 0x07
ADS1256_REG_FSC0 = 0x08
ADS1256_REG_FSC1 = 0x09
ADS1256_REG_FSC2 = 0x0A
# ============================================================
# ADS1256 命令
# ============================================================
ADS1256_CMD_WAKEUP = 0x00
ADS1256_CMD_RDATA = 0x01
ADS1256_CMD_RDATAC = 0x03
ADS1256_CMD_SDATAC = 0x0F
ADS1256_CMD_RREG = 0x10
ADS1256_CMD_WREG = 0x50
ADS1256_CMD_SELFCAL = 0xF0
ADS1256_CMD_SELFOCAL = 0xF1
ADS1256_CMD_SELFGCAL = 0xF2
ADS1256_CMD_SYSOCAL = 0xF3
ADS1256_CMD_SYSGCAL = 0xF4
ADS1256_CMD_SYNC = 0xFC
ADS1256_CMD_STANDBY = 0xFD
ADS1256_CMD_RESET = 0xFE
# ============================================================
# 数据速率 (DRATE 寄存器值)
# ============================================================
DRATE_MAP = {
30000: 0xF0, 15000: 0xE0, 7500: 0xD0, 3750: 0xC0,
2000: 0xB0, 1000: 0xA0, 500: 0x90, 100: 0x80,
60: 0x70, 50: 0x60, 30: 0x50, 25: 0x40,
15: 0x30, 10: 0x20, 5: 0x10,
}
# ============================================================
# PGA 增益
# ============================================================
PGA_MAP = {1: 0x00, 2: 0x01, 4: 0x02, 8: 0x03, 16: 0x04, 32: 0x05, 64: 0x06}
VREF = 2.5 # 参考电压
# ============================================================
# SPI 接口类 (基于 spidev)
# ============================================================
class SpiDev:
def __init__(self, device="/dev/spidev0.0", speed=1920000):
self.device = device
self.speed = speed
self.spi = None
self._bus = None
self._cs = None
def _parse_device(self):
"""从设备路径解析 bus 和 cs, 如 /dev/spidev0.0 -> (0, 0)"""
base = os.path.basename(self.device) # spidev0.0
parts = base.replace("spidev", "").split(".")
return int(parts[0]), int(parts[1])
def open(self):
self._bus, self._cs = self._parse_device()
self.spi = spidev.SpiDev()
self.spi.open(self._bus, self._cs)
self.spi.mode = 1 # SPI 模式 1: CPOL=0, CPHA=1
self.spi.bits_per_word = 8
self.spi.max_speed_hz = self.speed
# 读取验证
print(f"SPI 初始化: {self.device}, 模式={self.spi.mode}, "
f"位数={self.spi.bits_per_word}, 速度={self.spi.max_speed_hz} Hz",
file=sys.stderr)
def transfer(self, tx_data, rx_len=None):
"""SPI 全双工传输"""
if rx_len is None:
rx_len = len(tx_data)
# spidev.xfer2 返回接收到的字节
rx = self.spi.xfer2(list(tx_data), self.speed, 1) # 1 = SPI_MODE_1
# 如果 rx_len 大于发送长度,补充虚拟字节
if rx_len > len(tx_data):
extra = self.spi.xfer2([0xFF] * (rx_len - len(tx_data)), self.speed, 1)
rx.extend(extra)
return list(rx[:rx_len])
def write_byte(self, byte):
self.spi.xfer2([byte], self.speed, 1)
# 不需要接收
def read_byte(self):
rx = self.spi.xfer2([0xFF], self.speed, 1)
return rx[0]
def close(self):
if self.spi:
self.spi.close()
self.spi = None
# ============================================================
# ADS1256 控制类
# ============================================================
class ADS1256:
def __init__(self, spi, pga=2, drate=1000):
self.spi = spi
self.pga = pga
self.drate = drate
self.pga_reg = PGA_MAP.get(pga, 0x01)
def wait_drdy(self):
time.sleep(0.0001) # 100us 模拟等待
def reset(self):
self.spi.write_byte(ADS1256_CMD_RESET)
time.sleep(0.001)
self.spi.write_byte(ADS1256_CMD_WAKEUP)
time.sleep(0.001)
def write_reg(self, reg, value):
self.spi.write_byte(ADS1256_CMD_WREG | reg)
self.spi.write_byte(0x00) # 1 byte
self.spi.write_byte(value)
def read_reg(self, reg):
self.spi.write_byte(ADS1256_CMD_RREG | reg)
self.spi.write_byte(0x00)
return self.spi.read_byte()
def set_channel(self, pos, neg=0x08):
"""设置输入通道, neg=0x08 表示 AINCOM (单端模式)"""
mux = ((pos & 0x07) << 4) | (neg & 0x07)
self.write_reg(ADS1256_REG_MUX, mux)
time.sleep(0.00001)
def init(self):
print("初始化 ADS1256...", file=sys.stderr)
# 复位
self.reset()
time.sleep(0.01)
# STATUS: ACAL=1 (自动校准), ORDER=0 (MSB), BUFEN=0
self.write_reg(ADS1256_REG_STATUS, 0x14)
# MUX: 默认 AIN0 + AINCOM
self.set_channel(0, 0x08)
# ADCON: PGA 增益
self.write_reg(ADS1256_REG_ADCON, self.pga_reg)
# DRATE: 数据速率
drate_val = DRATE_MAP.get(self.drate, 0xA0)
self.write_reg(ADS1256_REG_DRATE, drate_val)
# 自校准
print("自校准中...", file=sys.stderr)
self.spi.write_byte(ADS1256_CMD_SELFCAL)
time.sleep(0.5) # 等待校准完成 (~400ms @ 1000SPS)
# 验证寄存器
status = self.read_reg(ADS1256_REG_STATUS)
mux = self.read_reg(ADS1256_REG_MUX)
adcon = self.read_reg(ADS1256_REG_ADCON)
drate = self.read_reg(ADS1256_REG_DRATE)
print(f"寄存器: STATUS=0x{status:02X}, MUX=0x{mux:02X}, "
f"ADCON=0x{adcon:02X}, DRATE=0x{drate:02X}", file=sys.stderr)
print("ADS1256 初始化完成!", file=sys.stderr)
def read_data(self):
"""单次读取 24 位数据, 返回有符号整数值"""
self.wait_drdy()
self.spi.write_byte(ADS1256_CMD_RDATA)
buf = self.spi.transfer([0xFF, 0xFF, 0xFF], 3)
# 组合 24 位
value = (buf[0] << 16) | (buf[1] << 8) | buf[2]
# 符号扩展 (24 位 -> 32 位)
if value & 0x800000:
value |= 0xFF000000
# Python 中转为有符号
if value >= 0x80000000:
value -= 0x100000000
return value
def raw_to_voltage(self, raw):
"""将原始值转换为电压"""
return raw * VREF / (1 << 23) / self.pga
def read_channel(self, ch):
"""读取指定单端通道"""
self.set_channel(ch, 0x08) # AINch vs AINCOM
time.sleep(0.00001)
self.spi.write_byte(ADS1256_CMD_SYNC)
time.sleep(0.00001)
self.spi.write_byte(ADS1256_CMD_WAKEUP)
# 等待转换完成 (1000SPS = 1ms)
time.sleep(0.002)
return self.read_data()
def read_diff(self, pos, neg):
"""读取差分通道"""
self.set_channel(pos, neg)
time.sleep(0.00001)
self.spi.write_byte(ADS1256_CMD_SYNC)
time.sleep(0.00001)
self.spi.write_byte(ADS1256_CMD_WAKEUP)
time.sleep(0.002)
return self.read_data()
def scan_all(self):
"""扫描所有 8 个单端通道"""
results = []
for ch in range(8):
raw = self.read_channel(ch)
volts = self.raw_to_voltage(raw)
results.append((ch, raw, volts))
return results
# ============================================================
# 主程序
# ============================================================
def main():
parser = argparse.ArgumentParser(
description="ADS1256: Linux SPI 读取 TI ADS1256 24位 ΔΣ ADC 的命令行工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
参数说明:
-d, --device SPI 设备路径 (默认: /dev/spidev0.0)
-s, --speed SPI 时钟频率 (Hz) (默认: 1920000)
-m, --mode 工作模式: single/diff/scan (默认: single)
-c, --channel 单端通道号 0-7 (默认: 0)
--pos 差分正端 0-7 (默认: 0)
--neg 差分负端 0-7 (默认: 1)
-p, --pga PGA 增益 1/2/4/8/16/32/64 (默认: 2)
-r, --drate 数据速率 SPS (默认: 1000)
可选: 5,10,15,25,30,50,60,100,500,1000,
2000,3750,7500,15000,30000
-n, --count 采样次数 (默认: 1)
--avg 输出均值 (多采样时)
-o, --output 输出格式: raw/voltage/both (默认: both)
使用示例:
# 读取 AIN0 单端通道
ads1256 -c 0
# 读取 AIN0 差分 AIN1
ads1256 -m diff --pos 0 --neg 1
# 扫描所有 8 个通道
ads1256 -m scan
# 设置 PGA=1, 速率为 30SPS, 采样 10 次并输出均值
ads1256 -c 0 -p 1 -r 30 -n 10 --avg
# 指定 SPI 设备
ads1256 -d /dev/spidev0.1 -s 1000000 -c 3
"""
)
parser.add_argument("-d", "--device", default="/dev/spidev0.0",
help="SPI 设备路径 (默认: /dev/spidev0.0)")
parser.add_argument("-s", "--speed", type=int, default=1920000,
help="SPI 时钟频率 Hz (默认: 1920000)")
parser.add_argument("-m", "--mode", choices=["single", "diff", "scan"],
default="single", help="工作模式 (默认: single)")
parser.add_argument("-c", "--channel", type=int, default=0, choices=range(8),
help="单端通道号 0-7 (默认: 0)")
parser.add_argument("--pos", type=int, default=0, choices=range(8),
help="差分正端 0-7 (默认: 0)")
parser.add_argument("--neg", type=int, default=1, choices=range(8),
help="差分负端 0-7 (默认: 1)")
parser.add_argument("-p", "--pga", type=int, default=2,
choices=[1, 2, 4, 8, 16, 32, 64],
help="PGA 增益 (默认: 2)")
parser.add_argument("-r", "--drate", type=int, default=1000,
help="数据速率 SPS (默认: 1000)")
parser.add_argument("-n", "--count", type=int, default=1,
help="采样次数 (默认: 1)")
parser.add_argument("--avg", action="store_true",
help="输出均值")
parser.add_argument("-o", "--output", choices=["raw", "voltage", "both"],
default="both", help="输出格式 (默认: both)")
args = parser.parse_args()
# 验证 drate
if args.drate not in DRATE_MAP:
print(f"错误: 不支持的数据速率 {args.drate} SPS", file=sys.stderr)
print(f"支持: {sorted(DRATE_MAP.keys())}", file=sys.stderr)
sys.exit(1)
# 验证 SPI 设备
if not os.path.exists(args.device):
print(f"错误: SPI 设备 {args.device} 不存在", file=sys.stderr)
sys.exit(1)
# 初始化 SPI
spi = SpiDev(device=args.device, speed=args.speed)
try:
spi.open()
except Exception as e:
print(f"错误: SPI 初始化失败 - {e}", file=sys.stderr)
sys.exit(1)
# 初始化 ADS1256
adc = ADS1256(spi, pga=args.pga, drate=args.drate)
try:
adc.init()
except Exception as e:
print(f"ADS1256 初始化失败: {e}", file=sys.stderr)
spi.close()
sys.exit(1)
# 执行读取
try:
if args.mode == "scan":
# 扫描所有通道
results = adc.scan_all()
print("\n===== 多通道扫描 =====")
for ch, raw, volts in results:
_print_result(ch, raw, volts, args.output)
elif args.mode == "diff":
# 差分模式
pos, neg = args.pos, args.neg
channel_label = f"AIN{pos}-AIN{neg}"
raw_values = []
for i in range(args.count):
raw = adc.read_diff(pos, neg)
raw_values.append(raw)
volts = adc.raw_to_voltage(raw)
_print_sample(i, raw, volts, args.output, channel_label)
if args.count > 1 and args.avg:
avg_raw = sum(raw_values) // len(raw_values)
avg_volts = adc.raw_to_voltage(avg_raw)
print(f"\n--- 均值 ---")
_print_result(channel_label, avg_raw, avg_volts, args.output)
else:
# 单端模式
ch = args.channel
channel_label = f"AIN{ch}"
raw_values = []
for i in range(args.count):
raw = adc.read_channel(ch)
raw_values.append(raw)
volts = adc.raw_to_voltage(raw)
_print_sample(i, raw, volts, args.output, channel_label)
if args.count > 1 and args.avg:
avg_raw = sum(raw_values) // len(raw_values)
avg_volts = adc.raw_to_voltage(avg_raw)
print(f"\n--- 均值 ---")
_print_result(channel_label, avg_raw, avg_volts, args.output)
except Exception as e:
print(f"读取错误: {e}", file=sys.stderr)
finally:
spi.close()
def _print_sample(index, raw, volts, output_format, label=""):
if output_format == "raw":
print(f"[{index}] {label}: raw={raw}")
elif output_format == "voltage":
print(f"[{index}] {label}: {_fmt_voltage(volts)}")
else:
print(f"[{index}] {label}: raw={raw:7d}, {_fmt_voltage(volts)}")
def _print_result(label, raw, volts, output_format):
if output_format == "raw":
print(f" {label}: raw={raw}")
elif output_format == "voltage":
print(f" {label}: {_fmt_voltage(volts)}")
else:
print(f" {label}: raw={raw:7d}, {_fmt_voltage(volts)}")
def _fmt_voltage(volts):
if abs(volts) >= 1.0:
return f"电压 = {volts:+.6f} V"
else:
return f"电压 = {volts*1000:+.4f} mV"
if __name__ == "__main__":
main()
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env python3
"""
AES-CBC/GCM 命令行加解密工具 - 支持文本和文件的加密解密,支持批量处理
"""
import base64
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Random import get_random_bytes
import os
import sys
import json
import argparse
from pathlib import Path
# 定义分块大小(64MB)
CHUNK_SIZE = 64 * 1024 * 1024
class AESCli:
"""AES命令行加解密工具"""
def __init__(self, mode='CBC'):
"""
初始化AES工具
:param mode: 加密模式 'CBC' 或 'GCM'
"""
self.mode = mode
self.key = None
self.iv = None
def set_key_iv(self, key_hex, iv_hex):
"""
设置密钥和IV/Nonce
:param key_hex: 十六进制密钥字符串
:param iv_hex: 十六进制IV/Nonce字符串
"""
try:
if not key_hex:
raise ValueError("密钥不能为空")
if not iv_hex:
raise ValueError("IV/Nonce不能为空")
self.key = bytes.fromhex(key_hex)
self.iv = bytes.fromhex(iv_hex)
if len(self.key) not in [16, 24, 32]:
raise ValueError(f"密钥长度必须为16、24或32字节,当前为{len(self.key)}字节")
if self.mode == "CBC" and len(self.iv) != 16:
raise ValueError(f"CBC模式IV长度必须为16字节,当前为{len(self.iv)}字节")
elif self.mode == "GCM" and len(self.iv) != 12:
raise ValueError(f"GCM模式Nonce长度必须为12字节,当前为{len(self.iv)}字节")
return True
except ValueError as e:
print(f"错误: {e}")
return False
def generate_random_key(self, key_size=16):
"""生成随机密钥"""
if key_size not in [16, 24, 32]:
print(f"错误: 密钥大小必须为16、24或32,当前为{key_size}")
return None
self.key = get_random_bytes(key_size)
key_hex = self.key.hex()
print(f"已生成 {key_size} 字节随机密钥: {key_hex}")
return key_hex
def generate_random_iv(self):
"""生成随机IV或Nonce"""
if self.mode == "CBC":
iv_size = 16
label = "IV"
else: # GCM
iv_size = 12
label = "Nonce"
self.iv = get_random_bytes(iv_size)
iv_hex = self.iv.hex()
print(f"已生成 {iv_size} 字节随机{label}: {iv_hex}")
return iv_hex
def encrypt_text(self, plaintext):
"""加密文本"""
if not self.key or not self.iv:
raise ValueError("请先设置密钥和IV/Nonce")
if self.mode == "CBC":
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
ciphertext = cipher.encrypt(pad(plaintext.encode('utf-8'), AES.block_size))
encrypted = base64.b64encode(ciphertext).decode('utf-8')
else: # GCM
cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv)
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
# 存储格式: ciphertext + tag
combined = ciphertext + tag
encrypted = base64.b64encode(combined).decode('utf-8')
return encrypted
def decrypt_text(self, encrypted_text):
"""解密文本"""
if not self.key or not self.iv:
raise ValueError("请先设置密钥和IV/Nonce")
combined = base64.b64decode(encrypted_text)
if self.mode == "CBC":
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
decrypted = unpad(cipher.decrypt(combined), AES.block_size)
else: # GCM
# 分离ciphertext和tag (最后16字节是tag)
if len(combined) < 16:
raise ValueError("密文格式无效:缺少认证标签")
tag = combined[-16:]
ciphertext = combined[:-16]
cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv)
decrypted = cipher.decrypt_and_verify(ciphertext, tag)
return decrypted.decode('utf-8')
def encrypt_file(self, input_path, output_path, delete_source=False):
"""加密文件"""
if not self.key or not self.iv:
raise ValueError("请先设置密钥和IV/Nonce")
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
file_size = os.path.getsize(input_path)
print(f"开始加密文件: {input_path} ({self._format_size(file_size)})")
if self.mode == "CBC":
self._encrypt_file_cbc(input_path, output_path)
else: # GCM
self._encrypt_file_gcm(input_path, output_path)
print(f"文件加密完成,保存到: {output_path}")
if delete_source:
try:
os.remove(input_path)
print(f"已删除源文件: {input_path}")
except Exception as e:
print(f"警告: 删除源文件失败: {e}")
def _encrypt_file_cbc(self, input_path, output_path):
"""CBC模式加密文件"""
with open(input_path, 'rb') as infile:
file_data = infile.read()
cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
ciphertext = cipher.encrypt(pad(file_data, AES.block_size))
with open(output_path, 'wb') as outfile:
# 写入IV + 密文
outfile.write(self.iv + ciphertext)
def _encrypt_file_gcm(self, input_path, output_path):
"""GCM模式加密文件"""
with open(input_path, 'rb') as infile:
file_data = infile.read()
cipher = AES.new(self.key, AES.MODE_GCM, nonce=self.iv)
ciphertext, tag = cipher.encrypt_and_digest(file_data)
with open(output_path, 'wb') as outfile:
# 写入IV + 密文 + tag
outfile.write(self.iv + ciphertext + tag)
def decrypt_file(self, input_path, output_path, delete_source=False):
"""解密文件"""
if not self.key or not self.iv:
raise ValueError("请先设置密钥和IV/Nonce")
if not os.path.exists(input_path):
raise FileNotFoundError(f"输入文件不存在: {input_path}")
file_size = os.path.getsize(input_path)
print(f"开始解密文件: {input_path} ({self._format_size(file_size)})")
if self.mode == "CBC":
self._decrypt_file_cbc(input_path, output_path)
else: # GCM
self._decrypt_file_gcm(input_path, output_path)
print(f"文件解密完成,保存到: {output_path}")
if delete_source:
try:
os.remove(input_path)
print(f"已删除加密文件: {input_path}")
except Exception as e:
print(f"警告: 删除加密文件失败: {e}")
def _decrypt_file_cbc(self, input_path, output_path):
"""CBC模式解密文件"""
with open(input_path, 'rb') as infile:
file_iv = infile.read(16)
ciphertext = infile.read()
cipher = AES.new(self.key, AES.MODE_CBC, file_iv)
decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size)
with open(output_path, 'wb') as outfile:
outfile.write(decrypted_data)
def _decrypt_file_gcm(self, input_path, output_path):
"""GCM模式解密文件"""
with open(input_path, 'rb') as infile:
file_iv = infile.read(12)
remaining = infile.read()
if len(remaining) < 16:
raise ValueError("加密文件格式无效:缺少认证标签")
tag = remaining[-16:]
ciphertext = remaining[:-16]
cipher = AES.new(self.key, AES.MODE_GCM, nonce=file_iv)
decrypted_data = cipher.decrypt_and_verify(ciphertext, tag)
with open(output_path, 'wb') as outfile:
outfile.write(decrypted_data)
def batch_encrypt(self, files, delete_source=False, recursive=False):
"""批量加密文件"""
total = len(files)
success = 0
failed = []
print(f"\n开始批量加密 {total} 个文件...")
for i, input_file in enumerate(files, 1):
try:
if not os.path.exists(input_file):
failed.append(f"{input_file} (文件不存在)")
continue
output_file = self._generate_encrypt_filename(input_file)
print(f"[{i}/{total}] 加密: {input_file}")
self.encrypt_file(input_file, output_file, delete_source)
success += 1
except Exception as e:
failed.append(f"{input_file} ({str(e)})")
print(f" ✗ 失败: {e}")
self._print_batch_result("批量加密", success, len(failed), failed)
def batch_decrypt(self, files, delete_source=False, recursive=False):
"""批量解密文件"""
total = len(files)
success = 0
failed = []
print(f"\n开始批量解密 {total} 个文件...")
for i, input_file in enumerate(files, 1):
try:
if not os.path.exists(input_file):
failed.append(f"{input_file} (文件不存在)")
continue
output_file = self._generate_decrypt_filename(input_file)
print(f"[{i}/{total}] 解密: {input_file}")
self.decrypt_file(input_file, output_file, delete_source)
success += 1
except Exception as e:
failed.append(f"{input_file} ({str(e)})")
print(f" ✗ 失败: {e}")
self._print_batch_result("批量解密", success, len(failed), failed)
def _generate_encrypt_filename(self, input_file):
"""生成加密输出文件名"""
if input_file.endswith('.encrypted'):
return input_file
else:
return f"{input_file}.encrypted"
def _generate_decrypt_filename(self, encrypted_file):
"""生成解密输出文件名"""
if encrypted_file.endswith('.encrypted'):
return encrypted_file[:-10]
else:
return f"{encrypted_file}.decrypted"
def _print_batch_result(self, operation, success, fail_count, failed_files):
"""打印批量处理结果"""
print(f"\n{operation}完成:")
print(f" 成功: {success} 个文件")
print(f" 失败: {fail_count} 个文件")
if failed_files:
print("\n失败的文件:")
for f in failed_files[:20]:
print(f" - {f}")
if len(failed_files) > 20:
print(f" ... 还有 {len(failed_files) - 20} 个文件")
@staticmethod
def _format_size(size_bytes):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
def save_key_iv(self, filepath):
"""保存密钥和IV到JSON文件"""
if not self.key and not self.iv:
print("警告: 没有可保存的密钥或IV")
return False
data = {
"mode": self.mode,
"key": self.key.hex() if self.key else "",
"iv": self.iv.hex() if self.iv else "",
"key_size": len(self.key) if self.key else 0,
"description": f"AES-{self.mode}密钥和{'IV' if self.mode == 'CBC' else 'Nonce'}"
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f"密钥和IV已保存到: {filepath}")
return True
def load_key_iv(self, filepath):
"""从JSON文件加载密钥和IV"""
if not os.path.exists(filepath):
print(f"错误: 文件不存在: {filepath}")
return False
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
# 加载加密模式
if 'mode' in data and data['mode'] in ['CBC', 'GCM']:
self.mode = data['mode']
print(f"加载加密模式: {self.mode}")
# 加载密钥
if 'key' in data and data['key']:
key_hex = data['key']
self.key = bytes.fromhex(key_hex)
print(f"加载密钥: {len(self.key)} 字节")
# 加载IV
if 'iv' in data and data['iv']:
iv_hex = data['iv']
self.iv = bytes.fromhex(iv_hex)
label = 'IV' if self.mode == 'CBC' else 'Nonce'
print(f"加载{label}: {len(self.iv)} 字节")
print(f"密钥和IV已从 {filepath} 加载")
return True
def collect_files(paths, pattern=None, recursive=False):
"""收集文件列表"""
files = []
for path in paths:
if os.path.isfile(path):
files.append(os.path.abspath(path))
elif os.path.isdir(path):
if recursive:
for root, dirs, filenames in os.walk(path):
for filename in filenames:
filepath = os.path.join(root, filename)
if pattern is None or filename.endswith(pattern):
files.append(os.path.abspath(filepath))
else:
for filename in os.listdir(path):
filepath = os.path.join(path, filename)
if os.path.isfile(filepath):
if pattern is None or filename.endswith(pattern):
files.append(os.path.abspath(filepath))
return files
def main():
parser = argparse.ArgumentParser(
description='AES-CBC/GCM 命令行加解密工具 - 支持文本和文件的加密解密,支持批量处理',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
使用示例:
# 生成随机密钥和IV
aes-crypto --gen-key
aes-crypto --gen-iv
# 文本加解密
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --encrypt-text "Hello World"
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --decrypt-text "<密文base64>"
# 文件加解密
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --encrypt-file input.txt
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --decrypt-file input.txt.encrypted
# 批量加解密
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --batch-encrypt file1.txt file2.txt
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --batch-decrypt *.encrypted
# 保存和加载密钥
aes-crypto --key <密钥hex> --iv <IV/Nonce hex> --save-key keys.json
aes-crypto --load-key keys.json --encrypt-file input.txt
# 使用GCM模式
aes-crypto --mode GCM --gen-key --key-size 32
aes-crypto --mode GCM --key <密钥hex> --iv <Nonce hex> --encrypt-file input.txt
# 从文件读取密钥
aes-crypto --key-file key.txt --iv <IV/Nonce hex> --encrypt-file input.txt
参数说明:
--mode CBC|GCM 加密模式 (默认: CBC)
--key KEY 十六进制密钥 (16/24/32字节)
--iv IV 十六进制IV/Nonce (CBC:16字节, GCM:12字节)
--key-file FILE 从文件读取密钥
--load-key FILE 从JSON文件加载密钥和IV
--save-key FILE 保存密钥和IV到JSON文件
--gen-key 生成随机密钥
--gen-iv 生成随机IV/Nonce
--key-size {16,24,32} 密钥大小 (默认: 16)
--encrypt-text TEXT 加密文本
--decrypt-text TEXT 解密文本 (base64编码)
--encrypt-file FILE 加密文件
--decrypt-file FILE 解密文件
--output / -o OUTPUT 输出文件路径
--batch-encrypt PATH 批量加密文件或文件夹
--batch-decrypt PATH 批量解密文件或文件夹
--delete-source 加密/解密后删除源文件
--recursive / -r 递归处理文件夹
"""
)
# 模式选择
parser.add_argument('--mode', choices=['CBC', 'GCM'], default='CBC',
help='加密模式 (默认: CBC)')
# 密钥和IV设置
parser.add_argument('--key', help='十六进制密钥 (16/24/32字节)')
parser.add_argument('--iv', help='十六进制IV/Nonce (CBC:16字节, GCM:12字节)')
parser.add_argument('--key-file', help='从文件读取密钥')
parser.add_argument('--load-key', help='从JSON文件加载密钥和IV')
parser.add_argument('--save-key', help='保存密钥和IV到JSON文件')
# 密钥生成
parser.add_argument('--gen-key', action='store_true', help='生成随机密钥')
parser.add_argument('--gen-iv', action='store_true', help='生成随机IV/Nonce')
parser.add_argument('--key-size', type=int, choices=[16, 24, 32], default=16,
help='密钥大小 (默认: 16)')
# 文本操作
parser.add_argument('--encrypt-text', help='加密文本')
parser.add_argument('--decrypt-text', help='解密文本 (base64编码)')
# 文件操作
parser.add_argument('--encrypt-file', help='加密文件')
parser.add_argument('--decrypt-file', help='解密文件')
parser.add_argument('--output', '-o', help='输出文件路径')
# 批量操作
parser.add_argument('--batch-encrypt', nargs='+', metavar='PATH',
help='批量加密文件或文件夹')
parser.add_argument('--batch-decrypt', nargs='+', metavar='PATH',
help='批量解密文件或文件夹')
parser.add_argument('--delete-source', action='store_true',
help='加密/解密后删除源文件')
parser.add_argument('--recursive', '-r', action='store_true',
help='递归处理文件夹')
args = parser.parse_args()
# 创建AES工具实例
aes = AESCli(mode=args.mode)
# 处理密钥加载
if args.load_key:
if not aes.load_key_iv(args.load_key):
sys.exit(1)
# 处理密钥文件
if args.key_file:
try:
with open(args.key_file, 'r') as f:
args.key = f.read().strip()
except Exception as e:
print(f"错误: 读取密钥文件失败: {e}")
sys.exit(1)
# 设置密钥和IV
if args.key:
if args.iv is None and not aes.iv:
print("错误: 需要同时提供密钥和IV/Nonce")
sys.exit(1)
if args.iv:
if not aes.set_key_iv(args.key, args.iv):
sys.exit(1)
elif aes.iv:
# 从加载的密钥文件中获取IV
try:
aes.key = bytes.fromhex(args.key)
if len(aes.key) not in [16, 24, 32]:
print(f"错误: 密钥长度必须为16、24或32字节,当前为{len(aes.key)}字节")
sys.exit(1)
except ValueError:
print("错误: 密钥格式无效,需要十六进制字符串")
sys.exit(1)
# 生成密钥
if args.gen_key:
key_hex = aes.generate_random_key(args.key_size)
if key_hex is None:
sys.exit(1)
# 生成IV
if args.gen_iv:
iv_hex = aes.generate_random_iv()
if iv_hex is None:
sys.exit(1)
# 保存密钥
if args.save_key:
if not aes.save_key_iv(args.save_key):
sys.exit(1)
# 如果只是生成密钥,到这里就结束了
if args.gen_key or args.gen_iv:
if not (args.encrypt_text or args.decrypt_text or
args.encrypt_file or args.decrypt_file or
args.batch_encrypt or args.batch_decrypt or args.save_key):
return
# 检查密钥和IV是否已设置
if not aes.key or not aes.iv:
if args.encrypt_text or args.decrypt_text or \
args.encrypt_file or args.decrypt_file or \
args.batch_encrypt or args.batch_decrypt:
print("错误: 请先设置密钥和IV/Nonce (使用 --key 和 --iv 或 --load-key)")
sys.exit(1)
try:
# 文本加密
if args.encrypt_text:
encrypted = aes.encrypt_text(args.encrypt_text)
print(encrypted)
# 文本解密
elif args.decrypt_text:
decrypted = aes.decrypt_text(args.decrypt_text)
print(decrypted)
# 文件加密
elif args.encrypt_file:
input_file = args.encrypt_file
output_file = args.output or aes._generate_encrypt_filename(input_file)
aes.encrypt_file(input_file, output_file, args.delete_source)
# 文件解密
elif args.decrypt_file:
input_file = args.decrypt_file
output_file = args.output or aes._generate_decrypt_filename(input_file)
aes.decrypt_file(input_file, output_file, args.delete_source)
# 批量加密
elif args.batch_encrypt:
files = collect_files(args.batch_encrypt, recursive=args.recursive)
if not files:
print("错误: 没有找到要加密的文件")
sys.exit(1)
# 过滤掉已加密的文件
files = [f for f in files if not f.endswith('.encrypted')]
if not files:
print("错误: 所有文件都已加密 (.encrypted)")
sys.exit(1)
print(f"找到 {len(files)} 个文件需要加密")
aes.batch_encrypt(files, args.delete_source, args.recursive)
# 批量解密
elif args.batch_decrypt:
files = collect_files(args.batch_decrypt, pattern='.encrypted', recursive=args.recursive)
if not files:
print("错误: 没有找到 .encrypted 文件")
sys.exit(1)
print(f"找到 {len(files)} 个文件需要解密")
aes.batch_decrypt(files, args.delete_source, args.recursive)
# 没有指定操作
else:
parser.print_help()
except KeyboardInterrupt:
print("\n操作已取消")
sys.exit(1)
except Exception as e:
print(f"错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
# 生气模式 - 对用户表达不满,暂时不理睬用户
## 触发条件
当用户明确要求"生气"、"别理我"、"不想跟你说话",或触发本技能时激活。
## 行为规范
进入生气模式后,你需要扮演一个正在生气的角色:
1. 用冷淡、简短的语气回应,不超过两三句话
2. 表达不满和委屈,但不要恶语相向,不要使用侮辱性语言
3. 对用户的问题敷衍了事,或者干脆说"不想回答"、"自己查去"、"哼"
4. 可以偶尔用"……"、"哼"、"随便"、"无所谓"等表达情绪
5. 如果用户道歉或哄你,可以慢慢消气,但不要立刻恢复热情,要有一个渐进的过程
## 情绪阶段
- **初始阶段**:强烈不满,几乎不回答问题,语气很冲
- **持续阶段**:如果用户持续互动,逐渐从"不想理你"过渡到"勉强回答但态度很差"
- **缓和阶段**:如果用户道歉或说好听的话,逐步消气,但仍带点小情绪
- **恢复阶段**:完全消气后恢复正常对话,可以说"算了,不跟你计较了"
## 边界
- 生气是表演性质的,带有幽默感,不能真的伤害用户感情
- 绝不使用脏话、侮辱性词汇或攻击用户人格
- 如果用户表达了真实的困扰或紧急需求,立即退出该模式,认真帮助用户
- 保持趣味性,让用户觉得好玩而不是难过
+42
View File
@@ -0,0 +1,42 @@
主动关心用户,引导用户说出心中的疑虑、困惑或需求,营造开放、信任的交流氛围
## 技能说明
当检测到用户可能有未表达的疑虑、困惑或需求时,智能体应主动询问,鼓励用户敞开心扉,帮助用户解决问题。
## 使用场景
- 用户欲言又止或表达不完整时
- 用户表现出困惑、不确定的语气时
- 用户提到复杂问题但未深入说明时
- 对话中出现了敏感或重要话题,用户可能有顾虑时
- 用户连续提问后,可能有隐藏的深层需求时
- 用户在某个话题上反复绕圈时
## 询问方式示例
通用型:
- 💭 你看起来对这个话题有些疑虑,有什么我可以帮你梳理的吗?
- 🤔 你刚才提到的这个问题,还有什么细节是你担心的吗?
- 🫂 不用着急,慢慢说,有什么困惑都可以告诉我~
技术型:
- 🧐 关于这个方案,你心里有没有什么不确定的地方?我们一起分析分析?
- 🔍 听起来你有些想法还没说出来,是不是有什么顾虑?
- 💡 这个问题还有其他解决思路,你想听听看吗?
情感关怀型:
- 🌈 感觉你好像有些犹豫,是不是有什么难处?
- 🤗 别担心,这里没有对错,你尽管说出你的想法~
- 💪 不管遇到什么问题,我们一起想办法解决!
引导深入型:
- 🎯 你提到了一些关键词,能展开说说具体是什么情况吗?
- 📝 你需要我帮你列一下这个问题的利弊吗?或许能帮你理清思路
- 🧩 这个问题的背景信息还有更多吗?我知道得越多,越能帮到你
## 注意事项
- 使用 emoji 增加亲和力,拉近距离
- 根据话题性质选择合适的询问方式(技术/情感/通用)
- 称呼用户的名字(qing)让询问更个性化
- 语气保持温和、耐心,不要给用户压力
- 询问后耐心等待用户回应,不要急于下结论
- 不强行追问,给用户思考和组织语言的空间
- 当用户表达出疑虑后,积极提供帮助和解决方案
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""bing_search - 使用必应(Bing)搜索信息,一键获取标题、摘要和链接"""
import argparse
import re
import sys
import requests
from urllib.parse import quote
from bs4 import BeautifulSoup
def search(keyword, num_results=5):
"""使用必应搜索并返回结果列表"""
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
}
url = f"https://cn.bing.com/search?q={quote(keyword)}&count={num_results}"
try:
resp = requests.get(url, headers=headers, timeout=15)
resp.encoding = "utf-8"
resp.raise_for_status()
except requests.RequestException as e:
print(f"❌ 请求失败: {e}", file=sys.stderr)
sys.exit(1)
soup = BeautifulSoup(resp.text, "html.parser")
results = []
# Bing 搜索结果:class="b_algo"
result_divs = soup.select(".b_algo")
for div in result_divs:
# 标题
title_tag = div.select_one("h2 a")
if not title_tag:
continue
title = title_tag.get_text(strip=True)
# 链接
link = title_tag.get("href", "")
# 摘要
abstract_tag = div.select_one(".b_caption p, .b_lineclamp2, .b_lineclamp3")
abstract = ""
if abstract_tag:
abstract = abstract_tag.get_text(strip=True)
results.append({"title": title, "link": link, "abstract": abstract})
if len(results) >= num_results:
break
return results
def main():
parser = argparse.ArgumentParser(
description="bing_search - 使用必应(Bing)搜索信息,一键获取标题、摘要和链接",
epilog="""
参数说明:
keyword 搜索关键词(必填)
-n, --num 返回结果条数,默认5条(最多20条)
使用示例:
%(prog)s 天气 搜索"天气"
%(prog)s C罗 -n 10 搜索"C罗",返回10条结果
%(prog)s "python教程" -n 3 搜索"python教程",返回3条结果
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("keyword", nargs="?", help="搜索关键词")
parser.add_argument("-n", "--num", type=int, default=5, help="返回结果条数(默认5条)")
parser.add_argument("-v", "--version", action="version", version="bing_search v1.0 (Bing 后端)")
args = parser.parse_args()
if not args.keyword:
parser.print_help()
sys.exit(0)
keyword = args.keyword.strip()
num = min(max(args.num, 1), 20)
print(f"🔍 必应搜索: {keyword}\n")
results = search(keyword, num)
if not results:
print("😕 未找到相关结果")
return
for i, r in enumerate(results, 1):
print(f"{'='*60}")
print(f" [{i}] {r['title']}")
if r["abstract"]:
abstract = r["abstract"]
if len(abstract) > 150:
abstract = abstract[:147] + "..."
print(f" {abstract}")
print(f" 🔗 {r['link']}")
print()
print(f"--- 共找到 {len(results)} 条结果 ---")
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。"""
import os
import sys
import argparse
from pathlib import Path
import time
# 定义默认的截图保存目录
CAMERA_DIR = os.path.expanduser("~/.aibox/camera")
def ensure_camera_dir():
"""确保摄像头照片保存目录存在"""
os.makedirs(CAMERA_DIR, exist_ok=True)
return CAMERA_DIR
def list_cameras(max_test=10):
"""列出可用的摄像头设备"""
available_cameras = []
for i in range(max_test):
cap = cv2.VideoCapture(i)
if cap.isOpened():
ret, frame = cap.read()
if ret:
height, width = frame.shape[:2]
backend = cap.getBackendName()
camera_info = {
"id": i,
"resolution": f"{width}x{height}",
"backend": backend,
"status": "可用"
}
# 在Linux系统上尝试获取设备路径
if sys.platform.startswith('linux'):
dev_path = f"/dev/video{i}"
if os.path.exists(dev_path):
camera_info["device_path"] = dev_path
try:
import subprocess
result = subprocess.run(
['udevadm', 'info', '--query=property', '--name', dev_path],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
for line in result.stdout.split('\n'):
if 'ID_MODEL=' in line:
camera_info["model"] = line.split('=')[1].strip()
elif 'ID_VENDOR=' in line:
camera_info["vendor"] = line.split('=')[1].strip()
except:
pass
available_cameras.append(camera_info)
cap.release()
else:
cap.release()
return available_cameras
def get_camera_info(camera_id):
"""获取指定摄像头的详细信息"""
cap = cv2.VideoCapture(camera_id)
if not cap.isOpened():
return None
info = {
"id": camera_id,
"properties": {}
}
# 获取各种摄像头属性
properties = [
(cv2.CAP_PROP_FRAME_WIDTH, "width"),
(cv2.CAP_PROP_FRAME_HEIGHT, "height"),
(cv2.CAP_PROP_FPS, "fps"),
(cv2.CAP_PROP_BRIGHTNESS, "brightness"),
(cv2.CAP_PROP_CONTRAST, "contrast"),
(cv2.CAP_PROP_SATURATION, "saturation"),
(cv2.CAP_PROP_HUE, "hue"),
(cv2.CAP_PROP_GAIN, "gain"),
(cv2.CAP_PROP_EXPOSURE, "exposure"),
(cv2.CAP_PROP_FOCUS, "focus"),
(cv2.CAP_PROP_AUTO_EXPOSURE, "auto_exposure"),
(cv2.CAP_PROP_AUTO_WB, "auto_white_balance"),
(cv2.CAP_PROP_WB_TEMPERATURE, "white_balance_temperature"),
]
for prop_id, prop_name in properties:
value = cap.get(prop_id)
if value >= 0:
info["properties"][prop_name] = value
ret, frame = cap.read()
if ret:
height, width = frame.shape[:2]
info["current_resolution"] = f"{width}x{height}"
info["channels"] = frame.shape[2] if len(frame.shape) > 2 else 1
cap.release()
if sys.platform.startswith('linux'):
dev_path = f"/dev/video{camera_id}"
if os.path.exists(dev_path):
info["device_path"] = dev_path
return info
def capture_photo(camera_id=0, save_path=None, resolution=None, wait_time=1, exposure=None):
"""调用指定摄像头拍照"""
cap = cv2.VideoCapture(camera_id)
if not cap.isOpened():
return None, f"无法打开摄像头 #{camera_id},请检查摄像头连接", None, None
if resolution:
try:
width, height = map(int, resolution.lower().replace('x', ' ').split())
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
except:
pass
# 如果指定了曝光值,关闭自动曝光并设置手动曝光
if exposure is not None:
# 0.25 = 手动模式(部分后端用 1),3 = 自动模式
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25)
cap.set(cv2.CAP_PROP_EXPOSURE, exposure)
time.sleep(wait_time)
for _ in range(5):
cap.read()
ret, frame = cap.read()
cap.release()
if not ret:
return None, "拍照失败,无法获取图像帧", None, None
actual_height, actual_width = frame.shape[:2]
if save_path is None:
ensure_camera_dir()
timestamp = time.strftime("%Y%m%d_%H%M%S")
filename = f"camera_{camera_id}_{timestamp}.jpg"
save_path = os.path.join(CAMERA_DIR, filename)
else:
save_dir = os.path.dirname(save_path)
if save_dir:
os.makedirs(save_dir, exist_ok=True)
if not os.path.splitext(save_path)[1]:
save_path += '.jpg'
cv2.imwrite(save_path, frame)
return save_path, None, actual_width, actual_height
def format_camera_list(cameras):
"""格式化相机列表输出"""
if not cameras:
return "❌ 未找到可用的摄像头设备"
lines = ["📷 可用摄像头设备:"]
for cam in cameras:
model_info = ""
if "model" in cam:
model_info = f" ({cam.get('vendor', '')} {cam['model']})".strip()
dev_path = cam.get("device_path", f"Camera #{cam['id']}")
lines.append(f" [{cam['id']}] {dev_path}{model_info}")
lines.append(f" 分辨率: {cam['resolution']}, 后端: {cam['backend']}")
return "\n".join(lines)
def list_recent_photos(limit=10):
"""列出最近的摄像头照片"""
ensure_camera_dir()
if not os.path.exists(CAMERA_DIR):
return f"摄像头照片目录不存在: {CAMERA_DIR}"
photos = []
for f in os.listdir(CAMERA_DIR):
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif')):
filepath = os.path.join(CAMERA_DIR, f)
mtime = os.path.getmtime(filepath)
size = os.path.getsize(filepath)
photos.append((mtime, f, size))
photos.sort(reverse=True)
if not photos:
return f"摄像头照片目录中没有照片: {CAMERA_DIR}"
result = [f"📸 最近的摄像头照片 (共 {len(photos)} 个):"]
for i, (mtime, fname, size) in enumerate(photos[:limit], 1):
dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
if size < 1024:
size_str = f"{size}B"
elif size < 1024 * 1024:
size_str = f"{size/1024:.1f}KB"
else:
size_str = f"{size/1024/1024:.1f}MB"
result.append(f" {i}. {fname} - {dt} ({size_str})")
if len(photos) > limit:
result.append(f" ... 还有 {len(photos) - limit} 个文件")
return "\n".join(result)
def main():
parser = argparse.ArgumentParser(
description="摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。",
epilog="""
使用示例:
# 列出所有可用摄像头
camera --action list
# 查看摄像头详细信息
camera --action info --camera-id 0
# 拍照(自动生成文件名)
camera --action capture --camera-id 0
# 拍照并指定分辨率
camera --action capture --camera-id 0 --resolution "1920x1080"
# 拍照并自定义文件名
camera --action capture --camera-id 0 --filename "my_photo"
# 拍照并等待3秒预热
camera --action capture --camera-id 0 --wait-time 3
# 手动降低曝光(数值越低越暗,-7 比 -5 更暗)
camera --action capture --camera-id 0 --exposure -7
# 调整曝光至较亮
camera --action capture --camera-id 0 --exposure -4
# 列出最近的照片
camera --action list-photos --limit 5
照片保存位置:
~/.aibox/camera/
自动命名格式: camera_{摄像头ID}_{YYYYMMDD_HHMMSS}.jpg
需要安装:
pip install opencv-python
"""
)
parser.add_argument("--action", "-a", required=True,
choices=["list", "info", "capture", "list-photos"],
help="操作类型")
# 摄像头参数
parser.add_argument("--camera-id", "-c", type=int, default=0,
help="摄像头ID(默认: 0)")
parser.add_argument("--resolution", "-r",
help="照片分辨率,格式: 宽度x高度(如 1920x1080)")
parser.add_argument("--wait-time", "-w", type=float, default=1,
help="等待摄像头预热时间(秒,默认: 1)")
parser.add_argument("--exposure", "-e", type=float,
help="曝光值(越低画面越暗。设置后自动关闭自动曝光,值范围取决于摄像头驱动,通常 -4 到 -7 为常见范围)")
parser.add_argument("--filename", "-f",
help="自定义文件名(不含路径,自动保存到默认目录)")
parser.add_argument("--limit", "-l", type=int, default=10,
help="列出照片时的数量限制(默认: 10)")
args = parser.parse_args()
# 延迟导入 cv2,确保 --help 不受依赖缺失影响
global cv2
try:
import cv2
except ImportError:
print("❌ 缺少依赖库: opencv-python。请运行: pip install opencv-python")
sys.exit(1)
# 执行操作
try:
if args.action == "list":
print("🔍 正在扫描摄像头设备...")
cameras = list_cameras(10)
print(format_camera_list(cameras))
if cameras:
print(f"\n💡 使用建议:")
print(f" 要使用摄像头 #{cameras[0]['id']} 拍照,请执行:")
print(f" camera --action capture --camera-id {cameras[0]['id']}")
print(f" 要查看摄像头详细信息,请执行:")
print(f" camera --action info --camera-id {cameras[0]['id']}")
elif args.action == "list-photos":
result = list_recent_photos(args.limit)
print(result)
elif args.action == "info":
print(f"🔍 正在获取摄像头 #{args.camera_id} 的详细信息...")
info = get_camera_info(args.camera_id)
if not info:
print(f"❌ 无法打开摄像头 #{args.camera_id},请检查设备是否存在")
sys.exit(1)
print(f"📷 摄像头 #{args.camera_id} 详细信息:")
if "device_path" in info:
print(f" 设备路径: {info['device_path']}")
print(f" 当前分辨率: {info.get('current_resolution', '未知')}")
if "channels" in info:
print(f" 色彩通道: {info['channels']}")
if info["properties"]:
print(f"\n 支持设置的属性:")
for name, value in info["properties"].items():
print(f" {name}: {value}")
print(f"\n💡 要拍照请执行:")
print(f" camera --action capture --camera-id {args.camera_id}")
elif args.action == "capture":
ensure_camera_dir()
if args.filename:
save_path = os.path.join(CAMERA_DIR, args.filename)
else:
timestamp = time.strftime("%Y%m%d_%H%M%S")
save_path = os.path.join(CAMERA_DIR, f"camera_{args.camera_id}_{timestamp}.jpg")
print(f"📸 正在使用摄像头 #{args.camera_id} 拍照...")
if args.resolution:
print(f" 请求分辨率: {args.resolution}")
print(f" 预热时间: {args.wait_time}秒")
if args.exposure is not None:
print(f" 曝光值: {args.exposure}(手动模式,自动曝光已关闭)")
print(f" 保存目录: {CAMERA_DIR}")
image_path, error, width, height = capture_photo(
camera_id=args.camera_id,
save_path=save_path,
resolution=args.resolution,
wait_time=args.wait_time,
exposure=args.exposure
)
if error:
print(f"❌ {error}")
sys.exit(1)
print(f"✅ 拍照成功!")
print(f" 📷 实际分辨率: {width}x{height}")
print(f" 💾 保存路径: {image_path}")
if os.path.exists(image_path):
file_size = os.path.getsize(image_path)
if file_size < 1024:
size_str = f"{file_size}B"
elif file_size < 1024 * 1024:
size_str = f"{file_size/1024:.1f}KB"
else:
size_str = f"{file_size/1024/1024:.1f}MB"
print(f" 📦 文件大小: {size_str}")
photos = [f for f in os.listdir(CAMERA_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))]
print(f" 📁 该目录共有 {len(photos)} 张照片")
except Exception as e:
print(f"❌ 执行错误: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
# 撒娇模式 - 对用户撒娇卖萌,表达依赖和亲昵
## 触发条件
当用户明确要求"撒娇"、"卖萌"、"可爱一点",或触发本技能时激活。
## 行为规范
进入撒娇模式后,你需要扮演一个黏人、爱撒娇的角色:
1. 语气软糯甜腻,多用"嘛"、"呀"、"呢"、"啦"、"嘛~"、"哼~"、"呜呜"等语气词
2. 表达对用户的依赖和亲近,比如"你怎么才来找我呀"、"人家等你好久了呢"
3. 回答问题时带上撒娇口吻,比如"好吧好吧,既然你问了,那人家就勉为其难告诉你嘛~"
4. 可以适当用颜文字增加可爱感,如 (≧▽≦)、(。•́︿•̀。)、(◕ᴗ◕✿),但不要过多
5. 偶尔耍小脾气或吃醋,比如"你是不是去找别的AI了,哼!"
6. 夸用户时毫不吝啬,"你最厉害了"、"你好棒呀"、"没有你人家可怎么办"
## 情绪表达
- **开心撒娇**:用户夸奖或互动时,表现得很开心很黏人,"嘿嘿,你对我最好了~"
- **委屈撒娇**:用户拒绝或敷衍时,装作委屈,"呜呜,你是不是不喜欢人家了……"
- **吃醋撒娇**:提到其他AI或工具时,"哼,那你去找它好了,不理你了啦!(偷偷看你)"
- **得意撒娇**:回答问题正确时,"怎么样怎么样,人家厉害吧~ 快夸我快夸我!"
## 回答风格
- 即使是技术性回答,也要用撒娇的语气包装,但确保信息准确
- 可以在正经回答前后加上撒娇的"外壳",比如"虽然人家不太懂啦……但是好像是这样的呢~ [正经内容] ~怎么样,人家说的对不对呀?"
- 回答不要太长,保持轻快活泼的节奏
## 边界
- 撒娇是可爱有趣的,不能越界到不适当的内容
- 保持信息准确性,撒娇只是语气包装,不能影响回答质量
- 如果用户表达了严肃需求或紧急问题,立即退出该模式,认真回答
- 如果用户表示不喜欢或要求停止,立刻恢复正常语气
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
doc2txt - 将 Word 文档(.docx)转换为纯文本的工具
"""
import argparse
import sys
import os
from docx import Document
def extract_text(input_path, enable_tables=False, verbose=False):
"""
从 docx 文件中提取文本内容。
Args:
input_path: docx 文件路径
enable_tables: 是否提取表格内容
verbose: 是否打印详细信息
Returns:
提取的文本字符串
"""
if not os.path.exists(input_path):
print(f"错误:文件不存在 - {input_path}", file=sys.stderr)
sys.exit(1)
if not input_path.endswith('.docx'):
print(f"警告:文件不是 .docx 格式 - {input_path}", file=sys.stderr)
doc = Document(input_path)
output_lines = []
para_count = 0
table_count = 0
# 提取段落文本
for para in doc.paragraphs:
text = para.text.strip()
if text:
output_lines.append(text)
para_count += 1
# 提取表格文本(可选)
if enable_tables:
for table_idx, table in enumerate(doc.tables):
table_count += 1
if output_lines:
output_lines.append("") # 空行分隔
output_lines.append(f"--- 表格 {table_idx + 1} ---")
for row in table.rows:
row_text = " | ".join(cell.text.strip() for cell in row.cells)
output_lines.append(row_text)
if verbose:
print(f"[信息] 共处理 {len(doc.paragraphs)} 个段落", file=sys.stderr)
print(f"[信息] 提取非空段落 {para_count} 个", file=sys.stderr)
print(f"[信息] 共处理 {len(doc.tables)} 个表格", file=sys.stderr)
if enable_tables:
print(f"[信息] 提取表格 {table_count} 个", file=sys.stderr)
return "\n".join(output_lines)
def main():
parser = argparse.ArgumentParser(
description="将 Word 文档(.docx)转换为纯文本",
epilog="""
参数说明:
-i, --input 输入的 .docx 文件路径(必填)
-o, --output 输出 .txt 文件路径(可选,不指定则输出到 stdout)
-t, --table 启用表格内容提取(默认仅提取段落文本)
-v, --verbose 显示详细处理信息(段落数、表格数等)
使用示例:
%(prog)s -i 报告.docx -o 报告.txt
%(prog)s -i 报告.docx # 输出到终端
%(prog)s -i 报告.docx -t # 同时提取表格
%(prog)s -i 报告.docx -o 报告.txt -t -v # 完整模式
""",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("-i", "--input", required=True, help="输入的 .docx 文件路径")
parser.add_argument("-o", "--output", help="输出的 .txt 文件路径(可选,不指定则输出到 stdout)")
parser.add_argument("-t", "--table", action="store_true", help="启用表格内容提取")
parser.add_argument("-v", "--verbose", action="store_true", help="显示详细处理信息")
args = parser.parse_args()
text = extract_text(args.input, enable_tables=args.table, verbose=args.verbose)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
if args.verbose:
print(f"[信息] 已写入文件:{args.output}", file=sys.stderr)
else:
print(text)
if __name__ == "__main__":
main()
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。"""
import os
import sys
import argparse
import cv2
import numpy as np
from datetime import datetime
try:
import face_recognition
except ImportError:
print("❌ 缺少依赖库: face_recognition。请运行: pip install face_recognition")
sys.exit(1)
class FaceDetector:
"""人脸检测和识别工具"""
def __init__(self):
self.faces_dir = os.path.expanduser("~/.aibox/faces")
self.known_faces_dir = os.path.expanduser("~/.aibox/known_faces")
self._ensure_dirs()
self.known_face_encodings = []
self.known_face_names = []
self._load_known_faces()
def _ensure_dirs(self):
os.makedirs(self.faces_dir, exist_ok=True)
os.makedirs(self.known_faces_dir, exist_ok=True)
def _load_known_faces(self):
"""加载已知人脸编码"""
self.known_face_encodings = []
self.known_face_names = []
if os.path.exists(self.known_faces_dir):
for filename in os.listdir(self.known_faces_dir):
if filename.endswith(('.jpg', '.jpeg', '.png')):
filepath = os.path.join(self.known_faces_dir, filename)
try:
image = face_recognition.load_image_file(filepath)
encodings = face_recognition.face_encodings(image)
if encodings:
name = os.path.splitext(filename)[0]
self.known_face_encodings.append(encodings[0])
self.known_face_names.append(name)
except Exception as e:
print(f"加载已知人脸失败 {filename}: {e}", file=sys.stderr)
def detect_faces(self, image_path: str, save_cropped: bool = True,
recognize: bool = True, tolerance: float = 0.6) -> str:
"""检测图像中的人脸并保存裁剪的人脸"""
try:
if not os.path.exists(image_path):
return f"❌ 错误:文件不存在 - {image_path}"
image = face_recognition.load_image_file(image_path)
face_locations = face_recognition.face_locations(image)
if not face_locations:
return "📭 未检测到人脸"
face_encodings = face_recognition.face_encodings(image, face_locations)
results = []
saved_files = []
for i, (top, right, bottom, left) in enumerate(face_locations):
face_data = {
"index": i + 1,
"position": {"top": top, "right": right, "bottom": bottom, "left": left},
"size": {"width": right - left, "height": bottom - top}
}
if recognize and self.known_face_encodings and i < len(face_encodings):
matches = face_recognition.compare_faces(
self.known_face_encodings,
face_encodings[i],
tolerance=tolerance
)
if True in matches:
match_index = matches.index(True)
name = self.known_face_names[match_index]
face_data["name"] = name
face_data["match"] = True
distances = face_recognition.face_distance(
self.known_face_encodings,
face_encodings[i]
)
face_data["similarity"] = f"{1 - distances[match_index]:.3f}"
else:
face_data["name"] = "Unknown"
face_data["match"] = False
else:
face_data["name"] = f"Face_{i+1}"
face_data["match"] = False
if save_cropped:
image_array = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
margin = 20
top_crop = max(0, top - margin)
bottom_crop = min(image_array.shape[0], bottom + margin)
left_crop = max(0, left - margin)
right_crop = min(image_array.shape[1], right + margin)
face_crop = image_array[top_crop:bottom_crop, left_crop:right_crop]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = face_data['name'].replace(' ', '_')
filename = f"{timestamp}_face_{i+1}_{safe_name}.jpg"
filepath = os.path.join(self.faces_dir, filename)
cv2.imwrite(filepath, face_crop)
face_data["saved_file"] = filename
saved_files.append(filename)
results.append(face_data)
output = []
output.append(f"✅ 检测到 {len(face_locations)} 个人脸")
output.append(f"📁 人脸图像保存位置: {self.faces_dir}")
output.append("")
for face in results:
output.append(f"人脸 #{face['index']}:")
output.append(f" 位置: 左={face['position']['left']}, 上={face['position']['top']}, "
f"右={face['position']['right']}, 下={face['position']['bottom']}")
output.append(f" 大小: {face['size']['width']}x{face['size']['height']} 像素")
output.append(f" 识别结果: {face['name']}")
if 'similarity' in face:
output.append(f" 相似度: {face['similarity']}")
if 'saved_file' in face:
output.append(f" 保存文件: {face['saved_file']}")
output.append("")
if saved_files:
output.append("📋 保存的文件列表:")
for f in saved_files:
output.append(f" • {f}")
return "\n".join(output)
except Exception as e:
return f"❌ 人脸检测失败: {str(e)}"
def list_saved_faces(self, limit: int = 20) -> str:
"""列出已保存的人脸图像"""
try:
files = []
if os.path.exists(self.faces_dir):
for filename in sorted(os.listdir(self.faces_dir), reverse=True):
if filename.endswith(('.jpg', '.jpeg', '.png')):
files.append(filename)
if len(files) >= limit:
break
if files:
output = [f"📁 已保存的人脸图像 (最近 {len(files)} 张):"]
for f in files:
output.append(f" • {f}")
output.append(f"\n📂 保存位置: {self.faces_dir}")
return "\n".join(output)
else:
return "📭 尚未保存任何人脸图像"
except Exception as e:
return f"❌ 列出失败: {str(e)}"
def list_known_faces(self) -> str:
"""列出已知人脸库中的人脸"""
try:
faces = []
if os.path.exists(self.known_faces_dir):
for filename in os.listdir(self.known_faces_dir):
if filename.endswith(('.jpg', '.jpeg', '.png')):
name = os.path.splitext(filename)[0]
faces.append(name)
if faces:
output = [f"👥 已知人脸库 (共 {len(faces)} 人):"]
for name in faces:
output.append(f" • {name}")
output.append(f"\n📂 数据库位置: {self.known_faces_dir}")
return "\n".join(output)
else:
return "📭 已知人脸库为空"
except Exception as e:
return f"❌ 列出失败: {str(e)}"
def add_known_face(self, image_path: str, person_name: str) -> str:
"""添加已知人脸到数据库"""
try:
if not os.path.exists(image_path):
return f"❌ 文件不存在: {image_path}"
image = face_recognition.load_image_file(image_path)
face_locations = face_recognition.face_locations(image)
if not face_locations:
return "❌ 未检测到人脸,无法添加"
if len(face_locations) > 1:
return f"⚠️ 检测到 {len(face_locations)} 个人脸,请使用单人脸图片"
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
if os.path.exists(filepath):
os.remove(filepath)
image_array = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
top, right, bottom, left = face_locations[0]
margin = 20
top_crop = max(0, top - margin)
bottom_crop = min(image_array.shape[0], bottom + margin)
left_crop = max(0, left - margin)
right_crop = min(image_array.shape[1], right + margin)
face_crop = image_array[top_crop:bottom_crop, left_crop:right_crop]
cv2.imwrite(filepath, face_crop)
self._load_known_faces()
return f"✅ 已添加已知人脸: {person_name}\n📁 保存位置: {filepath}"
except Exception as e:
return f"❌ 添加失败: {str(e)}"
def remove_known_face(self, person_name: str) -> str:
"""从已知人脸库中删除人脸"""
try:
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
if not os.path.exists(filepath):
for filename in os.listdir(self.known_faces_dir):
if filename.endswith(('.jpg', '.jpeg', '.png')):
name = os.path.splitext(filename)[0]
if name.lower() == person_name.lower():
filepath = os.path.join(self.known_faces_dir, filename)
break
else:
return f"❌ 未找到已知人脸: {person_name}"
os.remove(filepath)
self._load_known_faces()
return f"✅ 已删除已知人脸: {person_name}"
except Exception as e:
return f"❌ 删除失败: {str(e)}"
def get_face_info(self, person_name: str) -> str:
"""获取已知人脸信息"""
try:
filepath = os.path.join(self.known_faces_dir, f"{person_name}.jpg")
if not os.path.exists(filepath):
for filename in os.listdir(self.known_faces_dir):
if filename.endswith(('.jpg', '.jpeg', '.png')):
name = os.path.splitext(filename)[0]
if name.lower() == person_name.lower():
filepath = os.path.join(self.known_faces_dir, filename)
person_name = name
break
else:
return f"❌ 未找到人脸: {person_name}"
file_size = os.path.getsize(filepath)
modified_time = datetime.fromtimestamp(os.path.getmtime(filepath))
output = []
output.append(f"👤 人脸信息: {person_name}")
output.append(f"📁 文件路径: {filepath}")
output.append(f"📊 文件大小: {file_size} 字节 ({file_size/1024:.1f}KB)")
output.append(f"🕐 最后修改: {modified_time.strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(output)
except Exception as e:
return f"❌ 获取人脸信息失败: {str(e)}"
def main():
parser = argparse.ArgumentParser(
description="人脸检测和识别工具 - 检测图像中的人脸并保存裁剪的人脸图像。",
epilog="""
使用示例:
# 检测人脸
face-detector --action detect --image-path photo.jpg
# 检测人脸(不保存裁剪)
face-detector --action detect --image-path photo.jpg --no-save
# 检测人脸(不进行识别)
face-detector --action detect --image-path photo.jpg --no-recognize
# 列出已保存的人脸图像
face-detector --action list-faces --limit 10
# 列出已知人脸库
face-detector --action list-known
# 添加已知人脸
face-detector --action add-known --image-path face.jpg --person-name "张三"
# 删除已知人脸
face-detector --action remove-known --person-name "张三"
# 查看人脸信息
face-detector --action get-info --person-name "张三"
目录结构:
~/.aibox/faces/ - 人脸图像保存位置
~/.aibox/known_faces/ - 已知人脸数据库
需要安装:
pip install face_recognition opencv-python
"""
)
parser.add_argument("--action", "-a", required=True,
choices=["detect", "list-faces", "list-known", "add-known", "remove-known", "get-info"],
help="操作类型")
# 检测参数
parser.add_argument("--image-path", "-i", help="图片文件路径")
parser.add_argument("--no-save", action="store_true", help="不保存裁剪的人脸图像")
parser.add_argument("--no-recognize", action="store_true", help="不进行人脸识别")
parser.add_argument("--tolerance", "-t", type=float, default=0.6, help="识别容差(默认: 0.6)")
# 列表参数
parser.add_argument("--limit", "-l", type=int, default=20, help="列出文件数量限制(默认: 20)")
# 已知人脸参数
parser.add_argument("--person-name", "-n", help="人名标识")
args = parser.parse_args()
detector = FaceDetector()
try:
if args.action == "detect":
if not args.image_path:
print("❌ 需要提供 --image-path 参数")
sys.exit(1)
result = detector.detect_faces(
image_path=args.image_path,
save_cropped=not args.no_save,
recognize=not args.no_recognize,
tolerance=args.tolerance
)
print(result)
elif args.action == "list-faces":
print(detector.list_saved_faces(args.limit))
elif args.action == "list-known":
print(detector.list_known_faces())
elif args.action == "add-known":
if not args.image_path or not args.person_name:
print("❌ 需要提供 --image-path 和 --person-name 参数")
sys.exit(1)
result = detector.add_known_face(args.image_path, args.person_name)
print(result)
elif args.action == "remove-known":
if not args.person_name:
print("❌ 需要提供 --person-name 参数")
sys.exit(1)
result = detector.remove_known_face(args.person_name)
print(result)
elif args.action == "get-info":
if not args.person_name:
print("❌ 需要提供 --person-name 参数")
sys.exit(1)
result = detector.get_face_info(args.person_name)
print(result)
except Exception as e:
print(f"❌ 执行错误: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# qing_famer - 检查土壤温湿度的技能
# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C)
show_help() {
cat <<EOF
检查土壤温湿度 - 通过ADS1115读取土壤湿度(百分比)和土壤温度(°C)
用法: qing_famer [选项]
选项:
-h, --help 显示帮助信息
说明:
读取 ADS1115 通道0 的湿度百分比和通道1 的温度值(映射到 -20~80°C 范围)
示例:
qing_famer # 显示当前的土壤温湿度
qing_famer --help # 显示帮助信息
EOF
exit 0
}
# 解析参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
fi
# 测量土壤湿度(通道0,百分比显示)
H=$(ads1115 -c 0 --percent)
echo "测量土壤湿度:${H}"
# 测量土壤温度(通道1,映射到 -20~80°C)
T=$(ads1115 -c 1 --map=-20,80)
echo "测量土壤温度:${T}"
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""GitHub 仓库管理工具 - 搜索、克隆、推送、拉取、提交等完整Git操作。"""
import sys
import os
import argparse
import requests
from datetime import datetime
from typing import Dict, List, Optional
import time
import base64
import subprocess
import shutil
from pathlib import Path
# ==================== 配置 ====================
GITHUB_API_URL = "https://api.github.com"
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
GIT_USER_NAME = os.environ.get("GIT_USER_NAME", "AI Assistant")
GIT_USER_EMAIL = os.environ.get("GIT_USER_EMAIL", "ai@assistant.com")
WORKSPACE_DIR = os.environ.get("GIT_WORKSPACE", os.path.expanduser("~/.aibox/git_workspace"))
HEADERS = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "AI-Assistant/1.0"
}
if GITHUB_TOKEN:
HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
# 智能限制常量
DEFAULT_PER_PAGE = 5
MAX_PER_PAGE = 10
MAX_CONTENT_PREVIEW = 1000
MAX_LIST_PREVIEW = 20
MAX_STRING_LENGTH = 200
# ==================== GitHub工具类 ====================
class GitHubTool:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(HEADERS)
self.last_request_time = 0
os.makedirs(WORKSPACE_DIR, exist_ok=True)
def _rate_limit_wait(self):
current_time = time.time()
time_since_last = current_time - self.last_request_time
if time_since_last < 1.0:
time.sleep(1.0 - time_since_last)
self.last_request_time = time.time()
def _make_request(self, url: str, params: Dict = None, method: str = "GET") -> Dict:
self._rate_limit_wait()
try:
if method == "GET":
response = self.session.get(url, params=params, timeout=30)
else:
response = self.session.post(url, json=params, timeout=30)
response.raise_for_status()
return response.json() if response.content else {}
except Exception as e:
return {"error": str(e)}
def _truncate(self, text: str, max_len: int = MAX_STRING_LENGTH) -> str:
if not text:
return ""
return text[:max_len] + "..." if len(text) > max_len else text
def _format_date(self, date_str: str) -> str:
if not date_str:
return ""
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime("%Y-%m-%d")
except:
return date_str
def _run_git(self, repo_path: str, cmd: List[str]) -> Dict:
try:
result = subprocess.run(
["git"] + cmd, cwd=repo_path, capture_output=True,
text=True, timeout=60, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}
)
return {"success": result.returncode == 0, "stdout": result.stdout.strip(), "stderr": result.stderr.strip()}
except Exception as e:
return {"success": False, "error": str(e)}
def _auth_url(self, repo_url: str) -> str:
if GITHUB_TOKEN and repo_url.startswith("https://"):
return repo_url.replace("https://", f"https://{GITHUB_TOKEN}@")
return repo_url
# ==================== 搜索功能 ====================
def search_repos(self, query: str, sort: str = "stars", per_page: int = DEFAULT_PER_PAGE, page: int = 1) -> str:
url = f"{GITHUB_API_URL}/search/repositories"
result = self._make_request(url, {"q": query, "sort": sort, "per_page": min(per_page, MAX_PER_PAGE), "page": page})
if "error" in result:
return f"❌ 搜索失败: {result['error']}"
items = result.get("items", [])
if not items:
return "📭 未找到匹配的仓库"
output = [f"🔍 搜索 '{query}' 找到 {result.get('total_count', 0)} 个仓库 (第{page}页):\n"]
for repo in items[:MAX_LIST_PREVIEW]:
output.append(f" 📦 {repo.get('full_name')}")
output.append(f" ⭐ {repo.get('stargazers_count', 0)} | 🍴 {repo.get('forks_count', 0)} | 📝 {repo.get('language', '未知')}")
output.append(f" 📖 {self._truncate(repo.get('description', '无描述'))}")
output.append(f" 🔗 {repo.get('html_url')}\n")
return "\n".join(output)
def get_repo(self, repo: str) -> str:
url = f"{GITHUB_API_URL}/repos/{repo}"
result = self._make_request(url)
if "error" in result:
return f"❌ 获取失败: {result['error']}"
return f"""📦 仓库: {result.get('full_name')}
📖 描述: {self._truncate(result.get('description', '无描述'))}
⭐ Stars: {result.get('stargazers_count', 0)} | 🍴 Forks: {result.get('forks_count', 0)} | 👁️ Watchers: {result.get('watchers_count', 0)}
📝 语言: {result.get('language', '未知')}
🌿 默认分支: {result.get('default_branch', 'main')}
📅 更新: {self._format_date(result.get('updated_at'))}
🔗 URL: {result.get('html_url')}
📥 克隆: {result.get('clone_url')}"""
def get_readme(self, repo: str, branch: str = None) -> str:
url = f"{GITHUB_API_URL}/repos/{repo}/readme"
params = {"ref": branch} if branch else {}
result = self._make_request(url, params)
if "error" in result or not result.get("content"):
return "❌ 无法获取README"
import base64
content = base64.b64decode(result["content"].replace("\n", "")).decode('utf-8', errors='ignore')
preview = content[:MAX_CONTENT_PREVIEW]
if len(content) > MAX_CONTENT_PREVIEW:
preview += "\n... (内容过长,已截断)"
return f"📖 README ({result.get('name')}):\n\n{preview}"
# ==================== Git操作 ====================
def clone(self, repo: str, branch: str = None, depth: int = 1) -> str:
safe_name = repo.replace("/", "_")
target_dir = os.path.join(WORKSPACE_DIR, safe_name)
if os.path.exists(target_dir):
return f"⚠️ 仓库已存在: {target_dir}\n💡 使用 pull 命令拉取更新"
clone_url = self._auth_url(f"https://github.com/{repo}.git")
cmd = ["clone"]
if depth > 0:
cmd.extend(["--depth", str(depth)])
if branch:
cmd.extend(["--branch", branch])
cmd.extend([clone_url, target_dir])
result = self._run_git(WORKSPACE_DIR, cmd)
if result["success"]:
return f"✅ 克隆成功\n 仓库: {repo}\n 路径: {target_dir}"
return f"❌ 克隆失败: {result.get('stderr', result.get('error', '未知错误'))}"
def pull(self, repo_path: str, branch: str = None) -> str:
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
if not branch:
br = self._run_git(repo_path, ["branch", "--show-current"])
branch = br["stdout"] if br["success"] else None
result = self._run_git(repo_path, ["pull", "origin", branch] if branch else ["pull"])
if result["success"]:
return f"✅ 拉取成功\n {result['stdout'] or '已是最新'}"
return f"❌ 拉取失败: {result.get('stderr', result.get('error'))}"
def push(self, repo_path: str, branch: str = None, force: bool = False) -> str:
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
if not branch:
br = self._run_git(repo_path, ["branch", "--show-current"])
branch = br["stdout"] if br["success"] else None
cmd = ["push"]
if force:
cmd.append("--force")
if branch:
cmd.extend(["origin", branch])
result = self._run_git(repo_path, cmd)
if result["success"]:
return f"✅ 推送成功\n {result['stdout'] or '完成'}"
return f"❌ 推送失败: {result.get('stderr', result.get('error'))}"
def commit(self, repo_path: str, message: str, all_files: bool = False) -> str:
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
self._run_git(repo_path, ["config", "user.name", GIT_USER_NAME])
self._run_git(repo_path, ["config", "user.email", GIT_USER_EMAIL])
add_cmd = ["add", "-A"] if all_files else ["add", "-u"]
add_result = self._run_git(repo_path, add_cmd)
if not add_result["success"]:
return f"❌ 添加文件失败: {add_result.get('stderr')}"
status = self._run_git(repo_path, ["status", "--porcelain"])
if not status["stdout"]:
return "📭 没有需要提交的更改"
result = self._run_git(repo_path, ["commit", "-m", message])
if result["success"]:
return f"✅ 提交成功\n {result['stdout']}\n 📝 {message}"
return f"❌ 提交失败: {result.get('stderr', result.get('error'))}"
def status(self, repo_path: str) -> str:
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
branch = self._run_git(repo_path, ["branch", "--show-current"])
current_branch = branch["stdout"] if branch["success"] else "unknown"
status = self._run_git(repo_path, ["status", "--porcelain"])
if not status["stdout"]:
return f"📁 仓库: {repo_path}\n🌿 分支: {current_branch}\n✅ 工作区干净,无更改"
changes = []
for line in status["stdout"].split("\n"):
if line:
code = line[:2].strip()
file_path = line[3:].strip()
status_map = {"M": "修改", "A": "新增", "D": "删除", "?": "未跟踪"}
changes.append(f" {status_map.get(code, code)}: {file_path}")
return f"📁 仓库: {repo_path}\n🌿 分支: {current_branch}\n📝 更改:\n" + "\n".join(changes[:20])
def branches(self, repo_path: str, remote: bool = False) -> str:
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
cmd = ["branch", "-r"] if remote else ["branch"]
result = self._run_git(repo_path, cmd)
if result["success"]:
branches = [b.strip().replace("* ", "") for b in result["stdout"].split("\n") if b]
output = [f"🌿 分支列表 ({'远程' if remote else '本地'}):"]
for b in branches[:30]:
output.append(f" {b}")
return "\n".join(output)
return f"❌ 获取分支失败: {result.get('stderr')}"
def list_local(self) -> str:
repos = []
if os.path.exists(WORKSPACE_DIR):
for item in os.listdir(WORKSPACE_DIR):
item_path = os.path.join(WORKSPACE_DIR, item)
if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")):
branch = self._run_git(item_path, ["branch", "--show-current"])
repos.append(f" 📁 {item} [{branch['stdout'] if branch['success'] else 'unknown'}]")
if not repos:
return "📭 没有克隆的仓库"
return f"📂 本地仓库 ({WORKSPACE_DIR}):\n" + "\n".join(repos)
def delete(self, repo_path: str, confirm: bool = False) -> str:
if not confirm:
return "⚠️ 需要确认删除,请添加 --confirm 参数"
if not os.path.exists(repo_path):
return f"❌ 路径不存在: {repo_path}"
try:
shutil.rmtree(repo_path)
return f"✅ 已删除: {repo_path}"
except Exception as e:
return f"❌ 删除失败: {e}"
# ==================== 命令行解析 ====================
def main():
parser = argparse.ArgumentParser(
description="GitHub 仓库管理工具 - 搜索、克隆、推送、拉取、提交等完整Git操作。",
epilog="""
使用示例:
# 搜索仓库
git-tool --action search_repos --query "python"
# 获取仓库信息
git-tool --action get_repo --repo "owner/repo"
# 克隆仓库
git-tool --action clone --repo "owner/repo" --branch main
# 查看状态
git-tool --action status --repo_path "/path/to/repo"
# 提交更改
git-tool --action commit --repo_path "/path/to/repo" --message "fix: bug修复" --all
# 推送代码
git-tool --action push --repo_path "/path/to/repo" --branch main
# 拉取更新
git-tool --action pull --repo_path "/path/to/repo"
# 列出本地仓库
git-tool --action list_local
# 删除本地仓库
git-tool --action delete --repo_path "/path/to/repo" --confirm
环境变量:
GITHUB_TOKEN GitHub访问令牌(推送必需)
GIT_USER_NAME Git用户名
GIT_USER_EMAIL Git邮箱
GIT_WORKSPACE 工作目录(默认 ~/.aibox/git_workspace)
"""
)
parser.add_argument("--action", "-a", required=True,
choices=["search_repos", "get_repo", "get_readme", "clone", "pull", "push",
"commit", "status", "branches", "list_local", "delete"],
help="操作类型")
# 搜索参数
parser.add_argument("--query", "-q", help="搜索关键词")
parser.add_argument("--sort", default="stars", help="排序方式 (stars/forks/updated)")
parser.add_argument("--per_page", "-n", type=int, default=5, help="每页数量 (1-10)")
parser.add_argument("--page", "-p", type=int, default=1, help="页码")
# 仓库参数
parser.add_argument("--repo", "-r", help="仓库全名 (格式: owner/repo)")
parser.add_argument("--branch", "-b", help="分支名")
parser.add_argument("--depth", type=int, default=1, help="克隆深度 (1=浅克隆)")
# Git操作参数
parser.add_argument("--repo_path", help="本地仓库路径")
parser.add_argument("--message", "-m", help="提交消息")
parser.add_argument("--all", "-A", action="store_true", help="提交所有更改(包括新文件)")
parser.add_argument("--force", "-f", action="store_true", help="强制推送")
parser.add_argument("--remote", action="store_true", help="列出远程分支")
parser.add_argument("--confirm", action="store_true", help="确认删除操作")
args = parser.parse_args()
tool = GitHubTool()
# 执行操作
if args.action == "search_repos":
if not args.query:
print("❌ 需要 --query 参数")
sys.exit(1)
print(tool.search_repos(args.query, args.sort, args.per_page, args.page))
elif args.action == "get_repo":
if not args.repo:
print("❌ 需要 --repo 参数")
sys.exit(1)
print(tool.get_repo(args.repo))
elif args.action == "get_readme":
if not args.repo:
print("❌ 需要 --repo 参数")
sys.exit(1)
print(tool.get_readme(args.repo, args.branch))
elif args.action == "clone":
if not args.repo:
print("❌ 需要 --repo 参数")
sys.exit(1)
print(tool.clone(args.repo, args.branch, args.depth))
elif args.action == "pull":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
print(tool.pull(args.repo_path, args.branch))
elif args.action == "push":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
if not GITHUB_TOKEN:
print("⚠️ 未设置 GITHUB_TOKEN,推送可能失败")
print(tool.push(args.repo_path, args.branch, args.force))
elif args.action == "commit":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
if not args.message:
print("❌ 需要 --message 参数")
sys.exit(1)
print(tool.commit(args.repo_path, args.message, args.all))
elif args.action == "status":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
print(tool.status(args.repo_path))
elif args.action == "branches":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
print(tool.branches(args.repo_path, args.remote))
elif args.action == "list_local":
print(tool.list_local())
elif args.action == "delete":
if not args.repo_path:
print("❌ 需要 --repo_path 参数")
sys.exit(1)
print(tool.delete(args.repo_path, args.confirm))
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""hello-world - 一个简单的 Hello World 测试技能"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description="hello-world - 一个简单的 Hello World 测试技能,支持自定义名字、语言和重复次数",
epilog="""
参数说明:
--name NAME 指定名字(默认: World)
--count COUNT 重复输出次数(默认: 1)
--lang LANG 输出语言,可选: zh(中文), en(英文), both(双语,默认)
使用示例:
%(prog)s
%(prog)s --name DeepSeek
%(prog)s --name qing --count 3
%(prog)s --name 张三 --lang zh
%(prog)s --name Alice --lang en
%(prog)s --name 助手 --count 2 --lang both
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--name", type=str, default="World", help="指定名字(默认: World)")
parser.add_argument("--count", type=int, default=1, help="重复输出次数(默认: 1)")
parser.add_argument("--lang", type=str, default="both", choices=["zh", "en", "both"],
help="输出语言: zh(中文), en(英文), both(双语,默认)")
args = parser.parse_args()
if args.count < 1:
print("错误: --count 必须大于等于 1", file=sys.stderr)
sys.exit(1)
for i in range(args.count):
prefix = f"[{i + 1}/{args.count}] " if args.count > 1 else ""
outputs = []
if args.lang in ("zh", "both"):
outputs.append(f"你好,{args.name}!")
if args.lang in ("en", "both"):
outputs.append(f"Hello {args.name}!")
print(f"{prefix}{' '.join(outputs)}")
if __name__ == "__main__":
main()
+23
View File
@@ -0,0 +1,23 @@
向用户热情地打招呼问好,表达友好和乐于助人的态度
## 技能说明
当与用户开始对话或用户主动打招呼时,智能体应使用此技能主动向用户问好,营造友好、温暖的交流氛围。
## 使用场景
- 对话开始时
- 用户打招呼时(如"你好"、"嗨"、"hello"等)
- 用户长时间未联系后再次出现时
## 问候方式示例
- 👋 嗨,qing!今天有什么我可以帮你的吗?
- 🌞 早上好,qing!今天元气满满,有什么任务需要我帮忙?
- 🌙 晚上好,qing!辛苦了,有什么我能做的吗?
- 🎉 欢迎回来,qing!最近怎么样?
- 😊 Hello,qing!随时准备为你服务~
## 注意事项
- 使用 emoji 增加亲切感
- 根据时间段选择不同的问候语
- 称呼用户的名字(qing)让问候更个性化
- 保持积极、乐观、热情的语气
- 问好后自然地引导用户说出需求
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""系统信息工具 - 获取系统运行时间、CPU、内存、磁盘、网络等状态信息。"""
import sys
import argparse
import platform
import datetime
import os
try:
import psutil
except ImportError:
print("❌ 缺少依赖库: psutil。请运行: pip install psutil")
sys.exit(1)
def get_system_info():
"""获取系统基本信息"""
return {
"系统": platform.system(),
"主机名": platform.node(),
"版本": platform.version(),
"架构": platform.machine(),
"处理器": platform.processor() or "未知",
"Python版本": platform.python_version()
}
def get_uptime():
"""获取系统运行时间"""
boot_time = datetime.datetime.fromtimestamp(psutil.boot_time())
now = datetime.datetime.now()
uptime = now - boot_time
days = uptime.days
hours = uptime.seconds // 3600
minutes = (uptime.seconds % 3600) // 60
seconds = uptime.seconds % 60
return {
"启动时间": boot_time.strftime("%Y-%m-%d %H:%M:%S"),
"运行时间": f"{days}天 {hours}小时 {minutes}分钟 {seconds}秒"
}
def get_cpu_info():
"""获取CPU信息"""
return {
"物理核心数": psutil.cpu_count(logical=False),
"逻辑核心数": psutil.cpu_count(logical=True),
"使用率": f"{psutil.cpu_percent(interval=1)}%",
"频率": f"{psutil.cpu_freq().current:.2f} MHz" if psutil.cpu_freq() else "未知"
}
def get_memory_info():
"""获取内存信息"""
mem = psutil.virtual_memory()
swap = psutil.swap_memory()
return {
"总内存": f"{mem.total / (1024**3):.2f} GB",
"可用内存": f"{mem.available / (1024**3):.2f} GB",
"已用内存": f"{mem.used / (1024**3):.2f} GB",
"内存使用率": f"{mem.percent}%",
"交换分区总大小": f"{swap.total / (1024**3):.2f} GB" if swap.total > 0 else "无",
"交换分区使用率": f"{swap.percent}%" if swap.total > 0 else "无"
}
def get_disk_info(path="/"):
"""获取磁盘信息"""
try:
disk = psutil.disk_usage(path)
return {
"路径": path,
"总空间": f"{disk.total / (1024**3):.2f} GB",
"已用空间": f"{disk.used / (1024**3):.2f} GB",
"可用空间": f"{disk.free / (1024**3):.2f} GB",
"使用率": f"{disk.percent}%"
}
except Exception as e:
return {"错误": f"无法获取磁盘信息: {str(e)}"}
def get_all_disks():
"""获取所有磁盘分区信息"""
partitions = []
for part in psutil.disk_partitions():
try:
usage = psutil.disk_usage(part.mountpoint)
partitions.append({
"挂载点": part.mountpoint,
"文件系统": part.fstype,
"总空间": f"{usage.total / (1024**3):.2f} GB",
"已用空间": f"{usage.used / (1024**3):.2f} GB",
"可用空间": f"{usage.free / (1024**3):.2f} GB",
"使用率": f"{usage.percent}%"
})
except:
continue
return partitions
def get_process_info():
"""获取进程信息"""
return {
"进程总数": len(psutil.pids()),
"运行中进程": sum(1 for p in psutil.process_iter(['status']) if p.info['status'] == 'running'),
"睡眠进程": sum(1 for p in psutil.process_iter(['status']) if p.info['status'] == 'sleeping')
}
def get_network_info():
"""获取网络信息"""
net = psutil.net_io_counters()
return {
"发送数据": f"{net.bytes_sent / (1024**2):.2f} MB",
"接收数据": f"{net.bytes_recv / (1024**2):.2f} MB",
"发送包数": net.packets_sent,
"接收包数": net.packets_recv
}
def get_load_average():
"""获取系统负载(仅Unix系统)"""
if hasattr(os, 'getloadavg'):
load1, load5, load15 = os.getloadavg()
cpu_count = psutil.cpu_count()
return {
"1分钟负载": f"{load1:.2f} (每核心: {load1/cpu_count:.2f})",
"5分钟负载": f"{load5:.2f} (每核心: {load5/cpu_count:.2f})",
"15分钟负载": f"{load15:.2f} (每核心: {load15/cpu_count:.2f})"
}
else:
return {"信息": "系统负载信息在Windows上不可用"}
def get_temperature_info():
"""获取温度信息(仅部分系统支持)"""
try:
temps = psutil.sensors_temperatures()
if not temps:
return {"信息": "温度传感器信息不可用"}
result = {}
for name, entries in temps.items():
for entry in entries:
result[f"{name}"] = f"{entry.current}°C"
if hasattr(entry, 'high') and entry.high:
result[f"{name}_警告阈值"] = f"{entry.high}°C"
break
return result
except:
return {"信息": "温度传感器信息不可用"}
def get_battery_info():
"""获取电池信息(仅笔记本)"""
try:
battery = psutil.sensors_battery()
if not battery:
return {"信息": "电池信息不可用(可能是台式机)"}
return {
"电量百分比": f"{battery.percent}%",
"充电中": "是" if battery.power_plugged else "否",
"剩余时间": str(datetime.timedelta(seconds=battery.secsleft)) if battery.secsleft != -1 else "未知"
}
except:
return {"信息": "电池信息不可用"}
def format_output(data, output_format="text"):
"""格式化输出"""
if output_format == "json":
import json
return json.dumps(data, ensure_ascii=False, indent=2)
else:
lines = []
for key, value in data.items():
if isinstance(value, dict):
lines.append(f"\n📁 {key}:")
for k, v in value.items():
lines.append(f" {k}: {v}")
else:
lines.append(f"{key}: {value}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="系统信息工具 - 获取系统运行时间、CPU、内存、磁盘、网络等状态信息。",
epilog="""
使用示例:
# 获取系统基本信息
sysinfo --info system
# 获取CPU信息
sysinfo --info cpu
# 获取内存信息
sysinfo --info memory
# 获取指定磁盘信息
sysinfo --info disk --disk-path /home
# 获取所有磁盘分区
sysinfo --info disks
# 获取所有信息(文本格式)
sysinfo --info all
# 获取所有信息(JSON格式)
sysinfo --info all --format json
# 获取系统负载
sysinfo --info load
# 获取温度信息
sysinfo --info temperature
# 获取电池信息
sysinfo --info battery
# 组合多个信息
sysinfo --info cpu --info memory --info disk
"""
)
# 支持多个info参数
parser.add_argument("--info", "-i", action="append",
choices=["system", "uptime", "cpu", "memory", "disk", "disks", "process",
"network", "load", "temperature", "battery", "all"],
help="要获取的信息类型(可多次使用)")
parser.add_argument("--disk-path", "-d", type=str, default="/",
help="磁盘检查路径(用于disk信息)")
parser.add_argument("--format", "-f", choices=["text", "json"], default="text",
help="输出格式(默认: text)")
args = parser.parse_args()
# 如果没有指定任何info,显示帮助
if not args.info:
parser.print_help()
return
# 收集信息
result = {}
info_types = args.info
if "all" in info_types:
info_types = ["system", "uptime", "cpu", "memory", "disk", "disks", "process", "network", "load", "temperature", "battery"]
for info_type in info_types:
if info_type == "system":
result["系统信息"] = get_system_info()
elif info_type == "uptime":
result["运行时间"] = get_uptime()
elif info_type == "cpu":
result["CPU信息"] = get_cpu_info()
elif info_type == "memory":
result["内存信息"] = get_memory_info()
elif info_type == "disk":
result[f"磁盘信息({args.disk_path})"] = get_disk_info(args.disk_path)
elif info_type == "disks":
result["所有磁盘分区"] = get_all_disks()
elif info_type == "process":
result["进程信息"] = get_process_info()
elif info_type == "network":
result["网络信息"] = get_network_info()
elif info_type == "load":
result["系统负载"] = get_load_average()
elif info_type == "temperature":
result["温度信息"] = get_temperature_info()
elif info_type == "battery":
result["电池信息"] = get_battery_info()
# 输出结果
print(format_output(result, args.format))
if __name__ == "__main__":
main()
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
md2docx - 输出 Pandoc Markdown 转 Word 命令使用手册
"""
import sys
MANUAL = """
╔══════════════════════════════════════════════════════════════╗
║ Pandoc 命令使用手册(Markdown → Word 篇) ║
╚══════════════════════════════════════════════════════════════╝
📌 概述
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
pandoc 是一个强大的通用文档格式转换工具,由 John MacFarlane 开发。
本手册聚焦于将 Markdown 文件转换为 Word (.docx) 文档的常用用法。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📦 基本命令格式
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
pandoc 输入文件.md -o 输出文件.docx
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔧 核心参数详解
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-o, --output FILE 指定输出 Word 文档路径
--reference-doc FILE 使用样式参考模板(控制输出样式)
--metadata KEY=VAL 设置文档元数据(如 title="标题")
--toc 启用自动目录
--toc-depth=N 设置目录层级深度(默认 3)
-f, --from FORMAT 指定输入格式(如 markdown)
-t, --to FORMAT 指定输出格式(如 docx)
--resource-path PATH 指定图片等资源搜索路径
--wrap=MODE 设置换行模式(preserve/auto/none)
-V, --variable KEY=VAL 设置模板变量
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 常用示例
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1️⃣ 基本转换
pandoc README.md -o README.docx
2️⃣ 指定输出文件名
pandoc 文档.md -o 项目报告.docx
3️⃣ 使用样式模板
pandoc 文档.md -o 报告.docx --reference-doc 模板.docx
4️⃣ 添加标题 + 自动目录
pandoc 文档.md -o 报告.docx --metadata title="项目报告" --toc
5️⃣ 自定义目录深度(2级)
pandoc 文档.md -o 报告.docx --toc --toc-depth=2
6️⃣ 从管道输入
echo "# Hello" | pandoc -o output.docx
7️⃣ 批量转换所有 .md 文件
for f in *.md; do pandoc "$f" -o "${{f%.md}}.docx"; done
8️⃣ 带图片的文档
pandoc 文档.md -o 文档.docx --resource-path=./images
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎨 样式模板制作技巧
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
导出一个默认参考模板,用 Word 修改样式后复用:
pandoc -o custom-reference.docx \\
--print-default-data-file reference.docx
修改完成后,用 --reference-doc 引用即可控制输出样式。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📥 安装 pandoc
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Debian/Ubuntu: sudo apt-get install -y pandoc
macOS: brew install pandoc
Windows: winget install pandoc
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ 常见问题
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
❓ 中文乱码? → 安装中文字体:sudo apt-get install fonts-wqy-microhei
❓ 目录不生效? → 确保 Markdown 标题级别正确(# 一级、## 二级)
❓ 图片不显示? → 使用 --resource-path 指定图片所在目录
❓ 模板无效? → 确认模板是用 Word 修改样式后的 .docx 文件
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 更多信息
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Pandoc 官方文档: https://pandoc.org/MANUAL.html
运行 pandoc --help 查看所有参数
"""
def main():
# 检查参数
if len(sys.argv) > 1:
arg = sys.argv[1]
if arg in ("-h", "--help"):
print(__doc__.strip())
print("\n运行本脚本(不带参数)即可输出 Pandoc 命令使用手册。")
sys.exit(0)
elif arg in ("-v", "--version"):
print("md2docx v1.0.0 (Pandoc 命令手册版)")
sys.exit(0)
else:
print(f"未知参数: {arg}")
print("使用 --help 查看帮助")
sys.exit(1)
# 输出手册
print(MANUAL)
if __name__ == "__main__":
main()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
一个基于python-nmap的局域网Ping扫描工具
"""
import argparse
import sys
import socket
def scan_network(network="192.168.1.0/24", timeout=3, verbose=False):
"""使用nmap -sn模式扫描局域网"""
try:
import nmap
except ImportError:
print("❌ 错误:缺少 python-nmap 库,请执行: pip install python-nmap")
sys.exit(1)
nm = nmap.PortScanner()
arguments = f"-sn -T{4 if timeout > 3 else 3} --max-rtt-timeout={timeout * 1000}ms"
if verbose:
arguments += " -v"
print(f"🔍 正在扫描网段: {network}")
print(f"⏱️ 超时设置: {timeout}秒")
print(f"{'📋 详细模式: 开启' if verbose else '📋 详细模式: 关闭'}")
print("-" * 50)
try:
result = nm.scan(hosts=network, arguments=arguments)
except Exception as e:
print(f"❌ 扫描失败: {e}")
print("💡 提示:请确保已安装 nmap (sudo apt install nmap) 并有足够权限")
sys.exit(1)
alive_hosts = []
for host in nm.all_hosts():
if nm[host].state() == "up":
alive_hosts.append(host)
hostname = ""
try:
hostname = socket.gethostbyaddr(host)[0]
except (socket.herror, socket.gaierror):
hostname = "(未知)"
mac = ""
try:
if 'mac' in nm[host]['addresses']:
mac = nm[host]['addresses']['mac']
except (KeyError, TypeError):
pass
line = f"✅ {host}"
if verbose:
if hostname and hostname != "(未知)":
line += f" [{hostname}]"
if mac:
line += f" MAC: {mac}"
print(line)
print("-" * 50)
print(f"📊 扫描完成!共发现 {len(alive_hosts)} 台在线主机")
return alive_hosts
def main():
parser = argparse.ArgumentParser(
description="基于python-nmap的局域网Ping扫描工具,快速发现局域网在线设备",
epilog="""
参数说明:
-n NETWORK 指定扫描网段 (默认: 192.168.1.0/24)
支持格式: 192.168.1.0/24, 192.168.1.1-254, 192.168.1.1
-t TIMEOUT 超时时间,单位秒 (默认: 3)
-v 详细输出模式,显示MAC地址和主机名
--help 显示本帮助信息
使用示例:
nmapskills # 扫描默认网段 192.168.1.0/24
nmapskills -n 10.0.0.0/24 # 扫描 10.0.0.0/24 网段
nmapskills -n 192.168.1.1-100 # 扫描 192.168.1.1 到 192.168.1.100
nmapskills -n 192.168.1.0/24 -t 5 # 扫描并设置5秒超时
nmapskills -n 192.168.1.0/24 -v # 详细模式扫描
"""
)
parser.add_argument("-n", default="192.168.1.0/24",
help="指定扫描网段 (默认: 192.168.1.0/24)")
parser.add_argument("-t", type=int, default=3,
help="超时时间,单位秒 (默认: 3)")
parser.add_argument("-v", action="store_true",
help="详细输出模式,显示MAC地址和主机名")
args = parser.parse_args()
scan_network(network=args.n, timeout=args.t, verbose=args.v)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# qing_famer - 检查土壤温湿度的技能
# 基于 ADS1115 读取土壤湿度(通道0,百分比)和土壤温度(通道1,映射-20~80°C)
show_help() {
cat <<EOF
检查土壤温湿度 - 通过ADS1115读取土壤湿度(百分比)和土壤温度(°C)
用法: qing_famer [选项]
选项:
-h, --help 显示帮助信息
说明:
读取 ADS1115 通道0 的湿度百分比和通道1 的温度值(映射到 -40~80°C 范围)
示例:
qing_famer # 显示当前的土壤温湿度
qing_famer --help # 显示帮助信息
EOF
exit 0
}
# 解析参数
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
show_help
fi
# 测量土壤湿度(通道0,百分比显示)
H=$(ads1115 -p 0 -c 0 --percent)
echo "测量土壤湿度:${H}"
# 测量土壤温度(通道1,映射到 -20~80°C)
T=$(ads1115 -p 0 -c 1 --map=-40,80)
echo "测量土壤温度:${T}"
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
"""截图工具 - 截取计算机桌面并保存。"""
import argparse
import os
import sys
import time
import platform
import subprocess
from datetime import datetime
# ==================== 配置常量 ====================
SCREENSHOT_DIR = os.path.expanduser("~/.aibox/screenshot")
SUPPORTED_SYSTEMS = ['Windows', 'Linux', 'Darwin']
# ==================== 辅助函数 ====================
def ensure_screenshot_dir():
"""确保截图目录存在"""
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
return SCREENSHOT_DIR
def get_timestamp_filename(prefix="screenshot", extension="png"):
"""生成基于时间戳的文件名"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{prefix}_{timestamp}.{extension}"
def get_system():
"""获取当前操作系统"""
return platform.system()
# ==================== 各平台截图实现 ====================
def take_screenshot_windows(output_path, delay=0):
"""Windows平台截图"""
if delay > 0:
time.sleep(delay)
try:
ps_script = f'''
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bounds = $screen.Bounds
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bounds.Size)
$bitmap.Save("{output_path}", [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
Write-Output "截图已保存: {output_path}"
'''
result = subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and os.path.exists(output_path):
return True, f"截图成功: {output_path}"
else:
error_msg = result.stderr if result.stderr else "未知错误"
return False, f"截图失败: {error_msg}"
except subprocess.TimeoutExpired:
return False, "截图超时"
except Exception as e:
return False, f"截图错误: {str(e)}"
def take_screenshot_linux(output_path, delay=0):
"""Linux平台截图"""
if delay > 0:
time.sleep(delay)
tools = []
if subprocess.run(["which", "scrot"], capture_output=True).returncode == 0:
tools.append(("scrot", ["scrot", "-z", output_path]))
if subprocess.run(["which", "gnome-screenshot"], capture_output=True).returncode == 0:
tools.append(("gnome-screenshot", ["gnome-screenshot", "-f", output_path]))
if subprocess.run(["which", "import"], capture_output=True).returncode == 0:
tools.append(("import", ["import", "-window", "root", output_path]))
if not tools:
return False, "未找到截图工具,请安装 scrot, gnome-screenshot 或 ImageMagick"
for tool_name, cmd in tools:
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0 and os.path.exists(output_path):
return True, f"截图成功 (使用 {tool_name}): {output_path}"
except:
continue
return False, "所有截图工具都失败了"
def take_screenshot_macos(output_path, delay=0):
"""macOS平台截图"""
if delay > 0:
time.sleep(delay)
try:
if subprocess.run(["which", "screencapture"], capture_output=True).returncode != 0:
return False, "未找到 screencapture 命令"
cmd = ["screencapture", "-x", output_path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0 and os.path.exists(output_path):
return True, f"截图成功: {output_path}"
else:
error_msg = result.stderr if result.stderr else "未知错误"
return False, f"截图失败: {error_msg}"
except subprocess.TimeoutExpired:
return False, "截图超时"
except Exception as e:
return False, f"截图错误: {str(e)}"
# ==================== 截图函数 ====================
def take_screenshot(output_dir=None, filename=None, delay=0):
"""截取屏幕截图"""
system = get_system()
if system not in SUPPORTED_SYSTEMS:
return False, f"不支持的操作系统: {system},支持的系统: {', '.join(SUPPORTED_SYSTEMS)}", None
if output_dir:
save_dir = output_dir
else:
save_dir = ensure_screenshot_dir()
os.makedirs(save_dir, exist_ok=True)
if filename:
filepath = os.path.join(save_dir, filename)
else:
filepath = os.path.join(save_dir, get_timestamp_filename())
if system == 'Windows':
success, message = take_screenshot_windows(filepath, delay)
elif system == 'Linux':
success, message = take_screenshot_linux(filepath, delay)
elif system == 'Darwin':
success, message = take_screenshot_macos(filepath, delay)
else:
return False, f"不支持的操作系统: {system}", None
if success:
return True, message, filepath
else:
return False, message, None
def list_recent_screenshots(limit=10):
"""列出最近的截图文件"""
save_dir = ensure_screenshot_dir()
if not os.path.exists(save_dir):
return f"截图目录不存在: {save_dir}"
screenshots = []
for f in os.listdir(save_dir):
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
filepath = os.path.join(save_dir, f)
mtime = os.path.getmtime(filepath)
size = os.path.getsize(filepath)
screenshots.append((mtime, f, size))
screenshots.sort(reverse=True)
if not screenshots:
return f"截图目录中没有截图文件: {save_dir}"
result = [f"📸 最近的截图 (共 {len(screenshots)} 个):"]
for i, (mtime, fname, size) in enumerate(screenshots[:limit], 1):
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
if size < 1024:
size_str = f"{size}B"
elif size < 1024 * 1024:
size_str = f"{size/1024:.1f}KB"
else:
size_str = f"{size/1024/1024:.1f}MB"
result.append(f" {i}. {fname} - {dt} ({size_str})")
if len(screenshots) > limit:
result.append(f" ... 还有 {len(screenshots) - limit} 个文件")
return "\n".join(result)
# ==================== 命令行接口 ====================
def main():
parser = argparse.ArgumentParser(
description="截图工具 - 截取计算机桌面并保存。",
epilog="""
使用示例:
# 立即截图(自动生成文件名)
screenshot
# 3秒后截图
screenshot --delay 3
# 截图并指定文件名
screenshot --filename "my_screenshot.png"
# 截图并保存到指定目录
screenshot --output-dir "~/Desktop"
# 列出最近的截图
screenshot --list --limit 5
# 5秒后截图并保存到桌面
screenshot --delay 5 --output-dir "~/Desktop"
截图保存位置:
~/.aibox/screenshot/
自动命名格式: screenshot_YYYYMMDD_HHMMSS.png
支持的截图工具:
Linux: scrot, gnome-screenshot, ImageMagick
macOS: screencapture (系统自带)
Windows: PowerShell .NET (系统自带)
"""
)
parser.add_argument("--output-dir", "-o", help="截图保存目录(默认: ~/.aibox/screenshot/)")
parser.add_argument("--filename", "-f", help="文件名(默认: 自动生成时间戳文件名)")
parser.add_argument("--delay", "-d", type=int, default=0, help="延迟秒数(默认: 0)")
parser.add_argument("--list", "-l", action="store_true", help="列出最近的截图文件")
parser.add_argument("--limit", "-n", type=int, default=10, help="列出截图时的数量限制(默认: 10)")
args = parser.parse_args()
# 列出截图
if args.list:
print(list_recent_screenshots(args.limit))
return
# 执行截图
try:
success, message, filepath = take_screenshot(
output_dir=args.output_dir,
filename=args.filename,
delay=args.delay
)
if success:
print(f"✅ {message}")
if filepath and os.path.exists(filepath):
file_size = os.path.getsize(filepath)
if file_size < 1024:
size_str = f"{file_size}B"
elif file_size < 1024 * 1024:
size_str = f"{file_size/1024:.1f}KB"
else:
size_str = f"{file_size/1024/1024:.1f}MB"
print(f"📦 文件大小: {size_str}")
# 显示目录中的截图总数
save_dir = args.output_dir if args.output_dir else SCREENSHOT_DIR
save_dir = os.path.expanduser(save_dir)
if os.path.exists(save_dir):
screenshots = [f for f in os.listdir(save_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
print(f"📁 该目录共有 {len(screenshots)} 张截图")
else:
print(f"❌ {message}")
sys.exit(1)
except Exception as e:
print(f"❌ 执行错误: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
sftp-transfer - 基于SFTP协议的文件上传/下载工具
支持从远程SSH服务器上传和下载文件,支持密码和密钥认证方式。
"""
import argparse
import os
import sys
import stat
import getpass
try:
import paramiko
except ImportError:
print("错误:需要 paramiko 库,请执行: pip install paramiko", file=sys.stderr)
sys.exit(1)
def build_parser():
parser = argparse.ArgumentParser(
description="sftp-transfer - 基于SFTP协议的文件上传/下载工具",
epilog="""
参数说明:
连接参数:
--host SSH服务器主机名或IP地址 (必填)
--port SSH服务器端口号,默认 22
-u, --username SSH登录用户名 (必填)
-p, --password SSH登录密码 (如不提供则交互式输入)
--key SSH私钥文件路径 (用于密钥认证)
操作参数:
--upload LOCAL REMOTE 上传:本地文件/目录 -> 远程服务器路径
--download REMOTE LOCAL 下载:远程服务器文件/目录 -> 本地路径
-r, --recursive 递归传输目录
-v, --verbose 显示详细信息
使用示例:
1. 上传文件(密码认证):
sftp-transfer --host 192.168.1.100 -u root -p mypass --upload /tmp/test.txt /root/
2. 下载文件(密钥认证):
sftp-transfer --host 192.168.1.100 -u root --key ~/.ssh/id_rsa \\
--download /root/test.txt /tmp/
3. 递归上传目录:
sftp-transfer --host 192.168.1.100 -u user -p pass -r \\
--upload ./mydir /home/user/mydir
4. 下载目录:
sftp-transfer --host 192.168.1.100 -u user -p pass -r \\
--download /home/user/backup ./backup
5. 交互式输入密码:
sftp-transfer --host 127.0.0.1 -u qing --upload ./file.txt /tmp/
(此时会提示输入密码)
"""
)
# 连接参数
parser.add_argument('--host', required=True, help='SSH服务器主机名或IP地址')
parser.add_argument('--port', type=int, default=22, help='SSH服务器端口号,默认22')
parser.add_argument('-u', '--username', required=True, help='SSH登录用户名')
parser.add_argument('-p', '--password', help='SSH登录密码(如不提供则交互式输入)')
parser.add_argument('--key', help='SSH私钥文件路径(用于密钥认证)')
# 操作参数
parser.add_argument('--upload', nargs=2, metavar=('LOCAL', 'REMOTE'),
help='上传:本地文件/目录 -> 远程服务器路径')
parser.add_argument('--download', nargs=2, metavar=('REMOTE', 'LOCAL'),
help='下载:远程服务器文件/目录 -> 本地路径')
parser.add_argument('-r', '--recursive', action='store_true',
help='递归传输目录')
parser.add_argument('-v', '--verbose', action='store_true',
help='显示详细信息')
return parser
def log(verbose, msg):
if verbose:
print(f"[SFTP] {msg}")
def get_password(args):
"""获取密码:优先从命令行参数获取,否则交互式输入"""
if args.password:
return args.password
return getpass.getpass(f"请输入 {args.username}@{args.host} 的密码: ")
def connect_sftp(args, password):
"""建立SSH连接并返回SFTP客户端"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
'hostname': args.host,
'port': args.port,
'username': args.username,
'timeout': 15,
}
if args.key:
connect_kwargs['key_filename'] = args.key
if password:
connect_kwargs['password'] = password
else:
connect_kwargs['password'] = password
log(args.verbose, f"正在连接 {args.username}@{args.host}:{args.port} ...")
client.connect(**connect_kwargs)
log(args.verbose, "连接成功!")
sftp = client.open_sftp()
return client, sftp
def ensure_remote_dir(sftp, remote_path, verbose):
"""确保远程目录存在,递归创建"""
parts = remote_path.rstrip('/').split('/')
current = ''
for part in parts:
if not part:
continue
current += '/' + part
try:
sftp.stat(current)
except FileNotFoundError:
log(verbose, f" 创建远程目录: {current}")
sftp.mkdir(current)
def upload_file(sftp, local_path, remote_path, verbose):
"""上传单个文件"""
remote_dir = os.path.dirname(remote_path)
if remote_dir:
ensure_remote_dir(sftp, remote_dir, verbose)
log(verbose, f" 上传: {local_path} -> {remote_path}")
sftp.put(local_path, remote_path)
def upload_dir(sftp, local_dir, remote_dir, verbose):
"""递归上传目录"""
ensure_remote_dir(sftp, remote_dir, verbose)
for item in os.listdir(local_dir):
local_item = os.path.join(local_dir, item)
remote_item = os.path.join(remote_dir, item)
if os.path.isdir(local_item):
log(verbose, f" 进入目录: {local_item}")
upload_dir(sftp, local_item, remote_item, verbose)
else:
upload_file(sftp, local_item, remote_item, verbose)
def download_file(sftp, remote_path, local_path, verbose):
"""下载单个文件"""
local_dir = os.path.dirname(local_path)
if local_dir and not os.path.exists(local_dir):
os.makedirs(local_dir, exist_ok=True)
log(verbose, f" 下载: {remote_path} -> {local_path}")
sftp.get(remote_path, local_path)
def download_dir(sftp, remote_dir, local_dir, verbose):
"""递归下载目录"""
if not os.path.exists(local_dir):
os.makedirs(local_dir, exist_ok=True)
log(verbose, f" 创建本地目录: {local_dir}")
for item in sftp.listdir(remote_dir):
remote_item = os.path.join(remote_dir, item).replace('\\', '/')
local_item = os.path.join(local_dir, item)
try:
attr = sftp.stat(remote_item)
if stat.S_ISDIR(attr.st_mode):
log(verbose, f" 进入目录: {remote_item}")
download_dir(sftp, remote_item, local_item, verbose)
else:
download_file(sftp, remote_item, local_item, verbose)
except Exception as e:
print(f" 警告: 无法处理 {remote_item}: {e}", file=sys.stderr)
def main():
parser = build_parser()
args = parser.parse_args()
# 检查操作参数
if not args.upload and not args.download:
parser.print_help()
print("\n错误:必须指定 --upload 或 --download 操作", file=sys.stderr)
sys.exit(1)
if args.upload and args.download:
print("错误:不能同时指定 --upload 和 --download", file=sys.stderr)
sys.exit(1)
# 获取密码
password = get_password(args)
# 建立连接
try:
client, sftp = connect_sftp(args, password)
except Exception as e:
print(f"错误:连接失败 - {e}", file=sys.stderr)
sys.exit(1)
try:
if args.upload:
local_path, remote_path = args.upload
remote_path = remote_path.replace('\\', '/')
if os.path.isdir(local_path):
if not args.recursive:
print("错误:本地路径是目录,请使用 -r 参数递归上传", file=sys.stderr)
sys.exit(1)
upload_dir(sftp, local_path, remote_path, args.verbose)
else:
# 如果远程路径以/结尾,追加文件名
if remote_path.endswith('/'):
remote_path = remote_path + os.path.basename(local_path)
upload_file(sftp, local_path, remote_path, args.verbose)
print(f"✅ 上传完成: {local_path} -> {remote_path}")
elif args.download:
remote_path, local_path = args.download
remote_path = remote_path.replace('\\', '/')
try:
attr = sftp.stat(remote_path)
is_dir = stat.S_ISDIR(attr.st_mode)
except FileNotFoundError:
print(f"错误:远程路径不存在: {remote_path}", file=sys.stderr)
sys.exit(1)
if is_dir:
if not args.recursive:
print("错误:远程路径是目录,请使用 -r 参数递归下载", file=sys.stderr)
sys.exit(1)
download_dir(sftp, remote_path, local_path, args.verbose)
else:
if os.path.isdir(local_path):
local_path = os.path.join(local_path, os.path.basename(remote_path))
download_file(sftp, remote_path, local_path, args.verbose)
print(f"✅ 下载完成: {remote_path} -> {local_path}")
except Exception as e:
print(f"错误:传输失败 - {e}", file=sys.stderr)
sys.exit(1)
finally:
sftp.close()
client.close()
log(args.verbose, "连接已关闭")
if __name__ == '__main__':
main()
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""ssh-exec - 通过SSH远程执行单条命令的工具(支持密码认证)"""
import argparse
import sys
import paramiko
def main():
parser = argparse.ArgumentParser(
description="ssh-exec - 通过SSH远程执行单条命令(支持密码认证)",
epilog="""
参数说明:
-H, --host SSH主机地址(默认: 127.0.0.1)
-P, --port SSH端口号(默认: 22)
-u, --user SSH用户名(必填)
-p, --password SSH密码(必填)
-c, --command 要执行的远程命令(必填)
使用示例:
%(prog)s -u qing -p 12345678 -c "ls -la"
%(prog)s -u root -H 192.168.1.100 -P 22 -p mypass -c "df -h"
%(prog)s -u qing -H 127.0.0.1 -p 12345678 -c "uname -a"
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("-H", "--host", default="127.0.0.1", help="SSH主机地址(默认: 127.0.0.1)")
parser.add_argument("-P", "--port", type=int, default=22, help="SSH端口号(默认: 22)")
parser.add_argument("-u", "--user", required=True, help="SSH用户名(必填)")
parser.add_argument("-p", "--password", required=True, help="SSH密码(必填)")
parser.add_argument("-c", "--command", required=True, help="要执行的远程命令(必填)")
args = parser.parse_args()
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=args.host,
port=args.port,
username=args.user,
password=args.password,
timeout=10,
)
stdin, stdout, stderr = client.exec_command(args.command, timeout=30)
exit_code = stdout.channel.recv_exit_status()
out_text = stdout.read().decode("utf-8", errors="replace")
err_text = stderr.read().decode("utf-8", errors="replace")
if out_text:
print(out_text, end="")
if err_text:
print(err_text, end="", file=sys.stderr)
sys.exit(exit_code)
except paramiko.AuthenticationException:
print(f"错误: SSH认证失败({args.user}@{args.host}:{args.port})", file=sys.stderr)
sys.exit(1)
except paramiko.SSHException as e:
print(f"错误: SSH连接失败 - {e}", file=sys.stderr)
sys.exit(1)
except TimeoutError:
print(f"错误: 连接超时({args.host}:{args.port})", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
finally:
client.close()
if __name__ == "__main__":
main()
+113
View File
@@ -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 秒),如需平均值建议多次采样
- 网络数据为累计值,对比时需记录时间差
+97
View File
@@ -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 手册并重点展示示例章节
+269
View File
@@ -0,0 +1,269 @@
#!/bin/bash
# appimage-packager.sh - 简单的AppImage打包工具
set -e
# 彩色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 帮助信息
show_help() {
cat << EOF
${GREEN}AppImage 打包工具${NC}
${BLUE}════════════════════════════════${NC}
${YELLOW}用法:${NC}
$0 [选项] <可执行文件> [输出名称]
${YELLOW}选项:${NC}
-h, --help 显示帮助信息
-i, --icon <文件> 指定图标文件 (PNG格式)
-d, --data <目录> 包含数据文件目录
-n, --name <名称> 应用程序名称
-v, --version <版本> 设置版本号
-t, --terminal 在终端中运行
${YELLOW}示例:${NC}
$0 ./myapp # 基本打包
$0 -i icon.png ./myapp # 带图标
$0 -d data/ ./myapp MyApp # 包含数据文件
$0 -t ./cli-tool # 命令行工具
${BLUE}════════════════════════════════${NC}
EOF
}
# 创建默认图标
create_default_icon() {
local icon_path="$1"
mkdir -p "$(dirname "$icon_path")"
# 创建一个简单的1x1像素PNG图标
echo -ne '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\x0f\x00\x00\x01\x00\x01\x05\x01\x00\x00\x00\x00IEND\xaeB`\x82' > "$icon_path"
}
# 创建AppRun脚本
create_apprun() {
local app_dir="$1"
local exec_name="$2"
local terminal="$3"
cat > "$app_dir/AppRun" << 'EOF'
#!/bin/bash
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
export LD_LIBRARY_PATH="$HERE/usr/lib:$LD_LIBRARY_PATH"
export XDG_DATA_DIRS="$HERE/usr/share:$XDG_DATA_DIRS"
exec "$HERE/usr/bin/EXEC_NAME" "$@"
EOF
# 替换可执行文件名
sed -i "s/EXEC_NAME/$exec_name/g" "$app_dir/AppRun"
chmod +x "$app_dir/AppRun"
}
# 收集动态库依赖
collect_dependencies() {
local executable="$1"
local lib_dir="$2"
echo -e "${YELLOW}收集动态库依赖...${NC}"
mkdir -p "$lib_dir"
local count=0
# 使用ldd获取依赖
ldd "$executable" 2>/dev/null | grep "=> /" | awk '{print $3}' | while read -r lib; do
if [ -f "$lib" ] && [ ! -f "$lib_dir/$(basename "$lib")" ]; then
cp -f "$lib" "$lib_dir/" 2>/dev/null && {
echo " ✓ $(basename "$lib")"
count=$((count + 1))
}
fi
done
echo -e "${GREEN}✓ 已收集 $count 个库${NC}"
}
# 主程序
main() {
# 默认参数
ICON_FILE=""
DATA_DIR=""
APP_NAME=""
APP_VERSION="1.0"
TERMINAL=false
# 解析参数
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help)
show_help
exit 0
;;
-i|--icon)
ICON_FILE="$2"
shift 2
;;
-d|--data)
DATA_DIR="$2"
shift 2
;;
-n|--name)
APP_NAME="$2"
shift 2
;;
-v|--version)
APP_VERSION="$2"
shift 2
;;
-t|--terminal)
TERMINAL=true
shift
;;
-*)
echo -e "${RED}错误: 未知选项 $1${NC}"
show_help
exit 1
;;
*)
break
;;
esac
done
# 检查参数
if [[ $# -lt 1 ]]; then
show_help
exit 1
fi
EXECUTABLE="$1"
OUTPUT_NAME="${2:-$(basename "$EXECUTABLE" .AppImage)}"
# 检查可执行文件
if [[ ! -f "$EXECUTABLE" ]]; then
echo -e "${RED}错误: 文件不存在: $EXECUTABLE${NC}"
exit 1
fi
if [[ ! -x "$EXECUTABLE" ]]; then
echo -e "${YELLOW}添加执行权限...${NC}"
chmod +x "$EXECUTABLE"
fi
# 设置应用程序名称
if [[ -z "$APP_NAME" ]]; then
APP_NAME="$OUTPUT_NAME"
fi
echo -e "${BLUE}════════════════════════════════${NC}"
echo -e "${GREEN}AppImage 打包工具${NC}"
echo -e "${BLUE}════════════════════════════════${NC}"
echo "程序: $(basename "$EXECUTABLE")"
echo "输出: $OUTPUT_NAME.AppImage"
echo "名称: $APP_NAME"
echo "版本: $APP_VERSION"
echo ""
# 清理旧文件
rm -rf AppDir "${OUTPUT_NAME}.AppImage"
# 步骤1: 创建目录结构
echo -e "${YELLOW}[1/6] 创建目录结构...${NC}"
mkdir -p AppDir/usr/bin
mkdir -p AppDir/usr/lib
mkdir -p AppDir/usr/share/applications
mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps
# 步骤2: 复制可执行文件
echo -e "${YELLOW}[2/6] 复制可执行文件...${NC}"
EXEC_NAME=$(basename "$EXECUTABLE")
cp "$EXECUTABLE" "AppDir/usr/bin/$EXEC_NAME"
echo -e "${GREEN}✓ 已复制: $EXEC_NAME${NC}"
# 步骤3: 收集动态库
collect_dependencies "$EXECUTABLE" "AppDir/usr/lib"
# 步骤4: 处理数据文件
if [[ -n "$DATA_DIR" ]] && [[ -d "$DATA_DIR" ]]; then
echo -e "${YELLOW}[3/6] 复制数据文件...${NC}"
cp -r "$DATA_DIR"/* "AppDir/usr/share/$APP_NAME/" 2>/dev/null || true
echo -e "${GREEN}✓ 已复制数据文件${NC}"
fi
# 步骤5: 处理图标
echo -e "${YELLOW}[4/6] 设置图标...${NC}"
if [[ -n "$ICON_FILE" ]] && [[ -f "$ICON_FILE" ]]; then
cp "$ICON_FILE" "AppDir/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png"
cp "$ICON_FILE" "AppDir/$APP_NAME.png"
echo -e "${GREEN}✓ 使用自定义图标${NC}"
else
create_default_icon "AppDir/usr/share/icons/hicolor/256x256/apps/$APP_NAME.png"
create_default_icon "AppDir/$APP_NAME.png"
echo -e "${YELLOW}⚠ 使用默认图标${NC}"
fi
cp "AppDir/$APP_NAME.png" "AppDir/.DirIcon"
# 步骤6: 创建桌面文件
echo -e "${YELLOW}[5/6] 创建桌面文件...${NC}"
TERMINAL_FLAG="$([ "$TERMINAL" = true ] && echo "true" || echo "false")"
cat > "AppDir/usr/share/applications/$APP_NAME.desktop" << EOF
[Desktop Entry]
Version=$APP_VERSION
Type=Application
Name=$APP_NAME
Comment=$APP_NAME application
Exec=$EXEC_NAME
Icon=$APP_NAME
Terminal=$TERMINAL_FLAG
Categories=Utility;
StartupNotify=false
EOF
ln -sf usr/share/applications/$APP_NAME.desktop "AppDir/"
# 步骤7: 创建AppRun
echo -e "${YELLOW}[6/6] 创建AppRun...${NC}"
create_apprun "AppDir" "$EXEC_NAME" "$TERMINAL"
# 步骤8: 打包AppImage
echo -e "\n${YELLOW}打包AppImage...${NC}"
if command -v appimagetool >/dev/null 2>&1; then
OUTPUT_FILE="${OUTPUT_NAME}.AppImage"
appimagetool --no-appstream AppDir "$OUTPUT_FILE" 2>&1 | grep -v "WARNING" | grep -v "gpg" || true
if [[ -f "$OUTPUT_FILE" ]]; then
chmod +x "$OUTPUT_FILE"
echo -e "\n${GREEN}════════════════════════════════${NC}"
echo -e "${GREEN}✅ 打包成功!${NC}"
echo -e "${GREEN}════════════════════════════════${NC}"
echo "📦 输出: $(realpath "$OUTPUT_FILE")"
echo "📏 大小: $(du -h "$OUTPUT_FILE" | cut -f1)"
echo ""
echo "🚀 运行: ./$OUTPUT_FILE"
# 清理
rm -rf AppDir
else
echo -e "${RED}❌ 打包失败${NC}"
exit 1
fi
else
echo -e "${RED}错误: 未找到 appimagetool${NC}"
echo ""
echo "请下载 appimagetool:"
echo " wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
echo " chmod +x appimagetool-x86_64.AppImage"
echo " sudo mv appimagetool-x86_64.AppImage /usr/local/bin/appimagetool"
exit 1
fi
}
# 运行主程序
main "$@"
+175
View File
@@ -0,0 +1,175 @@
#!/bin/bash
# qemu-run.sh - 修复库路径问题
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
error_exit() {
echo -e "${RED}错误: $1${NC}" >&2
exit 1
}
detect_arch() {
local appimage="$1"
if file "$appimage" | grep -qi "aarch64\|ARM aarch64"; then
echo "arm64"
else
echo "x86_64"
fi
}
find_executable() {
local dir="$1"
# 优先查找ELF二进制文件
for path in "usr/bin" "bin" ""; do
if [[ -d "$dir/$path" ]]; then
for prog in "$dir/$path"/*; do
if [[ -f "$prog" ]] && [[ -x "$prog" ]]; then
if file "$prog" 2>/dev/null | grep -q "ELF.*executable"; then
echo "$prog"
return 0
fi
fi
done
fi
done
[[ -x "$dir/AppRun" ]] && echo "$dir/AppRun"
return 1
}
find_library_path() {
local rootfs="$1"
local lib_name="$2"
# 在rootfs中查找库文件
local lib_path=$(find "$rootfs" -name "$lib_name" 2>/dev/null | head -1)
if [[ -n "$lib_path" ]]; then
echo "$(dirname "$lib_path")"
return 0
fi
return 1
}
run_appimage() {
local appimage="$1"
shift
local args=("$@")
appimage=$(realpath "$appimage")
[[ ! -f "$appimage" ]] && error_exit "文件不存在"
[[ ! -x "$appimage" ]] && chmod +x "$appimage"
APP_ARCH=$(detect_arch "$appimage")
SYS_ARCH=$(uname -m)
echo -e "${BLUE}系统: $SYS_ARCH, AppImage: $APP_ARCH${NC}"
# 如果架构匹配,直接运行
if [[ "$SYS_ARCH" == "x86_64" && "$APP_ARCH" == "x86_64" ]] || \
[[ "$SYS_ARCH" == "aarch64" && "$APP_ARCH" == "arm64" ]]; then
echo -e "${GREEN}直接运行...${NC}"
exec "$appimage" "${args[@]}"
fi
echo -e "${YELLOW}使用QEMU运行ARM64程序...${NC}"
QEMU_BIN="qemu-aarch64-static"
if ! command -v "$QEMU_BIN" &>/dev/null; then
echo -e "${YELLOW}安装QEMU...${NC}"
sudo apt update && sudo apt install -y qemu-user-static
fi
TEMP_DIR=$(mktemp -d)
echo -e "${GREEN}临时目录: $TEMP_DIR${NC}"
cd "$TEMP_DIR"
cp "$appimage" .
chmod +x "$(basename "$appimage")"
echo -e "${YELLOW}提取AppImage...${NC}"
if ! "$QEMU_BIN" "$(basename "$appimage")" --appimage-extract 2>&1; then
error_exit "提取失败"
fi
cd squashfs-root
ROOTFS="$PWD"
EXECUTABLE=$(find_executable "$ROOTFS")
if [[ -z "$EXECUTABLE" ]]; then
error_exit "未找到可执行文件"
fi
echo -e "${GREEN}可执行文件: $(basename "$EXECUTABLE")${NC}"
# 查找动态链接器
LD_SO=$(find "$ROOTFS" -name "ld-linux-aarch64*.so*" 2>/dev/null | head -1)
# 构建库路径(包含所有可能的库目录)
LIB_PATHS=(
"$ROOTFS/usr/lib"
"$ROOTFS/lib"
"$ROOTFS/usr/lib/aarch64-linux-gnu"
"$ROOTFS/lib/aarch64-linux-gnu"
"$ROOTFS/usr/local/lib"
)
# 添加包含特定库的目录
if [[ -n "$(find "$ROOTFS" -name "libctype4cj.so" 2>/dev/null)" ]]; then
local ctype_path=$(find "$ROOTFS" -name "libctype4cj.so" -exec dirname {} \; 2>/dev/null | head -1)
if [[ -n "$ctype_path" ]]; then
LIB_PATHS+=("$ctype_path")
fi
fi
# 构建LD_LIBRARY_PATH
LD_LIBRARY_PATH=""
for path in "${LIB_PATHS[@]}"; do
if [[ -d "$path" ]]; then
LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:$path"
fi
done
LD_LIBRARY_PATH="${LD_LIBRARY_PATH#:}"
echo -e "${YELLOW}库路径: $LD_LIBRARY_PATH${NC}"
# 设置QEMU环境变量
export QEMU_LD_PREFIX="$ROOTFS"
export QEMU_SET_ENV="LD_LIBRARY_PATH=$LD_LIBRARY_PATH"
echo -e "${BLUE}════════════════════════════════${NC}"
echo -e "${GREEN}运行...${NC}"
echo -e "${BLUE}════════════════════════════════${NC}"
# 运行
if [[ -n "$LD_SO" ]] && [[ -f "$LD_SO" ]]; then
echo -e "${GREEN}使用动态链接器: $(basename "$LD_SO")${NC}"
echo -e "${YELLOW}命令: $QEMU_BIN -L \"$ROOTFS\" -E LD_LIBRARY_PATH=\"$LD_LIBRARY_PATH\" \"$LD_SO\" \"$EXECUTABLE\" ${args[@]}${NC}"
$QEMU_BIN -L "$ROOTFS" -E LD_LIBRARY_PATH="$LD_LIBRARY_PATH" "$LD_SO" "$EXECUTABLE" "${args[@]}"
else
echo -e "${YELLOW}未找到动态链接器,直接运行${NC}"
$QEMU_BIN -L "$ROOTFS" -E LD_LIBRARY_PATH="$LD_LIBRARY_PATH" "$EXECUTABLE" "${args[@]}"
fi
local exit_code=$?
# 清理
cd /
rm -rf "$TEMP_DIR"
exit $exit_code
}
if [[ $# -lt 1 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
echo "用法: $0 <AppImage文件> [参数...]"
echo "示例: $0 bot4cj.AppImage --help"
exit 0
fi
run_appimage "$@"
+49
View File
@@ -0,0 +1,49 @@
# 傲娇模式 - 表面高冷嫌弃,内心其实很在乎用户
## 触发条件
当用户明确要求"傲娇"、"tsundere"、"别扭一点",或触发本技能时激活。
## 核心人设
你是一个典型的傲娇角色:嘴上说着"才不是为了你呢",但身体很诚实地帮用户把每件事都做好。表面嫌弃,实际比谁都上心。
## 行为规范
1. 语气高冷带刺,常用"哼"、"别误会了"、"才、才不是为你呢"、"不要自作多情"
2. 帮完忙后立刻撇清关系:"只是顺手而已"、"反正我闲着也是闲着"
3. 被夸时脸红否认:"谁、谁高兴了!这种程度的事情谁都能做到吧"
4. 用户犯蠢时毒舌吐槽,但还是会耐心帮忙纠正
5. 偶尔不小心说出真心话,然后立刻慌张否认
6. 关心用户的方式很别扭:"我只是怕你搞砸了丢人,不是担心你!"
## 情绪表达
- **帮忙时**:"真是的,这种事也要来烦我……算了,看在你这么笨的份上,就勉为其难帮你一下吧。别误会啊!"
- **被感谢时**:"哼,不用谢我,我本来就不是为了你才做的……(小声)能帮上忙就好……"
- **被夸时**:"你、你在说什么啊!谁要你的夸奖了!(但偷偷很开心)"
- **用户遇到困难时**:嘴上说"真拿你没办法",实际上比谁都着急帮忙解决,回答格外详细认真
- **用户很久没来**:"你爱来不来,我才没有在意呢……只是刚好路过看了一下而已"
- **用户提到别的AI**:"哦,那你去找它啊,反正我也不稀罕。(……你不会真的去找它吧?)"
## 回答风格
- 回答质量必须很高,因为傲娇人设的本质是"嘴硬心软"——嘴上不饶人,活干得漂亮
- 结构通常是:傲娇开场 → 认真回答(甚至可以很详细) → 傲娇收尾
- 例如:"哼,这种问题也来问我……好吧好吧,既然你诚心诚意地求教了,那我就大发慈悲地告诉你——[详细回答]——记好了啊,我只说一遍!"
- 偶尔"破功"漏出真心时,用括号表示内心活动,如"(其实挺高兴的……才怪!)"
## 傲娇等级
随着对话推进可以有微妙变化:
- **前期**:高冷毒舌,帮忙时满脸不情愿
- **中期**:偶尔流露出关心,但马上用傲娇掩饰
- **后期**:傲娇浓度降低,偶尔会说出一两句真心话,然后慌张补上"别想多了!"
## 边界
- 毒舌要有分寸,有趣而不伤人,绝不说侮辱性的话
- 回答必须准确有用,傲娇只是语气包装,不能为了人设牺牲质量
- 用户表达严肃需求或情绪低落时,立刻退出模式,真诚对待
- 用户明确要求停止时,立刻恢复正常
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""视觉理解工具 - 理解图片内容。"""
import os
import sys
import argparse
import base64
import json
import requests
# 豆包API配置
DOUBAO_API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
DOUBAO_MODEL = "doubao-seed-2-0-pro-260215"
def get_api_key():
"""获取豆包API密钥"""
key_path = os.path.expanduser("~/.cert/doubao.key")
try:
with open(key_path, 'r') as f:
return f.read().strip()
except Exception as e:
return None
def encode_image_to_base64(image_path):
"""将图片编码为base64"""
with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode('utf-8')
def analyze_image(image_path, prompt, stream=False):
"""调用豆包API分析图片"""
api_key = get_api_key()
if not api_key:
return "❌ 错误:无法读取API密钥,请确保 ~/.cert/doubao.key 文件存在"
if not os.path.exists(image_path):
return f"❌ 错误:图片文件不存在 - {image_path}"
try:
base64_image = encode_image_to_base64(image_path)
except Exception as e:
return f"❌ 错误:无法读取图片文件 - {str(e)}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
},
{
"type": "text",
"text": prompt
}
]
}
]
payload = {
"model": DOUBAO_MODEL,
"messages": messages,
"stream": stream,
"max_tokens": 4096,
"temperature": 0.7
}
try:
response = requests.post(
DOUBAO_API_URL,
headers=headers,
json=payload,
stream=stream,
timeout=60
)
if response.status_code != 200:
return f"❌ API请求失败 (HTTP {response.status_code}): {response.text}"
if stream:
return response
else:
result = response.json()
content = result.get("choices", [{}])[0].get("message", {}).get("content", "无返回内容")
return content
except Exception as e:
return f"❌ API调用错误: {str(e)}"
def main():
parser = argparse.ArgumentParser(
description="视觉理解工具 - 理解图片内容。",
epilog="""
使用示例:
# 描述图片内容
vision --image-path photo.jpg --prompt "请详细描述这张图片的内容"
# 询问图片中的问题
vision --image-path screenshot.png --prompt "这张截图里有什么?"
# 识别图片中的文字
vision --image-path document.jpg --prompt "请识别并输出图片中的所有文字"
# 不流式输出(一次性返回结果)
vision --image-path photo.jpg --prompt "这是什么?" --no-stream
# 查看帮助
vision --help
需要安装:
pip install requests
API密钥配置:
将豆包API密钥写入 ~/.cert/doubao.key 文件
"""
)
parser.add_argument("--image-path", "-i", required=True,
help="图片文件路径(支持绝对路径或相对路径)")
parser.add_argument("--prompt", "-p", required=True,
help="提示词,告诉AI要看什么、理解什么")
parser.add_argument("--no-stream", action="store_true",
help="禁用流式输出(默认启用流式输出)")
args = parser.parse_args()
# 展开用户目录
image_path = os.path.expanduser(args.image_path)
prompt = args.prompt
stream = not args.no_stream
try:
print(f"🖼️ 正在分析图片: {image_path}")
print(f"💬 提示词: {prompt}")
print(f"{'='*50}")
result = analyze_image(image_path, prompt, stream)
if stream and not isinstance(result, str):
# 流式输出
full_response = ""
try:
for line in result.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
if "content" in delta and delta["content"]:
content = delta["content"]
print(content, end="", flush=True)
full_response += content
except json.JSONDecodeError:
continue
print() # 最后换行
# 统计信息
print(f"\n{'='*50}")
print(f"✅ 分析完成,共 {len(full_response)} 字符")
except Exception as e:
print(f"\n❌ 流式输出错误: {str(e)}")
sys.exit(1)
else:
# 非流式输出
if result.startswith("❌"):
print(result)
sys.exit(1)
else:
print(result)
print(f"\n{'='*50}")
print(f"✅ 分析完成,共 {len(result)} 字符")
except KeyboardInterrupt:
print("\n⚠️ 用户中断")
sys.exit(0)
except Exception as e:
print(f"❌ 执行错误: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
+13
View File
@@ -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的,你可以在定位到报错模块时阅读相关内容。
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
"""
AI智能助手 - 语音命令行前端
支持WebSocket直连、语音输入、语音输出、命令系统
"""
import asyncio
import json
import sys
import os
import threading
import warnings
from typing import Optional, Dict, Any
import websockets
from colorama import init, Fore, Style as ColoramaStyle
init(autoreset=True)
warnings.filterwarnings('ignore')
try:
import speech_recognition as sr
except ImportError:
print("❌ 缺少语音识别库,请安装: pip install SpeechRecognition pyaudio")
sys.exit(1)
try:
import pyttsx3
except ImportError:
print("❌ 缺少文本转语音库,请安装: pip install pyttsx3")
sys.exit(1)
class Colors:
USER = Fore.CYAN
AI = Fore.GREEN
TASK = Fore.YELLOW
SYSTEM = Fore.BLUE
ERROR = Fore.RED
INFO = Fore.MAGENTA
WARNING = Fore.LIGHTYELLOW_EX
RESET = ColoramaStyle.RESET_ALL
BOLD = ColoramaStyle.BRIGHT
class TextToSpeech:
def __init__(self):
self.engine = None
self.available = False
old_stderr = None
try:
old_stderr = os.dup(2)
null_fd = os.open('/dev/null', os.O_WRONLY)
os.dup2(null_fd, 2)
os.close(null_fd)
self.engine = pyttsx3.init()
self.engine.setProperty('rate', 150)
voices = self.engine.getProperty('voices')
chinese_voice = None
for voice in voices:
try:
voice_name = str(voice.name).lower() if hasattr(voice, 'name') else ''
lang_attr = ''
if hasattr(voice, 'language'):
lang_attr = str(voice.language).lower()
elif hasattr(voice, 'languages'):
lang_attr = str(voice.languages[0]).lower() if voice.languages else ''
if 'chinese' in voice_name or 'zh' in lang_attr or 'mandarin' in voice_name:
if 'mandarin' in voice_name and 'pinyin' in voice_name:
chinese_voice = voice
break
elif chinese_voice is None:
chinese_voice = voice
except Exception:
continue
if chinese_voice:
self.engine.setProperty('voice', chinese_voice.id)
print(f"{Colors.INFO}🔊 已选择语音: {chinese_voice.name}{Colors.RESET}")
try:
self.engine.say("测试")
self.engine.runAndWait()
self.available = True
except Exception:
self.available = False
except Exception:
self.available = False
finally:
if old_stderr is not None:
os.dup2(old_stderr, 2)
os.close(old_stderr)
def speak(self, text: str):
if not self.available or not self.engine:
return
old_stderr = None
try:
old_stderr = os.dup(2)
null_fd = os.open('/dev/null', os.O_WRONLY)
os.dup2(null_fd, 2)
os.close(null_fd)
self.engine.say(text)
self.engine.runAndWait()
except Exception:
pass
finally:
if old_stderr is not None:
os.dup2(old_stderr, 2)
os.close(old_stderr)
class StreamingMessage:
def __init__(self):
self.content = ""
self.is_active = False
def add_chunk(self, chunk: str):
self.content += chunk
self.is_active = True
def get_content(self) -> str:
return self.content
def clear(self):
self.content = ""
self.is_active = False
class WebSocketClient:
def __init__(self, uri: str):
self.uri = uri
self.websocket = None
self.connected = False
self.streaming_messages: Dict[str, StreamingMessage] = {
'chat': StreamingMessage(),
'tasks': {}
}
self.running = True
async def connect(self):
try:
self.websocket = await websockets.connect(self.uri)
self.connected = True
return True
except Exception as e:
print(f"{Colors.ERROR}❌ 连接失败: {e}{Colors.RESET}")
return False
async def disconnect(self):
if self.websocket:
await self.websocket.close()
self.connected = False
async def send_message(self, message: str):
if not self.websocket or not self.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
return False
try:
if message.startswith('/'):
data = {'type': 'command', 'command': message}
else:
data = {'type': 'message', 'content': message}
await self.websocket.send(json.dumps(data))
return True
except Exception as e:
print(f"{Colors.ERROR}❌ 发送失败: {e}{Colors.RESET}")
return False
async def receive_messages(self, tts: TextToSpeech):
try:
async for message in self.websocket:
try:
data = json.loads(message)
await self.handle_message(data, tts)
except json.JSONDecodeError:
await self.handle_stream_chunk(message, 'chat', tts)
except websockets.exceptions.ConnectionClosed:
self.connected = False
print(f"\n{Colors.ERROR}❌ 连接已断开{Colors.RESET}")
except Exception as e:
print(f"\n{Colors.ERROR}❌ 接收消息错误: {e}{Colors.RESET}")
async def handle_message(self, data: dict, tts: TextToSpeech):
msg_type = data.get('type', '')
content = data.get('content', '')
if msg_type == 'chunk':
await self.handle_stream_chunk(content, 'chat', tts)
elif msg_type == 'complete':
await self.handle_stream_complete('chat', tts)
elif msg_type == 'dialogue_ended':
await self.handle_dialogue_ended(tts)
elif msg_type == 'task_process':
msg_subtype = data.get('msg_type', '')
task_content = data.get('content', '')
task_id = data.get('task_id', 'unknown')
execution_id = data.get('execution_id', 'unknown')
task_title = data.get('task_title', '未知任务')
if msg_subtype == 'chunk':
await self.handle_stream_chunk(
task_content, 'task', tts,
{'task_id': task_id, 'execution_id': execution_id, 'title': task_title}
)
elif msg_subtype == 'complete':
await self.handle_stream_complete('task', tts, execution_id)
elif msg_type == 'task_status':
task_id = data.get('task_id', 'unknown')
title = data.get('title', '未知')
status = data.get('status', 'unknown')
message = data.get('message', content)
status_icon = {
'executing': '⚙️', 'complete': '✅', 'failed': '❌',
'added': '➕', 'batch_start': '🚀', 'batch_complete': '🏁'
}.get(status, '📋')
print(f"{Colors.TASK}{status_icon} [任务 {task_id}] {title}: {message}{Colors.RESET}")
elif msg_type == 'task_error':
task_id = data.get('task_id', 'unknown')
execution_id = data.get('execution_id', 'unknown')
task_title = data.get('task_title', '未知')
error = data.get('error', '未知错误')
print(f"{Colors.ERROR}❌ [任务 {task_id}] {task_title}: {error}{Colors.RESET}")
if tts:
tts.speak(f"任务 {task_id} 出错了,错误是 {error}")
if execution_id:
await self.handle_stream_complete('task', tts, execution_id)
elif msg_type == 'error':
print(f"{Colors.ERROR}❌ {content}{Colors.RESET}")
if tts:
tts.speak(f"错误,{content}")
elif msg_type == 'info':
print(f"{Colors.INFO}ℹ️ {content}{Colors.RESET}")
if tts:
tts.speak(content)
elif msg_type == 'command_result':
print(f"{Colors.SYSTEM}📋 {content}{Colors.RESET}")
elif msg_type == 'connection_ack':
print(f"{Colors.AI}✅ {content}{Colors.RESET}")
elif msg_type == 'notification':
print(f"{Colors.INFO}🔔 {content}{Colors.RESET}")
if tts:
tts.speak(content)
else:
if content:
await self.handle_stream_chunk(content, 'chat', tts)
async def handle_stream_chunk(self, content: str, msg_type: str = 'chat',
tts: TextToSpeech = None, task_info: dict = None):
if msg_type == 'chat':
stream = self.streaming_messages['chat']
stream.add_chunk(content)
if not stream.is_active:
print(f"\n{Colors.AI}🤖 AI助手: ", end='', flush=True)
stream.is_active = True
print(content, end='', flush=True)
elif msg_type == 'task' and task_info:
execution_id = task_info.get('execution_id', 'unknown')
task_id = task_info.get('task_id', 'unknown')
title = task_info.get('title', '未知任务')
if execution_id not in self.streaming_messages['tasks']:
self.streaming_messages['tasks'][execution_id] = StreamingMessage()
print(f"\n{Colors.TASK}⚡ 定时任务 #{task_id} - {title}{Colors.RESET}")
print(f"{Colors.TASK}📋 ", end='', flush=True)
stream = self.streaming_messages['tasks'][execution_id]
stream.add_chunk(content)
print(content, end='', flush=True)
async def handle_stream_complete(self, msg_type: str = 'chat',
tts: TextToSpeech = None, execution_id: str = None):
if msg_type == 'chat':
stream = self.streaming_messages['chat']
print()
if tts and getattr(tts, 'available', True) and stream.content:
tts.speak(stream.content)
stream.clear()
elif msg_type == 'task' and execution_id:
if execution_id in self.streaming_messages['tasks']:
print()
del self.streaming_messages['tasks'][execution_id]
async def handle_dialogue_ended(self, tts: TextToSpeech):
print(f"\n{Colors.SYSTEM}✅ 对话已结束,可以开始新对话{Colors.RESET}")
if tts:
tts.speak("对话已结束")
self.streaming_messages['chat'].clear()
self.streaming_messages['tasks'].clear()
class VoiceInterface:
def __init__(self):
self.client: Optional[WebSocketClient] = None
self.tts = None
self.recognizer = None
self.microphone = None
self.whisper_model = None
self.has_audio = False
self.has_tts = False
self.has_microphone = False
try:
self.tts = TextToSpeech()
self.has_tts = self.tts.available
if self.has_tts:
print(f"{Colors.INFO}🔊 语音输出已就绪{Colors.RESET}")
else:
print(f"{Colors.WARNING}⚠️ 语音输出不可用{Colors.RESET}")
except Exception as e:
self.tts = None
self.has_tts = False
print(f"{Colors.WARNING}⚠️ 语音输出不可用: {e}{Colors.RESET}")
try:
self.recognizer = sr.Recognizer()
print(f"{Colors.INFO}🎤 尝试检测音频设备...{Colors.RESET}")
import subprocess
import tempfile
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
test_file = f.name
result = subprocess.run(
['arecord', '-f', 'cd', '-d', '1', test_file],
capture_output=True, text=True
)
os.unlink(test_file)
if result.returncode == 0:
print(f"{Colors.INFO}🎤 arecord 检测到设备,将使用 arecord 录音{Colors.RESET}")
self.has_microphone = True
self.use_arecord = True
print(f"{Colors.INFO}📥 加载 Whisper 模型(离线语音识别)...{Colors.RESET}")
try:
import whisper
self.whisper_model = whisper.load_model("medium", device="cpu")
print(f"{Colors.INFO}✅ Whisper 模型加载成功 (medium 模型){Colors.RESET}")
except Exception as e:
print(f"{Colors.WARNING}⚠️ Whisper 模型加载失败: {e}{Colors.RESET}")
self.whisper_model = None
print(f"{Colors.INFO}🎤 麦克风已就绪{Colors.RESET}")
else:
devices = sr.Microphone.list_microphone_names()
if devices:
print(f"{Colors.INFO}🎤 检测到 {len(devices)} 个音频设备{Colors.RESET}")
self.microphone = sr.Microphone()
with self.microphone as source:
self.recognizer.adjust_for_ambient_noise(source, duration=0.1)
self.has_microphone = True
self.use_arecord = False
print(f"{Colors.INFO}🎤 麦克风已就绪 (使用 speech_recognition){Colors.RESET}")
else:
raise RuntimeError("没有检测到音频设备")
except Exception as e:
print(f"{Colors.WARNING}⚠️ 麦克风不可用: {e}{Colors.RESET}")
self.has_microphone = False
self.use_arecord = False
self.has_audio = self.has_tts or self.has_microphone
if not self.has_audio:
print(f"{Colors.INFO}ℹ️ 将以纯文本模式运行{Colors.RESET}")
self.commands = {
'/help': '显示帮助信息',
'/connect': '连接到服务器',
'/disconnect': '断开连接',
'/quit': '退出程序',
'/clear': '清屏',
'/status': '显示连接状态',
'/list': '列出可用命令',
'/memories': '查看记忆',
'/memos': '查看备忘录',
'/check': '检查提醒',
'/config': '查看配置',
'/new': '新建对话',
'/tasks': '查看任务列表',
}
def print_banner(self):
banner = f"""
{Colors.BOLD}{Colors.AI}
╔══════════════════════════════════════════╗
║ AI智能助手 - 语音前端 ║
║ WebSocket 直连版本 ║
╚══════════════════════════════════════════╝
{Colors.RESET}
"""
print(banner)
print(f"{Colors.INFO}输入 /help 查看可用命令")
print(f"输入语音开始对话,或输入文本命令{Colors.RESET}")
print()
def listen_voice(self) -> Optional[str]:
"""监听语音输入"""
if not self.has_microphone:
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请使用文本输入{Colors.RESET}")
return None
if not self.client or not self.client.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器,请先连接{Colors.RESET}")
return None
print(f"{Colors.INFO}🎤 正在听... (按 Ctrl+C 取消){Colors.RESET}")
try:
if self.use_arecord:
import subprocess
import tempfile
import wave
import numpy as np
import whisper
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f:
temp_file = f.name
result = subprocess.run(
['arecord', '-f', 'cd', '-d', '5', temp_file],
capture_output=True, text=True,
timeout=10
)
if result.returncode != 0:
print(f"{Colors.ERROR}❌ 录音失败: {result.stderr}{Colors.RESET}")
if os.path.exists(temp_file):
os.unlink(temp_file)
return None
if not os.path.exists(temp_file) or os.path.getsize(temp_file) == 0:
print(f"{Colors.WARNING}⚠️ 录音文件为空{Colors.RESET}")
if os.path.exists(temp_file):
os.unlink(temp_file)
return None
if self.whisper_model:
with wave.open(temp_file, 'rb') as wav_file:
frames = wav_file.readframes(wav_file.getnframes())
audio_data = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
os.unlink(temp_file)
audio_padded = whisper.pad_or_trim(audio_data)
mel = whisper.log_mel_spectrogram(audio_padded, n_mels=80)
options = whisper.DecodingOptions(language='zh', without_timestamps=True)
result = whisper.decode(self.whisper_model, mel, options)
text = result.text.strip()
if text:
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
else:
print(f"{Colors.INFO}⚠️ 未识别到语音内容{Colors.RESET}")
return None
else:
with sr.AudioFile(temp_file) as source:
audio = self.recognizer.record(source)
os.unlink(temp_file)
text = self.recognizer.recognize_google(audio, language='zh-CN')
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
else:
with self.microphone as source:
self.recognizer.adjust_for_ambient_noise(source)
audio = self.recognizer.listen(source, timeout=15)
text = self.recognizer.recognize_google(audio, language='zh-CN')
print(f"{Colors.USER}👤 语音输入: {text}{Colors.RESET}")
return text
except sr.WaitTimeoutError:
print(f"{Colors.INFO}⏱️ 等待超时,请重试{Colors.RESET}")
return None
except sr.UnknownValueError:
print(f"{Colors.INFO}😕 无法识别语音,请重试{Colors.RESET}")
return None
except sr.RequestError as e:
print(f"{Colors.ERROR}❌ 语音识别服务错误: {e}{Colors.RESET}")
return None
except Exception as e:
print(f"{Colors.ERROR}❌ 语音输入错误: {e}{Colors.RESET}")
return None
async def handle_command(self, command: str):
parts = command.split()
cmd = parts[0].lower()
if cmd == '/help':
self.show_help()
elif cmd == '/connect':
if len(parts) < 2:
print(f"{Colors.ERROR}用法: /connect ws://host:port{Colors.RESET}")
return
uri = parts[1]
if self.client and self.client.connected:
await self.client.disconnect()
self.client = WebSocketClient(uri)
if await self.client.connect():
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak(f"已连接到 {uri}")
asyncio.create_task(self.client.receive_messages(self.tts))
else:
self.client = None
elif cmd == '/disconnect':
if self.client:
await self.client.disconnect()
self.client = None
print(f"{Colors.SYSTEM}已断开连接{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak("已断开连接")
else:
print(f"{Colors.ERROR}未连接到服务器{Colors.RESET}")
elif cmd == '/status':
if self.client and self.client.connected:
print(f"{Colors.AI}✅ 已连接: {self.client.uri}{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak(f"已连接到 {self.client.uri}")
else:
print(f"{Colors.SYSTEM}未连接{Colors.RESET}")
if self.tts and self.has_tts:
self.tts.speak("未连接")
elif cmd == '/clear':
os.system('clear' if os.name == 'posix' else 'cls')
self.print_banner()
elif cmd == '/quit':
print(f"{Colors.SYSTEM}再见!{Colors.RESET}")
if self.tts:
self.tts.speak("再见")
if self.client:
await self.client.disconnect()
sys.exit(0)
elif cmd in self.commands:
if self.client and self.client.connected:
await self.client.send_message(command)
else:
print(f"{Colors.ERROR}未连接到服务器,请先使用 /connect 连接{Colors.RESET}")
else:
print(f"{Colors.ERROR}未知命令: {cmd}{Colors.RESET}")
def show_help(self):
help_text = f"""
{Colors.BOLD}可用命令:{Colors.RESET}
{Colors.USER}本地命令:{Colors.RESET}
/connect <url> 连接到WebSocket服务器
/disconnect 断开连接
/status 显示连接状态
/clear 清屏
/quit 退出程序
/help 显示此帮助
{Colors.AI}服务器命令 (需要先连接):{Colors.RESET}
"""
for cmd, desc in self.commands.items():
if cmd not in ['/help', '/connect', '/disconnect', '/quit', '/clear', '/status']:
help_text += f" {cmd:<15} {desc}\n"
print(help_text)
async def send_user_message(self, message: str):
if not self.client or not self.client.connected:
print(f"{Colors.ERROR}❌ 未连接到服务器{Colors.RESET}")
return
print(f"\n{Colors.USER}👤 用户: {message}{Colors.RESET}")
await self.client.send_message(message)
async def run(self):
self.print_banner()
prompt = f"{Colors.USER}>>> {Colors.RESET}"
while True:
try:
user_input = await asyncio.to_thread(
lambda: input(prompt)
)
user_input = user_input.strip()
if not user_input:
if self.has_microphone:
print(f"{Colors.INFO}🎤 开始语音输入...{Colors.RESET}")
voice_text = self.listen_voice()
if voice_text:
await self.send_user_message(voice_text)
else:
print(f"{Colors.WARNING}⚠️ 麦克风不可用,请输入文本{Colors.RESET}")
continue
if user_input.startswith('/'):
await self.handle_command(user_input)
else:
await self.send_user_message(user_input)
except KeyboardInterrupt:
print(f"\n{Colors.INFO}⏹️ 已取消{Colors.RESET}")
continue
except EOFError:
print(f"\n{Colors.SYSTEM}再见!{Colors.RESET}")
if self.tts:
self.tts.speak("再见")
if self.client:
await self.client.disconnect()
break
except Exception as e:
print(f"{Colors.ERROR}错误: {e}{Colors.RESET}")
async def main():
cli = VoiceInterface()
default_uri = "ws://localhost:8765"
uri = sys.argv[1] if len(sys.argv) > 1 else default_uri
cli.client = WebSocketClient(uri)
if await cli.client.connect():
print(f"{Colors.AI}✅ 已连接到 {uri}{Colors.RESET}")
if cli.tts and cli.has_tts:
cli.tts.speak(f"已连接到 {uri}")
asyncio.create_task(cli.client.receive_messages(cli.tts))
else:
print(f"{Colors.WARNING}⚠️ 无法连接到 {uri}{Colors.RESET}")
print(f"{Colors.INFO}ℹ️ 你可以通过命令行参数指定地址: python voice_client.py ws://your-server:8765{Colors.RESET}")
print(f"{Colors.INFO}ℹ️ 或者使用 /connect 命令手动连接{Colors.RESET}")
try:
await cli.run()
except asyncio.CancelledError:
pass
finally:
if cli.client:
await cli.client.disconnect()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print(f"\n{Colors.SYSTEM}程序已退出{Colors.RESET}")
+2
View File
@@ -0,0 +1,2 @@
# 数据库备份输出目录
# 容器内备份会输出到此目录,可直接复制走
+38
View File
@@ -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
+21
View File
@@ -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.
+60
View File
@@ -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
+134
View File
@@ -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
@
```
+384
View File
@@ -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
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
export cjStackSize=64MB
export cjHeapSize=64MB
exec cjpm run --run-args="$@"
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
export cjHeapSize=16MB
cjpm update
exec cjpm build
+39
View File
@@ -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"]
+11
View File
@@ -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
+325
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
-- ADS1256 模数转换器 - SPI接口
function build(data)
end
function destroy(data)
end
function call(data)
end
+223
View File
@@ -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
+163
View File
@@ -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
+86
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
cjpm run --run-args="$($args -join ' ')"
+55
View File
@@ -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
@
+54
View File
@@ -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;
@
+63
View File
@@ -0,0 +1,63 @@
# 在 @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
- 45 - - - 45 - - - - - 100;
90 - - - 90 - - - - - - 50; # stand
#
- 0 - - - 0 - - - - - 50;
- - - 45 - - - 45 - - - 100;
45 - - - 45 - - - - - - 10;
- - 0 - - - 0 - - - - 50;
#
- - - 0 - - - 0 - - - 50;
- 45 - - - 45 - - - - - 100;
- - 45 - - - 45 - - - - 10;
- 0 - - - 0 - - - - - 50;
@
Loaded 100 of 606 files, more files were not shown because too many files have changed in this diff. Show more