feat(dashboard): LLM stats answer who/what spends tokens — member scope chips and spend breakdowns
Nightly Build / build (push) Successful in 4m27s
Nightly Build / build (push) Successful in 4m27s
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'.
This commit is contained in:
@@ -10,6 +10,13 @@ release PR may merge — and a section is closed at the commit that bumps it.
|
|||||||
|
|
||||||
### Added
|
### 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`):
|
- 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
|
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
|
document, live-reloading when the file changes, exactly like a compiled `.tex`. A
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
//! of the traffic is known — `user_id` is what the UI filters on).
|
//! of the traffic is known — `user_id` is what the UI filters on).
|
||||||
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
|
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
|
||||||
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
|
//! 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 anyhow::Result;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
@@ -31,6 +32,13 @@ pub struct LlmRequestRow {
|
|||||||
pub cache_read_tokens: Option<i64>,
|
pub cache_read_tokens: Option<i64>,
|
||||||
/// Tokens written into the provider's prompt cache (Anthropic only).
|
/// Tokens written into the provider's prompt cache (Anthropic only).
|
||||||
pub cache_creation_tokens: Option<i64>,
|
pub cache_creation_tokens: Option<i64>,
|
||||||
|
/// 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<String>,
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub depth: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Writes ────────────────────────────────────────────────────────────────────
|
// ── Writes ────────────────────────────────────────────────────────────────────
|
||||||
@@ -40,8 +48,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
|
|||||||
"INSERT INTO llm_requests (
|
"INSERT INTO llm_requests (
|
||||||
request_id, user_id, session_id, stack_id, model_name,
|
request_id, user_id, session_id, stack_id, model_name,
|
||||||
error_text, input_tokens, output_tokens, duration_ms,
|
error_text, input_tokens, output_tokens, duration_ms,
|
||||||
cache_read_tokens, cache_creation_tokens
|
cache_read_tokens, cache_creation_tokens,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
source, agent_id, depth
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.bind(&row.request_id)
|
.bind(&row.request_id)
|
||||||
@@ -55,6 +64,9 @@ pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
|
|||||||
.bind(row.duration_ms)
|
.bind(row.duration_ms)
|
||||||
.bind(row.cache_read_tokens)
|
.bind(row.cache_read_tokens)
|
||||||
.bind(row.cache_creation_tokens)
|
.bind(row.cache_creation_tokens)
|
||||||
|
.bind(&row.source)
|
||||||
|
.bind(&row.agent_id)
|
||||||
|
.bind(row.depth)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,16 @@ pub(crate) async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.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
|
// User directory + auth material. Read before every login, so it lives in
|
||||||
// the registry — which means it must never hold anything that derives a
|
// 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
|
// user's key: `database_password` is the DEK sealed under a key derived
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
//!
|
//!
|
||||||
//! * a **metadata-only** row in `llm_requests` (`system.db`) — cost, tokens,
|
//! * a **metadata-only** row in `llm_requests` (`system.db`) — cost, tokens,
|
||||||
//! timing, plus the correlation the UI filters on (`user_id`, `session_id`,
|
//! 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
|
//! * the **payload** (request/response bodies + headers) in
|
||||||
//! `llm_request_payloads` in the caller's own database, keyed by the same
|
//! `llm_request_payloads` in the caller's own database, keyed by the same
|
||||||
//! `request_id`.
|
//! `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<i64>,
|
||||||
|
) -> (Option<String>, Option<String>, Option<i64>) {
|
||||||
|
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<SqlitePool>,
|
||||||
|
owner: Option<Arc<SqlitePool>>,
|
||||||
|
request_id: Option<String>,
|
||||||
|
user_id: Option<String>,
|
||||||
|
session_id: Option<i64>,
|
||||||
|
stack_id: Option<i64>,
|
||||||
|
model_name: String,
|
||||||
|
error_text: Option<String>,
|
||||||
|
duration_ms: i64,
|
||||||
|
// (input, output, cache_read, cache_creation) — all None on HTTP failure.
|
||||||
|
usage: (Option<i64>, Option<i64>, Option<i64>, Option<i64>),
|
||||||
|
) {
|
||||||
|
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]
|
#[async_trait]
|
||||||
impl Model for LoggingModel {
|
impl Model for LoggingModel {
|
||||||
async fn complete(
|
async fn complete(
|
||||||
@@ -108,11 +193,12 @@ impl Model for LoggingModel {
|
|||||||
let request_id = Some(req.request_id.clone());
|
let request_id = Some(req.request_id.clone());
|
||||||
let model_name = self.model_name.clone();
|
let model_name = self.model_name.clone();
|
||||||
let pool = Arc::clone(&self.registry);
|
let pool = Arc::clone(&self.registry);
|
||||||
|
let owner = self.target.payloads.clone();
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let usage = resp.usage();
|
let usage = resp.usage();
|
||||||
let (input_tokens, output_tokens, cache_read, cache_write) = (
|
let usage = (
|
||||||
usage.input_tokens.map(|n| n as i64),
|
usage.input_tokens.map(|n| n as i64),
|
||||||
usage.output_tokens.map(|n| n as i64),
|
usage.output_tokens.map(|n| n as i64),
|
||||||
usage.cache_read.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() {
|
if let Some(raw) = resp.raw() {
|
||||||
self.spawn_payload(&req.request_id, raw);
|
self.spawn_payload(&req.request_id, raw);
|
||||||
}
|
}
|
||||||
tokio::spawn(async move {
|
spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
|
||||||
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
model_name, None, duration_ms, usage);
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Only an HTTP failure carries a body (a provider 400 is exactly
|
// 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() {
|
if let Some(raw) = e.raw.as_ref() {
|
||||||
self.spawn_payload(&req.request_id, raw);
|
self.spawn_payload(&req.request_id, raw);
|
||||||
}
|
}
|
||||||
let error_text = e.to_string();
|
spawn_insert(pool, owner, request_id, user_id, session_id, stack_id,
|
||||||
tokio::spawn(async move {
|
model_name, Some(e.to_string()), duration_ms,
|
||||||
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
|
(None, None, None, None));
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +296,14 @@ mod tests {
|
|||||||
let path = temp_db_path("llmlog-ok");
|
let path = temp_db_path("llmlog-ok");
|
||||||
let pool = Arc::new(crate::db::init_system_pool(&path).await.unwrap());
|
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");
|
let mut resp = ModelResponse::message("hi");
|
||||||
*resp.usage_mut() = Usage {
|
*resp.usage_mut() = Usage {
|
||||||
input_tokens: Some(11),
|
input_tokens: Some(11),
|
||||||
@@ -270,6 +334,13 @@ mod tests {
|
|||||||
assert_eq!(model_name, "gpt-test");
|
assert_eq!(model_name, "gpt-test");
|
||||||
assert_eq!((input, output), (Some(11), Some(7)));
|
assert_eq!((input, output), (Some(11), Some(7)));
|
||||||
|
|
||||||
|
let (source, agent_id, depth): (Option<String>, Option<String>, Option<i64>) =
|
||||||
|
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;
|
wait_for(&pool, "SELECT COUNT(*) FROM llm_request_payloads").await;
|
||||||
let body: String = sqlx::query_scalar(
|
let body: String = sqlx::query_scalar(
|
||||||
"SELECT request_json FROM llm_request_payloads WHERE request_id = 'req-1'")
|
"SELECT request_json FROM llm_request_payloads WHERE request_id = 'req-1'")
|
||||||
|
|||||||
@@ -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 |
|
| `copilot-render.js` | (helpers) | `renderMsg`, `renderTool`, `renderDiff`, etc. — shared by copilot and chat-page |
|
||||||
| `sidebar.js` | `<app-sidebar>` | 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 |
|
| `sidebar.js` | `<app-sidebar>` | 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` | `<app-topbar>` | Top nav bar; per-user avatar color hashed from the username |
|
| `topbar.js` | `<app-topbar>` | Top nav bar; per-user avatar color hashed from the username |
|
||||||
| `dashboard-page.js` | `<dashboard-page>` | `#dashboard` — status hero, LLM stats charts, pending inbox, quick guide |
|
| `dashboard-page.js` | `<dashboard-page>` | `#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 |
|
| `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` | `<file-viewer-page>` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` |
|
| `file-viewer-page.js` | `<file-viewer-page>` | Desktop file viewer: `FileViewerBase` + hash routing via `window.openFile(path)` → `#file_viewer?path=...` |
|
||||||
| `shared/file-viewer-mobile.js` | `<mobile-file-viewer-page>` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button |
|
| `shared/file-viewer-mobile.js` | `<mobile-file-viewer-page>` | Mobile file viewer: `FileViewerBase` + prop-driven (`visible`/`path`), full-screen with back button |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## The client layer (`crates/skald-core/src/llm/`)
|
## 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
|
## `providers.yaml` — two traps in the model metadata
|
||||||
|
|
||||||
|
|||||||
+16
-6
@@ -15,17 +15,27 @@ The status reflects the **whole instance**, not one person's account: there is o
|
|||||||
|
|
||||||
## LLM usage stats
|
## 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.
|
- **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.
|
- **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.
|
- **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:
|
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, 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.
|
||||||
- **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.
|
- **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.
|
- **Empty is normal on a new instance.** "No LLM requests in the selected range" means exactly that: nothing has run in that window.
|
||||||
|
|
||||||
## Pending
|
## 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 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 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
|
## 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.
|
- *"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.
|
- *"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.
|
- *"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.
|
- *"Does the dashboard show what people asked?"* — no. Only counts, timings and model names; never content.
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
||||||
|
|||||||
+151
-15
@@ -22,6 +22,9 @@ pub enum StatsRange {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct StatsQuery {
|
pub struct StatsQuery {
|
||||||
pub range: Option<StatsRange>,
|
pub range: Option<StatsRange>,
|
||||||
|
/// Scope filter: a `user_id` restricts every series and breakdown to that
|
||||||
|
/// member; absent = the whole instance.
|
||||||
|
pub user: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -34,18 +37,40 @@ pub struct DailyStats {
|
|||||||
pub avg_duration_ms: f64,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct ModelStats {
|
pub struct BreakdownRow {
|
||||||
pub model_name: String,
|
pub key: String,
|
||||||
pub requests: i64,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct LlmStatsResponse {
|
pub struct LlmStatsResponse {
|
||||||
pub daily: Vec<DailyStats>,
|
pub daily: Vec<DailyStats>,
|
||||||
pub models: Vec<ModelStats>,
|
pub members: Vec<MemberEntry>,
|
||||||
|
pub by_user: Vec<BreakdownRow>,
|
||||||
|
pub by_kind: Vec<BreakdownRow>,
|
||||||
|
pub by_agent: Vec<BreakdownRow>,
|
||||||
|
pub by_model: Vec<BreakdownRow>,
|
||||||
|
pub by_provider: Vec<BreakdownRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 =
|
const SQL_DAILY_HOUR: &str =
|
||||||
"SELECT strftime('%H:%M', created_at, 'localtime') AS day,
|
"SELECT strftime('%H:%M', created_at, 'localtime') AS day,
|
||||||
COUNT(*) AS requests,
|
COUNT(*) AS requests,
|
||||||
@@ -55,6 +80,7 @@ const SQL_DAILY_HOUR: &str =
|
|||||||
AVG(duration_ms) AS avg_duration_ms
|
AVG(duration_ms) AS avg_duration_ms
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
WHERE created_at >= datetime('now', ?)
|
WHERE created_at >= datetime('now', ?)
|
||||||
|
AND (? IS NULL OR user_id = ?)
|
||||||
GROUP BY strftime('%H:%M', created_at, 'localtime')
|
GROUP BY strftime('%H:%M', created_at, 'localtime')
|
||||||
ORDER BY day ASC";
|
ORDER BY day ASC";
|
||||||
|
|
||||||
@@ -67,6 +93,7 @@ const SQL_DAILY_HOUR_BUCKET: &str =
|
|||||||
AVG(duration_ms) AS avg_duration_ms
|
AVG(duration_ms) AS avg_duration_ms
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
WHERE created_at >= datetime('now', ?)
|
WHERE created_at >= datetime('now', ?)
|
||||||
|
AND (? IS NULL OR user_id = ?)
|
||||||
GROUP BY strftime('%m-%d %H:00', created_at, 'localtime')
|
GROUP BY strftime('%m-%d %H:00', created_at, 'localtime')
|
||||||
ORDER BY day ASC";
|
ORDER BY day ASC";
|
||||||
|
|
||||||
@@ -79,22 +106,121 @@ const SQL_DAILY_DATE: &str =
|
|||||||
AVG(duration_ms) AS avg_duration_ms
|
AVG(duration_ms) AS avg_duration_ms
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
WHERE created_at >= datetime('now', ?)
|
WHERE created_at >= datetime('now', ?)
|
||||||
|
AND (? IS NULL OR user_id = ?)
|
||||||
GROUP BY DATE(created_at, 'localtime')
|
GROUP BY DATE(created_at, 'localtime')
|
||||||
ORDER BY day ASC";
|
ORDER BY day ASC";
|
||||||
|
|
||||||
const SQL_MODELS: &str =
|
// The breakdown projections. The SELECT tail is identical for all of them —
|
||||||
"SELECT model_name, COUNT(*) AS requests
|
// 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
|
FROM llm_requests
|
||||||
WHERE created_at >= datetime('now', ?)
|
WHERE created_at >= datetime('now', ?)
|
||||||
GROUP BY model_name
|
AND (? IS NULL OR user_id = ?)
|
||||||
ORDER BY requests DESC
|
GROUP BY user_id ORDER BY total_tokens DESC";
|
||||||
LIMIT 6";
|
|
||||||
|
// "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<Vec<BreakdownRow>, 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(
|
pub async fn llm_stats(
|
||||||
State(skald): State<Arc<Skald>>,
|
State(skald): State<Arc<Skald>>,
|
||||||
Query(params): Query<StatsQuery>,
|
Query(params): Query<StatsQuery>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let range = params.range.unwrap_or_default();
|
let range = params.range.unwrap_or_default();
|
||||||
|
let user = params.user.filter(|u| !u.is_empty());
|
||||||
|
|
||||||
let (window, daily_sql) = match range {
|
let (window, daily_sql) = match range {
|
||||||
StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR),
|
StatsRange::Hour => ("-60 minutes", SQL_DAILY_HOUR),
|
||||||
@@ -103,9 +229,12 @@ pub async fn llm_stats(
|
|||||||
StatsRange::Month => ("-30 days", SQL_DAILY_DATE),
|
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)
|
.bind(window)
|
||||||
.fetch_all(&**skald.db())
|
.bind(user.as_deref()).bind(user.as_deref())
|
||||||
|
.fetch_all(db)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| {
|
.map(|(day, requests, input_tokens, output_tokens, cache_read_tokens, avg_duration_ms)| {
|
||||||
@@ -113,13 +242,20 @@ pub async fn llm_stats(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let models = sqlx::query_as::<_, (String, i64)>(SQL_MODELS)
|
let members = sqlx::query_as::<_, (String, String)>(SQL_MEMBERS)
|
||||||
.bind(window)
|
.fetch_all(db)
|
||||||
.fetch_all(&**skald.db())
|
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(model_name, requests)| ModelStats { model_name, requests })
|
.map(|(id, label)| MemberEntry { id, label })
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,38 @@ import { LightElement } from '../lib/base.js';
|
|||||||
import { t } from '../lib/i18n.js';
|
import { t } from '../lib/i18n.js';
|
||||||
import { InboxMixin } from '../lib/inbox-mixin.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) {
|
export class DashboardPage extends InboxMixin(LightElement) {
|
||||||
|
|
||||||
static get properties() {
|
static get properties() {
|
||||||
@@ -13,6 +45,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
_plugins: { state: true },
|
_plugins: { state: true },
|
||||||
_stats: { state: true },
|
_stats: { state: true },
|
||||||
_statsRange: { state: true },
|
_statsRange: { state: true },
|
||||||
|
_statsUser: { state: true },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +57,9 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
this._pollTimer = null;
|
this._pollTimer = null;
|
||||||
this._stats = null; // null = loading
|
this._stats = null; // null = loading
|
||||||
this._statsRange = 'week';
|
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._chartInstances = {};
|
||||||
this._statsTimer = null;
|
this._statsTimer = null;
|
||||||
}
|
}
|
||||||
@@ -100,11 +136,16 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
|
|
||||||
async _loadStats() {
|
async _loadStats() {
|
||||||
try {
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
this._stats = await res.json();
|
this._stats = await res.json();
|
||||||
|
if (Array.isArray(this._stats.members) && this._stats.members.length) {
|
||||||
|
this._members = this._stats.members;
|
||||||
|
}
|
||||||
} catch {
|
} 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();
|
await this._loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _setUser(user) {
|
||||||
|
if (user === this._statsUser) return;
|
||||||
|
this._statsUser = user;
|
||||||
|
this._stats = null;
|
||||||
|
await this._loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
get _honchoActive() {
|
get _honchoActive() {
|
||||||
return this._plugins?.some(p => p.id === 'honcho' && p.enabled && p.running) ?? false;
|
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}$/, '');
|
.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() {
|
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');
|
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);
|
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
|
// 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 lat = filled.map(d => d.requests > 0 ? Math.round(d.avg_duration_ms) : null);
|
||||||
const models = this._stats.models;
|
|
||||||
|
|
||||||
const axisDefaults = () => ({
|
const axisDefaults = () => ({
|
||||||
ticks: { color: textColor, font: { size: 11 } },
|
ticks: { color: textColor, font: { size: 11 } },
|
||||||
@@ -322,38 +402,76 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
options: baseOpts(),
|
options: baseOpts(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Models — always horizontal bar
|
// Breakdowns — "how the spend splits": horizontal bars on billed tokens,
|
||||||
const c4 = get('chart-models');
|
// requests and the input/output/cache split in the tooltip.
|
||||||
if (c4) this._chartInstances.models = new Chart(c4, {
|
for (const spec of this._breakdownSpecs) {
|
||||||
|
const c = get(spec.id);
|
||||||
|
if (!c) continue;
|
||||||
|
this._chartInstances[spec.id] = new Chart(c, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: models.map(m => this._shortModelName(m.model_name)),
|
labels: spec.rows.map(r => spec.labelFn(r.key)),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: models.map(m => m.requests),
|
data: spec.rows.map(r => r.total_tokens),
|
||||||
backgroundColor: ['#3b82f6','#10b981','#f59e0b','#8b5cf6','#ef4444','#06b6d4'],
|
backgroundColor: spec.rows.map((_, i) => PALETTE[i % PALETTE.length]),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
...baseOpts(),
|
...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',
|
indexAxis: 'y',
|
||||||
scales: {
|
scales: {
|
||||||
x: { ...axisDefaults(), beginAtZero: true },
|
x: { ...axisDefaults(), beginAtZero: true,
|
||||||
|
ticks: { color: textColor, font: { size: 10 }, callback: v => fmtTok(v) } },
|
||||||
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
|
y: { ...axisDefaults(), ticks: { color: textColor, font: { size: 10 } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_renderScopeChips() {
|
||||||
|
if (this._members.length < 2) return nothing;
|
||||||
|
return html`
|
||||||
|
<div class="home-stats-scope">
|
||||||
|
<button class="home-stats-range-btn ${this._statsUser === '' ? 'active' : ''}"
|
||||||
|
@click=${() => this._setUser('')}>${t('dashboard.stats.scope.all')}</button>
|
||||||
|
${this._members.map(m => html`
|
||||||
|
<button class="home-stats-range-btn ${this._statsUser === m.id ? 'active' : ''}"
|
||||||
|
@click=${() => this._setUser(m.id)}>${m.label}</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
_renderStats() {
|
_renderStats() {
|
||||||
if (this._stats === null) {
|
if (this._stats === null) {
|
||||||
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
|
return html`<div class="home-stats-loading"><i class="bi bi-hourglass-split"></i> ${t('dashboard.stats.loading')}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (empty) {
|
||||||
return html`
|
return html`
|
||||||
<div class="home-stats-empty">
|
<div class="home-stats-empty">
|
||||||
@@ -363,6 +481,8 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const breakdown = this._breakdownSpecs;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="home-stats-grid">
|
<div class="home-stats-grid">
|
||||||
<div class="home-stat-card">
|
<div class="home-stat-card">
|
||||||
@@ -377,11 +497,20 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
<div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
|
<div class="home-stat-card-title">${t('dashboard.stats.latency')}</div>
|
||||||
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
|
<div class="home-stat-canvas-wrap"><canvas id="chart-latency"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
${breakdown.length ? html`
|
||||||
|
<div class="home-stats-sub">${t('dashboard.stats.sub.breakdown')}</div>
|
||||||
|
<div class="home-stats-grid">
|
||||||
|
${breakdown.map(c => html`
|
||||||
<div class="home-stat-card">
|
<div class="home-stat-card">
|
||||||
<div class="home-stat-card-title">${t('dashboard.stats.models')}</div>
|
<div class="home-stat-card-title">${c.title}</div>
|
||||||
<div class="home-stat-canvas-wrap"><canvas id="chart-models"></canvas></div>
|
<div class="home-stat-canvas-wrap" style="height:${Math.max(140, c.rows.length * 30)}px">
|
||||||
|
<canvas id=${c.id}></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
` : nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +566,7 @@ export class DashboardPage extends InboxMixin(LightElement) {
|
|||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${this._renderScopeChips()}
|
||||||
${this._renderStats()}
|
${this._renderStats()}
|
||||||
|
|
||||||
<!-- ── Pending inbox ── -->
|
<!-- ── Pending inbox ── -->
|
||||||
|
|||||||
+19
-1
@@ -312,6 +312,24 @@ dashboard-page {
|
|||||||
color: #fff;
|
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-loading,
|
||||||
.home-stats-empty {
|
.home-stats-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -325,7 +343,7 @@ dashboard-page {
|
|||||||
|
|
||||||
.home-stats-grid {
|
.home-stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-1
@@ -419,7 +419,6 @@ export default {
|
|||||||
'dashboard.stats.requests': 'Requests {per}',
|
'dashboard.stats.requests': 'Requests {per}',
|
||||||
'dashboard.stats.tokens': 'Tokens {per}',
|
'dashboard.stats.tokens': 'Tokens {per}',
|
||||||
'dashboard.stats.latency': 'Avg latency (ms)',
|
'dashboard.stats.latency': 'Avg latency (ms)',
|
||||||
'dashboard.stats.models': 'Models',
|
|
||||||
'dashboard.stats.per_min': '/ min',
|
'dashboard.stats.per_min': '/ min',
|
||||||
'dashboard.stats.per_hour': '/ hour',
|
'dashboard.stats.per_hour': '/ hour',
|
||||||
'dashboard.stats.per_day': '/ day',
|
'dashboard.stats.per_day': '/ day',
|
||||||
@@ -435,6 +434,26 @@ export default {
|
|||||||
'dashboard.stats.chart.non_cached': 'Non-cached',
|
'dashboard.stats.chart.non_cached': 'Non-cached',
|
||||||
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
|
'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.hero.subtitle': 'Your AI command centre — research, code, plan, and orchestrate. All in one place.',
|
||||||
|
|
||||||
'dashboard.banner.no_models.title': 'No LLM models configured.',
|
'dashboard.banner.no_models.title': 'No LLM models configured.',
|
||||||
|
|||||||
+20
-1
@@ -416,7 +416,6 @@ export default {
|
|||||||
'dashboard.stats.requests': 'Requêtes {per}',
|
'dashboard.stats.requests': 'Requêtes {per}',
|
||||||
'dashboard.stats.tokens': 'Tokens {per}',
|
'dashboard.stats.tokens': 'Tokens {per}',
|
||||||
'dashboard.stats.latency': 'Latence moyenne (ms)',
|
'dashboard.stats.latency': 'Latence moyenne (ms)',
|
||||||
'dashboard.stats.models': 'Modèles',
|
|
||||||
'dashboard.stats.per_min': '/ min',
|
'dashboard.stats.per_min': '/ min',
|
||||||
'dashboard.stats.per_hour': '/ heure',
|
'dashboard.stats.per_hour': '/ heure',
|
||||||
'dashboard.stats.per_day': '/ jour',
|
'dashboard.stats.per_day': '/ jour',
|
||||||
@@ -432,6 +431,26 @@ export default {
|
|||||||
'dashboard.stats.chart.non_cached': 'Non en cache',
|
'dashboard.stats.chart.non_cached': 'Non en cache',
|
||||||
'dashboard.stats.chart.cache_hit': 'Cache hit : {pct}%',
|
'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.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é.',
|
'dashboard.banner.no_models.title': 'Aucun modèle LLM configuré.',
|
||||||
|
|||||||
+20
-1
@@ -416,7 +416,6 @@ export default {
|
|||||||
'dashboard.stats.requests': 'Richieste {per}',
|
'dashboard.stats.requests': 'Richieste {per}',
|
||||||
'dashboard.stats.tokens': 'Token {per}',
|
'dashboard.stats.tokens': 'Token {per}',
|
||||||
'dashboard.stats.latency': 'Latenza media (ms)',
|
'dashboard.stats.latency': 'Latenza media (ms)',
|
||||||
'dashboard.stats.models': 'Modelli',
|
|
||||||
'dashboard.stats.per_min': '/ min',
|
'dashboard.stats.per_min': '/ min',
|
||||||
'dashboard.stats.per_hour': '/ h',
|
'dashboard.stats.per_hour': '/ h',
|
||||||
'dashboard.stats.per_day': '/ giorno',
|
'dashboard.stats.per_day': '/ giorno',
|
||||||
@@ -432,6 +431,26 @@ export default {
|
|||||||
'dashboard.stats.chart.non_cached': 'Non in cache',
|
'dashboard.stats.chart.non_cached': 'Non in cache',
|
||||||
'dashboard.stats.chart.cache_hit': 'Cache hit: {pct}%',
|
'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.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.',
|
'dashboard.banner.no_models.title': 'Nessun modello LLM configurato.',
|
||||||
|
|||||||
Reference in New Issue
Block a user