Split view, personal modes, budget warnings, server move script
- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ee084ff2
commit
d152ad836d
26 files changed
+836
-52
No files matched your search
+76
-7
@@ -15,7 +15,7 @@ use axum::middleware;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use axum::{Extension, Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -78,6 +78,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/title", post(suggest_title))
|
||||
.route("/sessions/{id}/regenerate", post(regenerate))
|
||||
.route("/sessions/{id}/worktree", get(worktree_status).post(worktree_action))
|
||||
.route("/sessions/{id}/tabs", get(session_tabs).put(put_file_tabs))
|
||||
.route("/sessions/{id}/export", get(export_session))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
@@ -136,6 +137,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||
.route("/account/modes", get(get_personal_modes).put(put_personal_modes))
|
||||
.route("/templates", get(crate::cli_api::get_templates))
|
||||
.route("/templates/{scope}", put(crate::cli_api::put_templates))
|
||||
.route("/cli/config", get(crate::cli_api::config))
|
||||
@@ -322,13 +324,43 @@ fn scope(app: &App, user: &User, cwd: Option<String>) -> (PathBuf, PathBuf) {
|
||||
|
||||
async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
||||
Json(json!(prompts::agents_for(&app, &root, &cwd, Some(&user.id))))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
/// Built-in tools for the mode editor (name, read-only, first line of the description).
|
||||
async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
|
||||
require_admin(&user)?;
|
||||
/// The user's personal modes (name -> settings).
|
||||
async fn get_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(prompts::personal_modes(&app, &user.id))))
|
||||
}
|
||||
|
||||
/// Replace the user's personal modes. Names of built-in, admin or project modes are
|
||||
/// taken; tools and read-only work like in admin modes (the user's sandbox still applies).
|
||||
async fn put_personal_modes(Extension(user): Extension<User>, State(app): State<AppState>, Json(modes): Json<std::collections::BTreeMap<String, crate::config::AgentConfig>>) -> ApiResult {
|
||||
if modes.len() > 30 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many personal modes (max 30)".into()));
|
||||
}
|
||||
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||
let taken: Vec<String> = prompts::agents(&app, &root, &root).into_iter().map(|a| a.name).collect();
|
||||
let mut clean = std::collections::BTreeMap::new();
|
||||
for (name, mut m) in modes {
|
||||
if name.is_empty() || name.len() > 40 || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid mode name '{name}' (letters, digits, - and _)")));
|
||||
}
|
||||
if taken.contains(&name) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, format!("the name '{name}' is already used by another mode")));
|
||||
}
|
||||
if m.prompt.as_deref().is_some_and(|p| p.len() > 20_000) {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "prompt too long (max 20,000 characters)".into()));
|
||||
}
|
||||
m.mode = Some("primary".into());
|
||||
m.max_parallel = None;
|
||||
clean.insert(name, m);
|
||||
}
|
||||
app.store.set_user_setting(&user.id, "modes", &clean)?;
|
||||
Ok(Json(json!(clean)))
|
||||
}
|
||||
|
||||
/// Built-in tools for the mode editors (name, read-only, first line of the description).
|
||||
async fn agent_tools() -> ApiResult {
|
||||
let list: Vec<Value> = crate::tools::builtin_tools()
|
||||
.iter()
|
||||
.map(|t| json!({"name": t.name(), "read_only": t.read_only(), "description": t.description().split(['.', '\n']).next().unwrap_or("").trim()}))
|
||||
@@ -336,6 +368,7 @@ async fn agent_tools(Extension(user): Extension<User>) -> ApiResult {
|
||||
Ok(Json(json!(list)))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
|
||||
@@ -756,6 +789,40 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
Ok(Json(json!(s)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct FileTab {
|
||||
id: String,
|
||||
dir: String,
|
||||
}
|
||||
|
||||
fn file_tabs_key(session_id: &str) -> String {
|
||||
format!("file_tabs:{session_id}")
|
||||
}
|
||||
|
||||
/// Tabs of a chat for this user: its terminals and file managers (kept on the server,
|
||||
/// so they are the same on every device).
|
||||
async fn session_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
let files: Vec<FileTab> = app.store.user_setting(&user.id, &file_tabs_key(&id))?;
|
||||
Ok(Json(json!({"terminals": app.terminals.for_session(&id, &user.id), "files": files})))
|
||||
}
|
||||
|
||||
async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(tabs): Json<Vec<FileTab>>) -> ApiResult {
|
||||
readable(&app, &user, &id)?;
|
||||
if tabs.len() > 20 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "too many file manager tabs".into()));
|
||||
}
|
||||
let roots = app.user_roots(Some(&user.id));
|
||||
for t in &tabs {
|
||||
let p = crate::tools::normalize(&PathBuf::from(&t.dir));
|
||||
if t.id.len() > 64 || !roots.iter().any(|r| p.starts_with(r)) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "folder is outside your folders".into()));
|
||||
}
|
||||
}
|
||||
app.store.set_user_setting(&user.id, &file_tabs_key(&id), &tabs)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
// Notify before deleting: visibility is checked against the stored session.
|
||||
@@ -1225,7 +1292,9 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
|
||||
(name, v)
|
||||
})
|
||||
.collect();
|
||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
|
||||
// Budgets and plan windows (admins: all; plan windows are shown per provider anyway).
|
||||
let budgets = if admin { crate::budget::meters(&app).unwrap_or_default() } else { vec![] };
|
||||
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp, "budgets": budgets, "currency": cfg.server.currency}))
|
||||
}
|
||||
|
||||
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||
|
||||
Reference in new issue
Block a user