Compare commits

...

27 Commits

Author SHA1 Message Date
3263fac7a3 chore: set version to 1.3.0 2026-07-26 22:39:55 +08:00
80fbc7fd25 chore(release): P3 configuration 2026-07-26 22:27:23 +08:00
7a845cffd5 feat(webui): enhanced settings page with outline, reload status, hot reload 2026-07-26 22:25:01 +08:00
1e984eef66 docs: add P3 configuration implementation plan 2026-07-26 22:21:54 +08:00
c6a3814530 chore(release): P2 logs and data 2026-07-26 22:18:58 +08:00
ee5bc46cc6 feat(webui): enhanced tasks page with countdown and status dots 2026-07-26 22:17:27 +08:00
f0ef14099d feat(webui): editable memory page with delete confirmation 2026-07-26 22:09:01 +08:00
652a837e88 feat(webui): real-time streaming logs page 2026-07-26 22:04:42 +08:00
b49f0ec8a5 feat(gateway): PUT/DELETE /api/memories/{key} write endpoints 2026-07-26 21:58:01 +08:00
3f09793864 feat(gateway): /ws/logs real-time log streaming endpoint 2026-07-26 21:54:26 +08:00
bee11c7735 feat(logging): tracing broadcast layer for real-time log streaming 2026-07-26 21:50:03 +08:00
dff155a93c docs: add P2 logs and data implementation plan 2026-07-26 21:47:32 +08:00
038854afa1 chore(release): P1 observability 2026-07-26 21:43:33 +08:00
4621210484 feat(webui): wire overview and tools pages 2026-07-26 21:41:16 +08:00
266fb8c5e9 feat(webui): tools and skills browser 2026-07-26 21:40:37 +08:00
0b02fd580b feat(webui): overview runtime dashboard 2026-07-26 21:35:22 +08:00
6e719e7fcb feat(webui): sparkline, capacity meter, metric tile components 2026-07-26 21:31:14 +08:00
5e2771c538 feat(gateway): GET /api/skills 2026-07-26 21:26:57 +08:00
119df57b49 feat(gateway): GET /api/tools with capability metadata 2026-07-26 21:21:32 +08:00
fa58e4d2f6 docs: record P1 observability progress 2026-07-24 18:32:33 +08:00
87cf5004b9 fix(gateway): stabilize status snapshot output 2026-07-24 18:27:37 +08:00
b14edc40d3 feat(gateway): GET /api/status runtime snapshot 2026-07-24 18:16:31 +08:00
aa989cbaf1 feat(gateway): runtime introspection for status endpoint 2026-07-24 18:02:28 +08:00
5d0cf5b070 feat(agent): record tool/turn/provider metrics 2026-07-24 17:21:44 +08:00
115e77ff27 feat(config): optional provider pricing for cost metrics 2026-07-24 17:11:37 +08:00
11474c080e feat(observability): process-global Metrics collector 2026-07-24 15:24:04 +08:00
d25da1ea27 docs: add P1 observability implementation plan 2026-07-24 15:12:20 +08:00
33 changed files with 3387 additions and 70 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.4.0"
version = "1.3.0"
edition = "2024"
[dependencies]
@ -23,6 +23,7 @@ prost = "0.14"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
tracing-appender = "0.2"
time = { version = "0.3", features = ["formatting", "local-offset"] }
anyhow = "1.0"
mime_guess = "2.0"
base64 = "0.22"

View File

