feat: add provider and model fields to persona configuration (#166)

This commit is contained in:
Wes
2026-03-24 13:02:20 -07:00
committed by GitHub
parent b20d4596a9
commit bf6de08906
15 changed files with 458 additions and 58 deletions
+4 -3
View File
@@ -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) {
+18 -3
View File
@@ -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}
+14
View File
@@ -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) => ({
+8
View File
@@ -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 ────────────────────────────────────────────────────────────────