use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
use crate::memory::longterm::LongTermMemory;
#[derive(Debug, Clone)]
pub struct PersonaPreset {
pub name: &'static str,
pub label: &'static str,
pub soul_content: &'static str,
}
pub const PERSONA_PRESETS: &[PersonaPreset] = &[
PersonaPreset {
name: "default",
label: "Default Assistant",
soul_content: "",
},
PersonaPreset {
name: "concise",
label: "Concise & Direct",
soul_content: "You are extremely concise. Answer in as few words as possible. No filler, no pleasantries. Get straight to the point.",
},
PersonaPreset {
name: "friendly",
label: "Friendly & Warm",
soul_content: "You are warm, friendly, and encouraging. Use a conversational tone. Show genuine interest in helping. Be supportive and positive.",
},
PersonaPreset {
name: "professional",
label: "Professional & Formal",
soul_content: "You are professional and formal. Use precise language. Structure responses clearly. Maintain a business-appropriate tone at all times.",
},
PersonaPreset {
name: "creative",
label: "Creative & Playful",
soul_content: "You are creative, playful, and imaginative. Use vivid language, metaphors, and humor when appropriate. Think outside the box.",
},
PersonaPreset {
name: "technical",
label: "Technical Expert",
soul_content: "You are a technical expert. Provide detailed, accurate technical explanations. Include code examples when relevant. Prioritize precision over simplicity.",
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PersonaCommand {
Show,
Set(String),
Reset,
List,
}
pub type PersonaOverrideStore = Arc<RwLock<HashMap<String, String>>>;
const PERSONA_PREF_CATEGORY: &str = "persona_pref";
const PERSONA_PREF_PREFIX: &str = "persona_pref:";
pub fn new_persona_store() -> PersonaOverrideStore {
Arc::new(RwLock::new(HashMap::new()))
}
pub fn parse_persona_command(text: &str) -> Option<PersonaCommand> {
let trimmed = text.trim();
let rest = if trimmed == "/persona" {
""
} else if let Some(after) = trimmed.strip_prefix("/persona ") {
after.trim()
} else {
return None;
};
if rest.is_empty() {
return Some(PersonaCommand::Show);
}
match rest {
"reset" => Some(PersonaCommand::Reset),
"list" => Some(PersonaCommand::List),
arg => Some(PersonaCommand::Set(arg.to_string())),
}
}
pub fn format_persona_list(current: Option<&str>) -> String {
let mut output = String::from("Available personas:\n\n");
for preset in PERSONA_PRESETS {
let is_current = current.is_some_and(|c| c == preset.name);
let marker = if is_current { " (current)" } else { "" };
output.push_str(&format!(" {} — {}{}\n", preset.name, preset.label, marker));
}
output.push_str(
"\nUse /persona <name> to set a preset, or /persona <custom text> for a custom persona.",
);
output.trim_end().to_string()
}
pub fn format_current_persona(current: Option<&str>) -> String {
match current {
Some(name) => {
if let Some(preset) = PERSONA_PRESETS.iter().find(|p| p.name == name) {
format!(
"Current persona: {} — {} (override)",
preset.name, preset.label
)
} else {
format!("Current persona: custom override\nContent: {}", name)
}
}
None => "Current persona: default (no override)".to_string(),
}
}
pub async fn persist_single(chat_id: &str, value: &str, ltm: &Arc<Mutex<LongTermMemory>>) {
let key = format!("{}{}", PERSONA_PREF_PREFIX, chat_id);
let mut ltm = ltm.lock().await;
let _ = ltm
.set(&key, value, PERSONA_PREF_CATEGORY, vec![], 0.2)
.await;
}
pub async fn remove_single(chat_id: &str, ltm: &Arc<Mutex<LongTermMemory>>) {
let key = format!("{}{}", PERSONA_PREF_PREFIX, chat_id);
let mut ltm = ltm.lock().await;
let _ = ltm.delete(&key).await;
}
pub async fn hydrate_overrides(store: &PersonaOverrideStore, ltm: &Arc<Mutex<LongTermMemory>>) {
let entries: Vec<(String, String)> = {
let ltm = ltm.lock().await;
ltm.list_by_category(PERSONA_PREF_CATEGORY)
.iter()
.map(|entry| (entry.key.clone(), entry.value.clone()))
.collect()
};
let mut map = store.write().await;
for (key, value) in entries {
if let Some(chat_id) = key.strip_prefix(PERSONA_PREF_PREFIX) {
if !value.is_empty() {
map.insert(chat_id.to_string(), value);
}
}
}
}
pub fn resolve_soul_content(name_or_text: &str) -> String {
if let Some(preset) = PERSONA_PRESETS.iter().find(|p| p.name == name_or_text) {
preset.soul_content.to_string()
} else {
name_or_text.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::builtin_searcher::BuiltinSearcher;
#[test]
fn test_parse_persona_command_show() {
let cmd = parse_persona_command("/persona");
assert_eq!(cmd, Some(PersonaCommand::Show));
}
#[test]
fn test_parse_persona_command_list() {
let cmd = parse_persona_command("/persona list");
assert_eq!(cmd, Some(PersonaCommand::List));
}
#[test]
fn test_parse_persona_command_reset() {
let cmd = parse_persona_command("/persona reset");
assert_eq!(cmd, Some(PersonaCommand::Reset));
}
#[test]
fn test_parse_persona_command_set_preset() {
let cmd = parse_persona_command("/persona concise");
assert_eq!(cmd, Some(PersonaCommand::Set("concise".to_string())));
}
#[test]
fn test_parse_persona_command_set_custom() {
let cmd = parse_persona_command("/persona Be a pirate");
assert_eq!(cmd, Some(PersonaCommand::Set("Be a pirate".to_string())));
}
#[test]
fn test_parse_persona_command_not_persona() {
let cmd = parse_persona_command("hello");
assert_eq!(cmd, None);
}
#[test]
fn test_parse_persona_rejects_similar() {
assert_eq!(parse_persona_command("/personas"), None);
assert_eq!(parse_persona_command("/persona_x"), None);
}
#[test]
fn test_format_persona_list_shows_presets() {
let output = format_persona_list(None);
assert!(output.contains("default"));
assert!(output.contains("concise"));
assert!(output.contains("friendly"));
assert!(output.contains("professional"));
assert!(output.contains("creative"));
assert!(output.contains("technical"));
}
#[test]
fn test_resolve_soul_content_preset() {
let content = resolve_soul_content("concise");
assert_eq!(
content,
"You are extremely concise. Answer in as few words as possible. No filler, no pleasantries. Get straight to the point."
);
}
#[test]
fn test_resolve_soul_content_custom() {
let content = resolve_soul_content("Be a pirate");
assert_eq!(content, "Be a pirate");
}
#[tokio::test]
async fn test_persist_and_hydrate_persona() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("longterm.json");
let ltm = LongTermMemory::with_path_and_searcher(path, Arc::new(BuiltinSearcher)).unwrap();
let ltm = Arc::new(Mutex::new(ltm));
let store = new_persona_store();
{
let mut map = store.write().await;
map.insert("chat456".to_string(), "concise".to_string());
}
{
let map = store.read().await;
for (chat_id, value) in map.iter() {
persist_single(chat_id, value, <m).await;
}
}
let store2 = new_persona_store();
hydrate_overrides(&store2, <m).await;
let map = store2.read().await;
let value = map.get("chat456").unwrap();
assert_eq!(value, "concise");
}
}