@ -0,0 +1,600 @@
# P1 观测 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 增加运行状况观测能力:进程级 `Metrics` 采集token/费用/工具调用/turn/延迟)、`GET /api/status` 聚合快照、`GET /api/tools``GET /api/skills` 只读端点,以及前端概览页(运行仪表盘)与工具&Skills 页。
**Architecture:** 新增进程级 `Metrics``OnceLock<Arc<Metrics>>` 全局访问器,结构体可直接单测;选择全局而非穿线,避免改动 5 个 AgentLoop 构造点且天然跨配置重载存活——precedented by `mcp::MCP_SERVER_STATUS`。AgentLoop 在工具执行/模型调用/turn 完成处记录到全局 Metrics。`/api/status` handler 聚合 Metrics + 各服务只读内省(需为 MessageBus/TaskSupervisor/SessionManager/OutboundDispatcher 补轻量内省方法 + ws 连接计数 + 进程 uptime 静态)。前端概览页每 2s 轮询 `/api/status`,工具页拉取 `/api/tools`+`/api/skills`
**Tech Stack:** RustAxum handler、tokio、原子计数、Svelte 5runes、手写 SVG sparkline。
**关键设计决策(务必遵守):**
1. **Metrics 进程级全局**`src/observability/metrics.rs` 定义 `Metrics` 结构体 + `pub fn global_metrics() -> Arc<Metrics>``OnceLock` 首次调用初始化。AgentLoop 与 `/api/status` 都通过 `global_metrics()` 访问,不穿线、不给 GatewayState 加字段。
2. **cost 可选**:在 `LLMProviderConfig` 增加可选 `price_input_per_million: Option<f64>` / `price_output_per_million: Option<f64>`serde default None。Metrics 记录 tokencost 仅在 AgentLoop 知道单价时累加(从 provider config 取),缺省为 0。不引入硬编码价格表。
3. **uptime**`src/gateway/mod.rs` 增加进程级 `static STARTED: OnceLock<std::time::Instant>` + `fn process_uptime_secs() -> u64`,跨重载存活。
4. **active_lanes**`OutboundDispatcher` 增加 `active_lanes: Arc<AtomicUsize>` 字段构造时注入lane 生成时 +1、lane 任务结束时 -1暴露 `active_lane_count()`。Gateway 侧保留一个 clone 供 `/api/status` 读取。
5. **failed_7d 近似**`/api/status` 的调度器失败数用「`last_status` 为失败态error/timeout/delivery_error的任务数」近似避免 2s 轮询时逐任务查运行记录。在响应字段命名为 `failed_jobs`(语义=最近一次运行失败的任务数UI 标注清楚。
6. **provider status 派生**Metrics 维护 per-provider 最近调用错误率;`status` = 最近窗口错误率 > 阈值(如最近 10 次中 ≥3 次失败)→ `"degraded"`,否则 `"ok"`
**参考:** 规格 `docs/superpowers/specs/2026-07-23-webui-refactor-design.md` §6.2/§6.3/§7.2/§7.3/§7.6P0 计划 `docs/superpowers/plans/2026-07-23-p0-webui-foundation.md`(前端组件/页面模式)。
**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。
## Implementation Progress (2026-07-26)
**Branch:** `feat/webui-p1`
**Completed and reviewed:**
- Task 1.1 Metrics collector — `11474c0`
- Task 1.2 optional provider pricing — `115e77f`
- Task 1.3 AgentLoop instrumentation — `5d0cf5b`
- Task 2.1 runtime introspection — `aa989cb`
- Task 2.2 protected `GET /api/status``b14edc4`, with review fixes in `87cf500`
- Task 2.3 protected `GET /api/tools``119df57`
- Task 2.4 protected `GET /api/skills` + SkillsLoader accessor — `5e2771c`
- Task 3.1 Sparkline/CapacityMeter/MetricTile components — `6e719e7`
- Task 3.2 OverviewPage runtime dashboard — `0b02fd5`
- Task 3.3 ToolsPage tools & skills browser — `266fb8c`
- Task 3.4 App.svelte wiring — `4621210`
- Task 4.1 P1 release — `7676502`
**Final verification baseline:** `cargo test --lib` 350 passed; `cargo clippy --all-targets --all-features -- -D warnings` clean; `cargo build` passed; `npm run check` 0 errors/warnings; `npm run build` passed.
**Status:** P1 COMPLETE. Version bumped to 1.5.0.
---
## Chunk 1: Metrics 后端
### Task 1.1: Metrics 模块(结构体 + 记录/快照 + 全局访问器 + 单测)
**Files:**
- Create: `src/observability/metrics.rs`
- Modify: `src/observability/mod.rs``pub mod metrics;`,若 observability 模块存在;否则按代码库实际模块组织放置——先读 `src/observability/` 确认)
- [ ] **Step 1: 写失败测试**(在 `metrics.rs` 底部 `#[cfg(test)]`
覆盖record_turn 累加 tokens/turns/延迟窗口record_tool_call 累加总量与 per-toolrecord_provider 累加 per-provider token/cost/延迟/错误snapshot 返回正确聚合p95 计算provider status 派生ok vs degraded。示例
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_turns_tokens_and_p95() {
let m = Metrics::new();
for i in 1..=100 {
m.record_turn(Some(&Usage { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30, ..Default::default() }), i);
}
let s = m.snapshot();
assert_eq!(s.turns, 100);
assert_eq!(s.tokens_in, 1000);
assert_eq!(s.tokens_out, 2000);
assert!(s.turn_latency_p95_ms >= 95); // p95 of 1..=100
}
#[test]
fn records_per_tool_counts() {
let m = Metrics::new();
m.record_tool_call("bash", true);
m.record_tool_call("bash", true);
m.record_tool_call("read_file", false);
let s = m.snapshot();
assert_eq!(s.tool_calls, 3);
assert_eq!(s.per_tool.get("bash"), Some(&2));
assert_eq!(s.per_tool.get("read_file"), Some(&1));
}
#[test]
fn derives_provider_status() {
let m = Metrics::new();
for _ in 0..7 { m.record_provider("openai", "gpt-4o", None, 100, false); }
for _ in 0..3 { m.record_provider("openai", "gpt-4o", None, 100, true); }
let s = m.snapshot();
let p = s.providers.iter().find(|p| p.name == "openai").unwrap();
assert_eq!(p.status, "degraded"); // 3/10 recent errors
}
}
```
- [ ] **Step 2: 运行测试确认失败**`cargo test --lib metrics` → FAIL模块不存在
- [ ] **Step 3: 实现 Metrics**
```rust
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use crate::providers::Usage;
const WINDOW: usize = 100; // 延迟/错误滚动窗口大小
const DEGRADE_THRESHOLD: usize = 3; // 最近 10 次中失败 ≥3 → degraded
const DEGRADE_WINDOW: usize = 10;
#[derive(Default)]
struct ProviderStat {
model: String,
tokens_in: u64,
tokens_out: u64,
cost: f64,
calls: u64,
last_latency_ms: u64,
latencies: VecDeque<u64>,
recent_results: VecDeque<bool>, // true = error
}
pub struct Metrics {
tokens_in: std::sync::atomic::AtomicU64,
tokens_out: std::sync::atomic::AtomicU64,
turns: std::sync::atomic::AtomicU64,
tool_calls: std::sync::atomic::AtomicU64,
per_tool: Mutex<HashMap<String, u64>>,
turn_latencies: Mutex<VecDeque<u64>>,
providers: Mutex<HashMap<String, ProviderStat>>, // key = provider name
}
#[derive(serde::Serialize)]
pub struct MetricsSnapshot {
pub tokens_in: u64,
pub tokens_out: u64,
pub cost: f64,
pub turns: u64,
pub tool_calls: u64,
pub turn_latency_p95_ms: u64,
pub per_tool: HashMap<String, u64>,
pub providers: Vec<ProviderSnapshot>,
}
#[derive(serde::Serialize)]
pub struct ProviderSnapshot {
pub name: String,
pub model: String,
pub status: String, // "ok" | "degraded"
pub latency_ms: u64,
pub latencies: Vec<u64>, // sparkline
pub tokens_in: u64,
pub tokens_out: u64,
pub cost: f64,
}
impl Default for Metrics {
fn default() -> Self { Self::new() }
}
impl Metrics {
pub fn new() -> Self {
Self {
tokens_in: Default::default(),
tokens_out: Default::default(),
turns: Default::default(),
tool_calls: Default::default(),
per_tool: Mutex::new(HashMap::new()),
turn_latencies: Mutex::new(VecDeque::new()),
providers: Mutex::new(HashMap::new()),
}
}
pub fn record_turn(&self, usage: Option<&Usage>, latency_ms: u64) {
use std::sync::atomic::Ordering::Relaxed;
self.turns.fetch_add(1, Relaxed);
if let Some(u) = usage {
self.tokens_in.fetch_add(u.prompt_tokens as u64, Relaxed);
self.tokens_out.fetch_add(u.completion_tokens as u64, Relaxed);
}
let mut q = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
q.push_back(latency_ms);
while q.len() > WINDOW { q.pop_front(); }
}
pub fn record_tool_call(&self, name: &str, _success: bool) {
use std::sync::atomic::Ordering::Relaxed;
self.tool_calls.fetch_add(1, Relaxed);
*self.per_tool.lock().unwrap_or_else(|e| e.into_inner()).entry(name.to_string()).or_insert(0) += 1;
}
pub fn record_provider(&self, name: &str, model: &str, cost: Option<f64>, latency_ms: u64, is_error: bool) {
let mut map = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let stat = map.entry(name.to_string()).or_insert_with(|| ProviderStat { model: model.to_string(), ..Default::default() });
stat.model = model.to_string();
stat.calls += 1;
stat.last_latency_ms = latency_ms;
stat.latencies.push_back(latency_ms);
while stat.latencies.len() > WINDOW { stat.latencies.pop_front(); }
stat.recent_results.push_back(is_error);
while stat.recent_results.len() > DEGRADE_WINDOW { stat.recent_results.pop_front(); }
if let Some(c) = cost { stat.cost += c; }
}
pub fn record_provider_tokens(&self, name: &str, usage: &Usage) {
let mut map = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let stat = map.entry(name.to_string()).or_default();
stat.tokens_in += usage.prompt_tokens as u64;
stat.tokens_out += usage.completion_tokens as u64;
}
pub fn tool_call_count(&self, name: &str) -> u64 {
*self.per_tool.lock().unwrap_or_else(|e| e.into_inner()).get(name).unwrap_or(&0)
}
pub fn snapshot(&self) -> MetricsSnapshot {
use std::sync::atomic::Ordering::Relaxed;
let latencies = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
let p95 = percentile_95(&latencies);
let providers = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let mut cost = 0.0;
let provider_snaps = providers.iter().map(|(name, s)| {
cost += s.cost;
let errors = s.recent_results.iter().filter(|e| **e).count();
ProviderSnapshot {
name: name.clone(),
model: s.model.clone(),
status: if s.recent_results.len() >= DEGRADE_WINDOW && errors >= DEGRADE_THRESHOLD { "degraded".into() } else { "ok".into() },
latency_ms: s.last_latency_ms,
latencies: s.latencies.iter().copied().collect(),
tokens_in: s.tokens_in,
tokens_out: s.tokens_out,
cost: s.cost,
}
}).collect();
MetricsSnapshot {
tokens_in: self.tokens_in.load(Relaxed),
tokens_out: self.tokens_out.load(Relaxed),
cost,
turns: self.turns.load(Relaxed),
tool_calls: self.tool_calls.load(Relaxed),
turn_latency_p95_ms: p95,
per_tool: self.per_tool.lock().unwrap_or_else(|e| e.into_inner()).clone(),
providers: provider_snaps,
}
}
}
fn percentile_95(values: &VecDeque<u64>) -> u64 {
if values.is_empty() { return 0; }
let mut sorted: Vec<u64> = values.iter().copied().collect();
sorted.sort_unstable();
let idx = ((sorted.len() as f64 * 0.95).ceil() as usize).saturating_sub(1).min(sorted.len() - 1);
sorted[idx]
}
static GLOBAL: OnceLock<Arc<Metrics>> = OnceLock::new();
/// Process-global metrics, shared across config reloads (same process).
pub fn global_metrics() -> Arc<Metrics> {
GLOBAL.get_or_init(|| Arc::new(Metrics::new())).clone()
}
```
`Usage` 字段:`prompt_tokens`/`completion_tokens`/`total_tokens` 等,见 `src/providers/traits.rs:118-129``#[derive(Default)]``record_provider` 的 degraded 判定按「最近 DEGRADE_WINDOW 次中错误 ≥ DEGRADE_THRESHOLD」测试需相应构造样本量。若测试断言与实现阈值不符以实现为准调整测试样本。
- [ ] **Step 4: 运行测试确认通过**`cargo test --lib metrics` → PASS
- [ ] **Step 5: clippy**`cargo clippy --all-targets --all-features -- -D warnings`
- [ ] **Step 6: Commit**`git add src/observability && git commit -m "feat(observability): process-global Metrics collector"`
### Task 1.2: 配置可选价格字段
**Files:**
- Modify: `src/config/mod.rs``LLMProviderConfig` 结构体)
- [ ] **Step 1: 加可选字段** — 在 `LLMProviderConfig` 增加serde 可选,缺省 None不影响现有配置解析
```rust
#[serde(default)]
pub price_input_per_million: Option<f64>,
#[serde(default)]
pub price_output_per_million: Option<f64>,
```
- [ ] **Step 2: 加 cost 辅助方法**(在 LLMProviderConfig impl
```rust
pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option<f64> {
match (self.price_input_per_million, self.price_output_per_million) {
(Some(pi), Some(po)) => Some(prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po),
_ => None,
}
}
```
- [ ] **Step 3: 验证**`cargo test --lib config` + `cargo clippy -- -D warnings`(确认现有配置测试仍过,新字段不破坏反序列化)。
- [ ] **Step 4: Commit**`git add src/config/mod.rs && git commit -m "feat(config): optional provider pricing for cost metrics"`
### Task 1.3: AgentLoop 埋点(记录到全局 Metrics
**Files:**
- Modify: `src/agent/agent_loop.rs`
埋点位置(来自探查):
- 工具执行:`execute_one_tool`(约 970-1021 行),`tool_name` 已知(约 977 行),成功与否在 `result.success`(约 1004/1015 行)。在已有 observer `ObserverEvent::ToolCall` 附近加 `crate::observability::metrics::global_metrics().record_tool_call(&tool_name, success);`
- 模型调用延迟 + per-provider`stream_completion`(约 488-525 行)是唯一模型调用入口。用 `Instant`(已 import包裹 `self.provider.stream(request).await` 累加循环,得到 `latency_ms`;调用 `global_metrics().record_provider(self.provider.name(), self.provider.model_id(), cost, latency_ms, is_error)``record_provider_tokens(name, &response.usage)`。cost 从 provider config 取——但 AgentLoop 当前不持有 provider config 单价;**简化**cost 传 None除非能拿到 config。若 AgentLoop 无法拿到单价cost 由 record_provider 传 None保持 0。**注**:若需 cost可在构造 AgentLoop 时把 `(price_in, price_out)` 一并传入为降低侵入P1 先传 Nonecost 留待有 config 接入时补(在计划风险项注明)。
- turn 完成:在 `process_inner` 的三个返回点(约 706、847、879 行)已知 `accumulated_usage`turn 延迟用进入 `process_inner` 时的 `Instant` 到返回的 elapsed。加 `global_metrics().record_turn(accumulated_usage_opt, latency_ms)`
- [ ] **Step 1: 工具埋点** — 在 `execute_one_tool` 的成功/失败分支(已有 observer ToolCall 事件处)加 `record_tool_call(&tool_name, success)`
- [ ] **Step 2: 模型调用埋点** — 在 `stream_completion` 用 Instant 测延迟,结束后 `record_provider(name, model, None, latency_ms, is_error)` + `record_provider_tokens(name, &usage)`。provider 名用 `self.provider.name()`model 用 `self.provider.model_id()`(见 `src/providers/traits.rs:145-149`)。**注意:`stream_completion` 的错误通过 `?` 提前返回(约 494-497、500-503 行)——必须在这些错误路径也 `record_provider(..., is_error=true)`,否则 provider status 的 degraded 判定在生产中永远不触发(单测因直接调 record_provider 仍能过,会掩盖此 bug。** 可用一个在成功/失败都执行的收尾闭包或在小函数返回前统一记录。
- [ ] **Step 3: turn 埋点** — 在 `process_inner` 入口记 `let turn_start = Instant::now();`,三个返回点前 `global_metrics().record_turn(usage_opt, turn_start.elapsed().as_millis() as u64)`
- [ ] **Step 4: 验证**`cargo test --lib`(现有 agent 测试仍过)+ `cargo clippy -- -D warnings` + `cargo build`
- [ ] **Step 5: Commit**`git add src/agent/agent_loop.rs && git commit -m "feat(agent): record tool/turn/provider metrics"`
---
## Chunk 2: 内省接口 + 端点
### Task 2.1: 服务内省方法
**Files:**
- Modify: `src/bus/mod.rs`MessageBus 队列深度)
- Modify: `src/bus/dispatcher.rs`active lane 计数)
- Modify: `src/task_supervisor.rs`(运行任务数)
- Modify: `src/session/session.rs`(会话数 + 活动 turn 数)
- Modify: `src/gateway/ws.rs`ws 连接计数)
- Modify: `src/gateway/mod.rs`uptime 静态 + ws_connections 字段 + dispatcher lane 计数注入)
- [ ] **Step 1: MessageBus 队列深度** — 在 `src/bus/mod.rs` 加:
```rust
pub fn queue_depths(&self) -> QueueDepths {
QueueDepths {
inbound_depth: (self.inbound_tx.max_capacity() - self.inbound_tx.capacity()) as u64,
inbound_cap: self.inbound_tx.max_capacity() as u64,
outbound_depth: (self.outbound_tx.max_capacity() - self.outbound_tx.capacity()) as u64,
outbound_cap: self.outbound_tx.max_capacity() as u64,
control_depth: (self.control_tx.max_capacity() - self.control_tx.capacity()) as u64,
control_cap: self.control_tx.max_capacity() as u64,
}
}
```
并定义 `#[derive(serde::Serialize)] pub struct QueueDepths { pub inbound_depth: u64, pub inbound_cap: u64, pub outbound_depth: u64, pub outbound_cap: u64, pub control_depth: u64, pub control_cap: u64 }`。(模式参考 `session.rs:2139` 已用 `capacity()`/`max_capacity()`。)
- [ ] **Step 2: OutboundDispatcher active lane 计数** — 给 `OutboundDispatcher` 加字段 `active_lanes: Arc<std::sync::atomic::AtomicUsize>`,构造时注入(在 gateway 创建 dispatcher 处 `Arc::new(AtomicUsize::new(0))`)。**计数增减位置(避免 spawn 失败泄漏)**:把该 `Arc` clone 进 lane 任务,在**已生成的 lane 任务体起始处** `fetch_add(1, Relaxed)`,在 lane 循环的**唯一退出口**(约 131-146 行 break 之后、任务结束前)`fetch_sub(1, Relaxed)`——不要在调用 `spawn_lane` 之前 +1spawn 可能失败)。在 gateway 侧把同一 `Arc` clone 存入 GatewayState 新字段 `pub outbound_lanes: Arc<std::sync::atomic::AtomicUsize>``from_config` 字面量初始化),供 `/api/status` 直接 `state.outbound_lanes.load(Relaxed)` 读取(无需额外方法)。
- [ ] **Step 3: TaskSupervisor 运行任务数** — 在 `src/task_supervisor.rs` 加:
```rust
pub fn running_count(&self) -> usize {
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
state.tasks.retain(|task| !task.handle.is_finished());
state.tasks.len()
}
```
- [ ] **Step 4: SessionManager 会话数 + 活动 turn 数** — 在 `src/session/session.rs` 加(镜像 `wait_until_idle` 约 2125-2158 的判定):
```rust
pub async fn session_count(&self) -> usize {
self.inner.lock().await.sessions.len()
}
pub async fn active_turn_count(&self) -> usize {
let sessions: Vec<_> = self.inner.lock().await.sessions.values().cloned().collect();
let mut count = 0;
for session in sessions {
let s = session.lock().await;
if s.current_cancel.is_some() { count += 1; }
}
count
}
```
`inner``Arc<Mutex<SessionManagerInner>>``sessions: HashMap<String, Arc<Mutex<Session>>>``Session.current_cancel: Option<oneshot::Sender>` 表示 turn 执行中。锁均为 tokio Mutex纯内存计数不跨 I/O 持锁,符合不变量。)
- [ ] **Step 5: ws 连接计数 + uptime 静态** — 在 `src/gateway/mod.rs`
- 加 `static STARTED: OnceLock<std::time::Instant> = OnceLock::new();``pub fn process_uptime_secs() -> u64 { STARTED.get_or_init(Instant::now).elapsed().as_secs() }`(在 `run()` 起始调用一次 `STARTED.get_or_init(Instant::now)`)。
- GatewayState 加 `pub ws_connections: Arc<std::sync::atomic::AtomicUsize>`(在 `from_config` 结构体字面量初始化 `Default::default()`)。
- 在 `src/gateway/ws.rs``handle_socket`(约 39-132 行):进入时 `state.ws_connections.fetch_add(1, Relaxed)`,在函数尾部清理处(约 120-131 行)`fetch_sub(1, Relaxed)`。为保证多 break 路径都减,用一个 RAII guard 或在唯一尾部清理段减(确认 handle_socket 是否有单一出口;若多出口,用 guard struct `Drop` 减)。
- [ ] **Step 6: 验证**`cargo build` + `cargo test --lib` + `cargo clippy -- -D warnings`
- [ ] **Step 7: Commit**`git add src/bus src/task_supervisor.rs src/session/session.rs src/gateway && git commit -m "feat(gateway): runtime introspection for status endpoint"`
### Task 2.2: GET /api/status 聚合 handler
**Files:**
- Modify: `src/gateway/http.rs`handler
- Modify: `src/gateway/mod.rs`(注册路由)
- [ ] **Step 1: handler** — 在 `src/gateway/http.rs` 加(聚合所有来源;用 `serde_json::json!`
```rust
pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let reload = state.reload.status();
let metrics = crate::observability::metrics::global_metrics().snapshot();
let depths = state.bus().queue_depths();
let active_lanes = state.outbound_lanes.load(std::sync::atomic::Ordering::Relaxed);
let ws_connections = state.ws_connections.load(std::sync::atomic::Ordering::Relaxed);
let background_tasks = state.task_supervisor.running_count();
let sessions_total = state.session_manager.session_count().await;
let active_turns = state.session_manager.active_turn_count().await;
// 渠道状态
let mut channels = Vec::new();
for name in state.channel_manager.list_channel_names().await {
let running = state.channel_manager.get_channel(&name).await.map(|c| c.is_running()).unwrap_or(false);
channels.push(json!({ "name": name, "status": if running { "connected" } else { "stopped" } }));
}
// 调度器(失败数用 last_status 近似)
let jobs = state.storage.list_scheduled_jobs().await.map_err(ApiError::internal)?;
let failed_jobs = jobs.iter().filter(|j| matches!(j.last_status.as_deref(), Some("error") | Some("timeout") | Some("delivery_error"))).count();
let enabled = jobs.iter().filter(|j| j.enabled).count();
let next_run = jobs.iter().filter(|j| j.enabled).map(|j| j.next_run_at).min();
Ok(Json(json!({
"generation": reload.generation,
"version": env!("CARGO_PKG_VERSION"),
"uptime_secs": crate::gateway::process_uptime_secs(),
"phase": reload.phase,
"ws_connections": ws_connections,
"background_tasks": background_tasks,
"sessions": { "total": sessions_total, "active_turns": active_turns },
"metrics": {
"tokens_in": metrics.tokens_in,
"tokens_out": metrics.tokens_out,
"cost": metrics.cost,
"tool_calls": metrics.tool_calls,
"turns": metrics.turns,
"turn_latency_p95_ms": metrics.turn_latency_p95_ms,
},
"bus": {
"inbound": { "depth": depths.inbound_depth, "cap": depths.inbound_cap },
"outbound": { "depth": depths.outbound_depth, "cap": depths.outbound_cap },
"control": { "depth": depths.control_depth, "cap": depths.control_cap },
"active_lanes": active_lanes,
},
"providers": metrics.providers,
"channels": channels,
"scheduler": { "jobs": jobs.len(), "enabled": enabled, "failed_jobs": failed_jobs, "next_run_at": next_run },
"mcp": crate::mcp::get_mcp_status().iter().map(|s| json!({ "name": s.name, "connected": s.connected, "tools": s.tools.len() })).collect::<Vec<_>>(),
})))
}
```
`ReloadPhase``Serialize`snake_case`McpServerStatus` 字段见 `src/mcp/mod.rs:27-34``state.bus()`/`channel_manager`/`task_supervisor`/`session_manager`/`storage`/`reload` 均为 GatewayState 字段;`outbound_lane_count()` 按 Task 2.1 实际暴露方式调用。)
- [ ] **Step 2: 注册路由** — 在 `src/gateway/mod.rs` 的 protected router约 574-575 行 `/api/tasks`/`/api/jobs` 附近)加 `.route("/api/status", routing::get(http::get_status))`。确认在 `route_layer(require_auth)` 之内(设备鉴权)。
- [ ] **Step 3: 验证**`cargo build` + `cargo clippy -- -D warnings`。(可选:`cargo run -- gateway``curl` 需鉴权,目检 JSON 结构;或写一个轻量集成断言。无鉴权 token 时跳过 curl靠编译 + 结构审查。)
- [ ] **Step 4: Commit**`git add src/gateway && git commit -m "feat(gateway): GET /api/status runtime snapshot"`
### Task 2.3: GET /api/tools handler
**Files:**
- Modify: `src/gateway/http.rs`
- Modify: `src/gateway/mod.rs`(路由)
- [ ] **Step 1: handler** — 遍历 `state.session_manager.tools().iter()`(返回 `Vec<(String, Arc<dyn Tool>)>`,见 `src/tools/registry.rs:69-76`。source 由命名约定派生:名字含 `__``"mcp"`MCP 工具名为 `{server}__{tool}`,见 `src/mcp/tool_wrapper.rs:26`),否则 `"builtin"`。call_count 取 `global_metrics().tool_call_count(&name)`
```rust
pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let metrics = crate::observability::metrics::global_metrics();
let tools: Vec<Value> = state.session_manager.tools().iter().iter().map(|(name, tool)| {
let source = if name.contains("__") { "mcp" } else { "builtin" };
json!({
"name": name,
"description": tool.description(),
"parameters_schema": tool.parameters_schema(),
"source": source,
"read_only": tool.read_only(),
"exclusive": tool.exclusive(),
"concurrency_safe": tool.concurrency_safe(),
"call_count": metrics.tool_call_count(name),
})
}).collect();
Ok(Json(json!({ "tools": tools })))
}
```
- [ ] **Step 2: 注册路由** — protected router 加 `.route("/api/tools", routing::get(http::get_tools))`
- [ ] **Step 3: 验证**`cargo build` + `cargo clippy -- -D warnings`
- [ ] **Step 4: Commit**`git add src/gateway && git commit -m "feat(gateway): GET /api/tools with capability metadata"`
### Task 2.4: GET /api/skills handler+ SkillsLoader 访问器)
**Files:**
- Modify: `src/session/session.rs`(暴露 SkillsLoader 访问器)
- Modify: `src/gateway/http.rs`
- Modify: `src/gateway/mod.rs`(路由)
- [ ] **Step 1: 暴露 SkillsLoader**`SessionManager` 私有字段 `skills_loader: Arc<SkillsLoader>``session.rs:1423`)无公开访问器。加:
```rust
pub fn skills_loader(&self) -> Arc<crate::skills::SkillsLoader> {
self.skills_loader.clone()
}
```
- [ ] **Step 2: source 派生辅助**`Skill.path` 是技能目录source 由路径前缀派生(`~/.agents/skills``agent``~/.picobot/skills``picobot``{workspace}/skills``workspace`)。由于三个目录字段是 SkillsLoader 私有,**简化**:在 handler 里用 `Skill.path` 的字符串包含关系粗略派生(包含 `/.agents/skills`→agent`/.picobot/skills`→picobot否则 workspace/other。或在 SkillsLoader 加 `pub fn source_of(&self, path) -> &str` 辅助(更干净,推荐)。选择在 SkillsLoader 加辅助方法。
- [ ] **Step 3: handler** — 用 `get_loaded_skills()``src/skills/mod.rs:252`,返回 `Vec<Skill>`**不要**用 `list_skills()`)。默认不返回 `content`
```rust
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let loader = state.session_manager.skills_loader();
let skills: Vec<Value> = loader.get_loaded_skills().iter().map(|s| json!({
"name": s.name,
"description": s.description,
"always": s.always,
"source": loader.source_of(s.path.as_deref()),
})).collect();
Ok(Json(json!({ "skills": skills })))
}
```
- [ ] **Step 4: 注册路由** — protected router 加 `.route("/api/skills", routing::get(http::get_skills))`
- [ ] **Step 5: 验证**`cargo build` + `cargo test --lib` + `cargo clippy -- -D warnings`
- [ ] **Step 6: Commit**`git add src/session/session.rs src/skills/mod.rs src/gateway && git commit -m "feat(gateway): GET /api/skills"`
---
## Chunk 3: 前端
### Task 3.1: 可视化组件Sparkline / CapacityMeter / MetricTile
**Files:**
- Create: `webui/src/lib/components/Sparkline.svelte``CapacityMeter.svelte``MetricTile.svelte`
- [ ] **Step 1: Sparkline.svelte** — props `values: number[]``color: string`(默认 `var(--signal)`)。手写 SVG归一化 values 到 viewBox画折线/柱。无第三方库。
- [ ] **Step 2: CapacityMeter.svelte** — props `depth: number``cap: number``segments?: number`(默认 8。按 depth/cap 比例点亮分段块;接近满(>90%)用 `var(--accent)`/`var(--danger)`,否则 `var(--signal)`
- [ ] **Step 3: MetricTile.svelte** — props `label``value``sub`(可选小字)、`sparkValues`/`sparkColor`(可选)。大等宽数字(`var(--font-mono)`+ label-caps + 可选 sparkline。用 `.panel` 容器。
- [ ] **Step 4: 验证**`cd webui && npm run check && npm run build`
- [ ] **Step 5: Commit**`git add webui/src/lib/components && git commit -m "feat(webui): sparkline, capacity meter, metric tile components"`
### Task 3.2: 概览页(轮询 /api/status
**Files:**
- Create: `webui/src/pages/OverviewPage.svelte`
- Modify: `webui/src/lib/api.js`(如需 status 辅助;现有 `api()` 通用函数已够,可复用)
布局(规格 §6.2,参考 `.superpowers/brainstorm/111044-1784795642/pages-runtime.html` mockup
- 主状态条:`RUNNING`phase=active 时青绿脉冲、运行代、uptime格式化为 Xd Xh、版本、WS 连接、后台任务、上次重载相位。
- 指标块行MetricTile会话数、今日 Tokenin+out+cost sub、工具调用、今日 Turns+p95 sub
- Provider 表名称、模型、状态徽标ok=signal/degraded=accent、延迟 Sparkline、token、cost。
- 消息总线inbound/outbound/control 三个 CapacityMeter + 活跃 lane 数 + 调度器状态 + MCP 连接数。
- 渠道状态列表name + 状态徽标。
- 调度器jobs/enabled/failed_jobs/next_run格式化为倒计时或时间
- 数据:`onMount``setInterval` 每 2s `api("/api/status")`;卸载清除。用 `$state` 持有 status`$derived` 派生展示值。错误时显示离线态。
- [ ] **Step 1: 实现 OverviewPage.svelte**(轮询 + 上述布局,套用 Signal Deck tokens 与 P0 组件)。
- [ ] **Step 2: 验证**`npm run check && npm run build`
- [ ] **Step 3: Commit**`git add webui/src/pages/OverviewPage.svelte && git commit -m "feat(webui): overview runtime dashboard"`
### Task 3.3: 工具 & Skills 页
**Files:**
- Create: `webui/src/pages/ToolsPage.svelte`
布局(规格 §6.3,参考 `.superpowers/brainstorm/111044-1784795642/page-tools-v2.html` mockup
- 三标签:工具 / Skills / MCP。
- 工具标签:搜索框 + 能力筛选 chips全部/只读/可并发/有副作用/独占)+ 图例工具卡片网格名称、来源徽标builtin/mcp、能力徽标◇只读=signal / ⇉可并发=info / △有副作用=accent / ■独占=danger、调用次数、描述、可展开 `<details>` 参数 schema`<pre>` mono
- Skills 标签名称、描述、always 徽标、来源。
- MCP 标签:服务器名 + 连接状态徽标 + 工具数。
- 数据:`onMount``api("/api/tools")` + `api("/api/skills")`MCP 从 `api("/api/status")``mcp` 字段取一次即可MCP 状态为连接时快照)。能力筛选与搜索为前端纯派生(`$derived` 过滤)。
- [ ] **Step 1: 实现 ToolsPage.svelte**(三标签 + 搜索 + 能力筛选 + 卡片)。
- [ ] **Step 2: 验证**`npm run check && npm run build`
- [ ] **Step 3: Commit**`git add webui/src/pages/ToolsPage.svelte && git commit -m "feat(webui): tools and skills browser"`
### Task 3.4: App.svelte 接线(替换占位)
**Files:**
- Modify: `webui/src/App.svelte`
- [ ] **Step 1: 接线** — import `OverviewPage``ToolsPage`;把 `overview`/`tools``{:else}` 占位(`即将上线`)替换为对应页面渲染(`{:else if current === "overview"}<OverviewPage />` `{:else if current === "tools"}<ToolsPage />`)。保留其余页面分支。
- [ ] **Step 2: 验证**`npm run check && npm run build`
- [ ] **Step 3: 目检**(可选,需 gateway— 概览页显示实时指标、工具页列出工具/Skills/MCP、亮暗主题正常。
- [ ] **Step 4: Commit**`git add webui/src/App.svelte && git commit -m "feat(webui): wire overview and tools pages"`
---
## Chunk 4: 收尾
### Task 4.1: P1 收尾验证 + 版本号
- [ ] **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 bump1.4.0 → 1.5.0)。检查 README 是否有需更新的观测能力描述(如有则最小化更新)。
- [ ] **Step 3: Commit**`git add -A && git commit -m "chore(release): P1 observability"`(仅暂存版本号文件 + 可能的 README确认无构建产物
---
## P1 完成标志
- `GET /api/status` 返回完整运行快照generation/uptime/sessions/metrics/bus/providers/channels/scheduler/mcp/ws_connections/background_tasks设备鉴权保护。
- `GET /api/tools` 返回工具 + 能力字段read_only/exclusive/concurrency_safe+ call_count + source。
- `GET /api/skills` 返回 name/description/always/source不含 content
- 概览页每 2s 轮询并渲染仪表盘;工具页三标签 + 搜索 + 能力筛选。
- Metrics 进程级、跨重载存活、纯内存。
- `npm run check``npm run build``cargo build``cargo test --lib``cargo clippy -- -D warnings` 全绿。
## 风险与开放项
- **cost 暂未接入单价**AgentLoop 埋点 cost 传 None拿不到 provider config 单价),故 `/api/status` 的 cost 恒为 0除非后续把 `(price_in, price_out)` 传入 AgentLoop。可选价格字段已加Task 1.2),接线留待需要时补。若评审认为 P1 必须出 cost则在 Task 1.3 把单价随 AgentLoop 构造传入(需从 Session 持有的 provider config 取)。
- **failed_jobs 为近似**:用 last_status 而非严格 7 天窗口(避免 2s 轮询逐任务查运行记录。UI 需标注语义。
- **active_lanes 计数**:依赖 dispatcher lane 生成/结束正确 +1/-1多 break 路径需保证减计数(用 guard 或单一出口)。
- **Metrics 全局态**选择进程级全局precedented by MCP非穿线若评审偏好显式依赖注入可改为经 SessionManagerServices 穿线成本5 个 AgentLoop 构造点 + 多处结构体字段)。
- **provider status 派生阈值**:最近 10 次 ≥3 失败 → degraded阈值可调。

View File

@ -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<broadcast::Sender<LogEvent>>``init_logging()` 时初始化。`/ws/logs` 独立 WebSocket 端点复用现有 `require_auth` 中间件,连接后订阅广播、按 level/search 过滤推送。记忆写入复用已有 `Storage::upsert_memory`/`delete_memory`。前端三页重构为 Svelte 5 runes 组件。
**Tech Stack:** Rusttracing-subscriber Layer trait、tokio broadcast、Axum WebSocket、Svelte 5runes、bits-ui。
**关键设计决策(务必遵守):**
1. **广播层全局**`src/logging.rs` 增加 `static LOG_TX: OnceLock<broadcast::Sender<LogEvent>>` + `pub fn log_sender() -> Option<broadcast::Sender<LogEvent>>``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.5P1 计划 `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<broadcast::Sender<LogEvent>> = OnceLock::new();
pub fn log_sender() -> Option<broadcast::Sender<LogEvent>> {
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<S: tracing::Subscriber> Layer<S> 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` cratetracing-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<String>,
search: Option<String>,
}
pub async fn ws_logs_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsLogsQuery>,
Extension(_identity): Extension<super::auth::AuthIdentity>,
) -> 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<f64>,
}
pub async fn put_memory(
State(state): State<Arc<GatewayState>>,
Path(key): Path<String>,
Json(body): Json<PutMemoryBody>,
) -> Result<Json<Value>, 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<Option<MemoryEntry>, 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<Arc<GatewayState>>,
Path(key): Path<String>,
) -> Result<Json<Value>, 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+ 排序(最近更新)。
- 记忆卡片列表keymono 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 bump1.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/importancekey 不存在返回 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。

