use anyhow::Context as _;
use axum::extract::State;
use axum::Json;
use super::{bad as fail, Reply, Shared};
use crate::api;
use crate::team::{self, TeamSettings};
fn done(r: anyhow::Result<impl Into<String>>) -> Reply<api::Done> {
r.map(|m| Json(api::Done::new(m))).map_err(fail)
}
pub(super) async fn root(app: &Shared) -> anyhow::Result<String> {
use anyhow::Context as _;
app.current
.read()
.await
.data
.root
.clone()
.context("read-only UI")
}
pub(super) fn defaults() -> api::Defaults {
api::Defaults {
llm_model: crate::llm::DEFAULT_MODEL.into(),
llm_base_url: crate::llm::DEFAULT_BASE_URL.into(),
embed_model: crate::embed::DEFAULT_MODEL.into(),
llm_concurrency: 8,
format: "md".into(),
}
}
pub(super) async fn get(who: super::Member, State(app): State<Shared>) -> Reply<api::Team> {
let run = async {
let root = root(&app).await?;
let mut stored = team::load(&root).await?;
if let Some(w) = who
.as_deref()
.filter(|w| !w.role.at_least(crate::server::Role::Admin))
{
for t in stored.settings.teams.iter_mut().flatten() {
t.members.retain(|m| m.eq_ignore_ascii_case(&w.email));
}
}
let key =
crate::credentials::llm_key(stored.settings.name.as_deref(), app.server.as_deref()).1;
anyhow::Ok(api::Team {
settings: stored.settings,
key_source: key,
version: stored.version,
changed_by: stored.changed_by,
changed_at: stored.changed_at,
defaults: defaults(),
})
};
run.await.map(Json).map_err(fail)
}
pub(super) async fn save(
who: super::Member,
State(app): State<Shared>,
Json(b): Json<api::TeamSave>,
) -> Reply<api::Done> {
let run = async {
let root = root(&app).await?;
let current = team::load(&root).await?;
let mut next = b.settings;
for v in [
&mut next.name,
&mut next.llm_base_url,
&mut next.llm_model,
&mut next.llm_model_summary,
&mut next.llm_model_guide,
&mut next.llm_model_flow,
&mut next.embed_model,
] {
if v.as_deref().is_some_and(|s| s.trim().is_empty()) {
*v = None;
}
}
if let Some(f) = next.format.as_deref() {
anyhow::ensure!(matches!(f, "md" | "json"), "the format is md or json");
}
anyhow::ensure!(
next.llm_concurrency.is_none_or(|n| (1..=64).contains(&n)),
"LLM requests at once: between 1 and 64"
);
next.teams = current.settings.teams.clone();
let reindex = next.embed_model != current.settings.embed_model;
let format_changed = next.format != current.settings.format;
match who.as_deref() {
Some(w) => team::save_as(&root, &next, b.version, &w.email).await?,
None => crate::config::save_team(&root, &next, b.version).await?,
};
app.set_team(next);
if format_changed {
app.reload_mcp(&root);
}
anyhow::Ok(if reindex {
"saved; the new embedding model needs a full re-index"
} else {
"saved for everyone using this index"
})
};
done(run.await)
}
pub(super) async fn key(
State(app): State<Shared>,
Json(b): Json<api::KeyRequest>,
) -> Reply<api::Done> {
let run = async {
anyhow::ensure!(
app.server.is_none(),
"on a team server the LLM key is set under Settings → Integrations"
);
let root = root(&app).await?;
let name = team::load(&root).await?.settings.name;
let (n, k) = (name.clone(), b.key.clone());
tokio::task::spawn_blocking(move || crate::credentials::set_llm_key(n.as_deref(), &k))
.await??;
anyhow::Ok(match name {
Some(n) if !b.key.trim().is_empty() => format!("saved as your key for {n}"),
None if !b.key.trim().is_empty() => "saved as your default key".to_string(),
_ => "removed your key".to_string(),
})
};
done(run.await)
}
pub(super) async fn join(
State(app): State<Shared>,
Json(b): Json<api::JoinRequest>,
) -> Reply<api::Done> {
let run = async {
let root = crate::config::expand_tilde(b.index.trim());
let indexed = has_index(&root).await?;
let stored = team::load(&root).await?;
anyhow::ensure!(
indexed || stored.version.is_some(),
"no team or index at {root}"
);
use_location(&app, &root, stored.settings.clone()).await?;
anyhow::Ok(format!("joined {}", stored.settings.name.unwrap_or(root)))
};
done(run.await)
}
pub(super) async fn create(
State(app): State<Shared>,
Json(b): Json<api::CreateTeam>,
) -> Reply<api::Done> {
let run = async {
let name = b.name.trim().to_string();
anyhow::ensure!(!name.is_empty(), "give the team a name");
let root = crate::config::expand_tilde(b.index.trim());
anyhow::ensure!(!root.is_empty(), "choose where the team's index lives");
if !root.contains("://") {
std::fs::create_dir_all(&root)?;
}
anyhow::ensure!(
!has_index(&root).await?,
"{root} already has an index; join it instead"
);
anyhow::ensure!(
team::load(&root).await?.version.is_none(),
"{root} already has a team; join it instead"
);
let settings = TeamSettings {
name: Some(name.clone()),
..Default::default()
};
crate::config::save_team(&root, &settings, None).await?;
use_location(&app, &root, settings).await?;
anyhow::Ok(format!("created {name}"))
};
done(run.await)
}
async fn has_index(root: &str) -> anyhow::Result<bool> {
let graph = format!("{}/_graph", root.trim_end_matches('/'));
match crate::index::graph_versions(&graph).await {
Ok(v) => Ok(!v.is_empty()),
Err(e) if format!("{e:#}").contains("was not found") => Ok(false),
Err(e) => Err(e),
}
}
async fn use_location(app: &Shared, root: &str, settings: TeamSettings) -> anyhow::Result<()> {
if app.server.is_none() {
crate::config::update(&[("index_dir", crate::config::Setting::Text(root.to_string()))])?;
app.config_changed();
}
app.set_team(settings);
app.reload(root).await
}
pub(super) async fn change_teams(
app: &Shared,
who: &super::Member,
expected: Option<Option<u64>>,
edit: impl FnOnce(&mut TeamSettings) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let root = root(app).await?;
let current = team::load(&root).await?;
let mut next = current.settings.clone();
edit(&mut next)?;
next.tidy_teams()?;
let version = expected.unwrap_or(current.version);
match who.as_deref() {
Some(w) => team::save_as(&root, &next, version, &w.email).await?,
None => crate::config::save_team(&root, &next, version).await?,
};
app.set_team(next);
Ok(())
}
pub(super) async fn save_teams(
who: super::Member,
State(app): State<Shared>,
Json(b): Json<api::TeamsSave>,
) -> Reply<api::Done> {
let run = change_teams(&app, &who, Some(b.version), |t| {
t.teams = Some(b.teams);
Ok(())
});
done(run.await.map(|_| "teams saved"))
}
pub(super) async fn label_repo(
who: super::Member,
State(app): State<Shared>,
Json(b): Json<api::RepoTeams>,
) -> Reply<api::Done> {
let repo = b.repo.trim().to_string();
let run = change_teams(&app, &who, None, |t| {
anyhow::ensure!(!repo.is_empty(), "which repository?");
t.label(&repo, &b.teams);
Ok(())
});
done(run.await.map(|_| format!("teams of {repo} saved")))
}
pub(super) async fn my_teams(
who: super::Member,
State(app): State<Shared>,
Json(b): Json<api::MyTeams>,
) -> Reply<api::Done> {
let run = async {
let email = who
.as_deref()
.map(|w| w.email.clone())
.context("teams per person need a server with sign-in")?;
change_teams(&app, &who, None, |t| t.set_member(&email, &b.teams)).await
};
done(run.await.map(|_| "your teams saved"))
}