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:
1 parent
c08c6c7436
commit
388ca96419
31 files changed
+1518
-82
No files matched your search
+186
-9
@@ -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"]})))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user