增加提示词管理
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)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ pub mod git;
|
|||||||
pub mod language;
|
pub mod language;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod packycode_nodes;
|
pub mod packycode_nodes;
|
||||||
|
pub mod prompt_files;
|
||||||
|
pub mod prompt_files_v2;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod relay_adapters;
|
pub mod relay_adapters;
|
||||||
pub mod relay_stations;
|
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,
|
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,
|
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::{
|
use commands::filesystem::{
|
||||||
get_file_info, get_file_tree, get_watched_paths, read_directory_tree, read_file,
|
get_file_info, get_file_tree, get_watched_paths, read_directory_tree, read_file,
|
||||||
search_files_by_name, unwatch_directory, watch_directory, write_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_get,
|
||||||
commands::slash_commands::slash_command_save,
|
commands::slash_commands::slash_command_save,
|
||||||
commands::slash_commands::slash_command_delete,
|
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
|
// Proxy Settings
|
||||||
get_proxy_settings,
|
get_proxy_settings,
|
||||||
save_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 { WelcomePage } from "@/components/WelcomePage";
|
||||||
import RelayStationManager from "@/components/RelayStationManager";
|
import RelayStationManager from "@/components/RelayStationManager";
|
||||||
import { CcrRouterManager } from "@/components/CcrRouterManager";
|
import { CcrRouterManager } from "@/components/CcrRouterManager";
|
||||||
|
import { PromptFilesManager } from "@/components";
|
||||||
import i18n from "@/lib/i18n";
|
import i18n from "@/lib/i18n";
|
||||||
|
|
||||||
// Lazy load these components to match TabContent's dynamic imports
|
// Lazy load these components to match TabContent's dynamic imports
|
||||||
@@ -48,6 +49,7 @@ type View =
|
|||||||
| "mcp"
|
| "mcp"
|
||||||
| "relay-stations"
|
| "relay-stations"
|
||||||
| "ccr-router"
|
| "ccr-router"
|
||||||
|
| "prompt-files"
|
||||||
| "usage-dashboard"
|
| "usage-dashboard"
|
||||||
| "project-settings"
|
| "project-settings"
|
||||||
| "tabs"; // New view for tab-based interface
|
| "tabs"; // New view for tab-based interface
|
||||||
@@ -464,6 +466,13 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "prompt-files":
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<PromptFilesManager onBack={() => handleViewChange("welcome")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case "settings":
|
case "settings":
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-hidden">
|
<div className="h-full overflow-hidden">
|
||||||
@@ -655,6 +664,7 @@ function AppContent() {
|
|||||||
onMCPClick={() => view === 'tabs' ? createMCPTab() : handleViewChange('mcp')}
|
onMCPClick={() => view === 'tabs' ? createMCPTab() : handleViewChange('mcp')}
|
||||||
onInfoClick={() => setShowNFO(true)}
|
onInfoClick={() => setShowNFO(true)}
|
||||||
onAgentsClick={() => view === 'tabs' ? setShowAgentsModal(true) : handleViewChange('cc-agents')}
|
onAgentsClick={() => view === 'tabs' ? setShowAgentsModal(true) : handleViewChange('cc-agents')}
|
||||||
|
onPromptFilesClick={() => handleViewChange('prompt-files')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Analytics Consent Banner */}
|
{/* 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 { StorageTab } from "./StorageTab";
|
||||||
import { HooksEditor } from "./HooksEditor";
|
import { HooksEditor } from "./HooksEditor";
|
||||||
import { SlashCommandsManager } from "./SlashCommandsManager";
|
import { SlashCommandsManager } from "./SlashCommandsManager";
|
||||||
|
import PromptFilesManager from "./PromptFilesManager";
|
||||||
import { ProxySettings } from "./ProxySettings";
|
import { ProxySettings } from "./ProxySettings";
|
||||||
import { AnalyticsConsent } from "./AnalyticsConsent";
|
import { AnalyticsConsent } from "./AnalyticsConsent";
|
||||||
import { useTheme, useTrackEvent, useTranslation } from "@/hooks";
|
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="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<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="general">{t('settings.general')}</TabsTrigger>
|
||||||
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
|
<TabsTrigger value="permissions">{t('settings.permissionsTab')}</TabsTrigger>
|
||||||
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
|
<TabsTrigger value="environment">{t('settings.environmentTab')}</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
|
<TabsTrigger value="advanced">{t('settings.advancedTab')}</TabsTrigger>
|
||||||
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
|
<TabsTrigger value="hooks">{t('settings.hooksTab')}</TabsTrigger>
|
||||||
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
|
<TabsTrigger value="commands">{t('settings.commands')}</TabsTrigger>
|
||||||
|
<TabsTrigger value="prompts">{t('promptFiles.title')}</TabsTrigger>
|
||||||
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
|
<TabsTrigger value="storage">{t('settings.storage')}</TabsTrigger>
|
||||||
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
|
<TabsTrigger value="proxy">{t('settings.proxy')}</TabsTrigger>
|
||||||
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
|
<TabsTrigger value="analytics">{t('settings.analyticsTab')}</TabsTrigger>
|
||||||
@@ -1019,6 +1021,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Prompt Files Tab */}
|
||||||
|
<TabsContent value="prompts" className="mt-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<PromptFilesManager className="p-0" />
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Storage Tab */}
|
{/* Storage Tab */}
|
||||||
<TabsContent value="storage" className="mt-6">
|
<TabsContent value="storage" className="mt-6">
|
||||||
<StorageTab />
|
<StorageTab />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Popover } from "@/components/ui/popover";
|
import { Popover } from "@/components/ui/popover";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
@@ -34,6 +34,10 @@ interface TopbarProps {
|
|||||||
* Callback when Agents is clicked
|
* Callback when Agents is clicked
|
||||||
*/
|
*/
|
||||||
onAgentsClick?: () => void;
|
onAgentsClick?: () => void;
|
||||||
|
/**
|
||||||
|
* Callback when Prompt Files is clicked
|
||||||
|
*/
|
||||||
|
onPromptFilesClick?: () => void;
|
||||||
/**
|
/**
|
||||||
* Optional className for styling
|
* Optional className for styling
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +62,7 @@ export const Topbar: React.FC<TopbarProps> = ({
|
|||||||
onMCPClick,
|
onMCPClick,
|
||||||
onInfoClick,
|
onInfoClick,
|
||||||
onAgentsClick,
|
onAgentsClick,
|
||||||
|
onPromptFilesClick,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -218,6 +223,18 @@ export const Topbar: React.FC<TopbarProps> = ({
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -74,13 +74,13 @@ export function WelcomePage({ onNavigate, onNewSession, onSmartQuickStart }: Wel
|
|||||||
view: "ccr-router"
|
view: "ccr-router"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "claude-md",
|
id: "prompt-files",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
title: t("welcome.claudeMd"),
|
title: t("welcome.promptFiles"),
|
||||||
subtitle: t("welcome.claudeMdDesc"),
|
subtitle: t("welcome.promptFilesDesc"),
|
||||||
color: "text-orange-500",
|
color: "text-orange-500",
|
||||||
bgColor: "bg-orange-500/10",
|
bgColor: "bg-orange-500/10",
|
||||||
view: "editor"
|
view: "prompt-files"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export * from "./ui/toast";
|
|||||||
export * from "./ui/tooltip";
|
export * from "./ui/tooltip";
|
||||||
export * from "./SlashCommandPicker";
|
export * from "./SlashCommandPicker";
|
||||||
export * from "./SlashCommandsManager";
|
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/popover";
|
||||||
export * from "./ui/pagination";
|
export * from "./ui/pagination";
|
||||||
export * from "./ui/split-pane";
|
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;
|
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
|
* 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
|
// Language Settings
|
||||||
// ================================
|
// ================================
|
||||||
|
|||||||
@@ -123,6 +123,7 @@
|
|||||||
"usage": "Usage Dashboard",
|
"usage": "Usage Dashboard",
|
||||||
"mcp": "MCP Manager",
|
"mcp": "MCP Manager",
|
||||||
"relayStations": "Relay Stations",
|
"relayStations": "Relay Stations",
|
||||||
|
"promptFiles": "Prompt Files",
|
||||||
"about": "About"
|
"about": "About"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@@ -138,6 +139,8 @@
|
|||||||
"mcpBrokerDesc": "Manage MCP servers",
|
"mcpBrokerDesc": "Manage MCP servers",
|
||||||
"claudeMd": "CLAUDE.md",
|
"claudeMd": "CLAUDE.md",
|
||||||
"claudeMdDesc": "Edit Claude configuration files",
|
"claudeMdDesc": "Edit Claude configuration files",
|
||||||
|
"promptFiles": "Prompt Files",
|
||||||
|
"promptFilesDesc": "Manage and switch CLAUDE.md prompt files",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settingsDesc": "App settings and configuration",
|
"settingsDesc": "App settings and configuration",
|
||||||
"quickStartSession": "Quick Start New Session",
|
"quickStartSession": "Quick Start New Session",
|
||||||
@@ -317,6 +320,52 @@
|
|||||||
"createFirstProjectCommand": "Create your first project command",
|
"createFirstProjectCommand": "Create your first project command",
|
||||||
"createFirstCommand": "Create your first 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": {
|
"hooks": {
|
||||||
"hooksConfiguration": "Hooks Configuration",
|
"hooksConfiguration": "Hooks Configuration",
|
||||||
"configureShellCommands": "Configure shell commands to execute at various points in Claude Code's lifecycle.",
|
"configureShellCommands": "Configure shell commands to execute at various points in Claude Code's lifecycle.",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@
|
|||||||
"usage": "用量仪表板",
|
"usage": "用量仪表板",
|
||||||
"mcp": "MCP 管理器",
|
"mcp": "MCP 管理器",
|
||||||
"relayStations": "中转站",
|
"relayStations": "中转站",
|
||||||
|
"promptFiles": "提示词文件",
|
||||||
"about": "关于"
|
"about": "关于"
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
@@ -133,6 +134,8 @@
|
|||||||
"mcpBrokerDesc": "管理 MCP 服务器",
|
"mcpBrokerDesc": "管理 MCP 服务器",
|
||||||
"claudeMd": "CLAUDE.md",
|
"claudeMd": "CLAUDE.md",
|
||||||
"claudeMdDesc": "编辑 Claude 配置文件",
|
"claudeMdDesc": "编辑 Claude 配置文件",
|
||||||
|
"promptFiles": "提示词管理",
|
||||||
|
"promptFilesDesc": "管理和切换 CLAUDE.md 提示词文件",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"settingsDesc": "应用设置和配置",
|
"settingsDesc": "应用设置和配置",
|
||||||
"quickStartSession": "快速开始新会话",
|
"quickStartSession": "快速开始新会话",
|
||||||
@@ -304,6 +307,52 @@
|
|||||||
"createFirstProjectCommand": "创建您的第一个项目命令",
|
"createFirstProjectCommand": "创建您的第一个项目命令",
|
||||||
"createFirstCommand": "创建您的第一个命令"
|
"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": {
|
"hooks": {
|
||||||
"hooksConfiguration": "钩子配置",
|
"hooksConfiguration": "钩子配置",
|
||||||
"configureShellCommands": "配置在 Claude Code 生命周期的各个阶段执行的 shell 命令。",
|
"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