mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fun bot names from persona name pools (#273)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
063ce331dd
commit
68a3d16d9e
@@ -31,7 +31,7 @@ const rules = [
|
||||
// Exceptions should stay rare and temporary. Prefer splitting files instead.
|
||||
const overrides = new Map([
|
||||
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 772], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
|
||||
["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
|
||||
["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
|
||||
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
|
||||
@@ -117,6 +117,12 @@ pub fn update_managed_agent(
|
||||
|
||||
let record = find_managed_agent_mut(&mut records, &input.pubkey)?;
|
||||
|
||||
if let Some(name_update) = input.name {
|
||||
let trimmed = name_update.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
record.name = trimmed;
|
||||
}
|
||||
}
|
||||
// Tri-state: None = don't touch, Some(None) = clear, Some(Some(v)) = set
|
||||
if let Some(model_update) = input.model {
|
||||
record.model = model_update;
|
||||
|
||||
@@ -57,6 +57,12 @@ pub fn create_persona(
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut personas = load_personas(&app)?;
|
||||
let name_pool: Vec<String> = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let persona = PersonaRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
display_name,
|
||||
@@ -64,6 +70,7 @@ pub fn create_persona(
|
||||
system_prompt,
|
||||
provider,
|
||||
model,
|
||||
name_pool,
|
||||
is_builtin: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
@@ -104,6 +111,12 @@ pub fn update_persona(
|
||||
persona.system_prompt = system_prompt;
|
||||
persona.provider = provider;
|
||||
persona.model = model;
|
||||
persona.name_pool = input
|
||||
.name_pool
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
persona.updated_at = now_iso();
|
||||
|
||||
save_personas(&app, &personas)?;
|
||||
@@ -226,7 +239,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, provider, model) = {
|
||||
let (display_name, system_prompt, avatar_url, provider, model, name_pool) = {
|
||||
let _store_guard = state
|
||||
.managed_agents_store_lock
|
||||
.lock()
|
||||
@@ -242,6 +255,7 @@ pub async fn export_persona_to_json(
|
||||
persona.avatar_url.clone(),
|
||||
persona.provider.clone(),
|
||||
persona.model.clone(),
|
||||
persona.name_pool.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -251,6 +265,7 @@ pub async fn export_persona_to_json(
|
||||
avatar_url.as_deref(),
|
||||
provider.as_deref(),
|
||||
model.as_deref(),
|
||||
&name_pool,
|
||||
)?;
|
||||
|
||||
let slug = crate::util::slugify(&display_name, "persona", 50);
|
||||
|
||||
@@ -15,6 +15,8 @@ pub struct ParsedPersonaPreview {
|
||||
pub avatar_data_url: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub name_pool: Vec<String>,
|
||||
pub source_file: String,
|
||||
}
|
||||
|
||||
@@ -80,6 +82,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result<ParsedPersonaPreview, Strin
|
||||
avatar_data_url,
|
||||
provider: fields.provider,
|
||||
model: fields.model,
|
||||
name_pool: fields.name_pool,
|
||||
source_file: String::new(),
|
||||
})
|
||||
}
|
||||
@@ -98,6 +101,7 @@ struct SproutPersonaFields {
|
||||
avatar_url: Option<String>,
|
||||
provider: Option<String>,
|
||||
model: Option<String>,
|
||||
name_pool: Vec<String>,
|
||||
}
|
||||
|
||||
/// Extract and validate fields from a Sprout persona JSON value
|
||||
@@ -143,12 +147,24 @@ fn extract_sprout_fields(v: &Value) -> Result<SproutPersonaFields, String> {
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let name_pool = v
|
||||
.get("namePool")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(SproutPersonaFields {
|
||||
display_name: name,
|
||||
system_prompt: prompt,
|
||||
avatar_url,
|
||||
provider,
|
||||
model,
|
||||
name_pool,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,6 +208,7 @@ fn parse_chara_payload(b64: &str) -> Result<SproutPersonaFields, String> {
|
||||
avatar_url: None,
|
||||
provider: None,
|
||||
model: None,
|
||||
name_pool: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,6 +226,7 @@ pub fn parse_json_persona(json_bytes: &[u8]) -> Result<ParsedPersonaPreview, Str
|
||||
avatar_data_url: fields.avatar_url,
|
||||
provider: fields.provider,
|
||||
model: fields.model,
|
||||
name_pool: fields.name_pool,
|
||||
source_file: String::new(),
|
||||
})
|
||||
}
|
||||
@@ -219,6 +237,7 @@ pub fn encode_persona_json(
|
||||
avatar_url: Option<&str>,
|
||||
provider: Option<&str>,
|
||||
model: Option<&str>,
|
||||
name_pool: &[String],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert("version".to_string(), serde_json::json!(1));
|
||||
@@ -233,6 +252,9 @@ pub fn encode_persona_json(
|
||||
if let Some(m) = model {
|
||||
map.insert("model".to_string(), serde_json::json!(m));
|
||||
}
|
||||
if !name_pool.is_empty() {
|
||||
map.insert("namePool".to_string(), serde_json::json!(name_pool));
|
||||
}
|
||||
|
||||
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
|
||||
}
|
||||
@@ -614,6 +636,7 @@ mod tests {
|
||||
Some("https://example.com/ada.png"),
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
@@ -628,7 +651,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_no_avatar() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, 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.");
|
||||
@@ -638,8 +661,8 @@ 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), None, None).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));
|
||||
@@ -653,6 +676,7 @@ mod tests {
|
||||
None,
|
||||
Some("goose"),
|
||||
Some("claude-sonnet-4"),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let result = parse_json_persona(&bytes).unwrap();
|
||||
@@ -665,7 +689,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_json_round_trip_without_provider_and_model() {
|
||||
let bytes = encode_persona_json("Bob", "You are Bob.", None, None, 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!(result.provider.is_none());
|
||||
@@ -727,8 +751,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_with_json() {
|
||||
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 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);
|
||||
@@ -740,7 +764,8 @@ 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, None, 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),
|
||||
@@ -755,8 +780,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_zip_ignores_macos_resource_forks() {
|
||||
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 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),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -217,6 +217,7 @@ mod tests {
|
||||
system_prompt: prompt.to_string(),
|
||||
provider: None,
|
||||
model: None,
|
||||
name_pool: Vec::new(),
|
||||
is_builtin: false,
|
||||
created_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
updated_at: "2026-03-20T00:00:00Z".to_string(),
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct PersonaRecord {
|
||||
/// Passed to the agent at creation time when deploying from this persona.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Pool of short, thematic names for bot instances created from this persona.
|
||||
/// When a new copy is added to a channel, a random unused name is picked from this pool.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub name_pool: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_builtin: bool,
|
||||
pub created_at: String,
|
||||
@@ -177,6 +181,8 @@ pub struct CreatePersonaRequest {
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name_pool: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -190,6 +196,8 @@ pub struct UpdatePersonaRequest {
|
||||
pub provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name_pool: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -253,6 +261,9 @@ pub struct ManagedAgentPrereqsInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateManagedAgentRequest {
|
||||
pub pubkey: String,
|
||||
/// Absent = don't touch. Present = rename the agent.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// Absent = don't touch. null = clear to agent default. "id" = set.
|
||||
#[serde(default)]
|
||||
pub model: Option<Option<String>>,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Universal fallback pool of short, distinctive names used when a persona's
|
||||
* own name pool is empty or exhausted.
|
||||
*/
|
||||
const UNIVERSAL_POOL = [
|
||||
"Alder",
|
||||
"Brook",
|
||||
"Coral",
|
||||
"Dawn",
|
||||
"Echo",
|
||||
"Frost",
|
||||
"Gale",
|
||||
"Heath",
|
||||
"Ivy",
|
||||
"Jade",
|
||||
"Kite",
|
||||
"Luna",
|
||||
"Maple",
|
||||
"Nova",
|
||||
"Opal",
|
||||
"Pyre",
|
||||
"Quartz",
|
||||
"Rune",
|
||||
"Silk",
|
||||
"Thorn",
|
||||
"Umber",
|
||||
"Vale",
|
||||
"Wisp",
|
||||
"Yarn",
|
||||
"Zinc",
|
||||
"Brine",
|
||||
"Cove",
|
||||
"Drift",
|
||||
"Elm",
|
||||
"Fjord",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pick a random unused name for a bot instance.
|
||||
*
|
||||
* 1. Try the persona's own name pool first.
|
||||
* 2. If exhausted, fall back to the universal pool.
|
||||
* 3. If both are exhausted, append a 2-digit suffix to a random name.
|
||||
*/
|
||||
export function pickBotName(
|
||||
namePool: string[],
|
||||
usedNames: Set<string>,
|
||||
): string {
|
||||
const usedLower = new Set([...usedNames].map((n) => n.toLowerCase()));
|
||||
|
||||
const pick = (pool: readonly string[]) => {
|
||||
const available = pool.filter((n) => !usedLower.has(n.toLowerCase()));
|
||||
if (available.length === 0) return null;
|
||||
return available[Math.floor(Math.random() * available.length)];
|
||||
};
|
||||
|
||||
// Try persona pool first
|
||||
if (namePool.length > 0) {
|
||||
const name = pick(namePool);
|
||||
if (name) return name;
|
||||
}
|
||||
|
||||
// Fallback to universal pool
|
||||
const fallback = pick(UNIVERSAL_POOL);
|
||||
if (fallback) return fallback;
|
||||
|
||||
// Both pools exhausted — pick a random base name and add a suffix
|
||||
const allNames = namePool.length > 0 ? namePool : UNIVERSAL_POOL;
|
||||
const base = allNames[Math.floor(Math.random() * allNames.length)];
|
||||
for (let i = 2; i < 100; i++) {
|
||||
const suffixed = `${base}-${String(i).padStart(2, "0")}`;
|
||||
if (!usedLower.has(suffixed.toLowerCase())) {
|
||||
return suffixed;
|
||||
}
|
||||
}
|
||||
|
||||
// Extremely unlikely fallback
|
||||
return `${base}-${Date.now() % 1000}`;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export function PersonaDialog({
|
||||
const [systemPrompt, setSystemPrompt] = React.useState("");
|
||||
const [provider, setProvider] = React.useState("");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [namePoolText, setNamePoolText] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !initialValues) {
|
||||
@@ -59,6 +60,12 @@ export function PersonaDialog({
|
||||
setSystemPrompt(initialValues.systemPrompt);
|
||||
setProvider(initialValues.provider ?? "");
|
||||
setModel(initialValues.model ?? "");
|
||||
setNamePoolText(
|
||||
("namePool" in initialValues
|
||||
? (initialValues as { namePool?: string[] }).namePool
|
||||
: undefined
|
||||
)?.join(", ") ?? "",
|
||||
);
|
||||
}, [initialValues, open]);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
@@ -68,6 +75,7 @@ export function PersonaDialog({
|
||||
setSystemPrompt("");
|
||||
setProvider("");
|
||||
setModel("");
|
||||
setNamePoolText("");
|
||||
}
|
||||
|
||||
onOpenChange(next);
|
||||
@@ -78,12 +86,17 @@ export function PersonaDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
const namePool = namePoolText
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const baseInput = {
|
||||
displayName,
|
||||
avatarUrl: avatarUrl.trim() || undefined,
|
||||
systemPrompt,
|
||||
provider: provider.trim() || undefined,
|
||||
model: model.trim() || undefined,
|
||||
namePool: namePool.length > 0 ? namePool : undefined,
|
||||
};
|
||||
|
||||
if ("id" in initialValues) {
|
||||
@@ -212,6 +225,29 @@ export function PersonaDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="persona-name-pool"
|
||||
>
|
||||
Instance name pool
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
disabled={isPending}
|
||||
id="persona-name-pool"
|
||||
onChange={(event) => setNamePoolText(event.target.value)}
|
||||
placeholder="Birch, Compass, Ridge, Thistle, ..."
|
||||
spellCheck={false}
|
||||
value={namePoolText}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated names for bot copies. Each instance gets a
|
||||
random name from this pool. Leave empty to use generic defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error.message}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
usePersonasQuery,
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { pickBotName } from "@/features/agents/lib/pickBotName";
|
||||
import {
|
||||
useBotRecents,
|
||||
DEFAULT_PERSONA_NAMES,
|
||||
@@ -68,9 +69,9 @@ export function ChannelMembersBar({
|
||||
const { recentIds, pushRecent } = useBotRecents();
|
||||
const quickDrop = useQuickBotDrop(channel.id);
|
||||
|
||||
// Track in-flight instance numbers so rapid clicks don't produce duplicates.
|
||||
// Track in-flight instance names so rapid clicks don't produce duplicates.
|
||||
// Cleared when the members query refetches with the new member.
|
||||
const inflightCountRef = React.useRef<Record<string, number>>({});
|
||||
const inflightNamesRef = React.useRef<Record<string, string[]>>({});
|
||||
|
||||
// Resolve the 3 personas to show in the quick bar.
|
||||
// Use recents if available, otherwise fall back to default names.
|
||||
@@ -99,35 +100,34 @@ export function ChannelMembersBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Reset in-flight counts when members list updates (the new bot appeared).
|
||||
inflightCountRef.current = {};
|
||||
// Reset in-flight names when members list updates (the new bot appeared).
|
||||
inflightNamesRef.current = {};
|
||||
|
||||
// Compute instance names from current members
|
||||
// Build the set of names already used in this channel
|
||||
const usedNames = new Set(
|
||||
members.map((m) => m.displayName ?? "").filter((n) => n.length > 0),
|
||||
);
|
||||
|
||||
// Compute instance names from persona name pools
|
||||
return resolved.map((persona) => {
|
||||
const prefix = `${persona.displayName}::`;
|
||||
let maxNum = 0;
|
||||
for (const member of members) {
|
||||
const label = member.displayName ?? "";
|
||||
if (label.startsWith(prefix)) {
|
||||
const num = Number.parseInt(label.slice(prefix.length), 10);
|
||||
if (!Number.isNaN(num) && num > maxNum) maxNum = num;
|
||||
}
|
||||
}
|
||||
const inflight = inflightCountRef.current[persona.id] ?? 0;
|
||||
const next = maxNum + 1 + inflight;
|
||||
return {
|
||||
persona,
|
||||
instanceName: `${persona.displayName}::${String(next).padStart(2, "0")}`,
|
||||
};
|
||||
// Include in-flight names to avoid duplicates on rapid clicks
|
||||
const inflight = inflightNamesRef.current[persona.id] ?? [];
|
||||
const combinedUsed = new Set(usedNames);
|
||||
for (const n of inflight) combinedUsed.add(n);
|
||||
|
||||
const instanceName = pickBotName(persona.namePool ?? [], combinedUsed);
|
||||
return { persona, instanceName };
|
||||
});
|
||||
}, [allPersonas, recentIds, members]);
|
||||
|
||||
const addBot = quickDrop.addBot;
|
||||
const handleQuickAdd = React.useCallback(
|
||||
async (persona: AgentPersona, instanceName: string) => {
|
||||
// Optimistically bump the in-flight counter to avoid duplicate names.
|
||||
inflightCountRef.current[persona.id] =
|
||||
(inflightCountRef.current[persona.id] ?? 0) + 1;
|
||||
// Optimistically track the chosen name to avoid duplicates on rapid clicks.
|
||||
inflightNamesRef.current[persona.id] = [
|
||||
...(inflightNamesRef.current[persona.id] ?? []),
|
||||
instanceName,
|
||||
];
|
||||
pushRecent(persona.id);
|
||||
await addBot(persona, instanceName);
|
||||
},
|
||||
|
||||
@@ -38,6 +38,8 @@ type ChannelPaneProps = {
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
replyTargetId: string | null;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
@@ -64,6 +66,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onSend,
|
||||
onTargetReached,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
@@ -79,6 +82,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
|
||||
@@ -8,6 +8,10 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
mergeMessages,
|
||||
useChannelMessagesQuery,
|
||||
@@ -140,16 +144,52 @@ export function ChannelScreen({
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
const messageProfiles = React.useMemo(
|
||||
() =>
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const messageProfiles = React.useMemo(() => {
|
||||
const base =
|
||||
mergeCurrentProfileIntoLookup(
|
||||
messageProfilesQuery.data?.profiles,
|
||||
currentProfile,
|
||||
),
|
||||
[currentProfile, messageProfilesQuery.data?.profiles],
|
||||
);
|
||||
) ?? {};
|
||||
// Merge managed agent names so system messages resolve instantly
|
||||
// (without waiting for the relay profile batch query).
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
const merged = { ...base };
|
||||
for (const agent of agents) {
|
||||
const key = agent.pubkey.toLowerCase();
|
||||
if (!merged[key]?.displayName) {
|
||||
merged[key] = {
|
||||
...merged[key],
|
||||
displayName: agent.name,
|
||||
avatarUrl: null,
|
||||
nip05Handle: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [
|
||||
currentProfile,
|
||||
managedAgentsQuery.data,
|
||||
messageProfilesQuery.data?.profiles,
|
||||
]);
|
||||
const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null);
|
||||
const channelMembers = channelMembersQuery.data;
|
||||
const personasQuery = usePersonasQuery();
|
||||
const personaLookup = React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
const personas = personasQuery.data ?? [];
|
||||
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
|
||||
const lookup = new Map<string, string>();
|
||||
for (const agent of agents) {
|
||||
if (agent.personaId) {
|
||||
const personaName = personaById.get(agent.personaId);
|
||||
if (personaName) {
|
||||
lookup.set(agent.pubkey.toLowerCase(), personaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}, [managedAgentsQuery.data, personasQuery.data]);
|
||||
const timelineMessages = React.useMemo(
|
||||
() =>
|
||||
formatTimelineMessages(
|
||||
@@ -159,6 +199,7 @@ export function ChannelScreen({
|
||||
currentProfile?.avatarUrl ?? null,
|
||||
messageProfiles,
|
||||
channelMembers,
|
||||
personaLookup,
|
||||
),
|
||||
[
|
||||
activeChannel,
|
||||
@@ -166,6 +207,7 @@ export function ChannelScreen({
|
||||
currentProfile?.avatarUrl,
|
||||
currentPubkey,
|
||||
messageProfiles,
|
||||
personaLookup,
|
||||
resolvedMessages,
|
||||
],
|
||||
);
|
||||
@@ -403,6 +445,7 @@ export function ChannelScreen({
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function QuickBotBar({ personas, pending, onAdd }: QuickBotBarProps) {
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Add {persona.displayName} → {instanceName}
|
||||
Add {instanceName} ({persona.displayName})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -114,6 +114,8 @@ export function formatTimelineMessages(
|
||||
currentUserAvatarUrl: string | null,
|
||||
profiles?: UserProfileLookup,
|
||||
members?: ChannelMember[],
|
||||
/** Map from lowercase pubkey → persona display name for bot messages. */
|
||||
personaLookup?: Map<string, string>,
|
||||
): TimelineMessage[] {
|
||||
const currentPubkeyLower = currentPubkey?.toLowerCase();
|
||||
const roleByPubkey = new Map<string, string>();
|
||||
@@ -291,6 +293,7 @@ export function formatTimelineMessages(
|
||||
});
|
||||
const thread = getThreadReference(event.tags);
|
||||
const edit = editsByTargetId.get(event.id);
|
||||
const role = roleByPubkey.get(authorPubkey.toLowerCase());
|
||||
return {
|
||||
id: event.id,
|
||||
createdAt: event.created_at,
|
||||
@@ -302,7 +305,11 @@ export function formatTimelineMessages(
|
||||
currentUserAvatarUrl,
|
||||
profiles,
|
||||
}),
|
||||
role: roleByPubkey.get(authorPubkey.toLowerCase()),
|
||||
role,
|
||||
personaDisplayName:
|
||||
role === "bot"
|
||||
? personaLookup?.get(authorPubkey.toLowerCase())
|
||||
: undefined,
|
||||
time: formatTime(event.created_at),
|
||||
body: edit ? edit.content : event.content,
|
||||
parentId: thread.parentId,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
usePersonasQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import { useChannelMembersQuery } from "@/features/channels/hooks";
|
||||
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
@@ -17,6 +20,7 @@ export function useMentions(channelId: string | null) {
|
||||
const membersQuery = useChannelMembersQuery(channelId);
|
||||
const members = membersQuery.data;
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const managedAgentNamesByPubkey = React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -27,20 +31,41 @@ export function useMentions(channelId: string | null) {
|
||||
),
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
const personaNameByPubkey = React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
const personas = personasQuery.data ?? [];
|
||||
const personaById = new Map(personas.map((p) => [p.id, p.displayName]));
|
||||
const lookup = new Map<string, string>();
|
||||
for (const agent of agents) {
|
||||
if (agent.personaId) {
|
||||
const name = personaById.get(agent.personaId);
|
||||
if (name) lookup.set(agent.pubkey.toLowerCase(), name);
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}, [managedAgentsQuery.data, personasQuery.data]);
|
||||
|
||||
const knownNames = React.useMemo<string[]>(() => {
|
||||
if (!members) return [];
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const member of members) {
|
||||
const name =
|
||||
member.displayName ??
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (name) {
|
||||
names.push(name);
|
||||
seen.add(name.toLowerCase());
|
||||
}
|
||||
// Also include persona names so typing @Scout triggers the dropdown
|
||||
const personaName = personaNameByPubkey.get(member.pubkey.toLowerCase());
|
||||
if (personaName && !seen.has(personaName.toLowerCase())) {
|
||||
names.push(personaName);
|
||||
seen.add(personaName.toLowerCase());
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [members, managedAgentNamesByPubkey]);
|
||||
}, [members, managedAgentNamesByPubkey, personaNameByPubkey]);
|
||||
|
||||
/** Lower-cased version of knownNames, used for case-insensitive prefix matching. */
|
||||
const knownNamesLower = React.useMemo<string[]>(
|
||||
@@ -78,27 +103,31 @@ export function useMentions(channelId: string | null) {
|
||||
const lowerQuery = mentionQuery.toLowerCase();
|
||||
return (members ?? [])
|
||||
.map((member) => {
|
||||
const pubkeyLower = member.pubkey.toLowerCase();
|
||||
const fallbackName =
|
||||
managedAgentNamesByPubkey.get(member.pubkey.toLowerCase()) ??
|
||||
managedAgentNamesByPubkey.get(pubkeyLower) ??
|
||||
member.pubkey.slice(0, 8);
|
||||
|
||||
return {
|
||||
member,
|
||||
label: member.displayName ?? fallbackName,
|
||||
personaName: personaNameByPubkey.get(pubkeyLower) ?? null,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
({ label, member }) =>
|
||||
({ label, member, personaName }) =>
|
||||
label.toLowerCase().includes(lowerQuery) ||
|
||||
member.pubkey.toLowerCase().includes(lowerQuery),
|
||||
member.pubkey.toLowerCase().includes(lowerQuery) ||
|
||||
personaName?.toLowerCase().includes(lowerQuery),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map(({ member, label }) => ({
|
||||
.map(({ member, label, personaName }) => ({
|
||||
pubkey: member.pubkey,
|
||||
displayName: label,
|
||||
role: member.role === "admin" ? "admin" : null,
|
||||
personaName,
|
||||
}));
|
||||
}, [managedAgentNamesByPubkey, members, mentionQuery]);
|
||||
}, [managedAgentNamesByPubkey, members, mentionQuery, personaNameByPubkey]);
|
||||
|
||||
const isMentionOpen = mentionQuery !== null && suggestions.length > 0;
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export type TimelineMessage = {
|
||||
author: string;
|
||||
avatarUrl?: string | null;
|
||||
role?: string;
|
||||
/** For bot messages, the display name of the persona this bot was created from. */
|
||||
personaDisplayName?: string;
|
||||
time: string;
|
||||
body: string;
|
||||
parentId?: string | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MentionSuggestion = {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
role?: string | null;
|
||||
personaName?: string | null;
|
||||
};
|
||||
|
||||
type MentionAutocompleteProps = {
|
||||
@@ -57,7 +58,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
<span className="truncate font-medium">
|
||||
{suggestion.displayName}
|
||||
</span>
|
||||
{suggestion.role ? (
|
||||
{suggestion.personaName ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({suggestion.personaName})
|
||||
</span>
|
||||
) : suggestion.role ? (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{suggestion.role}
|
||||
</span>
|
||||
|
||||
@@ -14,9 +14,9 @@ import { BotIdenticon } from "./BotIdenticon";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
/** Returns true if the author name looks like a numbered bot copy (e.g. "Scout::01") */
|
||||
function isNumberedBot(author: string, role?: string): boolean {
|
||||
return Boolean(role) && author.includes("::");
|
||||
/** Returns true if this message is from a bot instance. */
|
||||
function isBotInstance(role?: string): boolean {
|
||||
return role === "bot";
|
||||
}
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
@@ -178,7 +178,7 @@ export const MessageRow = React.memo(
|
||||
data-testid="message-row"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isNumberedBot(message.author, message.role) ? (
|
||||
{isBotInstance(message.role) ? (
|
||||
<BotIdenticon
|
||||
value={message.author}
|
||||
size={20}
|
||||
@@ -259,7 +259,11 @@ export const MessageRow = React.memo(
|
||||
{message.author}
|
||||
</h3>
|
||||
)}
|
||||
{message.role ? (
|
||||
{message.personaDisplayName ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : message.role ? (
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{message.role}
|
||||
</p>
|
||||
@@ -353,6 +357,7 @@ export const MessageRow = React.memo(
|
||||
prev.message.reactions === next.message.reactions &&
|
||||
prev.message.tags === next.message.tags &&
|
||||
prev.message.role === next.message.role &&
|
||||
prev.message.personaDisplayName === next.message.personaDisplayName &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.activeReplyTargetId === next.activeReplyTargetId &&
|
||||
prev.profiles === next.profiles,
|
||||
|
||||
@@ -23,6 +23,8 @@ type MessageTimelineProps = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
@@ -47,6 +49,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
fetchOlder,
|
||||
hasOlderMessages = true,
|
||||
isFetchingOlder = false,
|
||||
personaLookup,
|
||||
profiles,
|
||||
onDelete,
|
||||
onEdit,
|
||||
@@ -158,6 +161,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
onEdit={onEdit}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -23,20 +23,31 @@ function resolveLabel(
|
||||
return resolveUserLabel({ pubkey, currentPubkey, profiles });
|
||||
}
|
||||
|
||||
function resolvePersonaSuffix(
|
||||
pubkey: string | undefined,
|
||||
personaLookup: Map<string, string> | undefined,
|
||||
): string {
|
||||
if (!pubkey || !personaLookup) return "";
|
||||
const personaName = personaLookup.get(pubkey.toLowerCase());
|
||||
return personaName ? ` (${personaName})` : "";
|
||||
}
|
||||
|
||||
function describeSystemEvent(
|
||||
payload: SystemMessagePayload,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
personaLookup?: Map<string, string>,
|
||||
): string | null {
|
||||
const actor = resolveLabel(payload.actor, currentPubkey, profiles);
|
||||
|
||||
switch (payload.type) {
|
||||
case "member_joined": {
|
||||
const target = resolveLabel(payload.target, currentPubkey, profiles);
|
||||
const personaSuffix = resolvePersonaSuffix(payload.target, personaLookup);
|
||||
if (payload.actor === payload.target) {
|
||||
return `${actor} joined the channel`;
|
||||
return `${actor}${personaSuffix} joined the channel`;
|
||||
}
|
||||
return `${actor} added ${target} to the channel`;
|
||||
return `${actor} added ${target}${personaSuffix} to the channel`;
|
||||
}
|
||||
case "member_left": {
|
||||
return `${actor} left the channel`;
|
||||
@@ -62,12 +73,15 @@ export function SystemMessageRow({
|
||||
time,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
personaLookup,
|
||||
}: {
|
||||
body: string;
|
||||
createdAt: number;
|
||||
time: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
}) {
|
||||
let payload: SystemMessagePayload;
|
||||
try {
|
||||
@@ -76,7 +90,12 @@ export function SystemMessageRow({
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = describeSystemEvent(payload, currentPubkey, profiles);
|
||||
const description = describeSystemEvent(
|
||||
payload,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
personaLookup,
|
||||
);
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ type TimelineMessageListProps = {
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
|
||||
@@ -36,6 +38,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onEdit,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
}: TimelineMessageListProps) {
|
||||
const elements: React.ReactNode[] = [];
|
||||
@@ -60,6 +63,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
body={message.body}
|
||||
createdAt={message.createdAt}
|
||||
currentPubkey={currentPubkey}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
time={message.time}
|
||||
/>,
|
||||
|
||||
@@ -12,6 +12,7 @@ type RawParsedPersonaPreview = {
|
||||
avatar_data_url: string | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
name_pool?: string[];
|
||||
source_file: string;
|
||||
};
|
||||
|
||||
@@ -32,6 +33,7 @@ export type ParsedPersonaPreview = {
|
||||
avatarDataUrl: string | null;
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
namePool: string[];
|
||||
sourceFile: string;
|
||||
};
|
||||
|
||||
@@ -52,6 +54,7 @@ type RawPersona = {
|
||||
system_prompt: string;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
name_pool?: string[];
|
||||
is_builtin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -65,6 +68,7 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
|
||||
systemPrompt: persona.system_prompt,
|
||||
provider: persona.provider ?? null,
|
||||
model: persona.model ?? null,
|
||||
namePool: persona.name_pool ?? [],
|
||||
isBuiltIn: persona.is_builtin,
|
||||
createdAt: persona.created_at,
|
||||
updatedAt: persona.updated_at,
|
||||
@@ -86,6 +90,7 @@ export async function createPersona(
|
||||
systemPrompt: input.systemPrompt,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
namePool: input.namePool ?? [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -103,6 +108,7 @@ export async function updatePersona(
|
||||
systemPrompt: input.systemPrompt,
|
||||
provider: input.provider,
|
||||
model: input.model,
|
||||
namePool: input.namePool ?? [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -127,6 +133,7 @@ export async function parsePersonaFiles(
|
||||
avatarDataUrl: p.avatar_data_url,
|
||||
provider: p.provider,
|
||||
model: p.model,
|
||||
namePool: p.name_pool ?? [],
|
||||
sourceFile: p.source_file,
|
||||
})),
|
||||
skipped: raw.skipped.map((s) => ({
|
||||
|
||||
@@ -404,6 +404,7 @@ export type AgentModelInfo = {
|
||||
};
|
||||
export type UpdateManagedAgentInput = {
|
||||
pubkey: string;
|
||||
name?: string;
|
||||
model?: string | null;
|
||||
systemPrompt?: string | null;
|
||||
};
|
||||
@@ -416,6 +417,7 @@ export type AgentPersona = {
|
||||
provider: string | null;
|
||||
/** Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
|
||||
model: string | null;
|
||||
namePool: string[];
|
||||
isBuiltIn: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -427,6 +429,7 @@ export type CreatePersonaInput = {
|
||||
systemPrompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
namePool?: string[];
|
||||
};
|
||||
|
||||
export type UpdatePersonaInput = {
|
||||
@@ -436,7 +439,10 @@ export type UpdatePersonaInput = {
|
||||
systemPrompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
namePool?: string[];
|
||||
};
|
||||
|
||||
// ── Team types ────────────────────────────────────────────────────────────────
|
||||
export type AgentTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user