From 0958264c6f8e4344caa830f43be1f92dce9dae40 Mon Sep 17 00:00:00 2001 From: Daniele Date: Thu, 10 Sep 2026 14:03:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20LLM=20stats=20answer=20who/w?= =?UTF-8?q?hat=20spends=20tokens=20=E2=80=94=20member=20scope=20chips=20an?= =?UTF-8?q?d=20spend=20breakdowns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stats section was designed single-user: four global charts, no attribution. Request metadata rows now carry the session's source and the frame's agent_id/depth (additive ensure_column), denormalized at log time by the LoggingModel from the owner's pool — off the turn's hot path, degrading to NULLs, never to a lost row. The dashboard gains member scope chips filtering every chart, and a breakdown row splitting the range's billed tokens by member, kind (chat / sub-agents / cron / system agents / channels), agent, model and provider — a sub-agent's spend is attributed to the sub-agent itself. Rows predating the columns group under 'older data'. --- CHANGELOG.md | 7 + crates/skald-core/src/db/llm_requests/mod.rs | 18 +- crates/skald-core/src/db/mod.rs | 10 + crates/skald-core/src/llm/logging.rs | 145 ++++++++++---- dev-docs/frontend.md | 2 +- dev-docs/llm-stack.md | 2 +- docs/dashboard.md | 22 ++- docs/index.md | 2 +- src/frontend/api/stats.rs | 170 +++++++++++++++-- web/components/dashboard-page.js | 188 ++++++++++++++++--- web/css/home.css | 20 +- web/i18n/en.js | 21 ++- web/i18n/fr.js | 21 ++- web/i18n/it.js | 21 ++- 14 files changed, 550 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5470f2c..b3d277b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it. ### Added +- The dashboard's **LLM stats** section answers "who and what is spending tokens", not + just "how much": a row of member chips filters every chart to one person (or the whole + instance, as before), and a new breakdown row splits the billed tokens of the selected + range **by member, by kind** (chat / sub-agents / scheduled tasks / system agents / + channels), **by agent, by model and by provider** — a sub-agent's spend is attributed to + the sub-agent itself. Request metadata rows now carry the session's source and the + frame's agent/depth, denormalized at log time; older rows group under "Older data". - The file viewer opens **word-processor documents** (`.docx`, `.doc`, `.odt`, `.rtf`): when LibreOffice is installed on the server they are converted to PDF and shown as the document, live-reloading when the file changes, exactly like a compiled `.tex`. A diff --git a/crates/skald-core/src/db/llm_requests/mod.rs b/crates/skald-core/src/db/llm_requests/mod.rs index fe5bc60..dc76ddd 100644 --- a/crates/skald-core/src/db/llm_requests/mod.rs +++ b/crates/skald-core/src/db/llm_requests/mod.rs @@ -6,7 +6,8 @@ //! of the traffic is known — `user_id` is what the UI filters on). //! Payloads (request/response bodies + headers) live in `llm_request_payloads` //! in the owner bucket (`{userid}.db`), correlated by `request_id`. -//! Rows are retained for `llm.request_log.retention_days` days (default 14). +//! Rows are retained for `llm.requests_log.cleanup_rows_after` days (the +//! shipped `default.config.yaml` sets 90; unset = kept forever). use anyhow::Result; use sqlx::SqlitePool; @@ -31,6 +32,13 @@ pub struct LlmRequestRow { pub cache_read_tokens: Option, /// Tokens written into the provider's prompt cache (Anthropic only). pub cache_creation_tokens: Option, + /// Denormalized attribution (see `db::mod`): the session's `source`, the + /// frame's `agent_id` (the sub-agent's own for a child frame) and `depth` + /// (0 = main agent, >0 = sub-agent). Resolved by the LoggingModel from the + /// owner's pool; `None` when it was unavailable. + pub source: Option, + pub agent_id: Option, + pub depth: Option, } // ── Writes ──────────────────────────────────────────────────────────────────── @@ -40,8 +48,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result { "INSERT INTO llm_requests ( request_id, user_id, session_id, stack_id, model_name, error_text, input_tokens, output_tokens, duration_ms, - cache_read_tokens, cache_creation_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + cache_read_tokens, cache_creation_tokens, + source, agent_id, depth + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", ) .bind(&row.request_id) @@ -55,6 +64,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result { .bind(row.duration_ms) .bind(row.cache_read_tokens) .bind(row.cache_creation_tokens) + .bind(&row.source) + .bind(&row.agent_id) + .bind(row.depth) .fetch_one(pool) .await?; diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index c4a46f1..230057a 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -421,6 +421,16 @@ pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { .execute(pool) .await?; + // Attribution columns for the multi-user stats (who/what consumed): the + // session's `source`, the frame's `agent_id` and its `depth` (0 = the + // conversation's main agent, >0 = a sub-agent). They live in the owner + // bucket, so the LoggingModel denormalizes them onto the row at insert + // time — a registry query cannot join an encrypted per-user file. NULL on + // rows predating the columns and when the owner's pool was not available. + ensure_column(pool, "llm_requests", "source", "TEXT").await?; + ensure_column(pool, "llm_requests", "agent_id", "TEXT").await?; + ensure_column(pool, "llm_requests", "depth", "INTEGER").await?; + // User directory + auth material. Read before every login, so it lives in // the registry — which means it must never hold anything that derives a // user's key: `database_password` is the DEK sealed under a key derived diff --git a/crates/skald-core/src/llm/logging.rs b/crates/skald-core/src/llm/logging.rs index e8e8a64..c324049 100644 --- a/crates/skald-core/src/llm/logging.rs +++ b/crates/skald-core/src/llm/logging.rs @@ -5,7 +5,8 @@ //! //! * a **metadata-only** row in `llm_requests` (`system.db`) — cost, tokens, //! timing, plus the correlation the UI filters on (`user_id`, `session_id`, -//! `stack_id`); +//! `stack_id`) and the denormalized attribution the dashboard breaks down on +//! (`source`, `agent_id`, `depth`); //! * the **payload** (request/response bodies + headers) in //! `llm_request_payloads` in the caller's own database, keyed by the same //! `request_id`. @@ -90,6 +91,90 @@ impl LoggingModel { } } +/// Resolves the denormalized attribution columns from the owner's pool: the +/// session's `source`, and the frame's `agent_id`/`depth`. The frame row +/// carries the sub-agent's own id for a child frame, so it wins over the +/// session's agent; the root frame carries the session's main agent. Runs +/// inside the spawned insert task — never on the turn's hot path — and any +/// failure degrades to NULLs, never to a lost metadata row. +async fn resolve_attribution( + pool: &SqlitePool, + session_id: i64, + stack_id: Option, +) -> (Option, Option, Option) { + let session = sqlx::query_as::<_, (String, String)>( + "SELECT source, agent_id FROM chat_sessions WHERE id = ?", + ) + .bind(session_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + + let frame = match stack_id { + Some(id) => sqlx::query_as::<_, (i64, String)>( + "SELECT depth, agent_id FROM chat_sessions_stack WHERE id = ?", + ) + .bind(id) + .fetch_optional(pool) + .await + .ok() + .flatten(), + None => None, + }; + + let source = session.as_ref().map(|(s, _)| s.clone()); + let agent_id = frame + .as_ref() + .map(|(_, a)| a.clone()) + .or_else(|| session.as_ref().map(|(_, a)| a.clone())); + let depth = frame.as_ref().map(|(d, _)| *d); + (source, agent_id, depth) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_insert( + registry: Arc, + owner: Option>, + request_id: Option, + user_id: Option, + session_id: Option, + stack_id: Option, + model_name: String, + error_text: Option, + duration_ms: i64, + // (input, output, cache_read, cache_creation) — all None on HTTP failure. + usage: (Option, Option, Option, Option), +) { + tokio::spawn(async move { + // Attribution needs the owner's pool (chat_sessions lives there); a + // locked owner still gets the metadata row, only without attribution. + let (source, agent_id, depth) = match (owner.as_deref(), session_id) { + (Some(p), Some(sid)) => resolve_attribution(p, sid, stack_id).await, + _ => (None, None, None), + }; + let (input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens) = usage; + if let Err(e) = llm_requests::insert(®istry, llm_requests::LlmRequestRow { + request_id, + user_id, + session_id, + stack_id, + model_name, + error_text, + input_tokens, + output_tokens, + duration_ms, + cache_read_tokens, + cache_creation_tokens, + source, + agent_id, + depth, + }).await { + warn!(error = %e, "llm_requests: failed to insert log row"); + } + }); +} + #[async_trait] impl Model for LoggingModel { async fn complete( @@ -108,11 +193,12 @@ impl Model for LoggingModel { let request_id = Some(req.request_id.clone()); let model_name = self.model_name.clone(); let pool = Arc::clone(&self.registry); + let owner = self.target.payloads.clone(); match &result { Ok(resp) => { let usage = resp.usage(); - let (input_tokens, output_tokens, cache_read, cache_write) = ( + let usage = ( usage.input_tokens.map(|n| n as i64), usage.output_tokens.map(|n| n as i64), usage.cache_read.map(|n| n as i64), @@ -121,23 +207,8 @@ impl Model for LoggingModel { if let Some(raw) = resp.raw() { self.spawn_payload(&req.request_id, raw); } - tokio::spawn(async move { - if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { - request_id, - user_id, - session_id, - stack_id, - model_name, - error_text: None, - input_tokens, - output_tokens, - duration_ms, - cache_read_tokens: cache_read, - cache_creation_tokens: cache_write, - }).await { - warn!(error = %e, "llm_requests: failed to insert log row"); - } - }); + spawn_insert(pool, owner, request_id, user_id, session_id, stack_id, + model_name, None, duration_ms, usage); } Err(e) => { // Only an HTTP failure carries a body (a provider 400 is exactly @@ -145,24 +216,9 @@ impl Model for LoggingModel { if let Some(raw) = e.raw.as_ref() { self.spawn_payload(&req.request_id, raw); } - let error_text = e.to_string(); - tokio::spawn(async move { - if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { - request_id, - user_id, - session_id, - stack_id, - model_name, - error_text: Some(error_text), - input_tokens: None, - output_tokens: None, - duration_ms, - cache_read_tokens: None, - cache_creation_tokens: None, - }).await { - warn!(error = %log_err, "llm_requests: failed to insert error log row"); - } - }); + spawn_insert(pool, owner, request_id, user_id, session_id, stack_id, + model_name, Some(e.to_string()), duration_ms, + (None, None, None, None)); } } @@ -240,6 +296,14 @@ mod tests { let path = temp_db_path("llmlog-ok"); let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap()); + // The attribution the row should carry: session 42 is a cron session + // whose agent is "assistant", frame 7 is a child frame of the + // "researcher" sub-agent — the frame's agent wins over the session's. + sqlx::query("INSERT INTO chat_sessions (id, source, agent_id) VALUES (42, 'cron', 'assistant')") + .execute(&*pool).await.unwrap(); + sqlx::query("INSERT INTO chat_sessions_stack (id, session_id, agent_id, depth) VALUES (7, 42, 'researcher', 1)") + .execute(&*pool).await.unwrap(); + let mut resp = ModelResponse::message("hi"); *resp.usage_mut() = Usage { input_tokens: Some(11), @@ -270,6 +334,13 @@ mod tests { assert_eq!(model_name, "gpt-test"); assert_eq!((input, output), (Some(11), Some(7))); + let (source, agent_id, depth): (Option, Option, Option) = + sqlx::query_as("SELECT source, agent_id, depth FROM llm_requests WHERE request_id = 'req-1'") + .fetch_one(&*pool).await.unwrap(); + assert_eq!(source.as_deref(), Some("cron"), "the dashboard breaks down on source"); + assert_eq!(agent_id.as_deref(), Some("researcher"), "the frame's agent wins (sub-agent)"); + assert_eq!(depth, Some(1)); + wait_for(&pool, "SELECT COUNT(*) FROM llm_request_payloads").await; let body: String = sqlx::query_scalar( "SELECT request_json FROM llm_request_payloads WHERE request_id = 'req-1'") diff --git a/dev-docs/frontend.md b/dev-docs/frontend.md index 51e7fc6..2ab46ea 100644 --- a/dev-docs/frontend.md +++ b/dev-docs/frontend.md @@ -52,7 +52,7 @@ Two independent things have to be true, and both were violated at some point: | `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page | | `sidebar.js` | `` | Nav sidebar; role-driven (`ui_mode`); inbox badge is **live** — the chat WS forwards the inbox lifecycle events (`approval_requested/resolved`, `clarification_*`, `elicitation_*`) regardless of `source`, `chat-session.js` re-dispatches them as the `inbox-changed` window event, and the sidebar (+ `agent-inbox.js`) refreshes on it; a 60 s poll remains as fallback | | `topbar.js` | `` | Top nav bar; per-user avatar color hashed from the username | -| `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide | +| `dashboard-page.js` | `` | `#dashboard` — status hero, LLM stats (member-scope chips + spend breakdowns by member/kind/agent/model/provider over `llm_requests` attribution columns), pending inbox, quick guide | | `shared/file-viewer-base.js` | `FileViewerBase` (base) | Shared file-viewer engine (fetch, kind detection, markdown/PDF/SVG/LaTeX/word-docs, watcher, `_renderBody`); driven by `_show`/`_hide`. Extended by desktop + mobile | | `file-viewer-page.js` | `` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` | | `shared/file-viewer-mobile.js` | `` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button | diff --git a/dev-docs/llm-stack.md b/dev-docs/llm-stack.md index 112c9da..5ee6048 100644 --- a/dev-docs/llm-stack.md +++ b/dev-docs/llm-stack.md @@ -8,7 +8,7 @@ ## The client layer (`crates/skald-core/src/llm/`) -LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config in [../CLAUDE.md](../CLAUDE.md)); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`Model::is_retriable`, `agent-loop`) keys on the real HTTP status carried by `ModelError { status }`, **not** a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. **Request logging** is the `logging.rs::LoggingModel` decorator, attached by the *caller's* `ModelSelector` (`loop_adapters/selector.rs::SkaldSelector::with_log`) — never by `LlmManager`, which builds one shared client per model and cannot know whose traffic it serves. The decorator's `RequestLogTarget` carries the owner: metadata → `llm_requests` in the registry (`user_id`, the column the UI filters on), payload bodies/headers → `llm_request_payloads` in that user's own encrypted DB, keyed by `request_id`; session + frame come from the request's own `conversation`/`frame`, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (`ModelRequest::log` is unused here) +LLM client abstraction (OpenAI-compat, Anthropic, Ollama…). OpenAI-compatible provider *types* are runtime data, not code: `providers/declared.rs` loads `providers.yaml` at boot (see Config in [../CLAUDE.md](../CLAUDE.md)); only non-OpenAI-compatible or bespoke providers (anthropic, ollama, openai, openrouter) stay native. **Retriability** (`Model::is_retriable`, `agent-loop`) keys on the real HTTP status carried by `ModelError { status }`, **not** a substring of the message — a model id/token count containing "404"/"401" cannot mis-classify; 401/403/404/422 don't retry, 400/429/5xx/network do. **Request logging** is the `logging.rs::LoggingModel` decorator, attached by the *caller's* `ModelSelector` (`loop_adapters/selector.rs::SkaldSelector::with_log`) — never by `LlmManager`, which builds one shared client per model and cannot know whose traffic it serves. The decorator's `RequestLogTarget` carries the owner: metadata → `llm_requests` in the registry (`user_id`, the column the UI filters on), payload bodies/headers → `llm_request_payloads` in that user's own encrypted DB, keyed by `request_id`; session + frame come from the request's own `conversation`/`frame`, so kernel rounds, sub-agent frames and compaction summaries are all attributed with no extra plumbing (`ModelRequest::log` is unused here). The metadata row also carries **denormalized attribution** for the dashboard's spend breakdowns — `source`, `agent_id`, `depth` — resolved by the spawned insert task from the owner's pool (`chat_sessions` + `chat_sessions_stack`; the frame row supplies the sub-agent's own id for child frames). That resolution is the only read the log path ever does, stays off the turn's hot path, and any failure degrades to NULLs — a missing attribution must never cost the row. Rows pre-dating the columns read as the stats' "older data" bucket. ## `providers.yaml` — two traps in the model metadata diff --git a/docs/dashboard.md b/docs/dashboard.md index 87be5a3..93c4c32 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -15,17 +15,27 @@ The status reflects the **whole instance**, not one person's account: there is o ## LLM usage stats -Four charts with a range switch (last hour / 24 hours / 7 days / 30 days): +The section answers two questions: *how much is the instance being used?* and *who or what is spending the tokens?* A range switch (last hour / 24 hours / 7 days / 30 days) applies to everything on it, and — on an instance with more than one member — a row of member chips filters **all** the charts to one person; **Everyone** is the whole instance, as before. + +Three charts show the trend over the range: - **Requests** — how many LLM calls per minute, hour or day. - **Tokens** — the metered volume, split into input (split again into cached and non-cached) and output. The tooltip shows the cache-hit percentage: repeated context that was *cached* costs less and answers faster, so a high hit rate is good news, not a sign something is stuck. - **Avg latency** — how long a model call took on average. -- **Models** — the top models by requests in the range. + +Below them, **How the spend splits** breaks the range's billed tokens down into bars: + +- **By member** — who consumed what (hidden while a member chip is selected: it would be a single bar). +- **By kind** — direct chats vs **sub-agents** (specialists the assistant delegates to) vs **scheduled tasks** and the other background system agents. Data from before this breakdown existed groups under *Older data*. +- **By agent** — which assistant or specialist consumed the most. +- **By model** and **By provider** — where the money actually goes. + +A bar's tooltip shows the request count and the input/output/cached split. Three honest answers to give with a straight face: -- **These numbers are everyone's, together.** The charts aggregate the whole instance; there is no per-person breakdown on this page. -- **They record how much, when and which model — never what was said.** The content of a request lives in the requester's own encrypted space; the charts read only counters. +- **They record how much, when, by whom and on which model — never what was said.** The content of a request lives in the requester's own encrypted space; the charts read only counters. +- **The per-member chips are a consumption view, not a surveillance tool.** They show token counts, not conversations. - **Empty is normal on a new instance.** "No LLM requests in the selected range" means exactly that: nothing has run in that window. ## Pending @@ -41,12 +51,12 @@ The same cards as the [Inbox](inbox.md) — approvals, questions and sign-in pro - **Not a monitor.** Nothing here alerts anyone; it shows the present state to whoever is looking. - **Not where models are configured.** Adding providers and models, and their priority order, is the admin's Models and Providers pages. -- **Not per-person.** No page on the instance shows "who used how much" — deliberately; usage is shared, like the models. +- **Not a message log.** The per-member and per-kind charts show volumes of tokens, never what anyone asked or was answered. ## Common questions - *"It says Degraded — should I worry?"* — it means the model checks are not all passing. Individual chats may still work on a fallback model; if it persists, the admin checks the provider (its key, its quota) on the Models/Providers pages. -- *"Why are the bars so high at odd hours?"* — scheduled background work (system agents, cron tasks) uses the same models. The Tasks page and the system-agents page show what ran when. +- *"Why are the bars so high at odd hours?"* — scheduled background work (system agents, cron tasks) uses the same models. The **By kind** chart shows exactly how much of the spend is background work versus direct chats; the Tasks page and the system-agents page show what ran when. - *"What is a token?"* — the unit LLM providers meter and bill by, roughly a word fragment. Input is what was sent (long history = more input; caching repeats cheaply), output is what was written back. - *"Why doesn't my child see this page?"* — their role uses the simple interface: chat, inbox and projects only. - *"Does the dashboard show what people asked?"* — no. Only counts, timings and model names; never content. diff --git a/docs/index.md b/docs/index.md index 095efcb..e8adf5b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ This index will grow over time. Right now it covers the chat window, the inbox, | --- | --- | | [chat.md](chat.md) | The chat window: full-page vs docked, the tab bar and what lands where, the composer's controls, the slash commands, and what happens while an answer is being written | | [inbox.md](inbox.md) | The Inbox: the three kinds of pending request, why background work asks here rather than in the chat, answering one (and the time-limited approvals), and why an unanswered card means a stopped job | -| [dashboard.md](dashboard.md) | The Dashboard: the instance status line, the LLM usage charts (everyone's together, counts never content), the pending-inbox section, and what the no-models banner means | +| [dashboard.md](dashboard.md) | The Dashboard: the instance status line, the LLM usage charts (filterable per member, with spend broken down by member, kind, agent, model and provider — counts never content), the pending-inbox section, and what the no-models banner means | | [security-groups.md](security-groups.md) | Security groups: allow / ask / deny per tool, the shield in the chat, what the default group already permits, and how an admin edits the rules | | [memory.md](memory.md) | Private and shared memory: what goes where, the indexes and history log, why some shared facts can't be changed on request | | [agents.md](agents.md) | Agents: the three kinds (chat, task, system), which one you are talking to and why, the specialist agents the assistant delegates to, how the model is chosen, and adding a custom agent | diff --git a/src/frontend/api/stats.rs b/src/frontend/api/stats.rs index 373c223..3ba1960 100644 --- a/src/frontend/api/stats.rs +++ b/src/frontend/api/stats.rs @@ -22,6 +22,9 @@ pub enum StatsRange { #[derive(Deserialize)] pub struct StatsQuery { pub range: Option, + /// Scope filter: a `user_id` restricts every series and breakdown to that + /// member; absent = the whole instance. + pub user: Option, } #[derive(Serialize)] @@ -34,18 +37,40 @@ pub struct DailyStats { pub avg_duration_ms: f64, } +/// One row of a "how the spend splits" chart (by user, kind, agent, model, +/// provider). Bars are drawn on `total_tokens` — tokens are what the provider +/// bills — with the rest in the tooltip. #[derive(Serialize)] -pub struct ModelStats { - pub model_name: String, - pub requests: i64, +pub struct BreakdownRow { + pub key: String, + pub requests: i64, + pub input_tokens: i64, + pub output_tokens: i64, + pub cache_read_tokens: i64, + pub total_tokens: i64, +} + +/// A selectable member for the scope chips (id + display label only). +#[derive(Serialize)] +pub struct MemberEntry { + pub id: String, + pub label: String, } #[derive(Serialize)] pub struct LlmStatsResponse { - pub daily: Vec, - pub models: Vec, + pub daily: Vec, + pub members: Vec, + pub by_user: Vec, + pub by_kind: Vec, + pub by_agent: Vec, + pub by_model: Vec, + pub by_provider: Vec, } +// Every query shares the same two filters: the time window and the optional +// user scope (`? IS NULL OR user_id = ?`). + const SQL_DAILY_HOUR: &str = "SELECT strftime('%H:%M', created_at, 'localtime') AS day, COUNT(*) AS requests, @@ -55,6 +80,7 @@ const SQL_DAILY_HOUR: &str = AVG(duration_ms) AS avg_duration_ms FROM llm_requests WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) GROUP BY strftime('%H:%M', created_at, 'localtime') ORDER BY day ASC"; @@ -67,6 +93,7 @@ const SQL_DAILY_HOUR_BUCKET: &str = AVG(duration_ms) AS avg_duration_ms FROM llm_requests WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) GROUP BY strftime('%m-%d %H:00', created_at, 'localtime') ORDER BY day ASC"; @@ -79,22 +106,121 @@ const SQL_DAILY_DATE: &str = AVG(duration_ms) AS avg_duration_ms FROM llm_requests WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) GROUP BY DATE(created_at, 'localtime') ORDER BY day ASC"; -const SQL_MODELS: &str = - "SELECT model_name, COUNT(*) AS requests +// The breakdown projections. The SELECT tail is identical for all of them — +// counts + token sums, grouped and ordered by billed volume. + +const SQL_BY_USER: &str = + "SELECT COALESCE(user_id, '') AS key, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(input_tokens), 0) + + COALESCE(SUM(output_tokens), 0) AS total_tokens FROM llm_requests WHERE created_at >= datetime('now', ?) - GROUP BY model_name - ORDER BY requests DESC - LIMIT 6"; + AND (? IS NULL OR user_id = ?) + GROUP BY user_id ORDER BY total_tokens DESC"; + +// "What consumed it": a child frame (depth > 0) is sub-agent work regardless +// of the session's source; otherwise the source itself (web / mobile / +// telegram / cron / a system agent's name). NULL = rows predating the column. +const SQL_BY_KIND: &str = + "SELECT CASE WHEN depth > 0 THEN 'sub-agent' + WHEN source IS NULL OR source = '' THEN 'unknown' + ELSE source END AS key, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(input_tokens), 0) + + COALESCE(SUM(output_tokens), 0) AS total_tokens + FROM llm_requests + WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) + GROUP BY key ORDER BY total_tokens DESC"; + +const SQL_BY_AGENT: &str = + "SELECT COALESCE(NULLIF(agent_id, ''), 'unknown') AS key, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(input_tokens), 0) + + COALESCE(SUM(output_tokens), 0) AS total_tokens + FROM llm_requests + WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) + GROUP BY agent_id ORDER BY total_tokens DESC LIMIT 10"; + +const SQL_BY_MODEL: &str = + "SELECT model_name AS key, + COUNT(*) AS requests, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(input_tokens), 0) + + COALESCE(SUM(output_tokens), 0) AS total_tokens + FROM llm_requests + WHERE created_at >= datetime('now', ?) + AND (? IS NULL OR user_id = ?) + GROUP BY model_name ORDER BY total_tokens DESC LIMIT 10"; + +// Provider is resolved through the model row. No `removed_at` filter on +// purpose: models are soft-deleted precisely so telemetry keeps resolving. +const SQL_BY_PROVIDER: &str = + "SELECT COALESCE(p.name, 'other') AS key, + COUNT(*) AS requests, + COALESCE(SUM(r.input_tokens), 0) AS input_tokens, + COALESCE(SUM(r.output_tokens), 0) AS output_tokens, + COALESCE(SUM(r.cache_read_tokens), 0) AS cache_read_tokens, + COALESCE(SUM(r.input_tokens), 0) + + COALESCE(SUM(r.output_tokens), 0) AS total_tokens + FROM llm_requests r + LEFT JOIN llm_models m ON m.name = r.model_name + LEFT JOIN llm_providers p ON p.id = m.provider_id + WHERE r.created_at >= datetime('now', ?) + AND (? IS NULL OR r.user_id = ?) + GROUP BY p.name ORDER BY total_tokens DESC"; + +const SQL_MEMBERS: &str = + "SELECT id, COALESCE(NULLIF(display_name, ''), username) AS label + FROM users + WHERE active = 1 + ORDER BY label COLLATE NOCASE ASC"; + +type DailyRow = (String, i64, i64, i64, i64, f64); +type BreakdownSql = (String, i64, i64, i64, i64, i64); + +async fn run_breakdown( + pool: &sqlx::SqlitePool, + sql: &'static str, + window: &str, + user: Option<&str>, +) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, BreakdownSql>(sql) + .bind(window) + .bind(user).bind(user) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(key, requests, input_tokens, output_tokens, cache_read_tokens, total_tokens)| { + BreakdownRow { key, requests, input_tokens, output_tokens, cache_read_tokens, total_tokens } + }) + .collect()) +} pub async fn llm_stats( State(skald): State>, Query(params): Query, ) -> Result { let range = params.range.unwrap_or_default(); + let user = params.user.filter(|u| !u.is_empty()); let (window, daily_sql) = match range { StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR), @@ -103,9 +229,12 @@ pub async fn llm_stats( StatsRange::Month => ("-30 days", SQL_DAILY_DATE), }; - let daily = sqlx::query_as::<_, (String, i64, i64, i64, i64, f64)>(daily_sql) + let db = &**skald.db(); + + let daily = sqlx::query_as::<_, DailyRow>(daily_sql) .bind(window) - .fetch_all(&**skald.db()) + .bind(user.as_deref()).bind(user.as_deref()) + .fetch_all(db) .await? .into_iter() .map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| { @@ -113,13 +242,20 @@ pub async fn llm_stats( }) .collect::>(); - let models = sqlx::query_as::<_, (String, i64)>(SQL_MODELS) - .bind(window) - .fetch_all(&**skald.db()) + let members = sqlx::query_as::<_, (String, String)>(SQL_MEMBERS) + .fetch_all(db) .await? .into_iter() - .map(|(model_name, requests)| ModelStats { model_name, requests }) + .map(|(id, label)| MemberEntry { id, label }) .collect::>(); - Ok(Json(LlmStatsResponse { daily, models })) + let (by_user, by_kind, by_agent, by_model, by_provider) = tokio::try_join!( + run_breakdown(db, SQL_BY_USER, window, user.as_deref()), + run_breakdown(db, SQL_BY_KIND, window, user.as_deref()), + run_breakdown(db, SQL_BY_AGENT, window, user.as_deref()), + run_breakdown(db, SQL_BY_MODEL, window, user.as_deref()), + run_breakdown(db, SQL_BY_PROVIDER, window, user.as_deref()), + )?; + + Ok(Json(LlmStatsResponse { daily, members, by_user, by_kind, by_agent, by_model, by_provider })) } diff --git a/web/components/dashboard-page.js b/web/components/dashboard-page.js index 25f6f14..e6305b9 100644 --- a/web/components/dashboard-page.js +++ b/web/components/dashboard-page.js @@ -3,6 +3,38 @@ import { LightElement } from '../lib/base.js'; import { t } from '../lib/i18n.js'; import { InboxMixin } from '../lib/inbox-mixin.js'; +// Shared palette for the breakdown bars (cycled when there are more rows). +const PALETTE = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4']; + +// The `kind` values that have a proper label (the server sends the session's +// source, or 'sub-agent' for child frames); anything else renders as-is, so a +// new source needs no frontend change to appear. +const KIND_LABELS = { + 'web': 'dashboard.stats.kind.web', + 'mobile': 'dashboard.stats.kind.mobile', + 'telegram': 'dashboard.stats.kind.telegram', + 'cron': 'dashboard.stats.kind.cron', + 'sub-agent': 'dashboard.stats.kind.sub_agent', + 'event-triage': 'dashboard.stats.kind.event_triage', + 'memory-lint': 'dashboard.stats.kind.memory_lint', + 'conversation-review': 'dashboard.stats.kind.conversation_review', + 'unknown': 'dashboard.stats.kind.unknown', +}; + +function fmtTok(n) { + if (n == null) return '0'; + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k'; + return String(n); +} + +function kindLabel(key) { + const i18nKey = KIND_LABELS[key]; + if (!i18nKey) return key; + const label = t(i18nKey); + return label === i18nKey ? key : label; +} + export class DashboardPage extends InboxMixin(LightElement) { static get properties() { @@ -13,6 +45,7 @@ export class DashboardPage extends InboxMixin(LightElement) { _plugins: { state: true }, _stats: { state: true }, _statsRange: { state: true }, + _statsUser: { state: true }, }; } @@ -24,6 +57,9 @@ export class DashboardPage extends InboxMixin(LightElement) { this._pollTimer = null; this._stats = null; // null = loading this._statsRange = 'week'; + this._statsUser = ''; // '' = whole instance + this._members = []; // cached out of the stats response: the chips + // must survive a reload that filters them away this._chartInstances = {}; this._statsTimer = null; } @@ -100,11 +136,16 @@ export class DashboardPage extends InboxMixin(LightElement) { async _loadStats() { try { - const res = await fetch(`/api/stats/llm?range=${this._statsRange}`); + const params = new URLSearchParams({ range: this._statsRange }); + if (this._statsUser) params.set('user', this._statsUser); + const res = await fetch(`/api/stats/llm?${params}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._stats = await res.json(); + if (Array.isArray(this._stats.members) && this._stats.members.length) { + this._members = this._stats.members; + } } catch { - this._stats = { daily: [], models: [] }; + this._stats = { daily: [], by_user: [], by_kind: [], by_agent: [], by_model: [], by_provider: [] }; } } @@ -115,6 +156,13 @@ export class DashboardPage extends InboxMixin(LightElement) { await this._loadStats(); } + async _setUser(user) { + if (user === this._statsUser) return; + this._statsUser = user; + this._stats = null; + await this._loadStats(); + } + get _honchoActive() { return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false; } @@ -161,6 +209,39 @@ export class DashboardPage extends InboxMixin(LightElement) { .replace(/-\d{8}$/, ''); } + _memberLabel(key) { + if (!key) return t('dashboard.stats.user_unknown'); + const m = this._members.find(m => m.id === key); + return m ? m.label : key; + } + + // The breakdown cards to show for the current data, in display order. Both + // the render and the chart init consume this, so they can never disagree + // about which canvases exist. The per-member card is pointless — and empty + // by construction — when the scope is already a single member. + get _breakdownSpecs() { + const s = this._stats; + if (!s) return []; + return [ + !this._statsUser && { id: 'chart-by-user', title: t('dashboard.stats.by_user'), rows: s.by_user ?? [] }, + { id: 'chart-by-kind', title: t('dashboard.stats.by_kind'), rows: s.by_kind ?? [] }, + { id: 'chart-by-agent', title: t('dashboard.stats.by_agent'), rows: s.by_agent ?? [] }, + { id: 'chart-by-model', title: t('dashboard.stats.by_model'), rows: s.by_model ?? [] }, + { id: 'chart-by-provider', title: t('dashboard.stats.by_provider'), rows: s.by_provider ?? [] }, + ] + .filter(Boolean) + .filter(c => c.rows.length > 0) + .map(c => ({ + ...c, + labelFn: c.id === 'chart-by-user' ? k => this._memberLabel(k) + : c.id === 'chart-by-kind' ? k => kindLabel(k) + : c.id === 'chart-by-model' ? k => this._shortModelName(k) + : k => k, + // The axis may carry a shortened label; the tooltip shows the key itself. + titleFn: c.id === 'chart-by-model' ? k => k : null, + })); + } + get _periodLabel() { return { hour: t('dashboard.stats.per_min'), day: t('dashboard.stats.per_hour'), week: t('dashboard.stats.per_day'), month: t('dashboard.stats.per_day') }[this._statsRange] ?? t('dashboard.stats.per_day'); } @@ -212,7 +293,6 @@ export class DashboardPage extends InboxMixin(LightElement) { const cache = filled.map(d => d.cache_read_tokens); // null for empty slots so the latency line doesn't touch zero where there were no requests const lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null); - const models = this._stats.models; const axisDefaults = () => ({ ticks: { color: textColor, font: { size: 11 } }, @@ -322,38 +402,76 @@ export class DashboardPage extends InboxMixin(LightElement) { options: baseOpts(), }); - // Models — always horizontal bar - const c4 = get('chart-models'); - if (c4) this._chartInstances.models = new Chart(c4, { - type: 'bar', - data: { - labels: models.map(m => this._shortModelName(m.model_name)), - datasets: [{ - data: models.map(m => m.requests), - backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'], - borderRadius: 4, - borderSkipped: false, - }], - }, - options: { - ...baseOpts(), - indexAxis: 'y', - scales: { - x: { ...axisDefaults(), beginAtZero: true }, - y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } }, + // Breakdowns — "how the spend splits": horizontal bars on billed tokens, + // requests and the input/output/cache split in the tooltip. + for (const spec of this._breakdownSpecs) { + const c = get(spec.id); + if (!c) continue; + this._chartInstances[spec.id] = new Chart(c, { + type: 'bar', + data: { + labels: spec.rows.map(r => spec.labelFn(r.key)), + datasets: [{ + data: spec.rows.map(r => r.total_tokens), + backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]), + borderRadius: 4, + borderSkipped: false, + }], }, - }, - }); + options: { + ...baseOpts({ + tooltip: { + callbacks: { + title: items => { + const r = spec.rows[items[0]?.dataIndex]; + return r ? (spec.titleFn ?? spec.labelFn)(r.key) : ''; + }, + label: item => { + const r = spec.rows[item.dataIndex]; + if (!r) return ''; + return [ + `${fmtTok(r.total_tokens)} tokens · ${r.requests} ${t('dashboard.stats.tip.requests')}`, + `${t('dashboard.stats.chart.input')}: ${fmtTok(r.input_tokens)} · ${t('dashboard.stats.chart.output')}: ${fmtTok(r.output_tokens)} · ${t('dashboard.stats.chart.cached')}: ${fmtTok(r.cache_read_tokens)}`, + ]; + }, + }, + }, + }), + indexAxis: 'y', + scales: { + x: { ...axisDefaults(), beginAtZero: true, + ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } }, + y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } }, + }, + }, + }); + } } // ── Render ──────────────────────────────────────────────────────────────── + _renderScopeChips() { + if (this._members.length < 2) return nothing; + return html` +
+ + ${this._members.map(m => html` + + `)} +
+ `; + } + _renderStats() { if (this._stats === null) { return html`
${t('dashboard.stats.loading')}
`; } - const empty = this._stats.daily.length === 0 && this._stats.models.length === 0; + const empty = this._stats.daily.length === 0 + && !(this._stats.by_user ?? []).length + && !(this._stats.by_model ?? []).length; if (empty) { return html`
@@ -363,6 +481,8 @@ export class DashboardPage extends InboxMixin(LightElement) { `; } + const breakdown = this._breakdownSpecs; + return html`
@@ -377,11 +497,20 @@ export class DashboardPage extends InboxMixin(LightElement) {
${t('dashboard.stats.latency')}
-
-
${t('dashboard.stats.models')}
-
-
+ ${breakdown.length ? html` +
${t('dashboard.stats.sub.breakdown')}
+
+ ${breakdown.map(c => html` +
+
${c.title}
+
+ +
+
+ `)} +
+ ` : nothing} `; } @@ -437,6 +566,7 @@ export class DashboardPage extends InboxMixin(LightElement) { `)}
+ ${this._renderScopeChips()} ${this._renderStats()} diff --git a/web/css/home.css b/web/css/home.css index e3ab5bb..b7694b4 100644 --- a/web/css/home.css +++ b/web/css/home.css @@ -312,6 +312,24 @@ dashboard-page { color: #fff; } +/* Scope chips (member filter) — same pill look as the range buttons. */ +.home-stats-scope { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: -0.25rem 0 1rem; +} + +/* Subsection label above the breakdown grid. */ +.home-stats-sub { + font-size: 0.78rem; + font-weight: 600; + color: var(--bs-secondary-color); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 0.25rem 0 0.75rem; +} + .home-stats-loading, .home-stats-empty { display: flex; @@ -325,7 +343,7 @@ dashboard-page { .home-stats-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; margin-bottom: 2rem; } diff --git a/web/i18n/en.js b/web/i18n/en.js index 837e4bc..935039b 100644 --- a/web/i18n/en.js +++ b/web/i18n/en.js @@ -419,7 +419,6 @@ export default { 'dashboard.stats.requests': 'Requests {per}', 'dashboard.stats.tokens': 'Tokens {per}', 'dashboard.stats.latency': 'Avg latency (ms)', - 'dashboard.stats.models': 'Models', 'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_hour': '/ hour', 'dashboard.stats.per_day': '/ day', @@ -435,6 +434,26 @@ export default { 'dashboard.stats.chart.non_cached': 'Non-cached', 'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', + 'dashboard.stats.scope.all': 'Everyone', + 'dashboard.stats.sub.breakdown': 'How the spend splits in the selected range', + 'dashboard.stats.by_user': 'By member', + 'dashboard.stats.by_kind': 'By kind', + 'dashboard.stats.by_agent': 'By agent', + 'dashboard.stats.by_model': 'By model', + 'dashboard.stats.by_provider': 'By provider', + 'dashboard.stats.user_unknown': 'Unknown', + 'dashboard.stats.tip.requests': 'requests', + + 'dashboard.stats.kind.web': 'Chat', + 'dashboard.stats.kind.mobile': 'Mobile', + 'dashboard.stats.kind.telegram': 'Telegram', + 'dashboard.stats.kind.cron': 'Scheduled tasks', + 'dashboard.stats.kind.sub_agent': 'Sub-agents', + 'dashboard.stats.kind.event_triage': 'Event triage', + 'dashboard.stats.kind.memory_lint': 'Memory lint', + 'dashboard.stats.kind.conversation_review': 'Conversation review', + 'dashboard.stats.kind.unknown': 'Older data', + 'dashboard.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.', 'dashboard.banner.no_models.title': 'No LLM models configured.', diff --git a/web/i18n/fr.js b/web/i18n/fr.js index 5f93ebd..998a5c2 100644 --- a/web/i18n/fr.js +++ b/web/i18n/fr.js @@ -416,7 +416,6 @@ export default { 'dashboard.stats.requests': 'Requêtes {per}', 'dashboard.stats.tokens': 'Tokens {per}', 'dashboard.stats.latency': 'Latence moyenne (ms)', - 'dashboard.stats.models': 'Modèles', 'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_hour': '/ heure', 'dashboard.stats.per_day': '/ jour', @@ -432,6 +431,26 @@ export default { 'dashboard.stats.chart.non_cached': 'Non en cache', 'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%', + 'dashboard.stats.scope.all': 'Tous', + 'dashboard.stats.sub.breakdown': 'Répartition des dépenses sur la période sélectionnée', + 'dashboard.stats.by_user': 'Par membre', + 'dashboard.stats.by_kind': 'Par type', + 'dashboard.stats.by_agent': 'Par agent', + 'dashboard.stats.by_model': 'Par modèle', + 'dashboard.stats.by_provider': 'Par fournisseur', + 'dashboard.stats.user_unknown': 'Inconnu', + 'dashboard.stats.tip.requests': 'requêtes', + + 'dashboard.stats.kind.web': 'Chat', + 'dashboard.stats.kind.mobile': 'Mobile', + 'dashboard.stats.kind.telegram': 'Telegram', + 'dashboard.stats.kind.cron': 'Tâches planifiées', + 'dashboard.stats.kind.sub_agent': 'Sous-agents', + 'dashboard.stats.kind.event_triage': 'Triage des événements', + 'dashboard.stats.kind.memory_lint': 'Lint mémoire', + 'dashboard.stats.kind.conversation_review': 'Revue des conversations', + 'dashboard.stats.kind.unknown': 'Données anciennes', + 'dashboard.hero.subtitle': 'Votre centre de commande IA — recherche, code, planification et orchestration. Tout en un seul endroit.', 'dashboard.banner.no_models.title': 'Aucun modèle LLM configuré.', diff --git a/web/i18n/it.js b/web/i18n/it.js index 1f1fe46..f3a0b47 100644 --- a/web/i18n/it.js +++ b/web/i18n/it.js @@ -416,7 +416,6 @@ export default { 'dashboard.stats.requests': 'Richieste {per}', 'dashboard.stats.tokens': 'Token {per}', 'dashboard.stats.latency': 'Latenza media (ms)', - 'dashboard.stats.models': 'Modelli', 'dashboard.stats.per_min': '/ min', 'dashboard.stats.per_hour': '/ h', 'dashboard.stats.per_day': '/ giorno', @@ -432,6 +431,26 @@ export default { 'dashboard.stats.chart.non_cached': 'Non in cache', 'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%', + 'dashboard.stats.scope.all': 'Tutti', + 'dashboard.stats.sub.breakdown': 'Come si distribuisce la spesa nel periodo selezionato', + 'dashboard.stats.by_user': 'Per membro', + 'dashboard.stats.by_kind': 'Per tipo', + 'dashboard.stats.by_agent': 'Per agente', + 'dashboard.stats.by_model': 'Per modello', + 'dashboard.stats.by_provider': 'Per provider', + 'dashboard.stats.user_unknown': 'Sconosciuto', + 'dashboard.stats.tip.requests': 'richieste', + + 'dashboard.stats.kind.web': 'Chat', + 'dashboard.stats.kind.mobile': 'Mobile', + 'dashboard.stats.kind.telegram': 'Telegram', + 'dashboard.stats.kind.cron': 'Attività pianificate', + 'dashboard.stats.kind.sub_agent': 'Sub-agent', + 'dashboard.stats.kind.event_triage': 'Triage eventi', + 'dashboard.stats.kind.memory_lint': 'Lint memoria', + 'dashboard.stats.kind.conversation_review': 'Revisione conversazioni', + 'dashboard.stats.kind.unknown': 'Dati precedenti', + 'dashboard.hero.subtitle': 'Il tuo centro di comando AI — ricerca, codice, pianificazione e orchestrazione. Tutto in un unico posto.', 'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.',