diff --git a/README.md b/README.md index eace6c1..207ecc5 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,13 @@ npm run dev # or bun run dev The UI will be available at `http://localhost:3000`. +### Build Static Docs +To generate static files for deployment: +```bash +npx vuepress build +``` +The output will be in `docs/.vuepress/dist`. ## Contributing diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e73bec8..e1b617a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -251,6 +251,7 @@ dependencies = [ "os_pipe", "password-hash", "rand 0.9.0", + "regex", "reqwest 0.11.27", "reqwest-eventsource", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 91f7bc7..6e67cc8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" anyhow = "1.0.95" argon2 = { version = "0.5.3", features = ["std"] } # async-openai = "0.28.2" +regex = "1" # temporary until async-openai fixes their latest release async-openai = { git = "https://github.com/64bit/async-openai" } diff --git a/backend/migrations/20250625230855_add_prompt_directories_and_components.sql b/backend/migrations/20250625230855_add_prompt_directories_and_components.sql new file mode 100644 index 0000000..2a27254 --- /dev/null +++ b/backend/migrations/20250625230855_add_prompt_directories_and_components.sql @@ -0,0 +1,21 @@ +-- Add prompt_directory table +CREATE TABLE prompt_directory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES prompt_directory(id) ON DELETE SET NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add prompt_component table +CREATE TABLE prompt_component ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + content TEXT NOT NULL, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add directory_id to prompt table +ALTER TABLE prompt ADD COLUMN directory_id INTEGER REFERENCES prompt_directory(id) ON DELETE SET NULL; diff --git a/backend/src/controllers/prompt_components.rs b/backend/src/controllers/prompt_components.rs new file mode 100644 index 0000000..a98306c --- /dev/null +++ b/backend/src/controllers/prompt_components.rs @@ -0,0 +1,62 @@ +use axum::{ + extract::{Path, State}, + Json, +}; +use crate::{AppState, AppError}; +use crate::db::models::PromptComponent; + +pub async fn create_component( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let id = state.db.prompt_component.create_component( + &payload.name, + &payload.content, + payload.description.as_deref(), + ).await?; + Ok(Json(id)) +} + +pub async fn get_component( + Path(id): Path, + State(state): State, +) -> Result, AppError> { + let component = state.db.prompt_component.get_component(id).await?; + Ok(Json(component)) +} + +pub async fn list_components( + State(state): State, +) -> Result>, AppError> { + let components = state.db.prompt_component.list_components().await?; + Ok(Json(components)) +} + +pub async fn update_component( + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let updated = state.db.prompt_component.update_component( + id, + &payload.name, + &payload.content, + payload.description.as_deref(), + ).await?; + if !updated { + return Err(AppError::NotFound("Component not found".into())); + } + let component = state.db.prompt_component.get_component(id).await?; + Ok(Json(component)) +} + +pub async fn delete_component( + Path(id): Path, + State(state): State, +) -> Result<(), AppError> { + let deleted = state.db.prompt_component.delete_component(id).await?; + if !deleted { + return Err(AppError::NotFound("Component not found".into())); + } + Ok(()) +} \ No newline at end of file diff --git a/backend/src/controllers/prompt_directories.rs b/backend/src/controllers/prompt_directories.rs new file mode 100644 index 0000000..31e87cf --- /dev/null +++ b/backend/src/controllers/prompt_directories.rs @@ -0,0 +1,50 @@ +// --- Prompt Directory API --- +use crate::db::models::{PromptDirectory, PromptComponent}; +use axum::extract::Query; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct DirectoryQuery { + pub parent_id: Option, +} + +pub async fn create_directory( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let id = state.db.prompt.create_directory(&payload.name, payload.parent_id).await?; + Ok(Json(id)) +} + +pub async fn get_directory( + Path(id): Path, + State(state): State, +) -> Result>, AppError> { + let dir = state.db.prompt.get_directory(id).await?; + Ok(Json(dir)) +} + +pub async fn list_directories( + State(state): State, + Query(query): Query, +) -> Result>, AppError> { + let dirs = state.db.prompt.list_directories(query.parent_id).await?; + Ok(Json(dirs)) +} + +pub async fn update_directory( + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let updated = state.db.prompt.update_directory(id, &payload.name, payload.parent_id).await?; + Ok(Json(updated)) +} + +pub async fn delete_directory( + Path(id): Path, + State(state): State, +) -> Result, AppError> { + let deleted = state.db.prompt.delete_directory(id).await?; + Ok(Json(deleted)) +} \ No newline at end of file diff --git a/backend/src/db/logs.rs b/backend/src/db/logs.rs index 412b0fe..0c98b1c 100644 --- a/backend/src/db/logs.rs +++ b/backend/src/db/logs.rs @@ -1,3 +1,10 @@ +// IMPORTANT: SQLx query macros need a live database connection at compile time. +// Make sure to set DATABASE_URL before building. +// For example: +// On bash: export DATABASE_URL="sqlite://C:/Users/kunya/PycharmProjects/llmkit/backend/llmkit.db" +// On PowerShell: $env:DATABASE_URL="sqlite://C:/Users/kunya/PycharmProjects/llmkit/backend/llmkit.db" +// Alternatively, run `cargo sqlx prepare` to generate an offline query cache. + use anyhow::Result; use crate::db::types::log::{LogRow, LogRowModel}; @@ -226,4 +233,3 @@ impl LogRepository { Ok(log) } } - diff --git a/backend/src/db/models.rs b/backend/src/db/models.rs index 7a0f00a..c78b971 100644 --- a/backend/src/db/models.rs +++ b/backend/src/db/models.rs @@ -1,6 +1,8 @@ use anyhow::Result; use crate::db::types::models::ModelProviderRow; +// Removed: use async_openai::types::Prompt; // no longer needed + #[derive(Clone, Debug)] pub struct ModelRepository { pool: sqlx::SqlitePool, @@ -160,3 +162,36 @@ impl ModelRepository { Ok(model) } } + +// --- Prompt Directory --- +#[derive(Debug, Clone)] +pub struct PromptDirectory { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +// --- Prompt Component --- +#[derive(Debug, Clone)] +pub struct PromptComponent { + pub id: i64, + pub name: String, + pub content: String, + pub description: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +// --- Prompt --- +// Rename the local Prompt type to DbPrompt so that you can derive traits +#[derive(Debug, Clone)] +pub struct DbPrompt { + pub id: i64, + pub key: String, + pub current_prompt_version_id: Option, + pub directory_id: Option, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} diff --git a/backend/src/db/prompt_components.rs b/backend/src/db/prompt_components.rs new file mode 100644 index 0000000..bb5cb4e --- /dev/null +++ b/backend/src/db/prompt_components.rs @@ -0,0 +1,109 @@ +use crate::db::models::PromptComponent; +use regex::Regex; + +// --- Prompt Component CRUD --- + pub async fn create_component(&self, name: &str, content: &str, description: Option<&str>) -> Result { + let rec = sqlx::query!( + r#"INSERT INTO prompt_component (name, content, description) VALUES (?, ?, ?)"#, + name, content, description + ) + .execute(&self.pool) + .await?; + Ok(rec.last_insert_rowid()) + } + + pub async fn get_component(&self, id: i64) -> Result> { + let rec = sqlx::query_as!(PromptComponent, + r#"SELECT id, name, content, description, created_at, updated_at FROM prompt_component WHERE id = ?"#, + id + ) + .fetch_optional(&self.pool) + .await?; + Ok(rec) + } + + pub async fn list_components(&self) -> Result> { + let recs = sqlx::query_as!(PromptComponent, + r#"SELECT id, name, content, description, created_at, updated_at FROM prompt_component ORDER BY created_at DESC"# + ) + .fetch_all(&self.pool) + .await?; + Ok(recs) + } + + pub async fn update_component(&self, id: i64, name: &str, content: &str, description: Option<&str>) -> Result { + let rows = sqlx::query!( + r#"UPDATE prompt_component SET name = ?, content = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, + name, content, description, id + ) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(rows > 0) + } + + pub async fn delete_component(&self, id: i64) -> Result { + let rows = sqlx::query!( + r#"DELETE FROM prompt_component WHERE id = ?"#, + id + ) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(rows > 0) + } + + // Utility: Recursively resolve {{component:component_name}} in prompt content + pub async fn resolve_components_in_text(&self, text: &str) -> Result { + let re = Regex::new(r"\{\{component:([a-zA-Z0-9_\- ]+)}}")?; + let mut resolved = text.to_string(); + let mut changed = true; + while changed { + changed = false; + let mut new_text = resolved.clone(); + for cap in re.captures_iter(&resolved) { + let comp_name = &cap[1]; + let comp = sqlx::query!( + r#"SELECT content FROM prompt_component WHERE name = ? LIMIT 1"#, + comp_name + ) + .fetch_optional(&self.pool) + .await?; + if let Some(row) = comp { + let comp_content = row.content; + new_text = new_text.replace(&cap[0], &comp_content); + changed = true; + } + } + resolved = new_text; + } + Ok(resolved) + } + + // Wrap get_prompt to resolve components in system/user fields + pub async fn get_prompt_with_components(&self, id: i64) -> Result { + let mut prompt = self.get_prompt(id).await?; + if let Some(system) = &prompt.system { + prompt.system = Some(self.resolve_components_in_text(system).await?); + } + if let Some(user) = &prompt.user { + prompt.user = Some(self.resolve_components_in_text(user).await?); + } + Ok(prompt) + } +} + +fn generate_diff(text1: &str, text2: &str) -> String { + let mut diff_string = String::new(); + let differences = lines(text1, text2); + + for difference in differences { + match difference { + DiffResult::Left(l) => diff_string.push_str(&format!("-{}\n", l)), + DiffResult::Right(r) => diff_string.push_str(&format!("+{}\n", r)), + _ => {} + } + } + + diff_string +} diff --git a/backend/src/db/prompt_directories.rs b/backend/src/db/prompt_directories.rs new file mode 100644 index 0000000..ee54276 --- /dev/null +++ b/backend/src/db/prompt_directories.rs @@ -0,0 +1,57 @@ +use crate::db::models::PromptDirectory; + +// --- Prompt Directory CRUD --- + pub async fn create_directory(&self, name: &str, parent_id: Option) -> Result { + let rec = sqlx::query!( + r#"INSERT INTO prompt_directory (name, parent_id) VALUES (?, ?)"#, + name, + parent_id + ) + .execute(&self.pool) + .await?; + Ok(rec.last_insert_rowid()) + } + + pub async fn get_directory(&self, id: i64) -> Result> { + let rec = sqlx::query_as!(PromptDirectory, + r#"SELECT id, name, parent_id, created_at, updated_at FROM prompt_directory WHERE id = ?"#, + id + ) + .fetch_optional(&self.pool) + .await?; + Ok(rec) + } + + pub async fn list_directories(&self, parent_id: Option) -> Result> { + let recs = sqlx::query_as!(PromptDirectory, + r#"SELECT id, name, parent_id, created_at, updated_at FROM prompt_directory WHERE parent_id IS ?"#, + parent_id + ) + .fetch_all(&self.pool) + .await?; + Ok(recs) + } + + pub async fn update_directory(&self, id: i64, name: &str, parent_id: Option) -> Result { + let rows = sqlx::query!( + r#"UPDATE prompt_directory SET name = ?, parent_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, + name, parent_id, id + ) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(rows > 0) + } + + pub async fn delete_directory(&self, id: i64) -> Result { + let rows = sqlx::query!( + r#"DELETE FROM prompt_directory WHERE id = ?"#, + id + ) + .execute(&self.pool) + .await? + .rows_affected(); + Ok(rows > 0) + } + +// All table names below should be prompt_directory (singular) diff --git a/backend/src/db/prompts.rs b/backend/src/db/prompts.rs index ed3e578..18f6078 100644 --- a/backend/src/db/prompts.rs +++ b/backend/src/db/prompts.rs @@ -573,4 +573,3 @@ fn generate_diff(text1: &str, text2: &str) -> String { diff_string } - diff --git a/backend/src/db/tools.rs b/backend/src/db/tools.rs index 26dbe5d..02d36dc 100644 --- a/backend/src/db/tools.rs +++ b/backend/src/db/tools.rs @@ -1,3 +1,10 @@ +// IMPORTANT: SQLx query macros validate SQL at compile time using DATABASE_URL. +// To fix the error, use one of the options below: +// 1. Set DATABASE_URL before building, e.g., +// Windows CMD: set DATABASE_URL=sqlite://C:/Users/kunya/PycharmProjects/llmkit/backend/llmkit.db +// PowerShell: $env:DATABASE_URL="sqlite://C:/Users/kunya/PycharmProjects/llmkit/backend/llmkit.db" +// 2. Alternatively, run `cargo sqlx prepare` to generate an offline query cache. + use anyhow::Result; use crate::db::types::tool::ToolRow; @@ -200,7 +207,7 @@ impl ToolRepository { pub async fn get_prompt_versions_by_tool( &self, tool_id: i64, - ) -> Result> { + ) -> anyhow::Result> { let rows = sqlx::query!( r#" SELECT prompt_version_id @@ -215,12 +222,14 @@ impl ToolRepository { Ok(rows.into_iter().map(|row| row.prompt_version_id).collect()) } + // This method uses sqlx::query_as! which is validated at compile time. + // Ensure that DATABASE_URL is set or cargo sqlx prepare has been executed. pub async fn get_tools_by_prompt_version( &self, prompt_version_id: i64, - ) -> Result> { + ) -> anyhow::Result> { let tools = sqlx::query_as!( - ToolRow, + crate::db::types::tool::ToolRow, r#" SELECT t.id, t.name, t.tool_name, t.description, t.parameters, t.strict, t.created_at, t.updated_at FROM tool t