Agents crud (#9084)

This commit is contained in:
Jack Amadeo
2026-05-11 13:33:32 -04:00
committed by GitHub
parent 40d4b118bb
commit a38922d95e
43 changed files with 1463 additions and 1458 deletions
+1 -210
View File
@@ -1,64 +1,5 @@
use crate::services::personas::PersonaStore;
use crate::types::agents::*;
use serde::{Deserialize, Serialize};
use serde::Serialize;
use std::path::PathBuf;
use tauri::State;
#[tauri::command]
pub fn list_personas(store: State<'_, PersonaStore>) -> Vec<Persona> {
store.list()
}
#[tauri::command]
pub fn create_persona(
store: State<'_, PersonaStore>,
request: CreatePersonaRequest,
) -> Result<Persona, String> {
store.create(request)
}
#[tauri::command]
pub fn update_persona(
store: State<'_, PersonaStore>,
id: String,
request: UpdatePersonaRequest,
) -> Result<Persona, String> {
store.update(&id, request)
}
#[tauri::command]
pub fn delete_persona(store: State<'_, PersonaStore>, id: String) -> Result<(), String> {
store.delete(&id)
}
#[tauri::command]
pub fn refresh_personas(store: State<'_, PersonaStore>) -> Vec<Persona> {
store.refresh_markdown()
}
/// Save avatar from a local file path for a persona.
/// Copies the file into ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the stored filename (e.g. "persona-id.png").
#[tauri::command]
pub fn save_persona_avatar(persona_id: String, source_path: String) -> Result<String, String> {
PersonaStore::save_avatar_from_path(&persona_id, &source_path)
}
/// Save avatar from raw bytes (for drag-and-drop from the browser).
#[tauri::command]
pub fn save_persona_avatar_bytes(
persona_id: String,
bytes: Vec<u8>,
extension: String,
) -> Result<String, String> {
PersonaStore::save_avatar_from_bytes(&persona_id, &bytes, &extension)
}
/// Returns the absolute path to the avatars directory (~/.goose/avatars/).
#[tauri::command]
pub fn get_avatars_dir() -> String {
PersonaStore::avatars_dir().to_string_lossy().to_string()
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -111,156 +52,6 @@ pub fn read_import_persona_file(source_path: String) -> Result<ImportFileReadRes
})
}
// --- Sprout-compatible persona import/export ---
/// Sprout-compatible persona export format (version 1, camelCase keys).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersonaExportV1 {
version: u32,
display_name: String,
system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
avatar: Option<Avatar>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
/// Result returned by export_persona containing the JSON string and a suggested filename.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportResult {
pub json: String,
pub suggested_filename: String,
}
/// Convert a display name into a filesystem-safe slug.
/// Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim, max 50 chars.
pub fn slugify(name: &str) -> String {
let slug: String = name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
// Collapse consecutive hyphens
let mut collapsed = String::with_capacity(slug.len());
let mut prev_hyphen = false;
for c in slug.chars() {
if c == '-' {
if !prev_hyphen {
collapsed.push('-');
}
prev_hyphen = true;
} else {
collapsed.push(c);
prev_hyphen = false;
}
}
let trimmed = collapsed.trim_matches('-');
let result = if trimmed.len() > 50 {
// Cut at 50 chars without splitting mid-char, then trim trailing hyphens
trimmed[..50].trim_end_matches('-').to_string()
} else {
trimmed.to_string()
};
if result.is_empty() {
"persona".to_string()
} else {
result
}
}
/// Export a persona as sprout-compatible JSON (version 1).
/// Returns the JSON string and a suggested filename.
#[tauri::command]
pub fn export_persona(store: State<'_, PersonaStore>, id: String) -> Result<ExportResult, String> {
let persona = store
.get(&id)
.ok_or_else(|| format!("Persona '{}' not found", id))?;
// For export, only include URL avatars (local files aren't portable)
let export_avatar = match &persona.avatar {
Some(Avatar::Url(url)) => Some(Avatar::Url(url.clone())),
_ => None,
};
let export = PersonaExportV1 {
version: 1,
display_name: persona.display_name.clone(),
system_prompt: persona.system_prompt,
avatar: export_avatar,
provider: persona.provider,
model: persona.model,
};
let json = serde_json::to_string_pretty(&export)
.map_err(|e| format!("Failed to serialize persona: {}", e))?;
let slug = slugify(&persona.display_name);
let suggested_filename = format!("{}.persona.json", slug);
Ok(ExportResult {
json,
suggested_filename,
})
}
/// Import personas from sprout-compatible JSON (version 1).
/// Accepts raw file bytes and the original filename.
/// Returns the list of newly created personas.
#[tauri::command]
pub fn import_personas(
store: State<'_, PersonaStore>,
file_bytes: Vec<u8>,
file_name: String,
) -> Result<Vec<Persona>, String> {
// Validate file extension
if !file_name.ends_with(".persona.json") && !file_name.ends_with(".json") {
return Err("Unsupported file type. Expected a .persona.json or .json file.".to_string());
}
// Parse the bytes as UTF-8
let content =
String::from_utf8(file_bytes).map_err(|_| "File is not valid UTF-8 text".to_string())?;
// Parse as JSON
let export: PersonaExportV1 =
serde_json::from_str(&content).map_err(|e| format!("Invalid persona JSON: {}", e))?;
// Validate version
if export.version != 1 {
return Err(format!(
"Unsupported persona format version {}. Expected version 1.",
export.version
));
}
// Validate required fields
if export.display_name.trim().is_empty() {
return Err("Persona displayName cannot be empty".to_string());
}
if export.system_prompt.trim().is_empty() {
return Err("Persona systemPrompt cannot be empty".to_string());
}
// Create the persona via the store
let request = CreatePersonaRequest {
display_name: export.display_name,
avatar: export.avatar,
system_prompt: export.system_prompt,
provider: export.provider,
model: export.model,
};
let persona = store.create(request)?;
Ok(vec![persona])
}
#[cfg(test)]
mod tests {
use super::validate_import_persona_path;
+1 -13
View File
@@ -3,7 +3,6 @@ mod services;
mod types;
use services::distro_bundle::DistroBundleState;
use services::personas::PersonaStore;
use tauri::Manager;
use tauri_plugin_window_state::StateFlags;
@@ -25,8 +24,7 @@ pub fn run() {
tauri_plugin_window_state::Builder::default()
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
.build(),
)
.manage(PersonaStore::new());
);
#[cfg(feature = "app-test-driver")]
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
@@ -37,17 +35,7 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::agents::list_personas,
commands::agents::create_persona,
commands::agents::update_persona,
commands::agents::delete_persona,
commands::agents::refresh_personas,
commands::agents::export_persona,
commands::agents::import_personas,
commands::agents::read_import_persona_file,
commands::agents::save_persona_avatar,
commands::agents::save_persona_avatar_bytes,
commands::agents::get_avatars_dir,
commands::acp::get_goose_serve_url,
commands::acp::get_goose_serve_host_info,
commands::project_icons::scan_project_icons,
@@ -1,4 +1,4 @@
use tauri::Manager;
use tauri::{Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use std::ffi::OsString;
@@ -13,6 +13,8 @@ use tokio::sync::OnceCell;
const GOOSE_SERVE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const GOOSE_SERVE_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(100);
const LOCALHOST: &str = "127.0.0.1";
const ADDITIONAL_AGENT_SOURCE_ROOTS_ENV: &str = "ADDITIONAL_AGENT_SOURCE_ROOTS";
const BUNDLED_AGENT_ROOT_DIR: &str = "builtin-sources/agents";
// ---------------------------------------------------------------------------
// GooseServeProcess — singleton that owns the long-lived `goose serve` child
// ---------------------------------------------------------------------------
@@ -84,8 +86,10 @@ impl GooseServeProcess {
}
}
command.arg("serve");
add_bundled_agent_root_env(&app_handle, &mut command);
command
.arg("serve")
.arg("--host")
.arg(LOCALHOST)
.arg("--port")
@@ -121,6 +125,46 @@ impl GooseServeProcess {
}
}
fn add_bundled_agent_root_env<R: Runtime>(manager: &impl Manager<R>, command: &mut Command) {
let resource_dir = match manager.path().resource_dir() {
Ok(path) => path,
Err(error) => {
log::warn!("Failed to resolve Tauri resource dir for bundled sources: {error}");
return;
}
};
let root = resource_dir.join(BUNDLED_AGENT_ROOT_DIR);
if !root.is_dir() {
log::debug!(
"No bundled source root found at {}; skipping",
root.display()
);
return;
}
append_additional_agent_roots_env(command, &root);
}
fn append_additional_agent_roots_env(command: &mut Command, root: &std::path::Path) {
let existing = std::env::var_os(ADDITIONAL_AGENT_SOURCE_ROOTS_ENV);
let mut roots: Vec<PathBuf> = existing
.as_ref()
.map(std::env::split_paths)
.map(Iterator::collect)
.unwrap_or_default();
roots.push(root.to_path_buf());
match std::env::join_paths(&roots) {
Ok(joined) => {
command.env(ADDITIONAL_AGENT_SOURCE_ROOTS_ENV, joined);
}
Err(error) => {
eprintln!("Failed to set {ADDITIONAL_AGENT_SOURCE_ROOTS_ENV}: {error}");
}
}
}
pub fn get_goose_command(app_handle: &tauri::AppHandle) -> Result<Command, String> {
if let Ok(override_path) = std::env::var("GOOSE_BIN") {
Ok(Command::new(override_path))
-1
View File
@@ -1,3 +1,2 @@
pub mod acp;
pub mod distro_bundle;
pub mod personas;
@@ -1,457 +0,0 @@
use crate::types::agents::{
builtin_personas, Avatar, CreatePersonaRequest, Persona, UpdatePersonaRequest,
};
use log::warn;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use std::sync::Mutex;
pub struct PersonaStore {
personas: Mutex<Vec<Persona>>,
store_path: PathBuf,
}
/// YAML frontmatter fields parsed from markdown persona files.
#[derive(serde::Deserialize)]
struct MarkdownFrontmatter {
name: String,
description: Option<String>,
}
impl PersonaStore {
pub fn new() -> Self {
let store_path = Self::store_path();
let stored = Self::load_from_disk(&store_path);
let markdown = Self::load_markdown_personas();
let merged = Self::merge_all(stored, markdown);
Self {
personas: Mutex::new(merged),
store_path,
}
}
fn store_path() -> PathBuf {
let base = dirs::home_dir().expect("home dir");
base.join(".goose").join("personas.json")
}
/// Path to the avatars directory (~/.goose/avatars/).
pub fn avatars_dir() -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("avatars")
}
fn load_from_disk(path: &PathBuf) -> Vec<Persona> {
match std::fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
/// Merge builtins, JSON custom personas, and markdown personas.
/// Priority: builtins first, then JSON custom, then markdown.
/// Deduplication is by display_name (case-insensitive).
fn merge_all(stored: Vec<Persona>, markdown: Vec<Persona>) -> Vec<Persona> {
let builtins = builtin_personas();
let mut result = builtins;
let mut seen_names: HashSet<String> = result
.iter()
.map(|p| p.display_name.to_lowercase())
.collect();
let mut seen_ids: HashSet<String> = result.iter().map(|p| p.id.clone()).collect();
// Add custom (non-builtin) personas from JSON
for persona in stored {
if !seen_ids.contains(&persona.id) {
seen_names.insert(persona.display_name.to_lowercase());
seen_ids.insert(persona.id.clone());
result.push(persona);
}
}
// Add markdown personas, skipping any whose name already exists
for persona in markdown {
if !seen_names.contains(&persona.display_name.to_lowercase())
&& !seen_ids.contains(&persona.id)
{
seen_names.insert(persona.display_name.to_lowercase());
seen_ids.insert(persona.id.clone());
result.push(persona);
}
}
result
}
/// Directory containing markdown persona files.
fn agents_dir() -> PathBuf {
dirs::home_dir()
.expect("home dir")
.join(".goose")
.join("agents")
}
/// Scan `~/.goose/agents/*.md` and parse each into a Persona.
fn load_markdown_personas() -> Vec<Persona> {
let dir = Self::agents_dir();
if !dir.is_dir() {
return Vec::new();
}
let mut personas = Vec::new();
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(err) => {
warn!("Failed to read agents directory {:?}: {}", dir, err);
return Vec::new();
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
match Self::parse_markdown_persona(&path) {
Ok(persona) => personas.push(persona),
Err(err) => {
warn!("Skipping {:?}: {}", path, err);
}
}
}
personas
}
/// Parse a single markdown file with YAML frontmatter into a Persona.
fn parse_markdown_persona(path: &std::path::Path) -> Result<Persona, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
// Expect file to start with "---"
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return Err("Missing frontmatter delimiter".to_string());
}
// Find the closing "---"
let after_first = &trimmed[3..];
let end_idx = after_first
.find("\n---")
.ok_or_else(|| "Missing closing frontmatter delimiter".to_string())?;
let yaml_str = &after_first[..end_idx];
let body = after_first[end_idx + 4..].trim().to_string();
let frontmatter: MarkdownFrontmatter = serde_yaml::from_str(yaml_str)
.map_err(|e| format!("Invalid frontmatter YAML: {}", e))?;
// Derive a stable ID from the filename (without extension)
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let id = format!("md-{}", slug);
// Use the file modification time for timestamps, fall back to now
let mod_time = std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| {
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
let dt = chrono::DateTime::from_timestamp(
duration.as_secs() as i64,
duration.subsec_nanos(),
)?;
Some(dt.to_rfc3339())
})
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
// Use the body as system prompt. If body is empty, use description or a fallback.
let system_prompt = if body.is_empty() {
frontmatter
.description
.clone()
.unwrap_or_else(|| format!("You are {}.", frontmatter.name))
} else {
body
};
Ok(Persona {
id,
display_name: frontmatter.name,
avatar: None,
system_prompt,
provider: None,
model: None,
is_builtin: false,
is_from_disk: true,
created_at: mod_time.clone(),
updated_at: mod_time,
})
}
fn markdown_persona_path(id: &str) -> Result<PathBuf, String> {
let slug = id
.strip_prefix("md-")
.ok_or_else(|| format!("Persona '{}' is not a file-backed persona", id))?;
Self::validate_markdown_persona_slug(slug)?;
Ok(Self::agents_dir().join(format!("{}.md", slug)))
}
fn validate_markdown_persona_slug(slug: &str) -> Result<(), String> {
if slug.chars().any(|c| matches!(c, '/' | '\\')) {
return Err(format!("Persona '{}' has an invalid file-backed ID", slug));
}
let mut components = Path::new(slug).components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
_ => Err(format!("Persona '{}' has an invalid file-backed ID", slug)),
}
}
/// Re-scan markdown personas and update the in-memory list.
/// Returns the full updated persona list.
pub fn refresh_markdown(&self) -> Vec<Persona> {
let stored = Self::load_from_disk(&self.store_path);
let markdown = Self::load_markdown_personas();
let merged = Self::merge_all(stored, markdown);
let mut personas = self.personas.lock().unwrap();
*personas = merged;
personas.clone()
}
fn save_to_disk(&self, personas: &[Persona]) {
if let Some(parent) = self.store_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Only persist custom personas (not builtins, not from markdown files)
let custom: Vec<&Persona> = personas
.iter()
.filter(|p| !p.is_builtin && !p.is_from_disk)
.collect();
if let Ok(json) = serde_json::to_string_pretty(&custom) {
let _ = std::fs::write(&self.store_path, json);
}
}
pub fn list(&self) -> Vec<Persona> {
let personas = self.personas.lock().unwrap();
personas.clone()
}
#[allow(dead_code)]
pub fn get(&self, id: &str) -> Option<Persona> {
let personas = self.personas.lock().unwrap();
personas.iter().find(|p| p.id == id).cloned()
}
pub fn create(&self, req: CreatePersonaRequest) -> Result<Persona, String> {
let now = chrono::Utc::now().to_rfc3339();
let persona = Persona {
id: uuid::Uuid::new_v4().to_string(),
display_name: req.display_name,
avatar: req.avatar,
system_prompt: req.system_prompt,
provider: req.provider,
model: req.model,
is_builtin: false,
is_from_disk: false,
created_at: now.clone(),
updated_at: now,
};
let mut personas = self.personas.lock().unwrap();
personas.push(persona.clone());
self.save_to_disk(&personas);
Ok(persona)
}
pub fn update(&self, id: &str, req: UpdatePersonaRequest) -> Result<Persona, String> {
let mut personas = self.personas.lock().unwrap();
let persona = personas
.iter_mut()
.find(|p| p.id == id)
.ok_or_else(|| format!("Persona '{}' not found", id))?;
if persona.is_builtin {
return Err("Cannot update a built-in persona".to_string());
}
if persona.is_from_disk {
return Err("Cannot update a markdown persona — edit the file directly".to_string());
}
if let Some(name) = req.display_name {
persona.display_name = name;
}
if let Some(avatar_value) = req.avatar {
// Some(None) → clear, Some(Some(a)) → set
persona.avatar = avatar_value;
}
if let Some(prompt) = req.system_prompt {
persona.system_prompt = prompt;
}
if let Some(provider) = req.provider {
persona.provider = Some(provider);
}
if let Some(model) = req.model {
persona.model = Some(model);
}
persona.updated_at = chrono::Utc::now().to_rfc3339();
let updated = persona.clone();
self.save_to_disk(&personas);
Ok(updated)
}
pub fn delete(&self, id: &str) -> Result<(), String> {
let mut personas = self.personas.lock().unwrap();
let persona = personas
.iter()
.find(|p| p.id == id)
.cloned()
.ok_or_else(|| format!("Persona '{}' not found", id))?;
if persona.is_builtin {
return Err("Cannot delete a built-in persona".to_string());
}
if persona.is_from_disk {
let path = Self::markdown_persona_path(id)?;
match std::fs::remove_file(&path) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
return Err(format!(
"Failed to delete file-backed persona '{}': {}",
path.display(),
err
));
}
}
personas.retain(|p| p.id != id);
self.save_to_disk(&personas);
return Ok(());
}
// Clean up local avatar file if present
if let Some(Avatar::Local(filename)) = &persona.avatar {
let path = Self::avatars_dir().join(filename);
let _ = std::fs::remove_file(path);
}
personas.retain(|p| p.id != id);
self.save_to_disk(&personas);
Ok(())
}
/// Copy an avatar image from a source path to ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the filename (not full path).
pub fn save_avatar_from_path(persona_id: &str, source_path: &str) -> Result<String, String> {
let avatars_dir = Self::avatars_dir();
std::fs::create_dir_all(&avatars_dir)
.map_err(|e| format!("Failed to create avatars directory: {}", e))?;
let source = std::path::Path::new(source_path);
// Extract extension from source filename
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let stored_name = format!("{}.{}", persona_id, ext);
let dest = avatars_dir.join(&stored_name);
// Remove any existing avatar for this persona (different extension)
if let Ok(entries) = std::fs::read_dir(&avatars_dir) {
let prefix = format!("{}.", persona_id);
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name_str) = name.to_str() {
if name_str.starts_with(&prefix) && name_str != stored_name {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
std::fs::copy(source, &dest).map_err(|e| format!("Failed to copy avatar file: {}", e))?;
Ok(stored_name)
}
/// Write avatar image bytes to ~/.goose/avatars/{persona_id}.{ext}.
/// Returns the filename (not full path).
pub fn save_avatar_from_bytes(
persona_id: &str,
bytes: &[u8],
extension: &str,
) -> Result<String, String> {
let avatars_dir = Self::avatars_dir();
std::fs::create_dir_all(&avatars_dir)
.map_err(|e| format!("Failed to create avatars directory: {}", e))?;
let ext = extension.to_lowercase();
let stored_name = format!("{}.{}", persona_id, ext);
let dest = avatars_dir.join(&stored_name);
// Remove any existing avatar for this persona (different extension)
if let Ok(entries) = std::fs::read_dir(&avatars_dir) {
let prefix = format!("{}.", persona_id);
for entry in entries.flatten() {
let name = entry.file_name();
if let Some(name_str) = name.to_str() {
if name_str.starts_with(&prefix) && name_str != stored_name {
let _ = std::fs::remove_file(entry.path());
}
}
}
}
std::fs::write(&dest, bytes).map_err(|e| format!("Failed to write avatar file: {}", e))?;
Ok(stored_name)
}
/// Delete avatar file for a persona.
#[allow(dead_code)]
pub fn delete_avatar_file(filename: &str) {
let path = Self::avatars_dir().join(filename);
let _ = std::fs::remove_file(path);
}
}
#[cfg(test)]
mod tests {
use super::PersonaStore;
#[test]
fn markdown_persona_path_rejects_parent_segments() {
assert!(PersonaStore::markdown_persona_path("md-../secret").is_err());
assert!(PersonaStore::markdown_persona_path("md-..").is_err());
}
#[test]
fn markdown_persona_path_rejects_path_separators() {
assert!(PersonaStore::markdown_persona_path("md-nested/slug").is_err());
assert!(PersonaStore::markdown_persona_path(r"md-nested\slug").is_err());
}
#[test]
fn markdown_persona_path_accepts_normal_slug() {
let path = PersonaStore::markdown_persona_path("md-scout").unwrap();
let file_name = path.file_name().and_then(|name| name.to_str());
assert_eq!(file_name, Some("scout.md"));
}
}
-198
View File
@@ -1,198 +0,0 @@
use serde::{Deserialize, Deserializer, Serialize};
/// Avatar for a persona — either a remote URL or a local file in ~/.goose/avatars/.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum Avatar {
#[serde(rename = "url")]
Url(String),
#[serde(rename = "local")]
Local(String),
}
/// Custom deserializer that handles migration from old format.
/// Accepts:
/// - null → None
/// - "https://..." (bare string) → Some(Avatar::Url(s))
/// - { "type": "url", "value": "..." } → Some(Avatar::Url(...))
/// - { "type": "local", "value": "x" } → Some(Avatar::Local(...))
fn deserialize_avatar_compat<'de, D>(deserializer: D) -> Result<Option<Avatar>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AvatarOrString {
Avatar(Avatar),
BareString(String),
}
let opt: Option<AvatarOrString> = Option::deserialize(deserializer)?;
match opt {
None => Ok(None),
Some(AvatarOrString::BareString(s)) => {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(Avatar::Url(s)))
}
}
Some(AvatarOrString::Avatar(a)) => Ok(Some(a)),
}
}
/// Deserializer for UpdatePersonaRequest: distinguishes "field absent" from "field: null".
/// - JSON field absent → None (don't update)
/// - "avatar": null → Some(None) (clear the avatar)
/// - "avatar": {...} or "str" → Some(Some(Avatar))
fn deserialize_avatar_update<'de, D>(deserializer: D) -> Result<Option<Option<Avatar>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum AvatarOrString {
Avatar(Avatar),
BareString(String),
}
let opt: Option<AvatarOrString> = Option::deserialize(deserializer)?;
match opt {
None => Ok(Some(None)), // explicit null → clear
Some(AvatarOrString::BareString(s)) => {
if s.is_empty() {
Ok(Some(None))
} else {
Ok(Some(Some(Avatar::Url(s))))
}
}
Some(AvatarOrString::Avatar(a)) => Ok(Some(Some(a))),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Persona {
pub id: String,
pub display_name: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "avatarUrl",
deserialize_with = "deserialize_avatar_compat"
)]
pub avatar: Option<Avatar>,
pub system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub is_builtin: bool,
#[serde(default)]
pub is_from_disk: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePersonaRequest {
pub display_name: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_avatar_compat"
)]
pub avatar: Option<Avatar>,
pub system_prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePersonaRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_avatar_update"
)]
pub avatar: Option<Option<Avatar>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Agent {
pub id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub persona_id: Option<String>,
pub provider: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub connection_type: String,
pub status: String,
pub is_builtin: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub acp_endpoint: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub provider_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub persona_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub model_name: Option<String>,
pub created_at: String,
pub updated_at: String,
pub message_count: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_message_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub archived_at: Option<String>,
}
/// Partial update for a session — only provided fields are applied.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionUpdate {
pub title: Option<String>,
pub provider_id: Option<String>,
pub persona_id: Option<String>,
pub model_name: Option<String>,
#[serde(default, deserialize_with = "deserialize_nullable_field")]
pub project_id: Option<Option<String>>,
}
fn deserialize_nullable_field<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<Option<T>>::deserialize(deserializer)
}
pub use super::builtin_personas::builtin_personas;
File diff suppressed because one or more lines are too long
-3
View File
@@ -1,5 +1,2 @@
#[allow(dead_code)]
pub mod agents;
pub mod builtin_personas;
#[allow(dead_code)]
pub mod messages;