Skip to main content

cc_switch_lib/services/
prompt.rs

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        // 检查是否为已启用的提示词
70        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        // 如果是已启用的提示词,同步更新到对应的文件
86        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        // 回填当前 live 文件内容到已启用的提示词,或创建备份
195        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                    // 尝试回填到当前已启用的提示词
210                    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); // 释放锁后保存,避免死锁
223                        state.save()?; // 第一次保存:回填后立即持久化
224                    } else {
225                        // 没有已启用的提示词,则创建一次备份(避免重复备份)
226                        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); // 释放锁后保存
250                            state.save()?; // 第一次保存:回填后立即持久化
251                        } else {
252                            // 即使内容已存在,也无需重复备份;但不需要保存任何更改
253                            drop(cfg);
254                        }
255                    }
256                }
257            }
258        }
259
260        // 启用目标提示词并写入文件
261        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)?; // 原子写入
278        } else {
279            return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
280        }
281
282        drop(cfg);
283        state.save()?; // 第二次保存:启用目标提示词并写入文件后
284        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        // 验证提示词是否存在且已启用
299        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        // 清空对应的实时文件
312        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}