Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec95f9b8ad | ||
|
|
99ba3835f3 | ||
|
|
92a99c6a65 | ||
|
|
4c7d67cabf | ||
|
|
4423148552 | ||
|
|
388ca96419 | ||
|
|
c08c6c7436 | ||
|
|
1b3e3af4b7 | ||
|
|
44dd6978d4 | ||
|
|
8a90f83db5 | ||
|
|
ce0d8afe69 |
No files matched your search
@@ -3,6 +3,66 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00031] – 2026-11-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
|
||||||
|
|
||||||
|
## [v202611-00030] – 2026-11-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
|
||||||
|
the cut. Also starts a fresh session with Claude Code.
|
||||||
|
|
||||||
|
## [v202611-00029] – 2026-11-15
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
|
||||||
|
star, globe, calendar, car, plane, bulb, money, palette).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
|
||||||
|
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
|
||||||
|
|
||||||
|
## [v202611-00027] – 2026-11-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Chat bubbles:** your messages on the right in their own colour (the chat's colour tag if it
|
||||||
|
has one), the agent's answers on the left; tool-only steps and system notes stay without bubble.
|
||||||
|
- **Icons for chats:** pick one of 22 icons in the ⋯ menu (chat and session list); it shows in the
|
||||||
|
list, the top bar and the project page, in the chat's colour, and is part of the project default.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Colour tags are more vivid and more distinct:** eight fixed hues (new: pink), drawn with a
|
||||||
|
lightness that fits the theme (light/dark) and the highest chroma sRGB allows, so no two tags
|
||||||
|
look alike; backgrounds are tinted more strongly. Browsers without relative colours keep the
|
||||||
|
theme's own colours.
|
||||||
|
|
||||||
## [v202611-00026] – 2026-11-14
|
## [v202611-00026] – 2026-11-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00026" src="https://img.shields.io/badge/version-v202611--00026-d69e2e?style=flat-square">
|
<img alt="Version v202611-00031" src="https://img.shields.io/badge/version-v202611--00031-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
|
|||||||
+31
-1
@@ -193,10 +193,20 @@ pub struct Session {
|
|||||||
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
|
/// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
/// Icon of the chat (a name from `SESSION_ICONS`), shown in the list and the top bar.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Names of the colour tags: the client draws them with the colours of the active theme.
|
/// Names of the colour tags: the client draws them with the colours of the active theme.
|
||||||
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple"];
|
pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"];
|
||||||
|
|
||||||
|
/// Icons a chat can get (names of the client's icon set).
|
||||||
|
pub const SESSION_ICONS: &[&str] = &[
|
||||||
|
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
|
||||||
|
"users", "toolbox", "plug", "scroll", "eye", "template",
|
||||||
|
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||||
|
];
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn clean_room(&self, default: bool) -> bool {
|
pub fn clean_room(&self, default: bool) -> bool {
|
||||||
@@ -290,3 +300,23 @@ pub fn new_id() -> String {
|
|||||||
pub fn now_ms() -> i64 {
|
pub fn now_ms() -> i64 {
|
||||||
chrono::Utc::now().timestamp_millis()
|
chrono::Utc::now().timestamp_millis()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod icon_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
|
||||||
|
#[test]
|
||||||
|
fn session_icons_match_the_client() {
|
||||||
|
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
|
||||||
|
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
|
||||||
|
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
|
||||||
|
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
|
||||||
|
client.sort();
|
||||||
|
server.sort();
|
||||||
|
assert_eq!(client, server);
|
||||||
|
let mut dedup = server.clone();
|
||||||
|
dedup.dedup();
|
||||||
|
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
|
||||||
|
}
|
||||||
|
}
|
||||||
+204
-10
@@ -7,7 +7,7 @@ use crate::config::{self, ConfigPatch};
|
|||||||
use crate::prompts;
|
use crate::prompts;
|
||||||
use crate::runner;
|
use crate::runner;
|
||||||
use crate::store::User;
|
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::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
@@ -72,6 +72,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/agents/tools", get(agent_tools))
|
.route("/agents/tools", get(agent_tools))
|
||||||
.route("/agents/{name}", delete(delete_agent))
|
.route("/agents/{name}", delete(delete_agent))
|
||||||
.route("/commands", get(commands))
|
.route("/commands", get(commands))
|
||||||
|
.route("/transcribe", post(transcribe))
|
||||||
.route("/sessions", get(list_sessions).post(create_session))
|
.route("/sessions", get(list_sessions).post(create_session))
|
||||||
.route("/sessions/search", get(search_sessions))
|
.route("/sessions/search", get(search_sessions))
|
||||||
.route("/sessions/{id}/fork", post(fork_session))
|
.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", get(list_shells))
|
||||||
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
.route("/sessions/{id}/shells/{shell}/kill", post(kill_shell))
|
||||||
.route("/sessions/{id}/stats", get(session_stats))
|
.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}/shares", get(get_shares).put(put_shares))
|
||||||
.route("/sessions/{id}/queue", get(get_queue))
|
.route("/sessions/{id}/queue", get(get_queue))
|
||||||
.route("/sessions/{id}/queue/flush", post(flush_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("/git", post(crate::git::handle))
|
||||||
.route("/fs/dirs", get(list_dirs))
|
.route("/fs/dirs", get(list_dirs))
|
||||||
.route("/stats", get(stats))
|
.route("/stats", get(stats))
|
||||||
|
.route("/stats/export", get(stats_export))
|
||||||
.route("/budget", get(budget))
|
.route("/budget", get(budget))
|
||||||
.route("/audit", get(audit_log))
|
.route("/audit", get(audit_log))
|
||||||
.route("/backup", get(backup))
|
.route("/backup", get(backup))
|
||||||
@@ -152,6 +157,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/images/{id}", delete(delete_image))
|
.route("/images/{id}", delete(delete_image))
|
||||||
.route("/images/{id}/file", get(image_file))
|
.route("/images/{id}/file", get(image_file))
|
||||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
.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/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/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))
|
.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()) };
|
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() {
|
if !user.is_admin() {
|
||||||
// Users only need defaults, not the configuration.
|
// 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!({
|
Ok(Json(json!({
|
||||||
"config": config::redacted_json(&cfg),
|
"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,
|
"default_model": default_model,
|
||||||
"small_model": cfg.server.small_model,
|
"small_model": cfg.server.small_model,
|
||||||
"currency": cfg.server.currency,
|
"currency": cfg.server.currency,
|
||||||
|
"transcribe": !cfg.server.transcribe_model.is_empty(),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,6 +654,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
|
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
|
||||||
|
icon: dstr("icon").filter(|c| codecraft_core::types::SESSION_ICONS.contains(&c.as_str())),
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
@@ -942,6 +950,8 @@ struct UpdateSession {
|
|||||||
telegram_quiet: Option<bool>,
|
telegram_quiet: Option<bool>,
|
||||||
/// Colour tag ("" removes it).
|
/// Colour tag ("" removes it).
|
||||||
color: Option<String>,
|
color: Option<String>,
|
||||||
|
/// Icon ("" removes it).
|
||||||
|
icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
|
||||||
@@ -957,6 +967,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
|
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
|
||||||
}
|
}
|
||||||
|
if body.icon.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_ICONS.contains(&c)) {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid icon".into()));
|
||||||
|
}
|
||||||
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
|
||||||
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
|
||||||
}
|
}
|
||||||
@@ -999,6 +1012,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
if let Some(c) = body.color {
|
if let Some(c) = body.color {
|
||||||
s.color = (!c.is_empty()).then_some(c);
|
s.color = (!c.is_empty()).then_some(c);
|
||||||
}
|
}
|
||||||
|
if let Some(i) = body.icon {
|
||||||
|
s.icon = (!i.is_empty()).then_some(i);
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(Json(json!(s)))
|
Ok(Json(json!(s)))
|
||||||
}
|
}
|
||||||
@@ -1112,6 +1128,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
|
|||||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||||
return Ok(json!({"ok": true, "command": "compact"}));
|
return Ok(json!({"ok": true, "command": "compact"}));
|
||||||
}
|
}
|
||||||
|
if text == "/clear" {
|
||||||
|
if app.is_busy(&id) {
|
||||||
|
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||||
|
}
|
||||||
|
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||||
|
return Ok(json!({"ok": true, "command": "clear"}));
|
||||||
|
}
|
||||||
let cwd = PathBuf::from(&session.cwd);
|
let cwd = PathBuf::from(&session.cwd);
|
||||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||||
let mut prompt_text = text.clone();
|
let mut prompt_text = text.clone();
|
||||||
@@ -1309,6 +1332,117 @@ async fn session_stats(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
Ok(Json(v))
|
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 {
|
async fn get_shares(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||||
writable(&app, &user, &id)?;
|
writable(&app, &user, &id)?;
|
||||||
Ok(Json(json!(app.store.shares(&id)?)))
|
Ok(Json(json!(app.store.shares(&id)?)))
|
||||||
@@ -1454,7 +1588,7 @@ async fn put_project_defaults(Extension(user): Extension<User>, State(app): Stat
|
|||||||
let s = writable(&app, &user, &id)?;
|
let s = writable(&app, &user, &id)?;
|
||||||
let data = json!({
|
let data = json!({
|
||||||
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
|
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode,
|
||||||
"color": s.color, "instructions": s.instructions, "folder": s.folder,
|
"color": s.color, "icon": s.icon, "instructions": s.instructions, "folder": s.folder,
|
||||||
});
|
});
|
||||||
app.store.set_project_defaults(&s.cwd, Some(&data))?;
|
app.store.set_project_defaults(&s.cwd, Some(&data))?;
|
||||||
Ok(Json(data))
|
Ok(Json(data))
|
||||||
@@ -1959,6 +2093,45 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
|
|||||||
Ok(Json(v))
|
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 --------------------------------------------------------------
|
// --- websocket --------------------------------------------------------------
|
||||||
|
|
||||||
// --- automations (scheduled agents) ------------------------------------------------
|
// --- automations (scheduled agents) ------------------------------------------------
|
||||||
@@ -2048,12 +2221,13 @@ struct UpdateJob {
|
|||||||
model: Option<String>,
|
model: Option<String>,
|
||||||
permission_mode: Option<String>,
|
permission_mode: Option<String>,
|
||||||
timeout_minutes: Option<u64>,
|
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 {
|
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 = own_job(&app, &user, &id)?;
|
||||||
let job = crate::jobs::adopt(&app.store, job).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
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 {
|
if changes {
|
||||||
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
|
let mut st = job.settings.clone().unwrap_or_else(|| crate::jobs::JobSettings::new("automation"));
|
||||||
if let Some(x) = b.name {
|
if let Some(x) = b.name {
|
||||||
@@ -2077,6 +2251,9 @@ async fn update_job(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
if let Some(x) = b.timeout_minutes {
|
if let Some(x) = b.timeout_minutes {
|
||||||
st.timeout_minutes = x;
|
st.timeout_minutes = x;
|
||||||
}
|
}
|
||||||
|
if let Some(x) = b.notify {
|
||||||
|
st.notify = x;
|
||||||
|
}
|
||||||
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
st.validate().map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
app.store.job_set_settings(&job.id, &st)?;
|
app.store.job_set_settings(&job.id, &st)?;
|
||||||
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
|
app.audit(Some(&user), "job.settings", &job.path, &st.schedule);
|
||||||
@@ -2145,6 +2322,7 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
color: None,
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let s = app.save_session(s)?;
|
let s = app.save_session(s)?;
|
||||||
st.assistant_session = Some(s.id.clone());
|
st.assistant_session = Some(s.id.clone());
|
||||||
@@ -2350,22 +2528,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 {
|
async fn get_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
let on: bool = app.store.user_setting(&user.id, "cost_push")?;
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct DigestBody {
|
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 {
|
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)?;
|
if let Some(v) = b.enabled {
|
||||||
Ok(Json(json!({"enabled": 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).
|
/// Send today's summary right away (preview).
|
||||||
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
async fn send_digest(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<DigestQ>) -> 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"}));
|
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;
|
let (sent, errors) = crate::push::send_to_user(&app, &user.id, &payload).await;
|
||||||
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
|
Ok(Json(json!({"sent": sent, "errors": errors, "body": payload["body"]})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|p| path.ends_with(p))
|
.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<Arc<App>>, mut req: Request, next: Next) -> Response {
|
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::config::ModelConfig;
|
|||||||
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
|
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
|
||||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||||
use crate::store::UsageRecord;
|
use crate::store::UsageRecord;
|
||||||
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -286,6 +286,20 @@ fn result_text(content: &Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
|
||||||
|
fn result_images(content: &Value) -> Vec<ImageData> {
|
||||||
|
content
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
|
||||||
|
.filter_map(|b| {
|
||||||
|
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
|
||||||
|
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
|
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
|
||||||
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -458,7 +472,7 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
|||||||
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||||
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
||||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
||||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
|
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
||||||
}
|
}
|
||||||
if !parts.is_empty() {
|
if !parts.is_empty() {
|
||||||
let m = Message::new(Role::User, parts);
|
let m = Message::new(Role::User, parts);
|
||||||
@@ -852,6 +866,15 @@ pub async fn update() -> Result<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn tool_result_images_are_kept() {
|
||||||
|
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
||||||
|
let imgs = result_images(&content);
|
||||||
|
assert_eq!(imgs.len(), 1);
|
||||||
|
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
|
||||||
|
assert!(result_images(&json!("plain text")).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -231,6 +231,29 @@ pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
Ok(Json(b))
|
Ok(Json(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Personal prompt snippets (`/name` in the composer): [{id, name, text}].
|
||||||
|
pub async fn get_snippets(Extension(user): Extension<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(b): Json<Value>) -> 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<Value> = 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
|
/// 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]}.
|
/// every device shows the same order: {"order": [group ids], "pinned": [group ids]}.
|
||||||
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
pub async fn get_sidebar(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
|||||||
@@ -302,6 +302,9 @@ pub struct ServerConfig {
|
|||||||
pub data_dir: String,
|
pub data_dir: String,
|
||||||
pub default_model: String,
|
pub default_model: String,
|
||||||
pub small_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,
|
pub auto_compact_threshold: f64,
|
||||||
/// Currency label for cost statistics (display only).
|
/// Currency label for cost statistics (display only).
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
@@ -337,6 +340,7 @@ impl Default for ServerConfig {
|
|||||||
data_dir: "./data".into(),
|
data_dir: "./data".into(),
|
||||||
default_model: String::new(),
|
default_model: String::new(),
|
||||||
small_model: String::new(),
|
small_model: String::new(),
|
||||||
|
transcribe_model: String::new(),
|
||||||
auto_compact_threshold: 0.85,
|
auto_compact_threshold: 0.85,
|
||||||
currency: "USD".into(),
|
currency: "USD".into(),
|
||||||
max_parallel_subagents: 4,
|
max_parallel_subagents: 4,
|
||||||
|
|||||||
+49
-1
@@ -2,7 +2,7 @@
|
|||||||
//! user via the user setting "cost_push").
|
//! user via the user setting "cost_push").
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use chrono::{Local, Timelike};
|
use chrono::{Datelike, Local, Timelike};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -54,11 +54,59 @@ pub fn summary(app: &App, user_id: &str) -> Option<Value> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last seven days for one user (None when nothing happened).
|
||||||
|
pub fn weekly(app: &App, user_id: &str) -> Option<Value> {
|
||||||
|
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<App>) {
|
pub fn spawn(app: Arc<App>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
let now = Local::now();
|
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 {
|
if now.hour() != HOUR {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,15 @@ async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult {
|
|||||||
git_ok(&repo, &args, 60).await?;
|
git_ok(&repo, &args, 60).await?;
|
||||||
json!({"ok": true})
|
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" => {
|
"commit" => {
|
||||||
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
|
let msg = b.message.as_deref().unwrap_or("").trim().to_string();
|
||||||
if msg.is_empty() && !b.amend {
|
if msg.is_empty() && !b.amend {
|
||||||
|
|||||||
+13
-2
@@ -42,8 +42,9 @@ pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderSta
|
|||||||
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
|
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
|
||||||
Err(_) => (Some("timed out".to_string()), 0),
|
Err(_) => (Some("timed out".to_string()), 0),
|
||||||
};
|
};
|
||||||
// The host answered but has no model list: reachable.
|
// The host answered but has no model list: reachable. (fal.ai's router answers 403
|
||||||
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
|
// "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 {
|
let state = match &error {
|
||||||
None => "ok",
|
None => "ok",
|
||||||
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
||||||
@@ -83,3 +84,13 @@ pub fn spawn_checker(app: Arc<App>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,13 @@ pub struct JobSettings {
|
|||||||
/// Chat in which the user works out the prompt with a model.
|
/// Chat in which the user works out the prompt with a model.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub assistant_session: Option<String>,
|
pub assistant_session: Option<String>,
|
||||||
|
/// Push notification with the report when a run ends.
|
||||||
|
#[serde(default = "default_notify")]
|
||||||
|
pub notify: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_notify() -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobSettings {
|
impl JobSettings {
|
||||||
@@ -81,6 +88,7 @@ impl JobSettings {
|
|||||||
timeout_minutes: default_timeout(),
|
timeout_minutes: default_timeout(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
assistant_session: None,
|
assistant_session: None,
|
||||||
|
notify: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +129,7 @@ pub struct JobSpec {
|
|||||||
pub cwd: Option<String>,
|
pub cwd: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub assistant_session: Option<String>,
|
pub assistant_session: Option<String>,
|
||||||
|
pub notify: bool,
|
||||||
pub prompt: String,
|
pub prompt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +144,7 @@ impl JobSpec {
|
|||||||
timeout_minutes: s.timeout_minutes,
|
timeout_minutes: s.timeout_minutes,
|
||||||
cwd: s.cwd.clone(),
|
cwd: s.cwd.clone(),
|
||||||
assistant_session: s.assistant_session.clone(),
|
assistant_session: s.assistant_session.clone(),
|
||||||
|
notify: s.notify,
|
||||||
prompt,
|
prompt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +159,7 @@ impl JobSpec {
|
|||||||
timeout_minutes: self.timeout_minutes,
|
timeout_minutes: self.timeout_minutes,
|
||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
assistant_session: self.assistant_session.clone(),
|
assistant_session: self.assistant_session.clone(),
|
||||||
|
notify: self.notify,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +229,7 @@ pub fn parse(path: &Path) -> Result<JobSpec> {
|
|||||||
timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60),
|
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()),
|
cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()),
|
||||||
assistant_session: None,
|
assistant_session: None,
|
||||||
|
notify: meta.get("notify").map(|v| unquote(v) != "false").unwrap_or(true),
|
||||||
prompt: body.trim().to_string(),
|
prompt: body.trim().to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -323,6 +335,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
telegram_quiet: true,
|
telegram_quiet: true,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
color: None,
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let session = app.save_session(session)?;
|
let session = app.save_session(session)?;
|
||||||
app.store.job_started(&job.id, &session.id, now)?;
|
app.store.job_started(&job.id, &session.id, now)?;
|
||||||
@@ -341,6 +354,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
let job_id = job.id.clone();
|
let job_id = job.id.clone();
|
||||||
let sid = session.id.clone();
|
let sid = session.id.clone();
|
||||||
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
|
let timeout = Duration::from_secs(spec.timeout_minutes * 60);
|
||||||
|
let (spec_name, spec_notify) = (spec.name.clone(), spec.notify);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
@@ -348,15 +362,48 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
|
|||||||
if started.elapsed() > timeout {
|
if started.elapsed() > timeout {
|
||||||
crate::runner::abort(&app2, &sid);
|
crate::runner::abort(&app2, &sid);
|
||||||
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
|
let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout");
|
||||||
|
report(&app2, &sid, &spec_name, spec_notify, "timeout").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
|
let _ = app2.store.job_finished(&job_id, Some(&sid), "done");
|
||||||
|
report(&app2, &sid, &spec_name, spec_notify, "done").await;
|
||||||
});
|
});
|
||||||
Ok(session.id)
|
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::<Vec<_>>().join("\n"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let flat: String = last.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let body: String = if flat.chars().count() > 220 { format!("{}…", flat.chars().take(220).collect::<String>()) } 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.
|
/// Background scheduler: checks every 30 s which jobs are due.
|
||||||
pub fn spawn_scheduler(app: Arc<App>) {
|
pub fn spawn_scheduler(app: Arc<App>) {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod reverse;
|
|||||||
mod runner;
|
mod runner;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
mod shells;
|
mod shells;
|
||||||
|
mod stats_export;
|
||||||
mod store;
|
mod store;
|
||||||
mod telegram;
|
mod telegram;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
|
|||||||
@@ -899,6 +899,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
|
|||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
color: None,
|
color: None,
|
||||||
|
icon: None,
|
||||||
};
|
};
|
||||||
let child = app.save_session(child)?;
|
let child = app.save_session(child)?;
|
||||||
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
|
||||||
@@ -1247,6 +1248,58 @@ async fn side_task(app: &App, session: &Session, kind: &str, system: &str, input
|
|||||||
Ok(strip_think(&text))
|
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<String> {
|
||||||
|
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!("<recent-commits>\n{}\n</recent-commits>\n<staged-diff>\n{}\n</staged-diff>", 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 {
|
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::<Vec<_>>().join("\n")
|
m.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("\n")
|
||||||
}
|
}
|
||||||
@@ -1670,10 +1723,43 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
|
||||||
|
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
|
||||||
|
|
||||||
|
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
|
||||||
|
/// compaction without a summary: history before the marker is not sent any more.
|
||||||
|
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
|
||||||
|
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
|
||||||
|
app.update_session(session_id, |s| {
|
||||||
|
s.last_usage = None;
|
||||||
|
s.todos = Value::Null;
|
||||||
|
})?;
|
||||||
|
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||||
|
// Claude Code continues its own conversation: start a new one.
|
||||||
|
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
|
||||||
|
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cleared_marker_starts_a_new_history() {
|
||||||
|
let mk = |role, parts| Message::new(role, parts);
|
||||||
|
let all = vec![
|
||||||
|
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
|
||||||
|
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
|
||||||
|
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
|
||||||
|
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
|
||||||
|
];
|
||||||
|
let active = active_history(all);
|
||||||
|
assert_eq!(active.len(), 2);
|
||||||
|
assert!(!active.iter().any(|m| message_text(m).contains("old")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_keeps_head_and_tail_within_budget() {
|
fn fit_keeps_head_and_tail_within_budget() {
|
||||||
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
||||||
|
|||||||
@@ -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<Vec<Cell>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Cell> {
|
||||||
|
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<Table> {
|
||||||
|
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<String> = 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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="11" width="16" customWidth="1"/></cols><sheetData>"#);
|
||||||
|
for (r, row) in t.rows.iter().enumerate() {
|
||||||
|
x.push_str(&format!(r#"<row 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#"<c r="{at}" t="inlineStr"{style}><is><t xml:space="preserve">{}</t></is></c>"#, esc(s))),
|
||||||
|
Cell::Num(n) => x.push_str(&format!(r#"<c r="{at}"{style}><v>{n}</v></c>"#)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x.push_str("</row>");
|
||||||
|
}
|
||||||
|
x.push_str("</sheetData></worksheet>");
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#);
|
||||||
|
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#"<Override PartName="/xl/worksheets/sheet{n}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"#));
|
||||||
|
sheets.push_str(&format!(r#"<sheet name="{}" sheetId="{n}" r:id="rId{n}"/>"#, esc(&sheet_name(&t.title))));
|
||||||
|
rels.push_str(&format!(r#"<Relationship Id="rId{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{n}.xml"/>"#));
|
||||||
|
}
|
||||||
|
types.push_str("</Types>");
|
||||||
|
let styles_id = tables.len() + 1;
|
||||||
|
rels.push_str(&format!(r#"<Relationship Id="rId{styles_id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#));
|
||||||
|
put("[Content_Types].xml", &types)?;
|
||||||
|
put("_rels/.rels", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#)?;
|
||||||
|
put("xl/workbook.xml", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>{sheets}</sheets></workbook>"#))?;
|
||||||
|
put("xl/_rels/workbook.xml.rels", &format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>"#))?;
|
||||||
|
// Style 1 = bold header.
|
||||||
|
put("xl/styles.xml", r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders><cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs></styleSheet>"#)?;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,14 @@ impl Store {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (cwd, key)
|
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 (
|
CREATE TABLE IF NOT EXISTS project_defaults (
|
||||||
cwd TEXT PRIMARY KEY,
|
cwd TEXT PRIMARY KEY,
|
||||||
data TEXT NOT NULL
|
data TEXT NOT NULL
|
||||||
@@ -744,6 +752,9 @@ impl Store {
|
|||||||
|
|
||||||
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
|
let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')";
|
||||||
let by_day = grouped(day_sql, "day")?;
|
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<i64> = 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_model = grouped("provider || '/' || model", "model")?;
|
||||||
let by_provider = grouped("provider", "provider")?;
|
let by_provider = grouped("provider", "provider")?;
|
||||||
let by_agent = grouped("agent", "agent")?;
|
let by_agent = grouped("agent", "agent")?;
|
||||||
@@ -795,6 +806,8 @@ impl Store {
|
|||||||
Ok(json!({
|
Ok(json!({
|
||||||
"total": total,
|
"total": total,
|
||||||
"by_day": by_day,
|
"by_day": by_day,
|
||||||
|
"by_month": by_month,
|
||||||
|
"first_ts": first_ts,
|
||||||
"by_day_model": by_day_model,
|
"by_day_model": by_day_model,
|
||||||
"by_model": by_model,
|
"by_model": by_model,
|
||||||
"by_provider": by_provider,
|
"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<Vec<(String, i64, i64)>> {
|
||||||
|
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::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chat a link points to while it is valid.
|
||||||
|
pub fn share_link_session(&self, token: &str, now: i64) -> Result<Option<String>> {
|
||||||
|
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 -------------------------------------------------------
|
// --- defaults of new chats in a project -------------------------------------------------------
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
@@ -1710,3 +1776,36 @@ fn snippet_in(m: &Message, needle: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
None
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -543,6 +543,7 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
|
|||||||
telegram_quiet: false,
|
telegram_quiet: false,
|
||||||
summary: String::new(),
|
summary: String::new(),
|
||||||
color: None,
|
color: None,
|
||||||
|
icon: None,
|
||||||
})?;
|
})?;
|
||||||
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
update(app, &u.id, |st| st.session = Some(s.id.clone()));
|
||||||
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||||
|
|||||||
+19
-3
@@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
|
|||||||
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
import { connectEvents, get, setUnauthorizedHandler } from "./api";
|
||||||
import { useT } from "./i18n";
|
import { useT } from "./i18n";
|
||||||
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
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 type { Info } from "./types";
|
||||||
import { ImageViewer } from "./components/ImageViewer";
|
import { ImageViewer } from "./components/ImageViewer";
|
||||||
import { ProjectPage } from "./components/ProjectPage";
|
import { ProjectPage } from "./components/ProjectPage";
|
||||||
@@ -22,6 +22,8 @@ import { WhatsNew } from "./components/WhatsNew";
|
|||||||
import { ShareTarget } from "./components/ShareTarget";
|
import { ShareTarget } from "./components/ShareTarget";
|
||||||
import { Palette } from "./components/Palette";
|
import { Palette } from "./components/Palette";
|
||||||
import { pendingShare } from "./share";
|
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.
|
// 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 })));
|
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||||
@@ -34,11 +36,21 @@ export function App() {
|
|||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [shared, setShared] = useState(pendingShare);
|
const [shared, setShared] = useState(pendingShare);
|
||||||
|
const offline = useStore((s) => s.offline);
|
||||||
useTitleActivity();
|
useTitleActivity();
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
try {
|
try {
|
||||||
const i = await get<Info>("/info");
|
let i: Info;
|
||||||
|
try {
|
||||||
|
i = await get<Info>("/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 }));
|
setState(() => ({ me: i.user, update: i.update ?? null }));
|
||||||
if (i.user) {
|
if (i.user) {
|
||||||
await loadSessions();
|
await loadSessions();
|
||||||
@@ -71,6 +83,7 @@ export function App() {
|
|||||||
// Close the mobile menu on navigation.
|
// Close the mobile menu on navigation.
|
||||||
useEffect(() => setMenuOpen(false), [route.join("/")]);
|
useEffect(() => setMenuOpen(false), [route.join("/")]);
|
||||||
|
|
||||||
|
if (route[0] === "shared" && route[1]) return <PublicChat token={route[1]} />;
|
||||||
if (error) return <div className="empty">{error}</div>;
|
if (error) return <div className="empty">{error}</div>;
|
||||||
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
if (!info) return <div className="empty">{t("common.loading")}</div>;
|
||||||
if (!info.user)
|
if (!info.user)
|
||||||
@@ -119,7 +132,10 @@ export function App() {
|
|||||||
<div className="app">
|
<div className="app">
|
||||||
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
|
||||||
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
|
<Sidebar open={menuOpen} activeIds={chatIds} section={section ?? ""} />
|
||||||
<main className="main">{content}</main>
|
<main className="main">
|
||||||
|
{offline && <div className="offline-bar" role="status">{t("offline.banner")}</div>}
|
||||||
|
{content}
|
||||||
|
</main>
|
||||||
<WhatsNew version={info.version} />
|
<WhatsNew version={info.version} />
|
||||||
<Palette />
|
<Palette />
|
||||||
<ImageViewer />
|
<ImageViewer />
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { ShellsChip } from "./ShellsPanel";
|
|||||||
import { TodoList, ToolCard } from "./ToolCard";
|
import { TodoList, ToolCard } from "./ToolCard";
|
||||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||||
import { ColorSwatches } from "./ColorSwatches";
|
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||||
import { ProjectPermissions } from "./ProjectPermissions";
|
import { ProjectPermissions } from "./ProjectPermissions";
|
||||||
import { OutboxBar } from "./OutboxBar";
|
import { OutboxBar } from "./OutboxBar";
|
||||||
import { addToOutbox, isOffline } from "../outbox";
|
import { addToOutbox, isOffline } from "../outbox";
|
||||||
@@ -374,23 +374,32 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="msg work-block" data-mid={answer?.id}>
|
<div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
|
||||||
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||||
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||||
</button>
|
</button>
|
||||||
{answerText && (
|
{answerText && (
|
||||||
<>
|
<>
|
||||||
{answer && <MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />}
|
|
||||||
<Markdown text={answerText} baseDir={baseDir} />
|
<Markdown text={answerText} baseDir={baseDir} />
|
||||||
<ImageRefs text={answerText} baseDir={baseDir} />
|
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||||
{answer && <MsgTime ms={answer.created_at} />}
|
{answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
|
||||||
|
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="msg-foot">
|
||||||
|
<MsgTime ms={ms} />
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Text parts of a message. */
|
/** Text parts of a message. */
|
||||||
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
|
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
|
||||||
|
|
||||||
@@ -423,10 +432,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
|
|||||||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||||
if (m.role === "assistant") {
|
if (m.role === "assistant") {
|
||||||
return (
|
return (
|
||||||
<div className={cls} data-mid={m.id}>
|
<div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
|
||||||
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
|
|
||||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||||
<MsgTime ms={m.created_at} />
|
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
|
||||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -451,13 +459,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
|||||||
return (
|
return (
|
||||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||||
<summary>
|
<summary>
|
||||||
{summary.auto
|
{summary.text.startsWith("[Context cleared")
|
||||||
|
? t("chat.cleared")
|
||||||
|
: summary.auto
|
||||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||||
: summary.tokens
|
: summary.tokens
|
||||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||||
: t("chat.compacted")}
|
: t("chat.compacted")}
|
||||||
</summary>
|
</summary>
|
||||||
{summaryOpen && <Markdown text={summary.text} />}
|
{summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -466,14 +476,12 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
|||||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||||
return (
|
return (
|
||||||
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
<div className={`msg user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||||
{background.map((p, i) => (
|
{background.map((p, i) => (
|
||||||
<HarnessMessage key={i} text={p.text} />
|
<HarnessMessage key={i} text={p.text} />
|
||||||
))}
|
))}
|
||||||
<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
|
|
||||||
<div className="msg-user">
|
<div className="msg-user">
|
||||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||||
<MsgTime ms={m.created_at} />
|
|
||||||
{imgs.length > 0 && (
|
{imgs.length > 0 && (
|
||||||
<div className="user-files">
|
<div className="user-files">
|
||||||
{imgs.map((p, i) => (
|
{imgs.map((p, i) => (
|
||||||
@@ -483,6 +491,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -781,6 +790,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="undo" />
|
<Icon name="undo" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{session.icon && <Icon name={session.icon} className="chat-icon" />}
|
||||||
{session.title || t("session.untitled")}
|
{session.title || t("session.untitled")}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
|
||||||
@@ -877,6 +887,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
<Icon name="edit" /> {t("instructions.title")}
|
<Icon name="edit" /> {t("instructions.title")}
|
||||||
</a>
|
</a>
|
||||||
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
|
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
|
||||||
|
<IconPicker value={session.icon} color={session.color} onChange={(icon) => void update({ icon })} />
|
||||||
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
|
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
|
||||||
<Icon name="clock" /> {t("job.plan")}
|
<Icon name="clock" /> {t("job.plan")}
|
||||||
</a>
|
</a>
|
||||||
@@ -1012,7 +1023,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{draft && (
|
{draft && (
|
||||||
<div className="msg">
|
<div className={`msg ${draft.parts.some((p) => p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
|
||||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { useT, type Key } from "../i18n";
|
import { useT, type Key } from "../i18n";
|
||||||
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
|
/** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */
|
||||||
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple"] as const;
|
export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"] as const;
|
||||||
|
|
||||||
/** Row of colour dots; the selected one is ringed, a second click (or ✕) removes the tag. */
|
/** Icons a chat can get (must match SESSION_ICONS on the server). */
|
||||||
|
export const SESSION_ICONS = [
|
||||||
|
"robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock",
|
||||||
|
"users", "toolbox", "plug", "scroll", "eye", "template",
|
||||||
|
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Row of colour dots; the selected one is ringed, a second click removes the tag. */
|
||||||
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
|
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
return (
|
return (
|
||||||
@@ -27,3 +35,29 @@ export function ColorSwatches({ value, onChange }: { value?: string; onChange: (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Grid of icons for a chat (in the colour of the chat); a second click removes the icon. */
|
||||||
|
export function IconPicker({ value, color, onChange }: { value?: string; color?: string; onChange: (icon: string) => void }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="icon-picker" role="radiogroup" aria-label={t("icon.label")} data-color={color}>
|
||||||
|
{SESSION_ICONS.map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={value === i}
|
||||||
|
className={`icon-choice ${value === i ? "on" : ""}`}
|
||||||
|
title={i}
|
||||||
|
aria-label={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(value === i ? "" : i);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={i} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ interface Suggestion {
|
|||||||
|
|
||||||
const BUILTIN: { name: string; key: Key }[] = [
|
const BUILTIN: { name: string; key: Key }[] = [
|
||||||
{ name: "compact", key: "command.compact" },
|
{ name: "compact", key: "command.compact" },
|
||||||
|
{ name: "clear", key: "command.clear" },
|
||||||
{ name: "new", key: "command.new" },
|
{ name: "new", key: "command.new" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export function Composer({
|
|||||||
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
const [mention, setMention] = useState<{ start: number; end: number; q: string }>();
|
||||||
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
const [mentionHits, setMentionHits] = useState<{ rel: string; is_dir: boolean }[]>([]);
|
||||||
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
|
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;
|
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
|
||||||
|
|
||||||
// Auto-grow.
|
// Auto-grow.
|
||||||
@@ -134,11 +136,14 @@ export function Composer({
|
|||||||
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
...BUILTIN.map((b) => ({ name: b.name, description: t(b.key) })),
|
||||||
...(cmdData?.commands ?? []),
|
...(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]))
|
.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} `) }));
|
.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 = () => {
|
const send = () => {
|
||||||
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
|
||||||
|
|||||||
@@ -207,6 +207,13 @@ export function GitPanel({
|
|||||||
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
|
<input type="checkbox" checked={amend} onChange={(e) => setAmend(e.target.checked)} /> {t("git.amend")}
|
||||||
</label>
|
</label>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
|
<button
|
||||||
|
disabled={!!busy || staged.length === 0}
|
||||||
|
title={t("git.suggestHint")}
|
||||||
|
onClick={() => run("suggest", async () => setMessage((await call<{ message: string }>("suggest")).message))}
|
||||||
|
>
|
||||||
|
{busy === "suggest" ? <Spinner small /> : t("git.suggest")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
|
disabled={!!busy || (!amend && (!message.trim() || staged.length === 0))}
|
||||||
|
|||||||
@@ -34,6 +34,97 @@ const P: Record<string, ReactNode> = {
|
|||||||
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
||||||
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
||||||
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
||||||
|
gamepad: (
|
||||||
|
<>
|
||||||
|
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
|
||||||
|
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
computer: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
|
<path d="M8 20h8M12 16v4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
food: (
|
||||||
|
<>
|
||||||
|
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
|
||||||
|
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
home: (
|
||||||
|
<>
|
||||||
|
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
|
||||||
|
<path d="M10 20v-5h4v5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
book: (
|
||||||
|
<>
|
||||||
|
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
|
||||||
|
<path d="M5 17a3 3 0 0 1 3-3h11" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
|
||||||
|
camera: (
|
||||||
|
<>
|
||||||
|
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
|
||||||
|
<circle cx="12" cy="13" r="3.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
cart: (
|
||||||
|
<>
|
||||||
|
<path d="M3 4h2.5l2 11h10l2-8H7" />
|
||||||
|
<circle cx="9" cy="19" r="1.4" />
|
||||||
|
<circle cx="17" cy="19" r="1.4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
|
||||||
|
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
|
||||||
|
globe: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
calendar: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="5" width="16" height="15" rx="2" />
|
||||||
|
<path d="M4 10h16M8 3v4M16 3v4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
coffee: (
|
||||||
|
<>
|
||||||
|
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
|
||||||
|
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
car: (
|
||||||
|
<>
|
||||||
|
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
|
||||||
|
<path d="M3.5 12h17" />
|
||||||
|
<circle cx="7.5" cy="16" r="1.5" />
|
||||||
|
<circle cx="16.5" cy="16" r="1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plane: (
|
||||||
|
<>
|
||||||
|
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
|
||||||
|
<path d="M9.5 13 21 3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
|
||||||
|
money: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
palette: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
|
||||||
|
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
|
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
|
||||||
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
||||||
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface Settings {
|
|||||||
model: string;
|
model: string;
|
||||||
permission_mode: string;
|
permission_mode: string;
|
||||||
timeout_minutes: number;
|
timeout_minutes: number;
|
||||||
|
notify: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsOf = (j: JobInfo): Settings => ({
|
const settingsOf = (j: JobInfo): Settings => ({
|
||||||
@@ -29,6 +30,7 @@ const settingsOf = (j: JobInfo): Settings => ({
|
|||||||
model: j.spec?.model ?? "",
|
model: j.spec?.model ?? "",
|
||||||
permission_mode: j.spec?.permission_mode ?? "auto",
|
permission_mode: j.spec?.permission_mode ?? "auto",
|
||||||
timeout_minutes: j.spec?.timeout_minutes ?? 60,
|
timeout_minutes: j.spec?.timeout_minutes ?? 60,
|
||||||
|
notify: j.spec?.notify ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
type Tab = "prompt" | "settings" | "assistant";
|
type Tab = "prompt" | "settings" | "assistant";
|
||||||
@@ -254,6 +256,10 @@ export function JobEditor({ id, onMenu }: { id: string; onMenu: () => void }) {
|
|||||||
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
|
<input type="number" min={1} max={1440} value={settings.timeout_minutes} onChange={(e) => up({ timeout_minutes: Math.max(1, Number(e.target.value) || 60) })} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="row" style={{ gap: 6, alignItems: "center", marginTop: 6 }}>
|
||||||
|
<input type="checkbox" checked={settings.notify} onChange={(e) => up({ notify: e.target.checked })} />
|
||||||
|
{t("jobEditor.notify")}
|
||||||
|
</label>
|
||||||
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
|
{settings.permission_mode !== "bypass" && <div className="hint">{t("jobEditor.bypassHint")}</div>}
|
||||||
</section>
|
</section>
|
||||||
<section className="job-editor-prompt">
|
<section className="job-editor-prompt">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface JobInfo {
|
|||||||
next_run?: number | null;
|
next_run?: number | null;
|
||||||
error?: string;
|
error?: string;
|
||||||
webhook?: boolean;
|
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" }) : "–");
|
const fmtDate = (ms?: number | null) => (ms ? new Date(ms).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "–");
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }
|
|||||||
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
|
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
|
||||||
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
|
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
|
||||||
<div className="tl-title">
|
<div className="tl-title">
|
||||||
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
<span className={s.color ? "color-badge" : undefined}>
|
||||||
|
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
|
||||||
|
{s.title || t("session.untitled")}
|
||||||
|
</span>
|
||||||
<span className="tl-when" title={fmtDate(s.updated_at)}>
|
<span className="tl-when" title={fmtDate(s.updated_at)}>
|
||||||
{fmtRelative(s.updated_at)}
|
{fmtRelative(s.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -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<Shared>();
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
get<Shared>(`/public/chat/${encodeURIComponent(token)}`)
|
||||||
|
.then((c) => (setChat(c), (document.title = `${c.title || "Chat"} – CodeCraft`)))
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}, [token]);
|
||||||
|
if (error) return <div className="empty">{error}</div>;
|
||||||
|
if (!chat) return <div className="empty">{t("common.loading")}</div>;
|
||||||
|
return (
|
||||||
|
<div className="public-chat">
|
||||||
|
<header>
|
||||||
|
<h1>{chat.title || "Chat"}</h1>
|
||||||
|
<div className="hint">{t("link.readOnly")}</div>
|
||||||
|
</header>
|
||||||
|
<div className="chat-scroll">
|
||||||
|
{chat.messages.map((m) => (
|
||||||
|
<div key={m.id} className={m.role === "user" ? "bubble user-msg" : "bubble agent"}>
|
||||||
|
<Markdown text={m.text} />
|
||||||
|
<div className="msg-foot">
|
||||||
|
<span className="hint">{fmtMsgTime(m.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -146,8 +146,11 @@ function VersionInfo() {
|
|||||||
function DigestToggle() {
|
function DigestToggle() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [on, setOn] = useState<boolean>();
|
const [on, setOn] = useState<boolean>();
|
||||||
|
const [weekly, setWeekly] = useState(false);
|
||||||
useEffect(() => {
|
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;
|
if (on === undefined) return null;
|
||||||
return (
|
return (
|
||||||
@@ -164,6 +167,30 @@ function DigestToggle() {
|
|||||||
/>
|
/>
|
||||||
{t("settings.digest")}
|
{t("settings.digest")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={weekly}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setWeekly(v);
|
||||||
|
put("/push/digest", { weekly: v }).catch((err) => (setWeekly(!v), toast(err.message)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("settings.weeklyDigest")}
|
||||||
|
</label>
|
||||||
|
{weekly && (
|
||||||
|
<button
|
||||||
|
className="small ghost"
|
||||||
|
onClick={() =>
|
||||||
|
post<{ sent: number; errors: string[] }>("/push/digest?weekly=true")
|
||||||
|
.then((r) => toast(r.sent ? t("settings.pushTestSent") : r.errors.join("; ") || t("settings.pushTestNone"), r.sent ? "success" : "error"))
|
||||||
|
.catch((e) => toast(e.message))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("settings.digestNow")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{on && (
|
{on && (
|
||||||
<button
|
<button
|
||||||
className="small ghost"
|
className="small ghost"
|
||||||
@@ -246,6 +273,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
default_model: s.default_model ?? "",
|
default_model: s.default_model ?? "",
|
||||||
small_model: s.small_model ?? "",
|
small_model: s.small_model ?? "",
|
||||||
|
plan_model: ((cfg.config as any).agents?.plan?.model ?? "") as string,
|
||||||
|
transcribe_model: ((s as any).transcribe_model ?? "") as string,
|
||||||
currency: s.currency ?? "USD",
|
currency: s.currency ?? "USD",
|
||||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||||
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
||||||
@@ -270,6 +299,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
savePatches([
|
savePatches([
|
||||||
{ path: ["server", "default_model"], value: form.default_model },
|
{ path: ["server", "default_model"], value: form.default_model },
|
||||||
{ path: ["server", "small_model"], value: form.small_model },
|
{ path: ["server", "small_model"], value: form.small_model },
|
||||||
|
{ path: ["agents", "plan", "model"], value: form.plan_model || null },
|
||||||
|
{ path: ["server", "transcribe_model"], value: form.transcribe_model.trim() },
|
||||||
{ path: ["server", "currency"], value: form.currency },
|
{ path: ["server", "currency"], value: form.currency },
|
||||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||||
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
||||||
@@ -302,6 +333,12 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
<label>{t("settings.smallModel")}</label>
|
<label>{t("settings.smallModel")}</label>
|
||||||
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
|
<ModelSelect value={form.small_model} onChange={(v) => setForm({ ...form, small_model: v })} />
|
||||||
<div className="hint">{t("settings.smallModelHint")}</div>
|
<div className="hint">{t("settings.smallModelHint")}</div>
|
||||||
|
<label>{t("settings.planModel")}</label>
|
||||||
|
<ModelSelect value={form.plan_model} emptyLabel={t("settings.sameAsChat")} onChange={(v) => setForm({ ...form, plan_model: v })} />
|
||||||
|
<div className="hint">{t("settings.planModelHint")}</div>
|
||||||
|
<label>{t("settings.transcribeModel")}</label>
|
||||||
|
<input value={form.transcribe_model} placeholder="openai/whisper-1" onChange={(e) => setForm({ ...form, transcribe_model: e.target.value })} />
|
||||||
|
<div className="hint">{t("settings.transcribeHint")}</div>
|
||||||
<label>{t("settings.currency")}</label>
|
<label>{t("settings.currency")}</label>
|
||||||
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
|
<input value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value.toUpperCase() })} maxLength={3} />
|
||||||
<label>{t("settings.autoCompact")}</label>
|
<label>{t("settings.autoCompact")}</label>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get, put } from "../api";
|
import { del, get, post, put } from "../api";
|
||||||
import { useT } from "../i18n";
|
import { useT } from "../i18n";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
|
import { toast } from "../dialogs";
|
||||||
|
import { fmtDate } from "../i18n";
|
||||||
|
|
||||||
interface DirUser {
|
interface DirUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,6 +11,54 @@ interface DirUser {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Link {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only links for people without an account. */
|
||||||
|
function Links({ sessionId }: { sessionId: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const [links, setLinks] = useState<Link[]>([]);
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const load = () => get<Link[]>(`/sessions/${sessionId}/links`).then(setLinks).catch(() => undefined);
|
||||||
|
useEffect(() => void load(), [sessionId]);
|
||||||
|
const create = async () => {
|
||||||
|
try {
|
||||||
|
const l = await post<Link>(`/sessions/${sessionId}/links`, { days });
|
||||||
|
await navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"), () => undefined);
|
||||||
|
void load();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="share-links">
|
||||||
|
<h4>{t("link.title")}</h4>
|
||||||
|
<div className="hint">{t("link.hint")}</div>
|
||||||
|
{links.map((l) => (
|
||||||
|
<div key={l.token} className="row share-link">
|
||||||
|
<input readOnly value={l.url} onFocus={(e) => e.currentTarget.select()} aria-label={t("link.title")} />
|
||||||
|
<span className="hint">{t("link.until", { date: fmtDate(l.expires_at, false) })}</span>
|
||||||
|
<button onClick={() => navigator.clipboard?.writeText(l.url).then(() => toast(t("link.copied"), "success"))}>{t("link.copy")}</button>
|
||||||
|
<button onClick={() => del(`/sessions/${sessionId}/links/${l.token}`).then(load).catch((e) => toast(e.message))}>{t("link.revoke")}</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="row" style={{ marginTop: 6 }}>
|
||||||
|
<select value={days} onChange={(e) => setDays(Number(e.target.value))} aria-label={t("link.validity")}>
|
||||||
|
{[1, 7, 30, 90].map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{t("link.days", { n: d })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={create}>{t("link.create")}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Choose users a session is shared with (read-only for them). */
|
/** Choose users a session is shared with (read-only for them). */
|
||||||
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
|
export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -59,6 +109,7 @@ export function ShareDialog({ sessionId, onClose }: { sessionId: string; onClose
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<Links sessionId={sessionId} />
|
||||||
{error && <div className="notice err">{error}</div>}
|
{error && <div className="notice err">{error}</div>}
|
||||||
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
|
||||||
<button onClick={onClose}>{t("common.cancel")}</button>
|
<button onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ColorSwatches } from "./ColorSwatches";
|
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { StatusLeds } from "./StatusLeds";
|
import { StatusLeds } from "./StatusLeds";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
@@ -26,7 +26,7 @@ function groupKey(ms: number): Key {
|
|||||||
const GROUP_BY = "codecraft.sidebarGroupBy";
|
const GROUP_BY = "codecraft.sidebarGroupBy";
|
||||||
const COLLAPSED = "codecraft.sidebarCollapsed";
|
const COLLAPSED = "codecraft.sidebarCollapsed";
|
||||||
|
|
||||||
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string; icon?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
||||||
|
|
||||||
/** One session in the list, with a menu to pin, file or archive it. */
|
/** One session in the list, with a menu to pin, file or archive it. */
|
||||||
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) {
|
||||||
@@ -57,7 +57,10 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
|
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
|
||||||
<div className="title">
|
<div className="title">
|
||||||
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
|
||||||
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
<span className={s.color ? "color-badge" : undefined}>
|
||||||
|
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
|
||||||
|
{s.title || t("session.untitled")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
|
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
@@ -96,6 +99,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
||||||
|
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
|
||||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+185
-55
@@ -1,5 +1,5 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
|
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
@@ -15,6 +15,9 @@ interface StatsData {
|
|||||||
total: Aggregate;
|
total: Aggregate;
|
||||||
by_day: Row<"day">[];
|
by_day: Row<"day">[];
|
||||||
by_day_model: { day: string; model: string; cost: number; input_tokens: number; output_tokens: number }[];
|
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_model: Row<"model">[];
|
||||||
by_provider: Row<"provider">[];
|
by_provider: Row<"provider">[];
|
||||||
by_agent: Row<"agent">[];
|
by_agent: Row<"agent">[];
|
||||||
@@ -37,6 +40,16 @@ const RANGES: { key: string; days: number; label: Key }[] = [
|
|||||||
|
|
||||||
type HeatMetric = "requests" | "tokens" | "cost";
|
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) {
|
function dayKey(ms: number) {
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOLD_KEY = "codecraft.statsFolded";
|
||||||
|
|
||||||
|
function foldedSet(): Set<string> {
|
||||||
|
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 (
|
||||||
|
<div className={`panel fold${closed ? " closed" : ""}`}>
|
||||||
|
<h3>
|
||||||
|
<button className="fold-toggle" aria-expanded={!closed} onClick={toggle}>
|
||||||
|
<span className="fold-caret" aria-hidden="true" />
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{!closed && children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
function Kpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="kpi">
|
<div className="kpi">
|
||||||
@@ -67,8 +118,39 @@ function perMillion(r: Aggregate, currency: string) {
|
|||||||
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
|
function AggTable({
|
||||||
|
rows,
|
||||||
|
keyName,
|
||||||
|
label,
|
||||||
|
currency,
|
||||||
|
link,
|
||||||
|
group,
|
||||||
|
}: {
|
||||||
|
rows: (Aggregate & Record<string, any>)[];
|
||||||
|
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 t = useT();
|
||||||
|
const [shut, setShut] = useState<Set<string>>(new Set());
|
||||||
|
const groups = group ? [...new Set(rows.map(group))] : [];
|
||||||
|
const row = (r: (typeof rows)[number]) => (
|
||||||
|
<tr key={r[keyName]}>
|
||||||
|
<td className={group ? "fold-indent" : undefined}>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
|
||||||
|
<td className="num">{fmtNumber(r.requests)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
||||||
|
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
||||||
|
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
||||||
|
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||||||
|
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||||||
|
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||||||
|
<td className="num">{perMillion(r, currency)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: "auto" }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
@@ -87,20 +169,33 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{group
|
||||||
<tr key={r[keyName]}>
|
? groups.map((g) => {
|
||||||
<td>{link ? <a href="#" onClick={(e) => (e.preventDefault(), link(r))}>{r[keyName]}</a> : r[keyName]}</td>
|
const members = rows.filter((r) => group(r) === g);
|
||||||
<td className="num">{fmtNumber(r.requests)}</td>
|
const open = !shut.has(g);
|
||||||
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
|
return (
|
||||||
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
|
<Fragment key={g}>
|
||||||
<td className="num">{fmtCompact(r.output_tokens)}</td>
|
<tr className="fold-group">
|
||||||
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
|
<td>
|
||||||
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
<button
|
||||||
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
className="fold-toggle"
|
||||||
<td className="num">{fmtCost(r.cost, currency)}</td>
|
aria-expanded={open}
|
||||||
<td className="num">{perMillion(r, currency)}</td>
|
onClick={() => setShut((x) => (x.has(g) ? new Set([...x].filter((k) => k !== g)) : new Set(x).add(g)))}
|
||||||
|
>
|
||||||
|
<span className="fold-caret" aria-hidden="true" />
|
||||||
|
{g} <span className="hint">({members.length})</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="num">{fmtNumber(members.reduce((a, r) => a + r.requests, 0))}</td>
|
||||||
|
<td colSpan={6} />
|
||||||
|
<td className="num">{fmtCost(members.reduce((a, r) => a + r.cost, 0), currency)}</td>
|
||||||
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{open && members.map(row)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: rows.map(row)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,16 +212,40 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||||
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
||||||
const [userFilter, setUserFilter] = useState("");
|
const [userFilter, setUserFilter] = useState("");
|
||||||
const days = RANGES.find((r) => r.key === range)!.days;
|
|
||||||
const tz = -new Date().getTimezoneOffset();
|
const tz = -new Date().getTimezoneOffset();
|
||||||
// Stable per range: rounded to the minute so the URL doesn't change on every render.
|
// 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 now = Math.floor(Date.now() / 60000) * 60000 + 60000;
|
||||||
const from = days ? now - days * 86400000 : 0;
|
// "y2025": that calendar year (local time); otherwise a rolling window.
|
||||||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
|
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<StatsData>(`/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(() => {
|
const view = useMemo(() => {
|
||||||
if (!data) return undefined;
|
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".
|
// 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 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);
|
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<string, number>);
|
const costByDay = cats.map(() => ({}) as Record<string, number>);
|
||||||
const idx = new Map(cats.map((c, i) => [c, i]));
|
const idx = new Map(cats.map((c, i) => [c, i]));
|
||||||
for (const r of data.by_day_model) {
|
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;
|
if (i === undefined) continue;
|
||||||
const key = named.includes(r.model) ? r.model : other;
|
const key = named.includes(r.model) ? r.model : other;
|
||||||
costByDay[i][key] = (costByDay[i][key] ?? 0) + r.cost;
|
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] },
|
{ key: "output", label: t("stats.output"), color: SERIES[2] },
|
||||||
];
|
];
|
||||||
const tokensByDay = cats.map((c): Record<string, number> => {
|
const tokensByDay = cats.map((c): Record<string, number> => {
|
||||||
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 } : {};
|
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.
|
// 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 { label: `${wd.format(date)} ${dm.format(date)}`, title: full.format(date) };
|
||||||
});
|
});
|
||||||
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
|
return { cats, modelSeries, costByDay, tokenSeries, tokensByDay, cells, heatRows };
|
||||||
}, [data, heat, t]);
|
}, [data, heat, t, monthly]);
|
||||||
|
|
||||||
const dayLabel = (d: string) =>
|
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 cur = data?.currency ?? "USD";
|
||||||
const tot = data?.total;
|
const tot = data?.total;
|
||||||
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
|
const heatFormat = (v: number) => (heat === "cost" ? fmtCost(v, cur) : fmtCompact(v));
|
||||||
@@ -203,6 +324,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{years.length > 1 && (
|
||||||
|
<select value={year ? range : ""} onChange={(e) => e.target.value && setRange(e.target.value)} aria-label={t("stats.year")} className={year ? "active" : ""}>
|
||||||
|
<option value="">{t("stats.year")}</option>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={`y${y}`}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
||||||
<option value="">{t("stats.allUsers")}</option>
|
<option value="">{t("stats.allUsers")}</option>
|
||||||
@@ -213,6 +344,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
<select value="" onChange={(e) => exportAs(e.target.value)} aria-label={t("stats.export")} title={t("stats.exportHint")}>
|
||||||
|
<option value="">{t("stats.export")}</option>
|
||||||
|
<option value="xlsx">{t("stats.exportXlsx")}</option>
|
||||||
|
{(["day", "month", "model", "provider", "agent", "user", "session"] as const).map((k) => (
|
||||||
|
<option key={k} value={`csv:${k}`}>
|
||||||
|
{t("stats.exportCsv", { table: t(`stats.table.${k}` as Key) })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button className="small" onClick={() => setTableView(!tableView)}>
|
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||||
</button>
|
</button>
|
||||||
@@ -238,61 +378,51 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
{tot.requests === 0 ? (
|
{tot.requests === 0 ? (
|
||||||
<div className="empty">{t("stats.noData")}</div>
|
<div className="empty">{t("stats.noData")}</div>
|
||||||
) : tableView ? (
|
) : tableView ? (
|
||||||
<div className="panel">
|
<Fold id="perDay" title={t("stats.perDay")}>
|
||||||
<h3>{t("stats.perDay")}</h3>
|
|
||||||
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
|
<AggTable rows={data.by_day} keyName="day" label={t("stats.day")} currency={cur} />
|
||||||
</div>
|
</Fold>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="panel">
|
<Fold id="costPerDay" title={t(monthly ? "stats.costPerMonth" : "stats.costPerDay")}>
|
||||||
<h3>{t("stats.costPerDay")}</h3>
|
|
||||||
<Legend series={view.modelSeries} />
|
<Legend series={view.modelSeries} />
|
||||||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
|
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.modelSeries} values={view.costByDay} format={(v) => fmtCost(v, cur)} />
|
||||||
</div>
|
</Fold>
|
||||||
<div className="panel">
|
<Fold id="tokensPerDay" title={t(monthly ? "stats.tokensPerMonth" : "stats.tokensPerDay")}>
|
||||||
<h3>{t("stats.tokensPerDay")}</h3>
|
|
||||||
<Legend series={view.tokenSeries} />
|
<Legend series={view.tokenSeries} />
|
||||||
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
|
<StackedBars categories={view.cats} categoryLabel={dayLabel} series={view.tokenSeries} values={view.tokensByDay} format={fmtCompact} />
|
||||||
</div>
|
</Fold>
|
||||||
<div className="panel">
|
<Fold id="heatmap" title={t("stats.heatmap")}>
|
||||||
<h3>
|
<div className="row" style={{ justifyContent: "flex-end", marginBottom: 6 }}>
|
||||||
{t("stats.heatmap")}
|
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)} aria-label={t("stats.heatmap")}>
|
||||||
<span className="spacer" />
|
|
||||||
<select value={heat} onChange={(e) => setHeat(e.target.value as HeatMetric)}>
|
|
||||||
<option value="requests">{t("stats.requests")}</option>
|
<option value="requests">{t("stats.requests")}</option>
|
||||||
<option value="tokens">{t("stats.tokens")}</option>
|
<option value="tokens">{t("stats.tokens")}</option>
|
||||||
<option value="cost">{t("stats.cost")}</option>
|
<option value="cost">{t("stats.cost")}</option>
|
||||||
</select>
|
</select>
|
||||||
</h3>
|
|
||||||
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Heatmap cells={view.cells} rows={view.heatRows} format={heatFormat} />
|
||||||
|
</Fold>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tot.requests > 0 && (
|
{tot.requests > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="panel">
|
<Fold id="byModel" title={t("stats.byModel")}>
|
||||||
<h3>{t("stats.byModel")}</h3>
|
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} group={(r) => String(r.model).split("/")[0]} />
|
||||||
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
</Fold>
|
||||||
</div>
|
<Fold id="byProvider" title={t("stats.byProvider")}>
|
||||||
<div className="panel">
|
|
||||||
<h3>{t("stats.byProvider")}</h3>
|
|
||||||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||||
</div>
|
</Fold>
|
||||||
<div className="panel">
|
<Fold id="byAgent" title={t("stats.byAgent")}>
|
||||||
<h3>{t("stats.byAgent")}</h3>
|
|
||||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||||
</div>
|
</Fold>
|
||||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||||
<div className="panel">
|
<Fold id="byUser" title={t("stats.byUser")}>
|
||||||
<h3>{t("stats.byUser")}</h3>
|
|
||||||
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
||||||
</div>
|
</Fold>
|
||||||
)}
|
)}
|
||||||
<div className="panel">
|
<Fold id="topSessions" title={t("stats.topSessions")}>
|
||||||
<h3>{t("stats.topSessions")}</h3>
|
|
||||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
|
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
|
||||||
</div>
|
</Fold>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
|||||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||||
const openByDefault =
|
const openByDefault =
|
||||||
(status === "err" && !!result) ||
|
(status === "err" && !!result) ||
|
||||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
|
||||||
const open = toggled ?? openByDefault;
|
const open = toggled ?? openByDefault;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getLang, useT } from "../i18n";
|
import { getLang, useT } from "../i18n";
|
||||||
import { toast } from "../dialogs";
|
import { toast } from "../dialogs";
|
||||||
|
import { get } from "../api";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
/* Web Speech API (Chrome/Edge/Safari; Firefox has none). */
|
/* 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;
|
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<MediaRecorder | null>(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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ghost small voice-btn ${state === "recording" ? "on" : ""}`}
|
||||||
|
disabled={state === "sending"}
|
||||||
|
onClick={() => void toggle()}
|
||||||
|
title={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||||||
|
aria-label={t(state === "recording" ? "voice.stop" : "voice.start")}
|
||||||
|
aria-pressed={state === "recording"}
|
||||||
|
>
|
||||||
|
<Icon name="mic" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
|
const LOCALES: Record<string, string> = { de: "de-DE", en: "en-US" };
|
||||||
|
|
||||||
/** Microphone button: dictated text is appended to the message. */
|
/** 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 ? <RecorderButton {...props} /> : null;
|
||||||
|
return <BrowserVoiceButton {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowserVoiceButton({ onText, onInterim }: { onText: (text: string) => void; onInterim: (text: string) => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [listening, setListening] = useState(false);
|
const [listening, setListening] = useState(false);
|
||||||
const rec = useRef<Recognition | null>(null);
|
const rec = useRef<Recognition | null>(null);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
|||||||
import { ApiTokens } from "./ApiTokens";
|
import { ApiTokens } from "./ApiTokens";
|
||||||
import { CliInstall } from "./CliInstall";
|
import { CliInstall } from "./CliInstall";
|
||||||
import { Memory } from "./Memory";
|
import { Memory } from "./Memory";
|
||||||
|
import { Snippets } from "./Snippets";
|
||||||
import { ClaudeCode } from "./ClaudeCode";
|
import { ClaudeCode } from "./ClaudeCode";
|
||||||
import { Telegram } from "./Telegram";
|
import { Telegram } from "./Telegram";
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ export function Account() {
|
|||||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||||
</div>
|
</div>
|
||||||
<Memory />
|
<Memory />
|
||||||
|
<Snippets />
|
||||||
<GitAccount />
|
<GitAccount />
|
||||||
<CliInstall />
|
<CliInstall />
|
||||||
<ClaudeCode />
|
<ClaudeCode />
|
||||||
|
|||||||
@@ -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<Snippet[]>();
|
||||||
|
useEffect(() => {
|
||||||
|
get<Snippet[]>("/account/snippets")
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => toast(e.message));
|
||||||
|
}, []);
|
||||||
|
if (!items) return null;
|
||||||
|
const set = (i: number, patch: Partial<Snippet>) => 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<Snippet[]>("/account/snippets", clean)
|
||||||
|
.then((r) => (setItems(r), toast(t("settings.saved"), "success")))
|
||||||
|
.catch((e) => toast(e.message));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<Icon name="sparkle" /> {t("snippets.title")}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t("snippets.hint")}
|
||||||
|
</div>
|
||||||
|
{items.map((s, i) => (
|
||||||
|
<div key={s.id ?? i} className="snippet-row">
|
||||||
|
<div className="row">
|
||||||
|
<span className="snippet-slash">/</span>
|
||||||
|
<input value={s.name} placeholder={t("snippets.name")} maxLength={40} onChange={(e) => set(i, { name: e.target.value })} aria-label={t("snippets.name")} />
|
||||||
|
<button title={t("common.delete")} aria-label={t("common.delete")} onClick={() => setItems(items.filter((_, j) => j !== i))}>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea rows={2} value={s.text} placeholder={t("snippets.text")} onChange={(e) => set(i, { text: e.target.value })} aria-label={t("snippets.text")} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="row" style={{ marginTop: 8 }}>
|
||||||
|
<button onClick={() => setItems([...items, { name: "", text: "" }])}>
|
||||||
|
<Icon name="plus" /> {t("snippets.add")}
|
||||||
|
</button>
|
||||||
|
<span className="grow" />
|
||||||
|
<button className="primary" onClick={save}>
|
||||||
|
{t("common.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -400,6 +400,8 @@ export const de: Messages = {
|
|||||||
"color.aqua": "Türkis",
|
"color.aqua": "Türkis",
|
||||||
"color.blue": "Blau",
|
"color.blue": "Blau",
|
||||||
"color.purple": "Violett",
|
"color.purple": "Violett",
|
||||||
|
"color.pink": "Pink",
|
||||||
|
"icon.label": "Symbol dieses Chats",
|
||||||
"claude.title": "Claude Code",
|
"claude.title": "Claude Code",
|
||||||
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
|
"claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.",
|
||||||
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
|
"claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
|
||||||
@@ -1099,4 +1101,49 @@ export const de: Messages = {
|
|||||||
"stats.user": "Benutzer",
|
"stats.user": "Benutzer",
|
||||||
"stats.allUsers": "Alle Benutzer",
|
"stats.allUsers": "Alle Benutzer",
|
||||||
"stats.byUser": "Nach Benutzer",
|
"stats.byUser": "Nach Benutzer",
|
||||||
|
"snippets.title": "Prompt-Bausteine",
|
||||||
|
"snippets.hint": "Eigene Textbausteine. Tippe / im Nachrichtenfeld und wähle einen Baustein, um ihn einzufügen (z. B. /tests → \"Schreibe Tests zu dieser Änderung\").",
|
||||||
|
"snippets.name": "Name",
|
||||||
|
"snippets.text": "Einzufügender Text",
|
||||||
|
"snippets.add": "Baustein hinzufügen",
|
||||||
|
"snippets.insert": "Baustein",
|
||||||
|
"link.title": "Nur-Lese-Link",
|
||||||
|
"link.hint": "Wer den Link hat, kann den Gesprächstext lesen (keine Tool-Ausgaben, keine Dateien) – ohne Konto.",
|
||||||
|
"link.create": "Link erstellen",
|
||||||
|
"link.copy": "Kopieren",
|
||||||
|
"link.copied": "Link kopiert",
|
||||||
|
"link.revoke": "Widerrufen",
|
||||||
|
"link.validity": "Gültigkeit",
|
||||||
|
"link.days": "{n} Tage",
|
||||||
|
"link.until": "bis {date}",
|
||||||
|
"link.readOnly": "Schreibgeschützt geteilt",
|
||||||
|
"git.suggest": "Nachricht vorschlagen",
|
||||||
|
"git.suggestHint": "Das kleine Modell schreibt eine Commit-Nachricht für die vorgemerkten Änderungen",
|
||||||
|
"settings.weeklyDigest": "Wochenrückblick montags um 8:00 (Chats, Checks, Kosten)",
|
||||||
|
"settings.transcribeModel": "Spracherkennungs-Modell",
|
||||||
|
"settings.transcribeHint": "provider/modell eines OpenAI-kompatiblen Transkriptions-Endpunkts (z. B. openai/whisper-1). Schaltet den Mikrofon-Knopf in Browsern ohne eigene Diktierfunktion frei, etwa Firefox.",
|
||||||
|
"voice.transcribing": "Wird transkribiert …",
|
||||||
|
"offline.banner": "Keine Verbindung – gespeicherte Kopien. Geschriebene Nachrichten warten im Postausgang.",
|
||||||
|
"settings.planModel": "Planungsmodell",
|
||||||
|
"settings.planModelHint": "Der Plan-Modus nutzt immer dieses Modell (z. B. ein starkes); alle anderen Modi das im Chat gewählte (z. B. ein günstigeres). Leer = Modell des Chats.",
|
||||||
|
"settings.sameAsChat": "(Modell des Chats)",
|
||||||
|
"jobEditor.notify": "Bericht als Push-Nachricht senden, wenn ein Lauf endet",
|
||||||
|
"stats.year": "Jahr",
|
||||||
|
"stats.month": "Monat",
|
||||||
|
"stats.perMonth": "Pro Monat",
|
||||||
|
"stats.costPerMonth": "Kosten pro Monat",
|
||||||
|
"stats.tokensPerMonth": "Tokens pro Monat",
|
||||||
|
"stats.export": "Export",
|
||||||
|
"stats.exportHint": "Die Werte des gewählten Zeitraums herunterladen",
|
||||||
|
"stats.exportXlsx": "Excel (.xlsx) – alle Tabellen",
|
||||||
|
"stats.exportCsv": "CSV – {table}",
|
||||||
|
"stats.table.day": "pro Tag",
|
||||||
|
"stats.table.month": "pro Monat",
|
||||||
|
"stats.table.model": "nach Modell",
|
||||||
|
"stats.table.provider": "nach Anbieter",
|
||||||
|
"stats.table.agent": "nach Modus",
|
||||||
|
"stats.table.user": "nach Benutzer",
|
||||||
|
"stats.table.session": "teuerste Chats",
|
||||||
|
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
|
||||||
|
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
|
||||||
};
|
};
|
||||||
@@ -400,6 +400,8 @@ export const en = {
|
|||||||
"color.aqua": "Teal",
|
"color.aqua": "Teal",
|
||||||
"color.blue": "Blue",
|
"color.blue": "Blue",
|
||||||
"color.purple": "Purple",
|
"color.purple": "Purple",
|
||||||
|
"color.pink": "Pink",
|
||||||
|
"icon.label": "Icon of this chat",
|
||||||
"claude.title": "Claude Code",
|
"claude.title": "Claude Code",
|
||||||
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
|
"claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.",
|
||||||
"claude.notInstalled": "Claude Code is not installed on the server.",
|
"claude.notInstalled": "Claude Code is not installed on the server.",
|
||||||
@@ -1098,6 +1100,51 @@ export const en = {
|
|||||||
"stats.user": "User",
|
"stats.user": "User",
|
||||||
"stats.allUsers": "All users",
|
"stats.allUsers": "All users",
|
||||||
"stats.byUser": "By user",
|
"stats.byUser": "By user",
|
||||||
|
"snippets.title": "Prompt snippets",
|
||||||
|
"snippets.hint": "Your own text blocks. Type / in the message field and pick a snippet to insert it (e.g. /tests → \"Add tests for this change\").",
|
||||||
|
"snippets.name": "name",
|
||||||
|
"snippets.text": "Text to insert",
|
||||||
|
"snippets.add": "Add snippet",
|
||||||
|
"snippets.insert": "Snippet",
|
||||||
|
"link.title": "Read-only link",
|
||||||
|
"link.hint": "Anyone with the link can read the conversation text (no tool output, no files) – without an account.",
|
||||||
|
"link.create": "Create link",
|
||||||
|
"link.copy": "Copy",
|
||||||
|
"link.copied": "Link copied",
|
||||||
|
"link.revoke": "Revoke",
|
||||||
|
"link.validity": "Validity",
|
||||||
|
"link.days": "{n} days",
|
||||||
|
"link.until": "until {date}",
|
||||||
|
"link.readOnly": "Shared read-only",
|
||||||
|
"git.suggest": "Suggest message",
|
||||||
|
"git.suggestHint": "Let the small model write a commit message for the staged changes",
|
||||||
|
"settings.weeklyDigest": "Weekly review on Mondays at 8:00 (chats, checks, costs)",
|
||||||
|
"settings.transcribeModel": "Speech-to-text model",
|
||||||
|
"settings.transcribeHint": "provider/model of an OpenAI-compatible transcription endpoint (e.g. openai/whisper-1). Adds the microphone button in browsers without built-in dictation, such as Firefox.",
|
||||||
|
"voice.transcribing": "Transcribing …",
|
||||||
|
"offline.banner": "No connection – showing saved copies. Messages you write wait in the outbox.",
|
||||||
|
"settings.planModel": "Planning model",
|
||||||
|
"settings.planModelHint": "Plan mode always uses this model (e.g. a strong one); all other modes use the model chosen for the chat (e.g. a cheaper one). Empty = the chat's model.",
|
||||||
|
"settings.sameAsChat": "(model of the chat)",
|
||||||
|
"jobEditor.notify": "Send the report as a push notification when a run ends",
|
||||||
|
"stats.year": "Year",
|
||||||
|
"stats.month": "Month",
|
||||||
|
"stats.perMonth": "Per month",
|
||||||
|
"stats.costPerMonth": "Cost per month",
|
||||||
|
"stats.tokensPerMonth": "Tokens per month",
|
||||||
|
"stats.export": "Export",
|
||||||
|
"stats.exportHint": "Download the figures of the selected range",
|
||||||
|
"stats.exportXlsx": "Excel (.xlsx) – all tables",
|
||||||
|
"stats.exportCsv": "CSV – {table}",
|
||||||
|
"stats.table.day": "per day",
|
||||||
|
"stats.table.month": "per month",
|
||||||
|
"stats.table.model": "by model",
|
||||||
|
"stats.table.provider": "by provider",
|
||||||
|
"stats.table.agent": "by agent",
|
||||||
|
"stats.table.user": "by user",
|
||||||
|
"stats.table.session": "top chats",
|
||||||
|
"command.clear": "Forget the conversation so far (the chat stays visible)",
|
||||||
|
"chat.cleared": "Context cleared – the model no longer sees the messages above",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Messages = Record<keyof typeof en, string>;
|
export type Messages = Record<keyof typeof en, string>;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Info, Message, Part, Session } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only copy of the app's last state for the offline case (train, tunnel): the chat
|
||||||
|
* list and the most recently opened chats. Kept small: attachments are dropped and long
|
||||||
|
* tool output is cut. Everything is best-effort – storage may be full or blocked.
|
||||||
|
*/
|
||||||
|
const KEY = "codecraft.offline.v1";
|
||||||
|
const MAX_CHATS = 6;
|
||||||
|
const MAX_MESSAGES = 80;
|
||||||
|
const MAX_BYTES = 2_500_000;
|
||||||
|
|
||||||
|
interface Cache {
|
||||||
|
info?: Info;
|
||||||
|
sessions?: Session[];
|
||||||
|
chats: Record<string, { at: number; session: Session; messages: Message[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function read(): Cache {
|
||||||
|
try {
|
||||||
|
const c = JSON.parse(localStorage.getItem(KEY) ?? "null");
|
||||||
|
if (c && typeof c === "object") return { chats: {}, ...c };
|
||||||
|
} catch {
|
||||||
|
/* empty or corrupt */
|
||||||
|
}
|
||||||
|
return { chats: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(c: Cache) {
|
||||||
|
try {
|
||||||
|
// Oldest chats go first when the copy gets too big.
|
||||||
|
let text = JSON.stringify(c);
|
||||||
|
while (text.length > MAX_BYTES && Object.keys(c.chats).length > 0) {
|
||||||
|
const oldest = Object.entries(c.chats).sort((a, b) => a[1].at - b[1].at)[0][0];
|
||||||
|
delete c.chats[oldest];
|
||||||
|
text = JSON.stringify(c);
|
||||||
|
}
|
||||||
|
localStorage.setItem(KEY, text);
|
||||||
|
} catch {
|
||||||
|
/* storage full or unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function slim(part: Part): Part {
|
||||||
|
if (part.type === "image") return { type: "text", text: "[image]" } as Part;
|
||||||
|
if (part.type === "tool_result") return { ...part, content: part.content.slice(0, 1500), images: [], meta: undefined } as Part;
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheInfo(info: Info) {
|
||||||
|
if (!info.user) return;
|
||||||
|
write({ ...read(), info });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachedInfo(): Info | undefined {
|
||||||
|
return read().info;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheSessions(sessions: Session[]) {
|
||||||
|
write({ ...read(), sessions });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachedSessions(): Session[] {
|
||||||
|
return read().sessions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheChat(session: Session, messages: Message[]) {
|
||||||
|
const c = read();
|
||||||
|
c.chats[session.id] = {
|
||||||
|
at: Date.now(),
|
||||||
|
session,
|
||||||
|
messages: messages.slice(-MAX_MESSAGES).map((m) => ({ ...m, parts: m.parts.map(slim) })),
|
||||||
|
};
|
||||||
|
const ids = Object.entries(c.chats).sort((a, b) => b[1].at - a[1].at);
|
||||||
|
for (const [id] of ids.slice(MAX_CHATS)) delete c.chats[id];
|
||||||
|
write(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachedChat(id: string) {
|
||||||
|
return read().chats[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request failed because there is no connection (as opposed to an answer from the server). */
|
||||||
|
export function isOffline(e: unknown): boolean {
|
||||||
|
return e instanceof TypeError || !navigator.onLine;
|
||||||
|
}
|
||||||
+28
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import { cacheChat, cacheSessions, cachedChat, cachedSessions, isOffline } from "./offline";
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { get } from "./api";
|
import { get } from "./api";
|
||||||
import type {
|
import type {
|
||||||
@@ -29,6 +30,8 @@ export interface State {
|
|||||||
/** Newer release (admins). */
|
/** Newer release (admins). */
|
||||||
update: { version: string; url: string; notes: string } | null;
|
update: { version: string; url: string; notes: string } | null;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
/** Showing a saved copy: the server cannot be reached. */
|
||||||
|
offline: boolean;
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
/** Includes subagent sessions loaded on demand. */
|
/** Includes subagent sessions loaded on demand. */
|
||||||
sessionById: Record<string, Session>;
|
sessionById: Record<string, Session>;
|
||||||
@@ -60,6 +63,7 @@ let state: State = {
|
|||||||
me: null,
|
me: null,
|
||||||
update: null,
|
update: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
offline: false,
|
||||||
sessions: [],
|
sessions: [],
|
||||||
sessionById: {},
|
sessionById: {},
|
||||||
messages: {},
|
messages: {},
|
||||||
@@ -236,7 +240,17 @@ export function dismissError(sessionId: string, id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSessions() {
|
export async function loadSessions() {
|
||||||
const list = await get<Session[]>("/sessions");
|
let list: Session[];
|
||||||
|
try {
|
||||||
|
list = await get<Session[]>("/sessions");
|
||||||
|
cacheSessions(list);
|
||||||
|
if (getState().offline) setState(() => ({ offline: false }));
|
||||||
|
} catch (e) {
|
||||||
|
if (!isOffline(e)) throw e;
|
||||||
|
list = cachedSessions().map((x) => ({ ...x, busy: false }));
|
||||||
|
setState(() => ({ offline: true }));
|
||||||
|
if (list.length === 0) throw e;
|
||||||
|
}
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
sessions: list,
|
sessions: list,
|
||||||
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
|
sessionById: { ...s.sessionById, ...Object.fromEntries(list.map((x) => [x.id, x])) },
|
||||||
@@ -250,9 +264,19 @@ export const PAGE = 200;
|
|||||||
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
||||||
|
|
||||||
export async function loadSession(id: string) {
|
export async function loadSession(id: string) {
|
||||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
|
type Loaded = { session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean };
|
||||||
`/sessions/${id}?limit=${PAGE}`,
|
let data: Loaded;
|
||||||
);
|
try {
|
||||||
|
data = await get<Loaded>(`/sessions/${id}?limit=${PAGE}`);
|
||||||
|
cacheChat(data.session, data.messages);
|
||||||
|
if (getState().offline) setState(() => ({ offline: false }));
|
||||||
|
} catch (e) {
|
||||||
|
const copy = isOffline(e) ? cachedChat(id) : undefined;
|
||||||
|
if (!copy) throw e;
|
||||||
|
// No connection: show the saved copy; new messages wait in the outbox.
|
||||||
|
data = { session: copy.session, messages: copy.messages, busy: false, can_write: true, queue: [], has_older: true };
|
||||||
|
setState(() => ({ offline: true }));
|
||||||
|
}
|
||||||
setState((s) => ({
|
setState((s) => ({
|
||||||
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||||
...upsertSession(s, data.session),
|
...upsertSession(s, data.session),
|
||||||
|
|||||||
+192
-40
@@ -524,38 +524,90 @@ button.session-group:hover {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
/* Message actions: hover on desktop, faintly visible on touch. */
|
/* Message actions: hover on desktop, faintly visible on touch. */
|
||||||
.msg-menu {
|
/* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
|
||||||
position: absolute;
|
* the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
|
||||||
top: 2px;
|
.msg-foot {
|
||||||
right: 2px;
|
display: flex;
|
||||||
z-index: 3;
|
align-items: center;
|
||||||
opacity: 0;
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.msg-foot .msg-time {
|
||||||
|
margin-top: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop {
|
||||||
|
position: static; /* the popup is placed against the bubble */
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.45;
|
||||||
transition: opacity 0.12s;
|
transition: opacity 0.12s;
|
||||||
}
|
}
|
||||||
.msg:hover > .msg-menu,
|
.msg:hover .msg-menu,
|
||||||
.msg-menu:focus-within,
|
.msg-menu:focus-within,
|
||||||
.msg-menu[open] {
|
.msg-menu[open] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@media (pointer: coarse) {
|
.msg-menu.menu-pop > summary {
|
||||||
.msg-menu {
|
padding: 0 4px;
|
||||||
opacity: 0.5;
|
border-radius: 6px;
|
||||||
}
|
|
||||||
}
|
|
||||||
.msg-menu > summary {
|
|
||||||
background: var(--bg1);
|
|
||||||
border: 1px solid var(--bg2);
|
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
.msg-menu.menu-pop > summary:hover {
|
||||||
|
background: color-mix(in srgb, var(--fg1) 14%, transparent);
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop .menu-pop-body {
|
||||||
|
top: auto;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 0;
|
||||||
|
width: max-content;
|
||||||
|
max-width: max(calc(100% - 16px), 11rem);
|
||||||
|
}
|
||||||
|
.msg-menu.menu-pop .menu-pop-body a {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
|
||||||
.msg-user {
|
.msg-user {
|
||||||
background: var(--bg1);
|
background: color-mix(in srgb, var(--accent) 26%, var(--bg1));
|
||||||
border: 1px solid var(--bg2);
|
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
border-radius: var(--radius);
|
border-radius: 18px 4px 18px 18px;
|
||||||
padding: 8px 12px;
|
color: var(--fg0);
|
||||||
|
padding: 9px 14px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.msg.user-msg {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: min(88%, 780px);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.msg.bubble.agent {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg2);
|
||||||
|
border-radius: 4px 18px 18px 18px;
|
||||||
|
}
|
||||||
|
.msg.bubble.agent > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .msg-user {
|
||||||
|
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
||||||
|
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .msg.bubble.agent {
|
||||||
|
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.msg.user-msg {
|
||||||
|
max-width: 94%;
|
||||||
|
}
|
||||||
|
}
|
||||||
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
/* User messages are Markdown too; the line breaks come from keepLineBreaks. */
|
||||||
.msg-user .markdown {
|
.msg-user .markdown {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
@@ -578,7 +630,7 @@ button.session-group:hover {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.msg-user .msg-time {
|
.msg-user .msg-foot .msg-time {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.msg-user img {
|
.msg-user img {
|
||||||
@@ -4687,41 +4739,64 @@ span.chat-tab > button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- colour tags of chats ------------------------------------------------------
|
/* --- colour tags of chats ------------------------------------------------------
|
||||||
* The tag is a name; the colour comes from the active theme (--red, --accent, …),
|
* The tag is a name. Eight clearly separated hues, drawn with a lightness that fits the theme
|
||||||
* the background is mixed into --bg0, so every theme (light or dark) stays readable. */
|
* (light or dark). Browsers without relative colours use the theme's own colours.
|
||||||
[data-color="red"] { --tag: var(--red); }
|
* Backgrounds are mixed into --bg0, so text stays readable. */
|
||||||
[data-color="orange"] { --tag: var(--accent2, var(--red)); }
|
[data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; }
|
||||||
[data-color="yellow"] { --tag: var(--accent, var(--green)); }
|
[data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; }
|
||||||
[data-color="green"] { --tag: var(--green); }
|
[data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; }
|
||||||
[data-color="aqua"] { --tag: var(--aqua); }
|
[data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; }
|
||||||
[data-color="blue"] { --tag: var(--blue); }
|
[data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; }
|
||||||
[data-color="purple"] { --tag: var(--purple); }
|
[data-color="blue"] { --tag: var(--blue); --tag-h: 250; --tag-c: 0.12; }
|
||||||
|
[data-color="purple"] { --tag: var(--purple); --tag-h: 297; --tag-c: 0.135; }
|
||||||
|
[data-color="pink"] { --tag: var(--red); --tag-h: 338; --tag-c: 0.17; }
|
||||||
|
@supports (color: oklch(from red l c h)) {
|
||||||
|
/* Lightness 0.76 on dark and 0.60 on light themes (from the theme's background), chroma per hue
|
||||||
|
* as high as sRGB allows at both – vivid and still clearly different from each other. */
|
||||||
|
[data-color] {
|
||||||
|
--tag: oklch(from var(--bg0) calc(0.6 + 0.16 * clamp(0, (0.5 - l) * 20, 1)) var(--tag-c) var(--tag-h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat-pane[data-color] {
|
.chat-pane[data-color] {
|
||||||
background: color-mix(in srgb, var(--tag) 9%, var(--bg0));
|
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||||
|
}
|
||||||
|
.chat-pane[data-color] .composer-wrap {
|
||||||
|
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||||
}
|
}
|
||||||
.topbar[data-color] {
|
.topbar[data-color] {
|
||||||
border-bottom: 2px solid var(--tag);
|
border-bottom: 3px solid var(--tag);
|
||||||
background: color-mix(in srgb, var(--tag) 14%, var(--bg0));
|
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
|
||||||
}
|
}
|
||||||
.session-item[data-color] {
|
.session-item[data-color] {
|
||||||
box-shadow: inset 3px 0 0 var(--tag);
|
box-shadow: inset 4px 0 0 var(--tag);
|
||||||
}
|
}
|
||||||
.color-badge {
|
.color-badge {
|
||||||
padding: 0 7px;
|
padding: 0 8px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--fg0);
|
color: var(--fg0);
|
||||||
background: color-mix(in srgb, var(--tag) 30%, var(--bg1));
|
background: color-mix(in srgb, var(--tag) 42%, var(--bg1));
|
||||||
border: 1px solid color-mix(in srgb, var(--tag) 60%, transparent);
|
border: 1px solid color-mix(in srgb, var(--tag) 75%, transparent);
|
||||||
|
}
|
||||||
|
.chat-icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
}
|
||||||
|
[data-color] .chat-icon {
|
||||||
|
color: var(--tag);
|
||||||
|
}
|
||||||
|
.color-badge .chat-icon {
|
||||||
|
color: var(--fg0);
|
||||||
}
|
}
|
||||||
.color-swatches {
|
.color-swatches {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
}
|
}
|
||||||
.color-swatches .swatch {
|
.color-swatches .swatch {
|
||||||
width: 20px;
|
width: 22px;
|
||||||
height: 20px;
|
height: 22px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--tag);
|
background: var(--tag);
|
||||||
@@ -4731,8 +4806,28 @@ span.chat-tab > button {
|
|||||||
border-color: var(--fg0);
|
border-color: var(--fg0);
|
||||||
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
|
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
|
||||||
}
|
}
|
||||||
.chat-pane[data-color] .composer-wrap {
|
.icon-picker {
|
||||||
background: color-mix(in srgb, var(--tag) 9%, var(--bg0));
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(32px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px 8px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.icon-choice {
|
||||||
|
padding: 5px;
|
||||||
|
line-height: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
.icon-choice:hover {
|
||||||
|
background: var(--bg2);
|
||||||
|
}
|
||||||
|
.icon-choice.on {
|
||||||
|
color: var(--tag, var(--accent));
|
||||||
|
border-color: var(--tag, var(--accent));
|
||||||
|
background: var(--bg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- check badge of a turn ------------------------------------------------------ */
|
/* --- check badge of a turn ------------------------------------------------------ */
|
||||||
@@ -4870,3 +4965,60 @@ span.chat-tab > button {
|
|||||||
.tl-meta .check-timeout {
|
.tl-meta .check-timeout {
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Foldable statistics sections */
|
||||||
|
.fold-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.fold-caret {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid currentColor;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
border-bottom: 4px solid transparent;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.fold-toggle[aria-expanded="true"] .fold-caret {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.panel.fold.closed h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.fold-group td {
|
||||||
|
background: var(--bg2, color-mix(in srgb, var(--fg, #888) 6%, transparent));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
td.fold-indent {
|
||||||
|
padding-left: 1.6em;
|
||||||
|
}
|
||||||
|
.snippet-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.snippet-slash {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-chat { max-width: 820px; margin: 0 auto; padding: 16px; }
|
||||||
|
.public-chat header { margin-bottom: 12px; }
|
||||||
|
.public-chat h1 { font-size: 1.2rem; margin: 0 0 4px; }
|
||||||
|
.public-chat .chat-scroll { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.share-links { margin-top: 14px; border-top: 1px solid var(--border, #8884); padding-top: 10px; }
|
||||||
|
.share-links h4 { margin: 0 0 4px; }
|
||||||
|
.share-link { margin-top: 6px; flex-wrap: wrap; }
|
||||||
|
.share-link input { flex: 1 1 200px; min-width: 0; }
|
||||||
|
|
||||||
|
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
|
||||||
@@ -63,6 +63,8 @@ export interface Session {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
|
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** Icon name of the chat (from the picker). */
|
||||||
|
icon?: string;
|
||||||
todos: Todo[] | null;
|
todos: Todo[] | null;
|
||||||
last_usage?: Usage | null;
|
last_usage?: Usage | null;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
|
|||||||
@@ -3,6 +3,32 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00031": {
|
||||||
|
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
|
||||||
|
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
|
||||||
|
},
|
||||||
|
"202611-00030": {
|
||||||
|
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
|
||||||
|
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
|
||||||
|
},
|
||||||
|
"202611-00029": {
|
||||||
|
en: [
|
||||||
|
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
|
||||||
|
"Prompt snippets (/name), read-only links for chats, commit message suggestions, weekly review, offline copies of recent chats, planning model",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"Statistik: zuklappbare Abschnitte, Historie über Jahre mit Jahresfilter, Export als XLSX/CSV",
|
||||||
|
"Prompt-Bausteine (/name), Nur-Lese-Links für Chats, Commit-Vorschläge, Wochenrückblick, Offline-Kopien der letzten Chats, Planungsmodell",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00028": {
|
||||||
|
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
|
||||||
|
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
|
||||||
|
},
|
||||||
|
"202611-00027": {
|
||||||
|
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
|
||||||
|
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],
|
||||||
|
},
|
||||||
"202611-00026": {
|
"202611-00026": {
|
||||||
en: [
|
en: [
|
||||||
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
||||||
|
|||||||
Reference in new issue
Block a user