mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add local agent personas and bulk add flow (#119)
This commit is contained in:
1
desktop/src-tauri/Cargo.lock
generated
1
desktop/src-tauri/Cargo.lock
generated
@@ -4106,6 +4106,7 @@ dependencies = [
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -42,3 +42,4 @@ base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
49
desktop/src-tauri/src/commands/agent_discovery.rs
Normal file
49
desktop/src-tauri/src/commands/agent_discovery.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use reqwest::Method;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
admin_command, command_availability, discover_local_acp_providers, AcpProviderInfo,
|
||||
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{build_authed_request, send_json_request},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
discover_local_acp_providers()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_managed_agent_prereqs(
|
||||
input: DiscoverManagedAgentPrereqsRequest,
|
||||
app: AppHandle,
|
||||
) -> ManagedAgentPrereqsInfo {
|
||||
let acp_command = input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND);
|
||||
let mcp_command = input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND);
|
||||
let admin_command = admin_command();
|
||||
|
||||
ManagedAgentPrereqsInfo {
|
||||
admin: command_availability(&admin_command, Some(&app)),
|
||||
acp: command_availability(acp_command, Some(&app)),
|
||||
mcp: command_availability(mcp_command, Some(&app)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/agents", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
@@ -1,65 +1,23 @@
|
||||
use nostr::{Keys, ToBech32};
|
||||
use reqwest::Method;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
admin_command, build_managed_agent_summary, command_availability, default_token_scopes,
|
||||
discover_local_acp_providers, find_managed_agent_mut, load_managed_agents,
|
||||
managed_agent_avatar_url, managed_agent_log_path, read_log_tail,
|
||||
run_sprout_admin_mint_token, save_managed_agents, start_managed_agent_process,
|
||||
stop_managed_agent_process, sync_managed_agent_processes, AcpProviderInfo,
|
||||
CreateManagedAgentRequest, CreateManagedAgentResponse, DiscoverManagedAgentPrereqsRequest,
|
||||
ManagedAgentLogResponse, ManagedAgentPrereqsInfo, ManagedAgentSummary,
|
||||
MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, RelayAgentInfo,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
|
||||
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{
|
||||
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
|
||||
sync_managed_agent_profile,
|
||||
build_managed_agent_summary, default_token_scopes, find_managed_agent_mut,
|
||||
load_managed_agents, load_personas, managed_agent_avatar_url, managed_agent_log_path,
|
||||
read_log_tail, save_managed_agents,
|
||||
start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes,
|
||||
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
|
||||
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
|
||||
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
|
||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||
DEFAULT_MCP_COMMAND,
|
||||
},
|
||||
relay::{mint_managed_agent_api_token, relay_ws_url, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
discover_local_acp_providers()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn discover_managed_agent_prereqs(
|
||||
input: DiscoverManagedAgentPrereqsRequest,
|
||||
app: AppHandle,
|
||||
) -> ManagedAgentPrereqsInfo {
|
||||
let acp_command = input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND);
|
||||
let mcp_command = input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND);
|
||||
let admin_command = admin_command();
|
||||
|
||||
ManagedAgentPrereqsInfo {
|
||||
admin: command_availability(&admin_command, Some(&app)),
|
||||
acp: command_availability(acp_command, Some(&app)),
|
||||
mcp: command_availability(mcp_command, Some(&app)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, String> {
|
||||
let request = build_authed_request(&state.http_client, Method::GET, "/api/agents", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_managed_agents(
|
||||
app: AppHandle,
|
||||
@@ -95,188 +53,196 @@ pub async fn create_managed_agent(
|
||||
if name.is_empty() {
|
||||
return Err("agent name is required".to_string());
|
||||
}
|
||||
let requested_persona_id = input
|
||||
.persona_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
if let Some(parallelism) = input.parallelism {
|
||||
if !(1..=32).contains(¶llelism) {
|
||||
return Err("parallelism must be between 1 and 32".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let owner_pubkey = if input.mint_token {
|
||||
Some(managed_agent_owner_pubkey(&state).await?)
|
||||
let resolved_relay_url = input
|
||||
.relay_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
let private_key_nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
||||
let token_scopes = if input.mint_token {
|
||||
let requested = input
|
||||
.token_scopes
|
||||
.iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if requested.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(name)
|
||||
.to_string();
|
||||
let api_token = if input.mint_token {
|
||||
Some(
|
||||
mint_managed_agent_api_token(
|
||||
&state.http_client,
|
||||
&resolved_relay_url,
|
||||
&keys,
|
||||
&token_name,
|
||||
&token_scopes,
|
||||
)
|
||||
.await?
|
||||
.token,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut agent_args = input
|
||||
.agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if agent_args.is_empty() {
|
||||
agent_args.push(DEFAULT_AGENT_ARG.to_string());
|
||||
}
|
||||
let system_prompt = input
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let model = input
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let acp_command = input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND)
|
||||
.to_string();
|
||||
let agent_command = input
|
||||
.agent_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_AGENT_COMMAND)
|
||||
.to_string();
|
||||
let mcp_command = input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND)
|
||||
.to_string();
|
||||
let turn_timeout_seconds = input
|
||||
.turn_timeout_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS);
|
||||
let parallelism = input
|
||||
.parallelism
|
||||
.filter(|count| (1..=32).contains(count))
|
||||
.unwrap_or(DEFAULT_AGENT_PARALLELISM);
|
||||
|
||||
let (agent, private_key_nsec, api_token, pubkey, resolved_relay_url, spawn_error, token_scopes) =
|
||||
{
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key().to_hex();
|
||||
if records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} already exists"));
|
||||
}
|
||||
|
||||
let private_key_nsec = keys
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode private key: {error}"))?;
|
||||
let token_scopes = if input.mint_token {
|
||||
let requested = input
|
||||
.token_scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if requested.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
requested
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let api_token = if input.mint_token {
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(name);
|
||||
Some(
|
||||
run_sprout_admin_mint_token(
|
||||
&app,
|
||||
&pubkey,
|
||||
owner_pubkey.as_deref().ok_or_else(|| {
|
||||
"managed agent owner pubkey was not resolved".to_string()
|
||||
})?,
|
||||
token_name,
|
||||
&token_scopes,
|
||||
)?
|
||||
.api_token,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resolved_relay_url = input
|
||||
.relay_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(relay_ws_url);
|
||||
|
||||
let mut record = crate::managed_agents::ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: name.to_string(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
api_token: api_token.clone(),
|
||||
relay_url: resolved_relay_url.clone(),
|
||||
acp_command: input
|
||||
.acp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_ACP_COMMAND)
|
||||
.to_string(),
|
||||
agent_command: input
|
||||
.agent_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_AGENT_COMMAND)
|
||||
.to_string(),
|
||||
agent_args: input
|
||||
.agent_args
|
||||
.into_iter()
|
||||
.map(|arg| arg.trim().to_string())
|
||||
.filter(|arg| !arg.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
mcp_command: input
|
||||
.mcp_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(DEFAULT_MCP_COMMAND)
|
||||
.to_string(),
|
||||
turn_timeout_seconds: input
|
||||
.turn_timeout_seconds
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
|
||||
parallelism: input
|
||||
.parallelism
|
||||
.filter(|count| (1..=32).contains(count))
|
||||
.unwrap_or(DEFAULT_AGENT_PARALLELISM),
|
||||
system_prompt: input
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
model: input
|
||||
.model
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
start_on_app_launch: input.start_on_app_launch,
|
||||
runtime_pid: None,
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
};
|
||||
|
||||
if record.agent_args.is_empty() {
|
||||
record.agent_args.push(DEFAULT_AGENT_ARG.to_string());
|
||||
}
|
||||
|
||||
records.push(record);
|
||||
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
let (agent, spawn_error) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
if let Some(persona_id) = requested_persona_id.as_deref() {
|
||||
let personas = load_personas(&app)?;
|
||||
if !personas.iter().any(|persona| persona.id == persona_id) {
|
||||
return Err(format!("persona {persona_id} not found"));
|
||||
}
|
||||
}
|
||||
if records.iter().any(|record| record.pubkey == pubkey) {
|
||||
return Err(format!("agent {pubkey} already exists"));
|
||||
}
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
records.push(crate::managed_agents::ManagedAgentRecord {
|
||||
pubkey: pubkey.clone(),
|
||||
name: name.to_string(),
|
||||
persona_id: requested_persona_id.clone(),
|
||||
private_key_nsec: private_key_nsec.clone(),
|
||||
api_token: api_token.clone(),
|
||||
relay_url: resolved_relay_url.clone(),
|
||||
acp_command,
|
||||
agent_command,
|
||||
agent_args,
|
||||
mcp_command,
|
||||
turn_timeout_seconds,
|
||||
parallelism,
|
||||
system_prompt,
|
||||
model,
|
||||
start_on_app_launch: input.start_on_app_launch,
|
||||
runtime_pid: None,
|
||||
created_at: now_iso(),
|
||||
updated_at: now_iso(),
|
||||
last_started_at: None,
|
||||
last_stopped_at: None,
|
||||
last_exit_code: None,
|
||||
last_error: None,
|
||||
});
|
||||
|
||||
Ok::<_, String>((
|
||||
agent,
|
||||
private_key_nsec,
|
||||
api_token,
|
||||
pubkey,
|
||||
resolved_relay_url,
|
||||
spawn_error,
|
||||
token_scopes,
|
||||
))
|
||||
}?;
|
||||
let mut spawn_error = None;
|
||||
if input.spawn_after_create {
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
if let Err(error) = start_managed_agent_process(&app, record, &mut runtimes) {
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = Some(error.clone());
|
||||
spawn_error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
let avatar_url = managed_agent_avatar_url(agent.agent_command.as_str());
|
||||
save_managed_agents(&app, &records)?;
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|record| record.pubkey == pubkey)
|
||||
.ok_or_else(|| "created agent disappeared unexpectedly".to_string())?;
|
||||
let agent = build_managed_agent_summary(&app, record, &runtimes)?;
|
||||
|
||||
Ok::<_, String>((agent, spawn_error))
|
||||
}?;
|
||||
|
||||
let avatar_url = input
|
||||
.avatar_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| managed_agent_avatar_url(agent.agent_command.as_str()));
|
||||
let profile_sync_error = match sync_managed_agent_profile(
|
||||
&state,
|
||||
&resolved_relay_url,
|
||||
@@ -406,7 +372,56 @@ pub async fn mint_managed_agent_token(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MintManagedAgentTokenResponse, String> {
|
||||
let owner_pubkey = managed_agent_owner_pubkey(&state).await?;
|
||||
let scopes = input
|
||||
.scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let scopes = if scopes.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
scopes
|
||||
};
|
||||
let (pubkey, relay_url, private_key_nsec, token_name) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut records = load_managed_agents(&app)?;
|
||||
let mut runtimes = state
|
||||
.managed_agent_processes
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if sync_managed_agent_processes(&mut records, &mut runtimes) {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
(
|
||||
record.pubkey.clone(),
|
||||
record.relay_url.clone(),
|
||||
record.private_key_nsec.clone(),
|
||||
input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{}-token", record.name)),
|
||||
)
|
||||
};
|
||||
let keys = Keys::parse(private_key_nsec.trim())
|
||||
.map_err(|error| format!("failed to parse managed agent private key: {error}"))?;
|
||||
let minted = mint_managed_agent_api_token(
|
||||
&state.http_client,
|
||||
&relay_url,
|
||||
&keys,
|
||||
&token_name,
|
||||
&scopes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -422,32 +437,10 @@ pub async fn mint_managed_agent_token(
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
let scopes = input
|
||||
.scopes
|
||||
.into_iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let scopes = if scopes.is_empty() {
|
||||
default_token_scopes()
|
||||
} else {
|
||||
scopes
|
||||
};
|
||||
let token_name = input
|
||||
.token_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{}-token", record.name));
|
||||
let minted =
|
||||
run_sprout_admin_mint_token(&app, &record.pubkey, &owner_pubkey, &token_name, &scopes)?;
|
||||
|
||||
record.api_token = Some(minted.api_token.clone());
|
||||
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
||||
record.api_token = Some(minted.token.clone());
|
||||
record.updated_at = now_iso();
|
||||
record.last_error = None;
|
||||
let pubkey = record.pubkey.clone();
|
||||
|
||||
save_managed_agents(&app, &records)?;
|
||||
let record = records
|
||||
@@ -458,7 +451,7 @@ pub async fn mint_managed_agent_token(
|
||||
|
||||
Ok(MintManagedAgentTokenResponse {
|
||||
agent,
|
||||
token: minted.api_token,
|
||||
token: minted.token,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod agent_discovery;
|
||||
mod agent_models;
|
||||
mod agent_settings;
|
||||
mod agents;
|
||||
@@ -6,9 +7,11 @@ mod dms;
|
||||
mod identity;
|
||||
mod media;
|
||||
mod messages;
|
||||
mod personas;
|
||||
mod profile;
|
||||
mod tokens;
|
||||
|
||||
pub use agent_discovery::*;
|
||||
pub use agent_models::*;
|
||||
pub use agent_settings::*;
|
||||
pub use agents::*;
|
||||
@@ -17,5 +20,6 @@ pub use dms::*;
|
||||
pub use identity::*;
|
||||
pub use media::*;
|
||||
pub use messages::*;
|
||||
pub use personas::*;
|
||||
pub use profile::*;
|
||||
pub use tokens::*;
|
||||
|
||||
144
desktop/src-tauri/src/commands/personas.rs
Normal file
144
desktop/src-tauri/src/commands/personas.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use tauri::{AppHandle, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
load_managed_agents, load_personas, save_managed_agents, save_personas, CreatePersonaRequest,
|
||||
PersonaRecord, UpdatePersonaRequest,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
fn trim_required(value: &str, label: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("{label} is required"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn trim_optional(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|candidate| {
|
||||
let trimmed = candidate.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_personas(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<PersonaRecord>, String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
load_personas(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_persona(
|
||||
input: CreatePersonaRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
let now = now_iso();
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = PersonaRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
display_name,
|
||||
avatar_url,
|
||||
system_prompt,
|
||||
is_builtin: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
personas.push(persona.clone());
|
||||
save_personas(&app, &personas)?;
|
||||
Ok(persona)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_persona(
|
||||
input: UpdatePersonaRequest,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<PersonaRecord, String> {
|
||||
let display_name = trim_required(&input.display_name, "Display name")?;
|
||||
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
|
||||
let avatar_url = trim_optional(input.avatar_url);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter_mut()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} not found", input.id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be edited.".to_string());
|
||||
}
|
||||
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
personas
|
||||
.into_iter()
|
||||
.find(|record| record.id == input.id)
|
||||
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_persona(
|
||||
id: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.ok_or_else(|| format!("persona {id} not found"))?;
|
||||
if persona.is_builtin {
|
||||
return Err("Built-in personas cannot be deleted.".to_string());
|
||||
}
|
||||
|
||||
let original_len = personas.len();
|
||||
personas.retain(|record| record.id != id);
|
||||
if personas.len() == original_len {
|
||||
return Err(format!("persona {id} not found"));
|
||||
}
|
||||
save_personas(&app, &personas)?;
|
||||
|
||||
let mut agents = load_managed_agents(&app)?;
|
||||
let mut changed_agents = false;
|
||||
let now = now_iso();
|
||||
for agent in &mut agents {
|
||||
if agent.persona_id.as_deref() == Some(id.as_str()) {
|
||||
agent.persona_id = None;
|
||||
agent.updated_at = now.clone();
|
||||
changed_agents = true;
|
||||
}
|
||||
}
|
||||
if changed_agents {
|
||||
save_managed_agents(&app, &agents)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -159,6 +159,10 @@ pub fn run() {
|
||||
get_managed_agent_log,
|
||||
get_agent_models,
|
||||
update_managed_agent,
|
||||
list_personas,
|
||||
create_persona,
|
||||
update_persona,
|
||||
delete_persona,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::managed_agents::{
|
||||
AcpProviderInfo, CommandAvailabilityInfo, SproutAdminMintTokenJsonOutput, DEFAULT_AGENT_ARG,
|
||||
};
|
||||
use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo, DEFAULT_AGENT_ARG};
|
||||
|
||||
struct KnownAcpProvider {
|
||||
id: &'static str,
|
||||
@@ -312,164 +310,3 @@ mod tests {
|
||||
assert!(managed_agent_avatar_url("custom-agent").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn run_sprout_admin_command(
|
||||
program: &Path,
|
||||
pubkey: &str,
|
||||
owner_pubkey: &str,
|
||||
name: &str,
|
||||
scope_arg: &str,
|
||||
json: bool,
|
||||
) -> Result<Output, String> {
|
||||
let mut command = Command::new(program);
|
||||
command.arg("mint-token");
|
||||
if json {
|
||||
command.arg("--json");
|
||||
}
|
||||
command.args([
|
||||
"--name",
|
||||
name,
|
||||
"--scopes",
|
||||
scope_arg,
|
||||
"--pubkey",
|
||||
pubkey,
|
||||
"--owner-pubkey",
|
||||
owner_pubkey,
|
||||
]);
|
||||
|
||||
command.output().map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
format!(
|
||||
"failed to run `{}`: command not found. Build the workspace binaries from TESTING.md or set SPROUT_ADMIN_COMMAND explicitly.",
|
||||
program.display()
|
||||
)
|
||||
} else {
|
||||
format!("failed to run `{}`: {error}", program.display())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn output_error_detail(program: &Path, output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if !stderr.is_empty() { stderr } else { stdout };
|
||||
format!(
|
||||
"`{}` exited with {}: {detail}",
|
||||
program.display(),
|
||||
output.status
|
||||
)
|
||||
}
|
||||
|
||||
fn boxed_line_content(line: &str) -> String {
|
||||
line.trim_matches(|ch: char| ch == '║' || ch.is_whitespace())
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_legacy_sprout_admin_mint_output(
|
||||
stdout: &str,
|
||||
pubkey: &str,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
) -> Result<SproutAdminMintTokenJsonOutput, String> {
|
||||
let contents: Vec<String> = stdout
|
||||
.lines()
|
||||
.map(boxed_line_content)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
let token_id = contents
|
||||
.iter()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("Token ID:")
|
||||
.map(|value| value.trim().to_string())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing token id".to_string()
|
||||
})?;
|
||||
|
||||
let api_token_index = contents
|
||||
.iter()
|
||||
.position(|line| line == "API Token:")
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing API token label".to_string()
|
||||
})?;
|
||||
let api_token = contents
|
||||
.iter()
|
||||
.skip(api_token_index + 1)
|
||||
.find(|line| !line.ends_with(':'))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
"failed to parse legacy sprout-admin output: missing API token value".to_string()
|
||||
})?;
|
||||
|
||||
Ok(SproutAdminMintTokenJsonOutput {
|
||||
token_id,
|
||||
name: name.to_string(),
|
||||
scopes: scopes.to_vec(),
|
||||
pubkey: pubkey.to_string(),
|
||||
private_key_nsec: None,
|
||||
api_token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_sprout_admin_mint_token(
|
||||
app: &AppHandle,
|
||||
pubkey: &str,
|
||||
owner_pubkey: &str,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
) -> Result<SproutAdminMintTokenJsonOutput, String> {
|
||||
let configured_program = admin_command();
|
||||
let program = resolve_command(&configured_program, Some(app))
|
||||
.ok_or_else(|| missing_command_message(&configured_program, "sprout-admin command"))?;
|
||||
let scope_arg = scopes.join(",");
|
||||
let output = run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, true)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("unexpected argument '--json'") {
|
||||
let legacy_output =
|
||||
run_sprout_admin_command(&program, pubkey, owner_pubkey, name, &scope_arg, false)?;
|
||||
if !legacy_output.status.success() {
|
||||
return Err(output_error_detail(&program, &legacy_output));
|
||||
}
|
||||
|
||||
return parse_legacy_sprout_admin_mint_output(
|
||||
&String::from_utf8_lossy(&legacy_output.stdout),
|
||||
pubkey,
|
||||
name,
|
||||
scopes,
|
||||
);
|
||||
}
|
||||
|
||||
return Err(output_error_detail(&program, &output));
|
||||
}
|
||||
|
||||
let parsed: SproutAdminMintTokenJsonOutput = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("failed to parse sprout-admin JSON output: {error}"))?;
|
||||
|
||||
if parsed.pubkey.to_lowercase() != pubkey.to_lowercase() {
|
||||
return Err("sprout-admin returned a token for the wrong pubkey".to_string());
|
||||
}
|
||||
|
||||
if parsed.token_id.trim().is_empty() {
|
||||
return Err("sprout-admin returned an empty token id".to_string());
|
||||
}
|
||||
|
||||
if parsed.name.trim() != name.trim() {
|
||||
return Err("sprout-admin returned a token with the wrong name".to_string());
|
||||
}
|
||||
|
||||
if parsed.scopes != scopes {
|
||||
return Err("sprout-admin returned a token with the wrong scopes".to_string());
|
||||
}
|
||||
|
||||
if parsed.private_key_nsec.is_some() {
|
||||
return Err(
|
||||
"sprout-admin unexpectedly returned a private key for an existing pubkey".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod discovery;
|
||||
mod personas;
|
||||
mod runtime;
|
||||
mod storage;
|
||||
mod types;
|
||||
|
||||
pub use discovery::*;
|
||||
pub use personas::*;
|
||||
pub use runtime::*;
|
||||
pub use storage::*;
|
||||
pub use types::*;
|
||||
|
||||
236
desktop/src-tauri/src/managed_agents/personas.rs
Normal file
236
desktop/src-tauri/src/managed_agents/personas.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{managed_agents_base_dir, PersonaRecord},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
struct BuiltInPersona {
|
||||
id: &'static str,
|
||||
display_name: &'static str,
|
||||
system_prompt: &'static str,
|
||||
}
|
||||
|
||||
const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[
|
||||
BuiltInPersona {
|
||||
id: "builtin:orchestrator",
|
||||
display_name: "Orchestrator",
|
||||
system_prompt: "You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.",
|
||||
},
|
||||
BuiltInPersona {
|
||||
id: "builtin:researcher",
|
||||
display_name: "Researcher",
|
||||
system_prompt: "You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
|
||||
},
|
||||
BuiltInPersona {
|
||||
id: "builtin:planner",
|
||||
display_name: "Planner",
|
||||
system_prompt: "You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.",
|
||||
},
|
||||
BuiltInPersona {
|
||||
id: "builtin:implementer",
|
||||
display_name: "Builder",
|
||||
system_prompt: "You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.",
|
||||
},
|
||||
BuiltInPersona {
|
||||
id: "builtin:refactor",
|
||||
display_name: "Refactor",
|
||||
system_prompt: "You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.",
|
||||
},
|
||||
BuiltInPersona {
|
||||
id: "builtin:reviewer",
|
||||
display_name: "Reviewer",
|
||||
system_prompt: "You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.",
|
||||
},
|
||||
];
|
||||
|
||||
fn personas_store_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(managed_agents_base_dir(app)?.join("personas.json"))
|
||||
}
|
||||
|
||||
fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
|
||||
BUILT_IN_PERSONAS
|
||||
.iter()
|
||||
.map(|persona| PersonaRecord {
|
||||
id: persona.id.to_string(),
|
||||
display_name: persona.display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: persona.system_prompt.to_string(),
|
||||
is_builtin: true,
|
||||
created_at: now.to_string(),
|
||||
updated_at: now.to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn built_in_order(id: &str) -> Option<usize> {
|
||||
BUILT_IN_PERSONAS.iter().position(|persona| persona.id == id)
|
||||
}
|
||||
|
||||
fn sort_personas(records: &mut [PersonaRecord]) {
|
||||
records.sort_by(|left, right| {
|
||||
let left_builtin = if left.is_builtin { 0 } else { 1 };
|
||||
let right_builtin = if right.is_builtin { 0 } else { 1 };
|
||||
|
||||
left_builtin
|
||||
.cmp(&right_builtin)
|
||||
.then_with(|| match (built_in_order(&left.id), built_in_order(&right.id)) {
|
||||
(Some(left_order), Some(right_order)) => left_order.cmp(&right_order),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
})
|
||||
.then_with(|| {
|
||||
left.display_name
|
||||
.to_lowercase()
|
||||
.cmp(&right.display_name.to_lowercase())
|
||||
})
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaRecord>, bool) {
|
||||
let mut changed = false;
|
||||
|
||||
for built_in in built_in_persona_records(now) {
|
||||
if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) {
|
||||
let created_at = existing.created_at.clone();
|
||||
let updated_at = existing.updated_at.clone();
|
||||
if existing.display_name != built_in.display_name
|
||||
|| existing.avatar_url.is_some()
|
||||
|| existing.system_prompt != built_in.system_prompt
|
||||
|| !existing.is_builtin
|
||||
{
|
||||
*existing = PersonaRecord {
|
||||
created_at,
|
||||
updated_at,
|
||||
..built_in
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
stored.push(built_in);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
sort_personas(&mut stored);
|
||||
(stored, changed)
|
||||
}
|
||||
|
||||
pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
|
||||
let path = personas_store_path(app)?;
|
||||
let now = now_iso();
|
||||
|
||||
let records = if path.exists() {
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("failed to read persona store: {error}"))?;
|
||||
serde_json::from_str::<Vec<PersonaRecord>>(&content)
|
||||
.map_err(|error| format!("failed to parse persona store: {error}"))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let (records, changed) = merge_personas(records, &now);
|
||||
if changed || !path.exists() {
|
||||
save_personas(app, &records)?;
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), String> {
|
||||
let mut sorted = records.to_vec();
|
||||
sort_personas(&mut sorted);
|
||||
|
||||
let path = personas_store_path(app)?;
|
||||
let payload = serde_json::to_vec_pretty(&sorted)
|
||||
.map_err(|error| format!("failed to serialize persona store: {error}"))?;
|
||||
fs::write(&path, payload).map_err(|error| format!("failed to write persona store: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{merge_personas, BUILT_IN_PERSONAS};
|
||||
use crate::managed_agents::PersonaRecord;
|
||||
|
||||
fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
|
||||
PersonaRecord {
|
||||
id: id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
system_prompt: "Custom prompt".to_string(),
|
||||
is_builtin: false,
|
||||
created_at: "2026-03-19T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-19T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_personas_adds_missing_built_ins() {
|
||||
let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
|
||||
assert!(records.iter().all(|record| record.is_builtin));
|
||||
let display_names: Vec<&str> = records.iter().map(|record| record.display_name.as_str()).collect();
|
||||
assert_eq!(
|
||||
display_names,
|
||||
vec!["Orchestrator", "Researcher", "Planner", "Builder", "Refactor", "Reviewer"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_personas_preserves_custom_records() {
|
||||
let custom = custom_persona("custom:test", "Custom");
|
||||
let (records, changed) = merge_personas(vec![custom.clone()], "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
assert!(records.iter().any(|record| record.id == custom.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_personas_restores_builtin_defaults() {
|
||||
let mut edited_builtin = custom_persona("builtin:researcher", "My Researcher");
|
||||
edited_builtin.is_builtin = true;
|
||||
let original_created_at = edited_builtin.created_at.clone();
|
||||
let original_updated_at = edited_builtin.updated_at.clone();
|
||||
|
||||
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
let researcher = records
|
||||
.iter()
|
||||
.find(|record| record.id == "builtin:researcher")
|
||||
.expect("researcher built-in should exist");
|
||||
assert_eq!(researcher.display_name, "Researcher");
|
||||
assert_eq!(researcher.avatar_url, None);
|
||||
assert_eq!(researcher.created_at, original_created_at);
|
||||
assert_eq!(researcher.updated_at, original_updated_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_personas_backfills_new_builtins_for_existing_store() {
|
||||
let mut legacy_builtins = vec![
|
||||
custom_persona("builtin:researcher", "Researcher"),
|
||||
custom_persona("builtin:planner", "Planner"),
|
||||
custom_persona("builtin:implementer", "Implementer"),
|
||||
];
|
||||
for persona in &mut legacy_builtins {
|
||||
persona.is_builtin = true;
|
||||
persona.avatar_url = None;
|
||||
}
|
||||
|
||||
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
|
||||
|
||||
assert!(changed);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.any(|record| record.id == "builtin:implementer" && record.display_name == "Builder")
|
||||
);
|
||||
assert!(records.iter().any(|record| record.id == "builtin:orchestrator"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:refactor"));
|
||||
assert!(records.iter().any(|record| record.id == "builtin:reviewer"));
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ pub fn build_managed_agent_summary(
|
||||
Ok(ManagedAgentSummary {
|
||||
pubkey: record.pubkey.clone(),
|
||||
name: record.name.clone(),
|
||||
persona_id: record.persona_id.clone(),
|
||||
relay_url: record.relay_url.clone(),
|
||||
acp_command: record.acp_command.clone(),
|
||||
agent_command: record.agent_command.clone(),
|
||||
|
||||
@@ -2,6 +2,18 @@ use std::{path::PathBuf, process::Child};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonaRecord {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub is_builtin: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayAgentInfo {
|
||||
pub pubkey: String,
|
||||
@@ -16,6 +28,8 @@ pub struct RelayAgentInfo {
|
||||
pub struct ManagedAgentRecord {
|
||||
pub pubkey: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
pub private_key_nsec: String,
|
||||
pub api_token: Option<String>,
|
||||
pub relay_url: String,
|
||||
@@ -54,6 +68,7 @@ pub struct ManagedAgentProcess {
|
||||
pub struct ManagedAgentSummary {
|
||||
pub pubkey: String,
|
||||
pub name: String,
|
||||
pub persona_id: Option<String>,
|
||||
pub relay_url: String,
|
||||
pub acp_command: String,
|
||||
pub agent_command: String,
|
||||
@@ -80,6 +95,8 @@ pub struct ManagedAgentSummary {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateManagedAgentRequest {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub persona_id: Option<String>,
|
||||
pub relay_url: Option<String>,
|
||||
pub acp_command: Option<String>,
|
||||
pub agent_command: Option<String>,
|
||||
@@ -89,6 +106,7 @@ pub struct CreateManagedAgentRequest {
|
||||
pub turn_timeout_seconds: Option<u64>,
|
||||
pub parallelism: Option<u32>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mint_token: bool,
|
||||
@@ -110,6 +128,23 @@ pub struct CreateManagedAgentResponse {
|
||||
pub spawn_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePersonaRequest {
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdatePersonaRequest {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MintManagedAgentTokenRequest {
|
||||
@@ -161,16 +196,6 @@ pub struct ManagedAgentPrereqsInfo {
|
||||
pub mcp: CommandAvailabilityInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SproutAdminMintTokenJsonOutput {
|
||||
pub token_id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub pubkey: String,
|
||||
pub private_key_nsec: Option<String>,
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
/// Patch request for updating a managed agent's mutable fields.
|
||||
///
|
||||
/// Tri-state nullable semantics via `Option<Option<T>>`:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Kind, Tag};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
models::{ProfileInfo, UpdateProfileBody},
|
||||
models::{MintTokenBody, MintTokenResponse, UpdateProfileBody},
|
||||
};
|
||||
|
||||
pub fn relay_ws_url() -> String {
|
||||
@@ -57,26 +57,6 @@ pub fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
pub async fn managed_agent_owner_pubkey(state: &AppState) -> Result<String, String> {
|
||||
if state.configured_api_token.is_none() {
|
||||
return auth_pubkey_header(state);
|
||||
}
|
||||
|
||||
let request = build_authed_request(
|
||||
&state.http_client,
|
||||
Method::GET,
|
||||
"/api/users/me/profile",
|
||||
state,
|
||||
)?;
|
||||
let profile: ProfileInfo = send_json_request(request).await.map_err(|error| {
|
||||
format!(
|
||||
"failed to resolve the authenticated token owner: {error}. Managed-agent minting with SPROUT_API_TOKEN requires a token for the desktop identity with `users:read`."
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(profile.pubkey)
|
||||
}
|
||||
|
||||
fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool {
|
||||
scopes.iter().any(|scope| scope == required_scope)
|
||||
}
|
||||
@@ -162,6 +142,15 @@ pub fn build_nip98_auth_header(
|
||||
state: &AppState,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|error| error.to_string())?;
|
||||
build_nip98_auth_header_for_keys(&keys, method, url, body)
|
||||
}
|
||||
|
||||
pub fn build_nip98_auth_header_for_keys(
|
||||
keys: &Keys,
|
||||
method: &Method,
|
||||
url: &str,
|
||||
body: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let payload_hash = format!("{:x}", Sha256::digest(body));
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["u", url]).map_err(|error| format!("url tag failed: {error}"))?,
|
||||
@@ -182,6 +171,38 @@ pub fn build_nip98_auth_header(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn mint_managed_agent_api_token(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
let url = format!("{}{}", relay_http_base_url(relay_url), "/api/tokens");
|
||||
let body = MintTokenBody {
|
||||
name,
|
||||
scopes,
|
||||
channel_ids: None,
|
||||
expires_in_days: None,
|
||||
};
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?;
|
||||
let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?;
|
||||
let forwarded_proto = if url.starts_with("http://") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
let request = client
|
||||
.request(Method::POST, url)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Forwarded-Proto", forwarded_proto)
|
||||
.body(body_bytes);
|
||||
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
pub async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
@@ -47,6 +47,8 @@ export type CreateChannelManagedAgentInput = {
|
||||
provider: ChannelAgentProvider;
|
||||
name: string;
|
||||
systemPrompt?: string;
|
||||
avatarUrl?: string;
|
||||
personaId?: string | null;
|
||||
role?: Exclude<ChannelRole, "owner">;
|
||||
ensureRunning?: boolean;
|
||||
};
|
||||
@@ -57,6 +59,18 @@ export type CreateChannelManagedAgentResult =
|
||||
providerId: string;
|
||||
};
|
||||
|
||||
export type CreateChannelManagedAgentBatchFailure = {
|
||||
kind: "generic" | "persona";
|
||||
name: string;
|
||||
personaId: string | null;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type CreateChannelManagedAgentsResult = {
|
||||
successes: CreateChannelManagedAgentResult[];
|
||||
failures: CreateChannelManagedAgentBatchFailure[];
|
||||
};
|
||||
|
||||
function normalizePubkey(pubkey: string) {
|
||||
return pubkey.trim().toLowerCase();
|
||||
}
|
||||
@@ -88,17 +102,21 @@ export async function attachManagedAgentToChannel(
|
||||
) {
|
||||
const role = input.role ?? "bot";
|
||||
const ensureRunning = input.ensureRunning ?? true;
|
||||
const members = await getChannelMembers(channelId);
|
||||
const membershipAdded = !members.some(
|
||||
(member) =>
|
||||
normalizePubkey(member.pubkey) === normalizePubkey(input.agent.pubkey),
|
||||
);
|
||||
|
||||
await addChannelMembers({
|
||||
const agentPubkey = normalizePubkey(input.agent.pubkey);
|
||||
const membershipResult = await addChannelMembers({
|
||||
channelId,
|
||||
pubkeys: [input.agent.pubkey],
|
||||
role,
|
||||
});
|
||||
const membershipError = membershipResult.errors.find(
|
||||
(error) => normalizePubkey(error.pubkey) === agentPubkey,
|
||||
);
|
||||
if (membershipError) {
|
||||
throw new Error(membershipError.error);
|
||||
}
|
||||
const membershipAdded = membershipResult.added.some(
|
||||
(pubkey) => normalizePubkey(pubkey) === agentPubkey,
|
||||
);
|
||||
|
||||
let agent = input.agent;
|
||||
let started = false;
|
||||
@@ -256,7 +274,9 @@ export async function createChannelManagedAgent(
|
||||
mintToken: true,
|
||||
tokenName: `${trimmedName} agent`,
|
||||
tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES,
|
||||
personaId: input.personaId ?? undefined,
|
||||
systemPrompt: input.systemPrompt?.trim() || undefined,
|
||||
avatarUrl: input.avatarUrl?.trim() || undefined,
|
||||
spawnAfterCreate: false,
|
||||
});
|
||||
const attached = await attachManagedAgentToChannel(channelId, {
|
||||
@@ -271,3 +291,29 @@ export async function createChannelManagedAgent(
|
||||
providerId: input.provider.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChannelManagedAgents(
|
||||
channelId: string,
|
||||
inputs: readonly CreateChannelManagedAgentInput[],
|
||||
): Promise<CreateChannelManagedAgentsResult> {
|
||||
const successes: CreateChannelManagedAgentResult[] = [];
|
||||
const failures: CreateChannelManagedAgentBatchFailure[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
try {
|
||||
successes.push(await createChannelManagedAgent(channelId, input));
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
kind: input.personaId ? "persona" : "generic",
|
||||
name: input.name.trim() || "agent",
|
||||
personaId: input.personaId ?? null,
|
||||
error: error instanceof Error ? error.message : "Failed to add agent.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
successes,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
attachManagedAgentToChannel,
|
||||
createChannelManagedAgent,
|
||||
createChannelManagedAgents,
|
||||
ensureChannelAgentPresetInChannel,
|
||||
} from "@/features/agents/channelAgents";
|
||||
import { channelsQueryKey } from "@/features/channels/hooks";
|
||||
import {
|
||||
deleteManagedAgent,
|
||||
createManagedAgent,
|
||||
deleteManagedAgent,
|
||||
discoverAcpProviders,
|
||||
discoverManagedAgentPrereqs,
|
||||
getManagedAgentLog,
|
||||
@@ -18,16 +19,26 @@ import {
|
||||
startManagedAgent,
|
||||
stopManagedAgent,
|
||||
} from "@/shared/api/tauri";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
listPersonas,
|
||||
updatePersona,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
|
||||
import type {
|
||||
AgentPersona,
|
||||
CreateManagedAgentInput,
|
||||
CreatePersonaInput,
|
||||
ManagedAgent,
|
||||
MintManagedAgentTokenInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import type {
|
||||
AttachManagedAgentToChannelInput,
|
||||
AttachManagedAgentToChannelResult,
|
||||
CreateChannelManagedAgentInput,
|
||||
CreateChannelManagedAgentsResult,
|
||||
CreateChannelManagedAgentResult,
|
||||
EnsureChannelAgentPresetInput,
|
||||
EnsureChannelAgentPresetResult,
|
||||
@@ -36,6 +47,8 @@ export type {
|
||||
AttachManagedAgentToChannelInput,
|
||||
AttachManagedAgentToChannelResult,
|
||||
CreateChannelManagedAgentInput,
|
||||
CreateChannelManagedAgentBatchFailure,
|
||||
CreateChannelManagedAgentsResult,
|
||||
CreateChannelManagedAgentResult,
|
||||
EnsureChannelAgentPresetInput,
|
||||
EnsureChannelAgentPresetResult,
|
||||
@@ -43,6 +56,7 @@ export type {
|
||||
|
||||
export const relayAgentsQueryKey = ["relay-agents"] as const;
|
||||
export const managedAgentsQueryKey = ["managed-agents"] as const;
|
||||
export const personasQueryKey = ["personas"] as const;
|
||||
export const acpProvidersQueryKey = ["acp-providers"] as const;
|
||||
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
|
||||
|
||||
@@ -76,6 +90,14 @@ export function useAcpProvidersQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePersonasQuery() {
|
||||
return useQuery({
|
||||
queryKey: personasQueryKey,
|
||||
queryFn: listPersonas,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useManagedAgentPrereqsQuery(
|
||||
acpCommand: string,
|
||||
mcpCommand: string,
|
||||
@@ -145,6 +167,54 @@ export function useCreateManagedAgentMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePersonaMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreatePersonaInput) => createPersona(input),
|
||||
onSuccess: (created) => {
|
||||
queryClient.setQueryData<AgentPersona[]>(personasQueryKey, (current) => {
|
||||
const next = current ?? [];
|
||||
return [
|
||||
created,
|
||||
...next.filter((persona) => persona.id !== created.id),
|
||||
];
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: personasQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePersonaMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: UpdatePersonaInput) => updatePersona(input),
|
||||
onSettled: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePersonaMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deletePersona(id),
|
||||
onSettled: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartManagedAgentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -269,6 +339,27 @@ export function useCreateChannelManagedAgentMutation(channelId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateChannelManagedAgentsMutation(
|
||||
channelId: string | null,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
inputs: readonly CreateChannelManagedAgentInput[],
|
||||
): Promise<CreateChannelManagedAgentsResult> => {
|
||||
if (!channelId) {
|
||||
throw new Error("No channel selected.");
|
||||
}
|
||||
|
||||
return createChannelManagedAgents(channelId, inputs);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateAgentQueries(queryClient, channelId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnsureGooseInChannelMutation(channelId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -2,37 +2,62 @@ import * as React from "react";
|
||||
|
||||
import {
|
||||
type AttachManagedAgentToChannelResult,
|
||||
useCreatePersonaMutation,
|
||||
useDeletePersonaMutation,
|
||||
useDeleteManagedAgentMutation,
|
||||
useManagedAgentLogQuery,
|
||||
useManagedAgentsQuery,
|
||||
useMintManagedAgentTokenMutation,
|
||||
usePersonasQuery,
|
||||
useRelayAgentsQuery,
|
||||
useSetManagedAgentStartOnAppLaunchMutation,
|
||||
useStartManagedAgentMutation,
|
||||
useStopManagedAgentMutation,
|
||||
useUpdatePersonaMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import type {
|
||||
AgentPersona,
|
||||
Channel,
|
||||
CreatePersonaInput,
|
||||
CreateManagedAgentResponse,
|
||||
ManagedAgent,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
|
||||
import { CreateAgentDialog } from "./CreateAgentDialog";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ManagedAgentsSection } from "./ManagedAgentsSection";
|
||||
import { PersonaDialog } from "./PersonaDialog";
|
||||
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
|
||||
import { PersonasSection } from "./PersonasSection";
|
||||
import { RelayDirectorySection } from "./RelayDirectorySection";
|
||||
import { SecretRevealDialog } from "./SecretRevealDialog";
|
||||
import { TokenRevealDialog } from "./TokenRevealDialog";
|
||||
|
||||
type PersonaDialogState = {
|
||||
description: string;
|
||||
initialValues: CreatePersonaInput | UpdatePersonaInput;
|
||||
submitLabel: string;
|
||||
title: string;
|
||||
} | null;
|
||||
|
||||
export function AgentsView() {
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const startMutation = useStartManagedAgentMutation();
|
||||
const stopMutation = useStopManagedAgentMutation();
|
||||
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
|
||||
const deleteMutation = useDeleteManagedAgentMutation();
|
||||
const mintTokenMutation = useMintManagedAgentTokenMutation();
|
||||
const createPersonaMutation = useCreatePersonaMutation();
|
||||
const updatePersonaMutation = useUpdatePersonaMutation();
|
||||
const deletePersonaMutation = useDeletePersonaMutation();
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
|
||||
const [personaDialogState, setPersonaDialogState] =
|
||||
React.useState<PersonaDialogState>(null);
|
||||
const [personaToDelete, setPersonaToDelete] =
|
||||
React.useState<AgentPersona | null>(null);
|
||||
const [agentToAddToChannel, setAgentToAddToChannel] =
|
||||
React.useState<ManagedAgent | null>(null);
|
||||
const [createdAgent, setCreatedAgent] =
|
||||
@@ -58,6 +83,14 @@ export function AgentsView() {
|
||||
}),
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
const personas = personasQuery.data ?? [];
|
||||
const personaLabelsById = React.useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
personas.map((persona) => [persona.id, persona.displayName]),
|
||||
),
|
||||
[personas],
|
||||
);
|
||||
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -167,6 +200,43 @@ export function AgentsView() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePersonaSubmit(
|
||||
input: CreatePersonaInput | UpdatePersonaInput,
|
||||
) {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
|
||||
try {
|
||||
if ("id" in input) {
|
||||
await updatePersonaMutation.mutateAsync(input);
|
||||
setActionNoticeMessage(`Updated ${input.displayName}.`);
|
||||
} else {
|
||||
await createPersonaMutation.mutateAsync(input);
|
||||
setActionNoticeMessage(`Created ${input.displayName}.`);
|
||||
}
|
||||
setPersonaDialogState(null);
|
||||
} catch (error) {
|
||||
setActionErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to save persona.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePersona(persona: AgentPersona) {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
|
||||
try {
|
||||
await deletePersonaMutation.mutateAsync(persona.id);
|
||||
setActionNoticeMessage(`Deleted ${persona.displayName}.`);
|
||||
setPersonaToDelete(null);
|
||||
} catch (error) {
|
||||
setActionErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to delete persona.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddedToChannel(
|
||||
channel: Channel,
|
||||
result: AttachManagedAgentToChannelResult,
|
||||
@@ -202,13 +272,81 @@ export function AgentsView() {
|
||||
stopMutation.isPending ||
|
||||
startOnLaunchMutation.isPending ||
|
||||
deleteMutation.isPending ||
|
||||
mintTokenMutation.isPending;
|
||||
mintTokenMutation.isPending ||
|
||||
createPersonaMutation.isPending ||
|
||||
updatePersonaMutation.isPending ||
|
||||
deletePersonaMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PersonasSection
|
||||
error={
|
||||
personasQuery.error instanceof Error
|
||||
? personasQuery.error
|
||||
: null
|
||||
}
|
||||
isLoading={personasQuery.isLoading}
|
||||
isPending={
|
||||
createPersonaMutation.isPending ||
|
||||
updatePersonaMutation.isPending ||
|
||||
deletePersonaMutation.isPending
|
||||
}
|
||||
onCreate={() => {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
setPersonaDialogState({
|
||||
title: "Create persona",
|
||||
description:
|
||||
"Save a reusable role, prompt, and optional avatar for future agent deployments.",
|
||||
submitLabel: "Create persona",
|
||||
initialValues: {
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
systemPrompt: "",
|
||||
},
|
||||
});
|
||||
}}
|
||||
onDelete={setPersonaToDelete}
|
||||
onDuplicate={(persona) => {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
setPersonaDialogState({
|
||||
title: `Duplicate ${persona.displayName}`,
|
||||
description:
|
||||
"Create a new persona by copying this template and adjusting it as needed.",
|
||||
submitLabel: "Create persona",
|
||||
initialValues: {
|
||||
displayName: `${persona.displayName} copy`,
|
||||
avatarUrl: persona.avatarUrl ?? "",
|
||||
systemPrompt: persona.systemPrompt,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onEdit={(persona) => {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
setPersonaDialogState({
|
||||
title: `Edit ${persona.displayName}`,
|
||||
description:
|
||||
"Update this saved persona. New deployments will use the updated values.",
|
||||
submitLabel: "Save changes",
|
||||
initialValues: {
|
||||
id: persona.id,
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarUrl ?? "",
|
||||
systemPrompt: persona.systemPrompt,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void personasQuery.refetch();
|
||||
}}
|
||||
personas={personas}
|
||||
/>
|
||||
|
||||
<ManagedAgentsSection
|
||||
actionErrorMessage={actionErrorMessage}
|
||||
actionNoticeMessage={actionNoticeMessage}
|
||||
@@ -220,6 +358,7 @@ export function AgentsView() {
|
||||
}
|
||||
isActionPending={isActionPending}
|
||||
isLoading={managedAgentsQuery.isLoading}
|
||||
personaLabelsById={personaLabelsById}
|
||||
onAddToChannel={(agent) => {
|
||||
setActionNoticeMessage(null);
|
||||
setActionErrorMessage(null);
|
||||
@@ -307,6 +446,41 @@ export function AgentsView() {
|
||||
}}
|
||||
token={revealedToken?.token ?? null}
|
||||
/>
|
||||
<PersonaDialog
|
||||
description={personaDialogState?.description ?? ""}
|
||||
error={
|
||||
updatePersonaMutation.error instanceof Error
|
||||
? updatePersonaMutation.error
|
||||
: createPersonaMutation.error instanceof Error
|
||||
? createPersonaMutation.error
|
||||
: null
|
||||
}
|
||||
initialValues={personaDialogState?.initialValues ?? null}
|
||||
isPending={
|
||||
createPersonaMutation.isPending || updatePersonaMutation.isPending
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPersonaDialogState(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={handlePersonaSubmit}
|
||||
open={personaDialogState !== null}
|
||||
submitLabel={personaDialogState?.submitLabel ?? "Save"}
|
||||
title={personaDialogState?.title ?? "Persona"}
|
||||
/>
|
||||
<PersonaDeleteDialog
|
||||
onConfirm={(persona) => {
|
||||
void handleDeletePersona(persona);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPersonaToDelete(null);
|
||||
}
|
||||
}}
|
||||
open={personaToDelete !== null}
|
||||
persona={personaToDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export function ManagedAgentsSection({
|
||||
error,
|
||||
isActionPending,
|
||||
isLoading,
|
||||
personaLabelsById,
|
||||
onAddToChannel,
|
||||
onCreate,
|
||||
onDelete,
|
||||
@@ -49,6 +50,7 @@ export function ManagedAgentsSection({
|
||||
error: Error | null;
|
||||
isActionPending: boolean;
|
||||
isLoading: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
@@ -132,6 +134,7 @@ export function ManagedAgentsSection({
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
key={agent.pubkey}
|
||||
personaLabelsById={personaLabelsById}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onMintToken={onMintToken}
|
||||
@@ -172,6 +175,7 @@ export function ManagedAgentsSection({
|
||||
function ManagedAgentRow({
|
||||
agent,
|
||||
isActionPending,
|
||||
personaLabelsById,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onMintToken,
|
||||
@@ -183,6 +187,7 @@ function ManagedAgentRow({
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
personaLabelsById: Record<string, string>;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onMintToken: (pubkey: string, name: string) => void;
|
||||
@@ -193,6 +198,9 @@ function ManagedAgentRow({
|
||||
onViewLogs: (pubkey: string) => void;
|
||||
}) {
|
||||
const isRunning = agent.status === "running";
|
||||
const personaLabel = agent.personaId
|
||||
? (personaLabelsById[agent.personaId] ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -201,6 +209,13 @@ function ManagedAgentRow({
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<p className="truncate font-medium text-foreground">{agent.name}</p>
|
||||
{personaLabel ? (
|
||||
<p className="mt-1">
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{personaLabel}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{truncatePubkey(agent.pubkey)}
|
||||
</p>
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ModelPicker({
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [needsRestart, setNeedsRestart] = React.useState(false);
|
||||
const [hasRequestedModels, setHasRequestedModels] = React.useState(false);
|
||||
|
||||
const fetchModels = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,17 +40,26 @@ export function ModelPicker({
|
||||
}
|
||||
}, [agent.pubkey]);
|
||||
|
||||
// Auto-fetch on mount
|
||||
React.useEffect(() => {
|
||||
void fetchModels();
|
||||
}, [fetchModels]);
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open || loading || modelsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHasRequestedModels(true);
|
||||
void fetchModels();
|
||||
},
|
||||
[fetchModels, loading, modelsData],
|
||||
);
|
||||
|
||||
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
|
||||
const displayLabel =
|
||||
agent.model ??
|
||||
(modelsData?.agentDefaultModel
|
||||
? `${modelsData.agentDefaultModel} (default)`
|
||||
: "—");
|
||||
: hasRequestedModels && loading
|
||||
? "Loading..."
|
||||
: "Auto");
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
setSaving(true);
|
||||
@@ -69,38 +79,9 @@ export function ModelPicker({
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
||||
<span className="text-destructive">Failed</span>
|
||||
<button
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={fetchModels}
|
||||
type="button"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modelsData?.supportsSwitching) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">{displayLabel}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={false} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="h-7 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
@@ -118,16 +99,45 @@ export function ModelPicker({
|
||||
className="max-h-64 min-w-48 overflow-y-auto"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={handleModelChange}
|
||||
value={currentValue}
|
||||
>
|
||||
{modelsData.models.map((model) => (
|
||||
<DropdownMenuRadioItem key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-2 px-3 py-2 text-sm">
|
||||
<p className="text-destructive">Failed to load models.</p>
|
||||
<button
|
||||
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
onClick={() => {
|
||||
setHasRequestedModels(true);
|
||||
void fetchModels();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : !modelsData ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
Open to load available models.
|
||||
</div>
|
||||
) : !modelsData.supportsSwitching ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
This agent uses the runtime's default model.
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={handleModelChange}
|
||||
value={currentValue}
|
||||
>
|
||||
{modelsData.models.map((model) => (
|
||||
<DropdownMenuRadioItem key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{needsRestart ? (
|
||||
|
||||
61
desktop/src/features/agents/ui/PersonaDeleteDialog.tsx
Normal file
61
desktop/src/features/agents/ui/PersonaDeleteDialog.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type PersonaDeleteDialogProps = {
|
||||
open: boolean;
|
||||
persona: AgentPersona | null;
|
||||
onConfirm: (persona: AgentPersona) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function PersonaDeleteDialog({
|
||||
open,
|
||||
persona,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: PersonaDeleteDialogProps) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete persona?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{persona
|
||||
? `Delete ${persona.displayName}. Existing agents keep their copied settings, but this template will no longer be available for new deployments.`
|
||||
: "Delete this persona."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (persona) {
|
||||
onConfirm(persona);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
187
desktop/src/features/agents/ui/PersonaDialog.tsx
Normal file
187
desktop/src/features/agents/ui/PersonaDialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type PersonaDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
submitLabel: string;
|
||||
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
|
||||
error: Error | null;
|
||||
isPending: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PersonaDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
initialValues,
|
||||
error,
|
||||
isPending,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PersonaDialogProps) {
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
const [systemPrompt, setSystemPrompt] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisplayName(initialValues.displayName);
|
||||
setAvatarUrl(initialValues.avatarUrl ?? "");
|
||||
setSystemPrompt(initialValues.systemPrompt);
|
||||
}, [initialValues, open]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setDisplayName("");
|
||||
setAvatarUrl("");
|
||||
setSystemPrompt("");
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
displayName,
|
||||
avatarUrl: avatarUrl.trim() || undefined,
|
||||
systemPrompt,
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
await onSubmit({
|
||||
id: initialValues.id,
|
||||
...baseInput,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(baseInput);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-2xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="persona-display-name"
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<Input
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="persona-display-name"
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Researcher"
|
||||
value={displayName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="persona-avatar-url"
|
||||
>
|
||||
Avatar URL
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="persona-avatar-url"
|
||||
onChange={(event) => setAvatarUrl(event.target.value)}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
spellCheck={false}
|
||||
value={avatarUrl}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Deployed agents fall back to the runtime avatar if
|
||||
this is blank.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="persona-system-prompt"
|
||||
>
|
||||
System prompt
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
disabled={isPending}
|
||||
id="persona-system-prompt"
|
||||
onChange={(event) => setSystemPrompt(event.target.value)}
|
||||
placeholder="Describe what this persona should do."
|
||||
value={systemPrompt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
displayName.trim().length === 0 ||
|
||||
systemPrompt.trim().length === 0 ||
|
||||
isPending
|
||||
}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{isPending ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
190
desktop/src/features/agents/ui/PersonasSection.tsx
Normal file
190
desktop/src/features/agents/ui/PersonasSection.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
CopyPlus,
|
||||
Ellipsis,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
function promptPreview(systemPrompt: string) {
|
||||
const [firstLine] = systemPrompt
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
return firstLine ?? systemPrompt.trim();
|
||||
}
|
||||
|
||||
type PersonasSectionProps = {
|
||||
personas: AgentPersona[];
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
onCreate: () => void;
|
||||
onDuplicate: (persona: AgentPersona) => void;
|
||||
onEdit: (persona: AgentPersona) => void;
|
||||
onDelete: (persona: AgentPersona) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function PersonasSection({
|
||||
personas,
|
||||
error,
|
||||
isLoading,
|
||||
isPending,
|
||||
onCreate,
|
||||
onDuplicate,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRefresh,
|
||||
}: PersonasSectionProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">Personas</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reusable agent templates for common roles and prompts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={onCreate} type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create persona
|
||||
</Button>
|
||||
<Button onClick={onRefresh} type="button" variant="outline">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{["first", "second", "third", "fourth"].map((key) => (
|
||||
<div
|
||||
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
|
||||
key={key}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-9 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && personas.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{personas.map((persona) => (
|
||||
<div
|
||||
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
|
||||
key={persona.id}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<ProfileAvatar
|
||||
avatarUrl={persona.avatarUrl}
|
||||
className="h-10 w-10 rounded-lg text-xs"
|
||||
label={persona.displayName}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{persona.displayName}
|
||||
</p>
|
||||
{persona.isBuiltIn ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Built-in
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{!persona.isBuiltIn ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isPending}
|
||||
onClick={() => onEdit(persona)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={isPending}
|
||||
onClick={() => onDuplicate(persona)}
|
||||
>
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
{!persona.isBuiltIn ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isPending}
|
||||
onClick={() => onDelete(persona)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{promptPreview(persona.systemPrompt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && personas.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No personas yet
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Create one to save a role, prompt, and optional avatar for reuse.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
useCreateChannelManagedAgentMutation,
|
||||
useCreateChannelManagedAgentsMutation,
|
||||
usePersonasQuery,
|
||||
type CreateChannelManagedAgentResult,
|
||||
} from "@/features/agents/hooks";
|
||||
import { AddChannelBotGenericSection } from "@/features/channels/ui/AddChannelBotGenericSection";
|
||||
import { AddChannelBotPersonasSection } from "@/features/channels/ui/AddChannelBotPersonasSection";
|
||||
import type { AcpProvider } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -21,8 +24,6 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type AddChannelBotDialogProps = {
|
||||
channelId: string | null;
|
||||
@@ -47,6 +48,29 @@ function defaultBotName(provider: AcpProvider | null) {
|
||||
return provider.label.trim().toLowerCase() || "agent";
|
||||
}
|
||||
|
||||
function toggleValue(values: readonly string[], value: string) {
|
||||
return values.includes(value)
|
||||
? values.filter((candidate) => candidate !== value)
|
||||
: [...values, value];
|
||||
}
|
||||
|
||||
function formatAgentCountLabel(count: number) {
|
||||
return count === 1 ? "agent" : "agents";
|
||||
}
|
||||
|
||||
function formatBatchFailureSummary(
|
||||
failures: ReadonlyArray<{ name: string; error: string }>,
|
||||
) {
|
||||
if (failures.length === 1) {
|
||||
const [failure] = failures;
|
||||
return `Failed to add ${failure.name}: ${failure.error}`;
|
||||
}
|
||||
|
||||
return failures
|
||||
.map((failure) => `${failure.name}: ${failure.error}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export function AddChannelBotDialog({
|
||||
channelId,
|
||||
open,
|
||||
@@ -56,11 +80,23 @@ export function AddChannelBotDialog({
|
||||
onAdded,
|
||||
onOpenChange,
|
||||
}: AddChannelBotDialogProps) {
|
||||
const createBotMutation = useCreateChannelManagedAgentMutation(channelId);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId);
|
||||
const personas = personasQuery.data ?? [];
|
||||
const [selectedProviderId, setSelectedProviderId] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [prompt, setPrompt] = React.useState("");
|
||||
const [hasEditedName, setHasEditedName] = React.useState(false);
|
||||
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [includeGeneric, setIncludeGeneric] = React.useState(false);
|
||||
const [customName, setCustomName] = React.useState("");
|
||||
const [customPrompt, setCustomPrompt] = React.useState("");
|
||||
const [hasEditedCustomName, setHasEditedCustomName] = React.useState(false);
|
||||
const [submissionNotice, setSubmissionNotice] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [submissionError, setSubmissionError] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedProvider = React.useMemo(
|
||||
() =>
|
||||
@@ -69,6 +105,11 @@ export function AddChannelBotDialog({
|
||||
null,
|
||||
[providers, selectedProviderId],
|
||||
);
|
||||
const selectedPersonas = React.useMemo(
|
||||
() => personas.filter((persona) => selectedPersonaIds.includes(persona.id)),
|
||||
[personas, selectedPersonaIds],
|
||||
);
|
||||
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -81,19 +122,29 @@ export function AddChannelBotDialog({
|
||||
}, [open, providers, selectedProviderId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedProvider || hasEditedName) {
|
||||
if (!selectedProvider || hasEditedCustomName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setName(defaultBotName(selectedProvider));
|
||||
}, [hasEditedName, selectedProvider]);
|
||||
setCustomName(defaultBotName(selectedProvider));
|
||||
}, [hasEditedCustomName, selectedProvider]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedPersonaIds((current) =>
|
||||
current.filter((id) => personas.some((persona) => persona.id === id)),
|
||||
);
|
||||
}, [personas]);
|
||||
|
||||
function reset() {
|
||||
setSelectedProviderId(providers[0]?.id ?? "");
|
||||
setName(providers[0] ? defaultBotName(providers[0]) : "");
|
||||
setPrompt("");
|
||||
setHasEditedName(false);
|
||||
createBotMutation.reset();
|
||||
setSelectedPersonaIds([]);
|
||||
setIncludeGeneric(false);
|
||||
setCustomName(providers[0] ? defaultBotName(providers[0]) : "");
|
||||
setCustomPrompt("");
|
||||
setHasEditedCustomName(false);
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
createBotsMutation.reset();
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
@@ -105,19 +156,66 @@ export function AddChannelBotDialog({
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedProvider) {
|
||||
if (!selectedProvider || selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createBotMutation.mutateAsync({
|
||||
const inputs = [
|
||||
...(includeGeneric
|
||||
? [
|
||||
{
|
||||
provider: selectedProvider,
|
||||
name: customName,
|
||||
systemPrompt: customPrompt,
|
||||
role: "bot" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...selectedPersonas.map((persona) => ({
|
||||
provider: selectedProvider,
|
||||
name,
|
||||
systemPrompt: prompt,
|
||||
role: "bot",
|
||||
});
|
||||
onAdded?.(result);
|
||||
handleOpenChange(false);
|
||||
name: persona.displayName,
|
||||
personaId: persona.id,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
role: "bot" as const,
|
||||
})),
|
||||
];
|
||||
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
|
||||
try {
|
||||
const result = await createBotsMutation.mutateAsync(inputs);
|
||||
|
||||
if (result.failures.length === 0) {
|
||||
if (result.successes[0]) {
|
||||
onAdded?.(result.successes[0]);
|
||||
}
|
||||
handleOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedPersonaIds = new Set(
|
||||
result.failures
|
||||
.map((failure) => failure.personaId)
|
||||
.filter((personaId): personaId is string => Boolean(personaId)),
|
||||
);
|
||||
setSelectedPersonaIds((current) =>
|
||||
current.filter((personaId) => failedPersonaIds.has(personaId)),
|
||||
);
|
||||
setIncludeGeneric(
|
||||
result.failures.some((failure) => failure.kind === "generic"),
|
||||
);
|
||||
|
||||
if (result.successes.length > 0) {
|
||||
setSubmissionNotice(
|
||||
`Added ${result.successes.length} ${formatAgentCountLabel(
|
||||
result.successes.length,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
setSubmissionError(formatBatchFailureSummary(result.failures));
|
||||
} catch {
|
||||
// The mutation error is rendered inline.
|
||||
}
|
||||
@@ -125,30 +223,39 @@ export function AddChannelBotDialog({
|
||||
|
||||
const canSubmit =
|
||||
selectedProvider !== null &&
|
||||
name.trim().length > 0 &&
|
||||
selectedCount > 0 &&
|
||||
(!includeGeneric || customName.trim().length > 0) &&
|
||||
!providersLoading &&
|
||||
!createBotMutation.isPending;
|
||||
!createBotsMutation.isPending;
|
||||
const canChooseProvider =
|
||||
providers.length > 0 && !providersLoading && !createBotMutation.isPending;
|
||||
providers.length > 0 && !providersLoading && !createBotsMutation.isPending;
|
||||
const canToggleSelections = !createBotsMutation.isPending;
|
||||
const providerTriggerLabel = providersLoading
|
||||
? "Loading runtimes..."
|
||||
: (selectedProvider?.label ?? "No runtimes found");
|
||||
const addButtonLabel = createBotsMutation.isPending
|
||||
? selectedCount > 1
|
||||
? `Adding ${selectedCount}...`
|
||||
: "Adding..."
|
||||
: selectedCount > 1
|
||||
? `Add ${selectedCount} agents`
|
||||
: "Add agent";
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick a runtime, adjust the default name if needed, and describe what
|
||||
this agent should do in the channel.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-3xl overflow-hidden p-0">
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Add agents</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select any combination of saved personas, or turn on Generic for a
|
||||
one-off custom agent.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<div className="grid gap-3 sm:grid-cols-[max-content,minmax(0,1fr)] sm:items-end">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium">Agent</div>
|
||||
<div className="text-sm font-medium">Runtime</div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -168,10 +275,7 @@ export function AddChannelBotDialog({
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(value) => {
|
||||
setSelectedProviderId(value);
|
||||
setHasEditedName(false);
|
||||
}}
|
||||
onValueChange={setSelectedProviderId}
|
||||
value={selectedProvider?.id ?? ""}
|
||||
>
|
||||
{providers.map((provider) => (
|
||||
@@ -187,77 +291,95 @@ export function AddChannelBotDialog({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-bot-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
disabled={createBotMutation.isPending}
|
||||
id="channel-bot-name"
|
||||
onChange={(event) => {
|
||||
setHasEditedName(true);
|
||||
setName(event.target.value);
|
||||
}}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The name defaults to the runtime, but you can edit it before adding
|
||||
the agent.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-bot-prompt">
|
||||
Prompt
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
disabled={createBotMutation.isPending}
|
||||
id="channel-bot-prompt"
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="What should this agent help with in the channel?"
|
||||
value={prompt}
|
||||
<AddChannelBotPersonasSection
|
||||
canToggleSelections={canToggleSelections}
|
||||
includeGeneric={includeGeneric}
|
||||
isLoading={personasQuery.isLoading}
|
||||
onToggleGeneric={() => {
|
||||
setIncludeGeneric((current) => !current);
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
onTogglePersona={(personaId) => {
|
||||
setSelectedPersonaIds((current) =>
|
||||
toggleValue(current, personaId),
|
||||
);
|
||||
setSubmissionNotice(null);
|
||||
setSubmissionError(null);
|
||||
}}
|
||||
personas={personas}
|
||||
selectedPersonaIds={selectedPersonaIds}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as the agent's system prompt override.
|
||||
</p>
|
||||
|
||||
{includeGeneric ? (
|
||||
<AddChannelBotGenericSection
|
||||
disabled={createBotsMutation.isPending}
|
||||
name={customName}
|
||||
onNameChange={(value) => {
|
||||
setHasEditedCustomName(true);
|
||||
setCustomName(value);
|
||||
}}
|
||||
onPromptChange={setCustomPrompt}
|
||||
prompt={customPrompt}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedCount === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
|
||||
Pick one or more personas, or enable Generic to add a custom
|
||||
agent.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{providersErrorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{personasQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{personasQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{submissionNotice ? (
|
||||
<p className="rounded-2xl border border-border/70 bg-muted/25 px-4 py-3 text-sm text-foreground">
|
||||
{submissionNotice}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{submissionError ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{submissionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{createBotsMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{createBotsMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{providersErrorMessage ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{providersErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{createBotMutation.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{createBotMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{createBotMutation.isPending ? "Adding..." : "Add agent"}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
|
||||
<Button
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type AddChannelBotGenericSectionProps = {
|
||||
disabled: boolean;
|
||||
name: string;
|
||||
prompt: string;
|
||||
onNameChange: (value: string) => void;
|
||||
onPromptChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function AddChannelBotGenericSection({
|
||||
disabled,
|
||||
name,
|
||||
prompt,
|
||||
onNameChange,
|
||||
onPromptChange,
|
||||
}: AddChannelBotGenericSectionProps) {
|
||||
return (
|
||||
<div className="space-y-5 rounded-2xl border border-border/70 bg-card/70 p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Generic agent</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add one custom agent alongside any selected personas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-generic-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
id="channel-generic-name"
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Defaults to the selected runtime name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="channel-generic-prompt">
|
||||
Prompt
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
disabled={disabled}
|
||||
id="channel-generic-prompt"
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder="What should this agent help with in the channel?"
|
||||
value={prompt}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved as the generic agent's system prompt override.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
|
||||
function promptPreview(prompt: string) {
|
||||
const [firstLine] = prompt
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
return firstLine ?? prompt.trim();
|
||||
}
|
||||
|
||||
type SelectionChipButtonProps = {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function SelectionChipButton({
|
||||
disabled,
|
||||
onClick,
|
||||
selected,
|
||||
children,
|
||||
}: SelectionChipButtonProps) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"inline-flex min-h-9 items-center rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-foreground text-background shadow-sm"
|
||||
: "border-border/70 bg-muted/25 text-foreground hover:bg-muted/55",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type AddChannelBotPersonasSectionProps = {
|
||||
canToggleSelections: boolean;
|
||||
includeGeneric: boolean;
|
||||
isLoading: boolean;
|
||||
onToggleGeneric: () => void;
|
||||
onTogglePersona: (personaId: string) => void;
|
||||
personas: AgentPersona[];
|
||||
selectedPersonaIds: readonly string[];
|
||||
};
|
||||
|
||||
export function AddChannelBotPersonasSection({
|
||||
canToggleSelections,
|
||||
includeGeneric,
|
||||
isLoading,
|
||||
onToggleGeneric,
|
||||
onTogglePersona,
|
||||
personas,
|
||||
selectedPersonaIds,
|
||||
}: AddChannelBotPersonasSectionProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Personas</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Toggle as many as you want. Each selected persona is added as its
|
||||
own agent. Hover a persona to preview its role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SelectionChipButton
|
||||
disabled={!canToggleSelections}
|
||||
onClick={onToggleGeneric}
|
||||
selected={includeGeneric}
|
||||
>
|
||||
Generic
|
||||
</SelectionChipButton>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-left">
|
||||
Add one custom agent with a channel-specific name and prompt.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{personas.map((persona) => (
|
||||
<Tooltip key={persona.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SelectionChipButton
|
||||
disabled={!canToggleSelections}
|
||||
onClick={() => onTogglePersona(persona.id)}
|
||||
selected={selectedPersonaIds.includes(persona.id)}
|
||||
>
|
||||
{persona.displayName}
|
||||
</SelectionChipButton>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-left">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{persona.displayName}</p>
|
||||
<p>{promptPreview(persona.systemPrompt)}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading personas...</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,16 +16,11 @@ export const TOKEN_SCOPE_OPTIONS: Array<{
|
||||
export const MANAGED_AGENT_SCOPE_OPTIONS: Array<{
|
||||
value: TokenScope;
|
||||
label: string;
|
||||
}> = [
|
||||
...TOKEN_SCOPE_OPTIONS.slice(0, 5),
|
||||
{ value: "users:write", label: "Users: Write" },
|
||||
...TOKEN_SCOPE_OPTIONS.slice(5),
|
||||
];
|
||||
}> = [...TOKEN_SCOPE_OPTIONS];
|
||||
|
||||
export const DEFAULT_MANAGED_AGENT_SCOPES: TokenScope[] = [
|
||||
"messages:read",
|
||||
"messages:write",
|
||||
"channels:read",
|
||||
"users:read",
|
||||
"users:write",
|
||||
];
|
||||
|
||||
@@ -14,9 +14,7 @@ import type {
|
||||
MintTokenInput,
|
||||
MintTokenResponse,
|
||||
MintManagedAgentTokenInput,
|
||||
MintManagedAgentTokenResponse,
|
||||
ManagedAgent,
|
||||
ManagedAgentLog,
|
||||
RelayAgent,
|
||||
PresenceLookup,
|
||||
PresenceStatus,
|
||||
@@ -36,12 +34,10 @@ import type {
|
||||
UserSearchResult,
|
||||
UsersBatchResponse,
|
||||
CreateManagedAgentInput,
|
||||
CreateManagedAgentResponse,
|
||||
AgentModelsResponse,
|
||||
UpdateManagedAgentInput,
|
||||
AcpProvider,
|
||||
CommandAvailability,
|
||||
ManagedAgentPrereqs,
|
||||
OpenDmInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@@ -223,6 +219,7 @@ type RawRelayAgent = {
|
||||
export type RawManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
persona_id: string | null;
|
||||
relay_url: string;
|
||||
acp_command: string;
|
||||
agent_command: string;
|
||||
@@ -749,6 +746,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
|
||||
return {
|
||||
pubkey: agent.pubkey,
|
||||
name: agent.name,
|
||||
personaId: agent.persona_id,
|
||||
relayUrl: agent.relay_url,
|
||||
acpCommand: agent.acp_command,
|
||||
agentCommand: agent.agent_command,
|
||||
@@ -829,23 +827,24 @@ export async function revokeAllTokens(): Promise<{ revokedCount: number }> {
|
||||
}
|
||||
|
||||
export async function listRelayAgents(): Promise<RelayAgent[]> {
|
||||
const response = await invokeTauri<RawRelayAgent[]>("list_relay_agents");
|
||||
return response.map(fromRawRelayAgent);
|
||||
return (await invokeTauri<RawRelayAgent[]>("list_relay_agents")).map(
|
||||
fromRawRelayAgent,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listManagedAgents(): Promise<ManagedAgent[]> {
|
||||
const response = await invokeTauri<RawManagedAgent[]>("list_managed_agents");
|
||||
return response.map(fromRawManagedAgent);
|
||||
return (await invokeTauri<RawManagedAgent[]>("list_managed_agents")).map(
|
||||
fromRawManagedAgent,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createManagedAgent(
|
||||
input: CreateManagedAgentInput,
|
||||
): Promise<CreateManagedAgentResponse> {
|
||||
export async function createManagedAgent(input: CreateManagedAgentInput) {
|
||||
const response = await invokeTauri<RawCreateManagedAgentResponse>(
|
||||
"create_managed_agent",
|
||||
{
|
||||
input: {
|
||||
name: input.name,
|
||||
personaId: input.personaId,
|
||||
relayUrl: input.relayUrl,
|
||||
acpCommand: input.acpCommand,
|
||||
agentCommand: input.agentCommand,
|
||||
@@ -854,6 +853,8 @@ export async function createManagedAgent(
|
||||
turnTimeoutSeconds: input.turnTimeoutSeconds,
|
||||
parallelism: input.parallelism,
|
||||
systemPrompt: input.systemPrompt,
|
||||
avatarUrl: input.avatarUrl,
|
||||
model: input.model,
|
||||
mintToken: input.mintToken,
|
||||
tokenScopes: input.tokenScopes,
|
||||
tokenName: input.tokenName,
|
||||
@@ -890,9 +891,7 @@ export async function deleteManagedAgent(pubkey: string): Promise<void> {
|
||||
await invokeTauri("delete_managed_agent", { pubkey });
|
||||
}
|
||||
|
||||
export async function mintManagedAgentToken(
|
||||
input: MintManagedAgentTokenInput,
|
||||
): Promise<MintManagedAgentTokenResponse> {
|
||||
export async function mintManagedAgentToken(input: MintManagedAgentTokenInput) {
|
||||
const response = await invokeTauri<RawMintManagedAgentTokenResponse>(
|
||||
"mint_managed_agent_token",
|
||||
{
|
||||
@@ -910,10 +909,7 @@ export async function mintManagedAgentToken(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getManagedAgentLog(
|
||||
pubkey: string,
|
||||
lineCount?: number,
|
||||
): Promise<ManagedAgentLog> {
|
||||
export async function getManagedAgentLog(pubkey: string, lineCount?: number) {
|
||||
const response = await invokeTauri<RawManagedAgentLog>(
|
||||
"get_managed_agent_log",
|
||||
{
|
||||
@@ -929,16 +925,15 @@ export async function getManagedAgentLog(
|
||||
}
|
||||
|
||||
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
|
||||
const response = await invokeTauri<RawAcpProvider[]>(
|
||||
"discover_acp_providers",
|
||||
return (await invokeTauri<RawAcpProvider[]>("discover_acp_providers")).map(
|
||||
fromRawAcpProvider,
|
||||
);
|
||||
return response.map(fromRawAcpProvider);
|
||||
}
|
||||
|
||||
export async function discoverManagedAgentPrereqs(input: {
|
||||
acpCommand?: string;
|
||||
mcpCommand?: string;
|
||||
}): Promise<ManagedAgentPrereqs> {
|
||||
}) {
|
||||
const response = await invokeTauri<RawManagedAgentPrereqs>(
|
||||
"discover_managed_agent_prereqs",
|
||||
{
|
||||
@@ -958,9 +953,7 @@ export async function discoverManagedAgentPrereqs(input: {
|
||||
|
||||
// ── Model discovery ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function getAgentModels(
|
||||
pubkey: string,
|
||||
): Promise<AgentModelsResponse> {
|
||||
export async function getAgentModels(pubkey: string) {
|
||||
return invokeTauri<AgentModelsResponse>("get_agent_models", { pubkey });
|
||||
}
|
||||
|
||||
|
||||
65
desktop/src/shared/api/tauriPersonas.ts
Normal file
65
desktop/src/shared/api/tauriPersonas.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type {
|
||||
AgentPersona,
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type RawPersona = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
system_prompt: string;
|
||||
is_builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function fromRawPersona(persona: RawPersona): AgentPersona {
|
||||
return {
|
||||
id: persona.id,
|
||||
displayName: persona.display_name,
|
||||
avatarUrl: persona.avatar_url,
|
||||
systemPrompt: persona.system_prompt,
|
||||
isBuiltIn: persona.is_builtin,
|
||||
createdAt: persona.created_at,
|
||||
updatedAt: persona.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPersonas(): Promise<AgentPersona[]> {
|
||||
return (await invokeTauri<RawPersona[]>("list_personas")).map(fromRawPersona);
|
||||
}
|
||||
|
||||
export async function createPersona(
|
||||
input: CreatePersonaInput,
|
||||
): Promise<AgentPersona> {
|
||||
return fromRawPersona(
|
||||
await invokeTauri<RawPersona>("create_persona", {
|
||||
input: {
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
systemPrompt: input.systemPrompt,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updatePersona(
|
||||
input: UpdatePersonaInput,
|
||||
): Promise<AgentPersona> {
|
||||
return fromRawPersona(
|
||||
await invokeTauri<RawPersona>("update_persona", {
|
||||
input: {
|
||||
id: input.id,
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
systemPrompt: input.systemPrompt,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deletePersona(id: string): Promise<void> {
|
||||
await invokeTauri("delete_persona", { id });
|
||||
}
|
||||
@@ -258,6 +258,7 @@ export type RelayAgent = {
|
||||
export type ManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
personaId: string | null;
|
||||
relayUrl: string;
|
||||
acpCommand: string;
|
||||
agentCommand: string;
|
||||
@@ -282,6 +283,7 @@ export type ManagedAgent = {
|
||||
|
||||
export type CreateManagedAgentInput = {
|
||||
name: string;
|
||||
personaId?: string;
|
||||
relayUrl?: string;
|
||||
acpCommand?: string;
|
||||
agentCommand?: string;
|
||||
@@ -290,6 +292,7 @@ export type CreateManagedAgentInput = {
|
||||
turnTimeoutSeconds?: number;
|
||||
parallelism?: number;
|
||||
systemPrompt?: string;
|
||||
avatarUrl?: string;
|
||||
model?: string;
|
||||
mintToken?: boolean;
|
||||
tokenScopes?: TokenScope[];
|
||||
@@ -365,6 +368,29 @@ export type UpdateManagedAgentInput = {
|
||||
systemPrompt?: string | null;
|
||||
};
|
||||
|
||||
export type AgentPersona = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
systemPrompt: string;
|
||||
isBuiltIn: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreatePersonaInput = {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
|
||||
export type UpdatePersonaInput = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
|
||||
// ── Forum types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ThreadSummary = {
|
||||
|
||||
@@ -219,6 +219,7 @@ type RawRelayAgent = {
|
||||
type RawManagedAgent = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
persona_id: string | null;
|
||||
relay_url: string;
|
||||
acp_command: string;
|
||||
agent_command: string;
|
||||
@@ -279,6 +280,16 @@ type RawManagedAgentPrereqs = {
|
||||
mcp: RawCommandAvailability;
|
||||
};
|
||||
|
||||
type RawPersona = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
system_prompt: string;
|
||||
is_builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type MockToken = RawToken & {
|
||||
token: string;
|
||||
};
|
||||
@@ -518,6 +529,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
|
||||
return {
|
||||
pubkey: agent.pubkey,
|
||||
name: agent.name,
|
||||
persona_id: agent.persona_id,
|
||||
relay_url: agent.relay_url,
|
||||
acp_command: agent.acp_command,
|
||||
agent_command: agent.agent_command,
|
||||
@@ -560,6 +572,72 @@ function resetMockManagedAgents() {
|
||||
syncMockRelayAgentsFromManagedAgents();
|
||||
}
|
||||
|
||||
function resetMockPersonas() {
|
||||
const now = new Date().toISOString();
|
||||
mockPersonas = [
|
||||
{
|
||||
id: "builtin:orchestrator",
|
||||
display_name: "Orchestrator",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:researcher",
|
||||
display_name: "Researcher",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:planner",
|
||||
display_name: "Planner",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:implementer",
|
||||
display_name: "Builder",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:refactor",
|
||||
display_name: "Refactor",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
{
|
||||
id: "builtin:reviewer",
|
||||
display_name: "Reviewer",
|
||||
avatar_url: null,
|
||||
system_prompt:
|
||||
"You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.",
|
||||
is_builtin: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
|
||||
const normalizedPubkey = pubkey.toLowerCase();
|
||||
const existing = mockProfiles.get(normalizedPubkey);
|
||||
@@ -891,6 +969,7 @@ const realSockets = new Map<number, WebSocket>();
|
||||
let mockTokens: MockToken[] = [];
|
||||
let mockMintTokenError: string | null = null;
|
||||
let mockManagedAgents: MockManagedAgent[] = [];
|
||||
let mockPersonas: RawPersona[] = [];
|
||||
let mockRelayAgents: RawRelayAgent[] = [
|
||||
{
|
||||
pubkey: ALICE_PUBKEY,
|
||||
@@ -2249,9 +2328,80 @@ async function handleListManagedAgents(): Promise<RawManagedAgent[]> {
|
||||
return mockManagedAgents.map(cloneManagedAgent);
|
||||
}
|
||||
|
||||
async function handleListPersonas(): Promise<RawPersona[]> {
|
||||
return mockPersonas.map((persona) => ({ ...persona }));
|
||||
}
|
||||
|
||||
async function handleCreatePersona(args: {
|
||||
input: {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
}): Promise<RawPersona> {
|
||||
const now = new Date().toISOString();
|
||||
const persona: RawPersona = {
|
||||
id: crypto.randomUUID(),
|
||||
display_name: args.input.displayName.trim(),
|
||||
avatar_url: args.input.avatarUrl?.trim() || null,
|
||||
system_prompt: args.input.systemPrompt.trim(),
|
||||
is_builtin: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
mockPersonas.push(persona);
|
||||
return { ...persona };
|
||||
}
|
||||
|
||||
async function handleUpdatePersona(args: {
|
||||
input: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
};
|
||||
}): Promise<RawPersona> {
|
||||
const persona = mockPersonas.find(
|
||||
(candidate) => candidate.id === args.input.id,
|
||||
);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona ${args.input.id} not found.`);
|
||||
}
|
||||
if (persona.is_builtin) {
|
||||
throw new Error("Built-in personas cannot be edited.");
|
||||
}
|
||||
|
||||
persona.display_name = args.input.displayName.trim();
|
||||
persona.avatar_url = args.input.avatarUrl?.trim() || null;
|
||||
persona.system_prompt = args.input.systemPrompt.trim();
|
||||
persona.updated_at = new Date().toISOString();
|
||||
|
||||
return { ...persona };
|
||||
}
|
||||
|
||||
async function handleDeletePersona(args: { id: string }): Promise<void> {
|
||||
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
|
||||
if (!persona) {
|
||||
throw new Error(`Persona ${args.id} not found.`);
|
||||
}
|
||||
if (persona.is_builtin) {
|
||||
throw new Error("Built-in personas cannot be deleted.");
|
||||
}
|
||||
|
||||
mockPersonas = mockPersonas.filter((candidate) => candidate.id !== args.id);
|
||||
const now = new Date().toISOString();
|
||||
for (const agent of mockManagedAgents) {
|
||||
if (agent.persona_id === args.id) {
|
||||
agent.persona_id = null;
|
||||
agent.updated_at = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateManagedAgent(args: {
|
||||
input: {
|
||||
name: string;
|
||||
personaId?: string;
|
||||
relayUrl?: string;
|
||||
acpCommand?: string;
|
||||
agentCommand?: string;
|
||||
@@ -2260,6 +2410,7 @@ async function handleCreateManagedAgent(args: {
|
||||
turnTimeoutSeconds?: number;
|
||||
parallelism?: number;
|
||||
systemPrompt?: string;
|
||||
avatarUrl?: string;
|
||||
model?: string;
|
||||
mintToken?: boolean;
|
||||
tokenScopes?: string[];
|
||||
@@ -2282,6 +2433,7 @@ async function handleCreateManagedAgent(args: {
|
||||
const managedAgent: MockManagedAgent = {
|
||||
pubkey,
|
||||
name,
|
||||
persona_id: args.input.personaId ?? null,
|
||||
relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL,
|
||||
acp_command: args.input.acpCommand ?? "sprout-acp",
|
||||
agent_command: args.input.agentCommand ?? "goose",
|
||||
@@ -2882,6 +3034,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
|
||||
resetMockTokens(config);
|
||||
resetMockManagedAgents();
|
||||
resetMockPersonas();
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({
|
||||
@@ -2994,6 +3147,20 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return handleRevokeAllTokens(activeConfig);
|
||||
case "list_relay_agents":
|
||||
return handleListRelayAgents();
|
||||
case "list_personas":
|
||||
return handleListPersonas();
|
||||
case "create_persona":
|
||||
return handleCreatePersona(
|
||||
payload as Parameters<typeof handleCreatePersona>[0],
|
||||
);
|
||||
case "update_persona":
|
||||
return handleUpdatePersona(
|
||||
payload as Parameters<typeof handleUpdatePersona>[0],
|
||||
);
|
||||
case "delete_persona":
|
||||
return handleDeletePersona(
|
||||
payload as Parameters<typeof handleDeletePersona>[0],
|
||||
);
|
||||
case "list_managed_agents":
|
||||
return handleListManagedAgents();
|
||||
case "create_managed_agent":
|
||||
|
||||
Reference in New Issue
Block a user