diff --git a/package.json b/package.json index 881d51f..d5d5d03 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed7d06b..14f6983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@codemirror/lang-json': specifier: ^6.0.2 version: 6.0.2 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 '@codemirror/lint': specifier: ^6.8.5 version: 6.8.5 @@ -280,12 +283,21 @@ packages: '@codemirror/commands@6.8.1': resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==} + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + '@codemirror/lang-javascript@6.2.4': resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + '@codemirror/language@6.11.3': resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} @@ -573,9 +585,15 @@ packages: '@lezer/common@1.2.3': resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==} + '@lezer/css@1.3.0': + resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==} + '@lezer/highlight@1.2.1': resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} + '@lezer/html@1.3.12': + resolution: {integrity: sha512-RJ7eRWdaJe3bsiiLLHjCFT1JMk8m1YP9kaUbvu2rMLEoOnke9mcTVDyfOslsln0LtujdWespjJ39w6zo+RsQYw==} + '@lezer/javascript@1.5.4': resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} @@ -585,6 +603,9 @@ packages: '@lezer/lr@1.4.2': resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} + '@lezer/markdown@1.6.0': + resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -2468,6 +2489,26 @@ snapshots: '@codemirror/view': 6.38.2 '@lezer/common': 1.2.3 + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.18.7 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@lezer/common': 1.2.3 + '@lezer/css': 1.3.0 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.18.7 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.2 + '@lezer/common': 1.2.3 + '@lezer/css': 1.3.0 + '@lezer/html': 1.3.12 + '@codemirror/lang-javascript@6.2.4': dependencies: '@codemirror/autocomplete': 6.18.7 @@ -2483,6 +2524,16 @@ snapshots: '@codemirror/language': 6.11.3 '@lezer/json': 1.0.3 + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.18.7 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.11.3 + '@codemirror/state': 6.5.2 + '@codemirror/view': 6.38.2 + '@lezer/common': 1.2.3 + '@lezer/markdown': 1.6.0 + '@codemirror/language@6.11.3': dependencies: '@codemirror/state': 6.5.2 @@ -2713,10 +2764,22 @@ snapshots: '@lezer/common@1.2.3': {} + '@lezer/css@1.3.0': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + '@lezer/highlight@1.2.1': dependencies: '@lezer/common': 1.2.3 + '@lezer/html@1.3.12': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 + '@lezer/javascript@1.5.4': dependencies: '@lezer/common': 1.2.3 @@ -2733,6 +2796,11 @@ snapshots: dependencies: '@lezer/common': 1.2.3 + '@lezer/markdown@1.6.0': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.1 + '@marijn/find-cluster-break@1.0.2': {} '@mswjs/interceptors@0.40.0': diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 185184d..b6c8c3d 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -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, +} + +/// 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(), } } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 170dc7e..90d7516 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/prompt.rs b/src-tauri/src/commands/prompt.rs new file mode 100644 index 0000000..c44bb03 --- /dev/null +++ b/src-tauri/src/commands/prompt.rs @@ -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, 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 { + 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, 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()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acb3e49..d55cc96 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/prompt.rs b/src-tauri/src/prompt.rs new file mode 100644 index 0000000..ce9b8b4 --- /dev/null +++ b/src-tauri/src/prompt.rs @@ -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, + #[serde(default)] + pub enabled: bool, + #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 9efe214..a715aea 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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}; diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs new file mode 100644 index 0000000..942163d --- /dev/null +++ b/src-tauri/src/services/prompt.rs @@ -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, 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 { + 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 { + 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, 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)) + } +} diff --git a/src/App.tsx b/src/App.tsx index 7bc4ad1..eca4c9b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; import McpPanel from "@/components/mcp/McpPanel"; +import PromptPanel from "@/components/prompts/PromptPanel"; import { Button } from "@/components/ui/button"; function App() { @@ -31,6 +32,7 @@ function App() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isAddOpen, setIsAddOpen] = useState(false); const [isMcpOpen, setIsMcpOpen] = useState(false); + const [isPromptOpen, setIsPromptOpen] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); @@ -202,6 +204,13 @@ function App() {
+ + + + + + ); +}; + +export default PromptFormModal; diff --git a/src/components/prompts/PromptListItem.tsx b/src/components/prompts/PromptListItem.tsx new file mode 100644 index 0000000..f80a178 --- /dev/null +++ b/src/components/prompts/PromptListItem.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Edit3, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { Prompt } from "@/lib/api"; +import PromptToggle from "./PromptToggle"; + +interface PromptListItemProps { + id: string; + prompt: Prompt; + onToggle: (id: string, enabled: boolean) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +} + +const PromptListItem: React.FC = ({ + id, + prompt, + onToggle, + onEdit, + onDelete, +}) => { + const { t } = useTranslation(); + + const enabled = prompt.enabled === true; + + return ( +
+
+ {/* Toggle 开关 */} +
+ onToggle(id, newEnabled)} + /> +
+ +
+

+ {prompt.name} +

+ {prompt.description && ( +

+ {prompt.description} +

+ )} +
+ +
+ + +
+
+
+ ); +}; + +export default PromptListItem; diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx new file mode 100644 index 0000000..52590f7 --- /dev/null +++ b/src/components/prompts/PromptPanel.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, FileText, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { type AppId } from "@/lib/api"; +import { usePromptActions } from "@/hooks/usePromptActions"; +import PromptListItem from "./PromptListItem"; +import PromptFormModal from "./PromptFormModal"; +import { ConfirmDialog } from "../ConfirmDialog"; + +interface PromptPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + appId: AppId; +} + +const PromptPanel: React.FC = ({ + open, + onOpenChange, + appId, +}) => { + const { t } = useTranslation(); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + titleKey: string; + messageKey: string; + messageParams?: Record; + onConfirm: () => void; + } | null>(null); + + const { prompts, loading, reload, savePrompt, deletePrompt, toggleEnabled } = + usePromptActions(appId); + + useEffect(() => { + if (open) reload(); + }, [open, reload]); + + const handleAdd = () => { + setEditingId(null); + setIsFormOpen(true); + }; + + const handleEdit = (id: string) => { + setEditingId(id); + setIsFormOpen(true); + }; + + const handleDelete = (id: string) => { + const prompt = prompts[id]; + setConfirmDialog({ + isOpen: true, + titleKey: "prompts.confirm.deleteTitle", + messageKey: "prompts.confirm.deleteMessage", + messageParams: { name: prompt?.name }, + onConfirm: async () => { + try { + await deletePrompt(id); + setConfirmDialog(null); + } catch (e) { + // Error handled by hook + } + }, + }); + }; + + const promptEntries = useMemo(() => Object.entries(prompts), [prompts]); + + const enabledPrompt = promptEntries.find(([_, p]) => p.enabled); + + const appName = t(`apps.${appId}`); + const panelTitle = t("prompts.title", { appName }); + + return ( + <> + + + +
+ {panelTitle} + +
+
+ +
+
+ {t("prompts.count", { count: promptEntries.length })} ·{" "} + {enabledPrompt + ? t("prompts.enabledName", { name: enabledPrompt[1].name }) + : t("prompts.noneEnabled")} +
+
+ +
+ {loading ? ( +
+ {t("prompts.loading")} +
+ ) : promptEntries.length === 0 ? ( +
+
+ +
+

+ {t("prompts.empty")} +

+

+ {t("prompts.emptyDescription")} +

+
+ ) : ( +
+ {promptEntries.map(([id, prompt]) => ( + + ))} +
+ )} +
+ + + + +
+
+ + {isFormOpen && ( + setIsFormOpen(false)} + /> + )} + + {confirmDialog && ( + setConfirmDialog(null)} + /> + )} + + ); +}; + +export default PromptPanel; diff --git a/src/components/prompts/PromptToggle.tsx b/src/components/prompts/PromptToggle.tsx new file mode 100644 index 0000000..aae96a7 --- /dev/null +++ b/src/components/prompts/PromptToggle.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +interface PromptToggleProps { + enabled: boolean; + onChange: (enabled: boolean) => void; + disabled?: boolean; +} + +/** + * Toggle 开关组件(提示词专用) + * 启用时为蓝色,禁用时为灰色 + */ +const PromptToggle: React.FC = ({ + enabled, + onChange, + disabled = false, +}) => { + return ( + + ); +}; + +export default PromptToggle; diff --git a/src/hooks/usePromptActions.ts b/src/hooks/usePromptActions.ts new file mode 100644 index 0000000..fc6e4ec --- /dev/null +++ b/src/hooks/usePromptActions.ts @@ -0,0 +1,152 @@ +import { useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { promptsApi, type Prompt, type AppId } from "@/lib/api"; + +export function usePromptActions(appId: AppId) { + const { t } = useTranslation(); + const [prompts, setPrompts] = useState>({}); + const [loading, setLoading] = useState(false); + const [currentFileContent, setCurrentFileContent] = useState( + null, + ); + + const reload = useCallback(async () => { + setLoading(true); + try { + const data = await promptsApi.getPrompts(appId); + setPrompts(data); + + // 同时加载当前文件内容 + try { + const content = await promptsApi.getCurrentFileContent(appId); + setCurrentFileContent(content); + } catch (error) { + setCurrentFileContent(null); + } + } catch (error) { + toast.error(t("prompts.loadFailed")); + } finally { + setLoading(false); + } + }, [appId, t]); + + const savePrompt = useCallback( + async (id: string, prompt: Prompt) => { + try { + await promptsApi.upsertPrompt(appId, id, prompt); + await reload(); + toast.success(t("prompts.saveSuccess")); + } catch (error) { + toast.error(t("prompts.saveFailed")); + throw error; + } + }, + [appId, reload, t], + ); + + const deletePrompt = useCallback( + async (id: string) => { + try { + await promptsApi.deletePrompt(appId, id); + await reload(); + toast.success(t("prompts.deleteSuccess")); + } catch (error) { + toast.error(t("prompts.deleteFailed")); + throw error; + } + }, + [appId, reload, t], + ); + + const enablePrompt = useCallback( + async (id: string) => { + try { + await promptsApi.enablePrompt(appId, id); + await reload(); + toast.success(t("prompts.enableSuccess")); + } catch (error) { + toast.error(t("prompts.enableFailed")); + throw error; + } + }, + [appId, reload, t], + ); + + const toggleEnabled = useCallback( + async (id: string, enabled: boolean) => { + // Optimistic update + const previousPrompts = prompts; + + // 如果要启用当前提示词,先禁用其他所有提示词 + if (enabled) { + const updatedPrompts = Object.keys(prompts).reduce( + (acc, key) => { + acc[key] = { + ...prompts[key], + enabled: key === id, + }; + return acc; + }, + {} as Record, + ); + setPrompts(updatedPrompts); + } else { + setPrompts((prev) => ({ + ...prev, + [id]: { + ...prev[id], + enabled: false, + }, + })); + } + + try { + if (enabled) { + await promptsApi.enablePrompt(appId, id); + toast.success(t("prompts.enableSuccess")); + } else { + // 禁用提示词 - 需要后端支持 + await promptsApi.upsertPrompt(appId, id, { + ...prompts[id], + enabled: false, + }); + toast.success(t("prompts.disableSuccess")); + } + await reload(); + } catch (error) { + // Rollback on failure + setPrompts(previousPrompts); + toast.error( + enabled ? t("prompts.enableFailed") : t("prompts.disableFailed"), + ); + throw error; + } + }, + [appId, prompts, reload, t], + ); + + const importFromFile = useCallback(async () => { + try { + const id = await promptsApi.importFromFile(appId); + await reload(); + toast.success(t("prompts.importSuccess")); + return id; + } catch (error) { + toast.error(t("prompts.importFailed")); + throw error; + } + }, [appId, reload, t]); + + return { + prompts, + loading, + currentFileContent, + reload, + savePrompt, + deletePrompt, + enablePrompt, + toggleEnabled, + importFromFile, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 65514f1..f73d9d0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -550,5 +550,46 @@ "description": "Context7 documentation search tool providing latest library docs and code examples, with higher limits when configured with a key" } } + }, + "prompts": { + "manage": "Prompts", + "title": "{{appName}} Prompt Management", + "claudeTitle": "Claude Prompt Management", + "codexTitle": "Codex Prompt Management", + "add": "Add Prompt", + "edit": "Edit Prompt", + "addTitle": "Add {{appName}} Prompt", + "editTitle": "Edit {{appName}} Prompt", + "import": "Import Existing", + "count": "{{count}} prompts", + "enabled": "Enabled", + "enable": "Enable", + "enabledName": "Enabled: {{name}}", + "noneEnabled": "No prompt enabled", + "currentFile": "Current {{filename}} Content", + "empty": "No prompts yet", + "emptyDescription": "Click the button above to add or import prompts", + "loading": "Loading...", + "name": "Name", + "namePlaceholder": "e.g., Default Project Prompt", + "description": "Description", + "descriptionPlaceholder": "Optional description", + "content": "Content", + "contentPlaceholder": "# {{filename}}\n\nEnter prompt content here...", + "loadFailed": "Failed to load prompts", + "saveSuccess": "Saved successfully", + "saveFailed": "Failed to save", + "deleteSuccess": "Deleted successfully", + "deleteFailed": "Failed to delete", + "enableSuccess": "Enabled successfully", + "enableFailed": "Failed to enable", + "disableSuccess": "Disabled successfully", + "disableFailed": "Failed to disable", + "importSuccess": "Imported successfully", + "importFailed": "Failed to import", + "confirm": { + "deleteTitle": "Confirm Delete", + "deleteMessage": "Are you sure you want to delete prompt \"{{name}}\"?" + } } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f618658..967234b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -550,5 +550,46 @@ "description": "Context7 文档搜索工具,提供最新的库文档和代码示例,配置 key 会有更高限额" } } + }, + "prompts": { + "manage": "提示词", + "title": "{{appName}} 提示词管理", + "claudeTitle": "Claude 提示词管理", + "codexTitle": "Codex 提示词管理", + "add": "添加提示词", + "edit": "编辑提示词", + "addTitle": "添加 {{appName}} 提示词", + "editTitle": "编辑 {{appName}} 提示词", + "import": "导入现有", + "count": "共 {{count}} 个提示词", + "enabled": "已启用", + "enable": "启用", + "enabledName": "已启用: {{name}}", + "noneEnabled": "未启用任何提示词", + "currentFile": "当前 {{filename}} 内容", + "empty": "暂无提示词", + "emptyDescription": "点击右上角按钮添加或导入提示词", + "loading": "加载中...", + "name": "名称", + "namePlaceholder": "例如:项目默认提示词", + "description": "描述", + "descriptionPlaceholder": "可选的描述信息", + "content": "内容", + "contentPlaceholder": "# {{filename}}\n\n在此输入提示词内容...", + "loadFailed": "加载提示词失败", + "saveSuccess": "保存成功", + "saveFailed": "保存失败", + "deleteSuccess": "删除成功", + "deleteFailed": "删除失败", + "enableSuccess": "启用成功", + "enableFailed": "启用失败", + "disableSuccess": "禁用成功", + "disableFailed": "禁用失败", + "importSuccess": "导入成功", + "importFailed": "导入失败", + "confirm": { + "deleteTitle": "确认删除", + "deleteMessage": "确定要删除提示词 \"{{name}}\" 吗?" + } } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index b10787f..618cd9d 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -2,6 +2,8 @@ export type { AppId } from "./types"; export { providersApi } from "./providers"; export { settingsApi } from "./settings"; export { mcpApi } from "./mcp"; +export { promptsApi } from "./prompts"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; export type { ProviderSwitchEvent } from "./providers"; +export type { Prompt } from "./prompts"; diff --git a/src/lib/api/prompts.ts b/src/lib/api/prompts.ts new file mode 100644 index 0000000..52df5a9 --- /dev/null +++ b/src/lib/api/prompts.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { AppId } from "./types"; + +export interface Prompt { + id: string; + name: string; + content: string; + description?: string; + enabled: boolean; + createdAt?: number; + updatedAt?: number; +} + +export const promptsApi = { + async getPrompts(app: AppId): Promise> { + return await invoke("get_prompts", { app }); + }, + + async upsertPrompt(app: AppId, id: string, prompt: Prompt): Promise { + return await invoke("upsert_prompt", { app, id, prompt }); + }, + + async deletePrompt(app: AppId, id: string): Promise { + return await invoke("delete_prompt", { app, id }); + }, + + async enablePrompt(app: AppId, id: string): Promise { + return await invoke("enable_prompt", { app, id }); + }, + + async importFromFile(app: AppId): Promise { + return await invoke("import_prompt_from_file", { app }); + }, + + async getCurrentFileContent(app: AppId): Promise { + return await invoke("get_current_prompt_file_content", { app }); + }, +};