diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0ac28..6f07587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Statistics**: sections and models fold; long history with a year filter (monthly bars for long + ranges); export of the shown figures as XLSX (all tables) or CSV (one table). +- **Prompt snippets**: own text blocks, inserted with `/name` in the message field (Settings → Account). +- **Read-only links**: share a chat's text with people without an account (valid 1–90 days, revocable). +- **Git panel**: the small model suggests a commit message for the staged changes. +- **Weekly review** as a push notification (Mondays 08:00: chats, checks, costs; opt-in). +- **Speech-to-text on the server** (`server.transcribe_model`) for browsers without dictation (Firefox). +- **Offline**: the chat list and the last opened chats stay readable without a connection. +- **Planning model** setting: plan mode always uses it, other modes the chat's model. +- **Automations** send their report as a push notification when a run ends (switchable). + +### Fixed + +- fal.ai: the provider check no longer reports "API key rejected" when only the model listing is not offered. + ## [v202611-00028] – 2026-11-14 ### Added diff --git a/server/src/api.rs b/server/src/api.rs index fcf7372..1562e04 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -7,7 +7,7 @@ use crate::config::{self, ConfigPatch}; use crate::prompts; use crate::runner; use crate::store::User; -use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; +use crate::types::{Event, ImageData, Part, Role, Session, new_id, now_ms}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router { .route("/agents/tools", get(agent_tools)) .route("/agents/{name}", delete(delete_agent)) .route("/commands", get(commands)) + .route("/transcribe", post(transcribe)) .route("/sessions", get(list_sessions).post(create_session)) .route("/sessions/search", get(search_sessions)) .route("/sessions/{id}/fork", post(fork_session)) @@ -99,6 +100,9 @@ pub fn router(app: AppState) -> Router { .route("/sessions/{id}/shells", get(list_shells)) .route("/sessions/{id}/shells/{shell}/kill", post(kill_shell)) .route("/sessions/{id}/stats", get(session_stats)) + .route("/sessions/{id}/links", get(list_links).post(create_link)) + .route("/sessions/{id}/links/{token}", delete(delete_link)) + .route("/public/chat/{token}", get(public_chat)) .route("/sessions/{id}/shares", get(get_shares).put(put_shares)) .route("/sessions/{id}/queue", get(get_queue)) .route("/sessions/{id}/queue/flush", post(flush_queue)) @@ -133,6 +137,7 @@ pub fn router(app: AppState) -> Router { .route("/git", post(crate::git::handle)) .route("/fs/dirs", get(list_dirs)) .route("/stats", get(stats)) + .route("/stats/export", get(stats_export)) .route("/budget", get(budget)) .route("/audit", get(audit_log)) .route("/backup", get(backup)) @@ -152,6 +157,7 @@ pub fn router(app: AppState) -> Router { .route("/images/{id}", delete(delete_image)) .route("/images/{id}/file", get(image_file)) .route("/account/git", get(get_git_settings).put(put_git_settings)) + .route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets)) .route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs)) .route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar)) .route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory)) @@ -321,7 +327,7 @@ async fn get_config(Extension(user): Extension, State(app): State, State(app): State, State(app): State, +} + +/// Dictation for browsers without speech recognition: the recorded audio (request body) goes +/// to the configured OpenAI-compatible transcription endpoint. +async fn transcribe(State(app): State, Query(q): Query, headers: axum::http::HeaderMap, body: axum::body::Bytes) -> ApiResult { + let cfg = app.config(); + let spec = cfg.server.transcribe_model.clone(); + let (prov, model) = spec.split_once('/').ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, "speech-to-text is not configured (server.transcribe_model)".into()))?; + let p = cfg.providers.get(prov).filter(|p| p.enabled).ok_or_else(|| ApiError(StatusCode::NOT_IMPLEMENTED, format!("transcription provider '{prov}' is not available")))?; + if body.is_empty() || body.len() > 25 * 1024 * 1024 { + return Err(ApiError(StatusCode::BAD_REQUEST, "audio missing or larger than 25 MB".into())); + } + let mime = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("audio/webm").split(';').next().unwrap_or("audio/webm").to_string(); + let ext = if mime.contains("mp4") || mime.contains("aac") { "m4a" } else if mime.contains("ogg") { "ogg" } else if mime.contains("wav") { "wav" } else { "webm" }; + let part = reqwest::multipart::Part::bytes(body.to_vec()).file_name(format!("speech.{ext}")).mime_str(&mime).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e.to_string()))?; + let mut form = reqwest::multipart::Form::new().part("file", part).text("model", model.to_string()); + if let Some(l) = q.lang.as_deref().filter(|l| l.len() == 2 && l.chars().all(|c| c.is_ascii_lowercase())) { + form = form.text("language", l.to_string()); + } + let url = format!("{}/audio/transcriptions", p.base_url.trim_end_matches('/')); + let mut req = reqwest::Client::new().post(&url).multipart(form).timeout(std::time::Duration::from_secs(120)); + if !p.api_key.is_empty() { + req = match &p.auth_header { + Some(h) => req.header(h.as_str(), format!("{}{}", p.auth_prefix.clone().unwrap_or_default(), p.api_key)), + None => req.bearer_auth(&p.api_key), + }; + } + for (k, v) in &p.headers { + req = req.header(k.as_str(), v.as_str()); + } + let resp = req.send().await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, format!("transcription request failed: {e}")))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(ApiError(StatusCode::BAD_GATEWAY, format!("transcription failed ({status}): {}", text.chars().take(300).collect::()))); + } + let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"text": text.trim()})); + Ok(Json(json!({"text": v["text"].as_str().unwrap_or("").trim()}))) +} + +// --- read-only links ---------------------------------------------------------- + +#[derive(Deserialize)] +struct LinkBody { + /// Validity in days (1–90, default 7). + days: Option, +} + +fn link_json(base: &str, token: &str, created: i64, expires: i64) -> Value { + json!({"token": token, "url": format!("{base}/#/shared/{token}"), "created_at": created, "expires_at": expires}) +} + +async fn list_links(Extension(user): Extension, State(app): State, headers: axum::http::HeaderMap, Path(id): Path) -> ApiResult { + writable(&app, &user, &id)?; + let base = public_base(&app, &headers); + let now = now_ms(); + let links: Vec = app.store.share_links(&id)?.into_iter().filter(|(_, _, exp)| *exp > now).map(|(t, c, e)| link_json(&base, &t, c, e)).collect(); + Ok(Json(json!(links))) +} + +async fn create_link(Extension(user): Extension, State(app): State, headers: axum::http::HeaderMap, Path(id): Path, body: Option>) -> ApiResult { + writable(&app, &user, &id)?; + let days = body.and_then(|b| b.0.days).unwrap_or(7).clamp(1, 90); + let now = now_ms(); + let token = format!("{}{}", new_id(), new_id()).replace('-', ""); + let expires = now + days * 86_400_000; + app.store.purge_share_links(now)?; + app.store.add_share_link(&token, &id, &user.id, now, expires)?; + app.audit(Some(&user), "session.link", &format!("{id}: {days} day(s)"), ""); + Ok(Json(link_json(&public_base(&app, &headers), &token, now, expires))) +} + +async fn delete_link(Extension(user): Extension, State(app): State, Path((id, token)): Path<(String, String)>) -> ApiResult { + writable(&app, &user, &id)?; + app.store.delete_share_link(&id, &token)?; + app.audit(Some(&user), "session.link.revoke", &id, ""); + Ok(Json(json!({"ok": true}))) +} + +/// A chat for people without an account: only the conversation text (no tool output, +/// reasoning or attachments), readable as long as the link is valid. +async fn public_chat(State(app): State, Path(token): Path) -> ApiResult { + let gone = || ApiError(StatusCode::NOT_FOUND, "this link is invalid or has expired".into()); + let id = app.store.share_link_session(&token, now_ms())?.ok_or_else(gone)?; + let session = app.session(&id).map_err(|_| gone())?; + let messages: Vec = app + .store + .messages(&id)? + .into_iter() + .filter(|m| matches!(m.role, Role::User | Role::Assistant)) + .filter_map(|m| { + let text = m + .parts + .iter() + .filter_map(|p| match p { + Part::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n\n"); + (!text.trim().is_empty()).then(|| json!({"id": m.id, "role": m.role, "text": text, "created_at": m.created_at})) + }) + .collect(); + Ok(Json(json!({"title": session.title, "updated_at": session.updated_at, "messages": messages}))) +} + async fn get_shares(Extension(user): Extension, State(app): State, Path(id): Path) -> ApiResult { writable(&app, &user, &id)?; Ok(Json(json!(app.store.shares(&id)?))) @@ -1968,6 +2086,45 @@ async fn stats(Extension(user): Extension, State(app): State, Qu Ok(Json(v)) } +#[derive(Deserialize)] +struct StatsExportQuery { + from: Option, + to: Option, + tz: Option, + user: Option, + /// "xlsx" (all tables) or "csv" (one table: see `table`). + format: Option, + /// csv: total, day, month, model, provider, agent, user, session (default: day). + table: Option, + lang: Option, +} + +/// The statistics of a range as a download. +async fn stats_export(Extension(user): Extension, State(app): State, Query(q): Query) -> Result { + let to = q.to.unwrap_or_else(|| now_ms() + 1); + let from = q.from.unwrap_or(to - 30 * 86_400_000); + let filter = if user.is_admin() { q.user.filter(|u| !u.is_empty()) } else { Some(user.id.clone()) }; + let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0), filter.as_deref())?; + v["from"] = json!(from); + v["to"] = json!(to); + let de = q.lang.as_deref().is_some_and(|l| l.starts_with("de")); + let tables = crate::stats_export::tables(&v, de); + let stamp = chrono::Local::now().format("%Y-%m-%d"); + let (body, ctype, name): (Vec, &str, String) = if q.format.as_deref() == Some("csv") { + let id = q.table.as_deref().unwrap_or("day"); + let t = tables.iter().find(|t| t.id == id).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "unknown table".into()))?; + (crate::stats_export::csv(t, de).into_bytes(), "text/csv; charset=utf-8", format!("codecraft-stats-{id}-{stamp}.csv")) + } else { + let bytes = crate::stats_export::xlsx(&tables).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + (bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", format!("codecraft-stats-{stamp}.xlsx")) + }; + Ok(( + [(axum::http::header::CONTENT_TYPE, ctype.to_string()), (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{name}\""))], + body, + ) + .into_response()) +} + // --- websocket -------------------------------------------------------------- // --- automations (scheduled agents) ------------------------------------------------ @@ -2057,12 +2214,13 @@ struct UpdateJob { model: Option, permission_mode: Option, timeout_minutes: Option, + notify: Option, } async fn update_job(Extension(user): Extension, State(app): State, Path(id): Path, Json(b): Json) -> ApiResult { let job = own_job(&app, &user, &id)?; let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?; - let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some(); + let changes = b.name.is_some() || b.schedule.is_some() || b.agent.is_some() || b.model.is_some() || b.permission_mode.is_some() || b.timeout_minutes.is_some() || b.notify.is_some(); if changes { let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation")); if let Some(x) = b.name { @@ -2086,6 +2244,9 @@ async fn update_job(Extension(user): Extension, State(app): State, State(app): State, State(app): State) -> ApiResult { let on: bool = app.store.user_setting(&user.id, "cost_push")?; - Ok(Json(json!({"enabled": on}))) + let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?; + Ok(Json(json!({"enabled": on, "weekly": weekly}))) +} + +#[derive(Deserialize)] +struct DigestQ { + #[serde(default)] + weekly: bool, } #[derive(Deserialize)] struct DigestBody { - enabled: bool, + enabled: Option, + weekly: Option, } async fn put_digest(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { - app.store.set_user_setting(&user.id, "cost_push", &b.enabled)?; - Ok(Json(json!({"enabled": b.enabled}))) + if let Some(v) = b.enabled { + app.store.set_user_setting(&user.id, "cost_push", &v)?; + } + if let Some(v) = b.weekly { + app.store.set_user_setting(&user.id, "weekly_push", &v)?; + } + let on: bool = app.store.user_setting(&user.id, "cost_push")?; + let weekly: bool = app.store.user_setting(&user.id, "weekly_push")?; + Ok(Json(json!({"enabled": on, "weekly": weekly}))) } /// Send today's summary right away (preview). -async fn send_digest(Extension(user): Extension, State(app): State) -> ApiResult { - let payload = crate::digest::summary(&app, &user.id).unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"})); +async fn send_digest(Extension(user): Extension, State(app): State, Query(q): Query) -> ApiResult { + let found = if q.weekly { crate::digest::weekly(&app, &user.id) } else { crate::digest::summary(&app, &user.id) }; + let payload = found.unwrap_or_else(|| json!({"kind": "digest", "title": "CodeCraft", "body": "No model requests today.", "tag": "cost-digest", "url": "/#/stats"})); let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await; Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]}))) } diff --git a/server/src/auth.rs b/server/src/auth.rs index c0cf814..169f6ac 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -186,6 +186,9 @@ fn is_public(path: &str) -> bool { ["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"] .iter() .any(|p| path.ends_with(p)) + // Read-only chat links (the token is the credential). + || path.starts_with("/public/chat/") + || path.starts_with("/api/public/chat/") } pub async fn require_user(State(app): State>, mut req: Request, next: Next) -> Response { diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs index 1dbb44c..04649d4 100644 --- a/server/src/cli_api.rs +++ b/server/src/cli_api.rs @@ -231,6 +231,29 @@ pub async fn put_prefs(Extension(user): Extension, State(app): State, State(app): State) -> ApiResult { + let v: Value = app.store.user_setting(&user.id, "snippets")?; + Ok(Json(if v.is_array() { v } else { json!([]) })) +} + +pub async fn put_snippets(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { + let ok = b.as_array().is_some_and(|a| { + a.len() <= 100 + && a.iter().all(|x| { + let name = x["name"].as_str().unwrap_or(""); + !name.is_empty() && name.len() <= 40 && !name.contains(char::is_whitespace) && x["text"].as_str().is_some_and(|t| !t.is_empty() && t.len() <= 8000) + }) + }); + if !ok { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid snippets (name: one word, up to 40 characters; text: up to 8000)".into())); + } + let clean: Vec = b.as_array().unwrap().iter().map(|x| json!({"id": x["id"].as_str().filter(|i| i.len() <= 64).map(str::to_string).unwrap_or_else(crate::types::new_id), "name": x["name"], "text": x["text"]})).collect(); + let v = Value::Array(clean); + app.store.set_user_setting(&user.id, "snippets", &v)?; + Ok(Json(v)) +} + /// Order and pinning of the groups (folders, projects) in the chat list – per user, so /// every device shows the same order: {"order": [group ids], "pinned": [group ids]}. pub async fn get_sidebar(Extension(user): Extension, State(app): State) -> ApiResult { diff --git a/server/src/config.rs b/server/src/config.rs index 23f7ab8..9dd0d6b 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -302,6 +302,9 @@ pub struct ServerConfig { pub data_dir: String, pub default_model: String, pub small_model: String, + /// Speech-to-text for browsers without dictation (Firefox): "provider/model" of an + /// OpenAI-compatible /audio/transcriptions endpoint, e.g. "openai/whisper-1". Empty = off. + pub transcribe_model: String, pub auto_compact_threshold: f64, /// Currency label for cost statistics (display only). pub currency: String, @@ -337,6 +340,7 @@ impl Default for ServerConfig { data_dir: "./data".into(), default_model: String::new(), small_model: String::new(), + transcribe_model: String::new(), auto_compact_threshold: 0.85, currency: "USD".into(), max_parallel_subagents: 4, diff --git a/server/src/digest.rs b/server/src/digest.rs index bf86a6b..57199e8 100644 --- a/server/src/digest.rs +++ b/server/src/digest.rs @@ -2,7 +2,7 @@ //! user via the user setting "cost_push"). use crate::app::App; -use chrono::{Local, Timelike}; +use chrono::{Datelike, Local, Timelike}; use serde_json::{Value, json}; use std::sync::Arc; use std::time::Duration; @@ -54,11 +54,59 @@ pub fn summary(app: &App, user_id: &str) -> Option { })) } +/// The last seven days for one user (None when nothing happened). +pub fn weekly(app: &App, user_id: &str) -> Option { + let now = Local::now(); + let since = now.timestamp_millis() - 7 * 86_400_000; + let offset = now.offset().local_minus_utc() as i64 / 60; + let stats = app.store.global_stats(since, now.timestamp_millis() + 1, offset, Some(user_id)).ok()?; + let requests = stats["total"]["requests"].as_i64().unwrap_or(0); + let (chats, failed, checks) = app.store.weekly_counts(user_id, since).ok()?; + if requests == 0 && chats == 0 { + return None; + } + let prefs: Value = app.store.user_setting(user_id, "prefs").unwrap_or_default(); + let de = prefs["lang"].as_str().unwrap_or("").starts_with("de"); + let currency = app.config().server.currency.clone(); + let cost = money(stats["total"]["cost"].as_f64().unwrap_or(0.0), ¤cy, de); + let top = stats["top_sessions"].as_array().and_then(|a| a.first()).and_then(|s| s["title"].as_str()).filter(|t| !t.is_empty()).map(str::to_string); + let mut parts = if de { vec![format!("{chats} Chats aktiv"), format!("{requests} Anfragen · {cost}")] } else { vec![format!("{chats} chats active"), format!("{requests} requests · {cost}")] }; + if checks > 0 { + parts.push(if de { format!("Checks: {failed} von {checks} fehlgeschlagen") } else { format!("checks: {failed} of {checks} failed") }); + } + if let Some(t) = top { + parts.push(if de { format!("teuerster Chat: {t}") } else { format!("most expensive chat: {t}") }); + } + Some(json!({ + "kind": "digest", + "title": if de { "CodeCraft – Wochenrückblick" } else { "CodeCraft – weekly review" }, + "body": parts.join("\n"), + "tag": "weekly-digest", + "url": "/#/stats", + })) +} + pub fn spawn(app: Arc) { tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(60)).await; let now = Local::now(); + // Weekly review: Monday morning, opt-in (user setting "weekly_push"). + if now.hour() == 8 && now.weekday() == chrono::Weekday::Mon { + let week = now.format("%G-W%V").to_string(); + for user in app.store.users().unwrap_or_default() { + let on: bool = app.store.user_setting(&user.id, "weekly_push").unwrap_or(false); + let last: String = app.store.user_setting(&user.id, "weekly_push_last").unwrap_or_default(); + if !on || user.disabled || last == week { + continue; + } + let _ = app.store.set_user_setting(&user.id, "weekly_push_last", &week); + if let Some(payload) = weekly(&app, &user.id) { + let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await; + tracing::info!(user = %user.username, sent, errors = errors.len(), "weekly review"); + } + } + } if now.hour() != HOUR { continue; } diff --git a/server/src/git.rs b/server/src/git.rs index 875166c..4439d81 100644 --- a/server/src/git.rs +++ b/server/src/git.rs @@ -254,6 +254,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult { git_ok(&repo, &args, 60).await?; json!({"ok": true}) } + "suggest" => { + let diff = git_ok(&repo, &["diff", "--cached", "--no-color", "--no-ext-diff", "--stat", "--patch", "-U2"], 60).await?; + if diff.trim().is_empty() { + return Err(ApiError(StatusCode::BAD_REQUEST, "nothing is staged".into())); + } + let recent = git_ok(&repo, &["log", "-n", "8", "--pretty=%s"], 15).await.unwrap_or_default(); + let message = crate::runner::suggest_commit_message(&app, &user.id, &diff, &recent).await.map_err(|e| ApiError(StatusCode::BAD_GATEWAY, e.to_string()))?; + json!({"message": message}) + } "commit" => { let msg = b.message.as_deref().unwrap_or("").trim().to_string(); if msg.is_empty() && !b.amend { diff --git a/server/src/health.rs b/server/src/health.rs index 0c5c7bd..4be0340 100644 --- a/server/src/health.rs +++ b/server/src/health.rs @@ -42,8 +42,9 @@ pub async fn probe(provider: Arc) -> (ProviderSta Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0), Err(_) => (Some("timed out".to_string()), 0), }; - // The host answered but has no model list: reachable. - let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405"))); + // The host answered but has no model list: reachable. (fal.ai's router answers 403 + // "endpoint_not_priced" for /models: the key is fine, the listing is just not offered.) + let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced"))); let state = match &error { None => "ok", Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth", @@ -83,3 +84,13 @@ pub fn spawn_checker(app: Arc) { } }); } + +#[cfg(test)] +mod tests { + #[test] + fn unpriced_model_listing_is_not_an_auth_failure() { + let e = r#"provider returned HTTP 403 Forbidden: {"detail":"Endpoint openrouter/router/openai/v1/models is not available: no billing definition.","type":"endpoint_not_priced"}"#.to_string(); + let kept = Some(e).filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405") || e.contains("endpoint_not_priced"))); + assert!(kept.is_none()); + } +} diff --git a/server/src/jobs.rs b/server/src/jobs.rs index f4b4acf..4a0ec7c 100644 --- a/server/src/jobs.rs +++ b/server/src/jobs.rs @@ -68,6 +68,13 @@ pub struct JobSettings { /// Chat in which the user works out the prompt with a model. #[serde(default, skip_serializing_if = "Option::is_none")] pub assistant_session: Option, + /// Push notification with the report when a run ends. + #[serde(default = "default_notify")] + pub notify: bool, +} + +fn default_notify() -> bool { + true } impl JobSettings { @@ -81,6 +88,7 @@ impl JobSettings { timeout_minutes: default_timeout(), cwd: None, assistant_session: None, + notify: true, } } @@ -121,6 +129,7 @@ pub struct JobSpec { pub cwd: Option, #[serde(skip_serializing_if = "Option::is_none")] pub assistant_session: Option, + pub notify: bool, pub prompt: String, } @@ -135,6 +144,7 @@ impl JobSpec { timeout_minutes: s.timeout_minutes, cwd: s.cwd.clone(), assistant_session: s.assistant_session.clone(), + notify: s.notify, prompt, } } @@ -149,6 +159,7 @@ impl JobSpec { timeout_minutes: self.timeout_minutes, cwd: self.cwd.clone(), assistant_session: self.assistant_session.clone(), + notify: self.notify, } } } @@ -218,6 +229,7 @@ pub fn parse(path: &Path) -> Result { timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()), assistant_session: None, + notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true), prompt: body.trim().to_string(), }) } @@ -342,6 +354,7 @@ pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option< let job_id = job.id.clone(); let sid = session.id.clone(); let timeout = Duration::from_secs(spec.timeout_minutes * 60); + let (spec_name, spec_notify) = (spec.name.clone(), spec.notify); tokio::spawn(async move { let started = std::time::Instant::now(); tokio::time::sleep(Duration::from_secs(2)).await; @@ -349,15 +362,48 @@ pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option< if started.elapsed() > timeout { crate::runner::abort(&app2, &sid); let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout"); + report(&app2, &sid, &spec_name, spec_notify, "timeout").await; return; } tokio::time::sleep(Duration::from_secs(5)).await; } let _ = app2.store.job_finished(&job_id, Some(&sid), "done"); + report(&app2, &sid, &spec_name, spec_notify, "done").await; }); Ok(session.id) } +/// Push the report of a finished run (the agent's last message) to the owner. +async fn report(app: &App, session_id: &str, name: &str, notify: bool, status: &str) { + if !notify { + return; + } + let Ok(session) = app.session(session_id) else { return }; + let Some(owner) = session.owner_id.clone() else { return }; + let prefs: Value = app.store.user_setting(&owner, "prefs").unwrap_or_default(); + let de = prefs["lang"].as_str().unwrap_or("").starts_with("de"); + let last = app + .store + .messages(session_id) + .unwrap_or_default() + .into_iter() + .rev() + .find(|m| matches!(m.role, crate::types::Role::Assistant)) + .map(|m| m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join("\n")) + .unwrap_or_default(); + let flat: String = last.split_whitespace().collect::>().join(" "); + let body: String = if flat.chars().count() > 220 { format!("{}…", flat.chars().take(220).collect::()) } else { flat }; + let title = match (status, de) { + ("timeout", true) => format!("{name} – Zeitlimit erreicht"), + ("timeout", false) => format!("{name} – timed out"), + (_, true) => format!("{name} – fertig"), + (_, false) => format!("{name} – finished"), + }; + let payload = json!({"kind": "job", "title": title, "body": body, "tag": format!("job-{session_id}"), "url": format!("/#/s/{session_id}")}); + let (sent, _) = crate::push::send_to_user(app, &owner, &payload).await; + tracing::info!(job = name, sent, "automation report"); +} + /// Background scheduler: checks every 30 s which jobs are due. pub fn spawn_scheduler(app: Arc) { tokio::spawn(async move { diff --git a/server/src/main.rs b/server/src/main.rs index 04427c6..a1ffbfb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -34,6 +34,7 @@ mod reverse; mod runner; mod sandbox; mod shells; +mod stats_export; mod store; mod telegram; mod terminal; diff --git a/server/src/runner.rs b/server/src/runner.rs index 773260a..5a89212 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -1248,6 +1248,58 @@ async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input Ok(strip_think(&text)) } +/// A commit message for staged changes (small model; the diff is cut to its context window). +/// `recent` are the latest commit subjects, so the style matches the repository. +pub async fn suggest_commit_message(app: &App, user_id: &str, diff: &str, recent: &str) -> Result { + let cfg = app.config(); + let spec = if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { + cfg.server.small_model.clone() + } else { + app.registry().first_model_excluding(crate::claude_code::PROVIDER).ok_or_else(|| anyhow!("no model available"))? + }; + let (provider, mut model) = app.registry().resolve(&spec)?; + model.effort = Some("low".into()); + let max_out = 200u64; + let system = "You write a git commit message for the staged changes. Reply with the message only: a subject line of at most 72 characters in the imperative mood, then optionally a blank line and up to 4 short bullet points on what changed and why. No quotes, no code fences. Match the style of the recent commit subjects (language, prefixes)."; + let ctx = model.context.unwrap_or(32_000); + let room = ctx.saturating_sub(max_out + system.len() as u64 / 3 + 500).max(600) as usize * 5 / 2; + let input = format!("\n{}\n\n\n{}\n", fit(recent, 600), fit(diff, room.saturating_sub(700).clamp(500, 40_000))); + let req = ChatRequest { + system: system.into(), + messages: vec![Message::new(Role::User, vec![Part::Text { text: input }])], + tools: vec![], + max_tokens: max_out, + reasoning: false, + session_id: None, + model: model.clone(), + }; + let (text, usage) = complete_text(app, &spec, provider, req, CancellationToken::new()).await?; + let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str())); + let _ = app.store.add_usage(&UsageRecord { + ts: now_ms(), + session_id: "cli:git-commit".into(), + root_session_id: "cli:git-commit".into(), + provider: p.into(), + model: m.into(), + agent: "commit-message".into(), + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + ttft_ms: 0, + duration_ms: 0, + cost: model.cost(&usage), + error: false, + user_id: Some(user_id.to_string()), + allowance: usage.allowance, + }); + let msg = strip_think(&text).trim().trim_matches('`').trim().to_string(); + if msg.is_empty() { + bail!("the model returned no message"); + } + Ok(msg) +} + fn message_text(m: &Message) -> String { m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::>().join("\n") } diff --git a/server/src/stats_export.rs b/server/src/stats_export.rs new file mode 100644 index 0000000..fb3b977 --- /dev/null +++ b/server/src/stats_export.rs @@ -0,0 +1,249 @@ +//! Statistics as CSV (one table) or XLSX (all tables, one sheet each). + +use serde_json::Value; +use std::io::Write; + +/// A table: header row and rows of cells. +pub struct Table { + pub id: &'static str, + pub title: String, + pub rows: Vec>, +} + +pub enum Cell { + Text(String), + Num(f64), +} + +fn num(v: &Value, k: &str) -> Cell { + Cell::Num(v[k].as_f64().unwrap_or(0.0)) +} + +/// Column titles (English, German). +fn columns(first: &str, de: bool) -> Vec { + let t: [&str; 10] = if de { + ["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"] + } else { + ["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"] + }; + std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect() +} + +fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table { + let mut out = vec![columns(first, de)]; + for r in rows.as_array().into_iter().flatten() { + out.push(vec![ + Cell::Text(r[key].as_str().unwrap_or("").to_string()), + num(r, "requests"), + num(r, "input_tokens"), + num(r, "cache_read_tokens"), + num(r, "cache_write_tokens"), + num(r, "output_tokens"), + num(r, "errors"), + num(r, "avg_ttft_ms"), + num(r, "output_tps"), + num(r, "prefill_tps"), + num(r, "cost"), + ]); + } + Table { id, title: title.to_string(), rows: out } +} + +/// All tables of a statistics result (as returned by `Store::global_stats`). +pub fn tables(stats: &Value, de: bool) -> Vec { + let (day, month, model, provider, agent, user, session) = if de { + ("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat") + } else { + ("Day", "Month", "Model", "Provider", "Agent", "User", "Chat") + }; + let mut out = vec![ + agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de), + agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de), + agg_table("model", model, model, "model", &stats["by_model"], de), + agg_table("provider", provider, provider, "provider", &stats["by_provider"], de), + agg_table("agent", agent, agent, "agent", &stats["by_agent"], de), + agg_table("user", user, user, "user", &stats["by_user"], de), + agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de), + ]; + // The grand total first, as a one-row table. + let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)]; + let t = &stats["total"]; + total.push(vec![ + Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))), + num(t, "requests"), + num(t, "input_tokens"), + num(t, "cache_read_tokens"), + num(t, "cache_write_tokens"), + num(t, "output_tokens"), + num(t, "errors"), + num(t, "avg_ttft_ms"), + num(t, "output_tps"), + num(t, "prefill_tps"), + num(t, "cost"), + ]); + out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total }); + out +} + +fn date(ms: i64) -> String { + if ms <= 0 { + return "-".into(); + } + chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default() +} + +// --- CSV ----------------------------------------------------------------------------- + +/// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise; +/// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection. +pub fn csv(table: &Table, de: bool) -> String { + let sep = if de { ';' } else { ',' }; + let mut out = String::from('\u{feff}'); + for row in &table.rows { + let line: Vec = row + .iter() + .map(|c| match c { + Cell::Num(n) => { + let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") }; + if de { s.replace('.', ",") } else { s } + } + Cell::Text(t) => { + let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() }; + if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t } + } + }) + .collect(); + out.push_str(&line.join(&sep.to_string())); + out.push_str("\r\n"); + } + out +} + +// --- XLSX ---------------------------------------------------------------------------- + +fn esc(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) + .map(|c| match c { + '&' => "&".to_string(), + '<' => "<".to_string(), + '>' => ">".to_string(), + '"' => """.to_string(), + c => c.to_string(), + }) + .collect() +} + +fn col_name(mut i: usize) -> String { + let mut s = String::new(); + loop { + s.insert(0, (b'A' + (i % 26) as u8) as char); + if i < 26 { + break; + } + i = i / 26 - 1; + } + s +} + +fn sheet_xml(t: &Table) -> String { + let mut x = String::from(r#""#); + for (r, row) in t.rows.iter().enumerate() { + x.push_str(&format!(r#""#, r + 1)); + for (c, cell) in row.iter().enumerate() { + let at = format!("{}{}", col_name(c), r + 1); + let style = if r == 0 { r#" s="1""# } else { "" }; + match cell { + // Text as inline strings: no shared string table needed. + Cell::Text(s) => x.push_str(&format!(r#"{}"#, esc(s))), + Cell::Num(n) => x.push_str(&format!(r#"{n}"#)), + } + } + x.push_str(""); + } + x.push_str(""); + x +} + +/// Sheet names: at most 31 characters, none of []:*?/\ +fn sheet_name(s: &str) -> String { + s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect() +} + +pub fn xlsx(tables: &[Table]) -> anyhow::Result> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let mut put = |name: &str, body: &str| -> anyhow::Result<()> { + z.start_file(name, opts)?; + z.write_all(body.as_bytes())?; + Ok(()) + }; + let mut types = String::from(r#""#); + let mut sheets = String::new(); + let mut rels = String::new(); + for (i, t) in tables.iter().enumerate() { + let n = i + 1; + types.push_str(&format!(r#""#)); + sheets.push_str(&format!(r#""#, esc(&sheet_name(&t.title)))); + rels.push_str(&format!(r#""#)); + } + types.push_str(""); + let styles_id = tables.len() + 1; + rels.push_str(&format!(r#""#)); + put("[Content_Types].xml", &types)?; + put("_rels/.rels", r#""#)?; + put("xl/workbook.xml", &format!(r#"{sheets}"#))?; + put("xl/_rels/workbook.xml.rels", &format!(r#"{rels}"#))?; + // Style 1 = bold header. + put("xl/styles.xml", r#""#)?; + for (i, t) in tables.iter().enumerate() { + put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?; + } + z.finish()?; + } + Ok(buf.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn sample() -> Value { + json!({ + "from": 0, "to": 1_700_000_000_000i64, + "total": {"requests": 3, "cost": 1.5}, + "by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}], + "by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}], + "by_provider": [], "by_agent": [], "by_user": [], "top_sessions": [] + }) + } + + #[test] + fn csv_uses_locale_and_blocks_formulas() { + let tabs = tables(&sample(), true); + let day = tabs.iter().find(|t| t.id == "day").unwrap(); + let c = csv(day, true); + assert!(c.starts_with('\u{feff}')); + assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}"); + let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false); + assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}"); + } + + #[test] + fn xlsx_is_a_zip_with_every_sheet() { + let tabs = tables(&sample(), false); + let bytes = xlsx(&tabs).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + for i in 1..=tabs.len() { + assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok()); + } + let mut wb = String::new(); + std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap(); + assert!(wb.contains("Per day") && wb.contains("Total")); + assert_eq!(col_name(0), "A"); + assert_eq!(col_name(26), "AA"); + } +} diff --git a/server/src/store.rs b/server/src/store.rs index 8065759..c63c4cd 100644 --- a/server/src/store.rs +++ b/server/src/store.rs @@ -163,6 +163,14 @@ impl Store { created_at INTEGER NOT NULL, PRIMARY KEY (cwd, key) ); + CREATE TABLE IF NOT EXISTS share_links ( + token TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + owner_id TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS share_links_session ON share_links(session_id); CREATE TABLE IF NOT EXISTS project_defaults ( cwd TEXT PRIMARY KEY, data TEXT NOT NULL @@ -744,6 +752,9 @@ impl Store { let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; let by_day = grouped(day_sql, "day")?; + // Long ranges are shown per month. + let by_month = grouped("strftime('%Y-%m', (ts + ?3) / 1000, 'unixepoch')", "month")?; + let first_ts: Option = conn.query_row("SELECT MIN(ts) FROM usage WHERE (?1 IS NULL OR user_id = ?1)", params![user], |r| r.get(0))?; let by_model = grouped("provider || '/' || model", "model")?; let by_provider = grouped("provider", "provider")?; let by_agent = grouped("agent", "agent")?; @@ -795,6 +806,8 @@ impl Store { Ok(json!({ "total": total, "by_day": by_day, + "by_month": by_month, + "first_ts": first_ts, "by_day_model": by_day_model, "by_model": by_model, "by_provider": by_provider, @@ -1526,6 +1539,59 @@ impl Store { } } +// --- weekly overview ---------------------------------------------------------------------- + +impl Store { + /// (chats with activity, failed checks, checks run) of a user's own top-level chats since `since` (ms). + pub fn weekly_counts(&self, user_id: &str, since: i64) -> Result<(i64, i64, i64)> { + let conn = self.conn.lock().unwrap(); + let chats: i64 = conn.query_row("SELECT COUNT(*) FROM sessions WHERE owner_id = ?1 AND parent_id IS NULL AND updated_at >= ?2", params![user_id, since], |r| r.get(0))?; + let (failed, total): (i64, i64) = conn.query_row( + "SELECT COALESCE(SUM(c.status IN ('failed','timeout')),0), COUNT(*) FROM turn_checks c JOIN sessions s ON s.id = c.session_id WHERE s.owner_id = ?1 AND c.ts >= ?2", + params![user_id, since], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + Ok((chats, failed, total)) + } +} + +// --- read-only links ---------------------------------------------------------------------- + +impl Store { + pub fn add_share_link(&self, token: &str, session_id: &str, owner_id: &str, created_at: i64, expires_at: i64) -> Result<()> { + self.conn.lock().unwrap().execute( + "INSERT INTO share_links (token, session_id, owner_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![token, session_id, owner_id, created_at, expires_at], + )?; + Ok(()) + } + + /// (token, created_at, expires_at) of the links of a chat, newest first. + pub fn share_links(&self, session_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare("SELECT token, created_at, expires_at FROM share_links WHERE session_id = ?1 ORDER BY created_at DESC")?; + let rows = stmt.query_map([session_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; + Ok(rows.collect::>()?) + } + + /// The chat a link points to while it is valid. + pub fn share_link_session(&self, token: &str, now: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT session_id FROM share_links WHERE token = ?1 AND expires_at > ?2", params![token, now], |r| r.get(0)).optional()?) + } + + pub fn delete_share_link(&self, session_id: &str, token: &str) -> Result<()> { + self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE session_id = ?1 AND token = ?2", params![session_id, token])?; + Ok(()) + } + + /// Links are dropped with their chat and when expired. + pub fn purge_share_links(&self, now: i64) -> Result<()> { + self.conn.lock().unwrap().execute("DELETE FROM share_links WHERE expires_at <= ?1 OR session_id NOT IN (SELECT id FROM sessions)", params![now])?; + Ok(()) + } +} + // --- defaults of new chats in a project ------------------------------------------------------- impl Store { @@ -1710,3 +1776,36 @@ fn snippet_in(m: &Message, needle: &str) -> Option { } None } + +#[cfg(test)] +mod link_tests { + use super::*; + + #[test] + fn share_links_expire_and_can_be_revoked() { + let dir = std::env::temp_dir().join(format!("cc-links-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = Store::open(&dir.join("t.db")).unwrap(); + store.add_share_link("tok1", "s1", "u", 1_000, 5_000).unwrap(); + store.add_share_link("tok2", "s1", "u", 2_000, 9_000).unwrap(); + // Valid until the expiry time, then gone. + assert_eq!(store.share_link_session("tok1", 4_999).unwrap().as_deref(), Some("s1")); + assert_eq!(store.share_link_session("tok1", 5_000).unwrap(), None); + assert_eq!(store.share_link_session("nope", 1).unwrap(), None); + // Revoking removes only the named link of that chat. + store.delete_share_link("other", "tok2").unwrap(); + assert!(store.share_link_session("tok2", 3_000).unwrap().is_some()); + store.delete_share_link("s1", "tok2").unwrap(); + assert_eq!(store.share_link_session("tok2", 3_000).unwrap(), None); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn weekly_counts_of_an_empty_user_are_zero() { + let dir = std::env::temp_dir().join(format!("cc-weekly-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = Store::open(&dir.join("t.db")).unwrap(); + assert_eq!(store.weekly_counts("nobody", 0).unwrap(), (0, 0, 0)); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 013bdbe..3ba8014 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { connectEvents, get, setUnauthorizedHandler } from "./api"; import { useT } from "./i18n"; import { navigate, useRoute, closePane, MAX_PANES } from "./router"; -import { applyEvent, loadSessions, resync, setState } from "./store"; +import { applyEvent, loadSessions, resync, setState, useStore } from "./store"; import type { Info } from "./types"; import { ImageViewer } from "./components/ImageViewer"; import { ProjectPage } from "./components/ProjectPage"; @@ -22,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew"; import { ShareTarget } from "./components/ShareTarget"; import { Palette } from "./components/Palette"; import { pendingShare } from "./share"; +import { cacheInfo, cachedInfo, isOffline } from "./offline"; +import { PublicChat } from "./components/PublicChat"; // The file manager (editor, git) is loaded on demand to keep the chat small. const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files }))); @@ -34,11 +36,21 @@ export function App() { const [error, setError] = useState(); const [menuOpen, setMenuOpen] = useState(false); const [shared, setShared] = useState(pendingShare); + const offline = useStore((s) => s.offline); useTitleActivity(); const refresh = async () => { try { - const i = await get("/info"); + let i: Info; + try { + i = await get("/info"); + cacheInfo(i); + } catch (e) { + // No connection: open with the saved copy (chats are read-only). + const saved = isOffline(e) ? cachedInfo() : undefined; + if (!saved) throw e; + i = saved; + } setState(() => ({ me: i.user, update: i.update ?? null })); if (i.user) { await loadSessions(); @@ -71,6 +83,7 @@ export function App() { // Close the mobile menu on navigation. useEffect(() => setMenuOpen(false), [route.join("/")]); + if (route[0] === "shared" && route[1]) return ; if (error) return
{error}
; if (!info) return
{t("common.loading")}
; if (!info.user) @@ -119,7 +132,10 @@ export function App() {
{menuOpen &&
setMenuOpen(false)} />} -
{content}
+
+ {offline &&
{t("offline.banner")}
} + {content} +
diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 89f53d5..4e57f46 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -78,6 +78,7 @@ export function Composer({ const [mention, setMention] = useState<{ start: number; end: number; q: string }>(); const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]); const agents = useAgents(session.cwd).filter((a) => a.mode === "primary"); + const snippets = useFetch<{ id: string; name: string; text: string }[]>("/account/snippets").data; const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data; // Auto-grow. @@ -134,11 +135,14 @@ export function Composer({ ...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })), ...(cmdData?.commands ?? []), ]; - return all + const mine = (snippets ?? []) + .filter((s) => s.name.toLowerCase().startsWith(m[1].toLowerCase())) + .map((s) => ({ key: `snip:${s.id}`, label: `/${s.name}`, description: s.text.replace(/\s+/g, " ").slice(0, 80), apply: () => (setText(s.text), requestAnimationFrame(() => ref.current?.focus())) })); + const cmds = all .filter((c) => c.name.startsWith(m[1])) - .slice(0, 12) .map((c) => ({ key: c.name, label: `/${c.name}`, description: c.description, apply: () => setText(`/${c.name} `) })); - }, [text, cmdData, t, mention, mentionHits]); + return [...mine, ...cmds].slice(0, 12); + }, [text, cmdData, snippets, t, mention, mentionHits]); const send = () => { if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return; diff --git a/web/src/components/GitPanel.tsx b/web/src/components/GitPanel.tsx index 32340d1..e48a1b5 100644 --- a/web/src/components/GitPanel.tsx +++ b/web/src/components/GitPanel.tsx @@ -207,6 +207,13 @@ export function GitPanel({ setAmend(e.target.checked)} /> {t("git.amend")} +
+ {settings.permission_mode !== "bypass" &&
{t("jobEditor.bypassHint")}
}
diff --git a/web/src/components/Jobs.tsx b/web/src/components/Jobs.tsx index a11e42e..d075382 100644 --- a/web/src/components/Jobs.tsx +++ b/web/src/components/Jobs.tsx @@ -17,7 +17,7 @@ export interface JobInfo { next_run?: number | null; error?: string; webhook?: boolean; - spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string; assistant_session?: string | null }; + spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; notify?: boolean; prompt: string; assistant_session?: string | null }; } const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–"); diff --git a/web/src/components/PublicChat.tsx b/web/src/components/PublicChat.tsx new file mode 100644 index 0000000..d77832a --- /dev/null +++ b/web/src/components/PublicChat.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import { get } from "../api"; +import { fmtMsgTime, useT } from "../i18n"; +import { Markdown } from "./Markdown"; + +interface Shared { + title: string; + messages: { id: string; role: "user" | "assistant"; text: string; created_at: number }[]; +} + +/** A chat opened through a read-only link (no account needed). */ +export function PublicChat({ token }: { token: string }) { + const t = useT(); + const [chat, setChat] = useState(); + const [error, setError] = useState(); + useEffect(() => { + get(`/public/chat/${encodeURIComponent(token)}`) + .then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`))) + .catch((e) => setError(e.message)); + }, [token]); + if (error) return
{error}
; + if (!chat) return
{t("common.loading")}
; + return ( +
+
+

{chat.title || "Chat"}

+
{t("link.readOnly")}
+
+
+ {chat.messages.map((m) => ( +
+ +
+ {fmtMsgTime(m.created_at)} +
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index e65d9f1..70e39cc 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -146,8 +146,11 @@ function VersionInfo() { function DigestToggle() { const t = useT(); const [on, setOn] = useState(); + const [weekly, setWeekly] = useState(false); useEffect(() => { - get<{ enabled: boolean }>("/push/digest").then((r) => setOn(r.enabled)).catch(() => {}); + get<{ enabled: boolean; weekly?: boolean }>("/push/digest") + .then((r) => (setOn(r.enabled), setWeekly(!!r.weekly))) + .catch(() => {}); }, []); if (on === undefined) return null; return ( @@ -164,6 +167,30 @@ function DigestToggle() { /> {t("settings.digest")} + + {weekly && ( + + )} {on && ( + +
+ ))} +
+ + +
+ + ); +} + /** Choose users a session is shared with (read-only for them). */ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) { const t = useT(); @@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose ))} + {error &&
{error}
}
diff --git a/web/src/components/Stats.tsx b/web/src/components/Stats.tsx index 18a3dee..92d0c4b 100644 --- a/web/src/components/Stats.tsx +++ b/web/src/components/Stats.tsx @@ -1,5 +1,5 @@ import { Icon } from "./Icon"; -import { useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { useFetch } from "../hooks"; import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n"; import { navigate } from "../router"; @@ -15,6 +15,9 @@ interface StatsData { total: Aggregate; by_day: Row<"day">[]; by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[]; + by_month: Row<"month">[]; + /** First recorded request (any range): the years to choose from. */ + first_ts: number | null; by_model: Row<"model">[]; by_provider: Row<"provider">[]; by_agent: Row<"agent">[]; @@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [ type HeatMetric = "requests" | "tokens" | "cost"; +/** All months (YYYY-MM, local time) from the month of `fromMs` to the month of `toMs`. */ +function monthRange(fromMs: number, toMs: number): string[] { + const out: string[] = []; + const d = new Date(fromMs); + d.setDate(1); + d.setHours(0, 0, 0, 0); + for (; d.getTime() <= toMs && out.length < 600; d.setMonth(d.getMonth() + 1)) out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + return out; +} + function dayKey(ms: number) { const d = new Date(ms); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; @@ -51,6 +64,44 @@ function dayRange(fromMs: number, toMs: number, firstDataDay?: string): string[] return out; } +const FOLD_KEY = "codecraft.statsFolded"; + +function foldedSet(): Set { + try { + return new Set(JSON.parse(localStorage.getItem(FOLD_KEY) ?? "[]")); + } catch { + return new Set(); + } +} + +/** Panel with a title that folds the content; the state is remembered per device. */ +function Fold({ id, title, children }: { id: string; title: React.ReactNode; children: React.ReactNode }) { + const [closed, setClosed] = useState(() => foldedSet().has(id)); + const toggle = () => { + const next = !closed; + setClosed(next); + try { + const set = foldedSet(); + if (next) set.add(id); + else set.delete(id); + localStorage.setItem(FOLD_KEY, JSON.stringify([...set])); + } catch { + /* storage unavailable */ + } + }; + return ( +
+

+ +

+ {!closed && children} +
+ ); +} + function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) { return (
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) { return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–"; } -function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) { +function AggTable({ + rows, + keyName, + label, + currency, + link, + group, +}: { + rows: (Aggregate & Record)[]; + keyName: string; + label: string; + currency: string; + link?: (r: any) => void; + /** Group key of a row (e.g. the provider of a model): groups fold. */ + group?: (r: any) => string; +}) { const t = useT(); + const [shut, setShut] = useState>(new Set()); + const groups = group ? [...new Set(rows.map(group))] : []; + const row = (r: (typeof rows)[number]) => ( +
+ + + + + + + + + + + + ); return (
{link ? (e.preventDefault(), link(r))}>{r[keyName]} : r[keyName]}{fmtNumber(r.requests)}{fmtCompact(r.input_tokens + r.cache_write_tokens)}{fmtCompact(r.cache_read_tokens)}{fmtCompact(r.output_tokens)}{fmtNumber(r.avg_ttft_ms)} ms{fmtNumber(r.output_tps, 1)}{fmtNumber(r.prefill_tps)}{fmtCost(r.cost, currency)}{perMillion(r, currency)}
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & - {rows.map((r) => ( - - - - - - - - - - - - - ))} + {group + ? groups.map((g) => { + const members = rows.filter((r) => group(r) === g); + const open = !shut.has(g); + return ( + + + + + + + {open && members.map(row)} + + ); + }) + : rows.map(row)}
{link ? (e.preventDefault(), link(r))}>{r[keyName]} : r[keyName]}{fmtNumber(r.requests)}{fmtCompact(r.input_tokens + r.cache_write_tokens)}{fmtCompact(r.cache_read_tokens)}{fmtCompact(r.output_tokens)}{fmtNumber(r.avg_ttft_ms)} ms{fmtNumber(r.output_tps, 1)}{fmtNumber(r.prefill_tps)}{fmtCost(r.cost, currency)}{perMillion(r, currency)}
+ + {fmtNumber(members.reduce((a, r) => a + r.requests, 0))} + {fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)} +
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) { const isAdmin = useStore((s) => s.me?.role === "admin"); const users = useFetch(isAdmin ? "/users" : null).data ?? []; const [userFilter, setUserFilter] = useState(""); - const days = RANGES.find((r) => r.key === range)!.days; const tz = -new Date().getTimezoneOffset(); // Stable per range: rounded to the minute so the URL doesn't change on every render. const now = Math.floor(Date.now() / 60000) * 60000 + 60000; - const from = days ? now - days * 86400000 : 0; - const { data, error } = useFetch(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); + // "y2025": that calendar year (local time); otherwise a rolling window. + const year = /^y(\d{4})$/.test(range) ? Number(range.slice(1)) : 0; + const days = RANGES.find((r) => r.key === range)?.days ?? 0; + const from = year ? new Date(year, 0, 1).getTime() : days ? now - days * 86400000 : 0; + const to = year ? Math.min(now, new Date(year + 1, 0, 1).getTime()) : now; + const { data, error } = useFetch(`/stats?from=${from}&to=${to}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`); + /** Download of what is shown: all tables as XLSX, or one table as CSV. */ + const exportAs = (what: string) => { + if (!what) return; + const q = `from=${from}&to=${to}&tz=${tz}&lang=${getLang()}${userFilter ? `&user=${userFilter}` : ""}`; + const a = document.createElement("a"); + a.href = `/api/stats/export?${q}&format=${what === "xlsx" ? "xlsx" : "csv&table=" + what.slice(4)}`; + a.download = ""; + document.body.appendChild(a); + a.click(); + a.remove(); + }; + // Years since the first recorded request (the data survives indefinitely). + const [firstYear, setFirstYear] = useState(new Date().getFullYear()); + useEffect(() => { + if (data?.first_ts) setFirstYear(new Date(data.first_ts).getFullYear()); + }, [data?.first_ts]); + const years = Array.from({ length: new Date().getFullYear() - firstYear + 1 }, (_, i) => new Date().getFullYear() - i); + const spanDays = ((data?.to ?? now) - (data?.from || data?.first_ts || now)) / 86400000; + // Long ranges: one bar per month instead of one per day. + const monthly = spanDays > 120; const view = useMemo(() => { if (!data) return undefined; - const cats = dayRange(data.from, data.to, data.by_day[0]?.day); + const bucket = (day: string) => (monthly ? day.slice(0, 7) : day); + const cats = monthly ? monthRange(data.from || data.first_ts || data.to, data.to) : dayRange(data.from, data.to, data.by_day[0]?.day); // Up to 7 models by cost get their own color; the rest fold into "Other". const ranked = [...data.by_model].sort((a, b) => b.cost - a.cost || b.requests - a.requests); const named = ranked.slice(0, ranked.length > 8 ? 7 : 8).map((m) => m.model); @@ -135,7 +254,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) { const costByDay = cats.map(() => ({}) as Record); const idx = new Map(cats.map((c, i) => [c, i])); for (const r of data.by_day_model) { - const i = idx.get(r.day); + const i = idx.get(bucket(r.day)); if (i === undefined) continue; const key = named.includes(r.model) ? r.model : other; costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost; @@ -146,7 +265,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) { { key: "output", label: t("stats.output"), color: SERIES[2] }, ]; const tokensByDay = cats.map((c): Record => { - const d = data.by_day.find((x) => x.day === c); + const d = monthly ? data.by_month.find((x) => x.month === c) : data.by_day.find((x) => x.day === c); return d ? { input: d.input_tokens + d.cache_write_tokens, cache: d.cache_read_tokens, output: d.output_tokens } : {}; }); // Rolling window: the last 14 days, today at the bottom. @@ -174,10 +293,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) { return { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) }; }); return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows }; - }, [data, heat, t]); + }, [data, heat, t, monthly]); const dayLabel = (d: string) => - new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`)); + monthly + ? new Intl.DateTimeFormat(getLang(), { month: "short", year: "2-digit" }).format(new Date(`${d}-01T00:00:00`)) + : new Intl.DateTimeFormat(getLang(), { day: "numeric", month: "short" }).format(new Date(`${d}T00:00:00`)); const cur = data?.currency ?? "USD"; const tot = data?.total; const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v)); @@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) { ))} + {years.length > 1 && ( + + )} {isAdmin && ( )} + @@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) { {tot.requests === 0 ? (
{t("stats.noData")}
) : tableView ? ( -
-

{t("stats.perDay")}

+ -
+ ) : ( <> -
-

{t("stats.costPerDay")}

+ fmtCost(v, cur)} /> -
-
-

