use axum::{
extract::{Path, Query},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, OnceLock};
use crate::{config, core, logging, shared};
type FolderPicker =
dyn Fn(Option<String>) -> anyhow::Result<Option<String>> + Send + Sync + 'static;
static FOLDER_PICKER: OnceLock<Arc<FolderPicker>> = OnceLock::new();
#[derive(Serialize)]
struct ApiResponse<T: Serialize> {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
fn success(data: T) -> Json<Self> {
Json(Self {
ok: true,
data: Some(data),
error: None,
})
}
}
fn api_error(status: StatusCode, msg: impl ToString) -> impl IntoResponse {
let message = msg.to_string();
logging::error("api_error", &message);
(
status,
Json(ApiResponse::<()> {
ok: false,
data: None,
error: Some(message),
}),
)
}
pub fn router() -> Router {
Router::new()
.route("/api/v1/meta", get(get_meta))
.route("/api/v1/providers", get(list_providers))
.route("/api/v1/settings", get(get_settings).put(update_settings))
.route("/api/v1/system/select-folder", post(select_folder))
.route("/api/v1/sessions", get(list_sessions))
.route("/api/v1/sessions/{provider}/{session_id}", get(get_session))
.route(
"/api/v1/sessions/{provider}/{session_id}",
delete(delete_session)
.patch(rename_session)
.put(update_session_local_state),
)
.route("/api/v1/export", post(export_session))
.route("/api/v1/import", post(import_session))
.route("/api/v1/switch", post(switch_session))
.route("/api/v1/find", get(find_sessions))
.route("/api/v1/workspaces", get(list_workspaces))
.route(
"/api/v1/workspaces/history",
delete(remove_workspace_history),
)
.route(
"/api/v1/workspaces/providers",
get(get_workspace_providers).put(update_workspace_providers),
)
.route(
"/api/v1/share",
get(list_shared_sessions).post(create_shared_session),
)
.route("/api/v1/share/status", get(shared_status))
.route("/api/v1/share/sync", post(sync_shared_sessions))
.route("/api/v1/share/bind", post(bind_shared_session))
.route(
"/api/v1/share/holdings/{group_id}/{holding_id}",
delete(unbind_shared_session),
)
.route(
"/api/v1/share/{group_id}",
delete(remove_shared_session).patch(rename_shared_session),
)
.route("/api/v1/manager/preview", post(manager_preview))
.route("/api/v1/manager/clean", post(manager_clean))
.route("/api/v1/manager/backup", post(manager_backup))
}
pub fn register_folder_picker<F>(picker: F) -> bool
where
F: Fn(Option<String>) -> anyhow::Result<Option<String>> + Send + Sync + 'static,
{
FOLDER_PICKER.set(Arc::new(picker)).is_ok()
}
#[derive(Debug, Serialize)]
struct ProviderInfo {
id: String,
name: String,
scan: bool,
import: bool,
export: bool,
delete: bool,
rename: bool,
resume: bool,
}
#[derive(Debug, Serialize)]
struct SettingsPayload {
sessions_per_provider: usize,
language: config::UiLanguage,
show_opencode_subagents: bool,
#[serde(default)]
default_backup_dir: String,
logging: config::LogPreferences,
home_buttons: config::HomeButtonConfig,
agent_order: Vec<String>,
primary_agents: Vec<String>,
}
#[derive(Debug, Serialize)]
struct MetaPayload {
version: &'static str,
providers: Vec<ProviderInfo>,
selected_workspace: Option<String>,
workspaces: Vec<config::WorkspaceEntry>,
settings: SettingsPayload,
}
#[derive(Debug, Serialize)]
struct SessionDetailPayload {
view: core::SessionDetailView,
}
#[derive(Deserialize)]
struct SettingsBody {
sessions_per_provider: usize,
language: config::UiLanguage,
show_opencode_subagents: bool,
default_backup_dir: String,
#[serde(default)]
logging: config::LogPreferences,
home_buttons: config::HomeButtonConfig,
#[serde(default)]
agent_order: Vec<String>,
#[serde(default)]
primary_agents: Vec<String>,
}
#[derive(Deserialize)]
struct SelectFolderBody {
start_path: Option<String>,
}
#[derive(Serialize)]
struct SelectFolderPayload {
path: Option<String>,
}
fn provider_info_list() -> Vec<ProviderInfo> {
crate::providers::all_provider_ids()
.iter()
.filter_map(|id| crate::providers::find_provider(id))
.map(|provider| {
let capabilities = provider.capabilities();
ProviderInfo {
id: provider.id().to_string(),
name: provider.name().to_string(),
scan: capabilities.scan,
import: capabilities.import,
export: capabilities.export,
delete: capabilities.delete,
rename: capabilities.rename,
resume: capabilities.resume,
}
})
.collect()
}
fn settings_payload() -> anyhow::Result<SettingsPayload> {
let prefs = config::web_preferences()?;
Ok(SettingsPayload {
sessions_per_provider: prefs.sessions_per_provider,
language: prefs.language,
show_opencode_subagents: prefs.show_opencode_subagents,
default_backup_dir: prefs.default_backup_dir.clone(),
logging: prefs.logging.clone(),
home_buttons: prefs.home_buttons.clone(),
agent_order: config::ordered_provider_ids(&prefs),
primary_agents: config::primary_provider_ids(&prefs),
})
}
async fn get_meta() -> impl IntoResponse {
match (
settings_payload(),
config::selected_workspace(),
config::known_workspaces(),
) {
(Ok(settings), Ok(selected_workspace), Ok(workspaces)) => {
ApiResponse::success(MetaPayload {
version: env!("CARGO_PKG_VERSION"),
providers: provider_info_list(),
selected_workspace,
workspaces,
settings,
})
.into_response()
}
(Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => {
api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
}
}
}
async fn list_providers() -> impl IntoResponse {
ApiResponse::success(provider_info_list()).into_response()
}
async fn select_folder(Json(body): Json<SelectFolderBody>) -> impl IntoResponse {
let Some(picker) = FOLDER_PICKER.get() else {
return api_error(
StatusCode::NOT_IMPLEMENTED,
"Folder picker is only available in the desktop app.",
)
.into_response();
};
match picker(body.start_path) {
Ok(path) => ApiResponse::success(SelectFolderPayload { path }).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn get_settings() -> impl IntoResponse {
match settings_payload() {
Ok(settings) => ApiResponse::success(settings).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn update_settings(Json(body): Json<SettingsBody>) -> impl IntoResponse {
let update_result = config::update_web_preferences(
Some(body.sessions_per_provider),
Some(body.language),
Some(body.show_opencode_subagents),
Some(body.default_backup_dir),
Some(body.logging),
)
.and_then(|_| config::update_home_button_config(body.home_buttons))
.and_then(|_| config::update_agent_display_preferences(body.agent_order, body.primary_agents));
match update_result.and_then(|_| settings_payload()) {
Ok(settings) => ApiResponse::success(settings).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ListQuery {
all: Option<bool>,
provider: Option<String>,
dir: Option<String>,
workspace: Option<String>,
details: Option<bool>,
}
async fn list_sessions(Query(q): Query<ListQuery>) -> impl IntoResponse {
let providers: Vec<String> = q
.provider
.map(|p| p.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let cwd = q.workspace.or(q.dir).or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
});
if let Some(workspace) = cwd.as_deref() {
let _ = config::remember_workspace(std::path::Path::new(workspace));
if !providers.is_empty() {
let _ = config::set_workspace_providers(workspace, providers.clone());
}
}
let params = core::SessionListParams {
all: q.all.unwrap_or(false),
providers,
cwd,
include_message_counts: q.details.unwrap_or(true),
};
match core::list_sessions(¶ms) {
Ok(groups) => ApiResponse::success(groups).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn list_workspaces() -> impl IntoResponse {
match config::known_workspaces() {
Ok(workspaces) => ApiResponse::success(workspaces).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct WorkspaceHistoryBody {
workspace: String,
}
async fn remove_workspace_history(Json(body): Json<WorkspaceHistoryBody>) -> impl IntoResponse {
match config::remove_workspace_history(&body.workspace) {
Ok(workspaces) => ApiResponse::success(workspaces).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct WorkspaceQuery {
workspace: String,
}
async fn get_workspace_providers(Query(q): Query<WorkspaceQuery>) -> impl IntoResponse {
match config::workspace_providers(&q.workspace) {
Ok(providers) => ApiResponse::success(providers).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct WorkspaceProvidersBody {
workspace: String,
providers: Vec<String>,
}
async fn update_workspace_providers(Json(body): Json<WorkspaceProvidersBody>) -> impl IntoResponse {
match config::set_workspace_providers(&body.workspace, body.providers) {
Ok(()) => match config::workspace_providers(&body.workspace) {
Ok(providers) => ApiResponse::success(providers).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
},
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn get_session(Path((provider, session_id)): Path<(String, String)>) -> impl IntoResponse {
match core::get_session_detail_view(&provider, &session_id) {
Ok(view) => {
if let Some(project_dir) = view.workspace_dir.as_deref() {
let _ = config::remember_workspace(std::path::Path::new(project_dir));
}
ApiResponse::success(SessionDetailPayload { view }).into_response()
}
Err(e) => api_error(StatusCode::NOT_FOUND, e).into_response(),
}
}
async fn delete_session(Path((provider, session_id)): Path<(String, String)>) -> impl IntoResponse {
match core::delete_session(&provider, &session_id) {
Ok(()) => ApiResponse::success("deleted").into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct RenameBody {
title: String,
}
async fn rename_session(
Path((provider, session_id)): Path<(String, String)>,
Json(body): Json<RenameBody>,
) -> impl IntoResponse {
match core::rename_session(&provider, &session_id, &body.title) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn update_session_local_state(
Path((provider, session_id)): Path<(String, String)>,
Json(body): Json<crate::storage::session_state::SessionLocalStateUpdate>,
) -> impl IntoResponse {
match core::update_session_local_state(&provider, &session_id, &body) {
Ok(state) => ApiResponse::success(state).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ExportBody {
provider: String,
session_id: String,
output_prefix: Option<String>,
#[serde(default = "default_format")]
format: String,
}
fn default_format() -> String {
"both".to_string()
}
async fn export_session(Json(body): Json<ExportBody>) -> impl IntoResponse {
let params = core::ExportParams {
provider: body.provider,
session_id: body.session_id,
output_prefix: body.output_prefix,
format: body.format,
};
match core::export_session(¶ms) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ImportBody {
provider: String,
file_or_id: String,
to_dir: Option<String>,
}
async fn import_session(Json(body): Json<ImportBody>) -> impl IntoResponse {
let params = core::ImportParams {
provider: body.provider,
file_or_id: body.file_or_id,
to_dir: body.to_dir,
};
match core::import_session(¶ms) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct SwitchBody {
from: String,
to: String,
session_id: Option<String>,
to_dir: Option<String>,
}
async fn switch_session(Json(body): Json<SwitchBody>) -> impl IntoResponse {
let params = core::SwitchParams {
from: body.from,
to: body.to,
session_id: body.session_id,
to_dir: body.to_dir,
};
match core::switch_session(¶ms) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct FindQuery {
dir: Option<String>,
session: Option<String>,
provider: Option<String>,
}
async fn find_sessions(Query(q): Query<FindQuery>) -> impl IntoResponse {
if q.dir.is_none() && q.session.is_none() && q.provider.is_none() {
return api_error(
StatusCode::BAD_REQUEST,
"At least one filter required: dir, session, or provider",
)
.into_response();
}
let providers = q
.provider
.map(|p| p.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
let params = core::FindParams {
dir: q.dir,
session: q.session,
providers,
};
match core::find_sessions(¶ms) {
Ok(groups) => ApiResponse::success(groups).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn list_shared_sessions() -> impl IntoResponse {
match shared::list_groups() {
Ok(items) => ApiResponse::success(items).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ShareCreateBody {
provider: String,
session_id: String,
#[serde(default)]
targets: Vec<String>,
to_dir: Option<String>,
title: Option<String>,
}
async fn create_shared_session(Json(body): Json<ShareCreateBody>) -> impl IntoResponse {
let params = shared::ShareCreateParams {
provider: body.provider,
session_id: body.session_id,
targets: body.targets,
to_dir: body.to_dir,
title: body.title,
};
match shared::create_group(¶ms) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ShareBindBody {
group_id: String,
provider: String,
session_id: Option<String>,
to_dir: Option<String>,
}
async fn bind_shared_session(Json(body): Json<ShareBindBody>) -> impl IntoResponse {
let params = shared::AddHoldingParams {
group_id: body.group_id,
provider: body.provider,
session_id: body.session_id,
to_dir: body.to_dir,
};
match shared::add_holding(¶ms) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn unbind_shared_session(
Path((group_id, holding_id)): Path<(String, String)>,
) -> impl IntoResponse {
match shared::remove_holding(&group_id, &holding_id) {
Ok(()) => ApiResponse::success("unbound").into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ShareStatusQuery {
group_id: Option<String>,
}
async fn shared_status(Query(q): Query<ShareStatusQuery>) -> impl IntoResponse {
match q.group_id {
Some(id) => match shared::load_group(&id) {
Ok(mut group) => {
let _ = shared::refresh_active_times(&mut group);
ApiResponse::success(group).into_response()
}
Err(e) => api_error(StatusCode::NOT_FOUND, e).into_response(),
},
None => match shared::list_groups() {
Ok(mut groups) => {
for group in &mut groups {
let _ = shared::refresh_active_times(group);
}
ApiResponse::success(groups).into_response()
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
},
}
}
#[derive(Deserialize)]
struct ShareSyncBody {
group_id: String,
source_holding_id: Option<String>,
}
async fn sync_shared_sessions(Json(body): Json<ShareSyncBody>) -> impl IntoResponse {
let result = if let Some(source_id) = body.source_holding_id {
shared::push_sync(&body.group_id, &source_id)
} else {
shared::sync_to_latest(&body.group_id)
};
match result {
Ok(report) => ApiResponse::success(report).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ShareRemoveQuery {
delete_provider_sessions: Option<bool>,
}
async fn remove_shared_session(
Path(group_id): Path<String>,
Query(q): Query<ShareRemoveQuery>,
) -> impl IntoResponse {
match shared::delete_group(&group_id, q.delete_provider_sessions.unwrap_or(false)) {
Ok(()) => ApiResponse::success("removed").into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ShareRenameBody {
title: String,
}
async fn rename_shared_session(
Path(group_id): Path<String>,
Json(body): Json<ShareRenameBody>,
) -> impl IntoResponse {
match shared::rename_group(&group_id, &body.title) {
Ok(()) => ApiResponse::success("renamed").into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ManagerPreviewBody {
#[serde(default)]
providers: Vec<String>,
older_than_days: Option<u32>,
older_than_ms: Option<i64>,
larger_than_mb: Option<u32>,
larger_than_bytes: Option<u64>,
smaller_than_bytes: Option<u64>,
workspace: Option<String>,
}
async fn manager_preview(Json(body): Json<ManagerPreviewBody>) -> impl IntoResponse {
let filter = crate::core::manager::ManagerFilter {
providers: body.providers,
older_than_days: body.older_than_days,
older_than_ms: body.older_than_ms,
larger_than_mb: body.larger_than_mb,
larger_than_bytes: body.larger_than_bytes,
smaller_than_bytes: body.smaller_than_bytes,
workspace: body.workspace,
};
match crate::core::manager::preview(&filter) {
Ok(result) => ApiResponse::success(result).into_response(),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
#[derive(Deserialize)]
struct ManagerItemsBody {
items: Vec<crate::core::manager::ManagerItem>,
output_dir: Option<String>,
}
async fn manager_clean(Json(body): Json<ManagerItemsBody>) -> impl IntoResponse {
let result = crate::core::manager::clean(&body.items);
logging::info(
"manager_clean",
format!(
"success={} failed={} freed_bytes={}",
result.success, result.failed, result.freed_bytes
),
);
ApiResponse::success(result).into_response()
}
async fn manager_backup(Json(body): Json<ManagerItemsBody>) -> impl IntoResponse {
let output_dir = body.output_dir.unwrap_or_else(|| "./backups".to_string());
let result = crate::core::manager::backup(&body.items, std::path::Path::new(&output_dir));
logging::info(
"manager_backup",
format!(
"success={} failed={} output_dir={}",
result.success, result.failed, output_dir
),
);
ApiResponse::success(result).into_response()
}