Agents crud (#9084)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,2 @@
|
||||
#[allow(dead_code)]
|
||||
pub mod agents;
|
||||
pub mod builtin_personas;
|
||||
#[allow(dead_code)]
|
||||
pub mod messages;
|
||||
|
||||
Reference in New Issue
Block a user