//! The AI assistants panel (hub spec ยง13.4, #97) shared by the //! administration, organisation and repository settings pages: the //! level's own assistants, the ones it inherits, and its prompt. use crate::assistants::{self, Assistant, Available, Fields, APIS, DEFAULT_BUDGET}; use crate::auth::Viewer; use crate::db::accounts; use crate::levels::At; use crate::Shared; use serde::Deserialize; #[derive(Deserialize)] pub struct AssistantForm { #[serde(rename = "_csrf")] pub csrf_token: String, #[serde(default)] action: String, #[serde(default)] id: Option, #[serde(default)] name: String, #[serde(default)] endpoint: String, #[serde(default)] api: String, #[serde(default)] model: String, #[serde(default)] headers: String, #[serde(default)] token: String, #[serde(default)] enabled: Option, #[serde(default)] budget: String, #[serde(default)] prompt: String, } /// Everything the panel shows. pub struct Panel { /// The level's own assistants, each with whether a level above turned /// its name off. pub own: Vec, /// The ones the levels above provide, outermost first. pub inherited: Vec, pub prompt: String, /// (where from, text) for each level above that set a prompt. pub inherited_prompts: Vec<(String, String)>, pub apis: Vec<&'static str>, pub default_budget: i64, } pub async fn panel(app: &Shared, at: &At) -> sqlx::Result { let (own, inherited): (Vec<_>, Vec<_>) = assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty()); Ok(Panel { own, inherited, prompt: assistants::prompt(&app.db, at.scope).await?, inherited_prompts: assistants::inherited_prompts(&app.db, at.scope, &at.owner).await?, apis: APIS.to_vec(), default_budget: DEFAULT_BUDGET, }) } fn name_ok(name: &str) -> bool { !name.is_empty() && name.chars().count() <= 40 && name.chars().all(|c| c.is_alphanumeric() || matches!(c, ' ' | '.' | '-' | '_')) } fn endpoint_ok(url: &str) -> bool { (url.starts_with("http://") || url.starts_with("https://")) && url.len() <= 2000 && !url.contains(char::is_whitespace) } /// The form's details checked: `Err` is the message for the person. fn fields(f: &AssistantForm) -> Result { let name = f.name.trim().to_string(); if !name_ok(&name) { return Err("An assistant's name is up to 40 letters, digits, spaces, dots, dashes or underscores.".into()); } let endpoint = f.endpoint.trim().to_string(); if !endpoint_ok(&endpoint) { return Err("The endpoint is the full URL the request is posted to, starting with https://.".into()); } if !APIS.contains(&f.api.as_str()) { return Err("The API is chat-completions or anthropic-messages.".into()); } let model = f.model.trim().to_string(); if model.is_empty() || model.len() > 100 || model.contains(char::is_whitespace) { return Err("A model name is needed, such as claude-opus-5-5 or gpt-5.".into()); } let headers = f.headers.trim().to_string(); if let Err(line) = assistants::parse_headers(&headers) { return Err(format!("Extra headers are one Name: value per line; this line is not: {line}")); } if headers.lines().count() > 10 { return Err("Up to ten extra headers.".into()); } let budget = if f.budget.trim().is_empty() { DEFAULT_BUDGET } else { match f.budget.trim().parse::() { Ok(n) if (1_000..=2_000_000).contains(&n) => n, _ => return Err("The context budget is a number of characters from 1000 to 2000000.".into()), } }; Ok(Fields { name, endpoint, api: f.api.clone(), model, headers, enabled: f.enabled.is_some(), budget }) } /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result> { let owned = |a: Option| a.filter(|a| a.scope() == at.scope); let actor = viewer.account(); Ok(Ok(match (f.action.as_str(), f.id) { ("add", None) => { let fields = match fields(&f) { Ok(x) => x, Err(e) => return Ok(Err(e)), }; let token = app.assistant_key.seal(f.token.trim()); match assistants::create(&app.db, at.scope, &fields, &token).await? { Some(a) => { accounts::audit(&app.db, actor, "assistant.added", &at.label, &a.name).await.ok(); // Try it at once, so a wrong endpoint or token shows here. match assistants::test(app, &a).await { Ok(answer) => format!("{} added and answered: {answer}", a.name), Err(why) => { format!("{} added, but did not answer: {why}. Check the endpoint, API and token, then TEST CONNECTION.", a.name) } } } None => return Ok(Err(format!("There is already an assistant called {} here.", fields.name))), } } ("update", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; let fields = match fields(&f) { Ok(x) => x, Err(e) => return Ok(Err(e)), }; let token = f.token.trim(); let sealed = (!token.is_empty()).then(|| app.assistant_key.seal(token)); if !assistants::update(&app.db, id, &fields, sealed.as_deref()).await? { return Ok(Err(format!("There is already an assistant called {} here.", fields.name))); } accounts::audit(&app.db, actor, "assistant.updated", &at.label, &a.name).await.ok(); format!("{} updated.", fields.name) } ("toggle", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; assistants::set_enabled(&app.db, id, !a.enabled).await?; accounts::audit(&app.db, actor, if a.enabled { "assistant.disabled" } else { "assistant.enabled" }, &at.label, &a.name) .await .ok(); format!("{} is {}.", a.name, if a.enabled { "off" } else { "on" }) } ("delete", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; assistants::delete(&app.db, id).await?; accounts::audit(&app.db, actor, "assistant.deleted", &at.label, &a.name).await.ok(); format!("{} deleted.", a.name) } ("test", Some(id)) => { let Some(a) = owned(assistants::get(&app.db, id).await?) else { return Ok(Err("No such assistant.".into())) }; match assistants::test(app, &a).await { Ok(answer) => format!("{} answered: {answer}", a.name), Err(why) => return Ok(Err(format!("{} did not answer: {why}. Check the endpoint, API and token.", a.name))), } } ("prompt", _) => { if f.prompt.chars().count() > 20_000 { return Ok(Err("A prompt is up to 20000 characters.".into())); } assistants::set_prompt(&app.db, at.scope, &f.prompt).await?; accounts::audit(&app.db, actor, "assistant.prompt", &at.label, "").await.ok(); if f.prompt.trim().is_empty() { "Prompt cleared.".to_string() } else { "Prompt saved.".to_string() } } _ => return Ok(Err("Unknown action.".into())), })) }