diff --git a/docs/superpowers/plans/2026-07-26-p2-logs-data.md b/docs/superpowers/plans/2026-07-26-p2-logs-data.md new file mode 100644 index 0000000..60ea26b --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-p2-logs-data.md @@ -0,0 +1,413 @@ +# P2 日志与数据 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为 PicoBot WebUI 增加实时日志流(tracing 广播层 + `/ws/logs`)、记忆写入端点(PUT/DELETE)、以及日志页/记忆页/任务页的前端重构。 + +**Architecture:** 新增 tracing 广播层(自定义 `Layer` impl,格式化后发送到 `tokio::sync::broadcast`,容量 1024,慢客户端丢旧不反压)。全局 `OnceLock>` 在 `init_logging()` 时初始化。`/ws/logs` 独立 WebSocket 端点复用现有 `require_auth` 中间件,连接后订阅广播、按 level/search 过滤推送。记忆写入复用已有 `Storage::upsert_memory`/`delete_memory`。前端三页重构为 Svelte 5 runes 组件。 + +**Tech Stack:** Rust(tracing-subscriber Layer trait、tokio broadcast、Axum WebSocket)、Svelte 5(runes)、bits-ui。 + +**关键设计决策(务必遵守):** +1. **广播层全局**:`src/logging.rs` 增加 `static LOG_TX: OnceLock>` + `pub fn log_sender() -> Option>`。`init_logging()` 初始化 broadcast channel 并挂载自定义 Layer。无订阅者时 send 为 no-op。 +2. **LogEvent 结构**:`#[derive(Clone)] pub struct LogEvent { pub ts: String, pub level: String, pub target: String, pub message: String }`。ts 为 RFC 3339。level 为 "TRACE"/"DEBUG"/"INFO"/"WARN"/"ERROR"。 +3. **`/ws/logs` 独立于 `/ws`**:不与聊天 WS 复用连接。路由注册在 protected router 内(`require_auth`),handler 签名与 `ws_handler` 类似但更简单(无 session 注册)。查询参数 `level`(可选,最低级别过滤)和 `search`(可选,关键字)。 +4. **记忆 PUT 语义**:`PUT /api/memories/{key}` body `{content: string, importance?: f64}`。若 key 已存在则更新 content/importance/updated_at;若不存在则 404(不创建新条目——创建由 agent 内部完成)。需先查询 key 是否存在。 +5. **记忆 DELETE 语义**:`DELETE /api/memories/{key}` 删除后返回 `{"deleted": true}`。key 不存在也返回 200(幂等)。 +6. **GET /api/logs 保留**:文件尾读取不变,用于进入页面时拉历史与重连对齐。 +7. **前端日志页**:进入时先 `GET /api/logs` 拉历史尾 → 建立 `/ws/logs` 接管实时;断线重连重新拉尾对齐。level 过滤(全部/INF/WRN/ERR)、关键字搜索、暂停滚动、下载。 +8. **前端记忆页**:行内编辑(textarea + importance 滑块 + 保存/取消)、删除确认分级(Knowledge 普通 / Timeline 强警告)、搜索 + 分类筛选 + 分页加载。 +9. **前端任务页**:定时任务增加 cron 表达式显示、下次运行倒计时、最近运行状态点(绿/琥珀/红);后台任务增加脉冲动画。 + +**参考:** 规格 `docs/superpowers/specs/2026-07-23-webui-refactor-design.md` §6.4/§6.5/§6.6/§7.4/§7.5;P1 计划 `docs/superpowers/plans/2026-07-24-p1-observability.md`(前端组件/页面模式)。 + +**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。 + +--- + +## Chunk 1: 后端 — tracing 广播 + /ws/logs + +### Task 1.1: LogEvent + 广播 Layer + 全局访问器 + +**Files:** +- Modify: `src/logging.rs` + +- [ ] **Step 1: 定义 LogEvent 与全局 Sender** + +在 `src/logging.rs` 顶部增加: + +```rust +use tokio::sync::broadcast; +use std::sync::OnceLock; +use tracing::field::Visit; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +const LOG_BROADCAST_CAP: usize = 1024; + +#[derive(Clone, Debug)] +pub struct LogEvent { + pub ts: String, + pub level: String, + pub target: String, + pub message: String, +} + +static LOG_TX: OnceLock> = OnceLock::new(); + +pub fn log_sender() -> Option> { + LOG_TX.get().cloned() +} +``` + +- [ ] **Step 2: 实现自定义 Layer** + +```rust +struct BroadcastLayer; + +struct MessageVisitor(String); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{:?}", value); + } + } + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.0 = value.to_string(); + } + } +} + +impl Layer for BroadcastLayer { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + let Some(tx) = LOG_TX.get() else { return }; + if tx.receiver_count() == 0 { return; } + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + let metadata = event.metadata(); + let _ = tx.send(LogEvent { + ts: chrono::Local::now().to_rfc3339(), + level: metadata.level().to_string(), + target: metadata.target().to_string(), + message: visitor.0, + }); + } +} +``` + +注意:需确认 `chrono` 是否已在依赖中。若无需添加 `chrono = "0.4"` 到 Cargo.toml。或者用 `time` crate(tracing-subscriber 的 `local-time` feature 已引入 `time`)。优先用 `time::OffsetDateTime::now_local()` 格式化为 RFC 3339,避免新增依赖。 + +- [ ] **Step 3: 修改 init_logging() 挂载广播层** + +在 `init_logging()` 中,`tracing_subscriber::registry()` 链增加 `.with(BroadcastLayer)`,并在函数开头初始化 broadcast: + +```rust +pub fn init_logging() { + let (tx, _rx) = broadcast::channel(LOG_BROADCAST_CAP); + let _ = LOG_TX.set(tx); + // ... 现有代码 ... + tracing_subscriber::registry() + .with(env_filter) + .with(console_layer) + .with(file_layer) + .with(BroadcastLayer) + .init(); + // ... +} +``` + +- [ ] **Step 4: 验证** — `cargo build` + `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] **Step 5: Commit** — `git add src/logging.rs Cargo.toml && git commit -m "feat(logging): tracing broadcast layer for real-time log streaming"` + +### Task 1.2: /ws/logs WebSocket 端点 + +**Files:** +- Modify: `src/gateway/ws.rs`(或新建 `src/gateway/ws_logs.rs`) +- Modify: `src/gateway/mod.rs`(路由注册) + +- [ ] **Step 1: 实现 ws_logs handler** + +在 `src/gateway/ws.rs` 底部(或新文件)增加: + +```rust +#[derive(Debug, Default, Deserialize)] +pub struct WsLogsQuery { + level: Option, + search: Option, +} + +pub async fn ws_logs_handler( + ws: WebSocketUpgrade, + Query(query): Query, + Extension(_identity): Extension, +) -> Response { + ws.on_upgrade(|socket| async move { + handle_logs_socket(socket, query).await; + }) +} + +async fn handle_logs_socket(ws: WebSocket, query: WsLogsQuery) { + let Some(tx) = crate::logging::log_sender() else { return }; + let mut rx = tx.subscribe(); + let (mut ws_sender, mut ws_receiver) = ws.split(); + + let min_level = query.level.as_deref().map(parse_min_level).unwrap_or(0); + let search = query.search.filter(|s| !s.is_empty()).map(|s| s.to_ascii_lowercase()); + + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(event) => { + if level_rank(&event.level) < min_level { continue; } + if let Some(needle) = &search { + if !event.message.to_ascii_lowercase().contains(needle) + && !event.target.to_ascii_lowercase().contains(needle) { + continue; + } + } + let json = serde_json::json!({ + "ts": event.ts, + "level": event.level, + "target": event.target, + "message": event.message, + }); + if ws_sender.send(WsMessage::Text(json.to_string().into())).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + msg = ws_receiver.next() => { + match msg { + Some(Ok(WsMessage::Close(_))) | None => break, + _ => {} + } + } + } + } +} + +fn parse_min_level(level: &str) -> u8 { + match level.to_ascii_uppercase().as_str() { + "DEBUG" => 1, + "INFO" => 2, + "WARN" => 3, + "ERROR" => 4, + _ => 0, + } +} + +fn level_rank(level: &str) -> u8 { + match level.as_str() { + "TRACE" => 0, + "DEBUG" => 1, + "INFO" => 2, + "WARN" => 3, + "ERROR" => 4, + _ => 2, + } +} +``` + +- [ ] **Step 2: 注册路由** — 在 `src/gateway/mod.rs` 的 protected router(`route_layer(require_auth)` 之内)加: +```rust +.route("/ws/logs", routing::get(ws::ws_logs_handler)) +``` +放在 `/ws` 路由附近。 + +- [ ] **Step 3: 验证** — `cargo build` + `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] **Step 4: Commit** — `git add src/gateway && git commit -m "feat(gateway): /ws/logs real-time log streaming endpoint"` + +--- + +## Chunk 2: 后端 — 记忆写入端点 + +### Task 2.1: PUT /api/memories/{key} + DELETE /api/memories/{key} + +**Files:** +- Modify: `src/gateway/http.rs` +- Modify: `src/gateway/mod.rs`(路由) + +- [ ] **Step 1: PUT handler** + +```rust +#[derive(Deserialize)] +pub struct PutMemoryBody { + content: String, + importance: Option, +} + +pub async fn put_memory( + State(state): State>, + Path(key): Path, + Json(body): Json, +) -> Result, ApiError> { + let existing = state.storage + .list_memories(None, None, 1) + .await + .map_err(ApiError::internal)?; + // 需要按 key 查询——检查是否有 get_memory_by_key 方法 + // 若无,用 search 或直接 SQL 查询 + // 简化:直接用 upsert,但需先确认 key 存在 + // 实际实现:查询 SELECT * FROM memories WHERE key = ? + // 若不存在返回 404 + // 若存在,更新 content/importance/updated_at + ... +} +``` + +**注意**:当前 Storage 无 `get_memory_by_key` 方法。需在 `src/storage/memory.rs` 增加: +```rust +pub async fn get_memory_by_key(&self, key: &str) -> Result, StorageError> { + let row = sqlx::query( + "SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE key = ?" + ) + .bind(key) + .fetch_optional(self.pool()) + .await?; + match row { + Some(row) => Ok(Some(parse_memory_row(&row)?)), + None => Ok(None), + } +} +``` + +PUT handler 完整逻辑: +1. `get_memory_by_key(&key)` → None → 404 +2. 存在 → 构造更新后的 MemoryEntry(保留 id/category/session_id/created_at,更新 content/importance/updated_at) +3. `upsert_memory(&entry)` → 200 `{"updated": true, "key": key}` + +- [ ] **Step 2: DELETE handler** + +```rust +pub async fn delete_memory( + State(state): State>, + Path(key): Path, +) -> Result, ApiError> { + state.storage.delete_memory(&key).await.map_err(ApiError::internal)?; + Ok(Json(json!({ "deleted": true }))) +} +``` + +- [ ] **Step 3: 注册路由** — protected router 加: +```rust +.route("/api/memories/{key}", routing::put(http::put_memory).delete(http::delete_memory)) +``` +注意:现有 `GET /api/memories`(无 path param)不受影响。 + +- [ ] **Step 4: 验证** — `cargo build` + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] **Step 5: Commit** — `git add src/storage/memory.rs src/gateway && git commit -m "feat(gateway): PUT/DELETE /api/memories/{key} write endpoints"` + +--- + +## Chunk 3: 前端 — 日志页重构 + +### Task 3.1: LogsPage 实时流式重构 + +**Files:** +- Modify: `webui/src/pages/LogsPage.svelte` + +布局(规格 §6.4): +- 工具栏:level 过滤 chips(全部/INF/WRN/ERR)、关键字搜索、暂停滚动按钮、下载按钮。 +- 连接状态条:「实时推送中 · N 行/分」或「已断开 — 重连中」。 +- 日志行:时间戳 + level 着色(INF=signal, WRN=accent, ERR=danger, DBG=muted)+ target + 消息。 +- 自动跟随尾部(除非暂停)。 +- 进入页面:`GET /api/logs?lines=200` 拉历史尾 → 建立 `/ws/logs` WebSocket 接管实时。 +- 断线重连:重新拉尾对齐。 +- 下载:将当前 lines 导出为 .log 文件(Blob + URL.createObjectURL)。 + +数据流: +- `onMount`:先 `api("/api/logs?lines=200")` 填充历史 → 建立 WS `new WebSocket(\`${wsBase}/ws/logs?level=...\`)`。 +- WS `onmessage`:解析 JSON `{ts, level, target, message}`,追加到 `lines` 数组(上限 2000 行,超出 shift)。 +- WS `onclose`:设 disconnected 状态,3s 后重连(重新拉尾 + 重建 WS)。 +- level/search 过滤:前端 `$derived` 过滤已存储行(WS 查询参数在连接时固定;切换 filter 需重建 WS 或纯前端过滤——**选择纯前端过滤**,WS 不带 filter 参数,简化重连逻辑)。 +- 暂停:`paused` 状态,暂停时不自动滚动到底部,新行仍追加。 + +- [ ] **Step 1: 重写 LogsPage.svelte**(上述布局与数据流)。 +- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。 +- [ ] **Step 3: Commit** — `git add webui/src/pages/LogsPage.svelte && git commit -m "feat(webui): real-time streaming logs page"` + +--- + +## Chunk 4: 前端 — 记忆页重构 + +### Task 4.1: MemoryPage 可编辑/可删除重构 + +**Files:** +- Modify: `webui/src/pages/MemoryPage.svelte` + +布局(规格 §6.5): +- 统计条:总量 / Knowledge / Timeline。 +- 搜索框 + 分类筛选(全部/Knowledge/Timeline)+ 排序(最近更新)。 +- 记忆卡片列表:key(mono accent)、content、category badge、importance、updated_at、session_id。 +- 行内编辑:点击「编辑」→ content 变 textarea + importance range input + 保存/取消按钮。保存调 `PUT /api/memories/{key}`。 +- 删除:点击「删除」→ 确认对话框。Knowledge 普通确认;Timeline 强警告(红色面板 + 说明文字 + 按钮「我了解,确认删除」)。调 `DELETE /api/memories/{key}`。 +- 分页:limit=100,底部「加载更多」按钮(offset 通过增大 limit 实现——现有 API 不支持 offset,用 limit 递增近似)。 + +- [ ] **Step 1: 重写 MemoryPage.svelte**(上述布局)。 +- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。 +- [ ] **Step 3: Commit** — `git add webui/src/pages/MemoryPage.svelte && git commit -m "feat(webui): editable memory page with delete confirmation"` + +--- + +## Chunk 5: 前端 — 任务页重构 + +### Task 5.1: TasksPage 增强 + +**Files:** +- Modify: `webui/src/pages/TasksPage.svelte` + +布局(规格 §6.6): +- 定时任务卡片增强: + - 显示 cron 表达式(mono)。 + - 下次运行倒计时(「3 分钟后」格式,每 30s 刷新)。 + - 最近运行状态点(最多 10 个圆点:绿=completed / 琥珀=timeout / 红=error)。 + - 可展开运行记录(已有,保留)。 +- 后台任务增强: + - 运行中状态用脉冲动画(`.pulse` class)。 + - 显示耗时(created_at 到现在的差值,或 duration 字段)。 + +- [ ] **Step 1: 增强 TasksPage.svelte**(上述改动,保留现有结构)。 +- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。 +- [ ] **Step 3: Commit** — `git add webui/src/pages/TasksPage.svelte && git commit -m "feat(webui): enhanced tasks page with countdown and status dots"` + +--- + +## Chunk 6: 收尾 + +### Task 6.1: P2 收尾验证 + 版本号 + +- [ ] **Step 1: 全量验证** + - `cd webui && npm run check && npm run build` + - `cargo build` + - `cargo test --lib` + - `cargo clippy --all-targets --all-features -- -D warnings` + Expected: 全部通过。 +- [ ] **Step 2: 版本号** — `Cargo.toml` 与 `webui/package.json` minor bump(1.5.0 → 1.6.0)。 +- [ ] **Step 3: Commit** — `git add -A && git commit -m "chore(release): P2 logs and data"`(仅暂存版本号文件 + 可能的文档;确认无构建产物)。 + +--- + +## P2 完成标志 + +- `/ws/logs` 实时推送日志帧(ts/level/target/message),设备鉴权保护,慢客户端丢旧不阻塞。 +- `GET /api/logs` 保留,用于历史拉取与重连对齐。 +- `PUT /api/memories/{key}` 更新 content/importance,key 不存在返回 404。 +- `DELETE /api/memories/{key}` 幂等删除。 +- 日志页:实时流 + level 过滤 + 搜索 + 暂停 + 下载 + 断线重连。 +- 记忆页:行内编辑 + 分级删除确认 + 搜索 + 分类筛选。 +- 任务页:cron 显示 + 倒计时 + 运行状态点 + 脉冲动画。 +- `npm run check`、`npm run build`、`cargo build`、`cargo test --lib`、`cargo clippy -- -D warnings` 全绿。 + +## 风险与开放项 + +- **chrono vs time**:tracing-subscriber 的 `local-time` feature 已引入 `time` crate。优先用 `time::OffsetDateTime::now_local()` 避免新增 chrono 依赖。若 `now_local()` 在多线程环境报错(`time` crate 的已知限制),回退到 `std::time::SystemTime` + 手动格式化或用 `chrono`。 +- **广播层性能**:`receiver_count() == 0` 短路确保无订阅者时零开销。有订阅者时每条日志一次 format + send,对高频 DEBUG 日志可能有微量开销,但 EnvFilter 默认 INFO 已过滤大部分。 +- **WS 重连对齐**:前端断线重连时重新拉 `GET /api/logs` 尾部,可能丢失断线期间的少量日志(broadcast 不持久化)。可接受——日志页为运维辅助,非审计。 +- **记忆分页**:现有 API 无 offset/cursor,用 limit 递增近似。大量记忆(>1000)时性能可接受(SQLite 单表 LIMIT 查询)。 +- **记忆 PUT 不创建**:设计决策——新记忆只由 agent 内部创建,WebUI 仅编辑已有条目。若需创建能力,后续 P3 可加 POST。