{t("stats.tokensPerDay")}

+ + -
-
-

- {t("stats.heatmap")} - - setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}> -

+
- + )} {tot.requests > 0 && ( <> -
-

{t("stats.byModel")}

- -
-
-

{t("stats.byProvider")}

+ + String(r.model).split("/")[0]} /> + + -
-
-

{t("stats.byAgent")}

+ + -
+ {isAdmin && !userFilter && data.by_user.length > 1 && ( -
-

{t("stats.byUser")}

+ -
+ )} -
-

{t("stats.topSessions")}

+ !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} /> -
+ )} diff --git a/web/src/components/Voice.tsx b/web/src/components/Voice.tsx index 1df20b4..9ae5eb7 100644 --- a/web/src/components/Voice.tsx +++ b/web/src/components/Voice.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { getLang, useT } from "../i18n"; import { toast } from "../dialogs"; +import { get } from "../api"; import { Icon } from "./Icon"; /* Web Speech API (Chrome/Edge/Safari; Firefox has none). */ @@ -18,10 +19,86 @@ const Impl: (new () => Recognition) | undefined = (window as any).SpeechRecognit export const voiceSupported = () => !!Impl; +/** Server-side transcription is on (server.transcribe_model) – for browsers without dictation. */ +function useServerSpeech(): boolean { + const [on, setOn] = useState(false); + useEffect(() => { + if (Impl) return; + get<{ transcribe?: boolean }>("/config") + .then((c) => setOn(!!c.transcribe)) + .catch(() => undefined); + }, []); + return on; +} + +/** Record with the microphone; the audio is transcribed by the server. */ +function RecorderButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { + const t = useT(); + const [state, setState] = useState<"idle" | "recording" | "sending">("idle"); + const rec = useRef(null); + useEffect(() => () => rec.current?.stream.getTracks().forEach((x) => x.stop()), []); + + const toggle = async () => { + if (state === "recording") { + rec.current?.stop(); + return; + } + if (state === "sending") return; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const r = new MediaRecorder(stream); + const chunks: Blob[] = []; + r.ondataavailable = (e) => e.data.size && chunks.push(e.data); + r.onstop = async () => { + stream.getTracks().forEach((x) => x.stop()); + setState("sending"); + onInterim(t("voice.transcribing")); + try { + const blob = new Blob(chunks, { type: r.mimeType || "audio/webm" }); + const res = await fetch(`/api/transcribe?lang=${getLang()}`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": blob.type }, body: blob }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error ?? data.message ?? `HTTP ${res.status}`); + if (data.text) onText(String(data.text)); + } catch (e: any) { + toast(`${t("voice.error")}: ${e.message}`); + } finally { + onInterim(""); + setState("idle"); + } + }; + rec.current = r; + r.start(); + setState("recording"); + } catch { + toast(t("voice.denied")); + } + }; + + return ( + + ); +} + const LOCALES: Record = { de: "de-DE", en: "en-US" }; /** Microphone button: dictated text is appended to the message. */ -export function VoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { +export function VoiceButton(props: { onText: (text: string) => void; onInterim: (text: string) => void }) { + const server = useServerSpeech(); + if (!Impl) return server ? : null; + return ; +} + +function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) { const t = useT(); const [listening, setListening] = useState(false); const rec = useRef(null); diff --git a/web/src/components/settings/Account.tsx b/web/src/components/settings/Account.tsx index 2d141b9..7f0da0c 100644 --- a/web/src/components/settings/Account.tsx +++ b/web/src/components/settings/Account.tsx @@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount"; import { ApiTokens } from "./ApiTokens"; import { CliInstall } from "./CliInstall"; import { Memory } from "./Memory"; +import { Snippets } from "./Snippets"; import { ClaudeCode } from "./ClaudeCode"; import { Telegram } from "./Telegram"; @@ -51,6 +52,7 @@ export function Account() {
{t("account.hint")}
+ diff --git a/web/src/components/settings/Snippets.tsx b/web/src/components/settings/Snippets.tsx new file mode 100644 index 0000000..e2cc180 --- /dev/null +++ b/web/src/components/settings/Snippets.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { get, put } from "../../api"; +import { useT } from "../../i18n"; +import { toast } from "../../dialogs"; +import { Icon } from "../Icon"; + +export interface Snippet { + id?: string; + name: string; + text: string; +} + +/** Personal prompt snippets: typed as `/name` in the composer. */ +export function Snippets() { + const t = useT(); + const [items, setItems] = useState(); + useEffect(() => { + get("/account/snippets") + .then(setItems) + .catch((e) => toast(e.message)); + }, []); + if (!items) return null; + const set = (i: number, patch: Partial) => setItems(items.map((s, j) => (j === i ? { ...s, ...patch } : s))); + const save = () => { + const clean = items.filter((s) => s.name.trim() || s.text.trim()).map((s) => ({ ...s, name: s.name.trim().replace(/^\/+/, "").replace(/\s+/g, "-") })); + put("/account/snippets", clean) + .then((r) => (setItems(r), toast(t("settings.saved"), "success"))) + .catch((e) => toast(e.message)); + }; + return ( +
+

+ {t("snippets.title")} +

+
+ {t("snippets.hint")} +
+ {items.map((s, i) => ( +
+
+ / + set(i, { name: e.target.value })} aria-label={t("snippets.name")} /> + +
+