From b98f410bc826ae509f1c2c55eeabafd40e5d7bde Mon Sep 17 00:00:00 2001 From: Darkstalker Date: Wed, 11 Jun 2025 13:04:00 -0400 Subject: [PATCH 1/3] (feat) Add initial documentation and prompt management API --- README.md | 23 +++++ backend/src/controllers/prompts.rs | 106 +++++++++++++++++++++ backend/src/db/models.rs | 34 ++++++- backend/src/db/prompts.rs | 147 ++++++++++++++++++++++++++++- docs/index.md | 130 +++++++++++++++++++++++++ docs/package-lock.json | 34 +++++++ docs/package.json | 16 ++++ 7 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 docs/index.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json diff --git a/README.md b/README.md index eace6c1..df28e7b 100644 --- a/README.md +++ b/README.md @@ -309,7 +309,30 @@ npm run dev # or bun run dev The UI will be available at `http://localhost:3000`. +## Documentation +The documentation is located in the `docs/` directory and built with [VuePress](https://vuepress.vuejs.org/). + +### Local Preview + +1. Install dependencies: + ```bash + cd docs + npm install + ``` +2. Start the local docs server: + ```bash + npx vuepress dev + ``` +3. Open your browser to [http://localhost:8080](http://localhost:8080) + +### 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/src/controllers/prompts.rs b/backend/src/controllers/prompts.rs index 1c1dfc8..522dfdf 100644 --- a/backend/src/controllers/prompts.rs +++ b/backend/src/controllers/prompts.rs @@ -381,3 +381,109 @@ impl IntoResponse for CompletionResponse { } } } + +// --- 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)) +} + +// --- Prompt Component API --- +#[derive(Deserialize)] +pub struct CreateComponentRequest { + pub name: String, + pub content: String, + pub description: Option, +} + +pub async fn create_component( + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let id = state.db.prompt.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 comp = state.db.prompt.get_component(id).await?; + Ok(Json(comp)) +} + +pub async fn list_components( + State(state): State, +) -> Result>, AppError> { + let comps = state.db.prompt.list_components().await?; + Ok(Json(comps)) +} + +#[derive(Deserialize)] +pub struct UpdateComponentRequest { + pub name: String, + pub content: String, + pub description: Option, +} + +pub async fn update_component( + Path(id): Path, + State(state): State, + Json(payload): Json, +) -> Result, AppError> { + let updated = state.db.prompt.update_component(id, &payload.name, &payload.content, payload.description.as_deref()).await?; + Ok(Json(updated)) +} + +pub async fn delete_component( + Path(id): Path, + State(state): State, +) -> Result, AppError> { + let deleted = state.db.prompt.delete_component(id).await?; + Ok(Json(deleted)) +} diff --git a/backend/src/db/models.rs b/backend/src/db/models.rs index 7a0f00a..da83bb8 100644 --- a/backend/src/db/models.rs +++ b/backend/src/db/models.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use crate::db::types::models::ModelProviderRow; +use crate::db::types::models::{ModelProviderRow, Prompt, PromptComponent, PromptDirectory}; #[derive(Clone, Debug)] pub struct ModelRepository { @@ -160,3 +160,35 @@ 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 (partial, for directory support) --- +#[derive(Debug, Clone)] +pub struct Prompt { + 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/prompts.rs b/backend/src/db/prompts.rs index ed3e578..f498dc5 100644 --- a/backend/src/db/prompts.rs +++ b/backend/src/db/prompts.rs @@ -557,6 +557,152 @@ impl PromptRepository { // Return the updated prompt self.get_prompt(prompt_id).await } + + // --- 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) + } + + // --- 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 { + use regex::Regex; + 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 { @@ -573,4 +719,3 @@ fn generate_diff(text1: &str, text2: &str) -> String { diff_string } - diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5c09b5b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,130 @@ +# llmkit Documentation + +Welcome to **llmkit** – a modern, extensible platform for managing, testing, and deploying LLM prompts with a focus on versioning, evaluation, and developer-friendly workflows. + +--- + +## Table of Contents +- [Overview](#overview) +- [Key Features](#key-features) +- [Architecture](#architecture) +- [Setup & Installation](#setup--installation) +- [Prompt Management](#prompt-management) +- [API & Usage](#api--usage) +- [Development](#development) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Overview +llmkit provides a unified toolkit for: +- Crafting dynamic prompts with modern template syntax +- Managing, versioning, and evaluating prompts +- Integrating with multiple LLM providers (OpenAI, Azure, OpenRouter, and more) +- Running and tracing prompt executions +- OpenAI-compatible API endpoints + +--- + +## Key Features +- **Prompt Directories & Components**: Organize prompts in folders and reuse prompt parts. Example usage: + + ``` + {{component:component_name}} + ``` + +- **Prompt Versioning**: Track changes and roll back as needed +- **Prompt Evaluation**: Create test sets, run evals, and score performance +- **OpenAI-Compatible API**: Use with any OpenAI client library +- **Provider Abstraction**: Unified API for OpenAI, Azure, OpenRouter, and more +- **Modern UI/UX**: Built with Nuxt 3 (Vue 3) and Tailwind CSS + +--- + +## Architecture +- **Backend**: Rust (Axum), SQLite (SQLx), Tera templates +- **Frontend**: Nuxt 3 (Vue 3), Tailwind CSS +- **Docs**: VuePress (Markdown) + +### Code Structure +- `backend/` – API, DB, business logic +- `ui/` – Frontend app +- `docs/` – Documentation +- `tests/oai-libs/` – OpenAI client library examples + +--- + +## Setup & Installation + +### Quick Start +```sh +# Clone the repo +git clone https://github.com/your-org/llmkit.git +cd llmkit + +# Backend +cd backend && cargo build && cargo run + +# Frontend +cd ../ui && npm install && npm run dev +``` + +### Docker +```sh +docker-compose up -d +``` + +--- + +## Prompt Management +- **Prompt Types**: Static, dynamic (with variables), and fully templated system/user prompts +- **Template Syntax**: Jinja-style (e.g. `{{ variable }}`, `{% if ... %}`, `{% for ... %}`) +- **Prompt Components**: Reuse prompt parts with: + ``` + {{component:component_name}} + ``` +- **Prompt Directories**: Organize prompts in folders for easy management + +--- + +## API & Usage +- **RESTful API** for prompt CRUD, execution, and evaluation +- **OpenAI-Compatible Endpoints**: + - `/v1/chat/completions` + - `/v1/chat/completions/stream` +- **Sample Prompt Format**: + ``` + + you are a helpful assistant + + sup dude + ``` +- **Provider Setup**: Configure API keys and base URLs in `.env` and the UI + +--- + +## Development +- **Backend**: Rust, Axum, SQLx, Tera +- **Frontend**: Nuxt 3, Vue 3, Tailwind CSS +- **Docs**: VuePress (see this folder) + +### Backend +- See `backend/README.md` for database setup, migrations, and running the server + +### Frontend +- See `ui/README.md` for Nuxt app setup and commands + +### Tests & Examples +- See `tests/oai-libs/` for OpenAI client usage examples in Python and Node.js + +--- + +## Contributing +- Fork, branch, and open a PR +- See [contributing.md](./contributing.md) for guidelines + +--- + +## License +[MIT License](../LICENSE) diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 0000000..a21d3fe --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "vue-template-compiler": "^2.7.16" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..7e87d15 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,16 @@ +{ + "dependencies": { + "vue-template-compiler": "^2.7.16" + }, + "name": "docs", + "version": "1.0.0", + "main": "index.js", + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} From a323c7d1377b13b3661889907cd635f6db5c1728 Mon Sep 17 00:00:00 2001 From: Darkstalker Date: Wed, 25 Jun 2025 19:21:48 -0400 Subject: [PATCH 2/3] (feat) Add prompt_directories and prompt_components tables with directory_id reference in prompts --- ..._add_prompt_directories_and_components.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/migrations/20250625230855_add_prompt_directories_and_components.sql 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..ed1f60e --- /dev/null +++ b/backend/migrations/20250625230855_add_prompt_directories_and_components.sql @@ -0,0 +1,21 @@ +-- Add prompt_directories table +CREATE TABLE prompt_directories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER REFERENCES prompt_directories(id) ON DELETE SET NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Add prompt_components table +CREATE TABLE prompt_components ( + 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 prompts table +ALTER TABLE prompts ADD COLUMN directory_id INTEGER REFERENCES prompt_directories(id) ON DELETE SET NULL; \ No newline at end of file From 3936f44abdc637d05891550271e8461ec55f6a21 Mon Sep 17 00:00:00 2001 From: Darkstalker Date: Wed, 2 Jul 2025 20:48:12 -0400 Subject: [PATCH 3/3] (feat) Implement CRUD operations for prompt directories and components --- README.md | 17 -- backend/Cargo.lock | 1 + backend/Cargo.toml | 1 + ..._add_prompt_directories_and_components.sql | 14 +- backend/src/controllers/prompt_components.rs | 62 ++++++++ backend/src/controllers/prompt_directories.rs | 50 ++++++ backend/src/controllers/prompts.rs | 106 ------------- backend/src/db/logs.rs | 8 +- backend/src/db/models.rs | 9 +- backend/src/db/prompt_components.rs | 109 +++++++++++++ backend/src/db/prompt_directories.rs | 57 +++++++ backend/src/db/prompts.rs | 146 ------------------ backend/src/db/tools.rs | 15 +- docs/index.md | 130 ---------------- docs/package-lock.json | 34 ---- docs/package.json | 16 -- 16 files changed, 312 insertions(+), 463 deletions(-) create mode 100644 backend/src/controllers/prompt_components.rs create mode 100644 backend/src/controllers/prompt_directories.rs create mode 100644 backend/src/db/prompt_components.rs create mode 100644 backend/src/db/prompt_directories.rs delete mode 100644 docs/index.md delete mode 100644 docs/package-lock.json delete mode 100644 docs/package.json diff --git a/README.md b/README.md index df28e7b..207ecc5 100644 --- a/README.md +++ b/README.md @@ -309,23 +309,6 @@ npm run dev # or bun run dev The UI will be available at `http://localhost:3000`. -## Documentation - -The documentation is located in the `docs/` directory and built with [VuePress](https://vuepress.vuejs.org/). - -### Local Preview - -1. Install dependencies: - ```bash - cd docs - npm install - ``` -2. Start the local docs server: - ```bash - npx vuepress dev - ``` -3. Open your browser to [http://localhost:8080](http://localhost:8080) - ### Build Static Docs To generate static files for deployment: 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 index ed1f60e..2a27254 100644 --- a/backend/migrations/20250625230855_add_prompt_directories_and_components.sql +++ b/backend/migrations/20250625230855_add_prompt_directories_and_components.sql @@ -1,14 +1,14 @@ --- Add prompt_directories table -CREATE TABLE prompt_directories ( +-- Add prompt_directory table +CREATE TABLE prompt_directory ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - parent_id INTEGER REFERENCES prompt_directories(id) ON DELETE SET 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_components table -CREATE TABLE prompt_components ( +-- Add prompt_component table +CREATE TABLE prompt_component ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, content TEXT NOT NULL, @@ -17,5 +17,5 @@ CREATE TABLE prompt_components ( updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); --- Add directory_id to prompts table -ALTER TABLE prompts ADD COLUMN directory_id INTEGER REFERENCES prompt_directories(id) ON DELETE SET NULL; \ No newline at end of file +-- 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/controllers/prompts.rs b/backend/src/controllers/prompts.rs index 522dfdf..1c1dfc8 100644 --- a/backend/src/controllers/prompts.rs +++ b/backend/src/controllers/prompts.rs @@ -381,109 +381,3 @@ impl IntoResponse for CompletionResponse { } } } - -// --- 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)) -} - -// --- Prompt Component API --- -#[derive(Deserialize)] -pub struct CreateComponentRequest { - pub name: String, - pub content: String, - pub description: Option, -} - -pub async fn create_component( - State(state): State, - Json(payload): Json, -) -> Result, AppError> { - let id = state.db.prompt.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 comp = state.db.prompt.get_component(id).await?; - Ok(Json(comp)) -} - -pub async fn list_components( - State(state): State, -) -> Result>, AppError> { - let comps = state.db.prompt.list_components().await?; - Ok(Json(comps)) -} - -#[derive(Deserialize)] -pub struct UpdateComponentRequest { - pub name: String, - pub content: String, - pub description: Option, -} - -pub async fn update_component( - Path(id): Path, - State(state): State, - Json(payload): Json, -) -> Result, AppError> { - let updated = state.db.prompt.update_component(id, &payload.name, &payload.content, payload.description.as_deref()).await?; - Ok(Json(updated)) -} - -pub async fn delete_component( - Path(id): Path, - State(state): State, -) -> Result, AppError> { - let deleted = state.db.prompt.delete_component(id).await?; - Ok(Json(deleted)) -} 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 da83bb8..c78b971 100644 --- a/backend/src/db/models.rs +++ b/backend/src/db/models.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use crate::db::types::models::{ModelProviderRow, Prompt, PromptComponent, PromptDirectory}; +use crate::db::types::models::ModelProviderRow; + +// Removed: use async_openai::types::Prompt; // no longer needed #[derive(Clone, Debug)] pub struct ModelRepository { @@ -182,9 +184,10 @@ pub struct PromptComponent { pub updated_at: chrono::NaiveDateTime, } -// --- Prompt (partial, for directory support) --- +// --- Prompt --- +// Rename the local Prompt type to DbPrompt so that you can derive traits #[derive(Debug, Clone)] -pub struct Prompt { +pub struct DbPrompt { pub id: i64, pub key: String, pub current_prompt_version_id: Option, 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 f498dc5..18f6078 100644 --- a/backend/src/db/prompts.rs +++ b/backend/src/db/prompts.rs @@ -557,152 +557,6 @@ impl PromptRepository { // Return the updated prompt self.get_prompt(prompt_id).await } - - // --- 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) - } - - // --- 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 { - use regex::Regex; - 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 { 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 diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 5c09b5b..0000000 --- a/docs/index.md +++ /dev/null @@ -1,130 +0,0 @@ -# llmkit Documentation - -Welcome to **llmkit** – a modern, extensible platform for managing, testing, and deploying LLM prompts with a focus on versioning, evaluation, and developer-friendly workflows. - ---- - -## Table of Contents -- [Overview](#overview) -- [Key Features](#key-features) -- [Architecture](#architecture) -- [Setup & Installation](#setup--installation) -- [Prompt Management](#prompt-management) -- [API & Usage](#api--usage) -- [Development](#development) -- [Contributing](#contributing) -- [License](#license) - ---- - -## Overview -llmkit provides a unified toolkit for: -- Crafting dynamic prompts with modern template syntax -- Managing, versioning, and evaluating prompts -- Integrating with multiple LLM providers (OpenAI, Azure, OpenRouter, and more) -- Running and tracing prompt executions -- OpenAI-compatible API endpoints - ---- - -## Key Features -- **Prompt Directories & Components**: Organize prompts in folders and reuse prompt parts. Example usage: - - ``` - {{component:component_name}} - ``` - -- **Prompt Versioning**: Track changes and roll back as needed -- **Prompt Evaluation**: Create test sets, run evals, and score performance -- **OpenAI-Compatible API**: Use with any OpenAI client library -- **Provider Abstraction**: Unified API for OpenAI, Azure, OpenRouter, and more -- **Modern UI/UX**: Built with Nuxt 3 (Vue 3) and Tailwind CSS - ---- - -## Architecture -- **Backend**: Rust (Axum), SQLite (SQLx), Tera templates -- **Frontend**: Nuxt 3 (Vue 3), Tailwind CSS -- **Docs**: VuePress (Markdown) - -### Code Structure -- `backend/` – API, DB, business logic -- `ui/` – Frontend app -- `docs/` – Documentation -- `tests/oai-libs/` – OpenAI client library examples - ---- - -## Setup & Installation - -### Quick Start -```sh -# Clone the repo -git clone https://github.com/your-org/llmkit.git -cd llmkit - -# Backend -cd backend && cargo build && cargo run - -# Frontend -cd ../ui && npm install && npm run dev -``` - -### Docker -```sh -docker-compose up -d -``` - ---- - -## Prompt Management -- **Prompt Types**: Static, dynamic (with variables), and fully templated system/user prompts -- **Template Syntax**: Jinja-style (e.g. `{{ variable }}`, `{% if ... %}`, `{% for ... %}`) -- **Prompt Components**: Reuse prompt parts with: - ``` - {{component:component_name}} - ``` -- **Prompt Directories**: Organize prompts in folders for easy management - ---- - -## API & Usage -- **RESTful API** for prompt CRUD, execution, and evaluation -- **OpenAI-Compatible Endpoints**: - - `/v1/chat/completions` - - `/v1/chat/completions/stream` -- **Sample Prompt Format**: - ``` - - you are a helpful assistant - - sup dude - ``` -- **Provider Setup**: Configure API keys and base URLs in `.env` and the UI - ---- - -## Development -- **Backend**: Rust, Axum, SQLx, Tera -- **Frontend**: Nuxt 3, Vue 3, Tailwind CSS -- **Docs**: VuePress (see this folder) - -### Backend -- See `backend/README.md` for database setup, migrations, and running the server - -### Frontend -- See `ui/README.md` for Nuxt app setup and commands - -### Tests & Examples -- See `tests/oai-libs/` for OpenAI client usage examples in Python and Node.js - ---- - -## Contributing -- Fork, branch, and open a PR -- See [contributing.md](./contributing.md) for guidelines - ---- - -## License -[MIT License](../LICENSE) diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index a21d3fe..0000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "vue-template-compiler": "^2.7.16" - } - }, - "node_modules/de-indent": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", - "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==" - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "bin": { - "he": "bin/he" - } - }, - "node_modules/vue-template-compiler": { - "version": "2.7.16", - "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", - "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", - "dependencies": { - "de-indent": "^1.0.2", - "he": "^1.2.0" - } - } - } -} diff --git a/docs/package.json b/docs/package.json deleted file mode 100644 index 7e87d15..0000000 --- a/docs/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "dependencies": { - "vue-template-compiler": "^2.7.16" - }, - "name": "docs", - "version": "1.0.0", - "main": "index.js", - "devDependencies": {}, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "" -}