Stats history with year filter, folding and XLSX/CSV export; snippets, read-only links, commit suggestions, weekly review, offline copies, server speech-to-text, planning model, job reports; fal.ai health fix

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-15 08:20:52 +01:00
1 parent c08c6c7436
commit 388ca96419
31 files changed
+1518 -82

No files matched your search

+186 -9
View File
@@ -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<User>, State(app): State<AppState
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
if !user.is_admin() {
// Users only need defaults, not the configuration.
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency})));
return Ok(Json(json!({"default_model": default_model, "small_model": cfg.server.small_model, "currency": cfg.server.currency, "transcribe": !cfg.server.transcribe_model.is_empty()})));
}
Ok(Json(json!({
"config": config::redacted_json(&cfg),
@@ -329,6 +335,7 @@ async fn get_config(Extension(user): Extension<User>, State(app): State<AppState
"default_model": default_model,
"small_model": cfg.server.small_model,
"currency": cfg.server.currency,
"transcribe": !cfg.server.transcribe_model.is_empty(),
})))
}
@@ -1318,6 +1325,117 @@ async fn session_stats(Extension(user): Extension<User>, State(app): State<AppSt
Ok(Json(v))
}
// --- speech to text -----------------------------------------------------------
#[derive(Deserialize)]
struct TranscribeQ {
lang: Option<String>,
}
/// 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<AppState>, Query(q): Query<TranscribeQ>, 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::<String>())));
}
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<i64>,
}
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<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
let base = public_base(&app, &headers);
let now = now_ms();
let links: Vec<Value> = 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<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Path(id): Path<String>, body: Option<Json<LinkBody>>) -> 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<User>, State(app): State<AppState>, 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<AppState>, Path(token): Path<String>) -> 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<Value> = 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::<Vec<_>>()
.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<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
writable(&app, &user, &id)?;
Ok(Json(json!(app.store.shares(&id)?)))
@@ -1968,6 +2086,45 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
Ok(Json(v))
}
#[derive(Deserialize)]
struct StatsExportQuery {
from: Option<i64>,
to: Option<i64>,
tz: Option<i64>,
user: Option<String>,
/// "xlsx" (all tables) or "csv" (one table: see `table`).
format: Option<String>,
/// csv: total, day, month, model, provider, agent, user, session (default: day).
table: Option<String>,
lang: Option<String>,
}
/// The statistics of a range as a download.
async fn stats_export(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<StatsExportQuery>) -> Result<Response, ApiError> {
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<u8>, &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<String>,
permission_mode: Option<String>,
timeout_minutes: Option<u64>,
notify: Option<bool>,
}
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> 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<User>, State(app): State<AppState
if let Some(x) = b.timeout_minutes {
st.timeout_minutes = x;
}
if let Some(x) = b.notify {
st.notify = x;
}
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.store.job_set_settings(&job.id, &st)?;
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
@@ -2360,22 +2521,38 @@ async fn push_unsubscribe(Extension(user): Extension<User>, State(app): State<Ap
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> 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<bool>,
weekly: Option<bool>,
}
async fn put_digest(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<DigestBody>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Query(q): Query<DigestQ>) -> 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"]})))
}