1use std::collections::HashMap;
2
3use crate::app_config::AppType;
4use crate::config::write_text_file;
5use crate::error::AppError;
6use crate::prompt::Prompt;
7use crate::prompt_files::prompt_file_path;
8use crate::store::AppState;
9
10pub struct PromptService;
11
12impl PromptService {
13 pub fn generate_prompt_id(name: &str, existing_ids: &[String]) -> String {
14 let mut base_id = name
15 .trim()
16 .to_lowercase()
17 .chars()
18 .map(|c| {
19 if c.is_alphanumeric() || c == '-' || c == '_' {
20 c
21 } else {
22 '-'
23 }
24 })
25 .collect::<String>()
26 .trim_matches('-')
27 .to_string();
28
29 if base_id.is_empty() {
30 base_id = "prompt".to_string();
31 }
32
33 if !existing_ids.contains(&base_id) {
34 return base_id;
35 }
36
37 let mut counter = 1;
38 loop {
39 let candidate = format!("{base_id}-{counter}");
40 if !existing_ids.contains(&candidate) {
41 return candidate;
42 }
43 counter += 1;
44 }
45 }
46
47 pub fn get_prompts(
48 state: &AppState,
49 app: AppType,
50 ) -> Result<HashMap<String, Prompt>, AppError> {
51 let cfg = state.config.read()?;
52 let prompts = match app {
53 AppType::Claude => &cfg.prompts.claude.prompts,
54 AppType::Codex => &cfg.prompts.codex.prompts,
55 AppType::Gemini => &cfg.prompts.gemini.prompts,
56 AppType::OpenCode => &cfg.prompts.opencode.prompts,
57 AppType::OpenClaw => &cfg.prompts.openclaw.prompts,
58 AppType::Hermes => &cfg.prompts.hermes.prompts,
59 };
60 Ok(prompts.clone())
61 }
62
63 pub fn upsert_prompt(
64 state: &AppState,
65 app: AppType,
66 id: &str,
67 prompt: Prompt,
68 ) -> Result<(), AppError> {
69 let is_enabled = prompt.enabled;
71
72 let mut cfg = state.config.write()?;
73 let prompts = match app {
74 AppType::Claude => &mut cfg.prompts.claude.prompts,
75 AppType::Codex => &mut cfg.prompts.codex.prompts,
76 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
77 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
78 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
79 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
80 };
81 prompts.insert(id.to_string(), prompt.clone());
82 drop(cfg);
83 state.save()?;
84
85 if is_enabled {
87 let target_path = prompt_file_path(&app)?;
88 write_text_file(&target_path, &prompt.content)?;
89 }
90
91 Ok(())
92 }
93
94 pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
95 let mut cfg = state.config.write()?;
96 let prompts = match app {
97 AppType::Claude => &mut cfg.prompts.claude.prompts,
98 AppType::Codex => &mut cfg.prompts.codex.prompts,
99 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
100 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
101 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
102 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
103 };
104
105 if let Some(prompt) = prompts.get(id) {
106 if prompt.enabled {
107 return Err(AppError::InvalidInput("无法删除已启用的提示词".to_string()));
108 }
109 }
110
111 prompts.remove(id);
112 drop(cfg);
113 state.save()?;
114 Ok(())
115 }
116
117 pub fn rename_prompt(
118 state: &AppState,
119 app: AppType,
120 id: &str,
121 name: &str,
122 ) -> Result<(), AppError> {
123 let trimmed = name.trim();
124 if trimmed.is_empty() {
125 return Err(AppError::InvalidInput("提示词名称不能为空".to_string()));
126 }
127
128 let mut cfg = state.config.write()?;
129 let prompts = match app {
130 AppType::Claude => &mut cfg.prompts.claude.prompts,
131 AppType::Codex => &mut cfg.prompts.codex.prompts,
132 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
133 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
134 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
135 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
136 };
137
138 let Some(prompt) = prompts.get_mut(id) else {
139 return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
140 };
141
142 let timestamp = std::time::SystemTime::now()
143 .duration_since(std::time::UNIX_EPOCH)
144 .unwrap()
145 .as_secs() as i64;
146 prompt.name = trimmed.to_string();
147 prompt.updated_at = Some(timestamp);
148
149 drop(cfg);
150 state.save()?;
151 Ok(())
152 }
153
154 pub fn create_prompt(
155 state: &AppState,
156 app: AppType,
157 name: &str,
158 content: &str,
159 ) -> Result<Prompt, AppError> {
160 let trimmed_name = name.trim();
161 if trimmed_name.is_empty() {
162 return Err(AppError::InvalidInput("提示词名称不能为空".to_string()));
163 }
164
165 let existing_ids = Self::get_prompts(state, app.clone())?
166 .into_keys()
167 .collect::<Vec<_>>();
168 let id = Self::generate_prompt_id(trimmed_name, &existing_ids);
169 if id.trim().is_empty() {
170 return Err(AppError::InvalidInput(
171 "无法根据提示词名称生成有效 ID".to_string(),
172 ));
173 }
174
175 let timestamp = std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .unwrap_or_default()
178 .as_secs() as i64;
179 let prompt = Prompt {
180 id: id.clone(),
181 name: trimmed_name.to_string(),
182 content: content.trim_end().to_string(),
183 description: None,
184 enabled: false,
185 created_at: Some(timestamp),
186 updated_at: Some(timestamp),
187 };
188
189 Self::upsert_prompt(state, app, &id, prompt.clone())?;
190 Ok(prompt)
191 }
192
193 pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
194 let target_path = prompt_file_path(&app)?;
196 if target_path.exists() {
197 if let Ok(live_content) = std::fs::read_to_string(&target_path) {
198 if !live_content.trim().is_empty() {
199 let mut cfg = state.config.write()?;
200 let prompts = match app {
201 AppType::Claude => &mut cfg.prompts.claude.prompts,
202 AppType::Codex => &mut cfg.prompts.codex.prompts,
203 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
204 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
205 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
206 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
207 };
208
209 if let Some((enabled_id, enabled_prompt)) = prompts
211 .iter_mut()
212 .find(|(_, p)| p.enabled)
213 .map(|(id, p)| (id.clone(), p))
214 {
215 let timestamp = std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH)
217 .unwrap()
218 .as_secs() as i64;
219 enabled_prompt.content = live_content.clone();
220 enabled_prompt.updated_at = Some(timestamp);
221 log::info!("回填 live 提示词内容到已启用项: {enabled_id}");
222 drop(cfg); state.save()?; } else {
225 let content_exists = prompts
227 .values()
228 .any(|p| p.content.trim() == live_content.trim());
229 if !content_exists {
230 let timestamp = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap()
233 .as_secs() as i64;
234 let backup_id = format!("backup-{timestamp}");
235 let backup_prompt = Prompt {
236 id: backup_id.clone(),
237 name: format!(
238 "原始提示词 {}",
239 chrono::Local::now().format("%Y-%m-%d %H:%M")
240 ),
241 content: live_content,
242 description: Some("自动备份的原始提示词".to_string()),
243 enabled: false,
244 created_at: Some(timestamp),
245 updated_at: Some(timestamp),
246 };
247 prompts.insert(backup_id.clone(), backup_prompt);
248 log::info!("回填 live 提示词内容,创建备份: {backup_id}");
249 drop(cfg); state.save()?; } else {
252 drop(cfg);
254 }
255 }
256 }
257 }
258 }
259
260 let mut cfg = state.config.write()?;
262 let prompts = match app {
263 AppType::Claude => &mut cfg.prompts.claude.prompts,
264 AppType::Codex => &mut cfg.prompts.codex.prompts,
265 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
266 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
267 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
268 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
269 };
270
271 for prompt in prompts.values_mut() {
272 prompt.enabled = false;
273 }
274
275 if let Some(prompt) = prompts.get_mut(id) {
276 prompt.enabled = true;
277 write_text_file(&target_path, &prompt.content)?; } else {
279 return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
280 }
281
282 drop(cfg);
283 state.save()?; Ok(())
285 }
286
287 pub fn disable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
288 let mut cfg = state.config.write()?;
289 let prompts = match app {
290 AppType::Claude => &mut cfg.prompts.claude.prompts,
291 AppType::Codex => &mut cfg.prompts.codex.prompts,
292 AppType::Gemini => &mut cfg.prompts.gemini.prompts,
293 AppType::OpenCode => &mut cfg.prompts.opencode.prompts,
294 AppType::OpenClaw => &mut cfg.prompts.openclaw.prompts,
295 AppType::Hermes => &mut cfg.prompts.hermes.prompts,
296 };
297
298 if let Some(prompt) = prompts.get_mut(id) {
300 if !prompt.enabled {
301 return Err(AppError::InvalidInput(format!("提示词 {} 未激活", id)));
302 }
303 prompt.enabled = false;
304 } else {
305 return Err(AppError::InvalidInput(format!("提示词 {} 不存在", id)));
306 }
307
308 drop(cfg);
309 state.save()?;
310
311 let target_path = prompt_file_path(&app)?;
313 write_text_file(&target_path, "")?;
314
315 Ok(())
316 }
317
318 pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
319 let file_path = prompt_file_path(&app)?;
320
321 if !file_path.exists() {
322 return Err(AppError::Message("提示词文件不存在".to_string()));
323 }
324
325 let content =
326 std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
327 let timestamp = std::time::SystemTime::now()
328 .duration_since(std::time::UNIX_EPOCH)
329 .unwrap()
330 .as_secs() as i64;
331
332 let id = format!("imported-{timestamp}");
333 let prompt = Prompt {
334 id: id.clone(),
335 name: format!(
336 "导入的提示词 {}",
337 chrono::Local::now().format("%Y-%m-%d %H:%M")
338 ),
339 content,
340 description: Some("从现有配置文件导入".to_string()),
341 enabled: false,
342 created_at: Some(timestamp),
343 updated_at: Some(timestamp),
344 };
345
346 Self::upsert_prompt(state, app, &id, prompt)?;
347 Ok(id)
348 }
349
350 pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
351 let file_path = prompt_file_path(&app)?;
352 if !file_path.exists() {
353 return Ok(None);
354 }
355 let content =
356 std::fs::read_to_string(&file_path).map_err(|e| AppError::io(&file_path, e))?;
357 Ok(Some(content))
358 }
359
360 pub fn sync_all_active_to_live_best_effort(state: &AppState) -> Result<(), AppError> {
361 let active_prompts = {
362 let cfg = state.config.read()?;
363 let mut result = Vec::new();
364
365 for app in AppType::all() {
366 let prompts = match app {
367 AppType::Claude => &cfg.prompts.claude.prompts,
368 AppType::Codex => &cfg.prompts.codex.prompts,
369 AppType::Gemini => &cfg.prompts.gemini.prompts,
370 AppType::OpenCode => &cfg.prompts.opencode.prompts,
371 AppType::OpenClaw => &cfg.prompts.openclaw.prompts,
372 AppType::Hermes => &cfg.prompts.hermes.prompts,
373 };
374
375 if let Some(prompt) = select_active_prompt(prompts) {
376 result.push((app, prompt.content));
377 }
378 }
379
380 result
381 };
382
383 for (app, content) in active_prompts {
384 if !crate::sync_policy::should_sync_live(&app) {
385 continue;
386 }
387
388 let target_path = match prompt_file_path(&app) {
389 Ok(path) => path,
390 Err(err) => {
391 log::warn!("同步 {app} 提示词 live 文件时解析路径失败: {err}");
392 continue;
393 }
394 };
395
396 if let Err(err) = write_text_file(&target_path, &content) {
397 log::warn!("同步 {app} 提示词到 live 文件失败: {err}");
398 }
399 }
400
401 Ok(())
402 }
403}
404
405fn select_active_prompt(prompts: &HashMap<String, Prompt>) -> Option<Prompt> {
406 prompts
407 .values()
408 .filter(|prompt| prompt.enabled)
409 .max_by_key(|prompt| {
410 (
411 prompt.updated_at.unwrap_or(prompt.created_at.unwrap_or(0)),
412 prompt.created_at.unwrap_or(0),
413 prompt.id.clone(),
414 )
415 })
416 .cloned()
417}