mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: add provider and model fields to persona configuration (#166)
This commit is contained in:
@@ -30,7 +30,7 @@ const rules = [
|
||||
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests)
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 760], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests)
|
||||
["src/app/AppShell.tsx", 775],
|
||||
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
@@ -42,9 +42,10 @@ const overrides = new Map([
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement
|
||||
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
|
||||
["src/features/agents/ui/AgentsView.tsx", 775], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import
|
||||
["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
|
||||
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 600], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
|
||||
["src/shared/api/types.ts", 515], // persona provider/model fields + forum types
|
||||
]);
|
||||
|
||||
async function walkFiles(directory) {
|
||||
|
||||
@@ -45,6 +45,8 @@ pub fn create_persona(
|
||||
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 provider = trim_optional(input.provider);
|
||||
let model = trim_optional(input.model);
|
||||
let now = now_iso();
|
||||
|
||||
let _store_guard = state
|
||||
@@ -57,6 +59,8 @@ pub fn create_persona(
|
||||
display_name,
|
||||
avatar_url,
|
||||
system_prompt,
|
||||
provider,
|
||||
model,
|
||||
is_builtin: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
@@ -75,6 +79,8 @@ pub fn update_persona(
|
||||
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 provider = trim_optional(input.provider);
|
||||
let model = trim_optional(input.model);
|
||||
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
@@ -93,6 +99,8 @@ pub fn update_persona(
|
||||
persona.display_name = display_name;
|
||||
persona.avatar_url = avatar_url;
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.provider = provider;
|
||||
persona.model = model;
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
@@ -215,7 +223,7 @@ pub async fn export_persona_to_json(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// Load persona data under lock, then drop lock before dialog.
|
||||
let (display_name, system_prompt, avatar_url) = {
|
||||
let (display_name, system_prompt, avatar_url, provider, model) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -229,11 +237,18 @@ pub async fn export_persona_to_json(
|
||||
persona.display_name.clone(),
|
||||
persona.system_prompt.clone(),
|
||||
persona.avatar_url.clone(),
|
||||
persona.provider.clone(),
|
||||
persona.model.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
let json_bytes =
|
||||
encode_persona_json(&display_name, &system_prompt, avatar_url.as_deref())?;
|
||||
let json_bytes = encode_persona_json(
|
||||
&display_name,
|
||||
&system_prompt,
|
||||
avatar_url.as_deref(),
|
||||
provider.as_deref(),
|
||||
model.as_deref(),
|
||||
)?;
|
||||
|
||||
let slug = crate::util::slugify(&display_name, "persona", 50);
|
||||
let filename = format!("{slug}.persona.json");
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct ParsedPersonaPreview {
|
||||
pub display_name: String,
|
||||
pub system_prompt: String,
|
||||
pub avatar_data_url: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub source_file: String,
|
||||
}
|
||||
|
||||
@@ -55,7 +57,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
|
||||
}
|
||||
}
|
||||
|
||||
let preview = if let Some(text) = sprout_text {
|
||||
let fields = if let Some(text) = sprout_text {
|
||||
parse_sprout_payload(text)?
|
||||
} else if let Some(text) = chara_text {
|
||||
parse_chara_payload(text)?
|
||||
@@ -63,12 +65,16 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
|
||||
return Err("This image doesn't contain persona data.".to_string());
|
||||
};
|
||||
|
||||
// For PNG persona cards, the avatar is the image itself — override
|
||||
// whatever avatarUrl the embedded JSON metadata might contain.
|
||||
let avatar_data_url = Some(format!("data:image/png;base64,{}", STANDARD.encode(png_bytes)));
|
||||
|
||||
Ok(ParsedPersonaPreview {
|
||||
display_name: preview.0,
|
||||
system_prompt: preview.1,
|
||||
display_name: fields.display_name,
|
||||
system_prompt: fields.system_prompt,
|
||||
avatar_data_url,
|
||||
provider: fields.provider,
|
||||
model: fields.model,
|
||||
source_file: String::new(),
|
||||
})
|
||||
}
|
||||
@@ -80,9 +86,18 @@ fn decode_b64_json(b64: &str) -> Result<Value, String> {
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("Invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Extract and validate `displayName` + `systemPrompt` from a Sprout persona
|
||||
/// JSON value (shared by both the PNG tEXt-chunk path and the standalone JSON path).
|
||||
fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> {
|
||||
/// Extracted fields from a Sprout persona JSON payload.
|
||||
struct SproutPersonaFields {
|
||||
display_name: String,
|
||||
system_prompt: String,
|
||||
avatar_url: Option<String>,
|
||||
provider: Option<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract and validate fields from a Sprout persona JSON value
|
||||
/// (shared by both the PNG tEXt-chunk path and the standalone JSON path).
|
||||
fn extract_sprout_fields(v: &Value) -> Result<SproutPersonaFields, String> {
|
||||
let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
if version != 1 {
|
||||
return Err(format!("Unsupported persona version: {version}"));
|
||||
@@ -105,15 +120,39 @@ fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> {
|
||||
if prompt.is_empty() {
|
||||
return Err("systemPrompt is empty".to_string());
|
||||
}
|
||||
Ok((name, prompt))
|
||||
let avatar_url = v
|
||||
.get("avatarUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let provider = v
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let model = v
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
Ok(SproutPersonaFields {
|
||||
display_name: name,
|
||||
system_prompt: prompt,
|
||||
avatar_url,
|
||||
provider,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> {
|
||||
fn parse_sprout_payload(b64: &str) -> Result<SproutPersonaFields, String> {
|
||||
let v = decode_b64_json(b64)?;
|
||||
extract_sprout_fields(&v)
|
||||
}
|
||||
|
||||
fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
|
||||
fn parse_chara_payload(b64: &str) -> Result<SproutPersonaFields, String> {
|
||||
let v = decode_b64_json(b64)?;
|
||||
let data = v.get("data").ok_or("Missing 'data' in chara payload")?;
|
||||
let name = data
|
||||
@@ -142,7 +181,13 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
|
||||
if prompt.is_empty() {
|
||||
return Err("Chara card has no system_prompt or description".to_string());
|
||||
}
|
||||
Ok((name, prompt))
|
||||
Ok(SproutPersonaFields {
|
||||
display_name: name,
|
||||
system_prompt: prompt,
|
||||
avatar_url: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -152,19 +197,14 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
|
||||
pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, String> {
|
||||
let v: Value =
|
||||
serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let (name, prompt) = extract_sprout_fields(&v)?;
|
||||
|
||||
let avatar_data_url = v
|
||||
.get("avatarUrl")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let fields = extract_sprout_fields(&v)?;
|
||||
|
||||
Ok(ParsedPersonaPreview {
|
||||
display_name: name,
|
||||
system_prompt: prompt,
|
||||
avatar_data_url,
|
||||
display_name: fields.display_name,
|
||||
system_prompt: fields.system_prompt,
|
||||
avatar_data_url: fields.avatar_url,
|
||||
provider: fields.provider,
|
||||
model: fields.model,
|
||||
source_file: String::new(),
|
||||
})
|
||||
}
|
||||
@@ -173,6 +213,8 @@ pub fn encode_persona_json(
|
||||
display_name: &str,
|
||||
system_prompt: &str,
|
||||
avatar_url: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("version".to_string(), serde_json::json!(1));
|
||||
@@ -187,6 +229,12 @@ pub fn encode_persona_json(
|
||||
if let Some(url) = avatar_url {
|
||||
map.insert("avatarUrl".to_string(), serde_json::json!(url));
|
||||
}
|
||||
if let Some(p) = provider {
|
||||
map.insert("provider".to_string(), serde_json::json!(p));
|
||||
}
|
||||
if let Some(m) = model {
|
||||
map.insert("model".to_string(), serde_json::json!(m));
|
||||
}
|
||||
|
||||
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
|
||||
}
|
||||
@@ -550,7 +598,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_json_round_trip() {
|
||||
let bytes =
|
||||
encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"))
|
||||
encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"), None, None)
|
||||
.unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Ada Lovelace");
|
||||
@@ -564,7 +612,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_no_avatar() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None).unwrap();
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Bob");
|
||||
assert_eq!(result.system_prompt, "You are Bob.");
|
||||
@@ -574,12 +622,55 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_json_round_trip_data_uri_avatar() {
|
||||
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri)).unwrap();
|
||||
let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Carol");
|
||||
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_with_provider_and_model() {
|
||||
let bytes = encode_persona_json(
|
||||
"Agent Smith",
|
||||
"You are an agent.",
|
||||
None,
|
||||
Some("goose"),
|
||||
Some("claude-sonnet-4"),
|
||||
)
|
||||
.unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Agent Smith");
|
||||
assert_eq!(result.system_prompt, "You are an agent.");
|
||||
assert!(result.avatar_data_url.is_none());
|
||||
assert_eq!(result.provider.as_deref(), Some("goose"));
|
||||
assert_eq!(result.model.as_deref(), Some("claude-sonnet-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_without_provider_and_model() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Bob");
|
||||
assert!(result.provider.is_none());
|
||||
assert!(result.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_backward_compat_no_provider_model_fields() {
|
||||
// Simulate a legacy persona JSON without provider/model fields
|
||||
let json = serde_json::json!({
|
||||
"version": 1,
|
||||
"displayName": "Legacy Persona",
|
||||
"systemPrompt": "Old school prompt"
|
||||
});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
assert_eq!(result.display_name, "Legacy Persona");
|
||||
assert_eq!(result.system_prompt, "Old school prompt");
|
||||
assert!(result.provider.is_none());
|
||||
assert!(result.model.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_invalid_version() {
|
||||
let json = serde_json::json!({
|
||||
@@ -620,8 +711,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_with_json() {
|
||||
let j1 = encode_persona_json("Alice", "Prompt A", None).unwrap();
|
||||
let j2 = encode_persona_json("Bob", "Prompt B", None).unwrap();
|
||||
let j1 = encode_persona_json("Alice", "Prompt A", None, None, None).unwrap();
|
||||
let j2 = encode_persona_json("Bob", "Prompt B", None, None, None).unwrap();
|
||||
let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]);
|
||||
let result = parse_zip_personas(&zip).unwrap();
|
||||
assert_eq!(result.personas.len(), 2);
|
||||
@@ -633,7 +724,7 @@ mod tests {
|
||||
#[test]
|
||||
fn parse_zip_mixed_png_and_json() {
|
||||
let png = make_test_persona_png("PngPersona", "PNG prompt");
|
||||
let json = encode_persona_json("JsonPersona", "JSON prompt", None).unwrap();
|
||||
let json = encode_persona_json("JsonPersona", "JSON prompt", None, None, None).unwrap();
|
||||
let zip = make_test_zip(&[
|
||||
("persona.png", &png),
|
||||
("persona.json", &json),
|
||||
@@ -648,8 +739,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_ignores_macos_resource_forks() {
|
||||
let j1 = encode_persona_json("Frank", "You are Frank.", None).unwrap();
|
||||
let j2 = encode_persona_json("Jackie", "You are Jackie.", None).unwrap();
|
||||
let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None).unwrap();
|
||||
let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None).unwrap();
|
||||
let zip = make_test_zip(&[
|
||||
("frank-costanza.persona.json", &j1),
|
||||
("jackie-chiles.persona.json", &j2),
|
||||
|
||||
@@ -58,6 +58,8 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
|
||||
display_name: persona.display_name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: persona.system_prompt.to_string(),
|
||||
provider: None,
|
||||
model: None,
|
||||
is_builtin: true,
|
||||
created_at: now.to_string(),
|
||||
updated_at: now.to_string(),
|
||||
@@ -99,6 +101,8 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
|
||||
if existing.display_name != built_in.display_name
|
||||
|| existing.avatar_url.is_some()
|
||||
|| existing.system_prompt != built_in.system_prompt
|
||||
|| existing.provider.is_some()
|
||||
|| existing.model.is_some()
|
||||
|| !existing.is_builtin
|
||||
{
|
||||
*existing = PersonaRecord {
|
||||
@@ -160,6 +164,8 @@ mod tests {
|
||||
display_name: display_name.to_string(),
|
||||
avatar_url: Some("https://example.com/avatar.png".to_string()),
|
||||
system_prompt: "Custom prompt".to_string(),
|
||||
provider: None,
|
||||
model: None,
|
||||
is_builtin: false,
|
||||
created_at: "2026-03-19T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-19T00:00:00Z".to_string(),
|
||||
|
||||
@@ -225,6 +225,8 @@ mod tests {
|
||||
display_name: name.to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: prompt.to_string(),
|
||||
provider: None,
|
||||
model: None,
|
||||
is_builtin: false,
|
||||
created_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
|
||||
@@ -24,6 +24,14 @@ pub struct PersonaRecord {
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
/// Preferred ACP provider ID (e.g. "goose", "claude", "codex").
|
||||
/// When deploying an agent from this persona, this provider is pre-selected.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<String>,
|
||||
/// Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514").
|
||||
/// Passed to the agent at creation time when deploying from this persona.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_builtin: bool,
|
||||
pub created_at: String,
|
||||
@@ -160,6 +168,10 @@ pub struct CreatePersonaRequest {
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -169,6 +181,10 @@ pub struct UpdatePersonaRequest {
|
||||
pub display_name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -52,6 +52,8 @@ export type CreateChannelManagedAgentInput = {
|
||||
systemPrompt?: string;
|
||||
avatarUrl?: string;
|
||||
personaId?: string | null;
|
||||
/** Preferred model ID from the persona. Passed to createManagedAgent. */
|
||||
model?: string;
|
||||
role?: Exclude<ChannelRole, "owner">;
|
||||
ensureRunning?: boolean;
|
||||
backend?: ManagedAgentBackend;
|
||||
@@ -308,6 +310,7 @@ export async function createChannelManagedAgent(
|
||||
personaId: input.personaId ?? undefined,
|
||||
systemPrompt: input.systemPrompt?.trim() || undefined,
|
||||
avatarUrl: resolvedAvatarUrl,
|
||||
model: input.model?.trim() || undefined,
|
||||
spawnAfterCreate: isProviderMode,
|
||||
startOnAppLaunch: isProviderMode ? false : undefined,
|
||||
backend: input.backend,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AcpProvider } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Result of resolving a persona's preferred provider against the set of
|
||||
* currently-available ACP providers.
|
||||
*
|
||||
* `provider` is the provider that should be used for deployment.
|
||||
* `warnings` contains user-visible messages when the resolved provider
|
||||
* differs from what the persona requested (e.g. the configured runtime
|
||||
* was uninstalled) or when no provider is available at all.
|
||||
*/
|
||||
export type ResolvePersonaProviderResult = {
|
||||
provider: AcpProvider | null;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which ACP provider to use when deploying an agent from a persona.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If the persona has no `providerId` → use `defaultProvider`, no warnings.
|
||||
* 2. If the persona's `providerId` matches an available provider → use it.
|
||||
* 3. If the persona's `providerId` is set but not found in `providers` →
|
||||
* fall back to `defaultProvider` and emit a warning.
|
||||
* 4. If there is no `defaultProvider` either → return `null` with an error
|
||||
* warning so the UI can block deployment.
|
||||
*/
|
||||
export function resolvePersonaProvider(
|
||||
personaProviderId: string | undefined | null,
|
||||
providers: readonly AcpProvider[],
|
||||
defaultProvider: AcpProvider | null,
|
||||
): ResolvePersonaProviderResult {
|
||||
// Case 1: Persona has no provider preference — use the default.
|
||||
if (!personaProviderId) {
|
||||
return {
|
||||
provider: defaultProvider,
|
||||
warnings: defaultProvider
|
||||
? []
|
||||
: [
|
||||
"No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Case 2: Persona's preferred provider is available.
|
||||
const matched = providers.find((p) => p.id === personaProviderId);
|
||||
if (matched) {
|
||||
return { provider: matched, warnings: [] };
|
||||
}
|
||||
|
||||
// Case 3 & 4: Persona's provider is not available — fall back.
|
||||
if (defaultProvider) {
|
||||
return {
|
||||
provider: defaultProvider,
|
||||
warnings: [
|
||||
`Persona is configured for runtime "${personaProviderId}" but it is not available. Using ${defaultProvider.label} instead.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: null,
|
||||
warnings: [
|
||||
`Persona is configured for runtime "${personaProviderId}" but it is not available, and no other runtimes were found.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect provider-resolution warnings for a list of personas.
|
||||
*
|
||||
* Used by deploy dialogs to surface inline alerts when one or more
|
||||
* personas reference a runtime that isn't currently available.
|
||||
*/
|
||||
export function collectProviderWarnings(
|
||||
personas: readonly { provider: string | null }[],
|
||||
providers: readonly AcpProvider[],
|
||||
fallbackProvider: AcpProvider | null,
|
||||
): string[] {
|
||||
if (!fallbackProvider) return [];
|
||||
const warnings: string[] = [];
|
||||
for (const persona of personas) {
|
||||
const { warnings: w } = resolvePersonaProvider(
|
||||
persona.provider,
|
||||
providers,
|
||||
fallbackProvider,
|
||||
);
|
||||
warnings.push(...w);
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -5,6 +6,10 @@ import {
|
||||
useCreateChannelManagedAgentsMutation,
|
||||
} from "@/features/agents/hooks";
|
||||
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
|
||||
import {
|
||||
collectProviderWarnings,
|
||||
resolvePersonaProvider,
|
||||
} from "@/features/agents/lib/resolvePersonaProvider";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type {
|
||||
@@ -70,6 +75,14 @@ export function AddTeamToChannelDialog({
|
||||
|
||||
const resolved = team ? resolvePersonas(team.personaIds, personas) : [];
|
||||
|
||||
// Surface warnings when a persona's preferred provider is unavailable.
|
||||
// This dialog has no provider selector, so the fallback is always
|
||||
// `defaultProvider` (the first available runtime).
|
||||
const providerWarnings = React.useMemo(
|
||||
() => collectProviderWarnings(resolved, providers, defaultProvider),
|
||||
[resolved, providers, defaultProvider],
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setChannelId("");
|
||||
setRole("bot");
|
||||
@@ -101,19 +114,32 @@ export function AddTeamToChannelDialog({
|
||||
}
|
||||
|
||||
try {
|
||||
const inputs = resolved.map((persona) => ({
|
||||
provider: {
|
||||
id: defaultProvider.id,
|
||||
label: defaultProvider.label,
|
||||
command: defaultProvider.command,
|
||||
defaultArgs: defaultProvider.defaultArgs,
|
||||
},
|
||||
name: persona.displayName,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
personaId: persona.id,
|
||||
role,
|
||||
}));
|
||||
// Resolve each persona's preferred provider. This dialog has no
|
||||
// provider selector, so the fallback is `defaultProvider` (first
|
||||
// available runtime). Warnings are computed separately via the
|
||||
// `providerWarnings` memo and rendered as inline alerts above.
|
||||
const inputs = resolved.map((persona) => {
|
||||
const { provider: personaProvider } = resolvePersonaProvider(
|
||||
persona.provider,
|
||||
providers,
|
||||
defaultProvider,
|
||||
);
|
||||
const providerToUse = personaProvider ?? defaultProvider;
|
||||
return {
|
||||
provider: {
|
||||
id: providerToUse.id,
|
||||
label: providerToUse.label,
|
||||
command: providerToUse.command,
|
||||
defaultArgs: providerToUse.defaultArgs,
|
||||
},
|
||||
name: persona.displayName,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
personaId: persona.id,
|
||||
role,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await deployMutation.mutateAsync(inputs);
|
||||
onDeployed(selectedChannel, result);
|
||||
@@ -214,6 +240,20 @@ export function AddTeamToChannelDialog({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{providerWarnings.length > 0
|
||||
? providerWarnings.map((warning) => (
|
||||
<div
|
||||
className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30"
|
||||
key={warning}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
|
||||
{channelsQuery.error instanceof Error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{channelsQuery.error.message}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type AttachManagedAgentToChannelResult,
|
||||
personasQueryKey,
|
||||
useAcpProvidersQuery,
|
||||
useCreatePersonaMutation,
|
||||
useDeletePersonaMutation,
|
||||
useDeleteManagedAgentMutation,
|
||||
@@ -66,6 +67,7 @@ export function AgentsView() {
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const acpProvidersQuery = useAcpProvidersQuery();
|
||||
const startMutation = useStartManagedAgentMutation();
|
||||
const stopMutation = useStopManagedAgentMutation();
|
||||
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
|
||||
@@ -412,6 +414,8 @@ export function AgentsView() {
|
||||
displayName: p.displayName,
|
||||
avatarUrl: p.avatarDataUrl ?? "",
|
||||
systemPrompt: p.systemPrompt,
|
||||
provider: p.provider ?? undefined,
|
||||
model: p.model ?? undefined,
|
||||
},
|
||||
});
|
||||
} else if (result.personas.length > 0) {
|
||||
@@ -471,6 +475,8 @@ export function AgentsView() {
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
systemPrompt: "",
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -487,6 +493,8 @@ export function AgentsView() {
|
||||
displayName: `${persona.displayName} copy`,
|
||||
avatarUrl: persona.avatarUrl ?? "",
|
||||
systemPrompt: persona.systemPrompt,
|
||||
provider: persona.provider ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -503,6 +511,8 @@ export function AgentsView() {
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarUrl ?? "",
|
||||
systemPrompt: persona.systemPrompt,
|
||||
provider: persona.provider ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -665,6 +675,8 @@ export function AgentsView() {
|
||||
isPending={
|
||||
createPersonaMutation.isPending || updatePersonaMutation.isPending
|
||||
}
|
||||
providers={acpProvidersQuery.data ?? []}
|
||||
providersLoading={acpProvidersQuery.isLoading}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPersonaDialogState(null);
|
||||
|
||||
@@ -95,6 +95,8 @@ export function BatchImportDialog({
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarDataUrl ?? undefined,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
provider: persona.provider ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
});
|
||||
completed += 1;
|
||||
setImportedCount(completed);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
AcpProvider,
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
@@ -23,6 +24,8 @@ type PersonaDialogProps = {
|
||||
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
|
||||
error: Error | null;
|
||||
isPending: boolean;
|
||||
providers: AcpProvider[];
|
||||
providersLoading?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
|
||||
};
|
||||
@@ -35,12 +38,16 @@ export function PersonaDialog({
|
||||
initialValues,
|
||||
error,
|
||||
isPending,
|
||||
providers,
|
||||
providersLoading = false,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: PersonaDialogProps) {
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("");
|
||||
const [systemPrompt, setSystemPrompt] = React.useState("");
|
||||
const [provider, setProvider] = React.useState("");
|
||||
const [model, setModel] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialValues) {
|
||||
@@ -50,6 +57,8 @@ export function PersonaDialog({
|
||||
setDisplayName(initialValues.displayName);
|
||||
setAvatarUrl(initialValues.avatarUrl ?? "");
|
||||
setSystemPrompt(initialValues.systemPrompt);
|
||||
setProvider(initialValues.provider ?? "");
|
||||
setModel(initialValues.model ?? "");
|
||||
}, [initialValues, open]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
@@ -57,6 +66,8 @@ export function PersonaDialog({
|
||||
setDisplayName("");
|
||||
setAvatarUrl("");
|
||||
setSystemPrompt("");
|
||||
setProvider("");
|
||||
setModel("");
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
@@ -71,6 +82,8 @@ export function PersonaDialog({
|
||||
displayName,
|
||||
avatarUrl: avatarUrl.trim() || undefined,
|
||||
systemPrompt,
|
||||
provider: provider.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
@@ -93,7 +106,7 @@ export function PersonaDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
<div className="space-y-5 overflow-y-auto px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
@@ -151,6 +164,54 @@ export function PersonaDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="persona-provider">
|
||||
Preferred runtime
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
|
||||
disabled={isPending || providersLoading}
|
||||
id="persona-provider"
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
value={provider}
|
||||
>
|
||||
<option value="">
|
||||
{providersLoading
|
||||
? "Loading runtimes..."
|
||||
: "No preference (use default)"}
|
||||
</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. When deploying this persona, the selected runtime will
|
||||
be pre-selected. Falls back to the default if unavailable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="persona-model">
|
||||
Preferred model
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="persona-model"
|
||||
onChange={(event) => setModel(event.target.value)}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
spellCheck={false}
|
||||
value={model}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Optional. Passed to the agent at creation time. Leave blank to
|
||||
use the runtime default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
coerceConfigValues,
|
||||
ProviderConfigFields,
|
||||
} from "@/features/agents/ui/ProviderConfigFields";
|
||||
import {
|
||||
collectProviderWarnings,
|
||||
resolvePersonaProvider,
|
||||
} from "@/features/agents/lib/resolvePersonaProvider";
|
||||
|
||||
type AddChannelBotDialogProps = {
|
||||
backendProviders?: BackendProviderCandidate[];
|
||||
@@ -145,6 +149,18 @@ export function AddChannelBotDialog({
|
||||
);
|
||||
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
|
||||
|
||||
// Surface warnings when a persona's preferred provider differs from the
|
||||
// user-selected provider. In this dialog the user explicitly picks a
|
||||
// runtime via the dropdown, so the fallback is `selectedProvider` (their
|
||||
// choice), NOT `providers[0]`. This differs intentionally from
|
||||
// AddTeamToChannelDialog which has no provider selector and falls back
|
||||
// to the first available runtime.
|
||||
const providerWarnings = React.useMemo(
|
||||
() =>
|
||||
collectProviderWarnings(selectedPersonas, providers, selectedProvider),
|
||||
[selectedPersonas, providers, selectedProvider],
|
||||
);
|
||||
|
||||
const isProviderMode = runOn !== "local";
|
||||
const selectedBackendProvider = React.useMemo(
|
||||
() => resolvedBackendProviders.find((p) => p.id === runOn) ?? null,
|
||||
@@ -300,15 +316,23 @@ export function AddChannelBotDialog({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...selectedPersonas.map((persona) => ({
|
||||
provider: selectedProvider,
|
||||
name: persona.displayName,
|
||||
personaId: persona.id,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
role: "bot" as const,
|
||||
backend,
|
||||
})),
|
||||
...selectedPersonas.map((persona) => {
|
||||
const resolved = resolvePersonaProvider(
|
||||
persona.provider,
|
||||
providers,
|
||||
selectedProvider,
|
||||
);
|
||||
return {
|
||||
provider: resolved.provider ?? selectedProvider,
|
||||
name: persona.displayName,
|
||||
personaId: persona.id,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
avatarUrl: persona.avatarUrl ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
role: "bot" as const,
|
||||
backend,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
setSubmissionNotice(null);
|
||||
@@ -530,6 +554,20 @@ export function AddChannelBotDialog({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{providerWarnings.length > 0
|
||||
? providerWarnings.map((warning) => (
|
||||
<div
|
||||
className="flex gap-3 rounded-2xl border border-amber-400/60 bg-amber-50/60 px-4 py-3 dark:border-amber-500/40 dark:bg-amber-950/30"
|
||||
key={warning}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
: 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}
|
||||
|
||||
@@ -10,6 +10,8 @@ type RawParsedPersonaPreview = {
|
||||
display_name: string;
|
||||
system_prompt: string;
|
||||
avatar_data_url: string | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
source_file: string;
|
||||
};
|
||||
|
||||
@@ -28,6 +30,8 @@ export type ParsedPersonaPreview = {
|
||||
displayName: string;
|
||||
systemPrompt: string;
|
||||
avatarDataUrl: string | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
sourceFile: string;
|
||||
};
|
||||
|
||||
@@ -46,6 +50,8 @@ type RawPersona = {
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
system_prompt: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
is_builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -57,6 +63,8 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
|
||||
displayName: persona.display_name,
|
||||
avatarUrl: persona.avatar_url,
|
||||
systemPrompt: persona.system_prompt,
|
||||
provider: persona.provider ?? null,
|
||||
model: persona.model ?? null,
|
||||
isBuiltIn: persona.is_builtin,
|
||||
createdAt: persona.created_at,
|
||||
updatedAt: persona.updated_at,
|
||||
@@ -76,6 +84,8 @@ export async function createPersona(
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
systemPrompt: input.systemPrompt,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -91,6 +101,8 @@ export async function updatePersona(
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
systemPrompt: input.systemPrompt,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -113,6 +125,8 @@ export async function parsePersonaFiles(
|
||||
displayName: p.display_name,
|
||||
systemPrompt: p.system_prompt,
|
||||
avatarDataUrl: p.avatar_data_url,
|
||||
provider: p.provider,
|
||||
model: p.model,
|
||||
sourceFile: p.source_file,
|
||||
})),
|
||||
skipped: raw.skipped.map((s) => ({
|
||||
|
||||
@@ -410,6 +410,10 @@ export type AgentPersona = {
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
systemPrompt: string;
|
||||
/** Preferred ACP provider ID (e.g. "goose", "claude"). */
|
||||
provider: string | null;
|
||||
/** Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
|
||||
model: string | null;
|
||||
isBuiltIn: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -419,6 +423,8 @@ export type CreatePersonaInput = {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type UpdatePersonaInput = {
|
||||
@@ -426,6 +432,8 @@ export type UpdatePersonaInput = {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
systemPrompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
// ── Team types ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user