From 7021ab6becfc9f8710cdc36f7102c8e4de85b41f Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Tue, 21 Oct 2025 15:08:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8F=90=E7=A4=BA=E8=AF=8D?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/agents.rs | 3 + src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/prompt_files.rs | 515 +++++++++++++++++++ src-tauri/src/commands/prompt_files_v2.rs | 266 ++++++++++ src-tauri/src/main.rs | 18 + src/App.tsx | 10 + src/components/PromptFileEditor.tsx | 237 +++++++++ src/components/PromptFilePreview.tsx | 111 ++++ src/components/PromptFilesManager.tsx | 589 ++++++++++++++++++++++ src/components/Settings.tsx | 11 +- src/components/Topbar.tsx | 19 +- src/components/WelcomePage.tsx | 8 +- src/components/index.ts | 3 + src/lib/api.ts | 200 ++++++++ src/locales/en/common.json | 49 ++ src/locales/zh/common.json | 49 ++ src/stores/promptFilesStore.ts | 202 ++++++++ 17 files changed, 2286 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/commands/prompt_files.rs create mode 100644 src-tauri/src/commands/prompt_files_v2.rs create mode 100644 src/components/PromptFileEditor.tsx create mode 100644 src/components/PromptFilePreview.tsx create mode 100644 src/components/PromptFilesManager.tsx create mode 100644 src/stores/promptFilesStore.ts diff --git a/src-tauri/src/commands/agents.rs b/src-tauri/src/commands/agents.rs index 3bdbb47..67fa389 100644 --- a/src-tauri/src/commands/agents.rs +++ b/src-tauri/src/commands/agents.rs @@ -373,6 +373,9 @@ pub fn init_database(app: &AppHandle) -> SqliteResult { )?; } + // Initialize prompt files tables + crate::commands::prompt_files::init_prompt_files_tables(&conn)?; + Ok(conn) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b97e878..36ffc0b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,8 @@ pub mod git; pub mod language; pub mod mcp; pub mod packycode_nodes; +pub mod prompt_files; +pub mod prompt_files_v2; pub mod proxy; pub mod relay_adapters; pub mod relay_stations; diff --git a/src-tauri/src/commands/prompt_files.rs b/src-tauri/src/commands/prompt_files.rs new file mode 100644 index 0000000..7d2f4fa --- /dev/null +++ b/src-tauri/src/commands/prompt_files.rs @@ -0,0 +1,515 @@ +use chrono::Utc; +use dirs; +use log::{error, info}; +use rusqlite::{params, Connection, Result as SqliteResult, Row}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::{command, State}; +use uuid::Uuid; + +use crate::commands::agents::AgentDb; + +/// 提示词文件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptFile { + pub id: String, + pub name: String, + pub description: Option, + pub content: String, + pub tags: Vec, + pub is_active: bool, + pub created_at: i64, + pub updated_at: i64, + pub last_used_at: Option, + pub display_order: i32, +} + +/// 创建提示词文件请求 +#[derive(Debug, Serialize, Deserialize)] +pub struct CreatePromptFileRequest { + pub name: String, + pub description: Option, + pub content: String, + pub tags: Vec, +} + +/// 更新提示词文件请求 +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdatePromptFileRequest { + pub id: String, + pub name: String, + pub description: Option, + pub content: String, + pub tags: Vec, +} + +impl PromptFile { + pub fn from_row(row: &Row) -> Result { + let tags_str: String = row.get("tags")?; + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + + Ok(PromptFile { + id: row.get("id")?, + name: row.get("name")?, + description: row.get("description")?, + content: row.get("content")?, + tags, + is_active: row.get::<_, i32>("is_active")? == 1, + created_at: row.get("created_at")?, + updated_at: row.get("updated_at")?, + last_used_at: row.get("last_used_at")?, + display_order: row.get("display_order")?, + }) + } +} + +/// 初始化提示词文件数据库表 +pub fn init_prompt_files_tables(conn: &Connection) -> SqliteResult<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS prompt_files ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + content TEXT NOT NULL, + tags TEXT NOT NULL DEFAULT '[]', + is_active INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_used_at INTEGER, + display_order INTEGER DEFAULT 0 + )", + [], + )?; + + // 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_prompt_files_active ON prompt_files(is_active)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_prompt_files_name ON prompt_files(name)", + [], + )?; + + info!("Prompt files tables initialized"); + Ok(()) +} + +/// 列出所有提示词文件 +#[command] +pub async fn prompt_files_list(db: State<'_, AgentDb>) -> Result, String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let mut stmt = conn + .prepare( + "SELECT id, name, description, content, tags, is_active, created_at, updated_at, + last_used_at, display_order + FROM prompt_files + ORDER BY display_order ASC, created_at DESC", + ) + .map_err(|e| e.to_string())?; + + let files = stmt + .query_map([], |row| PromptFile::from_row(row)) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + Ok(files) +} + +/// 获取单个提示词文件 +#[command] +pub async fn prompt_file_get(id: String, db: State<'_, AgentDb>) -> Result { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let file = conn + .query_row( + "SELECT id, name, description, content, tags, is_active, created_at, updated_at, + last_used_at, display_order + FROM prompt_files + WHERE id = ?1", + params![id], + |row| PromptFile::from_row(row), + ) + .map_err(|e| format!("提示词文件不存在: {}", e))?; + + Ok(file) +} + +/// 创建提示词文件 +#[command] +pub async fn prompt_file_create( + request: CreatePromptFileRequest, + db: State<'_, AgentDb>, +) -> Result { + info!("Creating prompt file: {}", request.name); + + let id = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // 检查名称是否已存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM prompt_files WHERE name = ?1", + params![request.name], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + + if exists { + return Err(format!("提示词文件名称已存在: {}", request.name)); + } + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp(); + let tags_json = serde_json::to_string(&request.tags).unwrap_or_else(|_| "[]".to_string()); + + // 获取当前最大 display_order + let max_order: i32 = conn + .query_row( + "SELECT COALESCE(MAX(display_order), 0) FROM prompt_files", + [], + |row| row.get(0), + ) + .unwrap_or(0); + + conn.execute( + "INSERT INTO prompt_files + (id, name, description, content, tags, is_active, created_at, updated_at, display_order) + VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6, ?7)", + params![ + id.clone(), + request.name, + request.description, + request.content, + tags_json, + now, + max_order + 1 + ], + ) + .map_err(|e| format!("创建提示词文件失败: {}", e))?; + + id + }; // conn is dropped here + + prompt_file_get(id, db).await +} + +/// 更新提示词文件 +#[command] +pub async fn prompt_file_update( + request: UpdatePromptFileRequest, + db: State<'_, AgentDb>, +) -> Result { + info!("Updating prompt file: {}", request.id); + + let id = { + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // 检查文件是否存在 + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM prompt_files WHERE id = ?1", + params![request.id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + + if !exists { + return Err("提示词文件不存在".to_string()); + } + + // 检查名称冲突(排除自己) + let name_conflict: bool = conn + .query_row( + "SELECT COUNT(*) FROM prompt_files WHERE name = ?1 AND id != ?2", + params![request.name, request.id], + |row| { + let count: i32 = row.get(0)?; + Ok(count > 0) + }, + ) + .map_err(|e| e.to_string())?; + + if name_conflict { + return Err(format!("提示词文件名称已存在: {}", request.name)); + } + + let now = Utc::now().timestamp(); + let tags_json = serde_json::to_string(&request.tags).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "UPDATE prompt_files + SET name = ?1, description = ?2, content = ?3, tags = ?4, updated_at = ?5 + WHERE id = ?6", + params![ + request.name, + request.description, + request.content, + tags_json, + now, + request.id.clone() + ], + ) + .map_err(|e| format!("更新提示词文件失败: {}", e))?; + + request.id + }; // conn is dropped here + + prompt_file_get(id, db).await +} + +/// 删除提示词文件 +#[command] +pub async fn prompt_file_delete(id: String, db: State<'_, AgentDb>) -> Result<(), String> { + info!("Deleting prompt file: {}", id); + + let conn = db.0.lock().map_err(|e| e.to_string())?; + + let deleted = conn + .execute("DELETE FROM prompt_files WHERE id = ?1", params![id]) + .map_err(|e| format!("删除提示词文件失败: {}", e))?; + + if deleted == 0 { + return Err("提示词文件不存在".to_string()); + } + + Ok(()) +} + +/// 获取 Claude 配置目录路径(~/.claude) +fn get_claude_config_dir() -> Result { + let home_dir = dirs::home_dir() + .ok_or_else(|| "无法获取主目录".to_string())?; + + let claude_dir = home_dir.join(".claude"); + + // 确保目录存在 + if !claude_dir.exists() { + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + info!("创建 Claude 配置目录: {:?}", claude_dir); + } + + Ok(claude_dir) +} + +/// 应用提示词文件(替换本地 CLAUDE.md) +#[command] +pub async fn prompt_file_apply( + id: String, + target_path: Option, + db: State<'_, AgentDb>, +) -> Result { + info!("Applying prompt file: {} to {:?}", id, target_path); + + // 1. 从数据库读取提示词文件 + let file = prompt_file_get(id.clone(), db.clone()).await?; + + // 2. 确定目标路径 + let claude_md_path = if let Some(path) = target_path { + PathBuf::from(path).join("CLAUDE.md") + } else { + // 默认使用 ~/.claude/CLAUDE.md(和 settings.json 同目录) + get_claude_config_dir()?.join("CLAUDE.md") + }; + + // 3. 备份现有文件(如果存在)- 使用时间戳避免触发文件监视 + if claude_md_path.exists() { + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_path = claude_md_path.with_file_name(format!("CLAUDE.md.backup.{}", timestamp)); + fs::copy(&claude_md_path, &backup_path) + .map_err(|e| format!("备份文件失败: {}", e))?; + info!("Backed up existing CLAUDE.md to {:?}", backup_path); + } + + // 4. 写入新内容 + fs::write(&claude_md_path, &file.content) + .map_err(|e| format!("写入文件失败: {}", e))?; + + // 5. 更新数据库状态 + let conn = db.0.lock().map_err(|e| e.to_string())?; + + // 将所有文件的 is_active 设为 0 + conn.execute("UPDATE prompt_files SET is_active = 0", []) + .map_err(|e| format!("更新激活状态失败: {}", e))?; + + // 将当前文件设为激活并更新最后使用时间 + let now = Utc::now().timestamp(); + conn.execute( + "UPDATE prompt_files SET is_active = 1, last_used_at = ?1 WHERE id = ?2", + params![now, id], + ) + .map_err(|e| format!("更新激活状态失败: {}", e))?; + + info!("Applied prompt file to {:?}", claude_md_path); + Ok(claude_md_path.to_string_lossy().to_string()) +} + +/// 取消使用当前提示词文件 +#[command] +pub async fn prompt_file_deactivate(db: State<'_, AgentDb>) -> Result<(), String> { + info!("Deactivating all prompt files"); + + let conn = db.0.lock().map_err(|e| e.to_string())?; + + conn.execute("UPDATE prompt_files SET is_active = 0", []) + .map_err(|e| format!("取消激活失败: {}", e))?; + + Ok(()) +} + +/// 从当前 CLAUDE.md 导入 +#[command] +pub async fn prompt_file_import_from_claude_md( + name: String, + description: Option, + source_path: Option, + db: State<'_, AgentDb>, +) -> Result { + info!("Importing from CLAUDE.md: {:?}", source_path); + + // 1. 确定源文件路径 + let claude_md_path = if let Some(path) = source_path { + PathBuf::from(path) + } else { + // 默认从 ~/.claude/CLAUDE.md 导入 + get_claude_config_dir()?.join("CLAUDE.md") + }; + + // 2. 读取文件内容 + if !claude_md_path.exists() { + return Err("CLAUDE.md 文件不存在".to_string()); + } + + let content = fs::read_to_string(&claude_md_path) + .map_err(|e| format!("读取文件失败: {}", e))?; + + // 3. 自动提取标签(简单实现:从内容中提取关键词) + let tags = extract_tags_from_content(&content); + + // 4. 创建提示词文件 + let request = CreatePromptFileRequest { + name, + description, + content, + tags, + }; + + prompt_file_create(request, db).await +} + +/// 从内容中提取标签(简单实现) +fn extract_tags_from_content(content: &str) -> Vec { + let mut tags = Vec::new(); + let content_lower = content.to_lowercase(); + + // 常见技术栈关键词 + let keywords = [ + "react", + "vue", + "angular", + "typescript", + "javascript", + "node", + "nodejs", + "express", + "nest", + "python", + "django", + "flask", + "rust", + "go", + "java", + "spring", + "frontend", + "backend", + "fullstack", + "api", + "rest", + "graphql", + "database", + "mongodb", + "postgresql", + "mysql", + "redis", + "docker", + "kubernetes", + "aws", + "testing", + "jest", + "vitest", + ]; + + for keyword in keywords.iter() { + if content_lower.contains(keyword) { + tags.push(keyword.to_string()); + } + } + + // 限制标签数量 + tags.truncate(10); + tags +} + +/// 导出提示词文件 +#[command] +pub async fn prompt_file_export( + id: String, + export_path: String, + db: State<'_, AgentDb>, +) -> Result<(), String> { + info!("Exporting prompt file {} to {}", id, export_path); + + let file = prompt_file_get(id, db).await?; + + fs::write(&export_path, &file.content) + .map_err(|e| format!("导出文件失败: {}", e))?; + + Ok(()) +} + +/// 更新提示词文件排序 +#[command] +pub async fn prompt_files_update_order( + ids: Vec, + db: State<'_, AgentDb>, +) -> Result<(), String> { + info!("Updating prompt files order"); + + let conn = db.0.lock().map_err(|e| e.to_string())?; + + for (index, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE prompt_files SET display_order = ?1 WHERE id = ?2", + params![index as i32, id], + ) + .map_err(|e| format!("更新排序失败: {}", e))?; + } + + Ok(()) +} + +/// 批量导入提示词文件 +#[command] +pub async fn prompt_files_import_batch( + files: Vec, + db: State<'_, AgentDb>, +) -> Result, String> { + info!("Batch importing {} prompt files", files.len()); + + let mut imported = Vec::new(); + + for request in files { + match prompt_file_create(request, db.clone()).await { + Ok(file) => imported.push(file), + Err(e) => error!("Failed to import file: {}", e), + } + } + + Ok(imported) +} + diff --git a/src-tauri/src/commands/prompt_files_v2.rs b/src-tauri/src/commands/prompt_files_v2.rs new file mode 100644 index 0000000..e5da746 --- /dev/null +++ b/src-tauri/src/commands/prompt_files_v2.rs @@ -0,0 +1,266 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use tauri::command; +use log::{info, warn}; + +/// 提示词文件信息(从文件系统读取) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptFileInfo { + pub project_id: String, // 项目 ID(来自 projects 目录名) + pub project_path: String, // 实际项目路径 + pub has_claude_md: bool, // 是否存在 .claude/CLAUDE.md + pub content: Option, // CLAUDE.md 文件内容 + pub file_size: Option, // 文件大小(字节) + pub modified_at: Option, // 最后修改时间 + pub claude_md_path: String, // .claude/CLAUDE.md 完整路径 +} + +/// 获取 Claude 目录 +fn get_claude_dir() -> Result { + dirs::home_dir() + .map(|p| p.join(".claude")) + .ok_or_else(|| "无法获取 home 目录".to_string()) +} + +/// 从项目名解码实际路径 +fn decode_project_path(encoded_name: &str) -> String { + // 简单的路径解码 - 将编码的斜杠 (%2F 或 -) 转回斜杠 + encoded_name.replace("%2F", "/").replace("-", "/") +} + +/// 从会话文件中获取项目路径 +fn get_project_path_from_sessions(project_dir: &PathBuf) -> Result { + let entries = fs::read_dir(project_dir) + .map_err(|e| format!("无法读取项目目录: {}", e))?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + // 读取 JSONL 文件的第一行 + if let Ok(content) = fs::read_to_string(&path) { + if let Some(first_line) = content.lines().next() { + if let Ok(json) = serde_json::from_str::(first_line) { + if let Some(cwd) = json.get("cwd").and_then(|v| v.as_str()) { + return Ok(cwd.to_string()); + } + } + } + } + } + } + + Err("未找到项目路径".to_string()) +} + +/// 扫描所有项目的提示词文件 +#[command] +pub async fn scan_prompt_files() -> Result, String> { + info!("扫描项目提示词文件"); + + let claude_dir = get_claude_dir()?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + warn!("项目目录不存在: {:?}", projects_dir); + return Ok(Vec::new()); + } + + let mut prompt_files = Vec::new(); + + // 读取所有项目目录 + let entries = fs::read_dir(&projects_dir) + .map_err(|e| format!("无法读取项目目录: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("无法读取目录条目: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + let project_id = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "无效的目录名".to_string())? + .to_string(); + + // 获取实际项目路径 + let project_path = match get_project_path_from_sessions(&path) { + Ok(p) => p.clone(), + Err(_) => { + warn!("无法从会话获取项目路径,使用解码: {}", project_id); + decode_project_path(&project_id) + } + }; + + // 检查 .claude/CLAUDE.md 是否存在 + let claude_md_path = PathBuf::from(&project_path).join(".claude").join("CLAUDE.md"); + let has_claude_md = claude_md_path.exists(); + + let (content, file_size, modified_at) = if has_claude_md { + // 读取文件内容 + let content = fs::read_to_string(&claude_md_path) + .ok(); + + // 获取文件元数据 + let metadata = fs::metadata(&claude_md_path).ok(); + let file_size = metadata.as_ref().map(|m| m.len()); + let modified_at = metadata + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + + (content, file_size, modified_at) + } else { + (None, None, None) + }; + + prompt_files.push(PromptFileInfo { + project_id, + project_path: project_path.clone(), + has_claude_md, + content, + file_size, + modified_at, + claude_md_path: claude_md_path.to_string_lossy().to_string(), + }); + } + } + + // 按最后修改时间排序(最新的在前) + prompt_files.sort_by(|a, b| { + match (b.modified_at, a.modified_at) { + (Some(b_time), Some(a_time)) => b_time.cmp(&a_time), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.project_path.cmp(&b.project_path), + } + }); + + info!("找到 {} 个项目,其中 {} 个有 CLAUDE.md", + prompt_files.len(), + prompt_files.iter().filter(|p| p.has_claude_md).count() + ); + + Ok(prompt_files) +} + +/// 读取指定项目的 CLAUDE.md 文件 +#[command] +pub async fn read_prompt_file(project_path: String) -> Result { + info!("读取提示词文件: {}", project_path); + + let claude_md_path = PathBuf::from(&project_path).join(".claude").join("CLAUDE.md"); + + if !claude_md_path.exists() { + return Err(format!("文件不存在: {:?}", claude_md_path)); + } + + fs::read_to_string(&claude_md_path) + .map_err(|e| format!("读取文件失败: {}", e)) +} + +/// 保存 CLAUDE.md 文件 +#[command] +pub async fn save_prompt_file(project_path: String, content: String) -> Result<(), String> { + info!("保存提示词文件: {}", project_path); + + let claude_dir = PathBuf::from(&project_path).join(".claude"); + let claude_md_path = claude_dir.join("CLAUDE.md"); + + // 确保 .claude 目录存在 + if !claude_dir.exists() { + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + info!("创建 .claude 目录: {:?}", claude_dir); + } + + // 备份现有文件 + if claude_md_path.exists() { + let backup_path = claude_md_path.with_extension("md.backup"); + fs::copy(&claude_md_path, &backup_path) + .map_err(|e| format!("备份文件失败: {}", e))?; + info!("备份现有文件到: {:?}", backup_path); + } + + // 写入新内容 + fs::write(&claude_md_path, content) + .map_err(|e| format!("写入文件失败: {}", e))?; + + info!("成功保存文件: {:?}", claude_md_path); + Ok(()) +} + +/// 创建新的 CLAUDE.md 文件 +#[command] +pub async fn create_prompt_file(project_path: String, content: String) -> Result<(), String> { + info!("创建提示词文件: {}", project_path); + + let claude_dir = PathBuf::from(&project_path).join(".claude"); + let claude_md_path = claude_dir.join("CLAUDE.md"); + + // 检查文件是否已存在 + if claude_md_path.exists() { + return Err("CLAUDE.md 文件已存在,请使用编辑功能".to_string()); + } + + // 确保 .claude 目录存在 + if !claude_dir.exists() { + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("创建 .claude 目录失败: {}", e))?; + info!("创建 .claude 目录: {:?}", claude_dir); + } + + // 写入内容 + fs::write(&claude_md_path, content) + .map_err(|e| format!("写入文件失败: {}", e))?; + + info!("成功创建文件: {:?}", claude_md_path); + Ok(()) +} + +/// 删除 CLAUDE.md 文件 +#[command] +pub async fn delete_prompt_file(project_path: String) -> Result<(), String> { + info!("删除提示词文件: {}", project_path); + + let claude_md_path = PathBuf::from(&project_path).join(".claude").join("CLAUDE.md"); + + if !claude_md_path.exists() { + return Err("文件不存在".to_string()); + } + + // 备份到 .backup + let backup_path = claude_md_path.with_extension("md.backup"); + fs::copy(&claude_md_path, &backup_path) + .map_err(|e| format!("备份文件失败: {}", e))?; + + // 删除文件 + fs::remove_file(&claude_md_path) + .map_err(|e| format!("删除文件失败: {}", e))?; + + info!("成功删除文件(已备份到 {:?})", backup_path); + Ok(()) +} + +/// 复制 CLAUDE.md 到另一个项目 +#[command] +pub async fn copy_prompt_file( + source_project_path: String, + target_project_path: String, +) -> Result<(), String> { + info!("复制提示词文件: {} -> {}", source_project_path, target_project_path); + + let source_path = PathBuf::from(&source_project_path).join(".claude").join("CLAUDE.md"); + if !source_path.exists() { + return Err("源文件不存在".to_string()); + } + + // 读取源文件 + let content = fs::read_to_string(&source_path) + .map_err(|e| format!("读取源文件失败: {}", e))?; + + // 保存到目标路径 + save_prompt_file(target_project_path, content).await +} + diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f4eef6e..5f85ee4 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -43,6 +43,12 @@ use commands::ccr::{ check_ccr_installation, get_ccr_config_path, get_ccr_service_status, get_ccr_version, open_ccr_ui, restart_ccr_service, start_ccr_service, stop_ccr_service, }; +use commands::prompt_files::{ + prompt_file_apply, prompt_file_create, prompt_file_deactivate, prompt_file_delete, + prompt_file_export, prompt_file_get, prompt_file_import_from_claude_md, + prompt_file_update, prompt_files_import_batch, prompt_files_list, + prompt_files_update_order, +}; use commands::filesystem::{ get_file_info, get_file_tree, get_watched_paths, read_directory_tree, read_file, search_files_by_name, unwatch_directory, watch_directory, write_file, @@ -446,6 +452,18 @@ fn main() { commands::slash_commands::slash_command_get, commands::slash_commands::slash_command_save, commands::slash_commands::slash_command_delete, + // Prompt Files Management (Database Based) + prompt_files_list, + prompt_file_get, + prompt_file_create, + prompt_file_update, + prompt_file_delete, + prompt_file_apply, + prompt_file_deactivate, + prompt_file_import_from_claude_md, + prompt_file_export, + prompt_files_update_order, + prompt_files_import_batch, // Proxy Settings get_proxy_settings, save_proxy_settings, diff --git a/src/App.tsx b/src/App.tsx index 401dd50..d10c562 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { useTranslation } from "@/hooks/useTranslation"; import { WelcomePage } from "@/components/WelcomePage"; import RelayStationManager from "@/components/RelayStationManager"; import { CcrRouterManager } from "@/components/CcrRouterManager"; +import { PromptFilesManager } from "@/components"; import i18n from "@/lib/i18n"; // Lazy load these components to match TabContent's dynamic imports @@ -48,6 +49,7 @@ type View = | "mcp" | "relay-stations" | "ccr-router" + | "prompt-files" | "usage-dashboard" | "project-settings" | "tabs"; // New view for tab-based interface @@ -464,6 +466,13 @@ function AppContent() { ); + case "prompt-files": + return ( +
+ handleViewChange("welcome")} /> +
+ ); + case "settings": return (
@@ -655,6 +664,7 @@ function AppContent() { onMCPClick={() => view === 'tabs' ? createMCPTab() : handleViewChange('mcp')} onInfoClick={() => setShowNFO(true)} onAgentsClick={() => view === 'tabs' ? setShowAgentsModal(true) : handleViewChange('cc-agents')} + onPromptFilesClick={() => handleViewChange('prompt-files')} /> {/* Analytics Consent Banner */} diff --git a/src/components/PromptFileEditor.tsx b/src/components/PromptFileEditor.tsx new file mode 100644 index 0000000..a22b62b --- /dev/null +++ b/src/components/PromptFileEditor.tsx @@ -0,0 +1,237 @@ +import React, { useState, useEffect } from 'react'; +import { Loader2, Save, Eye, EyeOff, X, Tag as TagIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import MonacoEditor from '@monaco-editor/react'; +import ReactMarkdown from 'react-markdown'; +import { usePromptFilesStore } from '@/stores/promptFilesStore'; +import type { PromptFile } from '@/lib/api'; + +interface PromptFileEditorProps { + open: boolean; + onOpenChange: (open: boolean) => void; + file?: PromptFile; + onSuccess: () => void; +} + +export const PromptFileEditor: React.FC = ({ + open, + onOpenChange, + file, + onSuccess, +}) => { + const { createFile, updateFile } = usePromptFilesStore(); + const [saving, setSaving] = useState(false); + const [showPreview, setShowPreview] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [content, setContent] = useState(''); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(''); + + useEffect(() => { + if (file) { + setName(file.name); + setDescription(file.description || ''); + setContent(file.content); + setTags(file.tags); + } else { + setName(''); + setDescription(''); + setContent(''); + setTags([]); + } + }, [file, open]); + + const handleAddTag = () => { + const trimmed = tagInput.trim().toLowerCase(); + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]); + setTagInput(''); + } + }; + + const handleRemoveTag = (tag: string) => { + setTags(tags.filter((t) => t !== tag)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleSave = async () => { + if (!name.trim()) return; + + setSaving(true); + try { + if (file) { + await updateFile({ + id: file.id, + name: name.trim(), + description: description.trim() || undefined, + content: content, + tags, + }); + } else { + await createFile({ + name: name.trim(), + description: description.trim() || undefined, + content: content, + tags, + }); + } + onSuccess(); + } catch (error) { + // Error handling is done in the store + } finally { + setSaving(false); + } + }; + + return ( + + + + {file ? '编辑提示词文件' : '创建提示词文件'} + + {file ? '修改提示词文件的内容和信息' : '创建一个新的提示词文件模板'} + + + +
+ {/* Basic Info */} +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + {/* Tags */} +
+ +
+ {tags.map((tag) => ( + + {tag} + handleRemoveTag(tag)} + /> + + ))} +
+
+ setTagInput(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+
+ + {/* Content Editor */} +
+
+ + +
+ + {showPreview ? ( +
+ {content} +
+ ) : ( +
+ setContent(value || '')} + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 14, + wordWrap: 'on', + lineNumbers: 'on', + automaticLayout: true, + }} + /> +
+ )} +
+
+ + + + + +
+
+ ); +}; + +export default PromptFileEditor; + diff --git a/src/components/PromptFilePreview.tsx b/src/components/PromptFilePreview.tsx new file mode 100644 index 0000000..38a112a --- /dev/null +++ b/src/components/PromptFilePreview.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Edit, Play, Tag as TagIcon, Clock, Calendar } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import ReactMarkdown from 'react-markdown'; +import type { PromptFile } from '@/lib/api'; + +interface PromptFilePreviewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + file: PromptFile; + onEdit: () => void; + onApply: () => void; +} + +export const PromptFilePreview: React.FC = ({ + open, + onOpenChange, + file, + onEdit, + onApply, +}) => { + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + {file.name} + {file.description && ( + {file.description} + )} + + + {/* Metadata */} +
+ {file.tags.length > 0 && ( +
+ +
+ {file.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} +
+ + 创建于: {formatDate(file.created_at)} +
+ {file.updated_at !== file.created_at && ( +
+ + 更新于: {formatDate(file.updated_at)} +
+ )} + {file.last_used_at && ( +
+ + 最后使用: {formatDate(file.last_used_at)} +
+ )} +
+ + {/* Content */} +
+ {file.content} +
+ + + +
+ + {!file.is_active && ( + + )} +
+
+
+
+ ); +}; + +export default PromptFilePreview; + diff --git a/src/components/PromptFilesManager.tsx b/src/components/PromptFilesManager.tsx new file mode 100644 index 0000000..b7b6acd --- /dev/null +++ b/src/components/PromptFilesManager.tsx @@ -0,0 +1,589 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + FileText, + Plus, + Search, + Upload, + ArrowLeft, + Check, + Edit, + Trash2, + Eye, + Play, + AlertCircle, + Loader2, + Tag, + Clock, + CheckCircle2, + RefreshCw, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { usePromptFilesStore } from '@/stores/promptFilesStore'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { PromptFile } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import { PromptFileEditor } from './PromptFileEditor'; +import { PromptFilePreview } from './PromptFilePreview'; + +interface PromptFilesManagerProps { + onBack?: () => void; + className?: string; +} + +export const PromptFilesManager: React.FC = ({ onBack, className }) => { + const { t } = useTranslation(); + const { + files, + isLoading, + error, + loadFiles, + deleteFile, + applyFile, + deactivateAll, + importFromClaudeMd, + clearError, + } = usePromptFilesStore(); + + const [searchQuery, setSearchQuery] = useState(''); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showPreviewDialog, setShowPreviewDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [applyingFileId, setApplyingFileId] = useState(null); + const [syncingFileId, setSyncingFileId] = useState(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + useEffect(() => { + loadFiles(); + }, [loadFiles]); + + useEffect(() => { + if (error) { + showToast(error, 'error'); + clearError(); + } + }, [error, clearError]); + + const showToast = (message: string, type: 'success' | 'error' = 'success') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }; + + const handleApply = async (file: PromptFile) => { + setApplyingFileId(file.id); + try { + const path = await applyFile(file.id); + showToast(`已应用到: ${path}`, 'success'); + } catch (error) { + showToast('应用失败', 'error'); + } finally { + setApplyingFileId(null); + } + }; + + const handleDeactivate = async () => { + try { + await deactivateAll(); + showToast('已取消使用', 'success'); + } catch (error) { + showToast('取消失败', 'error'); + } + }; + + const handleSync = async (file: PromptFile) => { + setSyncingFileId(file.id); + try { + // 同步当前激活的文件到 ~/.claude/CLAUDE.md + const path = await applyFile(file.id); + showToast(`文件已同步到: ${path}`, 'success'); + await loadFiles(); // 重新加载以更新状态 + } catch (error) { + showToast('同步失败', 'error'); + } finally { + setSyncingFileId(null); + } + }; + + const handleDelete = async () => { + if (!selectedFile) return; + try { + await deleteFile(selectedFile.id); + setShowDeleteDialog(false); + setSelectedFile(null); + showToast('删除成功', 'success'); + } catch (error) { + showToast('删除失败', 'error'); + } + }; + + const handleImportFromClaudeMd = async (name: string, description?: string) => { + try { + await importFromClaudeMd(name, description); + setShowImportDialog(false); + showToast('导入成功', 'success'); + } catch (error) { + showToast('导入失败', 'error'); + } + }; + + const openPreview = (file: PromptFile) => { + setSelectedFile(file); + setShowPreviewDialog(true); + }; + + const openEdit = (file: PromptFile) => { + setSelectedFile(file); + setShowEditDialog(true); + }; + + const openDelete = (file: PromptFile) => { + setSelectedFile(file); + setShowDeleteDialog(true); + }; + + const filteredFiles = files.filter((file) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + file.name.toLowerCase().includes(query) || + file.description?.toLowerCase().includes(query) || + file.tags.some((tag) => tag.toLowerCase().includes(query)) + ); + }); + + const activeFiles = filteredFiles.filter((f) => f.is_active); + const inactiveFiles = filteredFiles.filter((f) => !f.is_active); + + return ( +
+
+
+ {/* Header */} +
+
+ {onBack && ( + + )} +
+

提示词文件管理

+

管理和切换 Claude 项目提示词文件

+
+
+
+ + +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + {/* Error Display */} + {error && ( + + + {error} + + )} + + {/* Loading State */} + {isLoading && ( +
+ +
+ )} + + {/* Active File */} + {!isLoading && activeFiles.length > 0 && ( +
+

+ + 当前使用 +

+ {activeFiles.map((file) => ( + + +
+
+ + + {file.name} + + 使用中 + + + {file.description && ( + {file.description} + )} +
+
+
+ {file.tags.length > 0 && ( +
+ + {file.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {file.tags.length > 3 && +{file.tags.length - 3}} +
+ )} + {file.last_used_at && ( +
+ + {new Date(file.last_used_at * 1000).toLocaleString('zh-CN')} +
+ )} +
+
+ +
+ + + + +
+
+
+ ))} +
+ )} + + {/* All Prompt Files */} + {!isLoading && ( +
+

+ 全部提示词文件 ({inactiveFiles.length}) +

+ {inactiveFiles.length === 0 ? ( + +
+ +

+ {searchQuery ? '没有找到匹配的提示词文件' : '还没有提示词文件'} +

+ {!searchQuery && ( + + )} +
+
+ ) : ( +
+ {inactiveFiles.map((file) => ( + + + + + {file.name} + + + {file.description || ' '} + + {file.tags.length > 0 && ( +
+ {file.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {file.tags.length > 3 && ( + + +{file.tags.length - 3} + + )} +
+ )} +
+ + +
+ + + +
+
+
+ ))} +
+ )} +
+ )} + + {/* Delete Confirmation Dialog */} + + + + 删除提示词文件 + + 确定要删除这个提示词文件吗?此操作无法撤销。 + + + {selectedFile && ( +
+

{selectedFile.name}

+ {selectedFile.description && ( +

{selectedFile.description}

+ )} +
+ )} + + + + +
+
+ + {/* Import Dialog */} + + + {/* Create/Edit Dialogs */} + {showCreateDialog && ( + { + setShowCreateDialog(false); + showToast('创建成功', 'success'); + }} + /> + )} + + {showEditDialog && selectedFile && ( + { + setShowEditDialog(false); + setSelectedFile(null); + showToast('更新成功', 'success'); + }} + /> + )} + + {/* Preview Dialog */} + {showPreviewDialog && selectedFile && ( + { + setShowPreviewDialog(false); + openEdit(selectedFile); + }} + onApply={() => { + setShowPreviewDialog(false); + handleApply(selectedFile); + }} + /> + )} +
+
+ + {/* Toast */} + + {toast && ( + + + {toast.type === 'success' ? ( + + ) : ( + + )} + {toast.message} + + + )} + +
+ ); +}; + +// Import from CLAUDE.md Dialog +const ImportFromClaudeMdDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (name: string, description?: string) => Promise; +}> = ({ open, onOpenChange, onImport }) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [importing, setImporting] = useState(false); + + const handleImport = async () => { + if (!name.trim()) return; + setImporting(true); + try { + await onImport(name, description || undefined); + setName(''); + setDescription(''); + } finally { + setImporting(false); + } + }; + + return ( + + + + 从 CLAUDE.md 导入 + 导入当前项目的 CLAUDE.md 文件作为提示词模板 + +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + + + +
+
+ ); +}; + +export default PromptFilesManager; + diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index b3402e4..203d674 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -30,6 +30,7 @@ import { ClaudeVersionSelector } from "./ClaudeVersionSelector"; import { StorageTab } from "./StorageTab"; import { HooksEditor } from "./HooksEditor"; import { SlashCommandsManager } from "./SlashCommandsManager"; +import PromptFilesManager from "./PromptFilesManager"; import { ProxySettings } from "./ProxySettings"; import { AnalyticsConsent } from "./AnalyticsConsent"; import { useTheme, useTrackEvent, useTranslation } from "@/hooks"; @@ -457,13 +458,14 @@ export const Settings: React.FC = ({
- + {t('settings.general')} {t('settings.permissionsTab')} {t('settings.environmentTab')} {t('settings.advancedTab')} {t('settings.hooksTab')} {t('settings.commands')} + {t('promptFiles.title')} {t('settings.storage')} {t('settings.proxy')} {t('settings.analyticsTab')} @@ -1019,6 +1021,13 @@ export const Settings: React.FC = ({ + {/* Prompt Files Tab */} + + + + + + {/* Storage Tab */} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index fb85d5e..36211b3 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { motion } from "framer-motion"; -import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot } from "lucide-react"; +import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info, Bot, Files } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Popover } from "@/components/ui/popover"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; @@ -34,6 +34,10 @@ interface TopbarProps { * Callback when Agents is clicked */ onAgentsClick?: () => void; + /** + * Callback when Prompt Files is clicked + */ + onPromptFilesClick?: () => void; /** * Optional className for styling */ @@ -58,6 +62,7 @@ export const Topbar: React.FC = ({ onMCPClick, onInfoClick, onAgentsClick, + onPromptFilesClick, className, }) => { const { t } = useTranslation(); @@ -218,6 +223,18 @@ export const Topbar: React.FC = ({ CLAUDE.md + {onPromptFilesClick && ( + + )} +