增加提示词管理
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
515
src-tauri/src/commands/prompt_files.rs
Normal file
515
src-tauri/src/commands/prompt_files.rs
Normal 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)
|
||||
}
|
||||
|
||||
266
src-tauri/src/commands/prompt_files_v2.rs
Normal file
266
src-tauri/src/commands/prompt_files_v2.rs
Normal 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
src/App.tsx
10
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() {
|
||||
</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 */}
|
||||
|
||||
237
src/components/PromptFileEditor.tsx
Normal file
237
src/components/PromptFileEditor.tsx
Normal 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;
|
||||
|
||||
111
src/components/PromptFilePreview.tsx
Normal file
111
src/components/PromptFilePreview.tsx
Normal 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;
|
||||
|
||||
589
src/components/PromptFilesManager.tsx
Normal file
589
src/components/PromptFilesManager.tsx
Normal 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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
200
src/lib/api.ts
200
src/lib/api.ts
@@ -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
|
||||
// ================================
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 命令。",
|
||||
|
||||
202
src/stores/promptFilesStore.ts
Normal file
202
src/stores/promptFilesStore.ts
Normal 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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user