增加提示词管理

This commit is contained in:
2025-10-21 15:08:31 +08:00
parent 0e32c6e64c
commit 7021ab6bec
17 changed files with 2286 additions and 6 deletions

View File

@@ -373,6 +373,9 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
)?;
}
// Initialize prompt files tables
crate::commands::prompt_files::init_prompt_files_tables(&conn)?;
Ok(conn)
}

View File

@@ -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;

View File

@@ -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<String>,
pub content: String,
pub tags: Vec<String>,
pub is_active: bool,
pub created_at: i64,
pub updated_at: i64,
pub last_used_at: Option<i64>,
pub display_order: i32,
}
/// 创建提示词文件请求
#[derive(Debug, Serialize, Deserialize)]
pub struct CreatePromptFileRequest {
pub name: String,
pub description: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
/// 更新提示词文件请求
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdatePromptFileRequest {
pub id: String,
pub name: String,
pub description: Option<String>,
pub content: String,
pub tags: Vec<String>,
}
impl PromptFile {
pub fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
let tags_str: String = row.get("tags")?;
let tags: Vec<String> = 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<Vec<PromptFile>, 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::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
Ok(files)
}
/// 获取单个提示词文件
#[command]
pub async fn prompt_file_get(id: String, db: State<'_, AgentDb>) -> Result<PromptFile, String> {
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<PromptFile, String> {
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<PromptFile, String> {
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<PathBuf, String> {
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<String>,
db: State<'_, AgentDb>,
) -> Result<String, String> {
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<String>,
source_path: Option<String>,
db: State<'_, AgentDb>,
) -> Result<PromptFile, String> {
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<String> {
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<String>,
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<CreatePromptFileRequest>,
db: State<'_, AgentDb>,
) -> Result<Vec<PromptFile>, 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)
}

View File

@@ -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<String>, // CLAUDE.md 文件内容
pub file_size: Option<u64>, // 文件大小(字节)
pub modified_at: Option<i64>, // 最后修改时间
pub claude_md_path: String, // .claude/CLAUDE.md 完整路径
}
/// 获取 Claude 目录
fn get_claude_dir() -> Result<PathBuf, String> {
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<String, String> {
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::<serde_json::Value>(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<Vec<PromptFileInfo>, 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<String, String> {
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
}

View File

@@ -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,

View File

@@ -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() {
</div>
);
case "prompt-files":
return (
<div className="h-full overflow-hidden">
<PromptFilesManager onBack={() => handleViewChange("welcome")} />
</div>
);
case "settings":
return (
<div className="h-full overflow-hidden">
@@ -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 */}

View File

@@ -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<PromptFileEditorProps> = ({
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<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{file ? '编辑提示词文件' : '创建提示词文件'}</DialogTitle>
<DialogDescription>
{file ? '修改提示词文件的内容和信息' : '创建一个新的提示词文件模板'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
placeholder="例如: React 项目指南"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
placeholder="简短描述..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Tags */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="flex items-center gap-1">
{tag}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveTag(tag)}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="添加标签(按 Enter"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button type="button" variant="outline" onClick={handleAddTag}>
<TagIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content Editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> *</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<>
<EyeOff className="mr-2 h-4 w-4" />
</>
) : (
<>
<Eye className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{showPreview ? (
<div className="border rounded-lg p-4 max-h-[400px] overflow-y-auto prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
) : (
<div className="border rounded-lg overflow-hidden" style={{ height: '400px' }}>
<MonacoEditor
language="markdown"
theme="vs-dark"
value={content}
onChange={(value) => setContent(value || '')}
options={{
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 14,
wordWrap: 'on',
lineNumbers: 'on',
automaticLayout: true,
}}
/>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={!name.trim() || !content.trim() || saving}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFileEditor;

View File

@@ -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<PromptFilePreviewProps> = ({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{file.name}</DialogTitle>
{file.description && (
<DialogDescription className="text-base mt-2">{file.description}</DialogDescription>
)}
</DialogHeader>
{/* Metadata */}
<div className="flex flex-wrap gap-4 py-4 border-y text-sm text-muted-foreground">
{file.tags.length > 0 && (
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4" />
<div className="flex gap-1 flex-wrap">
{file.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
: {formatDate(file.created_at)}
</div>
{file.updated_at !== file.created_at && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
: {formatDate(file.updated_at)}
</div>
)}
{file.last_used_at && (
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
使: {formatDate(file.last_used_at)}
</div>
)}
</div>
{/* Content */}
<div className="prose prose-sm dark:prose-invert max-w-none py-4">
<ReactMarkdown>{file.content}</ReactMarkdown>
</div>
<DialogFooter className="flex items-center justify-between">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onEdit}>
<Edit className="mr-2 h-4 w-4" />
</Button>
{!file.is_active && (
<Button onClick={onApply}>
<Play className="mr-2 h-4 w-4" />
使
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFilePreview;

View File

@@ -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<PromptFilesManagerProps> = ({ 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<PromptFile | null>(null);
const [applyingFileId, setApplyingFileId] = useState<string | null>(null);
const [syncingFileId, setSyncingFileId] = useState<string | null>(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 (
<div className={cn('h-full flex flex-col overflow-hidden', className)}>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{onBack && (
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
{t('app.back')}
</Button>
)}
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"> Claude </p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowImportDialog(true)}>
<Upload className="mr-2 h-4 w-4" />
CLAUDE.md
</Button>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提示词文件..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Error Display */}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{/* Active File */}
{!isLoading && activeFiles.length > 0 && (
<div>
<h2 className="text-lg font-semibold mb-3 flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
使
</h2>
{activeFiles.map((file) => (
<Card key={file.id} className="border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-950/20">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{file.name}
<Badge variant="secondary" className="bg-green-100 dark:bg-green-900">
使
</Badge>
</CardTitle>
{file.description && (
<CardDescription className="mt-2">{file.description}</CardDescription>
)}
</div>
</div>
<div className="flex items-center gap-4 mt-3 text-sm text-muted-foreground">
{file.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{file.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{file.tags.length > 3 && <span className="text-xs">+{file.tags.length - 3}</span>}
</div>
)}
{file.last_used_at && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(file.last_used_at * 1000).toLocaleString('zh-CN')}
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="sm"
onClick={() => handleSync(file)}
disabled={syncingFileId === file.id}
>
{syncingFileId === file.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => openPreview(file)}>
<Eye className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => openEdit(file)}>
<Edit className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={handleDeactivate}>
使
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* All Prompt Files */}
{!isLoading && (
<div>
<h2 className="text-lg font-semibold mb-3">
({inactiveFiles.length})
</h2>
{inactiveFiles.length === 0 ? (
<Card className="p-12">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? '没有找到匹配的提示词文件' : '还没有提示词文件'}
</p>
{!searchQuery && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{inactiveFiles.map((file) => (
<Card key={file.id} className="hover:shadow-md transition-shadow flex flex-col">
<CardHeader className="flex-1">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{file.name}</span>
</CardTitle>
<CardDescription className="text-sm line-clamp-2 min-h-[1.25rem]">
{file.description || ' '}
</CardDescription>
{file.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{file.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
{file.tags.length > 3 && (
<span className="text-xs text-muted-foreground">
+{file.tags.length - 3}
</span>
)}
</div>
)}
</CardHeader>
<CardContent className="space-y-2 pt-4">
<Button
className="w-full"
size="sm"
onClick={() => handleApply(file)}
disabled={applyingFileId === file.id}
>
{applyingFileId === file.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Play className="mr-2 h-4 w-4 flex-shrink-0" />
使
</>
)}
</Button>
<div className="flex gap-2 justify-center">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openPreview(file)}
title="查看内容"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openEdit(file)}
title="编辑"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => openDelete(file)}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{selectedFile && (
<div className="py-4">
<p className="font-medium">{selectedFile.name}</p>
{selectedFile.description && (
<p className="text-sm text-muted-foreground mt-1">{selectedFile.description}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Import Dialog */}
<ImportFromClaudeMdDialog
open={showImportDialog}
onOpenChange={setShowImportDialog}
onImport={handleImportFromClaudeMd}
/>
{/* Create/Edit Dialogs */}
{showCreateDialog && (
<PromptFileEditor
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSuccess={() => {
setShowCreateDialog(false);
showToast('创建成功', 'success');
}}
/>
)}
{showEditDialog && selectedFile && (
<PromptFileEditor
open={showEditDialog}
onOpenChange={setShowEditDialog}
file={selectedFile}
onSuccess={() => {
setShowEditDialog(false);
setSelectedFile(null);
showToast('更新成功', 'success');
}}
/>
)}
{/* Preview Dialog */}
{showPreviewDialog && selectedFile && (
<PromptFilePreview
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
file={selectedFile}
onEdit={() => {
setShowPreviewDialog(false);
openEdit(selectedFile);
}}
onApply={() => {
setShowPreviewDialog(false);
handleApply(selectedFile);
}}
/>
)}
</div>
</div>
{/* Toast */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-4 right-4 z-50"
>
<Alert variant={toast.type === 'error' ? 'destructive' : 'default'} className="shadow-lg">
{toast.type === 'success' ? (
<Check className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>{toast.message}</AlertDescription>
</Alert>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// Import from CLAUDE.md Dialog
const ImportFromClaudeMdDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
onImport: (name: string, description?: string) => Promise<void>;
}> = ({ 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle> CLAUDE.md </DialogTitle>
<DialogDescription> CLAUDE.md </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium"> *</label>
<Input
placeholder="例如: 我的项目指南"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
placeholder="简短描述这个提示词文件的用途"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleImport} disabled={!name.trim() || importing}>
{importing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFilesManager;

View File

@@ -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<SettingsProps> = ({
<div className="flex-1 overflow-y-auto min-h-0">
<div className="p-4">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid grid-cols-9 w-full sticky top-0 z-10 bg-background">
<TabsList className="grid grid-cols-10 w-full sticky top-0 z-10 bg-background">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
<TabsTrigger value="prompts">{t('promptFiles.title')}</TabsTrigger>
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
@@ -1019,6 +1021,13 @@ export const Settings: React.FC<SettingsProps> = ({
</Card>
</TabsContent>
{/* Prompt Files Tab */}
<TabsContent value="prompts" className="mt-6">
<Card className="p-6">
<PromptFilesManager className="p-0" />
</Card>
</TabsContent>
{/* Storage Tab */}
<TabsContent value="storage" className="mt-6">
<StorageTab />

View File

@@ -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<TopbarProps> = ({
onMCPClick,
onInfoClick,
onAgentsClick,
onPromptFilesClick,
className,
}) => {
const { t } = useTranslation();
@@ -218,6 +223,18 @@ export const Topbar: React.FC<TopbarProps> = ({
CLAUDE.md
</Button>
{onPromptFilesClick && (
<Button
variant="ghost"
size="sm"
onClick={onPromptFilesClick}
className="text-xs"
>
<Files className="mr-2 h-3 w-3" />
{t('navigation.promptFiles')}
</Button>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -74,13 +74,13 @@ export function WelcomePage({ onNavigate, onNewSession, onSmartQuickStart }: Wel
view: "ccr-router"
},
{
id: "claude-md",
id: "prompt-files",
icon: FileText,
title: t("welcome.claudeMd"),
subtitle: t("welcome.claudeMdDesc"),
title: t("welcome.promptFiles"),
subtitle: t("welcome.promptFilesDesc"),
color: "text-orange-500",
bgColor: "bg-orange-500/10",
view: "editor"
view: "prompt-files"
},
{
id: "settings",

View File

@@ -26,6 +26,9 @@ export * from "./ui/toast";
export * from "./ui/tooltip";
export * from "./SlashCommandPicker";
export * from "./SlashCommandsManager";
export { default as PromptFilesManager } from "./PromptFilesManager";
export { default as PromptFileEditor } from "./PromptFileEditor";
export { default as PromptFilePreview } from "./PromptFilePreview";
export * from "./ui/popover";
export * from "./ui/pagination";
export * from "./ui/split-pane";

View File

@@ -421,6 +421,62 @@ export interface SlashCommand {
accepts_arguments: boolean;
}
/**
* Represents a prompt file (CLAUDE.md template)
*/
export interface PromptFile {
/** Unique identifier */
id: string;
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags for categorization */
tags: string[];
/** Whether this is the currently active file */
is_active: boolean;
/** Unix timestamp when created */
created_at: number;
/** Unix timestamp when last updated */
updated_at: number;
/** Unix timestamp when last used */
last_used_at?: number;
/** Display order */
display_order: number;
}
/**
* Request to create a new prompt file
*/
export interface CreatePromptFileRequest {
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags */
tags: string[];
}
/**
* Request to update an existing prompt file
*/
export interface UpdatePromptFileRequest {
/** File ID */
id: string;
/** File name */
name: string;
/** Description */
description?: string;
/** Markdown content */
content: string;
/** Tags */
tags: string[];
}
/**
* Result of adding a server
*/
@@ -2121,6 +2177,150 @@ export const api = {
}
},
// ================================
// Prompt Files Management
// ================================
/**
* Lists all prompt files
*/
async promptFilesList(): Promise<PromptFile[]> {
try {
return await invoke<PromptFile[]>("prompt_files_list");
} catch (error) {
console.error("Failed to list prompt files:", error);
throw error;
}
},
/**
* Gets a single prompt file by ID
*/
async promptFileGet(id: string): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_get", { id });
} catch (error) {
console.error("Failed to get prompt file:", error);
throw error;
}
},
/**
* Creates a new prompt file
*/
async promptFileCreate(request: CreatePromptFileRequest): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_create", { request });
} catch (error) {
console.error("Failed to create prompt file:", error);
throw error;
}
},
/**
* Updates an existing prompt file
*/
async promptFileUpdate(request: UpdatePromptFileRequest): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_update", { request });
} catch (error) {
console.error("Failed to update prompt file:", error);
throw error;
}
},
/**
* Deletes a prompt file
*/
async promptFileDelete(id: string): Promise<void> {
try {
await invoke<void>("prompt_file_delete", { id });
} catch (error) {
console.error("Failed to delete prompt file:", error);
throw error;
}
},
/**
* Applies a prompt file (replaces local CLAUDE.md)
*/
async promptFileApply(id: string, targetPath?: string): Promise<string> {
try {
return await invoke<string>("prompt_file_apply", { id, targetPath });
} catch (error) {
console.error("Failed to apply prompt file:", error);
throw error;
}
},
/**
* Deactivates all prompt files
*/
async promptFileDeactivate(): Promise<void> {
try {
await invoke<void>("prompt_file_deactivate");
} catch (error) {
console.error("Failed to deactivate prompt files:", error);
throw error;
}
},
/**
* Imports a prompt file from CLAUDE.md
*/
async promptFileImportFromClaudeMd(
name: string,
description?: string,
sourcePath?: string
): Promise<PromptFile> {
try {
return await invoke<PromptFile>("prompt_file_import_from_claude_md", {
name,
description,
sourcePath,
});
} catch (error) {
console.error("Failed to import from CLAUDE.md:", error);
throw error;
}
},
/**
* Exports a prompt file
*/
async promptFileExport(id: string, exportPath: string): Promise<void> {
try {
await invoke<void>("prompt_file_export", { id, exportPath });
} catch (error) {
console.error("Failed to export prompt file:", error);
throw error;
}
},
/**
* Updates the display order of prompt files
*/
async promptFilesUpdateOrder(ids: string[]): Promise<void> {
try {
await invoke<void>("prompt_files_update_order", { ids });
} catch (error) {
console.error("Failed to update prompt files order:", error);
throw error;
}
},
/**
* Batch imports prompt files
*/
async promptFilesImportBatch(files: CreatePromptFileRequest[]): Promise<PromptFile[]> {
try {
return await invoke<PromptFile[]>("prompt_files_import_batch", { files });
} catch (error) {
console.error("Failed to batch import prompt files:", error);
throw error;
}
},
// ================================
// Language Settings
// ================================

View File

@@ -123,6 +123,7 @@
"usage": "Usage Dashboard",
"mcp": "MCP Manager",
"relayStations": "Relay Stations",
"promptFiles": "Prompt Files",
"about": "About"
},
"welcome": {
@@ -138,6 +139,8 @@
"mcpBrokerDesc": "Manage MCP servers",
"claudeMd": "CLAUDE.md",
"claudeMdDesc": "Edit Claude configuration files",
"promptFiles": "Prompt Files",
"promptFilesDesc": "Manage and switch CLAUDE.md prompt files",
"settings": "Settings",
"settingsDesc": "App settings and configuration",
"quickStartSession": "Quick Start New Session",
@@ -317,6 +320,52 @@
"createFirstProjectCommand": "Create your first project command",
"createFirstCommand": "Create your first command"
},
"promptFiles": {
"title": "Prompt Files Management",
"description": "Manage and switch Claude project prompt files",
"create": "New",
"createFile": "Create Prompt File",
"editFile": "Edit Prompt File",
"deleteFile": "Delete Prompt File",
"fileName": "File Name",
"fileDescription": "Description",
"fileContent": "File Content",
"tags": "Tags",
"addTag": "Add Tag",
"currentActive": "Currently Active",
"allFiles": "All Prompt Files",
"noFiles": "No prompt files yet",
"noFilesDesc": "Create your first prompt file template",
"createFirst": "Create First Prompt File",
"useFile": "Use This File",
"viewContent": "View Content",
"deactivate": "Deactivate",
"applySuccess": "Applied to",
"applyFailed": "Apply Failed",
"createSuccess": "Created Successfully",
"createFailed": "Create Failed",
"updateSuccess": "Updated Successfully",
"updateFailed": "Update Failed",
"deleteSuccess": "Deleted Successfully",
"deleteFailed": "Delete Failed",
"deleteConfirm": "Are you sure you want to delete this prompt file? This action cannot be undone.",
"importFromClaudeMd": "Import from CLAUDE.md",
"importFromClaudeMdDesc": "Import the current project's CLAUDE.md file as a prompt template",
"importSuccess": "Imported Successfully",
"importFailed": "Import Failed",
"nameRequired": "File name is required",
"contentRequired": "File content is required",
"preview": "Preview",
"edit": "Edit",
"inUse": "In Use",
"lastUsed": "Last Used",
"createdAt": "Created At",
"updatedAt": "Updated At",
"syncToClaudeDir": "Sync to .claude",
"syncing": "Syncing...",
"syncSuccess": "Synced to {{path}}",
"syncFailed": "Sync Failed"
},
"hooks": {
"hooksConfiguration": "Hooks Configuration",
"configureShellCommands": "Configure shell commands to execute at various points in Claude Code's lifecycle.",

View File

@@ -118,6 +118,7 @@
"usage": "用量仪表板",
"mcp": "MCP 管理器",
"relayStations": "中转站",
"promptFiles": "提示词文件",
"about": "关于"
},
"welcome": {
@@ -133,6 +134,8 @@
"mcpBrokerDesc": "管理 MCP 服务器",
"claudeMd": "CLAUDE.md",
"claudeMdDesc": "编辑 Claude 配置文件",
"promptFiles": "提示词管理",
"promptFilesDesc": "管理和切换 CLAUDE.md 提示词文件",
"settings": "设置",
"settingsDesc": "应用设置和配置",
"quickStartSession": "快速开始新会话",
@@ -304,6 +307,52 @@
"createFirstProjectCommand": "创建您的第一个项目命令",
"createFirstCommand": "创建您的第一个命令"
},
"promptFiles": {
"title": "提示词文件管理",
"description": "管理和切换 Claude 项目提示词文件",
"create": "新建",
"createFile": "创建提示词文件",
"editFile": "编辑提示词文件",
"deleteFile": "删除提示词文件",
"fileName": "文件名称",
"fileDescription": "描述",
"fileContent": "文件内容",
"tags": "标签",
"addTag": "添加标签",
"currentActive": "当前使用",
"allFiles": "全部提示词文件",
"noFiles": "还没有提示词文件",
"noFilesDesc": "创建第一个提示词文件模板",
"createFirst": "创建第一个提示词文件",
"useFile": "使用此文件",
"viewContent": "查看内容",
"deactivate": "取消使用",
"applySuccess": "已应用到",
"applyFailed": "应用失败",
"createSuccess": "创建成功",
"createFailed": "创建失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败",
"deleteConfirm": "确定要删除这个提示词文件吗?此操作无法撤销。",
"importFromClaudeMd": "从 CLAUDE.md 导入",
"importFromClaudeMdDesc": "导入当前项目的 CLAUDE.md 文件作为提示词模板",
"importSuccess": "导入成功",
"importFailed": "导入失败",
"nameRequired": "文件名称不能为空",
"contentRequired": "文件内容不能为空",
"preview": "预览",
"edit": "编辑",
"inUse": "使用中",
"lastUsed": "最后使用",
"createdAt": "创建于",
"updatedAt": "更新于",
"syncToClaudeDir": "同步到 .claude",
"syncing": "同步中...",
"syncSuccess": "已同步到 {{path}}",
"syncFailed": "同步失败"
},
"hooks": {
"hooksConfiguration": "钩子配置",
"configureShellCommands": "配置在 Claude Code 生命周期的各个阶段执行的 shell 命令。",

View File

@@ -0,0 +1,202 @@
import { create } from 'zustand';
import { api, type PromptFile, type CreatePromptFileRequest, type UpdatePromptFileRequest } from '@/lib/api';
interface PromptFilesState {
// Data
files: PromptFile[];
activeFile: PromptFile | null;
// UI state
isLoading: boolean;
error: string | null;
// Actions
loadFiles: () => Promise<void>;
getFile: (id: string) => Promise<PromptFile>;
createFile: (request: CreatePromptFileRequest) => Promise<PromptFile>;
updateFile: (request: UpdatePromptFileRequest) => Promise<PromptFile>;
deleteFile: (id: string) => Promise<void>;
applyFile: (id: string, targetPath?: string) => Promise<string>;
deactivateAll: () => Promise<void>;
importFromClaudeMd: (name: string, description?: string, sourcePath?: string) => Promise<PromptFile>;
exportFile: (id: string, exportPath: string) => Promise<void>;
updateOrder: (ids: string[]) => Promise<void>;
importBatch: (files: CreatePromptFileRequest[]) => Promise<PromptFile[]>;
clearError: () => void;
}
export const usePromptFilesStore = create<PromptFilesState>((set, get) => ({
// Initial state
files: [],
activeFile: null,
isLoading: false,
error: null,
// Load all prompt files
loadFiles: async () => {
set({ isLoading: true, error: null });
try {
const files = await api.promptFilesList();
const activeFile = files.find(f => f.is_active) || null;
set({ files, activeFile, isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to load prompt files',
isLoading: false
});
}
},
// Get a single file
getFile: async (id: string) => {
try {
const file = await api.promptFileGet(id);
return file;
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to get prompt file' });
throw error;
}
},
// Create a new file
createFile: async (request: CreatePromptFileRequest) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileCreate(request);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to create prompt file',
isLoading: false
});
throw error;
}
},
// Update an existing file
updateFile: async (request: UpdatePromptFileRequest) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileUpdate(request);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to update prompt file',
isLoading: false
});
throw error;
}
},
// Delete a file
deleteFile: async (id: string) => {
set({ isLoading: true, error: null });
try {
await api.promptFileDelete(id);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to delete prompt file',
isLoading: false
});
throw error;
}
},
// Apply a file (replace CLAUDE.md)
applyFile: async (id: string, targetPath?: string) => {
set({ isLoading: true, error: null });
try {
const resultPath = await api.promptFileApply(id, targetPath);
await get().loadFiles(); // Reload to update active state
set({ isLoading: false });
return resultPath;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to apply prompt file',
isLoading: false
});
throw error;
}
},
// Deactivate all files
deactivateAll: async () => {
set({ isLoading: true, error: null });
try {
await api.promptFileDeactivate();
await get().loadFiles(); // Reload to update active state
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to deactivate prompt files',
isLoading: false
});
throw error;
}
},
// Import from CLAUDE.md
importFromClaudeMd: async (name: string, description?: string, sourcePath?: string) => {
set({ isLoading: true, error: null });
try {
const file = await api.promptFileImportFromClaudeMd(name, description, sourcePath);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return file;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to import from CLAUDE.md',
isLoading: false
});
throw error;
}
},
// Export a file
exportFile: async (id: string, exportPath: string) => {
try {
await api.promptFileExport(id, exportPath);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to export prompt file' });
throw error;
}
},
// Update display order
updateOrder: async (ids: string[]) => {
try {
await api.promptFilesUpdateOrder(ids);
await get().loadFiles(); // Reload to get updated order
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to update order' });
throw error;
}
},
// Batch import files
importBatch: async (files: CreatePromptFileRequest[]) => {
set({ isLoading: true, error: null });
try {
const imported = await api.promptFilesImportBatch(files);
await get().loadFiles(); // Reload to get updated list
set({ isLoading: false });
return imported;
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to batch import prompt files',
isLoading: false
});
throw error;
}
},
// Clear error
clearError: () => set({ error: null }),
}));