Agents crud (#9084)
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Generated
-29
@@ -600,10 +600,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -1656,7 +1654,6 @@ name = "goose2"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"doctor",
|
||||
"ignore",
|
||||
@@ -1665,7 +1662,6 @@ dependencies = [
|
||||
"mime_guess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-app-test-driver",
|
||||
@@ -3702,12 +3698,6 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -4008,19 +3998,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.13.1",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5262,12 +5239,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
|
||||
@@ -29,8 +29,6 @@ dirs = "6.0.0"
|
||||
log = "0.4.29"
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde_yaml = "0.9"
|
||||
doctor = { git = "https://github.com/block/builderbot", rev = "8e1c3ec145edc0df5f04b4427cfd758378036862" }
|
||||
ignore = "0.4.25"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"../distro": "distro"
|
||||
"../distro": "distro",
|
||||
"../builtin-sources": "builtin-sources"
|
||||
},
|
||||
"externalBin": ["../../../target/release/goose"],
|
||||
"linux": {
|
||||
|
||||
@@ -6,6 +6,9 @@ export function getPersonaSource(persona: Persona): PersonaSource {
|
||||
if (persona.isBuiltin) {
|
||||
return "builtin";
|
||||
}
|
||||
if (persona.writable === true) {
|
||||
return "custom";
|
||||
}
|
||||
if (persona.isFromDisk) {
|
||||
return "file";
|
||||
}
|
||||
@@ -13,5 +16,5 @@ export function getPersonaSource(persona: Persona): PersonaSource {
|
||||
}
|
||||
|
||||
export function isPersonaReadOnly(persona: Persona): boolean {
|
||||
return getPersonaSource(persona) !== "custom";
|
||||
return persona.writable === false || getPersonaSource(persona) !== "custom";
|
||||
}
|
||||
|
||||
@@ -219,5 +219,6 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
|
||||
getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin),
|
||||
|
||||
getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin),
|
||||
getCustomPersonas: () =>
|
||||
get().personas.filter((p) => !p.isBuiltin && p.writable !== false),
|
||||
}));
|
||||
|
||||
@@ -119,7 +119,8 @@ export function AgentsView() {
|
||||
);
|
||||
|
||||
const handleDeletePersona = useCallback((persona: Persona) => {
|
||||
if (getPersonaSource(persona) === "builtin") return;
|
||||
if (getPersonaSource(persona) === "builtin" || persona.writable === false)
|
||||
return;
|
||||
setDeletingPersona(persona);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,21 +4,27 @@ import { Camera, X } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import { savePersonaAvatar, savePersonaAvatarBytes } from "@/shared/api/agents";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Avatar } from "@/shared/types/agents";
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg"];
|
||||
|
||||
function filePathToFileUrl(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
const url = new URL("file://");
|
||||
url.pathname = normalizedPath.startsWith("/")
|
||||
? normalizedPath
|
||||
: `/${normalizedPath}`;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
interface AvatarDropZoneProps {
|
||||
personaId: string;
|
||||
avatar: Avatar | null | undefined;
|
||||
onChange: (avatar: Avatar | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AvatarDropZone({
|
||||
personaId,
|
||||
avatar,
|
||||
onChange,
|
||||
disabled = false,
|
||||
@@ -44,11 +50,20 @@ export function AvatarDropZone({
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(buffer));
|
||||
const filename = await savePersonaAvatarBytes(personaId, bytes, ext);
|
||||
const value = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Avatar file could not be read as a data URL"));
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => reject(reader.error));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
onChange({ type: "local", value: filename });
|
||||
onChange({ type: "url", value });
|
||||
} catch (err) {
|
||||
console.error("Failed to save avatar:", err);
|
||||
setError(t("avatar.saveFailed"));
|
||||
@@ -56,7 +71,7 @@ export function AvatarDropZone({
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[personaId, onChange, t],
|
||||
[onChange, t],
|
||||
);
|
||||
|
||||
/** Save a file selected via the native file picker (has a path). */
|
||||
@@ -72,9 +87,7 @@ export function AvatarDropZone({
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const filename = await savePersonaAvatar(personaId, filePath);
|
||||
|
||||
onChange({ type: "local", value: filename });
|
||||
onChange({ type: "url", value: filePathToFileUrl(filePath) });
|
||||
} catch (err) {
|
||||
console.error("Failed to save avatar:", err);
|
||||
setError(t("avatar.saveFailed"));
|
||||
@@ -82,7 +95,7 @@ export function AvatarDropZone({
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[personaId, onChange, t],
|
||||
[onChange, t],
|
||||
);
|
||||
|
||||
// Standard HTML5 drag-and-drop (works when dragDropEnabled is false)
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
import {
|
||||
getPersonaSource,
|
||||
isPersonaReadOnly,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface PersonaCardProps {
|
||||
persona: Persona;
|
||||
@@ -40,8 +43,9 @@ export function PersonaCard({
|
||||
const initials = persona.displayName.charAt(0).toUpperCase();
|
||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
||||
const personaSource = getPersonaSource(persona);
|
||||
const canEditPersona = personaSource === "custom";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const canEditPersona = !isPersonaReadOnly(persona);
|
||||
const canDeletePersona =
|
||||
personaSource !== "builtin" && persona.writable !== false;
|
||||
const providerModelLabel = [persona.provider, persona.model]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
@@ -72,8 +72,9 @@ export function PersonaEditor({
|
||||
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
|
||||
const isReadOnly = detailsMode || readOnlyBySource;
|
||||
const personaSource = persona ? getPersonaSource(persona) : "custom";
|
||||
const canEditPersona = personaSource === "custom";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const canEditPersona = !readOnlyBySource;
|
||||
const canDeletePersona =
|
||||
personaSource !== "builtin" && persona?.writable !== false;
|
||||
const acpProviders = useAgentStore((s) => s.providers);
|
||||
const setProviders = useAgentStore((s) => s.setProviders);
|
||||
const mergeInventoryEntries = useProviderInventoryStore(
|
||||
@@ -187,9 +188,6 @@ export function PersonaEditor({
|
||||
|
||||
const initials = displayName.charAt(0).toUpperCase() || "?";
|
||||
|
||||
// For new personas, use a temporary ID for the avatar upload
|
||||
const avatarPersonaId = persona?.id ?? "new-persona";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
|
||||
@@ -236,7 +234,6 @@ export function PersonaEditor({
|
||||
</AvatarRoot>
|
||||
) : (
|
||||
<AvatarDropZone
|
||||
personaId={avatarPersonaId}
|
||||
avatar={avatar}
|
||||
onChange={setAvatar}
|
||||
disabled={isReadOnly}
|
||||
|
||||
@@ -60,10 +60,10 @@ export function PersonaGallery({
|
||||
});
|
||||
const sorted = useMemo(() => {
|
||||
const builtins = personas
|
||||
.filter((p) => p.isBuiltin)
|
||||
.filter((p) => p.isBuiltin || p.writable === false)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
const custom = personas
|
||||
.filter((p) => !p.isBuiltin)
|
||||
.filter((p) => !p.isBuiltin && p.writable !== false)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
return [...builtins, ...custom];
|
||||
}, [personas]);
|
||||
|
||||
@@ -246,12 +246,12 @@ function MentionAvatar({ persona }: { persona: Persona }) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full",
|
||||
persona.isBuiltin
|
||||
persona.isBuiltin || persona.writable === false
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "bg-brand/10 text-brand",
|
||||
)}
|
||||
>
|
||||
{persona.isBuiltin ? (
|
||||
{persona.isBuiltin || persona.writable === false ? (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<User className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -43,11 +43,11 @@ export function PersonaPicker({
|
||||
);
|
||||
|
||||
const builtinPersonas = useMemo(
|
||||
() => personas.filter((p) => p.isBuiltin),
|
||||
() => personas.filter((p) => p.isBuiltin || p.writable === false),
|
||||
[personas],
|
||||
);
|
||||
const customPersonas = useMemo(
|
||||
() => personas.filter((p) => !p.isBuiltin),
|
||||
() => personas.filter((p) => !p.isBuiltin && p.writable !== false),
|
||||
[personas],
|
||||
);
|
||||
|
||||
@@ -211,7 +211,9 @@ function PersonaAvatar({
|
||||
);
|
||||
}
|
||||
|
||||
const isBuiltin = persona?.isBuiltin ?? true;
|
||||
const isBuiltin = persona
|
||||
? persona.isBuiltin || persona.writable === false
|
||||
: true;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,78 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
exportPersona,
|
||||
importPersonas,
|
||||
listPersonas,
|
||||
refreshPersonas,
|
||||
updatePersona,
|
||||
} from "../agents";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
const mockGooseSourcesCreate = vi.fn();
|
||||
const mockGooseSourcesDelete = vi.fn();
|
||||
const mockGooseSourcesExport = vi.fn();
|
||||
const mockGooseSourcesImport = vi.fn();
|
||||
const mockGooseSourcesList = vi.fn();
|
||||
const mockGooseSourcesUpdate = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: async () => ({
|
||||
goose: {
|
||||
GooseSourcesCreate: (...args: unknown[]) =>
|
||||
mockGooseSourcesCreate(...args),
|
||||
GooseSourcesDelete: (...args: unknown[]) =>
|
||||
mockGooseSourcesDelete(...args),
|
||||
GooseSourcesExport: (...args: unknown[]) =>
|
||||
mockGooseSourcesExport(...args),
|
||||
GooseSourcesImport: (...args: unknown[]) =>
|
||||
mockGooseSourcesImport(...args),
|
||||
GooseSourcesList: (...args: unknown[]) => mockGooseSourcesList(...args),
|
||||
GooseSourcesUpdate: (...args: unknown[]) =>
|
||||
mockGooseSourcesUpdate(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
const source = {
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
path: "/Users/test/.agents/agents/scout.md",
|
||||
global: true,
|
||||
writable: true,
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: "file:///Users/test/.goose/avatars/agents/scout.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("agents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── exportPersona ────────────────────────────────────────────────────
|
||||
it("listPersonas maps agent sources to personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
it("exportPersona invokes correct Tauri command with ID", async () => {
|
||||
const mockResult = {
|
||||
json: '{"displayName":"Test"}',
|
||||
suggestedFilename: "test.json",
|
||||
};
|
||||
mockedInvoke.mockResolvedValue(mockResult);
|
||||
const result = await listPersonas();
|
||||
|
||||
const result = await exportPersona("persona-123");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
|
||||
id: "persona-123",
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: source.path,
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
isBuiltin: false,
|
||||
isFromDisk: true,
|
||||
writable: true,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ── importPersonas ───────────────────────────────────────────────────
|
||||
it("createPersona creates a global agent source", async () => {
|
||||
mockGooseSourcesCreate.mockResolvedValue({ source });
|
||||
|
||||
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "imported-1",
|
||||
displayName: "Imported",
|
||||
systemPrompt: "Hello",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
|
||||
const fileBytes = [0x7b, 0x7d]; // "{}"
|
||||
const result = await importPersonas(fileBytes, "personas.json");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
|
||||
fileBytes,
|
||||
fileName: "personas.json",
|
||||
const result = await createPersona({
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
expect(result).toEqual(mockPersonas);
|
||||
|
||||
expect(mockGooseSourcesCreate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
global: true,
|
||||
});
|
||||
expect(result.displayName).toBe("Scout");
|
||||
});
|
||||
|
||||
// ── refreshPersonas ──────────────────────────────────────────────────
|
||||
it("updatePersona loads existing agent source and updates by path", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
mockGooseSourcesUpdate.mockResolvedValue({
|
||||
source: { ...source, name: "Scout 2" },
|
||||
});
|
||||
|
||||
it("refreshPersonas invokes correct Tauri command", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "p1",
|
||||
displayName: "Refreshed",
|
||||
systemPrompt: "Prompt",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
const result = await updatePersona(source.path, {
|
||||
displayName: "Scout 2",
|
||||
});
|
||||
|
||||
expect(mockGooseSourcesUpdate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
name: "Scout 2",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
});
|
||||
expect(result.displayName).toBe("Scout 2");
|
||||
});
|
||||
|
||||
it("deletePersona deletes an agent source by path", async () => {
|
||||
mockGooseSourcesDelete.mockResolvedValue(undefined);
|
||||
|
||||
await deletePersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesDelete).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
});
|
||||
|
||||
it("exportPersona exports an agent source", async () => {
|
||||
mockGooseSourcesExport.mockResolvedValue({
|
||||
json: '{"type":"agent"}',
|
||||
filename: "scout.agent.json",
|
||||
});
|
||||
|
||||
const result = await exportPersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesExport).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
json: '{"type":"agent"}',
|
||||
suggestedFilename: "scout.agent.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("importPersonas imports agent source JSON", async () => {
|
||||
mockGooseSourcesImport.mockResolvedValue({ sources: [source] });
|
||||
const data = JSON.stringify({
|
||||
version: 1,
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
});
|
||||
const fileBytes = Array.from(new TextEncoder().encode(data));
|
||||
|
||||
const result = await importPersonas(fileBytes, "scout.agent.json");
|
||||
|
||||
expect(mockGooseSourcesImport).toHaveBeenCalledWith({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refreshPersonas lists personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
const result = await refreshPersonas();
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("refresh_personas");
|
||||
expect(result).toEqual(mockPersonas);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,155 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SourceEntry } from "@aaif/goose-sdk";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import type {
|
||||
Persona,
|
||||
CreatePersonaRequest,
|
||||
UpdatePersonaRequest,
|
||||
Avatar,
|
||||
} from "@/shared/types/agents";
|
||||
|
||||
const AGENT_SOURCE_TYPE = "agent" as const;
|
||||
const AGENT_DESCRIPTION = "Agent";
|
||||
|
||||
type AgentSourceProperties = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type AgentSourceEntry = SourceEntry & {
|
||||
type: typeof AGENT_SOURCE_TYPE;
|
||||
properties: AgentSourceProperties;
|
||||
};
|
||||
|
||||
function isAgentSource(source: SourceEntry): source is AgentSourceEntry {
|
||||
return source.type === AGENT_SOURCE_TYPE;
|
||||
}
|
||||
|
||||
function avatarToProperty(
|
||||
avatar: Avatar | null | undefined,
|
||||
): string | undefined {
|
||||
if (!avatar) return undefined;
|
||||
return avatar.value;
|
||||
}
|
||||
|
||||
function propertyToAvatar(value: string | undefined): Avatar | null {
|
||||
if (!value) return null;
|
||||
return { type: "url", value };
|
||||
}
|
||||
|
||||
function personaProperties(
|
||||
request: CreatePersonaRequest | UpdatePersonaRequest,
|
||||
): AgentSourceProperties | undefined {
|
||||
const properties: AgentSourceProperties = {};
|
||||
if (request.provider) properties.provider = request.provider;
|
||||
if (request.model) properties.model = request.model;
|
||||
const avatar = avatarToProperty(request.avatar);
|
||||
if (avatar) properties.avatar = avatar;
|
||||
return properties;
|
||||
}
|
||||
|
||||
function toPersona(source: AgentSourceEntry): Persona {
|
||||
const writable = source.writable !== false;
|
||||
return {
|
||||
id: source.path,
|
||||
displayName: source.name,
|
||||
avatar: propertyToAvatar(source.properties?.avatar),
|
||||
systemPrompt: source.content,
|
||||
provider: source.properties?.provider,
|
||||
model: source.properties?.model,
|
||||
isBuiltin: !writable,
|
||||
isFromDisk: writable,
|
||||
writable,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listAgentSources(): Promise<AgentSourceEntry[]> {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesList({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
});
|
||||
return response.sources.filter(isAgentSource);
|
||||
}
|
||||
|
||||
async function getAgentSource(id: string): Promise<AgentSourceEntry> {
|
||||
const source = (await listAgentSources()).find(
|
||||
(source) => source.path === id,
|
||||
);
|
||||
if (!source) {
|
||||
throw new Error(`Agent '${id}' not found`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function listPersonas(): Promise<Persona[]> {
|
||||
return invoke("list_personas");
|
||||
return (await listAgentSources()).map(toPersona);
|
||||
}
|
||||
|
||||
export async function createPersona(
|
||||
request: CreatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("create_persona", { request });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesCreate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: request.displayName,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content: request.systemPrompt,
|
||||
properties: personaProperties(request),
|
||||
global: true,
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function updatePersona(
|
||||
id: string,
|
||||
request: UpdatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("update_persona", { id, request });
|
||||
const existing = await getAgentSource(id);
|
||||
const client = await getClient();
|
||||
const merged: CreatePersonaRequest = {
|
||||
displayName: request.displayName ?? existing.name,
|
||||
avatar:
|
||||
request.avatar === undefined
|
||||
? propertyToAvatar(existing.properties?.avatar)
|
||||
: request.avatar,
|
||||
systemPrompt: request.systemPrompt ?? existing.content,
|
||||
provider: request.provider ?? existing.properties?.provider,
|
||||
model: request.model ?? existing.properties?.model,
|
||||
};
|
||||
const response = await client.goose.GooseSourcesUpdate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
name: merged.displayName,
|
||||
description: existing.description || AGENT_DESCRIPTION,
|
||||
content: merged.systemPrompt,
|
||||
properties: personaProperties(merged),
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function deletePersona(id: string): Promise<void> {
|
||||
return invoke("delete_persona", { id });
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSourcesDelete({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshPersonas(): Promise<Persona[]> {
|
||||
return invoke("refresh_personas");
|
||||
return listPersonas();
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -36,14 +158,61 @@ export interface ExportResult {
|
||||
}
|
||||
|
||||
export async function exportPersona(id: string): Promise<ExportResult> {
|
||||
return invoke("export_persona", { id });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesExport({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
return { json: response.json, suggestedFilename: response.filename };
|
||||
}
|
||||
|
||||
export async function importPersonas(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<Persona[]> {
|
||||
return invoke("import_personas", { fileBytes, fileName });
|
||||
if (
|
||||
!fileName.endsWith(".agent.json") &&
|
||||
!fileName.endsWith(".persona.json") &&
|
||||
!fileName.endsWith(".json")
|
||||
) {
|
||||
throw new Error(
|
||||
"File must have a .agent.json, .persona.json, or .json extension",
|
||||
);
|
||||
}
|
||||
|
||||
const raw = new TextDecoder().decode(new Uint8Array(fileBytes));
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const data =
|
||||
parsed.type === AGENT_SOURCE_TYPE
|
||||
? raw
|
||||
: JSON.stringify({
|
||||
version: parsed.version ?? 1,
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: parsed.displayName ?? parsed.name,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content:
|
||||
parsed.systemPrompt ?? parsed.content ?? parsed.instructions ?? "",
|
||||
properties: {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
avatar:
|
||||
typeof parsed.avatar === "string"
|
||||
? parsed.avatar
|
||||
: typeof parsed.avatar === "object" &&
|
||||
parsed.avatar !== null &&
|
||||
"value" in parsed.avatar
|
||||
? (parsed.avatar as { value?: unknown }).value
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesImport({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
|
||||
return response.sources.filter(isAgentSource).map(toPersona);
|
||||
}
|
||||
|
||||
export interface ImportFileReadResult {
|
||||
@@ -56,22 +225,3 @@ export async function readImportPersonaFile(
|
||||
): Promise<ImportFileReadResult> {
|
||||
return invoke("read_import_persona_file", { sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatar(
|
||||
personaId: string,
|
||||
sourcePath: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar", { personaId, sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatarBytes(
|
||||
personaId: string,
|
||||
bytes: number[],
|
||||
extension: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar_bytes", { personaId, bytes, extension });
|
||||
}
|
||||
|
||||
export async function getAvatarsDir(): Promise<string> {
|
||||
return invoke("get_avatars_dir");
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { getAvatarsDir } from "@/shared/api/agents";
|
||||
import type { Avatar } from "@/shared/types/agents";
|
||||
|
||||
let cachedAvatarsDir: string | null = null;
|
||||
|
||||
async function ensureAvatarsDir(): Promise<string> {
|
||||
if (!cachedAvatarsDir) {
|
||||
cachedAvatarsDir = await getAvatarsDir();
|
||||
function resolveFileUrl(value: string): string {
|
||||
try {
|
||||
return convertFileSrc(decodeURIComponent(new URL(value).pathname));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return cachedAvatarsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Avatar to a displayable image URL.
|
||||
* Lazily fetches the avatars directory on first call for a local avatar.
|
||||
*/
|
||||
export async function resolveAvatarSrc(
|
||||
avatar: Avatar | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!avatar) return undefined;
|
||||
if (avatar.type === "url") return avatar.value;
|
||||
if (avatar.type === "local") {
|
||||
const dir = await ensureAvatarsDir();
|
||||
return convertFileSrc(`${dir}/${avatar.value}`);
|
||||
if (avatar.type === "url") {
|
||||
return avatar.value.startsWith("file://")
|
||||
? resolveFileUrl(avatar.value)
|
||||
: avatar.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
// a narrow union.
|
||||
export type ProviderType = string;
|
||||
|
||||
// Avatar type — either a remote URL or a local file in ~/.goose/avatars/
|
||||
export type Avatar =
|
||||
| { type: "url"; value: string }
|
||||
| { type: "local"; value: string };
|
||||
export interface ProviderConfig {
|
||||
type: ProviderType;
|
||||
name: string;
|
||||
description?: string;
|
||||
models: ModelInfo[];
|
||||
requiresApiKey: boolean;
|
||||
apiKeyEnvVar?: string;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
contextWindow: number;
|
||||
supportsTools: boolean;
|
||||
supportsVision: boolean;
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
// Avatar type — remote, data, and file URLs are stored directly in source properties.
|
||||
export type Avatar = { type: "url"; value: string };
|
||||
|
||||
// Persona types (from sprout)
|
||||
export interface Persona {
|
||||
@@ -18,6 +34,7 @@ export interface Persona {
|
||||
model?: string;
|
||||
isBuiltin: boolean;
|
||||
isFromDisk?: boolean;
|
||||
writable?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,22 @@ export function buildInitScript(options?: {
|
||||
supportingFiles: [],
|
||||
});
|
||||
|
||||
const personaToSourceEntry = (p) => ({
|
||||
type: "agent",
|
||||
name: p.displayName ?? p.name,
|
||||
description: p.description ?? "Agent",
|
||||
content: p.systemPrompt ?? p.content ?? "",
|
||||
path: p.id ?? p.path ?? ("/mock/.agents/agents/" + (p.displayName ?? p.name)),
|
||||
global: p.global ?? true,
|
||||
writable: p.writable ?? !p.isBuiltin,
|
||||
supportingFiles: [],
|
||||
properties: {
|
||||
provider: p.provider,
|
||||
model: p.model,
|
||||
avatar: typeof p.avatar === "string" ? p.avatar : p.avatar?.value,
|
||||
},
|
||||
});
|
||||
|
||||
const projectToSourceEntry = (p) => ({
|
||||
type: "project",
|
||||
name: p.id ?? p.name?.toLowerCase(),
|
||||
@@ -263,30 +279,42 @@ export function buildInitScript(options?: {
|
||||
return jsonRpcResult(message.id, {});
|
||||
case "_goose/sources/list": {
|
||||
const sourceType = message.params?.type;
|
||||
if (sourceType === "agent") {
|
||||
return jsonRpcResult(message.id, { sources: PERSONAS.map(personaToSourceEntry) });
|
||||
}
|
||||
if (sourceType === "project") {
|
||||
return jsonRpcResult(message.id, { sources: PROJECTS.map(projectToSourceEntry) });
|
||||
}
|
||||
return jsonRpcResult(message.id, { sources: SKILLS.map(skillToSourceEntry) });
|
||||
}
|
||||
case "_goose/sources/create":
|
||||
case "_goose/sources/create": {
|
||||
const sourceType = message.params?.type ?? "skill";
|
||||
const name = message.params?.name ?? (sourceType === "agent" ? "New Agent" : "new-skill");
|
||||
return jsonRpcResult(message.id, {
|
||||
source: {
|
||||
name: message.params?.name ?? "new-skill",
|
||||
type: "skill",
|
||||
name,
|
||||
type: sourceType,
|
||||
description: message.params?.description ?? "",
|
||||
content: message.params?.content ?? "",
|
||||
path: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"),
|
||||
path: "/mock/.agents/" + (sourceType === "agent" ? "agents" : "skills") + "/" + name,
|
||||
global: message.params?.global ?? true,
|
||||
writable: true,
|
||||
supportingFiles: [],
|
||||
properties: message.params?.properties ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
case "_goose/sources/update":
|
||||
case "goose/sources/update": {
|
||||
const path = message.params?.path ?? "/mock/.agents/skills/updated-skill";
|
||||
const sourceType = message.params?.type ?? "skill";
|
||||
const path =
|
||||
message.params?.path ??
|
||||
(sourceType === "agent" ? "/mock/.agents/agents/updated-agent" : "/mock/.agents/skills/updated-skill");
|
||||
const nextName = message.params?.name;
|
||||
const name =
|
||||
typeof nextName === "string" && nextName.length > 0
|
||||
? nextName
|
||||
: String(path).split("/").filter(Boolean).at(-1) ?? "updated-skill";
|
||||
: String(path).split("/").filter(Boolean).at(-1) ?? (sourceType === "agent" ? "updated-agent" : "updated-skill");
|
||||
const segments = String(path).split("/").filter(Boolean);
|
||||
if (segments.length > 0) {
|
||||
segments[segments.length - 1] = name;
|
||||
@@ -295,12 +323,14 @@ export function buildInitScript(options?: {
|
||||
return jsonRpcResult(message.id, {
|
||||
source: {
|
||||
name,
|
||||
type: "skill",
|
||||
type: sourceType,
|
||||
description: message.params?.description ?? "",
|
||||
content: message.params?.content ?? "",
|
||||
path: updatedPath,
|
||||
global: message.params?.global ?? true,
|
||||
writable: true,
|
||||
supportingFiles: [],
|
||||
properties: message.params?.properties ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -797,6 +797,11 @@ export type SourceEntry = {
|
||||
* when it lives inside a specific project.
|
||||
*/
|
||||
global: boolean;
|
||||
/**
|
||||
* True when this source can be modified through source CRUD methods.
|
||||
* Client-provided bundled sources are returned as read-only.
|
||||
*/
|
||||
writable?: boolean;
|
||||
/**
|
||||
* Paths (absolute) of additional files that live alongside the source.
|
||||
* Only skills currently populate this; empty for other source types.
|
||||
|
||||
@@ -791,6 +791,7 @@ export const zSourceEntry = z.object({
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
global: z.boolean(),
|
||||
writable: z.boolean().optional().default(false),
|
||||
supportingFiles: z.array(z.string()).optional(),
|
||||
properties: z.record(z.unknown()).optional()
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user