use std::collections::HashMap;
use crate::app_config::AppType;
use crate::config::write_text_file;
use crate::error::AppError;
use crate::prompt::Prompt;
use crate::prompt_files::prompt_file_path;
use crate::store::AppState;
pub struct PromptService;
impl PromptService {
pub fn generate_prompt_id(name: &str, existing_ids: &[String]) -> String {
let mut base_id = name
.trim()
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if base_id.is_empty() {
base_id = "prompt".to_string();
}
if !existing_ids.contains(&base_id) {
return base_id;
}
let mut counter = 1;
loop {
let candidate = format!("{base_id}-{counter}");
if !existing_ids.contains(&candidate) {
return candidate;
}
counter += 1;
}
}
pub fn get_prompts(
state: &AppState,
app: AppType,
) -> Result<HashMap<String, Prompt>, AppError> {
let cfg = state.config.read()?;
let prompts = match app {
AppType::Claude => &cfg.prompts.claude.prompts,
AppType::Codex => &cfg.prompts.codex.prompts,
AppType::Gemini => &cfg.prompts.gemini.prompts,
AppType::OpenCode => &cfg.prompts.opencode.prompts,
AppType::OpenClaw => &cfg.prompts.openclaw.prompts,
AppType::Hermes => &cfg.prompts.hermes.prompts,
};
Ok(prompts.clone())
}
pub fn upsert_prompt(
state: &AppState,
app: AppType,
id: &str,
prompt: Prompt,
) -> Result<(), AppError> {
let is_enabled = prompt.enabled;
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
prompts.insert(id.to_string(), prompt.clone());
drop(cfg);
state.save()?;
if is_enabled {
let target_path = prompt_file_path(&app)?;
write_text_file(&target_path, &prompt.content)?;
}
Ok(())
}
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
if let Some(prompt) = prompts.get(id) {
if prompt.enabled {
return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
}
}
prompts.remove(id);
drop(cfg);
state.save()?;
Ok(())
}
pub fn rename_prompt(
state: &AppState,
app: AppType,
id: &str,
name: &str,
) -> Result<(), AppError> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::InvalidInput("提示词名称不能为空".to_string()));
}
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
let Some(prompt) = prompts.get_mut(id) else {
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
};
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
prompt.name = trimmed.to_string();
prompt.updated_at = Some(timestamp);
drop(cfg);
state.save()?;
Ok(())
}
pub fn create_prompt(
state: &AppState,
app: AppType,
name: &str,
content: &str,
) -> Result<Prompt, AppError> {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err(AppError::InvalidInput("提示词名称不能为空".to_string()));
}
let existing_ids = Self::get_prompts(state, app.clone())?
.into_keys()
.collect::<Vec<_>>();
let id = Self::generate_prompt_id(trimmed_name, &existing_ids);
if id.trim().is_empty() {
return Err(AppError::InvalidInput(
"无法根据提示词名称生成有效 ID".to_string(),
));
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let prompt = Prompt {
id: id.clone(),
name: trimmed_name.to_string(),
content: content.trim_end().to_string(),
description: None,
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
Self::upsert_prompt(state, app, &id, prompt.clone())?;
Ok(prompt)
}
pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
let target_path = prompt_file_path(&app)?;
if target_path.exists() {
if let Ok(live_content) = std::fs::read_to_string(&target_path) {
if !live_content.trim().is_empty() {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
if let Some((enabled_id, enabled_prompt)) = prompts
.iter_mut()
.find(|(_, p)| p.enabled)
.map(|(id, p)| (id.clone(), p))
{
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
enabled_prompt.content = live_content.clone();
enabled_prompt.updated_at = Some(timestamp);
log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
drop(cfg); state.save()?; } else {
let content_exists = prompts
.values()
.any(|p| p.content.trim() == live_content.trim());
if !content_exists {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt {
id: backup_id.clone(),
name: format!(
"原始提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content: live_content,
description: Some("自动备份的原始提示词".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
prompts.insert(backup_id.clone(), backup_prompt);
log::info!("回填 live 提示词内容,创建备份: {backup_id}");
drop(cfg); state.save()?; } else {
drop(cfg);
}
}
}
}
}
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
for prompt in prompts.values_mut() {
prompt.enabled = false;
}
if let Some(prompt) = prompts.get_mut(id) {
prompt.enabled = true;
write_text_file(&target_path, &prompt.content)?; } else {
return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
}
drop(cfg);
state.save()?; Ok(())
}
pub fn disable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
let mut cfg = state.config.write()?;
let prompts = match app {
AppType::Claude => &mut cfg.prompts.claude.prompts,
AppType::Codex => &mut cfg.prompts.codex.prompts,
AppType::Gemini => &mut cfg.prompts.gemini.prompts,
AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
AppType::Hermes => &mut cfg.prompts.hermes.prompts,
};
if let Some(prompt) = prompts.get_mut(id) {
if !prompt.enabled {
return Err(AppError::InvalidInput(format!("提示词 {} 未激活", id)));
}
prompt.enabled = false;
} else {
return Err(AppError::InvalidInput(format!("提示词 {} 不存在", id)));
}
drop(cfg);
state.save()?;
let target_path = prompt_file_path(&app)?;
write_text_file(&target_path, "")?;
Ok(())
}
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Err(AppError::Message("提示词文件不存在".to_string()));
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let id = format!("imported-{timestamp}");
let prompt = Prompt {
id: id.clone(),
name: format!(
"导入的提示词 {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("从现有配置文件导入".to_string()),
enabled: false,
created_at: Some(timestamp),
updated_at: Some(timestamp),
};
Self::upsert_prompt(state, app, &id, prompt)?;
Ok(id)
}
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
let file_path = prompt_file_path(&app)?;
if !file_path.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
Ok(Some(content))
}
pub fn sync_all_active_to_live_best_effort(state: &AppState) -> Result<(), AppError> {
let active_prompts = {
let cfg = state.config.read()?;
let mut result = Vec::new();
for app in AppType::all() {
let prompts = match app {
AppType::Claude => &cfg.prompts.claude.prompts,
AppType::Codex => &cfg.prompts.codex.prompts,
AppType::Gemini => &cfg.prompts.gemini.prompts,
AppType::OpenCode => &cfg.prompts.opencode.prompts,
AppType::OpenClaw => &cfg.prompts.openclaw.prompts,
AppType::Hermes => &cfg.prompts.hermes.prompts,
};
if let Some(prompt) = select_active_prompt(prompts) {
result.push((app, prompt.content));
}
}
result
};
for (app, content) in active_prompts {
if !crate::sync_policy::should_sync_live(&app) {
continue;
}
let target_path = match prompt_file_path(&app) {
Ok(path) => path,
Err(err) => {
log::warn!("同步 {app} 提示词 live 文件时解析路径失败: {err}");
continue;
}
};
if let Err(err) = write_text_file(&target_path, &content) {
log::warn!("同步 {app} 提示词到 live 文件失败: {err}");
}
}
Ok(())
}
}
fn select_active_prompt(prompts: &HashMap<String, Prompt>) -> Option<Prompt> {
prompts
.values()
.filter(|prompt| prompt.enabled)
.max_by_key(|prompt| {
(
prompt.updated_at.unwrap_or(prompt.created_at.unwrap_or(0)),
prompt.created_at.unwrap_or(0),
prompt.id.clone(),
)
})
.cloned()
}