Compare commits
27 Commits
719f586900
...
3263fac7a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 3263fac7a3 | |||
| 80fbc7fd25 | |||
| 7a845cffd5 | |||
| 1e984eef66 | |||
| c6a3814530 | |||
| ee5bc46cc6 | |||
| f0ef14099d | |||
| 652a837e88 | |||
| b49f0ec8a5 | |||
| 3f09793864 | |||
| bee11c7735 | |||
| dff155a93c | |||
| 038854afa1 | |||
| 4621210484 | |||
| 266fb8c5e9 | |||
| 0b02fd580b | |||
| 6e719e7fcb | |||
| 5e2771c538 | |||
| 119df57b49 | |||
| fa58e4d2f6 | |||
| 87cf5004b9 | |||
| b14edc40d3 | |||
| aa989cbaf1 | |||
| 5d0cf5b070 | |||
| 115e77ff27 | |||
| 11474c080e | |||
| d25da1ea27 |
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.4.0"
|
version = "1.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@ -23,6 +23,7 @@ prost = "0.14"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|||||||
600
docs/superpowers/plans/2026-07-24-p1-observability.md
Normal file
600
docs/superpowers/plans/2026-07-24-p1-observability.md
Normal 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:** Rust(Axum handler、tokio、原子计数)、Svelte 5(runes)、手写 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 记录 token;cost 仅在 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.6;P0 计划 `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-tool;record_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 先传 None,cost 留待有 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` 之前 +1(spawn 可能失败)。在 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):会话数、今日 Token(in+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 bump(1.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;阈值可调。
|
||||||
413
docs/superpowers/plans/2026-07-26-p2-logs-data.md
Normal file
413
docs/superpowers/plans/2026-07-26-p2-logs-data.md
Normal 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:** Rust(tracing-subscriber Layer trait、tokio broadcast、Axum WebSocket)、Svelte 5(runes)、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.5;P1 计划 `docs/superpowers/plans/2026-07-24-p1-observability.md`(前端组件/页面模式)。
|
||||||
|
|
||||||
|
**验证约定:** Rust 改动 → 定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` + `cargo build`;前端 → `cd webui && npm run check && npm run build`。前端无单测框架,勿虚构。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: 后端 — tracing 广播 + /ws/logs
|
||||||
|
|
||||||
|
### Task 1.1: LogEvent + 广播 Layer + 全局访问器
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/logging.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 定义 LogEvent 与全局 Sender**
|
||||||
|
|
||||||
|
在 `src/logging.rs` 顶部增加:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tracing::field::Visit;
|
||||||
|
use tracing_subscriber::layer::Context;
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
|
|
||||||
|
const LOG_BROADCAST_CAP: usize = 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LogEvent {
|
||||||
|
pub ts: String,
|
||||||
|
pub level: String,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOG_TX: OnceLock<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` crate(tracing-subscriber 的 `local-time` feature 已引入 `time`)。优先用 `time::OffsetDateTime::now_local()` 格式化为 RFC 3339,避免新增依赖。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 修改 init_logging() 挂载广播层**
|
||||||
|
|
||||||
|
在 `init_logging()` 中,`tracing_subscriber::registry()` 链增加 `.with(BroadcastLayer)`,并在函数开头初始化 broadcast:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn init_logging() {
|
||||||
|
let (tx, _rx) = broadcast::channel(LOG_BROADCAST_CAP);
|
||||||
|
let _ = LOG_TX.set(tx);
|
||||||
|
// ... 现有代码 ...
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(console_layer)
|
||||||
|
.with(file_layer)
|
||||||
|
.with(BroadcastLayer)
|
||||||
|
.init();
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证** — `cargo build` + `cargo clippy --all-targets --all-features -- -D warnings`
|
||||||
|
- [ ] **Step 5: Commit** — `git add src/logging.rs Cargo.toml && git commit -m "feat(logging): tracing broadcast layer for real-time log streaming"`
|
||||||
|
|
||||||
|
### Task 1.2: /ws/logs WebSocket 端点
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/gateway/ws.rs`(或新建 `src/gateway/ws_logs.rs`)
|
||||||
|
- Modify: `src/gateway/mod.rs`(路由注册)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 实现 ws_logs handler**
|
||||||
|
|
||||||
|
在 `src/gateway/ws.rs` 底部(或新文件)增加:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub struct WsLogsQuery {
|
||||||
|
level: Option<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)+ 排序(最近更新)。
|
||||||
|
- 记忆卡片列表:key(mono accent)、content、category badge、importance、updated_at、session_id。
|
||||||
|
- 行内编辑:点击「编辑」→ content 变 textarea + importance range input + 保存/取消按钮。保存调 `PUT /api/memories/{key}`。
|
||||||
|
- 删除:点击「删除」→ 确认对话框。Knowledge 普通确认;Timeline 强警告(红色面板 + 说明文字 + 按钮「我了解,确认删除」)。调 `DELETE /api/memories/{key}`。
|
||||||
|
- 分页:limit=100,底部「加载更多」按钮(offset 通过增大 limit 实现——现有 API 不支持 offset,用 limit 递增近似)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 重写 MemoryPage.svelte**(上述布局)。
|
||||||
|
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。
|
||||||
|
- [ ] **Step 3: Commit** — `git add webui/src/pages/MemoryPage.svelte && git commit -m "feat(webui): editable memory page with delete confirmation"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: 前端 — 任务页重构
|
||||||
|
|
||||||
|
### Task 5.1: TasksPage 增强
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `webui/src/pages/TasksPage.svelte`
|
||||||
|
|
||||||
|
布局(规格 §6.6):
|
||||||
|
- 定时任务卡片增强:
|
||||||
|
- 显示 cron 表达式(mono)。
|
||||||
|
- 下次运行倒计时(「3 分钟后」格式,每 30s 刷新)。
|
||||||
|
- 最近运行状态点(最多 10 个圆点:绿=completed / 琥珀=timeout / 红=error)。
|
||||||
|
- 可展开运行记录(已有,保留)。
|
||||||
|
- 后台任务增强:
|
||||||
|
- 运行中状态用脉冲动画(`.pulse` class)。
|
||||||
|
- 显示耗时(created_at 到现在的差值,或 duration 字段)。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 增强 TasksPage.svelte**(上述改动,保留现有结构)。
|
||||||
|
- [ ] **Step 2: 验证** — `cd webui && npm run check && npm run build`。
|
||||||
|
- [ ] **Step 3: Commit** — `git add webui/src/pages/TasksPage.svelte && git commit -m "feat(webui): enhanced tasks page with countdown and status dots"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: 收尾
|
||||||
|
|
||||||
|
### Task 6.1: P2 收尾验证 + 版本号
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量验证**
|
||||||
|
- `cd webui && npm run check && npm run build`
|
||||||
|
- `cargo build`
|
||||||
|
- `cargo test --lib`
|
||||||
|
- `cargo clippy --all-targets --all-features -- -D warnings`
|
||||||
|
Expected: 全部通过。
|
||||||
|
- [ ] **Step 2: 版本号** — `Cargo.toml` 与 `webui/package.json` minor bump(1.5.0 → 1.6.0)。
|
||||||
|
- [ ] **Step 3: Commit** — `git add -A && git commit -m "chore(release): P2 logs and data"`(仅暂存版本号文件 + 可能的文档;确认无构建产物)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 完成标志
|
||||||
|
|
||||||
|
- `/ws/logs` 实时推送日志帧(ts/level/target/message),设备鉴权保护,慢客户端丢旧不阻塞。
|
||||||
|
- `GET /api/logs` 保留,用于历史拉取与重连对齐。
|
||||||
|
- `PUT /api/memories/{key}` 更新 content/importance,key 不存在返回 404。
|
||||||
|
- `DELETE /api/memories/{key}` 幂等删除。
|
||||||
|
- 日志页:实时流 + level 过滤 + 搜索 + 暂停 + 下载 + 断线重连。
|
||||||
|
- 记忆页:行内编辑 + 分级删除确认 + 搜索 + 分类筛选。
|
||||||
|
- 任务页:cron 显示 + 倒计时 + 运行状态点 + 脉冲动画。
|
||||||
|
- `npm run check`、`npm run build`、`cargo build`、`cargo test --lib`、`cargo clippy -- -D warnings` 全绿。
|
||||||
|
|
||||||
|
## 风险与开放项
|
||||||
|
|
||||||
|
- **chrono vs time**:tracing-subscriber 的 `local-time` feature 已引入 `time` crate。优先用 `time::OffsetDateTime::now_local()` 避免新增 chrono 依赖。若 `now_local()` 在多线程环境报错(`time` crate 的已知限制),回退到 `std::time::SystemTime` + 手动格式化或用 `chrono`。
|
||||||
|
- **广播层性能**:`receiver_count() == 0` 短路确保无订阅者时零开销。有订阅者时每条日志一次 format + send,对高频 DEBUG 日志可能有微量开销,但 EnvFilter 默认 INFO 已过滤大部分。
|
||||||
|
- **WS 重连对齐**:前端断线重连时重新拉 `GET /api/logs` 尾部,可能丢失断线期间的少量日志(broadcast 不持久化)。可接受——日志页为运维辅助,非审计。
|
||||||
|
- **记忆分页**:现有 API 无 offset/cursor,用 limit 递增近似。大量记忆(>1000)时性能可接受(SQLite 单表 LIMIT 查询)。
|
||||||
|
- **记忆 PUT 不创建**:设计决策——新记忆只由 agent 内部创建,WebUI 仅编辑已有条目。若需创建能力,后续 P3 可加 POST。
|
||||||
67
docs/superpowers/plans/2026-07-26-p3-configuration.md
Normal file
67
docs/superpowers/plans/2026-07-26-p3-configuration.md
Normal 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 5(runes)、bits-ui Tabs。
|
||||||
|
|
||||||
|
**关键设计决策:**
|
||||||
|
1. **布局**:左侧编辑区(现有 editor-card)+ 右侧大纲/状态侧栏(仅 config.json 标签显示)。
|
||||||
|
2. **JSON 校验**:`$derived` 实时 parse content,显示错误信息或有效状态。
|
||||||
|
3. **配置大纲**:从 parsed config 提取顶层 key 列表,标注含密钥的 section(providers)和需重启的字段(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、phase(badge 着色)、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"`
|
||||||
@ -491,16 +491,41 @@ impl AgentLoop {
|
|||||||
iteration: u32,
|
iteration: u32,
|
||||||
turn: Option<&AgentTurnContext>,
|
turn: Option<&AgentTurnContext>,
|
||||||
) -> Result<ChatCompletionResponse, AgentError> {
|
) -> Result<ChatCompletionResponse, AgentError> {
|
||||||
let mut provider_stream = self.provider.stream(request).await.map_err(|error| {
|
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");
|
tracing::error!(error = %error, "LLM request failed");
|
||||||
AgentError::LlmError(error.to_string())
|
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();
|
let mut accumulator = ProviderResponseAccumulator::default();
|
||||||
while let Some(chunk) = provider_stream.next().await {
|
while let Some(chunk) = provider_stream.next().await {
|
||||||
let chunk = chunk.map_err(|error| {
|
let chunk = match chunk {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(error) => {
|
||||||
tracing::error!(error = %error, "LLM stream failed");
|
tracing::error!(error = %error, "LLM stream failed");
|
||||||
AgentError::LlmError(error.to_string())
|
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 {
|
if let Some(turn) = turn {
|
||||||
let event = match &chunk {
|
let event = match &chunk {
|
||||||
ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
|
ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
|
||||||
@ -521,7 +546,11 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
accumulator.push(chunk);
|
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(
|
fn annotate_message(
|
||||||
@ -615,6 +644,8 @@ impl AgentLoop {
|
|||||||
mut messages: Vec<ChatMessage>,
|
mut messages: Vec<ChatMessage>,
|
||||||
turn: Option<AgentTurnContext>,
|
turn: Option<AgentTurnContext>,
|
||||||
) -> Result<AgentProcessResult, AgentError> {
|
) -> Result<AgentProcessResult, AgentError> {
|
||||||
|
let turn_start = Instant::now();
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
history_len = messages.len(),
|
history_len = messages.len(),
|
||||||
@ -703,6 +734,10 @@ impl AgentLoop {
|
|||||||
assistant_message.provider_state = response.provider_state;
|
assistant_message.provider_state = response.provider_state;
|
||||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
||||||
emitted_messages.push(assistant_message.clone());
|
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 {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -844,6 +879,10 @@ impl AgentLoop {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
emitted_messages.push(assistant_message.clone());
|
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 {
|
Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -876,6 +915,11 @@ impl AgentLoop {
|
|||||||
let mut final_message = ChatMessage::assistant(fallback);
|
let mut final_message = ChatMessage::assistant(fallback);
|
||||||
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
||||||
emitted_messages.push(final_message.clone());
|
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 {
|
Ok(AgentProcessResult {
|
||||||
final_response: final_message,
|
final_response: final_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
@ -1016,6 +1060,9 @@ impl AgentLoop {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crate::observability::metrics::global_metrics()
|
||||||
|
.record_tool_call(&tool_name, result.success);
|
||||||
|
|
||||||
// Apply duration
|
// Apply duration
|
||||||
Ok(ToolExecutionOutcome { duration, ..result })
|
Ok(ToolExecutionOutcome { duration, ..result })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -832,6 +832,8 @@ mod tests {
|
|||||||
token_limit: 4096,
|
token_limit: 4096,
|
||||||
workspace_dir: std::env::temp_dir(),
|
workspace_dir: std::env::temp_dir(),
|
||||||
input_types: vec!["text".into()],
|
input_types: vec!["text".into()],
|
||||||
|
price_input_per_million: None,
|
||||||
|
price_output_per_million: None,
|
||||||
},
|
},
|
||||||
Arc::new(ToolRegistry::new()),
|
Arc::new(ToolRegistry::new()),
|
||||||
None,
|
None,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@ -22,6 +23,7 @@ pub struct OutboundDispatcher {
|
|||||||
channel_manager: ChannelManager,
|
channel_manager: ChannelManager,
|
||||||
task_supervisor: TaskSupervisor,
|
task_supervisor: TaskSupervisor,
|
||||||
write_locks: ConversationWriteLocks,
|
write_locks: ConversationWriteLocks,
|
||||||
|
active_lanes: Arc<AtomicUsize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OutboundDispatcher {
|
impl OutboundDispatcher {
|
||||||
@ -30,12 +32,14 @@ impl OutboundDispatcher {
|
|||||||
channel_manager: ChannelManager,
|
channel_manager: ChannelManager,
|
||||||
task_supervisor: TaskSupervisor,
|
task_supervisor: TaskSupervisor,
|
||||||
write_locks: ConversationWriteLocks,
|
write_locks: ConversationWriteLocks,
|
||||||
|
active_lanes: Arc<AtomicUsize>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bus,
|
bus,
|
||||||
channel_manager,
|
channel_manager,
|
||||||
task_supervisor,
|
task_supervisor,
|
||||||
write_locks,
|
write_locks,
|
||||||
|
active_lanes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,9 +129,14 @@ impl OutboundDispatcher {
|
|||||||
chat_id: String,
|
chat_id: String,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let target_lock = self.write_locks.for_target(&channel_name, &chat_id);
|
let target_lock = self.write_locks.for_target(&channel_name, &chat_id);
|
||||||
|
let active_lanes = self.active_lanes.clone();
|
||||||
self.task_supervisor.spawn(
|
self.task_supervisor.spawn(
|
||||||
format!("outbound-lane:{channel_name}:{chat_id}"),
|
format!("outbound-lane:{channel_name}:{chat_id}"),
|
||||||
async move {
|
async move {
|
||||||
|
active_lanes.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let _guard = LaneGuard {
|
||||||
|
counter: active_lanes,
|
||||||
|
};
|
||||||
loop {
|
loop {
|
||||||
let msg = match tokio::time::timeout(LANE_IDLE_TIMEOUT, receiver.recv()).await {
|
let msg = match tokio::time::timeout(LANE_IDLE_TIMEOUT, receiver.recv()).await {
|
||||||
Ok(Some(msg)) => msg,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -275,6 +296,7 @@ mod tests {
|
|||||||
manager,
|
manager,
|
||||||
supervisor.clone(),
|
supervisor.clone(),
|
||||||
ConversationWriteLocks::default(),
|
ConversationWriteLocks::default(),
|
||||||
|
Arc::new(AtomicUsize::new(0)),
|
||||||
);
|
);
|
||||||
let task = tokio::spawn(async move { dispatcher.run().await });
|
let task = tokio::spawn(async move { dispatcher.run().await });
|
||||||
bus.publish_outbound(outbound("slow", "slow-1"))
|
bus.publish_outbound(outbound("slow", "slow-1"))
|
||||||
@ -318,6 +340,7 @@ mod tests {
|
|||||||
manager,
|
manager,
|
||||||
supervisor.clone(),
|
supervisor.clone(),
|
||||||
ConversationWriteLocks::default(),
|
ConversationWriteLocks::default(),
|
||||||
|
Arc::new(AtomicUsize::new(0)),
|
||||||
);
|
);
|
||||||
let task = tokio::spawn(async move { dispatcher.run().await });
|
let task = tokio::spawn(async move { dispatcher.run().await });
|
||||||
|
|
||||||
@ -348,6 +371,7 @@ mod tests {
|
|||||||
manager,
|
manager,
|
||||||
supervisor.clone(),
|
supervisor.clone(),
|
||||||
ConversationWriteLocks::default(),
|
ConversationWriteLocks::default(),
|
||||||
|
Arc::new(AtomicUsize::new(0)),
|
||||||
);
|
);
|
||||||
let task = tokio::spawn(async move { dispatcher.run().await });
|
let task = tokio::spawn(async move { dispatcher.run().await });
|
||||||
|
|
||||||
@ -360,6 +384,50 @@ mod tests {
|
|||||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
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]
|
#[tokio::test]
|
||||||
async fn permanent_send_failure_is_not_retried() {
|
async fn permanent_send_failure_is_not_retried() {
|
||||||
let channel = PermanentFailureChannel {
|
let channel = PermanentFailureChannel {
|
||||||
|
|||||||
@ -106,6 +106,29 @@ impl MessageBus {
|
|||||||
pub async fn consume_control(&self) -> Option<ControlMessage> {
|
pub async fn consume_control(&self) -> Option<ControlMessage> {
|
||||||
self.control_rx.lock().await.recv().await
|
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 {}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -508,6 +508,19 @@ pub struct LLMProviderConfig {
|
|||||||
pub token_limit: usize,
|
pub token_limit: usize,
|
||||||
pub workspace_dir: PathBuf,
|
pub workspace_dir: PathBuf,
|
||||||
pub input_types: Vec<String>,
|
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 {
|
pub fn get_default_config_path() -> PathBuf {
|
||||||
@ -653,6 +666,8 @@ impl Config {
|
|||||||
token_limit: agent.token_limit,
|
token_limit: agent.token_limit,
|
||||||
workspace_dir: expand_path(&self.workspace_dir),
|
workspace_dir: expand_path(&self.workspace_dir),
|
||||||
input_types: model.input_type.clone(),
|
input_types: model.input_type.clone(),
|
||||||
|
price_input_per_million: None,
|
||||||
|
price_output_per_million: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -169,6 +169,14 @@ impl ApiError {
|
|||||||
message: error.to_string(),
|
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(
|
pub async fn upload_file(
|
||||||
@ -689,6 +697,170 @@ pub struct LimitQuery {
|
|||||||
limit: Option<usize>,
|
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(
|
pub async fn get_tasks(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
Query(query): Query<LimitQuery>,
|
Query(query): Query<LimitQuery>,
|
||||||
@ -775,10 +947,164 @@ pub async fn get_memories(
|
|||||||
Ok(Json(json!({ "memories": 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn secrets_are_redacted_and_restored() {
|
fn secrets_are_redacted_and_restored() {
|
||||||
let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});
|
let current = json!({"api_key":"real", "nested":{"access_token":"token"}, "safe":"yes"});
|
||||||
|
|||||||
@ -8,6 +8,7 @@ pub mod ws;
|
|||||||
use axum::{Router, middleware, routing};
|
use axum::{Router, middleware, routing};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::bus::{MessageBus, OutboundDispatcher};
|
use crate::bus::{MessageBus, OutboundDispatcher};
|
||||||
@ -21,6 +22,18 @@ use crate::scheduler::Scheduler;
|
|||||||
use crate::session::{SessionManager, SessionManagerServices};
|
use crate::session::{SessionManager, SessionManagerServices};
|
||||||
use crate::task_supervisor::TaskSupervisor;
|
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 struct GatewayState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub config_path: std::path::PathBuf,
|
pub config_path: std::path::PathBuf,
|
||||||
@ -33,6 +46,10 @@ pub struct GatewayState {
|
|||||||
pub connection_shutdown: tokio_util::sync::CancellationToken,
|
pub connection_shutdown: tokio_util::sync::CancellationToken,
|
||||||
pub auth: auth::AuthManager,
|
pub auth: auth::AuthManager,
|
||||||
pub uploads: uploads::UploadRegistry,
|
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) reload: reload::ReloadHandle,
|
||||||
pub(crate) admission: reload::RuntimeAdmission,
|
pub(crate) admission: reload::RuntimeAdmission,
|
||||||
}
|
}
|
||||||
@ -231,6 +248,8 @@ impl GatewayState {
|
|||||||
connection_shutdown,
|
connection_shutdown,
|
||||||
auth,
|
auth,
|
||||||
uploads,
|
uploads,
|
||||||
|
ws_connections: Arc::new(AtomicUsize::new(0)),
|
||||||
|
outbound_lanes: Arc::new(AtomicUsize::new(0)),
|
||||||
reload,
|
reload,
|
||||||
admission,
|
admission,
|
||||||
})
|
})
|
||||||
@ -312,6 +331,7 @@ impl GatewayState {
|
|||||||
self.channel_manager.clone(),
|
self.channel_manager.clone(),
|
||||||
self.task_supervisor.clone(),
|
self.task_supervisor.clone(),
|
||||||
self.delivery_coordinator.write_locks(),
|
self.delivery_coordinator.write_locks(),
|
||||||
|
self.outbound_lanes.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.task_supervisor
|
self.task_supervisor
|
||||||
@ -341,6 +361,7 @@ pub async fn run(
|
|||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
STARTED.get_or_init(std::time::Instant::now);
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
let startup_process_env = Config::startup_process_env();
|
let startup_process_env = Config::startup_process_env();
|
||||||
let startup_cwd = std::env::current_dir()?;
|
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/logs", routing::get(http::get_logs))
|
||||||
.route("/api/tasks", routing::get(http::get_tasks))
|
.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", routing::get(http::get_jobs))
|
||||||
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
.route("/api/jobs/{id}/runs", routing::get(http::get_job_runs))
|
||||||
.route("/api/memories", routing::get(http::get_memories))
|
.route("/api/memories", routing::get(http::get_memories))
|
||||||
|
.route(
|
||||||
|
"/api/memories/{key}",
|
||||||
|
routing::put(http::put_memory).delete(http::delete_memory),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/chat/{client_id}/uploads",
|
"/api/chat/{client_id}/uploads",
|
||||||
routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()),
|
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),
|
routing::get(http::download_attachment),
|
||||||
)
|
)
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
|
.route("/ws/logs", routing::get(ws::ws_logs_handler))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.auth.clone(),
|
state.auth.clone(),
|
||||||
auth::require_auth,
|
auth::require_auth,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use axum::response::Response;
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::time::{Duration, timeout};
|
use tokio::time::{Duration, timeout};
|
||||||
|
|
||||||
@ -42,6 +43,8 @@ async fn handle_socket(
|
|||||||
client_id: Option<String>,
|
client_id: Option<String>,
|
||||||
identity: super::auth::AuthIdentity,
|
identity: super::auth::AuthIdentity,
|
||||||
) {
|
) {
|
||||||
|
let _connection_guard = ConnectionGuard::new(state.ws_connections.clone());
|
||||||
|
|
||||||
// Create channel for sending outbound messages to this client
|
// Create channel for sending outbound messages to this client
|
||||||
let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100);
|
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");
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -145,4 +261,14 @@ mod tests {
|
|||||||
assert!(valid_client_id(Some("x".repeat(65))).is_none());
|
assert!(valid_client_id(Some("x".repeat(65))).is_none());
|
||||||
assert!(valid_client_id(Some(String::new())).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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,93 @@
|
|||||||
use std::path::PathBuf;
|
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_appender::rolling::{RollingFileAppender, Rotation};
|
||||||
|
use tracing_subscriber::layer::Context;
|
||||||
|
use tracing_subscriber::registry::LookupSpan;
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
|
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 {
|
pub fn get_default_log_dir() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("logs")
|
home.join(".picobot").join("logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the default config file path: ~/.picobot/config.json
|
|
||||||
pub fn get_default_config_path() -> PathBuf {
|
pub fn get_default_config_path() -> PathBuf {
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
home.join(".picobot").join("config.json")
|
home.join(".picobot").join("config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize logging with file appender
|
|
||||||
/// Logs are written to ~/.picobot/logs/ with daily rotation
|
|
||||||
pub fn init_logging() {
|
pub fn init_logging() {
|
||||||
|
let (tx, _) = broadcast::channel::<LogEvent>(1024);
|
||||||
|
let _ = LOG_TX.set(tx);
|
||||||
|
|
||||||
let log_dir = get_default_log_dir();
|
let log_dir = get_default_log_dir();
|
||||||
|
|
||||||
// Create log directory if it doesn't exist
|
|
||||||
if !log_dir.exists()
|
if !log_dir.exists()
|
||||||
&& let Err(e) = std::fs::create_dir_all(&log_dir)
|
&& 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");
|
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 env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
|
||||||
let file_layer = fmt::layer()
|
let file_layer = fmt::layer()
|
||||||
@ -55,6 +119,7 @@ pub fn init_logging() {
|
|||||||
.with(env_filter)
|
.with(env_filter)
|
||||||
.with(console_layer)
|
.with(console_layer)
|
||||||
.with(file_layer)
|
.with(file_layer)
|
||||||
|
.with(BroadcastLayer)
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
|
||||||
|
|||||||
295
src/observability/metrics.rs
Normal file
295
src/observability/metrics.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@
|
|||||||
//! This module provides an Observer pattern for emitting and collecting
|
//! This module provides an Observer pattern for emitting and collecting
|
||||||
//! telemetry events during agent execution.
|
//! telemetry events during agent execution.
|
||||||
|
|
||||||
|
pub mod metrics;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::bus::MediaRef;
|
use crate::bus::MediaRef;
|
||||||
|
|||||||
@ -225,6 +225,8 @@ mod tests {
|
|||||||
token_limit: 8_192,
|
token_limit: 8_192,
|
||||||
workspace_dir: PathBuf::from("."),
|
workspace_dir: PathBuf::from("."),
|
||||||
input_types: vec!["text".to_string(), "image".to_string()],
|
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(
|
let session = Arc::new(Mutex::new(
|
||||||
Session::new(
|
Session::new(
|
||||||
|
|||||||
@ -432,6 +432,7 @@ mod cancelled_partial_tests {
|
|||||||
channels,
|
channels,
|
||||||
supervisor.clone(),
|
supervisor.clone(),
|
||||||
ConversationWriteLocks::default(),
|
ConversationWriteLocks::default(),
|
||||||
|
Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||||
);
|
);
|
||||||
let dispatcher_task = tokio::spawn(async move { dispatcher.run().await });
|
let dispatcher_task = tokio::spawn(async move { dispatcher.run().await });
|
||||||
|
|
||||||
@ -1707,6 +1708,10 @@ impl SessionManager {
|
|||||||
self.tools.clone()
|
self.tools.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn skills_loader(&self) -> Arc<SkillsLoader> {
|
||||||
|
self.skills_loader.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn work_manager(&self) -> Arc<crate::work::WorkManager> {
|
pub fn work_manager(&self) -> Arc<crate::work::WorkManager> {
|
||||||
self.work_manager.clone()
|
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(
|
pub async fn create_session(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
|
|||||||
@ -248,6 +248,24 @@ impl SkillsLoader {
|
|||||||
max_mtime
|
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)
|
/// Get a copy of loaded skills (checks for changes first)
|
||||||
pub fn get_loaded_skills(&self) -> Vec<Skill> {
|
pub fn get_loaded_skills(&self) -> Vec<Skill> {
|
||||||
self.reload_if_changed();
|
self.reload_if_changed();
|
||||||
@ -586,4 +604,31 @@ This is the content.
|
|||||||
let beta_pos = prompt.find("**beta**").unwrap();
|
let beta_pos = prompt.find("**beta**").unwrap();
|
||||||
assert!(alpha_pos < beta_pos);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,17 @@ impl super::Storage {
|
|||||||
parse_memory_rows(&rows)
|
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).
|
/// Store or update a memory entry (upsert by key).
|
||||||
pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> {
|
pub async fn upsert_memory(&self, entry: &MemoryEntry) -> Result<(), StorageError> {
|
||||||
let category_str = entry.category.as_str();
|
let category_str = entry.category.as_str();
|
||||||
|
|||||||
@ -114,6 +114,13 @@ impl TaskSupervisor {
|
|||||||
true
|
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) {
|
pub fn cancel(&self) {
|
||||||
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
|
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
state.stopping = true;
|
state.stopping = true;
|
||||||
@ -199,4 +206,18 @@ mod tests {
|
|||||||
|
|
||||||
assert!(cleaned_up.load(std::sync::atomic::Ordering::SeqCst));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,8 @@ fn load_config() -> Option<LLMProviderConfig> {
|
|||||||
token_limit: 128_000,
|
token_limit: 128_000,
|
||||||
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
|
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
|
||||||
input_types: vec!["text".to_string()],
|
input_types: vec!["text".to_string()],
|
||||||
|
price_input_per_million: None,
|
||||||
|
price_output_per_million: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,8 @@ fn load_openai_config() -> Option<LLMProviderConfig> {
|
|||||||
token_limit: 128_000,
|
token_limit: 128_000,
|
||||||
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
|
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
|
||||||
input_types: vec!["text".to_string()],
|
input_types: vec!["text".to_string()],
|
||||||
|
price_input_per_million: None,
|
||||||
|
price_output_per_million: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
import MemoryPage from "./pages/MemoryPage.svelte";
|
import MemoryPage from "./pages/MemoryPage.svelte";
|
||||||
import LogsPage from "./pages/LogsPage.svelte";
|
import LogsPage from "./pages/LogsPage.svelte";
|
||||||
import SettingsPage from "./pages/SettingsPage.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";
|
import PairingPage from "./pages/PairingPage.svelte";
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
@ -125,6 +127,8 @@
|
|||||||
{:else if current === "memory"}<MemoryPage />
|
{:else if current === "memory"}<MemoryPage />
|
||||||
{:else if current === "tasks"}<TasksPage />
|
{:else if current === "tasks"}<TasksPage />
|
||||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
{: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}
|
{:else}<div class="empty-card">即将上线</div>{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
webui/src/lib/components/CapacityMeter.svelte
Normal file
30
webui/src/lib/components/CapacityMeter.svelte
Normal 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>
|
||||||
23
webui/src/lib/components/MetricTile.svelte
Normal file
23
webui/src/lib/components/MetricTile.svelte
Normal 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>
|
||||||
48
webui/src/lib/components/Sparkline.svelte
Normal file
48
webui/src/lib/components/Sparkline.svelte
Normal 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>
|
||||||
@ -1,44 +1,289 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { Switch } from "bits-ui";
|
|
||||||
import { api } from "../lib/api.js";
|
import { api } from "../lib/api.js";
|
||||||
|
|
||||||
let search = $state("");
|
const MAX_LINES = 2000;
|
||||||
let lineCount = $state("500");
|
const LEVELS = ["全部", "INF", "WRN", "ERR"];
|
||||||
let autoRefresh = $state(true);
|
|
||||||
let lines = $state([]);
|
|
||||||
let files = $state([]);
|
|
||||||
let updatedAt = $state("");
|
|
||||||
let error = $state("");
|
|
||||||
let logView;
|
|
||||||
|
|
||||||
async function load() {
|
let lines = $state([]);
|
||||||
const params = new URLSearchParams({ lines: lineCount });
|
let level = $state("全部");
|
||||||
if (search.trim()) params.set("search", search.trim());
|
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 {
|
try {
|
||||||
const result = await api(`/api/logs?${params}`);
|
const data = JSON.parse(event.data);
|
||||||
lines = result.lines;
|
appendLine({
|
||||||
files = result.files;
|
ts: data.ts || "",
|
||||||
updatedAt = new Date().toLocaleTimeString();
|
level: normalizeLevel(data.level),
|
||||||
error = "";
|
target: data.target || "",
|
||||||
requestAnimationFrame(() => { if (logView) logView.scrollTop = logView.scrollHeight; });
|
message: data.message || ""
|
||||||
} catch (caught) { error = caught.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?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(() => {
|
onMount(() => {
|
||||||
load();
|
loadHistory().then(() => connect());
|
||||||
const timer = setInterval(() => { if (autoRefresh) load(); }, 5000);
|
|
||||||
return () => clearInterval(timer);
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="page active content-page">
|
<section class="page active content-page logs-page">
|
||||||
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}>
|
<div class="toolbar filters">
|
||||||
<label class="search grow"><span>⌕</span><input bind:value={search} placeholder="过滤日志" /></label>
|
<div class="chips">
|
||||||
<select bind:value={lineCount}><option>200</option><option>500</option><option>1000</option><option>5000</option></select>
|
{#each LEVELS as lv}
|
||||||
<label class="switch-label"><Switch.Root class="switch" bind:checked={autoRefresh}><Switch.Thumb class="switch-thumb" /></Switch.Root><span>自动刷新</span></label>
|
<button class="chip" class:active={level === lv} onclick={() => (level = lv)}>{lv}</button>
|
||||||
<button class="secondary" type="submit">↻ 刷新</button>
|
{/each}
|
||||||
</form>
|
</div>
|
||||||
<div class="log-meta">{files.length} 个日志文件 · 显示 {lines.length} 行{#if updatedAt} · {updatedAt}{/if}</div>
|
<label class="search grow"><span>⌕</span><input bind:value={search} placeholder="搜索日志" /></label>
|
||||||
<pre bind:this={logView} class="log-view">{error || lines.join("\n") || "没有匹配的日志"}</pre>
|
<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>
|
</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>
|
||||||
|
|||||||
@ -7,12 +7,23 @@
|
|||||||
let memories = $state([]);
|
let memories = $state([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state("");
|
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 knowledge = $derived(memories.filter((item) => item.category === "knowledge").length);
|
||||||
|
const timeline = $derived(memories.length - knowledge);
|
||||||
|
const hasMore = $derived(memories.length >= limit);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = "";
|
error = "";
|
||||||
const params = new URLSearchParams({ limit: "200" });
|
const params = new URLSearchParams({ limit: String(limit) });
|
||||||
if (query.trim()) params.set("query", query.trim());
|
if (query.trim()) params.set("query", query.trim());
|
||||||
if (category) params.set("category", category);
|
if (category) params.set("category", category);
|
||||||
try { memories = (await api(`/api/memories?${params}`)).memories; }
|
try { memories = (await api(`/api/memories?${params}`)).memories; }
|
||||||
@ -20,19 +31,146 @@
|
|||||||
finally { loading = false; }
|
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);
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="page active content-page">
|
<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>
|
<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>
|
<button class="secondary" type="submit">查询</button>
|
||||||
</form>
|
</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">
|
<div class="cards">
|
||||||
{#if loading}<div class="loading">加载中…</div>
|
{#if loading}<div class="loading">加载中…</div>
|
||||||
{:else if error}<div class="empty-card error-text">{error}</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>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|||||||
198
webui/src/pages/OverviewPage.svelte
Normal file
198
webui/src/pages/OverviewPage.svelte
Normal 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>
|
||||||
@ -6,20 +6,42 @@
|
|||||||
let { notify } = $props();
|
let { notify } = $props();
|
||||||
let tab = $state("config");
|
let tab = $state("config");
|
||||||
let content = $state("");
|
let content = $state("");
|
||||||
|
let original = $state("");
|
||||||
let path = $state("");
|
let path = $state("");
|
||||||
let loading = $state(true);
|
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 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() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
if (tab === "config") {
|
if (isConfig) {
|
||||||
const result = await api("/api/config");
|
const result = await api("/api/config");
|
||||||
content = JSON.stringify(result.config, null, 2);
|
content = JSON.stringify(result.config, null, 2);
|
||||||
|
original = content;
|
||||||
path = result.path;
|
path = result.path;
|
||||||
} else {
|
} else {
|
||||||
const result = await api(`/api/profiles/${tab}`);
|
const result = await api(`/api/profiles/${tab}`);
|
||||||
content = result.content;
|
content = result.content;
|
||||||
|
original = content;
|
||||||
path = result.path;
|
path = result.path;
|
||||||
}
|
}
|
||||||
} catch (caught) { notify(caught.message, true); }
|
} catch (caught) { notify(caught.message, true); }
|
||||||
@ -27,22 +49,67 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (isConfig && jsonError) return;
|
||||||
|
saving = true;
|
||||||
try {
|
try {
|
||||||
if (tab === "config") {
|
if (isConfig) {
|
||||||
let config;
|
const config = JSON.parse(content);
|
||||||
try { config = JSON.parse(content); } catch (caught) { throw new Error(`JSON 格式错误:${caught.message}`); }
|
|
||||||
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
|
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
|
||||||
content = JSON.stringify(result.config, null, 2);
|
content = JSON.stringify(result.config, null, 2);
|
||||||
notify("配置已保存,请重启 Gateway 使其生效");
|
original = content;
|
||||||
|
notify("配置已保存");
|
||||||
} else {
|
} else {
|
||||||
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
|
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
|
||||||
|
original = content;
|
||||||
notify("助手档案已保存");
|
notify("助手档案已保存");
|
||||||
}
|
}
|
||||||
} catch (caught) { notify(caught.message, true); }
|
} catch (caught) { notify(caught.message, true); }
|
||||||
|
finally { saving = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeTab(value) { tab = value; load(); }
|
async function saveAndReload() {
|
||||||
onMount(load);
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="page active content-page">
|
<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.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.List>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
|
|
||||||
|
{#if isConfig}
|
||||||
|
<div class="config-layout">
|
||||||
<div class="editor-card">
|
<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>
|
<div class="editor-head">
|
||||||
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
|
<div><strong>{title}</strong><small>{path}</small></div>
|
||||||
<div class="notice">{#if tab === "config"}API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。配置保存后需重启 Gateway 生效。{:else}Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。{/if}</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>
|
||||||
</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>
|
</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>
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
let runs = $state({});
|
let runs = $state({});
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state("");
|
let error = $state("");
|
||||||
|
let tick = $state(0);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@ -21,7 +22,7 @@
|
|||||||
jobs = (await api("/api/jobs")).jobs;
|
jobs = (await api("/api/jobs")).jobs;
|
||||||
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
runs = Object.fromEntries(await Promise.all(jobs.map(async (job) => [
|
||||||
job.id,
|
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) {
|
} catch (caught) {
|
||||||
@ -36,7 +37,43 @@
|
|||||||
load();
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="page active content-page">
|
<section class="page active content-page">
|
||||||
@ -54,15 +91,60 @@
|
|||||||
{:else if error}<div class="empty-card error-text">{error}</div>
|
{:else if error}<div class="empty-card error-text">{error}</div>
|
||||||
{:else if tab === "background"}
|
{:else if tab === "background"}
|
||||||
{#each tasks as task (task.id)}
|
{#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}<div class="empty-card">暂无后台任务</div>{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each jobs as job (job.id)}
|
{#each jobs as job (job.id)}
|
||||||
<article class="card">
|
<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}
|
{#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>
|
</article>
|
||||||
{:else}<div class="empty-card">暂无定时任务</div>{/each}
|
{:else}<div class="empty-card">暂无定时任务</div>{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|||||||
185
webui/src/pages/ToolsPage.svelte
Normal file
185
webui/src/pages/ToolsPage.svelte
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user