feat(prompts+i18n): add prompt management and improve prompt editor i18n (#193)
* feat(prompts): add prompt management across Tauri service and React UI
- backend: add commands/prompt.rs, services/prompt.rs, register in commands/mod.rs and lib.rs, refine app_config.rs
- frontend: add PromptPanel, PromptFormModal, PromptListItem, MarkdownEditor, usePromptActions, integrate in App.tsx
- api: add src/lib/api/prompts.ts
- i18n: update src/i18n/locales/{en,zh}.json
- build: update package.json and pnpm-lock.yaml
* feat(i18n): improve i18n for prompts and Markdown editor
- update src/i18n/locales/{en,zh}.json keys and strings
- apply i18n in PromptFormModal, PromptPanel, and MarkdownEditor
- align prompt text with src-tauri/src/services/prompt.rs
* feat(prompts): add enable/disable toggle and simplify panel UI
- Add PromptToggle component and integrate in prompt list items
- Implement toggleEnabled with optimistic update; enable via API, disable via upsert with enabled=false;
reload after success
- Simplify PromptPanel: remove file import and current-file preview to keep CRUD flow focused
- Tweak header controls style (use mcp variant) and minor copy: rename “Prompt Management” to “Prompts”
- i18n: add disableSuccess/disableFailed messages
- Backend (Tauri): prevent duplicate backups when importing original prompt content
* style: unify code formatting with trailing commas
* feat(prompts): add Gemini filename support to PromptFormModal
Update filename mapping to use Record<AppId, string> pattern, supporting
GEMINI.md alongside CLAUDE.md and AGENTS.md.
* fix(prompts): sync enabled prompt to file when updating
When updating a prompt that is currently enabled, automatically sync
the updated content to the corresponding live file (CLAUDE.md/AGENTS.md/GEMINI.md).
This ensures the active prompt file always reflects the latest content
when editing enabled prompts.
This commit is contained in:
@@ -21,6 +21,24 @@ pub struct McpRoot {
|
||||
pub gemini: McpConfig, // Gemini MCP 配置(预留)
|
||||
}
|
||||
|
||||
/// Prompt 配置:单客户端维度
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PromptConfig {
|
||||
#[serde(default)]
|
||||
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
||||
}
|
||||
|
||||
/// Prompt 根:按客户端分开维护
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub claude: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub gemini: PromptConfig,
|
||||
}
|
||||
|
||||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::ProviderManager;
|
||||
@@ -73,6 +91,9 @@ pub struct MultiAppConfig {
|
||||
/// MCP 配置(按客户端分治)
|
||||
#[serde(default)]
|
||||
pub mcp: McpRoot,
|
||||
/// Prompt 配置(按客户端分治)
|
||||
#[serde(default)]
|
||||
pub prompts: PromptRoot,
|
||||
}
|
||||
|
||||
fn default_version() -> u32 {
|
||||
@@ -90,6 +111,7 @@ impl Default for MultiAppConfig {
|
||||
version: 2,
|
||||
apps,
|
||||
mcp: McpRoot::default(),
|
||||
prompts: PromptRoot::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod import_export;
|
||||
mod mcp;
|
||||
mod misc;
|
||||
mod plugin;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod settings;
|
||||
|
||||
@@ -13,5 +14,6 @@ pub use import_export::*;
|
||||
pub use mcp::*;
|
||||
pub use misc::*;
|
||||
pub use plugin::*;
|
||||
pub use prompt::*;
|
||||
pub use provider::*;
|
||||
pub use settings::*;
|
||||
|
||||
66
src-tauri/src/commands/prompt.rs
Normal file
66
src-tauri/src/commands/prompt.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::prompt::Prompt;
|
||||
use crate::services::PromptService;
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_prompts(
|
||||
app: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<HashMap<String, Prompt>, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::get_prompts(&state, app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upsert_prompt(
|
||||
app: String,
|
||||
id: String,
|
||||
prompt: Prompt,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::upsert_prompt(&state, app_type, &id, prompt).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_prompt(
|
||||
app: String,
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::delete_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_prompt(
|
||||
app: String,
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::enable_prompt(&state, app_type, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_prompt_from_file(
|
||||
app: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::import_from_file(&state, app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_prompt_file_content(
|
||||
app: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod error;
|
||||
mod gemini_config; // 新增
|
||||
mod init_status;
|
||||
mod mcp;
|
||||
mod prompt;
|
||||
mod provider;
|
||||
mod services;
|
||||
mod settings;
|
||||
@@ -24,7 +25,9 @@ pub use mcp::{
|
||||
import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex,
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService};
|
||||
pub use services::{
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, SpeedtestService,
|
||||
};
|
||||
pub use settings::{update_settings, AppSettings};
|
||||
pub use store::AppState;
|
||||
|
||||
@@ -551,6 +554,13 @@ pub fn run() {
|
||||
commands::sync_enabled_mcp_to_codex,
|
||||
commands::import_mcp_from_claude,
|
||||
commands::import_mcp_from_codex,
|
||||
// Prompt management
|
||||
commands::get_prompts,
|
||||
commands::upsert_prompt,
|
||||
commands::delete_prompt,
|
||||
commands::enable_prompt,
|
||||
commands::import_prompt_from_file,
|
||||
commands::get_current_prompt_file_content,
|
||||
// ours: endpoint speed test + custom endpoint management
|
||||
commands::test_api_endpoints,
|
||||
commands::get_custom_endpoints,
|
||||
|
||||
16
src-tauri/src/prompt.rs
Normal file
16
src-tauri/src/prompt.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Prompt {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<i64>,
|
||||
#[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod config;
|
||||
pub mod mcp;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod speedtest;
|
||||
|
||||
pub use config::ConfigService;
|
||||
pub use mcp::McpService;
|
||||
pub use prompt::PromptService;
|
||||
pub use provider::{ProviderService, ProviderSortUpdate};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
|
||||
213
src-tauri/src/services/prompt.rs
Normal file
213
src-tauri/src/services/prompt.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config::write_text_file;
|
||||
use crate::error::AppError;
|
||||
use crate::prompt::Prompt;
|
||||
use crate::store::AppState;
|
||||
|
||||
pub struct PromptService;
|
||||
|
||||
impl PromptService {
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
prompts.insert(id.to_string(), prompt.clone());
|
||||
drop(cfg);
|
||||
state.save()?;
|
||||
|
||||
// 如果是已启用的提示词,同步更新到对应的文件
|
||||
if is_enabled {
|
||||
let target_path = Self::get_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,
|
||||
};
|
||||
|
||||
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 enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||
// 先保存当前文件内容(如果存在且没有对应的提示词)
|
||||
let target_path = Self::get_prompt_file_path(&app)?;
|
||||
if target_path.exists() {
|
||||
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,
|
||||
};
|
||||
|
||||
// 检查是否有已启用的提示词
|
||||
let has_enabled = prompts.values().any(|p| p.enabled);
|
||||
|
||||
// 如果没有已启用的提示词,自动保存当前文件
|
||||
if !has_enabled {
|
||||
if let Ok(content) = std::fs::read_to_string(&target_path) {
|
||||
if !content.trim().is_empty() {
|
||||
// 检查是否已存在相同内容的提示词,避免重复备份
|
||||
let content_exists = prompts.values().any(|p| p.content.trim() == 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,
|
||||
description: Some("自动备份的原始提示词".to_string()),
|
||||
enabled: false,
|
||||
created_at: Some(timestamp),
|
||||
updated_at: Some(timestamp),
|
||||
};
|
||||
prompts.insert(backup_id, backup_prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn get_prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
let base_dir = match app {
|
||||
AppType::Claude => crate::config::get_claude_settings_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户目录")
|
||||
.join(".claude")
|
||||
}),
|
||||
AppType::Codex => crate::codex_config::get_codex_auth_path()
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.expect("无法获取用户目录")
|
||||
.join(".codex")
|
||||
}),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
AppType::Claude => "CLAUDE.md",
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
}
|
||||
|
||||
pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
|
||||
let file_path = Self::get_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 = Self::get_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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user