diff --git a/.gitignore b/.gitignore index 00741cb06..5faf2630e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,17 @@ .DS_Store -.idea/ \ No newline at end of file +.idea/ +firmware/main/apps/app_agent_tasks/agent_tasks_local_defaults.h +firmware/build-host-tests/ + +# 本机网关环境、运行记录和构建缓存 +.env +.env.* +!.env.example +__pycache__/ +*.pyc +.venv/ +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +captures/ +/user-defaults.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..db1569158 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# StackChan 官方固件与 Agent 看板 + +本项目以 `m5stack/StackChan` 为固件基础。`firmware/` 保持官方 ESP-IDF 6.1、Mooncake、HAL 与 Setup 架构;`gateway/` 是来自 `kisaragi-mochi/stackchan-mcp` 的独立 Python 进程,保留上游许可证。 + +## 架构与检查 + +- Codex/Claude 适配器经 MCP 写入网关快照;设备每秒通过 Wi-Fi HTTP 读取 `/agent-dashboard`。 +- 网关其它 WebSocket 设备控制功能依赖其上游替代固件,不代表本项目官方固件支持这些功能。 +- 网关检查:`cd gateway && uv run --locked pytest && uv run --locked ruff check .`。 +- 固件宿主检查:`cmake -S firmware/tests -B firmware/build-host-tests && cmake --build firmware/build-host-tests && ctest --test-dir firmware/build-host-tests --output-on-failure`。 +- 固件构建、设备配置及已验收范围见 `docs/agent-tasks-setup.zh-CN.md`、`docs/agent-tasks-validation.zh-CN.md`。 +- 面向单用户家庭局域网,优先验证实机主要路径;审查建议应友好,样式问题标为非阻断。 +- 不提交 `.env`、令牌、本机默认头文件、会话数据、设备备份或构建产物;日志不输出令牌或聊天正文。 + +## StackChan 待办同步 + +本机已安装 Agent 看板时,Codex 主会话在制定、调整、完成工作计划时同步短标题与状态。使用 `~/.local/share/stackchan-agent-dashboard/.venv/bin/python -m stackchan_mcp.agent_adapters.runner codex-plan`,通过 stdin 提交 JSON:`{"stage":"实现","plan":[{"step":"短任务标题","status":"in_progress"}]}`。会话 ID 使用 `CODEX_THREAD_ID`,最多 12 项,状态为 `pending`、`in_progress`、`completed`;缺少命令或会话 ID 时继续工作,不伪造 ID。 + +监视器负责合并计划与生命周期后经 MCP 上报;不要同时用完整快照覆盖同栏。子 Agent 不提交主栏计划。只提交短标题和状态,不上传提示词、正文、路径、工具参数或推理内容。 diff --git a/README.md b/README.md index 9cf3f7f8a..01f42bcc0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ +# StackChan · Agent 待办看板 + +基于 M5Stack 官方固件扩展:在独立 `AGENT TASKS` 菜单中,左侧显示 Codex、右侧显示 Claude 的状态、阶段与待办,顶部独立显示完成百分比,支持进行中优先排序、转圈图标和动态省略号。 + +- [中文安装与使用说明](docs/agent-tasks-setup.zh-CN.md) +- [验证记录与已知限制](docs/agent-tasks-validation.zh-CN.md) +- `firmware/`:官方 ESP-IDF / Mooncake 固件及看板 App。 +- `gateway/`:独立 Python MCP 网关与 Agent 适配器,读取本机会话状态,经 MCP 更新快照。 +- `scripts/`:macOS 常驻服务安装与本机固件默认配置脚本。 + +设备通过 Wi-Fi HTTP 拉取快照;网关的其它 WebSocket 设备控制功能面向其上游替代固件,本项目官方固件不提供那些设备控制功能。公开源码不含个人令牌。网关源码及第三方许可见 [gateway/LICENSE](gateway/LICENSE) 与 [gateway/LICENSE-THIRD-PARTY](gateway/LICENSE-THIRD-PARTY)。 + +![双栏看板预览(设计示意)](docs/design/agent-tasks-official-preview.png) + +## 让 Codex 和 Claude 在设备上显示进度 + +### 1. 启动共享网关 + +在 macOS 上安装 Python 3.12 和 `uv`,从仓库根目录运行以下命令,并将 IP 换成电脑当前的局域网 IPv4 地址: + +```bash +python3 scripts/install-agent-dashboard-macos.py --host-ip 192.168.1.20 +``` + +安装器会启动一个共享 MCP 网关,以及 Codex、Claude 的本机会话监视器。设备连接地址和令牌保存在 `~/.config/stackchan-agent-dashboard/设备连接信息.txt`。在 StackChan 上打开 `SETUP` → `Apps` → `Agent 看板`,填写其中的地址和令牌,测试并保存;设备需先连接同一局域网 Wi-Fi。详细的手动启动、网关地址变化和固件配置方法见[中文配置说明](docs/agent-tasks-setup.zh-CN.md)。 + +### 2. 提交 Codex 待办 + +Codex 监视器自动报告桌面主会话的工作状态;桌面目前没有可用的原生计划清单事件,因此 Codex 需要把当前计划显式提交给看板。使用主会话的 `CODEX_THREAD_ID`,通过仓库的 `AGENTS.md` 约定调用: + +用户可以先对 Codex 说: + +> 请把这项工作拆成不超过 12 条短待办,开始执行前同步计划;每当任务开始、完成或计划变化时,都更新状态并按本仓库 AGENTS.md 的 StackChan 待办同步约定提交完整清单。只同步任务标题和状态。 + +```bash +~/.local/share/stackchan-agent-dashboard/.venv/bin/python \ + -m stackchan_mcp.agent_adapters.runner codex-plan <<'JSON' +{"stage":"实现看板","plan":[{"step":"完成界面","status":"completed"},{"step":"验证设备显示","status":"in_progress"},{"step":"更新使用文档","status":"pending"}]} +JSON +``` + +每次提交的是该会话的完整待办清单,最多 12 项;状态使用 `pending`、`in_progress`、`completed`。计划变化时提交更新后的清单。不要使用子 Agent 的会话 ID。 + +### 3. 让 Claude 创建任务 + +Claude 监视器读取本机 Claude Code 主会话记录。Claude 成功执行 `TaskCreate`、`TaskUpdate` 或 `TodoWrite` 后,监视器会同步任务标题和状态;结束回合后保留最后清单。用户可以对 Claude 说: + +> 请先用任务清单把工作拆成不超过 12 项;执行时使用任务工具创建待办,并在开始、完成或调整任务时及时更新状态,这样我可以在 StackChan 上查看进度。 + +没有任务工具记录时,右栏仍会显示状态和阶段,但任务区为空。 + +### 设备显示内容 + +打开 Launcher 中的 `AGENT TASKS`:左栏为 Codex,右栏为 Claude。每栏展示 Agent 状态、当前阶段和最多 12 项任务,进行中的任务排在前面;栏标题右侧独立显示该 Agent 的整数完成百分比,计算方式为“已完成数 ÷ 当前清单总数”,无任务时显示 `--%`。工作时会显示转圈和动态省略号。网关或 Wi-Fi 暂时断开时,设备保留最近一次成功取得的快照。 + +看板只接收短阶段、任务标题、状态和会话生命周期,不上传提示词、对话正文、文件路径或工具参数。Codex 的原生计划事件与 Claude 新会话任务采集限制,以及设备验证记录,见[看板配置与验证文档](docs/agent-tasks-setup.zh-CN.md)和[验证记录](docs/agent-tasks-validation.zh-CN.md)。 + +以下保留官方项目介绍。 + # StackChan Open-Source diff --git a/docs/178-daemon-setup.md b/docs/178-daemon-setup.md new file mode 100644 index 000000000..2a8209430 --- /dev/null +++ b/docs/178-daemon-setup.md @@ -0,0 +1,151 @@ +# Issue #178 Daemon Setup + +The gateway now has two supported MCP server modes: + +- **stdio mode**: `stackchan-mcp` with no subcommand. This is the original + single-client mode used by existing MCP client configs. +- **daemon mode**: `stackchan-mcp serve --transport streamable-http`. This + starts one long-lived gateway process, keeps one ESP32 WebSocket connection, + and accepts multiple MCP clients over Streamable HTTP. + +Use daemon mode when more than one MCP client or automation needs to share the +same StackChan device. Keep stdio mode for simple local setups, compatibility +testing, and existing clients that do not yet support Streamable HTTP. + +## Start the daemon + +```bash +cd gateway +uv run stackchan-mcp serve --transport streamable-http +``` + +The daemon starts the same ESP32 WebSocket listener and capture server as stdio +mode, then exposes the MCP endpoint at: + +```text +http://127.0.0.1:8767/mcp +``` + +The zero-subcommand stdio entry point remains supported and unchanged: + +```bash +stackchan-mcp +``` + +`stackchan-mcp serve --transport stdio` is equivalent to the zero-subcommand +stdio path. + +## Environment variables + +| Variable | Default | Purpose | +|---|---:|---| +| `STACKCHAN_TOKEN` | unset | Bearer token shared with the ESP32 firmware and, when set, required by HTTP MCP clients. | +| `BEARER_TOKEN` | unset | Legacy alias used only when `STACKCHAN_TOKEN` is unset. | +| `MCP_HTTP_HOST` | `127.0.0.1` | Bind host for the Streamable HTTP MCP endpoint. | +| `MCP_HTTP_PORT` | `8767` | Bind port for the Streamable HTTP MCP endpoint. | +| `MCP_HTTP_ALLOWED_HOSTS` | unset | Comma-separated Host / Origin allowlist for non-loopback clients, for example `192.168.1.10,stackchan.local:8767`. | +| `STACKCHAN_COMMAND_QUEUE_SIZE` | `32` | Maximum queued ESP32-bound tool calls before the daemon returns backpressure. | + +The existing gateway variables (`HOST`, `WS_PORT`, `CAPTURE_PORT`, +`VISION_HOST`, `VISION_URL`, `VISION_TOKEN`, and audio hook settings) keep the +same meaning in daemon mode. + +## Bind safety + +Loopback binds (`127.0.0.1`, `::1`, or `localhost`) may run without an HTTP +bearer token for local development. + +Non-loopback binds, such as `0.0.0.0` or a LAN IP address, require +`STACKCHAN_TOKEN` or `BEARER_TOKEN`. Startup is refused otherwise: + +```text +stackchan-mcp: refusing non-loopback MCP_HTTP_HOST without STACKCHAN_TOKEN or BEARER_TOKEN +``` + +When a token is configured, every request to `/mcp` and `/status` must include: + +```text +Authorization: Bearer +``` + +`/healthz` is intentionally unauthenticated so local process monitors can check +whether the daemon is alive. Host and Origin headers are still validated on all +daemon endpoints. + +For wildcard binds such as `MCP_HTTP_HOST=0.0.0.0`, loopback Host values +(`127.0.0.1`, `localhost`, `::1`) are accepted by default. LAN clients usually +send the machine's LAN IP or DNS name in the `Host` header, so add those values +to `MCP_HTTP_ALLOWED_HOSTS`: + +```bash +STACKCHAN_TOKEN=your-secret-token-here \ +MCP_HTTP_HOST=0.0.0.0 \ +MCP_HTTP_ALLOWED_HOSTS=192.168.1.10,stackchan.local:8767 \ +stackchan-mcp serve --transport streamable-http +``` + +## MCP client configuration + +Use a Streamable HTTP MCP client pointed at `/mcp`. Example +`mcp.config.json`: + +```json +{ + "mcpServers": { + "stackchan-mcp": { + "type": "streamable-http", + "url": "http://127.0.0.1:8767/mcp", + "headers": { + "Authorization": "Bearer your-secret-token-here" + } + } + } +} +``` + +If no `STACKCHAN_TOKEN` or `BEARER_TOKEN` is configured and the daemon is bound +to loopback, omit the `headers` block. + +## Operational endpoints + +`GET /healthz` returns daemon-local health: + +```json +{ + "ok": true, + "esp32_connected": true, + "queue_depth": 0, + "queue_capacity": 32, + "owner_id": "stackchan-mcp-1234abcd" +} +``` + +`GET /status` returns the same gateway status as the `get_status` MCP tool, +plus `queue_depth`, `queue_capacity`, and `connected_clients`. + +## Migration notes + +Existing stdio configs do not need to change. The following remains valid: + +```json +{ + "mcpServers": { + "stackchan-mcp": { + "type": "stdio", + "command": "stackchan-mcp" + } + } +} +``` + +To migrate, start the daemon as an independent process first, then point each +Streamable HTTP-capable MCP client at `http://127.0.0.1:8767/mcp`. Once a +client uses the daemon, it should not also spawn `stackchan-mcp` in stdio mode +for the same device, because the daemon is the process that owns the ESP32 +WebSocket. + +The daemon keeps a single bounded FIFO command queue for ESP32-bound tool +calls. `get_status` bypasses the queue because it reads gateway-local state. +When the queue is full, the daemon returns a JSON-RPC error with code `-32000` +and message `stackchan command queue is full`; it does not buffer commands +without a limit. diff --git a/docs/agent-tasks-setup.zh-CN.md b/docs/agent-tasks-setup.zh-CN.md new file mode 100644 index 000000000..0eb94691a --- /dev/null +++ b/docs/agent-tasks-setup.zh-CN.md @@ -0,0 +1,137 @@ +# Agent 待办看板配置说明 + +看板左栏显示 Codex,右栏显示 Claude。StackChan 通过 HTTP 读取 `stackchan-mcp` 网关的状态快照;网关地址和 Bearer 令牌在设备设置页配置。界面显示状态、阶段和任务标题,不显示提示词、对话正文、文件路径或工具参数。 + +## 启动共享网关 + +本仓库 `gateway/` 包含已扩展的独立 Python MCP 网关、Codex/Claude 适配器与测试。上游为 [kisaragi-mochi/stackchan-mcp](https://github.com/kisaragi-mochi/stackchan-mcp);请从本仓库安装,PyPI 的上游版本未必包含看板接口。 + +### macOS 自动启动 + +先安装 Python 3.12 与 `uv`,在本仓库根目录执行(替换为电脑实际局域网 IPv4): + +```bash +python3 scripts/install-agent-dashboard-macos.py --host-ip 192.168.1.20 +``` + +安装器依据 `gateway/uv.lock` 安装到 `~/.local/share/stackchan-agent-dashboard/.venv`,注册网关、Codex、Claude 三个 LaunchAgent。随机令牌保存在 `~/.config/stackchan-agent-dashboard/设备连接信息.txt`(仅本人可读写);重新安装保留令牌。日志在 `~/Library/Logs/StackChanAgentDashboard/`。修改源码或电脑 IP 后重新运行安装器,更新设备配置。 + +安装器不修改 Codex 或 Claude 的全局配置,也不发起模型请求。停止服务: + +```bash +launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.codex +launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.claude +launchctl bootout gui/$(id -u)/io.stackchan.agent-dashboard.gateway +``` + +如需永久取消登录启动,再移除对应的 `~/Library/LaunchAgents/io.stackchan.agent-dashboard.*.plist`。 + +### 手动启动 + +在仓库 `gateway/` 中执行以下命令(适用于 zsh;令牌需提前生成并在设备与客户端保持一致): + +```bash +cd gateway +export MCP_HTTP_HOST=0.0.0.0 MCP_HTTP_PORT=8767 +export MCP_HTTP_ALLOWED_HOSTS='192.168.1.20' +export HOST=127.0.0.1 WS_PORT=18765 CAPTURE_PORT=18766 +read -s 'STACKCHAN_TOKEN?输入网关令牌:' +export STACKCHAN_TOKEN +uv run --locked stackchan-mcp serve --transport streamable-http --no-mdns +``` + +Codex 和 Claude Code 应连接同一个网关的 `http://<电脑局域网地址>:8767/mcp`,并使用相同 Bearer 令牌。StackChan 使用 `http://<电脑局域网地址>:8767` 作为基础地址,读取受令牌保护的 `GET /agent-dashboard`。不要把网关暴露到公网,也不要将令牌提交到仓库。 + +## 配置 StackChan + +公开源码不包含个人网关地址或令牌。设备连上 Wi-Fi 后,在 Launcher 的 `SETUP` → `Apps` → `Agent 看板` 中分别设置地址和访问令牌,点“测试”确认连接,再点“保存”。地址只填写基础 URL,例如 `http://192.168.1.20:8767`,不要附加 `/mcp` 或 `/agent-dashboard`。令牌必须与网关的 `STACKCHAN_TOKEN` 相同。已有个人定制固件也可能预置本机默认值;默认值不会随本仓库发布。 + + +设备应与网关处于可互相访问的网络。`localhost` 指设备本身,不能用来连接电脑。网关快照端点为 `GET /agent-dashboard`,须带相同 Bearer 令牌。 + +### 可选:生成本机固件默认值 + +macOS 安装器执行后,可在本仓库根目录运行: + +```bash +python3 scripts/configure-agent-dashboard-firmware.py +``` + +脚本读取本机配置,生成权限为 `0600` 的 `firmware/main/apps/app_agent_tasks/agent_tasks_local_defaults.h`,不打印令牌。该文件被 Git 忽略;随后重新构建的个人固件会回填空配置,保留 NVS 中已有非空值。没有此文件也可正常构建,通过设备设置页配置即可。含默认令牌的个人固件也应仅供本人使用。 + +## 状态和待办数据 + +每栏顶部名称右侧以小号数字显示该 Agent 的完成百分比,例如 `67%`;按当前同步清单中的已完成任务数除以任务总数计算,四舍五入为整数,进行中不计为已完成。没有任务时显示 `--%`。Codex 与 Claude 独立计算,百分比不代表模型内部运行进度。 + +每栏按“进行中 → 待办 → 已完成”稳定排序,同状态保持来源顺序。Claude 的任务超过 12 项时,先排序再选择可见任务,避免正在执行的任务被截掉。工作且在线时显示转圈、动态省略号与进行中标记轻微闪动;等待输入时显示动态省略号;完成、离线或过期后停止动画。 + + +Agent 通过 MCP 工具 `agent_update_snapshot` 更新自己的状态。任务按 pending、in_progress、completed 显示;Agent 状态包括 idle、working、waiting、completed、failed。网关在 45 秒未收到心跳后将来源标记为过期,仍保留最后任务。 + +Codex Desktop rollout 监视器是可选的本机实验适配器。它仅处理 `originator` 明确为 `Codex Desktop` 的 rollout,读取回合开始、完成、中断事件,不读消息正文。启动方式: + +```bash +cd /path/to/StackChan/gateway +export STACKCHAN_MCP_URL=http://127.0.0.1:8767/mcp +read -s 'STACKCHAN_TOKEN?输入与网关相同的令牌:' +export STACKCHAN_TOKEN +uv run python -m stackchan_mcp.agent_adapters.runner codex-watch +``` + +已检查的真实 Codex Desktop rollout 含 `task_started`、`task_complete`、`turn_aborted` 事件,但未包含计划清单。适配器自动汇报工作状态;待办使用下面的显式计划入口。`CODEX_SESSIONS_DIR` 可指定其他本机会话目录。使用 macOS 安装器后无需重复启动该监视器。 + +### Codex 计划同步 + +本仓库 AGENTS.md 已要求 Codex 主会话在计划变化时同步待办。其他项目可把同一段“StackChan 待办同步”说明加入自己的 AGENTS.md。以下命令应从需要展示的 Codex 主会话执行,自动采用该会话的 `CODEX_THREAD_ID`: + +```bash +~/.local/share/stackchan-agent-dashboard/.venv/bin/python \ + -m stackchan_mcp.agent_adapters.runner codex-plan <<'JSON' +{"stage":"设备验证","plan":[{"step":"完成固件构建","status":"completed"},{"step":"验证实机显示","status":"in_progress"},{"step":"验证蓝牙连接","status":"pending"}]} +JSON +``` + +每次提交替换此会话的完整待办,最多 12 项;空列表清空。也可显式传 `session_id`,必须是需要展示的真实 Codex 主会话 ID。计划在本机以仅用户可读写的文件原子保存,监视器约 0.5 秒内读取并经 MCP 推送;设备再按 1 秒周期拉取。计划与自动生命周期使用同一个上报来源,心跳不会清空任务;其他会话不继承此清单。已结束回合显示“已完成”,未完成的任务仍按原状态保留。 + +这依赖 Codex 按项目说明主动同步计划,不是从聊天正文猜测任务,也不是已验证的桌面原生计划事件订阅。 + +Claude Code 使用本地 transcript 增量监视器,无需安装 Hook: + +```bash +cd /path/to/StackChan/gateway +export STACKCHAN_MCP_URL=http://127.0.0.1:8767/mcp +# STACKCHAN_TOKEN 与网关一致 +uv run python -m stackchan_mcp.agent_adapters.runner claude-watch +``` + +默认读取 `~/.claude/projects/*/*.jsonl`,可用 `CLAUDE_PROJECTS_DIR` 改目录。只展示最近活动的主会话,排除子 Agent;成功的 TaskCreate、TaskUpdate、TodoWrite 结果更新任务,结束回合保留清单。仅读取任务标题、状态和生命周期,不上传聊天正文、任务描述或工具参数。没有创建任务的会话显示“暂无待办任务”。`online` 表示监视器心跳正常,回放的已结束会话仍显示“已完成”,不代表模型正在执行。 + +Claude 的既有会话回放已通过监视器 → MCP → HTTP 验证,包括 12 项任务。请使用自己已安装的 Claude Code 正常工作;新会话需实际调用任务工具后才会有待办。新 Claude 会话实时任务生成仍待单独验收。使用 macOS 安装器后无需重复启动 transcript 监视器。 + +每栏采用完整快照;请为一个 Agent 只选择一个上报来源。若会话需手动调用 `agent_update_snapshot`,先停止对应监视器,以免监视器的下一次心跳覆盖手动待办。`claude-hook` 是保留的实验入口,当前推荐 transcript 方案。 + +## 验证与限制 + +- 网关需保持运行;Agent 和设备须能访问同一个服务。 +- Codex 状态自动采集;待办由 Codex 显式计划同步入口提供,桌面原生计划事件仍未观察到。 +- Claude 没有任务工具记录时,右栏清单为空;不会用旧会话任务填充新会话。 +- StackChan 蓝牙广播不用于此功能;设备使用 Wi-Fi HTTP 读取看板。 +- 网关或 Wi-Fi 暂时不可用时,设备保留最近一次成功取得的快照。 + +## 官方固件构建 + +使用本仓库 `firmware/` 的官方固件,目标为 ESP-IDF 6.1、BOARD_TYPE_M5STACK_STACK_CHAN、Quad PSRAM。先加载 ESP-IDF 6.1 环境,再执行: + +```bash +cd firmware +python3 fetch_repos.py +idf.py build +``` + +生成 `firmware/build/stack-chan.bin`。不要使用 stackchan-mcp 上游仓库中的替代固件。 + +应用镜像写入 `0x20000`,大小必须小于 `0x4f0000`;常规刷写保留 NVS、分区表和 otadata。设备配置使用独立 NVS 命名空间 agent_tasks。若复位日志仍显示 DOWNLOAD(USB/UART0),须松开下载按键并通过实体电源键关机再开机;镜像校验成功不代表已启动。 + +看板使用 SIL OFL 思源黑体衍生的 14 px 常用中文字库,支持 GB2312 常用字符。罕见汉字和 Emoji 建议改写为常用中英文。 + +界面布局预览(示例任务,非设备实拍):![官方 App 看板预览](design/agent-tasks-official-preview.png) diff --git a/docs/agent-tasks-source.zh-CN.md b/docs/agent-tasks-source.zh-CN.md new file mode 100644 index 000000000..ebf0eb848 --- /dev/null +++ b/docs/agent-tasks-source.zh-CN.md @@ -0,0 +1,9 @@ +# 来源与整合说明 + +此项目以 [M5Stack/StackChan](https://github.com/m5stack/StackChan) 的 `28b9f0c8bd6acd81cb3c70f1a93c3e373b39d400` 为基础,增加官方 Mooncake Agent Tasks 应用。 + +`gateway/` 来自 [kisaragi-mochi/stackchan-mcp](https://github.com/kisaragi-mochi/stackchan-mcp),附带本项目的 Agent 快照 HTTP 接口、Codex/Claude 适配器及回归测试。网关保持独立 Python 进程,不与固件静态链接。上游许可证完整保留在 `gateway/LICENSE` 与 `gateway/LICENSE-THIRD-PARTY`。 + +本次整合不包含上游替代固件的 SCServo_lib 文件,也不以替代固件覆盖官方固件。早期替代固件实验与原网关工作区保存在本机整合前备份中;当前发布只保留官方固件实施路径。 + +`scripts/install-agent-dashboard-macos.py` 将当前源码安装到独立运行目录;`scripts/configure-agent-dashboard-firmware.py` 可从本机服务配置生成被 Git 忽略的个人默认头文件。仓库不包含个人令牌、会话记录、设备备份、编译产物或平台虚拟环境。 diff --git a/docs/agent-tasks-validation.zh-CN.md b/docs/agent-tasks-validation.zh-CN.md new file mode 100644 index 000000000..caf00705c --- /dev/null +++ b/docs/agent-tasks-validation.zh-CN.md @@ -0,0 +1,36 @@ +# Agent 待办看板验证记录 + +## 已完成验证 + +- 基于官方 m5stack/StackChan 1.6.2 新增 Mooncake Agent Tasks App,目标板为 M5STACK_STACK_CHAN、Quad PSRAM。 +- 官方 ESP-IDF 6.1 构建成功,应用镜像小于 0x4f0000 分区限制。 +- 网关测试 914 passed、5 skipped,Ruff 通过;官方固件宿主 CTest 2/2 通过。 +- MCP ClientSession、Codex Desktop 监视器和 Claude transcript 监视器均已验证能经 MCP 更新快照,并由 HTTP 接口按 Codex、Claude 顺序返回。 +- Codex 显式计划、Claude 最多 12 项任务、心跳续期和过期行为均已通过网关侧验证。 +- StackChan 已刷入新固件并正常启动。用户确认 AGENT TASKS 双栏任务和动画正常,默认连接配置已在本机固件完成实机验收。 +- 中文字体支持 ASCII 与 GB2312 常用字符;字体及许可随源码附带。 + +## 实机剩余项 + +用户已确认设备正常进入头像或菜单,之后在 AGENT TASKS 中看到双栏任务与动画。该确认覆盖基本实机路径;滚动、返回头像和网关断线恢复尚未单独验收。 + +## 2026-11-12 默认配置、排序与动画更新 + +- 最终本机镜像为 4,292,768 字节,应用分区余量 17%;刷写与独立校验通过,未改写 NVS、otadata 或分区表。 +- 默认配置通过 NVS 替身验证:缺少配置时使用本机默认值并持久化,已有非空值保留。本机默认配置文件由 Git 忽略,不包含在公开源码中。 +- 双栏快照稳定排序回归测试与任务更新后重新排序通过;Claude 第十三项任务变为进行中时能进入可见清单。生产快照确认两栏状态顺序正确。 +- 网关完整测试:914 passed、5 skipped;Ruff 通过。官方宿主 CTest 2/2 通过。 +- 用户在新固件打开 AGENT TASKS 后明确确认“双栏任务和动画正常”,默认连接与动画实机验收通过。动画预览 GIF 为设计示意,不是实机录像。 +- 实机验收通过双栏、任务排序、默认连接和动画;滚动、返回头像与网关断线恢复仍待单独检查。 + + +## 2026-11-12 首页上滑修复与顶部独立百分比 + +- 定位 AI AGENT 任务在 LVGL 全局锁内休眠 100 ms,改为不阻塞触摸处理的头像更新节流;导航约每 20 ms 检测。 +- 公共底边手势起触范围从 20 px 扩到 40 px,向上位移至少 40 px 即可单次触发,清理首页按钮的过期隐藏标志。 +- 看板导航与数据更新分开(约 33 ms / 500 ms),只在实际改变时设置标记透明度。 +- 首次优化固件刷入后,用户明确确认“返回顺畅,百分比显示正常”。 +- 最终按用户要求将 Codex / Claude 完成度分别放到顶部名称右侧,仅显示小号整数百分比;取消整体底栏与进度条,恢复任务列表高度。无任务显示 `--%`,比例按各自当前同步清单计算。 +- 宿主 CTest 3/3 通过,覆盖比例、取整、空任务、上滑与误触过滤;ESP-IDF 6.1 构建通过。 +- 最终本机镜像 4,293,696 字节,应用分区余量 17%;SHA-256 为 `de4c4f70349cedbc5cc264e946d426b1c80cad80aa42b1363e7c3062dee0945c`。仅写入 `0x20000`,写入校验与独立 verify-flash digest matched 均通过。 +- 刷写前已备份 NVS / OTA 元数据到仓库外私有文件;未改写 NVS、OTA 启动元数据、分区表与 assets。顶部各 Agent 数字排版仍待用户单独确认。 diff --git a/docs/design/agent-tasks-animation-preview.gif b/docs/design/agent-tasks-animation-preview.gif new file mode 100644 index 000000000..e495ac9e8 Binary files /dev/null and b/docs/design/agent-tasks-animation-preview.gif differ diff --git a/docs/design/agent-tasks-official-preview.png b/docs/design/agent-tasks-official-preview.png new file mode 100644 index 000000000..799bffdba Binary files /dev/null and b/docs/design/agent-tasks-official-preview.png differ diff --git a/docs/remote-access.md b/docs/remote-access.md new file mode 100644 index 000000000..655fd5f78 --- /dev/null +++ b/docs/remote-access.md @@ -0,0 +1,204 @@ +# Remote Access with Tailscale Funnel + +This guide explains how to make a `stackchan-mcp` gateway reachable when the +StackChan device and gateway host are on different networks. + +The recommended first path is Tailscale Funnel: + +- no firmware feature work is required +- the ESP32 connects to a public `wss://...ts.net` gateway URL +- the gateway still verifies the ESP32 bearer token with `STACKCHAN_TOKEN` +- photo uploads can use a second Funnel URL through `VISION_URL` and are + protected with `VISION_TOKEN` or, by default, `STACKCHAN_TOKEN` + +Tailscale Funnel publishes local services on a device in your tailnet to the +public internet over HTTPS. It requires Tailscale CLI support, MagicDNS, HTTPS +certificates, and a Funnel node attribute in the tailnet policy. Funnel can +listen only on `443`, `8443`, or `10000`, so this guide uses `443` for the +WebSocket gateway and `8443` for the photo capture endpoint. + +Reference: + +- [Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) +- [tailscale funnel command](https://tailscale.com/docs/reference/tailscale-cli/funnel) +- [Tailscale Funnel examples](https://tailscale.com/docs/reference/examples/funnel) + +## Network Shape + +``` +ESP32 StackChan + │ + │ wss://..ts.net/ + ▼ +Tailscale Funnel :443 + │ + │ forwards to local TCP + ▼ +stackchan-mcp gateway WebSocket :8765 + +ESP32 camera upload + │ + │ https://..ts.net:8443/capture + ▼ +Tailscale Funnel :8443 + │ + │ forwards to local HTTP + ▼ +stackchan-mcp capture server :8766 +``` + +## Security Notes + +Funnel makes the selected service public. Keep `STACKCHAN_TOKEN` set and use a +strong token for the WebSocket gateway. + +The `/capture` endpoint is a separate HTTP upload endpoint. Keep +`STACKCHAN_TOKEN` set so the gateway can pass a bearer token to the ESP32 for +photo uploads, or set `VISION_TOKEN` if you want a separate capture token. If +you expose capture with Funnel, still treat the URL as public while the Funnel +listener is enabled, turn it off when it is not needed, and do not expose +captured photos or other user data in git. + +Do not publish personal tokens, LAN IP addresses, WiFi credentials, `.env` +files, captures, or local firmware override files. + +If you only need access from devices that already run Tailscale, use Tailscale +Serve or a normal tailnet address instead of Funnel. The ESP32 firmware does +not currently run a Tailscale client, so the StackChan device itself needs a +public Funnel URL unless a separate network layer routes it into the tailnet. + +## Gateway Configuration + +In `gateway/.env`, keep the gateway listening locally and set both the ESP32 +auth token and the public capture URL: + +```bash +STACKCHAN_TOKEN= + +HOST=0.0.0.0 +WS_PORT=8765 +CAPTURE_PORT=8766 + +VISION_URL=https://..ts.net:8443/capture +# Optional: leave empty to reuse STACKCHAN_TOKEN. +VISION_TOKEN= +``` + +`VISION_URL` is the full URL sent to the ESP32 for `take_photo` uploads. Use it +for remote tunnel setups. `VISION_TOKEN` is sent to the ESP32 as the capture +upload bearer token; if it is empty, the gateway reuses `STACKCHAN_TOKEN`. +`VISION_HOST` remains useful for LAN-only setups where the capture URL is +`http://:8766/capture`. + +Start the gateway: + +```bash +cd gateway +uv run python -m stackchan_mcp +``` + +## Funnel Setup + +On the same host as the gateway, enable two Funnel listeners. + +Use port `443` for the WebSocket gateway: + +```bash +tailscale funnel --bg --https=443 http://localhost:8765 +``` + +Use port `8443` for the HTTP capture server: + +```bash +tailscale funnel --bg --https=8443 http://localhost:8766 +``` + +Check the published URLs: + +```bash +tailscale funnel status +``` + +The expected public endpoints are: + +- WebSocket: `wss://..ts.net/` +- Capture: `https://..ts.net:8443/capture` + +To stop the listeners: + +```bash +tailscale funnel --https=443 off +tailscale funnel --https=8443 http://localhost:8766 off +``` + +## Device Configuration + +Configure the firmware's WebSocket URL and bearer token: + +```text +websocket.url = wss://..ts.net/ +websocket.token = +``` + +For developer builds, these can be provided through the existing Kconfig +fallbacks: + +```text +CONFIG_DEFAULT_WEBSOCKET_URL="wss://..ts.net/" +CONFIG_DEFAULT_WEBSOCKET_TOKEN="" +``` + +To prefer a LAN gateway when the StackChan and gateway host are on the same +network, while still keeping a remote Funnel path for travel, configure the +local URL as primary and the Funnel URL as fallback: + +```text +CONFIG_DEFAULT_WEBSOCKET_URL="ws://:8765/" +CONFIG_DEFAULT_WEBSOCKET_FALLBACK_URL="wss://..ts.net/" +CONFIG_DEFAULT_WEBSOCKET_TOKEN="" +``` + +At runtime the firmware tries the primary URL first. If the TCP/WebSocket +connection fails, or if the stackchan-mcp server hello does not complete, it +tries the fallback URL next. Leave the fallback empty for local-only or +Tailscale-only setups. + +For existing devices with stale NVS values, use the documented +`CONFIG_FORCE_DEFAULT_WEBSOCKET_URL=y` flow from the top-level README. + +## Validation + +After the gateway, Funnel, and device settings are in place: + +1. Start the gateway. +2. Start both Funnel listeners. +3. Reboot or reset the StackChan device. +4. Press the StackChan main button to start a chat session. The firmware opens + the WebSocket connection when a session starts; an idle device may not appear + in `get_status` immediately after boot. +5. Call `get_status` from the MCP client and confirm `connected=true`. +6. Call `get_device_info`. +7. Call `take_photo` and confirm the capture reaches the gateway. + +If the device and gateway are on the same physical LAN during validation, this +still verifies that the firmware uses the public Funnel `wss://` URL, bearer +auth, MCP initialization, and the authenticated capture callback. It does not +prove behavior across every remote network path. For a stronger end-to-end +remote proof, place the StackChan device on a different network, such as a phone +hotspot, while keeping the gateway host behind Funnel. + +If `get_status` works but `take_photo` fails, the WebSocket path is working but +the capture callback URL is wrong. Re-check `VISION_URL`, the `8443` Funnel +listener, and whether the capture URL ends with `/capture`. + +If `take_photo` is not needed for a remote session, leave the `8443` Funnel +listener off and use only the WebSocket listener. + +## Limitations + +- Funnel bandwidth limits are managed by Tailscale and are not configurable. +- Latency depends on the device WiFi, the gateway network, and Funnel relay path. +- The future Opus audio stream may need lower and more stable latency than + simple control and photo capture. +- A firmware-native WireGuard or Tailscale-style client would be a separate + follow-up feature, not part of this guide. diff --git a/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md b/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md new file mode 100644 index 000000000..3a813e5b4 --- /dev/null +++ b/docs/superpowers/plans/2026-11-11-official-stackchan-agent-dashboard.md @@ -0,0 +1,26 @@ +# 官方 StackChan Agent 看板实施清单 + +本清单记录最终实施范围。架构详见同目录设计规范,实测证据见 `docs/agent-tasks-validation.zh-CN.md`。 + +- [x] 网关提供受 Bearer 令牌保护的 `/agent-dashboard`,复用 MCP 状态存储。 +- [x] 新增官方 Mooncake App,在 Launcher 提供 AGENT TASKS 独立入口。 +- [x] 320×240 双栏:Codex 左、Claude 右,图形标识、中文状态、阶段和任务。 +- [x] 每栏独立滚动,任务按进行中、待办、已完成稳定排序。 +- [x] 工作转圈、动态省略号与进行中标记动画,完成和离线停止。 +- [x] Setup 配置地址和隐藏令牌,软键盘编辑、连接测试、保存与取消。 +- [x] 独立 NVS 命名空间与可选本机默认配置,保留已有非空字段。 +- [x] 后台 HTTP 轮询,超时与响应大小限制,失败保留最近有效快照。 +- [x] Codex Desktop 生命周期监视与显式计划同步;Claude transcript 任务监视。 +- [x] macOS 安装器、中文使用说明、字体许可与界面预览。 +- [x] 网关完整测试与 Ruff;固件宿主 CTest;官方 ESP-IDF 6.1 构建。 +- [x] USB 刷写、独立校验与正常启动,蓝牙只读连接验证。 +- [x] 用户确认实机双栏任务与动画正常。 +- [ ] 实机单独验收长清单滚动、返回头像与网关断线恢复。 +- [ ] 验收新 Claude 模型会话产生任务事件的实时更新。 + +## 本次仓库整合 + +- [x] 官方固件作为主项目,网关保持 `gateway/` 独立进程。 +- [x] 同仓保留网关测试、依赖锁、安装脚本及上游许可证。 +- [x] 排除个人凭据、会话、备份及构建产物。 +- [ ] 在整合目录重新运行检查并推送 `codex/agent-tasks-dashboard`。 diff --git a/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md b/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md new file mode 100644 index 000000000..68ac06601 --- /dev/null +++ b/docs/superpowers/specs/2026-11-11-agent-tasks-official-stackchan-design.md @@ -0,0 +1,68 @@ +# StackChan Agent 待办看板设计 + +## 目标 + +在 M5Stack 官方 StackChan 固件中新增一个 Mooncake 应用,在 320×240 屏幕上查看 Codex 和 Claude 的状态、阶段及待办。左栏固定为 Codex,右栏固定为 Claude。Agent 通过 `stackchan-mcp` 的 MCP 服务提交状态;设备离线时保留最近任务并显示离线状态。头像和原语音交互不变。 + +固件以 [M5Stack/StackChan](https://github.com/m5stack/StackChan) 为基线。`stackchan-mcp` 继续作为独立网关和 MCP 状态服务,不把其 fork 固件作为设备固件。 + +## 架构 + +```text +Codex / Claude adapters + │ MCP Streamable HTTP + ▼ +stackchan-mcp gateway ── AgentStatusStore + ▲ │ + │ HTTP GET └── latest two-agent snapshot + │ (1 second polling) +M5Stack official StackChan + Mooncake Agent Tasks app +``` + +固件通过新增的独立 Mooncake App 显示看板,并由官方 Launcher 展示其应用图标。Agent 不连接设备。设备读取状态使用网关的只读 HTTP 接口;MCP 仍是状态写入入口。该边界适配官方固件当前的应用及联网结构,也避免依赖 `stackchan-mcp` fork 内的设备 WebSocket MCP 协议。 + +## 网关接口 + +- `agent_update_snapshot` 接受单个 Agent 的完整快照,沿用 `AgentStatusStore` 的会话隔离、序号拒绝、文本长度限制及 45 秒租约。 +- `agent_get_dashboard` 返回两个 Agent 的最新完整视图,顺序固定为 Codex、Claude。 +- 新增受现有 Bearer token 保护的 `GET /agent-dashboard`,供固件轮询同一只读视图。响应不包含 prompt、文件路径、工具参数、消息正文或模型推理内容。 +- 不新增设备 WebSocket 推送、设备队列 publisher 或 MCP 导航工具。设备网络中断时保留缓存视图;恢复后下一次轮询自动取得最新快照。 +- JSON 响应字段为 `agents[]`,每项包含 `agent`、`online`、`stale`、`status`、`stage`、`tasks[]`。状态值为 `idle`、`working`、`waiting`、`completed`、`failed`;任务状态值为 `pending`、`in_progress`、`completed`。 + +## 官方固件应用 + +- 在 `firmware/main/apps` 新增 Agent Tasks Mooncake App,并在 `apps.h`、`main.cpp` 注册;Launcher 提供独立入口和 Agent 图标。 +- UI 采用深色、高对比、细分隔线的双栏布局。每栏显示图形 Agent 标识、名称、中文状态、短阶段和任务。每栏同时最多显示三条任务,任务较多时在本栏独立滚动。阶段和任务文字进行长度限制。 +- 页面提供返回 Launcher 的官方 HomeIndicator;不在头像 App 上拦截触摸,也不更改音频、头部触摸或现有页面手势。 +- LVGL 创建、更新和销毁遵循官方 `LvglLockGuard`。HTTP 请求运行在独立 worker,不在 UI 更新或持锁区间执行阻塞网络操作。网络恢复后由 worker 将最新完整快照交给 App,随后在 UI 生命周期中更新 LVGL。 +- 空清单显示“暂无待办任务”。来源离线或租约过期时保留最后任务,并标示“离线”或“已过期”。设备暂时无法连接网关时显示上一次数据,不伪造进度。 + +## 设备配置 + +在官方 Setup App 中增加 Agent 看板设置页,包含网关只读接口地址和 Bearer token。配置保存到设备 NVS;token 输入默认隐藏,日志不打印 token。设置页提供软键盘、连接测试和保存/取消操作。通用源码默认地址为空;可用本机脚本生成被 Git 忽略的默认地址和令牌,首次加载时回填空字段并持久化,已有非空值优先。未配置地址时看板显示配置提示,不发起外部请求。 + +设备以约 1 秒间隔轮询 `GET /agent-dashboard`,单次 HTTP 操作超时为 5 秒,响应体上限为 8 KiB,并不跟随 HTTP 重定向,避免 Bearer token 被转发到其他地址。网关返回带 `Content-Length` 的 JSON 响应。地址支持家庭 LAN 的 HTTP 和 HTTPS。一次失败不清空缓存;重复失败显示连接异常状态。 + +## 事件适配器边界 + +- Codex Desktop 使用本机实验 rollout 监视器,仅接收 `originator` 明确为 `Codex Desktop` 的主会话,读取回合开始、完成和中断事件。已观察真实事件,但它不是稳定的官方桌面事件订阅 API。 +- Codex 待办使用主会话显式 `codex-plan` 入口,同会话计划与生命周期由同一个监视器合并。未观察到桌面原生计划事件时,不从消息正文猜测任务。 +- Claude Code 使用本地 transcript 增量监视器,成功的 TaskCreate、TaskUpdate、TodoWrite 更新清单,结束回合保留任务;不依赖全局 Hook。既有会话回放已验证,新模型会话实时验收待完成。 +- 只展示最近活动的主会话,不混入子 Agent、其它会话或聊天正文。一个 Agent 栏只使用一个上报来源。 +- 每栏稳定排序为“进行中 → 待办 → 已完成”;在线工作时显示转圈、省略号和任务指示动画,完成或离线时停止。 + +## 验证和验收 + +- 网关测试覆盖 MCP 更新、旧序号拒绝、双 Agent 输出顺序、45 秒过期、HTTP Bearer 授权、错误输入和不泄露额外字段。 +- 固件 host tests 覆盖两列映射、状态标签、UTF-8 截断、空清单、离线保留任务及非法快照拒绝。 +- 用官方仓库文档所列 ESP-IDF v6.1 流程构建;确认新增 App、Setup 配置和依赖不破坏官方 Launcher 与现有应用。 +- 在设备上检查 320×240 双栏排版、滚动、返回、网络断开/恢复、状态更新,以及 Avatar、语音触摸和头部手势未受影响。 +- 刷写只使用官方固件仓库的标准 CoreS3/StackChan 指引。先确认生成镜像和目标板型;下载模式由 USB 完成刷写,BLE 用于启动后广播发现,不作为刷写通道。 + +## 实施顺序 + +1. 在 `stackchan-mcp` 中完成并验证 MCP 状态工具及只读 HTTP 快照接口。 +2. 在官方固件中新增 App、纯数据模型、设置页配置持久化及异步轮询。 +3. 完成网关/固件契约测试、官方 ESP-IDF 构建和实体设备验收。 +4. 按真实生命周期与任务事件证据启用本机实验适配器,明确记录其限制与尚未验收的实机行为。 diff --git a/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md b/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md new file mode 100644 index 000000000..056765a93 --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-home-gesture-and-progress-design.md @@ -0,0 +1,16 @@ +# 首页上滑响应与各 Agent 完成度 + +用户反馈 AI AGENT 页需反复上滑才能唤出首页按钮。根因之一是 AI 工作任务持有 LVGL 全局锁休眠 100 ms,使触摸采样、绘制与手势处理一同延后。另有公共手势触发区窄、只在释放时判断,以及首页按钮隐藏动画结束标志未在重新显示时清理的问题。 + +## 最终方案 + +- AI 工作任务仍以 20 ms 周期处理导航,忙碌时只将头像工作限制为 120 ms 周期;锁内不休眠。 +- 公共首页手势从底部 40 px 起触,向上移动至少 40 px 且纵向位移大于横向位移时触发,一次触摸最多触发一次;支持释放时最后触点,排除中部滚动、横滑和短拖动。 +- 重新显示首页按钮时清除上一轮隐藏标志,避免动画完成时又隐藏。 +- 看板导航按约 33 ms 更新,任务数据按 500 ms 刷新;拷贝快照在 LVGL 锁外完成,标记透明度仅在实际值变化时更新。网络轮询仍为约 1 秒。 +- 按用户最终要求,Codex 和 Claude 名称右侧分别以 14 px 小号数字显示完成百分比,不显示底栏、进度条和计数,保留原任务列表高度。 +- 百分比按各自当前同步清单的已完成数 / 总数计算,四舍五入;进行中不计完成,空清单显示 `--%`,离线保留最后快照比例。 + +## 验证 + +宿主测试覆盖空清单、双 Agent 独立比例、取整、100%,以及底部上滑、横滑、短拖、中部滚动、触点无效和单次触发。ESP-IDF 6.1 构建,USB 仅写应用槽,保留 NVS、OTA 元数据和 assets。首次优化固件刷入后,用户确认“返回顺畅,百分比显示正常”;顶部各 Agent 百分比版本另行记录构建与刷写结果。 diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp new file mode 100644 index 000000000..11f7e6cde --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_config.cpp @@ -0,0 +1,67 @@ +#include "agent_tasks_config.h" +#include +#if __has_include("agent_tasks_local_defaults.h") +#include "agent_tasks_local_defaults.h" +#else +#define AGENT_TASKS_DEFAULT_URL "" +#define AGENT_TASKS_DEFAULT_TOKEN "" +#endif + +namespace agent_tasks { +namespace { +constexpr char kNamespace[] = "agent_tasks"; +constexpr size_t kUrlMax = 255; +constexpr size_t kTokenMax = 256; + +bool read_string(nvs_handle_t handle, const char* key, size_t max, std::string& value) { + size_t size = 0; + esp_err_t err = nvs_get_str(handle, key, nullptr, &size); + if (err == ESP_ERR_NVS_NOT_FOUND) return true; + if (err != ESP_OK || size == 0 || size > max + 1) return false; + value.resize(size); + if (nvs_get_str(handle, key, value.data(), &size) != ESP_OK) return false; + value.pop_back(); + return true; +} +} + +bool load_config(Config& config) { + nvs_handle_t handle; + auto err = nvs_open(kNamespace, NVS_READONLY, &handle); + if (err == ESP_ERR_NVS_NOT_FOUND) { + config = {AGENT_TASKS_DEFAULT_URL, AGENT_TASKS_DEFAULT_TOKEN}; + return config.url.empty() || save_config(config); + } + if (err != ESP_OK) return false; + Config loaded; + const bool ok = read_string(handle, "url", kUrlMax, loaded.url) && + read_string(handle, "token", kTokenMax, loaded.token); + nvs_close(handle); + if (!ok) return false; + bool use_defaults = false; + if (loaded.url.empty() && AGENT_TASKS_DEFAULT_URL[0]) { + loaded.url = AGENT_TASKS_DEFAULT_URL; + use_defaults = true; + } + if (loaded.token.empty() && AGENT_TASKS_DEFAULT_TOKEN[0]) { + loaded.token = AGENT_TASKS_DEFAULT_TOKEN; + use_defaults = true; + } + config = std::move(loaded); + return !use_defaults || save_config(config); +} + +bool save_config(const Config& config) { + if (config.url.size() > kUrlMax || config.token.size() > kTokenMax || + config.url.find_first_of("\r\n") != std::string::npos || + config.token.find_first_of("\r\n") != std::string::npos) return false; + nvs_handle_t handle; + auto err = nvs_open(kNamespace, NVS_READWRITE, &handle); + if (err != ESP_OK) return false; + err = nvs_set_str(handle, "url", config.url.c_str()); + if (err == ESP_OK) err = nvs_set_str(handle, "token", config.token.c_str()); + if (err == ESP_OK) err = nvs_commit(handle); + nvs_close(handle); + return err == ESP_OK; +} +} diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_config.h b/firmware/main/apps/app_agent_tasks/agent_tasks_config.h new file mode 100644 index 000000000..bbb82fdb7 --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_config.h @@ -0,0 +1,8 @@ +#pragma once +#include + +namespace agent_tasks { +struct Config { std::string url; std::string token; }; +bool load_config(Config& config); +bool save_config(const Config& config); +} diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_model.h b/firmware/main/apps/app_agent_tasks/agent_tasks_model.h new file mode 100644 index 000000000..ceb9dde91 --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_model.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include + +namespace agent_tasks { + +enum class AgentStatus { Idle, Working, Waiting, Completed, Failed }; +enum class TaskStatus { Pending, InProgress, Completed }; +constexpr size_t kMaxTasks = 12; +constexpr size_t kMaxStageCodepoints = 24; +constexpr size_t kMaxTaskCodepoints = 64; + +struct Task { + std::string id; + std::string text; + TaskStatus status = TaskStatus::Pending; +}; + +struct AgentView { + AgentStatus status = AgentStatus::Idle; + std::string stage; + std::vector tasks; + bool online = false; + bool stale = false; +}; + +struct Snapshot { + AgentView codex; + AgentView claude; +}; + +struct CompletionProgress { + size_t completed = 0; + size_t total = 0; + unsigned percent = 0; +}; + +inline CompletionProgress completion_progress(const AgentView& view) { + CompletionProgress progress; + progress.total = view.tasks.size(); + for (const auto& task : view.tasks) + if (task.status == TaskStatus::Completed) ++progress.completed; + if (progress.total) + progress.percent = static_cast((progress.completed * 100 + progress.total / 2) / progress.total); + return progress; +} + +inline bool valid_utf8(const std::string& value) { + for (size_t i = 0; i < value.size();) { + const auto a = static_cast(value[i]); + const size_t n = a < 0x80 ? 1 : a >= 0xC2 && a <= 0xDF ? 2 : + a >= 0xE0 && a <= 0xEF ? 3 : a >= 0xF0 && a <= 0xF4 ? 4 : 0; + if (!n || i + n > value.size()) return false; + for (size_t j = 1; j < n; ++j) { + const auto b = static_cast(value[i + j]); + if ((b & 0xC0) != 0x80) return false; + if (j == 1 && ((a == 0xE0 && b < 0xA0) || (a == 0xED && b >= 0xA0) || + (a == 0xF0 && b < 0x90) || (a == 0xF4 && b >= 0x90))) return false; + } + i += n; + } + return true; +} + +inline size_t codepoints(const std::string& value) { + size_t result = 0; + for (unsigned char c : value) if ((c & 0xC0) != 0x80) ++result; + return result; +} + +inline const char* status_label(const AgentView& view) { + if (view.stale) return "已过期"; + if (!view.online) return "离线"; + switch (view.status) { + case AgentStatus::Idle: return "空闲"; + case AgentStatus::Working: return "工作中"; + case AgentStatus::Waiting: return "等待中"; + case AgentStatus::Completed: return "已完成"; + case AgentStatus::Failed: return "失败"; + } + return "未知"; +} + +class Model { +public: + bool replace(const Snapshot& next) { + if (!valid(next.codex) || !valid(next.claude)) return false; + snapshot_ = next; + return true; + } + const Snapshot& snapshot() const { return snapshot_; } + +private: + static bool valid(const AgentView& view) { + if (static_cast(view.status) < 0 || static_cast(view.status) > 4 || + view.tasks.size() > kMaxTasks || !valid_utf8(view.stage) || + codepoints(view.stage) > kMaxStageCodepoints) return false; + for (const auto& task : view.tasks) { + if (task.id.empty() || task.id.size() > 128 || !valid_utf8(task.text) || + codepoints(task.text) > kMaxTaskCodepoints || + static_cast(task.status) < 0 || static_cast(task.status) > 2) return false; + } + return true; + } + Snapshot snapshot_; +}; + +} // namespace agent_tasks diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp new file mode 100644 index 000000000..822ea9a20 --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.cpp @@ -0,0 +1,162 @@ +#include "agent_tasks_poller.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace agent_tasks { +namespace { +constexpr int kTimeoutMs = 5000; +constexpr size_t kMaxResponse = 8192; + +bool parse_agent(cJSON* object, const char* expected, AgentView& view) { + auto* agent = cJSON_GetObjectItemCaseSensitive(object, "agent"); + auto* status = cJSON_GetObjectItemCaseSensitive(object, "status"); + auto* stage = cJSON_GetObjectItemCaseSensitive(object, "stage"); + auto* online = cJSON_GetObjectItemCaseSensitive(object, "online"); + auto* stale = cJSON_GetObjectItemCaseSensitive(object, "stale"); + auto* tasks = cJSON_GetObjectItemCaseSensitive(object, "tasks"); + if (!cJSON_IsString(agent) || strcmp(agent->valuestring, expected) || !cJSON_IsString(status) || + !cJSON_IsString(stage) || !cJSON_IsBool(online) || !cJSON_IsBool(stale) || !cJSON_IsArray(tasks) || + cJSON_GetArraySize(tasks) > static_cast(kMaxTasks)) return false; + const char* statuses[] = {"idle", "working", "waiting", "completed", "failed"}; + bool found = false; + for (int i = 0; i < 5; ++i) if (!strcmp(status->valuestring, statuses[i])) { + view.status = static_cast(i); found = true; break; + } + if (!found) return false; + view.stage = stage->valuestring; + view.online = cJSON_IsTrue(online); + view.stale = cJSON_IsTrue(stale); + for (auto* item = tasks->child; item; item = item->next) { + auto* id = cJSON_GetObjectItemCaseSensitive(item, "id"); + auto* text = cJSON_GetObjectItemCaseSensitive(item, "text"); + auto* task_status = cJSON_GetObjectItemCaseSensitive(item, "status"); + if (!cJSON_IsString(id) || !cJSON_IsString(text) || !cJSON_IsString(task_status)) return false; + Task task; + task.id = id->valuestring; + task.text = text->valuestring; + if (!strcmp(task_status->valuestring, "pending")) task.status = TaskStatus::Pending; + else if (!strcmp(task_status->valuestring, "in_progress")) task.status = TaskStatus::InProgress; + else if (!strcmp(task_status->valuestring, "completed")) task.status = TaskStatus::Completed; + else return false; + view.tasks.push_back(std::move(task)); + } + return true; +} + +bool fetch(const Config& config, Snapshot& result, const std::atomic_bool& stop) { + if (config.url.compare(0, 7, "http://") && config.url.compare(0, 8, "https://")) return false; + // Mooncake does not start Wi-Fi at boot. Reuse the official network + // manager and saved credentials before making the first HTTP request. + auto& wifi = WifiManager::GetInstance(); + if (!wifi.IsConnected()) { + if (SsidManager::GetInstance().GetSsidList().empty() || wifi.IsConfigMode()) return false; + if (!wifi.IsInitialized()) Board::GetInstance().StartNetwork(); + else wifi.StartStation(); + const int64_t deadline = esp_timer_get_time() + int64_t(kTimeoutMs) * 1000; + while (!wifi.IsConnected() && !stop && esp_timer_get_time() < deadline) + vTaskDelay(pdMS_TO_TICKS(100)); + if (!wifi.IsConnected() || stop) return false; + } + std::string url = config.url; + while (!url.empty() && url.back() == '/') url.pop_back(); + url += "/agent-dashboard"; + auto network = Board::GetInstance().GetNetwork(); + if (!network) return false; + esp_log_level_set("HttpClient", ESP_LOG_WARN); + auto client = network->CreateHttp(0); + if (!client) return false; + const int64_t started = esp_timer_get_time(); + auto set_remaining_timeout = [&]() { + const int remaining = kTimeoutMs - static_cast((esp_timer_get_time() - started) / 1000); + if (remaining <= 0 || stop) return false; + client->SetTimeout(remaining); + return true; + }; + if (!set_remaining_timeout()) return false; + client->SetKeepAlive(false); + client->SetHeader("Authorization", "Bearer " + config.token); + client->SetHeader("Accept", "application/json"); + client->SetHeader("Connection", "close"); + bool ok = client->Open("GET", url) && set_remaining_timeout() && client->GetStatusCode() == 200; + std::string body; + char buffer[512]; + const size_t content_length = ok ? client->GetBodyLength() : 0; + if (content_length == 0 || content_length > kMaxResponse) ok = false; + while (ok && !stop && body.size() < content_length) { + if (!set_remaining_timeout()) { ok = false; break; } + const size_t remaining = std::min(sizeof(buffer), content_length - body.size()); + const auto read = client->Read(buffer, remaining); + if (!read) { ok = false; break; } + const int n = *read; + if (n < 0 || body.size() + static_cast(n) > kMaxResponse) { ok = false; break; } + if (n == 0) { ok = false; break; } + body.append(buffer, n); + } + client->Close(); + if (body.size() != content_length || esp_timer_get_time() - started >= int64_t(kTimeoutMs) * 1000) ok = false; + if (!ok || stop) return false; + auto* root = cJSON_ParseWithLength(body.c_str(), body.size()); + if (!root) return false; + auto* agents = cJSON_GetObjectItemCaseSensitive(root, "agents"); + ok = cJSON_IsArray(agents) && cJSON_GetArraySize(agents) == 2; + if (ok) ok = parse_agent(cJSON_GetArrayItem(agents, 0), "codex", result.codex) && + parse_agent(cJSON_GetArrayItem(agents, 1), "claude", result.claude); + cJSON_Delete(root); + Model model; + return ok && model.replace(result); +} +} + +bool Poller::start(const Config& config) { + stop_ = false; configured_ = !config.url.empty(); connected_ = false; + if (!configured_) return true; + struct StartArgs { Poller* self; Config config; }; + auto* args = new StartArgs{this, config}; + task_running_ = true; + const auto created = xTaskCreate([](void* opaque) { + std::unique_ptr args(static_cast(opaque)); + auto* self = args->self; + self->run(std::move(args->config)); + args.reset(); + self->task_running_ = false; + vTaskDelete(nullptr); + }, "agent_tasks", 6144, args, 2, nullptr); + if (created != pdPASS) { delete args; task_running_ = false; return false; } + return true; +} + +void Poller::test_once(const Config& config) { + Snapshot next; + const bool ok = fetch(config, next, stop_); + if (ok) { + std::lock_guard lock(mutex_); + snapshot_ = std::move(next); + connected_ = true; + } else connected_ = false; +} + +void Poller::run(Config config) { + while (!stop_) { + Snapshot next; + if (fetch(config, next, stop_)) { + std::lock_guard lock(mutex_); snapshot_ = std::move(next); connected_ = true; + } else connected_ = false; + for (int i = 0; i < 10 && !stop_; ++i) vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void Poller::stop() { + stop_ = true; + while (task_running_) vTaskDelay(pdMS_TO_TICKS(10)); +} +Snapshot Poller::latest() const { std::lock_guard lock(mutex_); return snapshot_; } +} diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h new file mode 100644 index 000000000..d44fb48ab --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_poller.h @@ -0,0 +1,25 @@ +#pragma once +#include "agent_tasks_model.h" +#include "agent_tasks_config.h" +#include +#include + +namespace agent_tasks { +class Poller { +public: + bool start(const Config& config); + void stop(); + void test_once(const Config& config); + Snapshot latest() const; + bool configured() const { return configured_; } + bool connected() const { return connected_; } +private: + mutable std::mutex mutex_; + Snapshot snapshot_; + std::atomic_bool stop_{false}; + std::atomic_bool connected_{false}; + std::atomic_bool task_running_{false}; + bool configured_ = false; + void run(Config config); +}; +} diff --git a/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp b/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp new file mode 100644 index 000000000..e127b7d86 --- /dev/null +++ b/firmware/main/apps/app_agent_tasks/agent_tasks_setup_worker.cpp @@ -0,0 +1,122 @@ +#include "agent_tasks_setup_worker.h" +#include "agent_tasks_poller.h" +#include +#include +#include + +using namespace smooth_ui_toolkit::lvgl_cpp; +LV_FONT_DECLARE(agent_tasks_cjk_14); + +namespace agent_tasks { +void SetupWorker::edit(bool token) +{ + if (editor_) return; + editing_token_ = token; + editor_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(editor_, 320, 240); + lv_obj_set_style_pad_all(editor_, 0, 0); + lv_obj_set_style_border_width(editor_, 0, 0); + lv_obj_set_style_radius(editor_, 0, 0); + lv_obj_remove_flag(editor_, LV_OBJ_FLAG_SCROLLABLE); + auto* heading = lv_label_create(editor_); + lv_obj_set_style_text_font(heading, &agent_tasks_cjk_14, 0); + lv_label_set_text(heading, token ? "访问令牌" : "网关地址"); + lv_obj_set_pos(heading, 10, 4); + input_ = lv_textarea_create(editor_); + lv_obj_set_size(input_, 300, 40); + lv_obj_set_pos(input_, 10, 29); + lv_textarea_set_one_line(input_, true); + lv_textarea_set_max_length(input_, token ? 256 : 255); + lv_textarea_set_password_mode(input_, token); + lv_textarea_set_text(input_, (token ? config_.token : config_.url).c_str()); + auto* keyboard = lv_keyboard_create(editor_); + lv_obj_set_size(keyboard, 320, 165); + lv_obj_align(keyboard, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_keyboard_set_mode(keyboard, LV_KEYBOARD_MODE_TEXT_LOWER); + lv_keyboard_set_textarea(keyboard, input_); + lv_obj_add_event_cb(keyboard, [](lv_event_t* event) { + auto* self = static_cast(lv_event_get_user_data(event)); + const auto code = lv_event_get_code(event); + if (code != LV_EVENT_READY && code != LV_EVENT_CANCEL) return; + if (code == LV_EVENT_READY) { + (self->editing_token_ ? self->config_.token : self->config_.url) = + lv_textarea_get_text(self->input_); + self->test_result_->result = 0; + self->save_failed_ = false; + } + lv_obj_delete_async(self->editor_); + self->editor_ = nullptr; + self->input_ = nullptr; + }, LV_EVENT_ALL, this); +} + +SetupWorker::SetupWorker() +{ + save_failed_ = !load_config(config_); + title_ = std::make_unique