View File

@ -0,0 +1,67 @@
# P3 配置 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan.
**Goal:** 重构 WebUI 配置页SettingsPage增加实时 JSON 校验、配置大纲、重载状态卡、保存并热重载、放弃修改、未保存提示。
**Architecture:** 纯前端重构。所有后端端点已存在GET/PUT /api/config、GET/PUT /api/profiles/{name}、POST /api/config/reload、GET /api/config/reload/status。SettingsPage.svelte 重写为带配置大纲侧栏 + 重载状态卡 + 三操作按钮的布局。
**Tech Stack:** Svelte 5runes、bits-ui Tabs。
**关键设计决策:**
1. **布局**:左侧编辑区(现有 editor-card+ 右侧大纲/状态侧栏(仅 config.json 标签显示)。
2. **JSON 校验**`$derived` 实时 parse content显示错误信息或有效状态。
3. **配置大纲**:从 parsed config 提取顶层 key 列表,标注含密钥的 sectionproviders和需重启的字段gateway.host/port/workspace
4. **重载状态卡**onMount 起每 3s 轮询 `GET /api/config/reload/status`,显示 generation、phase badge、last_error。
5. **操作按钮**保存PUT /api/config、保存并热重载PUT 后 POST /api/config/reload、放弃修改重新 load
6. **未保存提示**:比较当前 content 与上次加载的 content不同则显示修改标记。
7. **USER.md / AGENTS.md 标签**:保持简单 textarea + 保存,无大纲/重载。
**验证约定:** `cd webui && npm run check && npm run build` + `cargo build`(验证 OUT_DIR 嵌入)。
---
## Task 1: SettingsPage 重构
**Files:**
- Modify: `webui/src/pages/SettingsPage.svelte`
- [ ] **Step 1: 重写 SettingsPage.svelte**
布局:
```
┌─────────────────────────────────────────────────────┐
│ [config.json] [USER.md] [AGENTS.md] [重载状态卡] │
├───────────────────────────────────┬─────────────────┤
│ editor-head: title + path │ 配置大纲 │
│ + 未保存标记 │ (仅 config) │
│ ───────────────────────────── │ │
│ textarea (JSON/Markdown) │ gateway ⚠重启 │
│ │ providers 🔑 │
│ │ agent │
│ │ channels │
│ │ memory │
│ │ scheduler │
│ ───────────────────────────── │ │
│ [保存] [保存并热重载] [放弃修改] │ │
│ notice / validation error │ │
└───────────────────────────────────┴─────────────────┘
```
功能:
- JSON 实时校验config 标签parse 失败显示红色错误行
- 未保存修改dirty 标记(对比 original content
- 保存PUT /api/config 或 PUT /api/profiles/{name}
- 保存并热重载:保存成功后 POST /api/config/reload显示结果
- 放弃修改:恢复为上次加载的 content
- 重载状态卡generation、phasebadge 着色、last_error
- 配置大纲:顶层 key 列表 + 标注
- [ ] **Step 2: 验证**`cd webui && npm run check && npm run build`
- [ ] **Step 3: Commit**`git add webui/src/pages/SettingsPage.svelte && git commit -m "feat(webui): enhanced settings page with outline, reload status, hot reload"`
## Task 2: 收尾
- [ ] **Step 1: 全量验证**`cargo build` + `cd webui && npm run check && npm run build`
- [ ] **Step 2: 版本号** — 1.6.0 → 1.7.0
- [ ] **Step 3: Commit**`git add Cargo.toml webui/package.json && git commit -m "chore(release): P3 configuration"`

View File

@ -491,16 +491,41 @@ impl AgentLoop {
iteration: u32,
turn: Option<&AgentTurnContext>,
) -> Result<ChatCompletionResponse, AgentError> {
let mut provider_stream = self.provider.stream(request).await.map_err(|error| {
tracing::error!(error = %error, "LLM request failed");
AgentError::LlmError(error.to_string())
})?;
let metrics = crate::observability::metrics::global_metrics();
let provider_name = self.provider.name().to_string();
let provider_model = self.provider.model_id().to_string();
let start = Instant::now();
let mut provider_stream = match self.provider.stream(request).await {
Ok(stream) => stream,
Err(error) => {
tracing::error!(error = %error, "LLM request failed");
metrics.record_provider(
&provider_name,
&provider_model,
None,
start.elapsed().as_millis() as u64,
true,
);
return Err(AgentError::LlmError(error.to_string()));
}
};
let mut accumulator = ProviderResponseAccumulator::default();
while let Some(chunk) = provider_stream.next().await {
let chunk = chunk.map_err(|error| {
tracing::error!(error = %error, "LLM stream failed");
AgentError::LlmError(error.to_string())
})?;
let chunk = match chunk {
Ok(chunk) => chunk,
Err(error) => {
tracing::error!(error = %error, "LLM stream failed");
metrics.record_provider(
&provider_name,
&provider_model,
None,
start.elapsed().as_millis() as u64,
true,
);
return Err(AgentError::LlmError(error.to_string()));
}
};
if let Some(turn) = turn {
let event = match &chunk {
ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
@ -521,7 +546,11 @@ impl AgentLoop {
}
accumulator.push(chunk);
}
Ok(accumulator.finish())
let response = accumulator.finish();
let latency_ms = start.elapsed().as_millis() as u64;
metrics.record_provider(&provider_name, &provider_model, None, latency_ms, false);
metrics.record_provider_tokens(&provider_name, &response.usage);
Ok(response)
}
fn annotate_message(
@ -615,6 +644,8 @@ impl AgentLoop {
mut messages: Vec<ChatMessage>,
turn: Option<AgentTurnContext>,
) -> Result<AgentProcessResult, AgentError> {
let turn_start = Instant::now();
#[cfg(debug_assertions)]
tracing::debug!(
history_len = messages.len(),
@ -703,6 +734,10 @@ impl AgentLoop {
assistant_message.provider_state = response.provider_state;
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
emitted_messages.push(assistant_message.clone());
crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64,
);
return Ok(AgentProcessResult {
final_response: assistant_message,
emitted_messages,
@ -844,6 +879,10 @@ impl AgentLoop {
true,
);
emitted_messages.push(assistant_message.clone());
crate::observability::metrics::global_metrics().record_turn(
Some(&accumulated_usage),
turn_start.elapsed().as_millis() as u64,
);
Ok(AgentProcessResult {
final_response: assistant_message,
emitted_messages,
@ -876,6 +915,11 @@ impl AgentLoop {
let mut final_message = ChatMessage::assistant(fallback);
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
emitted_messages.push(final_message.clone());
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
crate::observability::metrics::global_metrics().record_turn(
turn_usage,
turn_start.elapsed().as_millis() as u64,
);
Ok(AgentProcessResult {
final_response: final_message,
emitted_messages,
@ -1016,6 +1060,9 @@ impl AgentLoop {
});
}
crate::observability::metrics::global_metrics()
.record_tool_call(&tool_name, result.success);
// Apply duration
Ok(ToolExecutionOutcome { duration, ..result })
}

View File

@ -832,6 +832,8 @@ mod tests {
token_limit: 4096,
workspace_dir: std::env::temp_dir(),
input_types: vec!["text".into()],
price_input_per_million: None,
price_output_per_million: None,
},
Arc::new(ToolRegistry::new()),
None,

View File

@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::mpsc;
@ -22,6 +23,7 @@ pub struct OutboundDispatcher {
channel_manager: ChannelManager,
task_supervisor: TaskSupervisor,
write_locks: ConversationWriteLocks,
active_lanes: Arc<AtomicUsize>,
}
impl OutboundDispatcher {
@ -30,12 +32,14 @@ impl OutboundDispatcher {
channel_manager: ChannelManager,
task_supervisor: TaskSupervisor,
write_locks: ConversationWriteLocks,
active_lanes: Arc<AtomicUsize>,
) -> Self {
Self {
bus,
channel_manager,
task_supervisor,
write_locks,
active_lanes,
}
}
@ -125,9 +129,14 @@ impl OutboundDispatcher {
chat_id: String,
) -> bool {
let target_lock = self.write_locks.for_target(&channel_name, &chat_id);
let active_lanes = self.active_lanes.clone();
self.task_supervisor.spawn(
format!("outbound-lane:{channel_name}:{chat_id}"),
async move {
active_lanes.fetch_add(1, Ordering::Relaxed);
let _guard = LaneGuard {
counter: active_lanes,
};
loop {
let msg = match tokio::time::timeout(LANE_IDLE_TIMEOUT, receiver.recv()).await {
Ok(Some(msg)) => msg,
@ -180,6 +189,18 @@ impl OutboundDispatcher {
}
}
/// Decrements the active-lane counter exactly once when a lane task ends,
/// whether it exits normally, is cancelled, or is aborted.
struct LaneGuard {
counter: Arc<AtomicUsize>,
}
impl Drop for LaneGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -275,6 +296,7 @@ mod tests {
manager,
supervisor.clone(),
ConversationWriteLocks::default(),
Arc::new(AtomicUsize::new(0)),
);
let task = tokio::spawn(async move { dispatcher.run().await });
bus.publish_outbound(outbound("slow", "slow-1"))
@ -318,6 +340,7 @@ mod tests {
manager,
supervisor.clone(),
ConversationWriteLocks::default(),
Arc::new(AtomicUsize::new(0)),
);
let task = tokio::spawn(async move { dispatcher.run().await });
@ -348,6 +371,7 @@ mod tests {
manager,
supervisor.clone(),
ConversationWriteLocks::default(),
Arc::new(AtomicUsize::new(0)),
);
let task = tokio::spawn(async move { dispatcher.run().await });
@ -360,6 +384,50 @@ mod tests {
supervisor.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn active_lane_count_tracks_task_lifetime() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
manager
.register_channel(
"recording",
Arc::new(RecordingChannel {
sent: Mutex::new(Vec::new()),
notify: Notify::new(),
}),
)
.await;
let supervisor = TaskSupervisor::new();
let active_lanes = Arc::new(AtomicUsize::new(0));
let dispatcher = OutboundDispatcher::new(
bus.clone(),
manager,
supervisor.clone(),
ConversationWriteLocks::default(),
active_lanes.clone(),
);
let dispatcher_task = tokio::spawn(async move { dispatcher.run().await });
bus.publish_outbound(outbound("counted", "message"))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(1), async {
while active_lanes.load(Ordering::Relaxed) != 1 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
supervisor.shutdown(Duration::from_secs(1)).await;
assert_eq!(active_lanes.load(Ordering::Relaxed), 0);
dispatcher_task.abort();
let _ = dispatcher_task.await;
}
#[tokio::test]
async fn permanent_send_failure_is_not_retried() {
let channel = PermanentFailureChannel {

View File

@ -106,6 +106,29 @@ impl MessageBus {
pub async fn consume_control(&self) -> Option<ControlMessage> {
self.control_rx.lock().await.recv().await
}
/// Snapshot of the current depth and capacity of each bus queue.
pub fn queue_depths(&self) -> QueueDepths {
QueueDepths {
inbound_depth: (self.inbound_tx.max_capacity() - self.inbound_tx.capacity()) as u64,
inbound_cap: self.inbound_tx.max_capacity() as u64,
outbound_depth: (self.outbound_tx.max_capacity() - self.outbound_tx.capacity()) as u64,
outbound_cap: self.outbound_tx.max_capacity() as u64,
control_depth: (self.control_tx.max_capacity() - self.control_tx.capacity()) as u64,
control_cap: self.control_tx.max_capacity() as u64,
}
}
}
/// Read-only snapshot of MessageBus queue utilization.
#[derive(serde::Serialize)]
pub struct QueueDepths {
pub inbound_depth: u64,
pub inbound_cap: u64,
pub outbound_depth: u64,
pub outbound_cap: u64,
pub control_depth: u64,
pub control_cap: u64,
}
// ============================================================================
@ -130,3 +153,38 @@ impl std::fmt::Display for BusError {
}
impl std::error::Error for BusError {}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[tokio::test]
async fn queue_depths_report_retained_sender_usage() {
let bus = MessageBus::new(3);
let empty = bus.queue_depths();
assert_eq!(empty.inbound_depth, 0);
assert_eq!(empty.inbound_cap, 3);
assert_eq!(empty.outbound_depth, 0);
assert_eq!(empty.outbound_cap, 3);
assert_eq!(empty.control_depth, 0);
assert_eq!(empty.control_cap, 3);
bus.publish_outbound(OutboundMessage {
channel: "test".to_string(),
chat_id: "chat".to_string(),
content: "queued".to_string(),
reply_to: None,
media: vec![],
metadata: HashMap::new(),
delivery: None,
})
.await
.unwrap();
assert_eq!(bus.queue_depths().outbound_depth, 1);
bus.consume_outbound().await.unwrap();
assert_eq!(bus.queue_depths().outbound_depth, 0);
}
}

View File

@ -508,6 +508,19 @@ pub struct LLMProviderConfig {
pub token_limit: usize,
pub workspace_dir: PathBuf,
pub input_types: Vec<String>,
pub price_input_per_million: Option<f64>,
pub price_output_per_million: Option<f64>,
}
impl LLMProviderConfig {
pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option<f64> {
match (self.price_input_per_million, self.price_output_per_million) {
(Some(pi), Some(po)) => Some(
prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po,
),
_ => None,
}
}
}
pub fn get_default_config_path() -> PathBuf {
@ -653,6 +666,8 @@ impl Config {
token_limit: agent.token_limit,
workspace_dir: expand_path(&self.workspace_dir),
input_types: model.input_type.clone(),
price_input_per_million: None,
price_output_per_million: None,
})
}
}

View File

@ -169,6 +169,14 @@ impl ApiError {
message: error.to_string(),
}
}
fn internal_with_message(error: impl std::fmt::Display, message: impl Into<String>) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
pub async fn upload_file(
@ -689,6 +697,170 @@ pub struct LimitQuery {
limit: Option<usize>,
}
fn scheduler_snapshot(jobs: &[crate::storage::ScheduledJob]) -> Value {
let enabled = jobs.iter().filter(|job| job.enabled).count();
let failed_jobs = jobs
.iter()
.filter(|job| {
matches!(
job.last_status.as_deref(),
Some("error" | "timeout" | "delivery_error")
)
})
.count();
let next_run_at = jobs
.iter()
.filter(|job| job.enabled)
.map(|job| job.next_run_at)
.min();
json!({
"jobs": jobs.len(),
"enabled": enabled,
"failed_jobs": failed_jobs,
"next_run_at": next_run_at,
})
}
fn scheduler_status_error(error: impl std::fmt::Display) -> ApiError {
ApiError::internal_with_message(error, "failed to load scheduler status")
}
fn channel_snapshot(mut channels: Vec<(String, bool)>) -> Vec<Value> {
channels.sort_by(|left, right| left.0.cmp(&right.0));
channels
.into_iter()
.map(|(name, running)| {
json!({
"name": name,
"status": if running { "connected" } else { "stopped" },
})
})
.collect()
}
fn sorted_providers(
mut providers: Vec<crate::observability::metrics::ProviderSnapshot>,
) -> Vec<crate::observability::metrics::ProviderSnapshot> {
providers.sort_by(|left, right| left.name.cmp(&right.name));
providers
}
pub async fn get_status(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let reload = state.reload.status();
let metrics = crate::observability::metrics::global_metrics().snapshot();
let depths = state.bus().queue_depths();
let active_lanes = state
.outbound_lanes
.load(std::sync::atomic::Ordering::Relaxed);
let ws_connections = state
.ws_connections
.load(std::sync::atomic::Ordering::Relaxed);
let background_tasks = state.task_supervisor.running_count();
let sessions_total = state.session_manager.session_count().await;
let active_turns = state.session_manager.active_turn_count().await;
let mut channel_states = Vec::new();
for name in state.channel_manager.list_channel_names().await {
let running = state
.channel_manager
.get_channel(&name)
.await
.is_some_and(|channel| channel.is_running());
channel_states.push((name, running));
}
let channels = channel_snapshot(channel_states);
let jobs = state
.storage
.list_scheduled_jobs()
.await
.map_err(scheduler_status_error)?;
let scheduler = scheduler_snapshot(&jobs);
let mcp = crate::mcp::get_mcp_status()
.into_iter()
.map(|status| {
json!({
"name": status.name,
"connected": status.connected,
"tools": status.tools.len(),
})
})
.collect::<Vec<_>>();
Ok(Json(json!({
"generation": reload.generation,
"version": env!("CARGO_PKG_VERSION"),
"uptime_secs": crate::gateway::process_uptime_secs(),
"phase": reload.phase,
"ws_connections": ws_connections,
"background_tasks": background_tasks,
"sessions": {
"total": sessions_total,
"active_turns": active_turns,
},
"metrics": {
"tokens_in": metrics.tokens_in,
"tokens_out": metrics.tokens_out,
"cost": metrics.cost,
"tool_calls": metrics.tool_calls,
"turns": metrics.turns,
"turn_latency_p95_ms": metrics.turn_latency_p95_ms,
},
"bus": {
"inbound": { "depth": depths.inbound_depth, "cap": depths.inbound_cap },
"outbound": { "depth": depths.outbound_depth, "cap": depths.outbound_cap },
"control": { "depth": depths.control_depth, "cap": depths.control_cap },
"active_lanes": active_lanes,
},
"providers": sorted_providers(metrics.providers),
"channels": channels,
"scheduler": scheduler,
"mcp": mcp,
})))
}
pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let metrics = crate::observability::metrics::global_metrics();
let registry = state.session_manager.tools();
let mut entries = registry.iter();
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
let tools: Vec<Value> = entries
.into_iter()
.map(|(name, tool)| {
let source = if name.contains("__") { "mcp" } else { "builtin" };
json!({
"name": name,
"description": tool.description(),
"parameters_schema": tool.parameters_schema(),
"source": source,
"read_only": tool.read_only(),
"exclusive": tool.exclusive(),
"concurrency_safe": tool.concurrency_safe(),
"call_count": metrics.tool_call_count(&name),
})
})
.collect();
Ok(Json(json!({ "tools": tools })))
}
pub async fn get_skills(State(state): State<Arc<GatewayState>>) -> Result<Json<Value>, ApiError> {
let loader = state.session_manager.skills_loader();
let skills: Vec<Value> = loader
.get_loaded_skills()
.iter()
.map(|s| {
json!({
"name": s.name,
"description": s.description,
"always": s.always,
"source": loader.source_of(s.path.as_deref()),
})
})
.collect();
Ok(Json(json!({ "skills": skills })))
}
pub async fn get_tasks(
State(state): State<Arc<GatewayState>>,
Query(query): Query<LimitQuery>,
@ -775,10 +947,164 @@ pub async fn get_memories(
Ok(Json(json!({ "memories": memories })))
}
#[derive(Deserialize)]
pub struct PutMemoryBody {
content: String,
importance: Option<f64>,
}
pub async fn put_memory(
State(state): State<Arc<GatewayState>>,
Path(key): Path<String>,
Json(body): Json<PutMemoryBody>,
) -> Result<Json<Value>, ApiError> {
let existing = state
.storage
.get_memory_by_key(&key)
.await
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("memory not found"))?;
let mut updated = existing;
updated.content = body.content;
if let Some(importance) = body.importance {
updated.importance = importance.clamp(0.0, 1.0);
}
updated.updated_at = chrono::Utc::now().to_rfc3339();
state
.storage
.upsert_memory(&updated)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "updated": true, "key": key })))
}
pub async fn delete_memory(
State(state): State<Arc<GatewayState>>,
Path(key): Path<String>,
) -> Result<Json<Value>, ApiError> {
state
.storage
.delete_memory(&key)
.await
.map_err(ApiError::internal)?;
Ok(Json(json!({ "deleted": true })))
}
#[cfg(test)]
mod tests {
use super::*;
fn scheduled_job(
id: &str,
enabled: bool,
next_run_at: i64,
last_status: Option<&str>,
) -> crate::storage::ScheduledJob {
crate::storage::ScheduledJob {
id: id.to_string(),
name: id.to_string(),
schedule: crate::scheduler::Schedule::Every { every_ms: 60_000 },
prompt: String::new(),
channel: "cli_chat".to_string(),
chat_id: "test".to_string(),
model: None,
job_kind: crate::storage::JobKind::Task,
delivery_policy: crate::storage::DeliveryPolicy::Never,
enabled,
delete_after_run: false,
next_run_at,
last_run_at: None,
last_status: last_status.map(str::to_string),
last_error: None,
created_at: 0,
updated_at: 0,
}
}
#[test]
fn scheduler_snapshot_classifies_failures_and_next_enabled_run() {
let jobs = vec![
scheduled_job("healthy", true, 300, Some("ok")),
scheduled_job("error", true, 200, Some("error")),
scheduled_job("timeout", false, 100, Some("timeout")),
scheduled_job("delivery", true, 400, Some("delivery_error")),
scheduled_job("other", false, 50, Some("cancelled")),
];
assert_eq!(
scheduler_snapshot(&jobs),
json!({
"jobs": 5,
"enabled": 3,
"failed_jobs": 3,
"next_run_at": 200,
})
);
}
#[test]
fn scheduler_snapshot_has_null_next_run_without_enabled_jobs() {
let jobs = vec![scheduled_job("disabled", false, 100, None)];
assert_eq!(
scheduler_snapshot(&jobs),
json!({
"jobs": 1,
"enabled": 0,
"failed_jobs": 0,
"next_run_at": null,
})
);
}
#[test]
fn channel_snapshot_is_sorted_and_maps_running_state() {
let channels = channel_snapshot(vec![
("zeta".to_string(), false),
("alpha".to_string(), true),
]);
assert_eq!(
channels,
vec![
json!({ "name": "alpha", "status": "connected" }),
json!({ "name": "zeta", "status": "stopped" }),
]
);
}
#[test]
fn provider_snapshot_is_sorted_by_name() {
let provider = |name: &str| crate::observability::metrics::ProviderSnapshot {
name: name.to_string(),
model: String::new(),
status: "ok".to_string(),
latency_ms: 0,
latencies: Vec::new(),
tokens_in: 0,
tokens_out: 0,
cost: 0.0,
};
let providers = sorted_providers(vec![provider("zeta"), provider("alpha")]);
assert_eq!(
providers
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
vec!["alpha", "zeta"]
);
}
#[test]
fn scheduler_status_error_hides_internal_details() {
let error = scheduler_status_error("database contained private payload");
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(error.message, "failed to load scheduler status");
assert!(!error.message.contains("private payload"));
}
#[test]
fn secrets_are_redacted_and_restored() {
let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});

View File

@ -8,6 +8,7 @@ pub mod ws;
use axum::{Router, middleware, routing};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use tokio::net::TcpListener;
use crate::bus::{MessageBus, OutboundDispatcher};
@ -21,6 +22,18 @@ use crate::scheduler::Scheduler;
use crate::session::{SessionManager, SessionManagerServices};
use crate::task_supervisor::TaskSupervisor;
/// Process boot clock. A process-level static so uptime survives config reload,
/// which swaps GatewayState generations without restarting the process.
static STARTED: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
/// Seconds elapsed since the gateway process started.
pub fn process_uptime_secs() -> u64 {
STARTED
.get_or_init(std::time::Instant::now)
.elapsed()
.as_secs()
}
pub struct GatewayState {
pub config: Config,
pub config_path: std::path::PathBuf,
@ -33,6 +46,10 @@ pub struct GatewayState {
pub connection_shutdown: tokio_util::sync::CancellationToken,
pub auth: auth::AuthManager,
pub uploads: uploads::UploadRegistry,
/// Live WebSocket connection count.
pub ws_connections: Arc<AtomicUsize>,
/// Active outbound dispatcher lane count (shared with the dispatcher).
pub outbound_lanes: Arc<AtomicUsize>,
pub(crate) reload: reload::ReloadHandle,
pub(crate) admission: reload::RuntimeAdmission,
}
@ -231,6 +248,8 @@ impl GatewayState {
connection_shutdown,
auth,
uploads,
ws_connections: Arc::new(AtomicUsize::new(0)),
outbound_lanes: Arc::new(AtomicUsize::new(0)),
reload,
admission,
})
@ -312,6 +331,7 @@ impl GatewayState {
self.channel_manager.clone(),
self.task_supervisor.clone(),
self.delivery_coordinator.write_locks(),
self.outbound_lanes.clone(),
);
self.task_supervisor
@ -341,6 +361,7 @@ pub async fn run(
host: Option<String>,
port: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
STARTED.get_or_init(std::time::Instant::now);
let config_path = crate::config::resolve_default_config_path();
let startup_process_env = Config::startup_process_env();
let startup_cwd = std::env::current_dir()?;
@ -572,9 +593,16 @@ fn build_router(state: Arc<GatewayState>) -> Router {
)
.route("/api/logs", routing::get(http::get_logs))
.route("/api/tasks", routing::get(http::get_tasks))
.route("/api/status", routing::get(http::get_status))
.route("/api/tools", routing::get(http::get_tools))
.route("/api/skills", routing::get(http::get_skills))
.route("/api/jobs", routing::get(http::get_jobs))
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
.route("/api/memories", routing::get(http::get_memories))
.route(
"/api/memories/{key}",
routing::put(http::put_memory).delete(http::delete_memory),
)
.route(
"/api/chat/{client_id}/uploads",
routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()),
@ -584,6 +612,7 @@ fn build_router(state: Arc<GatewayState>) -> Router {
routing::get(http::download_attachment),
)
.route("/ws", routing::get(ws::ws_handler))
.route("/ws/logs", routing::get(ws::ws_logs_handler))
.route_layer(middleware::from_fn_with_state(
state.auth.clone(),
auth::require_auth,

View File

@ -7,6 +7,7 @@ use axum::response::Response;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
@ -42,6 +43,8 @@ async fn handle_socket(
client_id: Option<String>,
identity: super::auth::AuthIdentity,
) {
let _connection_guard = ConnectionGuard::new(state.ws_connections.clone());
// Create channel for sending outbound messages to this client
let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100);
@ -131,6 +134,119 @@ async fn handle_socket(
tracing::info!(session_id = %session_id, "CLI session ended");
}
struct ConnectionGuard {
counter: Arc<AtomicUsize>,
}
impl ConnectionGuard {
fn new(counter: Arc<AtomicUsize>) -> Self {
counter.fetch_add(1, Ordering::Relaxed);
Self { counter }
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::Relaxed);
}
}
#[derive(Debug, Default, Deserialize)]
pub struct WsLogsQuery {
level: Option<String>,
search: Option<String>,
}
pub async fn ws_logs_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsLogsQuery>,
Extension(_identity): Extension<super::auth::AuthIdentity>,
) -> 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
&& !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(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::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 {
"TRACE" => 0,
"DEBUG" => 1,
"INFO" => 2,
"WARN" => 3,
"ERROR" => 4,
_ => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -145,4 +261,14 @@ mod tests {
assert!(valid_client_id(Some("x".repeat(65))).is_none());
assert!(valid_client_id(Some(String::new())).is_none());
}
#[test]
fn connection_guard_tracks_its_scope() {
let connections = Arc::new(AtomicUsize::new(0));
{
let _guard = ConnectionGuard::new(connections.clone());
assert_eq!(connections.load(Ordering::Relaxed), 1);
}
assert_eq!(connections.load(Ordering::Relaxed), 0);
}
}

View File

@ -1,27 +1,93 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use tokio::sync::broadcast;
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
use tracing_subscriber::{
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
};
/// Get the default log directory path: ~/.picobot/logs
#[derive(Clone, Debug)]
pub struct LogEvent {
pub ts: String,
pub level: String,
pub target: String,
pub message: String,
}
static LOG_TX: OnceLock<broadcast::Sender<LogEvent>> = OnceLock::new();
pub fn log_sender() -> Option<broadcast::Sender<LogEvent>> {
LOG_TX.get().cloned()
}
struct MessageVisitor(String);
impl Visit for MessageVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0 = format!("{value:?}");
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.0 = value.to_string();
}
}
}
struct BroadcastLayer;
impl<S> Layer<S> for BroadcastLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &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 ts = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc())
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default();
let _ = tx.send(LogEvent {
ts,
level: event.metadata().level().to_string(),
target: event.metadata().target().to_string(),
message: visitor.0,
});
}
}
pub fn get_default_log_dir() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("logs")
}
/// Get the default config file path: ~/.picobot/config.json
pub fn get_default_config_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".picobot").join("config.json")
}
/// Initialize logging with file appender
/// Logs are written to ~/.picobot/logs/ with daily rotation
pub fn init_logging() {
let (tx, _) = broadcast::channel::<LogEvent>(1024);
let _ = LOG_TX.set(tx);
let log_dir = get_default_log_dir();
// Create log directory if it doesn't exist
if !log_dir.exists()
&& let Err(e) = std::fs::create_dir_all(&log_dir)
{
@ -32,10 +98,8 @@ pub fn init_logging() {
);
}
// Create file appender with daily rotation
let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "picobot.log");
// Build subscriber with both console and file output
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let file_layer = fmt::layer()
@ -55,6 +119,7 @@ pub fn init_logging() {
.with(env_filter)
.with(console_layer)
.with(file_layer)
.with(BroadcastLayer)
.init();
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());

View File

@ -0,0 +1,295 @@
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, Mutex, OnceLock};
use crate::providers::Usage;
const WINDOW: usize = 100;
const DEGRADE_THRESHOLD: usize = 3;
const DEGRADE_WINDOW: usize = 10;
#[derive(Default)]
struct ProviderStat {
model: String,
tokens_in: u64,
tokens_out: u64,
cost: f64,
calls: u64,
last_latency_ms: u64,
latencies: VecDeque<u64>,
recent_results: VecDeque<bool>,
}
pub struct Metrics {
tokens_in: AtomicU64,
tokens_out: AtomicU64,
turns: AtomicU64,
tool_calls: AtomicU64,
per_tool: Mutex<HashMap<String, u64>>,
turn_latencies: Mutex<VecDeque<u64>>,
providers: Mutex<HashMap<String, ProviderStat>>,
}
#[derive(serde::Serialize)]
pub struct MetricsSnapshot {
pub tokens_in: u64,
pub tokens_out: u64,
pub cost: f64,
pub turns: u64,
pub tool_calls: u64,
pub turn_latency_p95_ms: u64,
pub per_tool: HashMap<String, u64>,
pub providers: Vec<ProviderSnapshot>,
}
#[derive(serde::Serialize)]
pub struct ProviderSnapshot {
pub name: String,
pub model: String,
pub status: String,
pub latency_ms: u64,
pub latencies: Vec<u64>,
pub tokens_in: u64,
pub tokens_out: u64,
pub cost: f64,
}
impl Default for Metrics {
fn default() -> Self {
Self::new()
}
}
impl Metrics {
pub fn new() -> Self {
Self {
tokens_in: AtomicU64::new(0),
tokens_out: AtomicU64::new(0),
turns: AtomicU64::new(0),
tool_calls: AtomicU64::new(0),
per_tool: Mutex::new(HashMap::new()),
turn_latencies: Mutex::new(VecDeque::new()),
providers: Mutex::new(HashMap::new()),
}
}
pub fn record_turn(&self, usage: Option<&Usage>, latency_ms: u64) {
self.turns.fetch_add(1, Relaxed);
if let Some(u) = usage {
self.tokens_in.fetch_add(u64::from(u.prompt_tokens), Relaxed);
self.tokens_out
.fetch_add(u64::from(u.completion_tokens), Relaxed);
}
let mut q = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
q.push_back(latency_ms);
while q.len() > WINDOW {
q.pop_front();
}
}
pub fn record_tool_call(&self, name: &str, _success: bool) {
self.tool_calls.fetch_add(1, Relaxed);
*self
.per_tool
.lock()
.unwrap_or_else(|e| e.into_inner())
.entry(name.to_string())
.or_insert(0) += 1;
}
pub fn record_provider(
&self,
name: &str,
model: &str,
cost: Option<f64>,
latency_ms: u64,
is_error: bool,
) {
let mut map = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let stat = map.entry(name.to_string()).or_insert_with(|| ProviderStat {
model: model.to_string(),
..Default::default()
});
stat.model = model.to_string();
stat.calls += 1;
stat.last_latency_ms = latency_ms;
stat.latencies.push_back(latency_ms);
while stat.latencies.len() > WINDOW {
stat.latencies.pop_front();
}
stat.recent_results.push_back(is_error);
while stat.recent_results.len() > DEGRADE_WINDOW {
stat.recent_results.pop_front();
}
if let Some(c) = cost {
stat.cost += c;
}
}
pub fn record_provider_tokens(&self, name: &str, usage: &Usage) {
let mut map = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let stat = map.entry(name.to_string()).or_default();
stat.tokens_in += u64::from(usage.prompt_tokens);
stat.tokens_out += u64::from(usage.completion_tokens);
}
pub fn tool_call_count(&self, name: &str) -> u64 {
*self
.per_tool
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(name)
.unwrap_or(&0)
}
pub fn snapshot(&self) -> MetricsSnapshot {
let latencies = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
let p95 = percentile_95(&latencies);
let providers = self.providers.lock().unwrap_or_else(|e| e.into_inner());
let mut cost = 0.0;
let provider_snaps = providers
.iter()
.map(|(name, s)| {
cost += s.cost;
let errors = s.recent_results.iter().filter(|e| **e).count();
ProviderSnapshot {
name: name.clone(),
model: s.model.clone(),
status: if s.recent_results.len() >= DEGRADE_WINDOW
&& errors >= DEGRADE_THRESHOLD
{
"degraded".to_string()
} else {
"ok".to_string()
},
latency_ms: s.last_latency_ms,
latencies: s.latencies.iter().copied().collect(),
tokens_in: s.tokens_in,
tokens_out: s.tokens_out,
cost: s.cost,
}
})
.collect();
MetricsSnapshot {
tokens_in: self.tokens_in.load(Relaxed),
tokens_out: self.tokens_out.load(Relaxed),
cost,
turns: self.turns.load(Relaxed),
tool_calls: self.tool_calls.load(Relaxed),
turn_latency_p95_ms: p95,
per_tool: self
.per_tool
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
providers: provider_snaps,
}
}
}
fn percentile_95(values: &VecDeque<u64>) -> u64 {
if values.is_empty() {
return 0;
}
let mut sorted: Vec<u64> = values.iter().copied().collect();
sorted.sort_unstable();
let idx = ((sorted.len() as f64 * 0.95).ceil() as usize)
.saturating_sub(1)
.min(sorted.len() - 1);
sorted[idx]
}
static GLOBAL: OnceLock<Arc<Metrics>> = OnceLock::new();
pub fn global_metrics() -> Arc<Metrics> {
GLOBAL.get_or_init(|| Arc::new(Metrics::new())).clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_turns_tokens_and_p95() {
let m = Metrics::new();
for i in 1..=100u64 {
m.record_turn(
Some(&Usage {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
..Default::default()
}),
i,
);
}
let s = m.snapshot();
assert_eq!(s.turns, 100);
assert_eq!(s.tokens_in, 1000);
assert_eq!(s.tokens_out, 2000);
assert!(s.turn_latency_p95_ms >= 95);
}
#[test]
fn records_per_tool_counts() {
let m = Metrics::new();
m.record_tool_call("bash", true);
m.record_tool_call("bash", true);
m.record_tool_call("read_file", false);
let s = m.snapshot();
assert_eq!(s.tool_calls, 3);
assert_eq!(s.per_tool.get("bash"), Some(&2));
assert_eq!(s.per_tool.get("read_file"), Some(&1));
}
#[test]
fn records_provider_tokens_and_cost() {
let m = Metrics::new();
m.record_provider("openai", "gpt-4o", Some(0.05), 120, false);
m.record_provider_tokens(
"openai",
&Usage {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
..Default::default()
},
);
let s = m.snapshot();
assert_eq!(s.cost, 0.05);
let p = s.providers.iter().find(|p| p.name == "openai").unwrap();
assert_eq!(p.tokens_in, 100);
assert_eq!(p.tokens_out, 50);
assert_eq!(p.latency_ms, 120);
assert_eq!(p.status, "ok");
}
#[test]
fn derives_provider_status() {
let m = Metrics::new();
for _ in 0..7 {
m.record_provider("openai", "gpt-4o", None, 100, false);
}
for _ in 0..3 {
m.record_provider("openai", "gpt-4o", None, 100, true);
}
let s = m.snapshot();
let p = s.providers.iter().find(|p| p.name == "openai").unwrap();
assert_eq!(p.status, "degraded");
}
#[test]
fn tool_call_count_accessor() {
let m = Metrics::new();
assert_eq!(m.tool_call_count("bash"), 0);
m.record_tool_call("bash", true);
assert_eq!(m.tool_call_count("bash"), 1);
}
#[test]
fn global_metrics_returns_same_instance() {
let a = global_metrics();
let b = global_metrics();
assert!(Arc::ptr_eq(&a, &b));
}
}

View File

@ -3,6 +3,8 @@
//! This module provides an Observer pattern for emitting and collecting
//! telemetry events during agent execution.
pub mod metrics;
use std::time::Duration;
use crate::bus::MediaRef;

View File

@ -225,6 +225,8 @@ mod tests {
token_limit: 8_192,
workspace_dir: PathBuf::from("."),
input_types: vec!["text".to_string(), "image".to_string()],
price_input_per_million: None,
price_output_per_million: None,
};
let session = Arc::new(Mutex::new(
Session::new(

View File

@ -432,6 +432,7 @@ mod cancelled_partial_tests {
channels,
supervisor.clone(),
ConversationWriteLocks::default(),
Arc::new(std::sync::atomic::AtomicUsize::new(0)),
);
let dispatcher_task = tokio::spawn(async move { dispatcher.run().await });
@ -1707,6 +1708,10 @@ impl SessionManager {
self.tools.clone()
}
pub fn skills_loader(&self) -> Arc<SkillsLoader> {
self.skills_loader.clone()
}
pub fn work_manager(&self) -> Arc<crate::work::WorkManager> {
self.work_manager.clone()
}
@ -2157,6 +2162,27 @@ impl SessionManager {
}
}
/// Number of live sessions currently tracked by the manager.
pub async fn session_count(&self) -> usize {
self.inner.lock().await.sessions.len()
}
/// Number of sessions with an actively executing Turn.
pub async fn active_turn_count(&self) -> usize {
let sessions: Vec<_> = {
let inner = self.inner.lock().await;
inner.sessions.values().cloned().collect()
};
let mut count = 0;
for session in sessions {
let session = session.lock().await;
if session.current_cancel.is_some() {
count += 1;
}
}
count
}
pub async fn create_session(
&self,
channel: &str,

View File

@ -248,6 +248,24 @@ impl SkillsLoader {
max_mtime
}
pub fn source_of(&self, path: Option<&Path>) -> &'static str {
let Some(path) = path else {
return "unknown";
};
if let Some(ws) = &self.workspace_skills_dir
&& path.starts_with(ws)
{
return "workspace";
}
if path.starts_with(&self.picobot_skills_dir) {
return "picobot";
}
if path.starts_with(&self.agent_skills_dir) {
return "agent";
}
"other"
}
/// Get a copy of loaded skills (checks for changes first)
pub fn get_loaded_skills(&self) -> Vec<Skill> {
self.reload_if_changed();
@ -586,4 +604,31 @@ This is the content.
let beta_pos = prompt.find("**beta**").unwrap();
assert!(alpha_pos < beta_pos);
}
#[test]
fn test_source_of() {
let mut loader = SkillsLoader::new_for_testing(
PathBuf::from("/home/user/.picobot/skills"),
PathBuf::from("/home/user/.agents/skills"),
);
loader.set_workspace_skills_dir(PathBuf::from("/workspace"));
assert_eq!(loader.source_of(None), "unknown");
assert_eq!(
loader.source_of(Some(Path::new("/workspace/skills/my-skill"))),
"workspace"
);
assert_eq!(
loader.source_of(Some(Path::new("/home/user/.picobot/skills/foo"))),
"picobot"
);
assert_eq!(
loader.source_of(Some(Path::new("/home/user/.agents/skills/bar"))),
"agent"
);
assert_eq!(
loader.source_of(Some(Path::new("/opt/other/skill"))),
"other"
);
}
}

View File

@ -42,6 +42,17 @@ impl super::Storage {
parse_memory_rows(&rows)
}
pub async fn get_memory_by_key(&self, key: &str) -> Result<Option<MemoryEntry>, StorageError> {
let rows = sqlx::query(
"SELECT id, key, content, category, importance, session_id, created_at, updated_at FROM memories WHERE key = ?",
)
.bind(key)
.fetch_all(self.pool())
.await?;
let mut entries = parse_memory_rows(&rows)?;
Ok(entries.pop())
}
/// Store or update a memory entry (upsert by key).
pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> {
let category_str = entry.category.as_str();

View File

@ -114,6 +114,13 @@ impl TaskSupervisor {
true
}
/// Number of currently running managed tasks, pruning finished handles.
pub fn running_count(&self) -> usize {
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
state.tasks.retain(|task| !task.handle.is_finished());
state.tasks.len()
}
pub fn cancel(&self) {
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
state.stopping = true;
@ -199,4 +206,18 @@ mod tests {
assert!(cleaned_up.load(std::sync::atomic::Ordering::SeqCst));
}
#[tokio::test]
async fn running_count_prunes_finished_tasks() {
let supervisor = TaskSupervisor::new();
assert!(supervisor.spawn("pending", std::future::pending()));
assert_eq!(supervisor.running_count(), 1);
assert!(supervisor.spawn("finished", async {}));
tokio::task::yield_now().await;
assert_eq!(supervisor.running_count(), 1);
supervisor.shutdown(Duration::from_secs(1)).await;
assert_eq!(supervisor.running_count(), 0);
}
}

View File

@ -27,6 +27,8 @@ fn load_config() -> Option<LLMProviderConfig> {
token_limit: 128_000,
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
input_types: vec!["text".to_string()],
price_input_per_million: None,
price_output_per_million: None,
})
}

View File

@ -27,6 +27,8 @@ fn load_openai_config() -> Option<LLMProviderConfig> {
token_limit: 128_000,
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
input_types: vec!["text".to_string()],
price_input_per_million: None,
price_output_per_million: None,
})
}

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.4.0",
"version": "1.3.0",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -11,6 +11,8 @@
import MemoryPage from "./pages/MemoryPage.svelte";
import LogsPage from "./pages/LogsPage.svelte";
import SettingsPage from "./pages/SettingsPage.svelte";
import OverviewPage from "./pages/OverviewPage.svelte";
import ToolsPage from "./pages/ToolsPage.svelte";
import PairingPage from "./pages/PairingPage.svelte";
const pages = [
@ -125,6 +127,8 @@
{:else if current === "memory"}<MemoryPage />
{:else if current === "tasks"}<TasksPage />
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "overview"}<OverviewPage />
{:else if current === "tools"}<ToolsPage />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>

View File

@ -0,0 +1,30 @@
<script>
let { depth = 0, cap = 1, segments = 8, label = "capacity" } = $props();
const ratio = $derived(cap > 0 ? Math.min(depth / cap, 1) : 0);
const lit = $derived(Math.round(ratio * segments));
const nearFull = $derived(ratio > 0.9);
const barColor = $derived(nearFull ? "var(--danger)" : "var(--signal)");
</script>
<div class="meter" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={cap} aria-valuenow={depth}>
{#each Array(segments) as _, i}
<span class="seg" class:lit={i < lit} style:--seg-color={barColor}></span>
{/each}
</div>
<style>
.meter { display: flex; gap: 3px; align-items: stretch; height: 100%; }
.seg {
flex: 1;
min-width: 4px;
border-radius: 2px;
background: var(--panel-2);
border: 1px solid var(--line);
transition: background .2s, border-color .2s;
}
.seg.lit {
background: var(--seg-color);
border-color: var(--seg-color);
}
</style>

View File

@ -0,0 +1,23 @@
<script>
import Sparkline from "./Sparkline.svelte";
let { label = "", value = "", sub = "", sparkValues = [], sparkColor = undefined } = $props();
</script>
<div class="tile panel">
<span class="label-caps">{label}</span>
<span class="value mono">{value}</span>
{#if sub}<span class="sub">{sub}</span>{/if}
{#if sparkValues.length > 0}
<div class="spark">
<Sparkline values={sparkValues} color={sparkColor ?? "var(--signal)"} />
</div>
{/if}
</div>
<style>
.tile { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; }
.value { font-size: 26px; font-weight: 700; line-height: 1.1; color: var(--text); }
.sub { font-size: 11px; color: var(--muted); }
.spark { height: 28px; margin-top: 4px; }
</style>

View File

@ -0,0 +1,48 @@
<script>
let { values = [], color = "var(--signal)" } = $props();
const W = 100;
const H = 28;
const PAD = 2;
const points = $derived.by(() => {
if (!values || values.length === 0) return "";
if (values.length === 1) {
const y = H / 2;
return `${PAD},${y} ${W - PAD},${y}`;
}
let min = values[0];
let max = values[0];
for (let i = 1; i < values.length; i++) {
if (values[i] < min) min = values[i];
if (values[i] > max) max = values[i];
}
const range = max - min || 1;
const step = (W - PAD * 2) / (values.length - 1);
return values
.map((v, i) => {
const x = PAD + i * step;
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
});
</script>
{#if points}
<svg class="sparkline" viewBox="0 0 {W} {H}" preserveAspectRatio="none" aria-hidden="true">
<polyline
points={points}
fill="none"
stroke={color}
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
</svg>
{/if}
<style>
.sparkline { display: block; width: 100%; height: 100%; }
</style>

View File

@ -1,44 +1,289 @@
<script>
import { onMount } from "svelte";
import { Switch } from "bits-ui";
import { api } from "../lib/api.js";
let search = $state("");
let lineCount = $state("500");
let autoRefresh = $state(true);
let lines = $state([]);
let files = $state([]);
let updatedAt = $state("");
let error = $state("");
let logView;
const MAX_LINES = 2000;
const LEVELS = ["全部", "INF", "WRN", "ERR"];
async function load() {
const params = new URLSearchParams({ lines: lineCount });
if (search.trim()) params.set("search", search.trim());
let lines = $state([]);
let level = $state("全部");
let search = $state("");
let paused = $state(false);
let connected = $state(false);
let rate = $state(0);
let logView;
let nextId = 0;
let ws = null;
let reconnectTimer = null;
let rateTimer = null;
let timestamps = [];
let destroyed = false;
const filtered = $derived.by(() => {
const needle = search.trim().toLowerCase();
return lines.filter((line) => {
if (level !== "全部" && line.level !== level) return false;
if (needle && !line.message.toLowerCase().includes(needle) && !line.target.toLowerCase().includes(needle)) return false;
return true;
});
});
function normalizeLevel(raw) {
const upper = (raw || "").toUpperCase();
if (upper === "INFO") return "INF";
if (upper === "WARN") return "WRN";
if (upper === "ERROR") return "ERR";
return "DBG";
}
function parseHistoryLine(raw) {
const match = raw.match(/^(\S+)\s+(TRACE|DEBUG|INFO|WARN|ERROR)\s+(?:ThreadId\(\d+\)\s+)?([^:]+):\s*(.*)$/);
if (match) {
return { id: nextId++, ts: match[1], level: normalizeLevel(match[2]), target: match[3].trim(), message: match[4] };
}
return { id: nextId++, ts: "", level: "DBG", target: "", message: raw };
}
function appendLine(entry) {
entry.id = nextId++;
lines = [...lines, entry];
if (lines.length > MAX_LINES) {
lines = lines.slice(lines.length - MAX_LINES);
}
timestamps.push(Date.now());
if (!paused) {
requestAnimationFrame(() => {
if (logView) logView.scrollTop = logView.scrollHeight;
});
}
}
function connect() {
if (destroyed) return;
const proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(`${proto}//${location.host}/ws/logs`);
ws.onopen = () => {
connected = true;
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
appendLine({
ts: data.ts || "",
level: normalizeLevel(data.level),
target: data.target || "",
message: data.message || ""
});
} catch {}
};
ws.onclose = () => {
connected = false;
ws = null;
if (!destroyed) {
reconnectTimer = setTimeout(async () => {
await loadHistory();
connect();
}, 3000);
}
};
ws.onerror = () => {
ws?.close();
};
}
async function loadHistory() {
try {
const result = await api(`/api/logs?${params}`);
lines = result.lines;
files = result.files;
updatedAt = new Date().toLocaleTimeString();
error = "";
requestAnimationFrame(() => { if (logView) logView.scrollTop = logView.scrollHeight; });
} catch (caught) { error = caught.message; }
const result = await api("/api/logs?lines=200");
lines = (result.lines || []).map(parseHistoryLine);
requestAnimationFrame(() => {
if (logView) logView.scrollTop = logView.scrollHeight;
});
} catch {}
}
function download() {
const text = filtered.map((l) => `${l.ts} ${l.level} ${l.target}: ${l.message}`).join("\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `picobot-${new Date().toISOString().slice(0, 19).replaceAll(":", "")}.log`;
a.click();
URL.revokeObjectURL(url);
}
onMount(() => {
load();
const timer = setInterval(() => { if (autoRefresh) load(); }, 5000);
return () => clearInterval(timer);
loadHistory().then(() => connect());
rateTimer = setInterval(() => {
const cutoff = Date.now() - 60000;
timestamps = timestamps.filter((t) => t > cutoff);
rate = timestamps.length;
}, 5000);
return () => {
destroyed = true;
clearTimeout(reconnectTimer);
clearInterval(rateTimer);
ws?.close();
};
});
</script>
<section class="page active content-page">
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}>
<label class="search grow"><span></span><input bind:value={search} placeholder="过滤日志" /></label>
<select bind:value={lineCount}><option>200</option><option>500</option><option>1000</option><option>5000</option></select>
<label class="switch-label"><Switch.Root class="switch" bind:checked={autoRefresh}><Switch.Thumb class="switch-thumb" /></Switch.Root><span>自动刷新</span></label>
<button class="secondary" type="submit">↻ 刷新</button>
</form>
<div class="log-meta">{files.length} 个日志文件 · 显示 {lines.length}{#if updatedAt} · {updatedAt}{/if}</div>
<pre bind:this={logView} class="log-view">{error || lines.join("\n") || "没有匹配的日志"}</pre>
<section class="page active content-page logs-page">
<div class="toolbar filters">
<div class="chips">
{#each LEVELS as lv}
<button class="chip" class:active={level === lv} onclick={() => (level = lv)}>{lv}</button>
{/each}
</div>
<label class="search grow"><span></span><input bind:value={search} placeholder="搜索日志" /></label>
<button class="secondary" class:active={paused} onclick={() => (paused = !paused)}>
{paused ? "▶ 继续" : "⏸ 暂停"}
</button>
<button class="secondary" onclick={download}> 下载</button>
</div>
<div class="status-bar" class:ok={connected} class:fail={!connected}>
{#if connected}
<span class="dot"></span> 实时推送中 · {rate} 行/分
{:else}
<span class="dot"></span> 已断开 — 重连中
{/if}
</div>
<div bind:this={logView} class="log-view">
{#each filtered as line (line.id)}
<div class="log-line">
<span class="ts">{line.ts}</span>
<span class="lvl lvl-{line.level}">{line.level}</span>
<span class="target">{line.target}</span>
<span class="msg">{line.message}</span>
</div>
{/each}
{#if filtered.length === 0}
<div class="empty">没有匹配的日志</div>
{/if}
</div>
</section>
<style>
.chips {
display: flex;
gap: 4px;
}
.chip {
border: 1px solid var(--line);
background: var(--panel);
color: var(--muted);
border-radius: 99px;
padding: 5px 12px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.chip.active {
background: var(--accent-soft);
border-color: var(--accent-border);
color: var(--accent);
}
.status-bar {
display: flex;
align-items: center;
gap: 7px;
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
}
.status-bar.ok {
color: var(--signal);
}
.status-bar.fail {
color: var(--danger);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.logs-page {
display: flex;
flex-direction: column;
}
.log-view {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.log-line {
display: flex;
gap: 8px;
padding: 1px 0;
line-height: 1.65;
}
.ts {
color: var(--faint);
flex-shrink: 0;
}
.lvl {
flex-shrink: 0;
width: 3ch;
font-weight: 700;
}
.lvl-INF { color: var(--signal); }
.lvl-WRN { color: var(--accent); }
.lvl-ERR { color: var(--danger); }
.lvl-DBG { color: var(--muted); }
.target {
color: var(--text-soft);
flex-shrink: 0;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg {
color: var(--text);
word-break: break-all;
}
.empty {
color: var(--muted);
padding: 24px 0;
text-align: center;
}
.secondary.active {
background: var(--accent-soft);
border-color: var(--accent-border);
color: var(--accent);
}
</style>

View File

@ -7,12 +7,23 @@
let memories = $state([]);
let loading = $state(true);
let error = $state("");
let limit = $state(100);
let editingKey = $state(null);
let confirmingKey = $state(null);
let draftContent = $state("");
let draftImportance = $state(0);
let saving = $state(false);
let deleting = $state(false);
let actionError = $state("");
const knowledge = $derived(memories.filter((item) => item.category === "knowledge").length);
const timeline = $derived(memories.length - knowledge);
const hasMore = $derived(memories.length >= limit);
async function load() {
loading = true;
error = "";
const params = new URLSearchParams({ limit: "200" });
const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) params.set("query", query.trim());
if (category) params.set("category", category);
try { memories = (await api(`/api/memories?${params}`)).memories; }
@ -20,19 +31,146 @@
finally { loading = false; }
}
function applyFilters() {
limit = 100;
editingKey = null;
confirmingKey = null;
actionError = "";
load();
}
function loadMore() {
limit += 100;
load();
}
function startEdit(memory) {
confirmingKey = null;
actionError = "";
editingKey = memory.key;
draftContent = memory.content;
draftImportance = Number(memory.importance);
}
function cancelEdit() {
editingKey = null;
actionError = "";
}
async function saveEdit(key) {
saving = true;
actionError = "";
try {
await api(`/api/memories/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ content: draftContent, importance: Number(draftImportance) })
});
editingKey = null;
await load();
} catch (caught) { actionError = caught.message; }
finally { saving = false; }
}
function startConfirm(memory) {
editingKey = null;
actionError = "";
confirmingKey = memory.key;
}
function cancelConfirm() {
confirmingKey = null;
actionError = "";
}
async function removeMemory(key) {
deleting = true;
actionError = "";
try {
await api(`/api/memories/${encodeURIComponent(key)}`, { method: "DELETE" });
confirmingKey = null;
await load();
} catch (caught) { actionError = caught.message; }
finally { deleting = false; }
}
onMount(load);
</script>
<section class="page active content-page">
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}>
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); applyFilters(); }}>
<label class="search grow"><span></span><input bind:value={query} placeholder="搜索记忆内容或键" /></label>
<select bind:value={category}><option value="">全部类型</option><option value="knowledge">Knowledge</option><option value="timeline">Timeline</option></select>
<select bind:value={category}><option value="">全部</option><option value="knowledge">Knowledge</option><option value="timeline">Timeline</option></select>
<button class="secondary" type="submit">查询</button>
</form>
<div class="metrics"><div class="metric"><b>{memories.length}</b><small>当前结果</small></div><div class="metric"><b>{knowledge}</b><small>Knowledge</small></div><div class="metric"><b>{memories.length - knowledge}</b><small>Timeline</small></div></div>
<div class="metrics"><div class="metric"><b>{memories.length}</b><small>总数</small></div><div class="metric"><b>{knowledge}</b><small>Knowledge</small></div><div class="metric"><b>{timeline}</b><small>Timeline</small></div></div>
<div class="cards">
{#if loading}<div class="loading">加载中…</div>
{:else if error}<div class="empty-card error-text">{error}</div>
{:else}{#each memories as memory (memory.id)}<article class="card"><div class="card-row"><div><h3 class="memory-key">{memory.key}</h3><p class="memory-content">{memory.content}</p><div class="meta"><span>{formatTime(memory.updated_at)}</span>{#if memory.session_id}<span>{memory.session_id}</span>{/if}<span>重要度 {Number(memory.importance).toFixed(2)}</span></div></div><span class:ok={memory.category === "knowledge"} class="badge">{memory.category}</span></div></article>{:else}<div class="empty-card">没有匹配的记忆</div>{/each}{/if}
{:else}
{#each memories as memory (memory.id)}
<article class="card">
{#if editingKey === memory.key}
<div class="card-row"><h3 class="memory-key">{memory.key}</h3><span class="badge" class:ok={memory.category === "knowledge"}>{memory.category}</span></div>
<label class="edit-field"><span>内容</span><textarea bind:value={draftContent} rows="4" disabled={saving}></textarea></label>
<label class="edit-field"><span>重要度 {Number(draftImportance).toFixed(2)}</span><input type="range" min="0" max="1" step="0.05" bind:value={draftImportance} disabled={saving} /></label>
{#if actionError}<p class="error-text action-error">{actionError}</p>{/if}
<div class="card-actions">
<button class="primary" onclick={() => saveEdit(memory.key)} disabled={saving}>保存</button>
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
</div>
{:else}
<div class="card-row">
<div class="card-main">
<h3 class="memory-key">{memory.key}</h3>
<p class="memory-content">{memory.content}</p>
<div class="meta"><span>重要度 {Number(memory.importance).toFixed(2)}</span><span>更新 {formatTime(memory.updated_at)}</span>{#if memory.session_id}<span>{memory.session_id}</span>{/if}</div>
</div>
<div class="card-side">
<span class="badge" class:ok={memory.category === "knowledge"}>{memory.category}</span>
<div class="card-actions">
<button class="secondary" onclick={() => startEdit(memory)}>编辑</button>
<button class="secondary danger-outline" onclick={() => startConfirm(memory)}>删除</button>
</div>
</div>
</div>
{#if confirmingKey === memory.key}
<div class="confirm" class:confirm-danger={memory.category === "timeline"}>
{#if memory.category === "timeline"}
<p class="confirm-warning">Timeline 是压缩后的历史上下文,删除后模型将永久失去该时段长期记忆且无法自动重建;原始消息仍保留在聊天历史,但不再进入模型上下文</p>
{:else}
<p class="confirm-text">删除后不可恢复,影响后续召回</p>
{/if}
{#if actionError}<p class="error-text action-error">{actionError}</p>{/if}
<div class="card-actions">
<button class="danger-solid" onclick={() => removeMemory(memory.key)} disabled={deleting}>{memory.category === "timeline" ? "我了解,确认删除" : "确认删除"}</button>
<button class="secondary" onclick={cancelConfirm} disabled={deleting}>取消</button>
</div>
</div>
{/if}
{/if}
</article>
{:else}<div class="empty-card">没有匹配的记忆</div>{/each}
{#if hasMore}<div class="load-more"><button class="secondary" onclick={loadMore}>加载更多</button></div>{/if}
{/if}
</div>
</section>
<style>
.card-main { min-width: 0; flex: 1; }
.card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; flex: 0 0 auto; }
.card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.card > .card-actions { margin-top: 14px; }
.danger-outline { border-color: var(--danger-border); color: var(--danger); }
.danger-solid { border-radius: 9px; padding: 9px 14px; font-weight: 650; cursor: pointer; border: 1px solid var(--danger); background: var(--danger); color: #fff; }
.danger-solid:hover { filter: brightness(1.08); }
.edit-field { display: grid; gap: 6px; margin-top: 12px; }
.edit-field > span { color: var(--muted); font-size: 11px; }
.edit-field textarea { width: 100%; min-height: 90px; resize: vertical; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); color: var(--text); padding: 10px; font: 13px/1.55 var(--font-ui); }
.edit-field input[type="range"] { width: 100%; accent-color: var(--accent); }
.action-error { margin: 12px 0 0; font-size: 12px; }
.confirm { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; display: grid; gap: 10px; }
.confirm-text { margin: 0; color: var(--muted); font-size: 12px; }
.confirm-danger { border: 1px solid var(--danger-border); border-radius: 8px; background: var(--danger-soft); padding: 12px; }
.confirm-warning { margin: 0; color: var(--danger); font-size: 12px; line-height: 1.6; }
.load-more { display: flex; justify-content: center; padding: 6px 0; }
</style>

View File

@ -0,0 +1,198 @@
<script>
import { onMount } from "svelte";
import { api, formatTime } from "../lib/api.js";
import MetricTile from "../lib/components/MetricTile.svelte";
import CapacityMeter from "../lib/components/CapacityMeter.svelte";
import Sparkline from "../lib/components/Sparkline.svelte";
let status = $state(null);
let error = $state("");
let inflight = false;
const running = $derived(status && (status.phase === "active" || status.phase === "steady"));
const uptime = $derived.by(() => {
if (!status) return "—";
const s = status.uptime_secs || 0;
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m ${sec}s`;
});
const totalTokens = $derived(status ? (status.metrics?.tokens_in || 0) + (status.metrics?.tokens_out || 0) : 0);
const totalCost = $derived(status ? (status.metrics?.cost || 0).toFixed(2) : "0.00");
const p95 = $derived(status ? (status.metrics?.turn_latency_p95_ms || 0) : 0);
const mcpCount = $derived(status ? (status.mcp || []).filter((m) => m.connected).length : 0);
function fmt(n) {
return (n || 0).toLocaleString();
}
async function load() {
if (inflight) return;
inflight = true;
try {
status = await api("/api/status");
error = "";
} catch (caught) {
error = caught.message;
} finally {
inflight = false;
}
}
onMount(() => {
load();
const timer = setInterval(load, 2000);
return () => clearInterval(timer);
});
</script>
<section class="page active content-page">
{#if error && !status}
<div class="offline panel">
<span class="pulse-dot"></span>
<span>离线 — {error}</span>
</div>
{:else if status}
<div class="status-bar panel">
<div class="status-left">
<span class="pulse-dot" class:active={running}></span>
<strong class="mono">{running ? "RUNNING" : "IDLE"}</strong>
<span class="label-caps">GEN {status.generation}</span>
</div>
<div class="status-meta mono">
<span>{uptime}</span>
<span>v{status.version}</span>
<span>WS {status.ws_connections}</span>
<span>BG {status.background_tasks}</span>
<span class="label-caps">{status.phase}</span>
</div>
</div>
{#if error}
<div class="warn-bar">{error}</div>
{/if}
<div class="tiles">
<MetricTile label="SESSIONS" value={fmt(status.sessions?.total)} sub="{status.sessions?.active_turns || 0} active" />
<MetricTile label="TOKENS" value={fmt(totalTokens)} sub="cost ${totalCost}" />
<MetricTile label="TOOL CALLS" value={fmt(status.metrics?.tool_calls)} />
<MetricTile label="TURNS" value={fmt(status.metrics?.turns)} sub="p95 {fmt(p95)}ms" />
</div>
<div class="panel section">
<span class="label-caps">PROVIDERS</span>
<table class="provider-table">
<thead>
<tr><th>名称</th><th>模型</th><th>状态</th><th>延迟</th><th>Tokens In</th><th>Tokens Out</th><th>Cost</th></tr>
</thead>
<tbody>
{#each status.providers || [] as p}
<tr>
<td class="mono">{p.name}</td>
<td class="mono">{p.model}</td>
<td><span class="badge" class:ok={p.status === "ok"} class:fail={p.status !== "ok"}>{p.status}</span></td>
<td class="spark-cell"><Sparkline values={p.latencies || []} color={p.status === "ok" ? "var(--signal)" : "var(--danger)"} /></td>
<td class="mono">{fmt(p.tokens_in)}</td>
<td class="mono">{fmt(p.tokens_out)}</td>
<td class="mono">${(p.cost || 0).toFixed(3)}</td>
</tr>
{:else}
<tr><td colspan="7" class="empty-cell">暂无 Provider</td></tr>
{/each}
</tbody>
</table>
</div>
<div class="panel section">
<span class="label-caps">MESSAGE BUS</span>
<div class="bus-grid">
<div class="bus-item">
<span class="bus-label">Inbound</span>
<CapacityMeter depth={status.bus?.inbound?.depth || 0} cap={status.bus?.inbound?.cap || 1} label="inbound" />
<span class="mono bus-depth">{status.bus?.inbound?.depth || 0}/{status.bus?.inbound?.cap || 1}</span>
</div>
<div class="bus-item">
<span class="bus-label">Outbound</span>
<CapacityMeter depth={status.bus?.outbound?.depth || 0} cap={status.bus?.outbound?.cap || 1} label="outbound" />
<span class="mono bus-depth">{status.bus?.outbound?.depth || 0}/{status.bus?.outbound?.cap || 1}</span>
</div>
<div class="bus-item">
<span class="bus-label">Control</span>
<CapacityMeter depth={status.bus?.control?.depth || 0} cap={status.bus?.control?.cap || 1} label="control" />
<span class="mono bus-depth">{status.bus?.control?.depth || 0}/{status.bus?.control?.cap || 1}</span>
</div>
</div>
<div class="bus-footer mono">
<span>Lanes: {status.bus?.active_lanes || 0}</span>
<span>Scheduler: {status.scheduler?.enabled || 0}/{status.scheduler?.jobs || 0}</span>
<span>MCP: {mcpCount}</span>
</div>
</div>
<div class="two-col">
<div class="panel section">
<span class="label-caps">CHANNELS</span>
<div class="channel-list">
{#each status.channels || [] as ch}
<div class="channel-row">
<span class="mono">{ch.name}</span>
<span class="badge" class:ok={ch.status === "connected"} class:fail={ch.status !== "connected"}>{ch.status}</span>
</div>
{:else}
<span class="empty-cell">暂无 Channel</span>
{/each}
</div>
</div>
<div class="panel section">
<span class="label-caps">SCHEDULER</span>
<div class="sched-grid mono">
<span>Jobs: {status.scheduler?.jobs || 0}</span>
<span>Enabled: {status.scheduler?.enabled || 0}</span>
<span class:error-text={status.scheduler?.failed_jobs > 0}>Failed: {status.scheduler?.failed_jobs || 0}</span>
<span>Next: {formatTime(status.scheduler?.next_run_at)}</span>
</div>
</div>
</div>
{:else}
<div class="loading">加载中…</div>
{/if}
</section>
<style>
.offline { display: flex; align-items: center; gap: 10px; padding: 18px 22px; color: var(--danger); font-size: 13px; }
.status-bar { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; margin-bottom: 14px; flex-wrap: wrap; gap: 10px; }
.status-left { display: flex; align-items: center; gap: 10px; }
.status-meta { display: flex; gap: 16px; font-size: 11px; color: var(--text-soft); flex-wrap: wrap; }
.warn-bar { color: var(--accent); font-size: 11px; margin-bottom: 10px; }
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
.section { padding: 16px 18px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; }
.provider-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.provider-table th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--line); font-size: 10px; }
.provider-table td { padding: 7px 8px; border-bottom: 1px solid var(--line); color: var(--text-soft); }
.provider-table tr:last-child td { border-bottom: none; }
.spark-cell { width: 80px; height: 24px; }
.empty-cell { color: var(--muted); text-align: center; padding: 12px; }
.bus-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.bus-item { display: flex; flex-direction: column; gap: 6px; }
.bus-label { font-size: 10px; color: var(--muted); }
.bus-depth { font-size: 10px; color: var(--text-soft); }
.bus-footer { display: flex; gap: 20px; font-size: 11px; color: var(--text-soft); border-top: 1px solid var(--line); padding-top: 10px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.channel-list { display: flex; flex-direction: column; gap: 8px; }
.channel-row { display: flex; justify-content: space-between; align-items: center; }
.sched-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: var(--text-soft); }
.error-text { color: var(--danger); }
@media (max-width: 800px) {
.tiles { grid-template-columns: repeat(2, 1fr); }
.bus-grid { grid-template-columns: 1fr; }
.two-col { grid-template-columns: 1fr; }
}
</style>

View File

@ -6,20 +6,42 @@
let { notify } = $props();
let tab = $state("config");
let content = $state("");
let original = $state("");
let path = $state("");
let loading = $state(true);
let saving = $state(false);
let reloadStatus = $state(null);
let pollTimer = null;
const isConfig = $derived(tab === "config");
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : "Agent 行为准则");
const isDirty = $derived(content !== original);
const jsonParse = $derived.by(() => {
if (!isConfig || !content.trim()) return { value: null, error: null };
try { return { value: JSON.parse(content), error: null }; } catch (e) { return { value: null, error: e.message }; }
});
const jsonError = $derived(jsonParse.error);
const outlineKeys = $derived(jsonParse.value ? Object.keys(jsonParse.value) : []);
const phaseBadge = $derived.by(() => {
if (!reloadStatus) return "ok";
const map = { steady: "ok", pending: "run", preparing: "run", activating: "run", failed: "fail" };
return map[reloadStatus.phase] || "ok";
});
async function load() {
loading = true;
try {
if (tab === "config") {
if (isConfig) {
const result = await api("/api/config");
content = JSON.stringify(result.config, null, 2);
original = content;
path = result.path;
} else {
const result = await api(`/api/profiles/${tab}`);
content = result.content;
original = content;
path = result.path;
}
} catch (caught) { notify(caught.message, true); }
@ -27,22 +49,67 @@
}
async function save() {
if (isConfig && jsonError) return;
saving = true;
try {
if (tab === "config") {
let config;
try { config = JSON.parse(content); } catch (caught) { throw new Error(`JSON 格式错误:${caught.message}`); }
if (isConfig) {
const config = JSON.parse(content);
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2);
notify("配置已保存,请重启 Gateway 使其生效");
original = content;
notify("配置已保存");
} else {
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
original = content;
notify("助手档案已保存");
}
} catch (caught) { notify(caught.message, true); }
finally { saving = false; }
}
function changeTab(value) { tab = value; load(); }
onMount(load);
async function saveAndReload() {
if (jsonError) return;
saving = true;
try {
const config = JSON.parse(content);
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2);
original = content;
const reload = await api("/api/config/reload", { method: "POST" });
notify(reload.message || "配置已保存并触发热重载");
pollReloadStatus();
} catch (caught) { notify(caught.message, true); }
finally { saving = false; }
}
function discard() { content = original; }
async function pollReloadStatus() {
try { reloadStatus = await api("/api/config/reload/status"); } catch {}
}
function startPolling() {
stopPolling();
pollReloadStatus();
pollTimer = setInterval(pollReloadStatus, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function changeTab(value) {
tab = value;
stopPolling();
load();
if (value === "config") startPolling();
}
onMount(() => {
load();
startPolling();
return stopPolling;
});
</script>
<section class="page active content-page">
@ -52,10 +119,84 @@
<Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<div class="editor-card">
<div class="editor-head"><div><strong>{title}</strong><small>{path}</small></div><button class="primary" onclick={save} disabled={loading}>保存更改</button></div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
<div class="notice">{#if tab === "config"}API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。配置保存后需重启 Gateway 生效。{:else}Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。{/if}</div>
</div>
{#if isConfig}
<div class="config-layout">
<div class="editor-card">
<div class="editor-head">
<div><strong>{title}</strong><small>{path}</small></div>
<div class="editor-actions">
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
<button class="secondary" onclick={discard} disabled={!isDirty || loading || saving}>放弃修改</button>
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || !!jsonError}>保存并热重载</button>
<button class="primary" onclick={save} disabled={loading || saving || !!jsonError}>保存</button>
</div>
</div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
{#if jsonError}<div class="json-error">JSON 格式错误:{jsonError}</div>{/if}
<div class="notice">API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。</div>
</div>
<aside class="config-sidebar">
<div class="panel sidebar-panel">
<span class="label-caps">配置大纲</span>
{#if outlineKeys.length}
<ul class="outline-list">
{#each outlineKeys as key}
<li>
<span class="mono">{key}</span>
{#if key === "gateway"}<span class="outline-note">⚠ 含重启字段</span>{:else if key === "providers"}<span class="outline-note">🔑 含密钥</span>{/if}
</li>
{/each}
</ul>
{:else}
<p class="sidebar-empty">{jsonError ? "JSON 无效" : "暂无内容"}</p>
{/if}
</div>
<div class="panel sidebar-panel">
<span class="label-caps">热重载状态</span>
{#if reloadStatus}
<div class="reload-row"><span>Generation</span><span class="mono">{reloadStatus.generation}</span></div>
<div class="reload-row"><span>Phase</span><span class="badge {phaseBadge}">{reloadStatus.phase}</span></div>
{#if reloadStatus.last_error}<div class="reload-error">{reloadStatus.last_error}</div>{/if}
{:else}
<p class="sidebar-empty">加载中…</p>
{/if}
</div>
</aside>
</div>
{:else}
<div class="editor-card">
<div class="editor-head">
<div><strong>{title}</strong><small>{path}</small></div>
<div class="editor-actions">
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
<button class="primary" onclick={save} disabled={loading || saving}>保存更改</button>
</div>
</div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
<div class="notice">Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。</div>
</div>
{/if}
</div>
</section>
<style>
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
.config-sidebar { display: grid; gap: 14px; }
.sidebar-panel { padding: 14px; }
.sidebar-empty { margin: 10px 0 0; color: var(--muted); font-size: 11px; }
.outline-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 7px; }
.outline-list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--text-soft); }
.outline-note { font-size: 10px; color: var(--muted); white-space: nowrap; }
.reload-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; font-size: 12px; color: var(--text-soft); }
.reload-row + .reload-row { border-top: 1px solid var(--line); }
.reload-error { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 11px; line-height: 1.5; }
.json-error { padding: 9px 15px; color: var(--danger); font-size: 11px; background: var(--danger-soft); border-top: 1px solid var(--danger-border); }
.dirty-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 600; color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 99px; padding: 3px 9px; white-space: nowrap; }
.dirty-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.editor-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.editor-head { min-height: 62px; height: auto; padding: 10px 15px; flex-wrap: wrap; gap: 8px; }
@media (max-width: 800px) { .config-layout { grid-template-columns: 1fr; } }
</style>

View File

@ -10,6 +10,7 @@
let runs = $state({});
let loading = $state(true);
let error = $state("");
let tick = $state(0);
async function load() {
loading = true;
@ -21,7 +22,7 @@
jobs = (await api("/api/jobs")).jobs;
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
job.id,
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=5`).then((value) => value.runs).catch(() => [])
await api(`/api/jobs/${encodeURIComponent(job.id)}/runs?limit=10`).then((value) => value.runs).catch(() => [])
])));
}
} catch (caught) {
@ -36,7 +37,43 @@
load();
}
onMount(load);
function countdown(ts) {
void tick;
if (!ts) return "—";
const ms = ts < 1e12 ? ts * 1000 : ts;
const diff = ms - Date.now();
if (diff <= 0) return "即将运行";
const mins = Math.floor(diff / 60000);
if (mins < 1) return "即将运行";
if (mins < 60) return `${mins} 分钟后`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时后`;
return `${Math.floor(hours / 24)} 天后`;
}
function elapsed(createdAt) {
void tick;
if (!createdAt) return "";
const ms = createdAt < 1e12 ? createdAt * 1000 : createdAt;
const secs = Math.floor((Date.now() - ms) / 1000);
if (secs < 0) return "";
if (secs < 60) return `${secs}s`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`;
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
}
function dotColor(status) {
if (status === "completed" || status === "success" || status === "ok") return "var(--signal)";
if (status === "timeout") return "var(--accent)";
if (status === "running") return "var(--info)";
return "var(--danger)";
}
onMount(() => {
load();
const timer = setInterval(() => { tick += 1; }, 30000);
return () => clearInterval(timer);
});
</script>
<section class="page active content-page">
@ -54,15 +91,60 @@
{:else if error}<div class="empty-card error-text">{error}</div>
{:else if tab === "background"}
{#each tasks as task (task.id)}
<article class="card"><div class="card-row"><div><h3>{task.prompt.slice(0, 100)}</h3><div class="meta"><span>{task.session_id}</span><span>{formatTime(task.created_at)}</span><span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span></div></div><StatusBadge status={task.status} /></div>{#if task.result}<div class="details"><p>{task.result}</p></div>{/if}{#if task.error}<p class="error-text">{task.error}</p>{/if}</article>
<article class="card">
<div class="card-row">
<div>
<h3>
{#if task.status === "running"}<span class="pulse"></span>{/if}
{task.prompt.slice(0, 100)}
</h3>
<div class="meta">
<span>{task.session_id}</span>
<span>{formatTime(task.created_at)}</span>
{#if task.status === "running"}<span class="elapsed">{elapsed(task.created_at)}</span>{/if}
<span>{task.tool_calls_count} 次工具调用 · {task.iterations}</span>
</div>
</div>
<StatusBadge status={task.status} />
</div>
{#if task.result}<div class="details"><p>{task.result}</p></div>{/if}
{#if task.error}<p class="error-text">{task.error}</p>{/if}
</article>
{:else}<div class="empty-card">暂无后台任务</div>{/each}
{:else}
{#each jobs as job (job.id)}
<article class="card">
<div class="card-row"><div><h3>{job.name}</h3><p>{job.prompt}</p><div class="meta"><span>{job.job_kind === "monitor" ? "巡检" : "任务"} · {job.delivery_policy}</span><span>{job.channel} · {job.chat_id}</span><span>下次 {formatTime(job.next_run_at)}</span><span>上次 {formatTime(job.last_run_at)}</span></div></div><StatusBadge status={job.enabled ? (job.last_status || "enabled") : "disabled"} /></div>
<div class="card-row">
<div>
<h3>{job.name}</h3>
<p>{job.prompt}</p>
<div class="meta">
<span class="mono cron">{job.cron}</span>
<span>{job.job_kind === "monitor" ? "巡检" : "任务"} · {job.delivery_policy}</span>
<span>{job.channel} · {job.chat_id}</span>
<span>下次 {countdown(job.next_run_at)}</span>
<span>上次 {formatTime(job.last_run_at)}</span>
</div>
{#if runs[job.id]?.length}
<div class="status-dots">
{#each runs[job.id] as run}
<span class="dot" style="background:{dotColor(run.status)}" title="{run.status} {formatTime(run.finished_at)}"></span>
{/each}
</div>
{/if}
</div>
<StatusBadge status={job.enabled ? (job.last_status || "enabled") : "disabled"} />
</div>
{#if runs[job.id]?.length}<div class="details">{#each runs[job.id] as run}<div class="card-row run-row"><span class="meta">{formatTime(run.finished_at)} · {run.duration_ms}ms{run.result_kind ? ` · ${run.result_kind}` : ""}{run.delivery_status ? ` · ${run.delivery_status}` : ""}</span><StatusBadge status={run.status} /></div>{/each}</div>{/if}
</article>
{:else}<div class="empty-card">暂无定时任务</div>{/each}
{/if}
</div>
</section>
<style>
.cron { font-size: 11px; color: var(--text-soft); background: var(--code-bg); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); }
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 10px; }
</style>

View File

@ -0,0 +1,185 @@
<script>
import { onMount } from "svelte";
import { Tabs } from "bits-ui";
import { api } from "../lib/api.js";
let tab = $state("tools");
let tools = $state([]);
let skills = $state([]);
let servers = $state([]);
let loading = $state(true);
let error = $state("");
let query = $state("");
let filter = $state("all");
const filters = [
["all", "全部"],
["read_only", "只读"],
["concurrency_safe", "可并发"],
["side_effect", "有副作用"],
["exclusive", "独占"]
];
function matches(tool) {
if (filter === "read_only") return tool.read_only;
if (filter === "concurrency_safe") return tool.concurrency_safe;
if (filter === "side_effect") return !tool.read_only;
if (filter === "exclusive") return tool.exclusive;
return true;
}
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return tools.filter(
(tool) =>
matches(tool) &&
(!q || tool.name.toLowerCase().includes(q) || (tool.description || "").toLowerCase().includes(q))
);
});
const connected = $derived(servers.filter((server) => server.connected).length);
function schema(tool) {
return tool.parameters_schema ? JSON.stringify(tool.parameters_schema, null, 2) : "{}";
}
async function load() {
loading = true;
error = "";
try {
const [toolRes, skillRes, status] = await Promise.all([
api("/api/tools"),
api("/api/skills"),
api("/api/status")
]);
tools = toolRes.tools || [];
skills = skillRes.skills || [];
servers = status.mcp || [];
} catch (caught) {
error = caught.message;
} finally {
loading = false;
}
}
function changeTab(value) {
tab = value;
}
onMount(load);
</script>
<section class="page active content-page">
<div class="toolbar">
<Tabs.Root value={tab} onValueChange={changeTab}>
<Tabs.List class="tabs" aria-label="工具与技能">
<Tabs.Trigger value="tools">工具</Tabs.Trigger>
<Tabs.Trigger value="skills">Skills</Tabs.Trigger>
<Tabs.Trigger value="mcp">MCP</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
<button class="secondary" onclick={load}> 刷新</button>
</div>
{#if loading}
<div class="loading">加载中…</div>
{:else if error}
<div class="empty-card error-text">{error}</div>
{:else if tab === "tools"}
<form class="filters-bar" onsubmit={(event) => event.preventDefault()}>
<label class="search grow"><span></span><input bind:value={query} placeholder="搜索工具名称或描述" /></label>
<div class="chips">
{#each filters as [value, label]}
<button type="button" class="chip" class:active={filter === value} onclick={() => (filter = value)}>{label}</button>
{/each}
</div>
</form>
<div class="legend">
<span class="cap signal">◇ 只读</span>
<span class="cap info">⇉ 可并发</span>
<span class="cap accent">△ 有副作用</span>
<span class="cap danger">■ 独占</span>
</div>
<div class="tool-grid">
{#each filtered as tool (tool.name)}
<article class="card">
<div class="card-row">
<div>
<h3 class="mono">{tool.name}</h3>
<div class="meta"><span>{tool.call_count} 次调用</span></div>
</div>
<span class="badge" class:src-mcp={tool.source === "mcp"}>{tool.source}</span>
</div>
<p>{tool.description}</p>
<div class="cap-row">
{#if tool.read_only}<span class="cap signal">◇ 只读</span>{/if}
{#if tool.concurrency_safe}<span class="cap info">⇉ 可并发</span>{/if}
{#if !tool.read_only}<span class="cap accent">△ 有副作用</span>{/if}
{#if tool.exclusive}<span class="cap danger">■ 独占</span>{/if}
</div>
<details class="schema-wrap">
<summary>参数 schema</summary>
<pre class="mono">{schema(tool)}</pre>
</details>
</article>
{:else}
<div class="empty-card">没有匹配的工具</div>
{/each}
</div>
{:else if tab === "skills"}
<div class="cards">
{#each skills as skill (skill.name)}
<article class="card">
<div class="card-row">
<div>
<h3 class="mono">{skill.name}</h3>
<p>{skill.description}</p>
<div class="meta"><span>{skill.source}</span></div>
</div>
{#if skill.always}<span class="badge ok">always</span>{/if}
</div>
</article>
{:else}
<div class="empty-card">暂无 Skills</div>
{/each}
</div>
{:else}
<div class="metrics">
<div class="metric"><b>{servers.length}</b><small>MCP 服务器</small></div>
<div class="metric"><b>{connected}</b><small>已连接</small></div>
<div class="metric"><b>{servers.reduce((sum, server) => sum + (server.tools || 0), 0)}</b><small>工具总数</small></div>
</div>
<div class="cards">
{#each servers as server (server.name)}
<article class="card">
<div class="card-row">
<div>
<h3 class="mono">{server.name}</h3>
<div class="meta"><span>{server.tools || 0} 个工具</span></div>
</div>
<span class="badge" class:ok={server.connected} class:fail={!server.connected}>{server.connected ? "已连接" : "未连接"}</span>
</div>
</article>
{:else}
<div class="empty-card">暂无 MCP 服务器</div>
{/each}
</div>
{/if}
</section>
<style>
.filters-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
.chip { border: 1px solid var(--line); background: var(--panel); color: var(--muted); border-radius: 99px; padding: 6px 12px; font-size: 11px; cursor: pointer; }
.chip.active { background: var(--accent-soft); border-color: var(--accent-border); color: var(--accent); }
.legend { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
.cap-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.badge.src-mcp { background: var(--info-soft); color: var(--info); }
.schema-wrap { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
.schema-wrap summary { cursor: pointer; color: var(--muted); font-size: 11px; }
.schema-wrap pre { max-height: 300px; margin: 8px 0 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; }
@media (max-width: 800px) {
.tool-grid { grid-template-columns: 1fr; }
}
</style>