mirror of
https://github.com/perstarkse/minne.git
synced 2026-01-11 20:50:24 +01:00
feat: scratchpad
additional improvements changelog fix: wording
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
## Unreleased
|
||||
- Added manual knowledge entity creation flows using a modal, with the option for suggested relationships
|
||||
- Scratchpad feature, with the feature to convert scratchpads to content.
|
||||
- Added knowledge entity search results to the global search
|
||||
- Backend fixes for improved performance when ingesting and retrieval
|
||||
|
||||
|
||||
@@ -70,4 +70,4 @@ cargo = { level = "warn", priority = -1 }
|
||||
needless_question_mark = "allow"
|
||||
single_call_fn = "allow"
|
||||
must_use_candidate = "allow"
|
||||
|
||||
missing_errors_doc = "allow"
|
||||
|
||||
24
common/migrations/20251022_120302_add_scratchpad_table.surql
Normal file
24
common/migrations/20251022_120302_add_scratchpad_table.surql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Add scratchpad table and schema
|
||||
|
||||
-- Define scratchpad table and schema
|
||||
DEFINE TABLE IF NOT EXISTS scratchpad SCHEMALESS;
|
||||
|
||||
-- Standard fields from stored_object! macro
|
||||
DEFINE FIELD IF NOT EXISTS created_at ON scratchpad TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS updated_at ON scratchpad TYPE datetime;
|
||||
|
||||
-- Custom fields from the Scratchpad struct
|
||||
DEFINE FIELD IF NOT EXISTS user_id ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS title ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS content ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS last_saved_at ON scratchpad TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS is_dirty ON scratchpad TYPE bool DEFAULT false;
|
||||
DEFINE FIELD IF NOT EXISTS is_archived ON scratchpad TYPE bool DEFAULT false;
|
||||
DEFINE FIELD IF NOT EXISTS archived_at ON scratchpad TYPE option<datetime>;
|
||||
DEFINE FIELD IF NOT EXISTS ingested_at ON scratchpad TYPE option<datetime>;
|
||||
|
||||
-- Indexes based on query patterns
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_user_idx ON scratchpad FIELDS user_id;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_user_archived_idx ON scratchpad FIELDS user_id, is_archived;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_updated_idx ON scratchpad FIELDS updated_at;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_archived_idx ON scratchpad FIELDS archived_at;
|
||||
@@ -0,0 +1 @@
|
||||
{"schemas":"--- original\n+++ modified\n@@ -137,6 +137,30 @@\n DEFINE INDEX IF NOT EXISTS relates_to_metadata_source_id_idx ON relates_to FIELDS metadata.source_id;\n DEFINE INDEX IF NOT EXISTS relates_to_metadata_user_id_idx ON relates_to FIELDS metadata.user_id;\n\n+# Defines the schema for the 'scratchpad' table.\n+\n+DEFINE TABLE IF NOT EXISTS scratchpad SCHEMALESS;\n+\n+# Standard fields from stored_object! macro\n+DEFINE FIELD IF NOT EXISTS created_at ON scratchpad TYPE datetime;\n+DEFINE FIELD IF NOT EXISTS updated_at ON scratchpad TYPE datetime;\n+\n+# Custom fields from the Scratchpad struct\n+DEFINE FIELD IF NOT EXISTS user_id ON scratchpad TYPE string;\n+DEFINE FIELD IF NOT EXISTS title ON scratchpad TYPE string;\n+DEFINE FIELD IF NOT EXISTS content ON scratchpad TYPE string;\n+DEFINE FIELD IF NOT EXISTS last_saved_at ON scratchpad TYPE datetime;\n+DEFINE FIELD IF NOT EXISTS is_dirty ON scratchpad TYPE bool DEFAULT false;\n+DEFINE FIELD IF NOT EXISTS is_archived ON scratchpad TYPE bool DEFAULT false;\n+DEFINE FIELD IF NOT EXISTS archived_at ON scratchpad TYPE option<datetime>;\n+DEFINE FIELD IF NOT EXISTS ingested_at ON scratchpad TYPE option<datetime>;\n+\n+# Indexes based on query patterns\n+DEFINE INDEX IF NOT EXISTS scratchpad_user_idx ON scratchpad FIELDS user_id;\n+DEFINE INDEX IF NOT EXISTS scratchpad_user_archived_idx ON scratchpad FIELDS user_id, is_archived;\n+DEFINE INDEX IF NOT EXISTS scratchpad_updated_idx ON scratchpad FIELDS updated_at;\n+DEFINE INDEX IF NOT EXISTS scratchpad_archived_idx ON scratchpad FIELDS archived_at;\n+\n DEFINE TABLE OVERWRITE script_migration SCHEMAFULL\n PERMISSIONS\n FOR select FULL\n","events":null}
|
||||
23
common/schemas/scratchpad.surql
Normal file
23
common/schemas/scratchpad.surql
Normal file
@@ -0,0 +1,23 @@
|
||||
# Defines the schema for the 'scratchpad' table.
|
||||
|
||||
DEFINE TABLE IF NOT EXISTS scratchpad SCHEMALESS;
|
||||
|
||||
# Standard fields from stored_object! macro
|
||||
DEFINE FIELD IF NOT EXISTS created_at ON scratchpad TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS updated_at ON scratchpad TYPE datetime;
|
||||
|
||||
# Custom fields from the Scratchpad struct
|
||||
DEFINE FIELD IF NOT EXISTS user_id ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS title ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS content ON scratchpad TYPE string;
|
||||
DEFINE FIELD IF NOT EXISTS last_saved_at ON scratchpad TYPE datetime;
|
||||
DEFINE FIELD IF NOT EXISTS is_dirty ON scratchpad TYPE bool DEFAULT false;
|
||||
DEFINE FIELD IF NOT EXISTS is_archived ON scratchpad TYPE bool DEFAULT false;
|
||||
DEFINE FIELD IF NOT EXISTS archived_at ON scratchpad TYPE option<datetime>;
|
||||
DEFINE FIELD IF NOT EXISTS ingested_at ON scratchpad TYPE option<datetime>;
|
||||
|
||||
# Indexes based on query patterns
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_user_idx ON scratchpad FIELDS user_id;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_user_archived_idx ON scratchpad FIELDS user_id, is_archived;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_updated_idx ON scratchpad FIELDS updated_at;
|
||||
DEFINE INDEX IF NOT EXISTS scratchpad_archived_idx ON scratchpad FIELDS archived_at;
|
||||
@@ -7,6 +7,7 @@ pub mod ingestion_task;
|
||||
pub mod knowledge_entity;
|
||||
pub mod knowledge_relationship;
|
||||
pub mod message;
|
||||
pub mod scratchpad;
|
||||
pub mod system_prompts;
|
||||
pub mod system_settings;
|
||||
pub mod text_chunk;
|
||||
|
||||
462
common/src/storage/types/scratchpad.rs
Normal file
462
common/src/storage/types/scratchpad.rs
Normal file
@@ -0,0 +1,462 @@
|
||||
use chrono::Utc as ChronoUtc;
|
||||
use surrealdb::opt::PatchOp;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
|
||||
|
||||
stored_object!(Scratchpad, "scratchpad", {
|
||||
user_id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
#[serde(serialize_with = "serialize_datetime", deserialize_with="deserialize_datetime")]
|
||||
last_saved_at: DateTime<Utc>,
|
||||
is_dirty: bool,
|
||||
#[serde(default)]
|
||||
is_archived: bool,
|
||||
#[serde(
|
||||
serialize_with = "serialize_option_datetime",
|
||||
deserialize_with = "deserialize_option_datetime",
|
||||
default
|
||||
)]
|
||||
archived_at: Option<DateTime<Utc>>,
|
||||
#[serde(
|
||||
serialize_with = "serialize_option_datetime",
|
||||
deserialize_with = "deserialize_option_datetime",
|
||||
default
|
||||
)]
|
||||
ingested_at: Option<DateTime<Utc>>
|
||||
});
|
||||
|
||||
impl Scratchpad {
|
||||
pub fn new(user_id: String, title: String) -> Self {
|
||||
let now = ChronoUtc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
user_id,
|
||||
title,
|
||||
content: String::new(),
|
||||
last_saved_at: now,
|
||||
is_dirty: false,
|
||||
is_archived: false,
|
||||
archived_at: None,
|
||||
ingested_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_by_user(user_id: &str, db: &SurrealDbClient) -> Result<Vec<Self>, AppError> {
|
||||
let scratchpads: Vec<Scratchpad> = db.client
|
||||
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id AND (is_archived = false OR is_archived IS NONE) ORDER BY updated_at DESC")
|
||||
.bind(("table_name", Self::table_name()))
|
||||
.bind(("user_id", user_id.to_string()))
|
||||
.await?
|
||||
.take(0)?;
|
||||
|
||||
Ok(scratchpads)
|
||||
}
|
||||
|
||||
pub async fn get_archived_by_user(
|
||||
user_id: &str,
|
||||
db: &SurrealDbClient,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
let scratchpads: Vec<Scratchpad> = db.client
|
||||
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id AND is_archived = true ORDER BY archived_at DESC, updated_at DESC")
|
||||
.bind(("table_name", Self::table_name()))
|
||||
.bind(("user_id", user_id.to_string()))
|
||||
.await?
|
||||
.take(0)?;
|
||||
|
||||
Ok(scratchpads)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
db: &SurrealDbClient,
|
||||
) -> Result<Self, AppError> {
|
||||
let scratchpad: Option<Scratchpad> = db.get_item(id).await?;
|
||||
|
||||
let scratchpad =
|
||||
scratchpad.ok_or_else(|| AppError::NotFound("Scratchpad not found".to_string()))?;
|
||||
|
||||
if scratchpad.user_id != user_id {
|
||||
return Err(AppError::Auth(
|
||||
"You don't have access to this scratchpad".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(scratchpad)
|
||||
}
|
||||
|
||||
pub async fn update_content(
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
new_content: &str,
|
||||
db: &SurrealDbClient,
|
||||
) -> Result<Self, AppError> {
|
||||
// First verify ownership
|
||||
let scratchpad = Self::get_by_id(id, user_id, db).await?;
|
||||
|
||||
if scratchpad.is_archived {
|
||||
return Ok(scratchpad);
|
||||
}
|
||||
|
||||
let now = ChronoUtc::now();
|
||||
let _updated: Option<Self> = db
|
||||
.update((Self::table_name(), id))
|
||||
.patch(PatchOp::replace("/content", new_content.to_string()))
|
||||
.patch(PatchOp::replace(
|
||||
"/updated_at",
|
||||
surrealdb::Datetime::from(now),
|
||||
))
|
||||
.patch(PatchOp::replace(
|
||||
"/last_saved_at",
|
||||
surrealdb::Datetime::from(now),
|
||||
))
|
||||
.patch(PatchOp::replace("/is_dirty", false))
|
||||
.await?;
|
||||
|
||||
// Return the updated scratchpad
|
||||
Self::get_by_id(id, user_id, db).await
|
||||
}
|
||||
|
||||
pub async fn update_title(
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
new_title: &str,
|
||||
db: &SurrealDbClient,
|
||||
) -> Result<(), AppError> {
|
||||
// First verify ownership
|
||||
let _scratchpad = Self::get_by_id(id, user_id, db).await?;
|
||||
|
||||
let _updated: Option<Self> = db
|
||||
.update((Self::table_name(), id))
|
||||
.patch(PatchOp::replace("/title", new_title.to_string()))
|
||||
.patch(PatchOp::replace(
|
||||
"/updated_at",
|
||||
surrealdb::Datetime::from(ChronoUtc::now()),
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(id: &str, user_id: &str, db: &SurrealDbClient) -> Result<(), AppError> {
|
||||
// First verify ownership
|
||||
let _scratchpad = Self::get_by_id(id, user_id, db).await?;
|
||||
|
||||
let _: Option<Self> = db.client.delete((Self::table_name(), id)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn archive(
|
||||
id: &str,
|
||||
user_id: &str,
|
||||
db: &SurrealDbClient,
|
||||
mark_ingested: bool,
|
||||
) -> Result<Self, AppError> {
|
||||
// Verify ownership
|
||||
let scratchpad = Self::get_by_id(id, user_id, db).await?;
|
||||
|
||||
if scratchpad.is_archived {
|
||||
if mark_ingested && scratchpad.ingested_at.is_none() {
|
||||
// Ensure ingested_at is set if required
|
||||
let surreal_now = surrealdb::Datetime::from(ChronoUtc::now());
|
||||
let _updated: Option<Self> = db
|
||||
.update((Self::table_name(), id))
|
||||
.patch(PatchOp::replace("/ingested_at", surreal_now))
|
||||
.await?;
|
||||
return Self::get_by_id(id, user_id, db).await;
|
||||
}
|
||||
return Ok(scratchpad);
|
||||
}
|
||||
|
||||
let now = ChronoUtc::now();
|
||||
let surreal_now = surrealdb::Datetime::from(now);
|
||||
let mut update = db
|
||||
.update((Self::table_name(), id))
|
||||
.patch(PatchOp::replace("/is_archived", true))
|
||||
.patch(PatchOp::replace("/archived_at", surreal_now.clone()))
|
||||
.patch(PatchOp::replace("/updated_at", surreal_now.clone()));
|
||||
|
||||
update = if mark_ingested {
|
||||
update.patch(PatchOp::replace("/ingested_at", surreal_now))
|
||||
} else {
|
||||
update.patch(PatchOp::remove("/ingested_at"))
|
||||
};
|
||||
|
||||
let _updated: Option<Self> = update.await?;
|
||||
|
||||
Self::get_by_id(id, user_id, db).await
|
||||
}
|
||||
|
||||
pub async fn restore(id: &str, user_id: &str, db: &SurrealDbClient) -> Result<Self, AppError> {
|
||||
// Verify ownership
|
||||
let scratchpad = Self::get_by_id(id, user_id, db).await?;
|
||||
|
||||
if !scratchpad.is_archived {
|
||||
return Ok(scratchpad);
|
||||
}
|
||||
|
||||
let now = ChronoUtc::now();
|
||||
let surreal_now = surrealdb::Datetime::from(now);
|
||||
let _updated: Option<Self> = db
|
||||
.update((Self::table_name(), id))
|
||||
.patch(PatchOp::replace("/is_archived", false))
|
||||
.patch(PatchOp::remove("/archived_at"))
|
||||
.patch(PatchOp::remove("/ingested_at"))
|
||||
.patch(PatchOp::replace("/updated_at", surreal_now))
|
||||
.await?;
|
||||
|
||||
Self::get_by_id(id, user_id, db).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_scratchpad() {
|
||||
// Setup in-memory database for testing
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
// Create a new scratchpad
|
||||
let user_id = "test_user";
|
||||
let title = "Test Scratchpad";
|
||||
let scratchpad = Scratchpad::new(user_id.to_string(), title.to_string());
|
||||
|
||||
// Verify scratchpad properties
|
||||
assert_eq!(scratchpad.user_id, user_id);
|
||||
assert_eq!(scratchpad.title, title);
|
||||
assert_eq!(scratchpad.content, "");
|
||||
assert!(!scratchpad.is_dirty);
|
||||
assert!(!scratchpad.is_archived);
|
||||
assert!(scratchpad.archived_at.is_none());
|
||||
assert!(scratchpad.ingested_at.is_none());
|
||||
assert!(!scratchpad.id.is_empty());
|
||||
|
||||
// Store the scratchpad
|
||||
let result = db.store_item(scratchpad.clone()).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify it can be retrieved
|
||||
let retrieved: Option<Scratchpad> = db
|
||||
.get_item(&scratchpad.id)
|
||||
.await
|
||||
.expect("Failed to retrieve scratchpad");
|
||||
assert!(retrieved.is_some());
|
||||
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.id, scratchpad.id);
|
||||
assert_eq!(retrieved.user_id, user_id);
|
||||
assert_eq!(retrieved.title, title);
|
||||
assert!(!retrieved.is_archived);
|
||||
assert!(retrieved.archived_at.is_none());
|
||||
assert!(retrieved.ingested_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_by_user() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let user_id = "test_user";
|
||||
|
||||
// Create multiple scratchpads
|
||||
let scratchpad1 = Scratchpad::new(user_id.to_string(), "First".to_string());
|
||||
let scratchpad2 = Scratchpad::new(user_id.to_string(), "Second".to_string());
|
||||
let scratchpad3 = Scratchpad::new("other_user".to_string(), "Other".to_string());
|
||||
|
||||
// Store them
|
||||
let scratchpad1_id = scratchpad1.id.clone();
|
||||
let scratchpad2_id = scratchpad2.id.clone();
|
||||
db.store_item(scratchpad1).await.unwrap();
|
||||
db.store_item(scratchpad2).await.unwrap();
|
||||
db.store_item(scratchpad3).await.unwrap();
|
||||
|
||||
// Archive one of the user's scratchpads
|
||||
Scratchpad::archive(&scratchpad2_id, user_id, &db, false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Get scratchpads for user_id
|
||||
let user_scratchpads = Scratchpad::get_by_user(user_id, &db).await.unwrap();
|
||||
assert_eq!(user_scratchpads.len(), 1);
|
||||
assert_eq!(user_scratchpads[0].id, scratchpad1_id);
|
||||
|
||||
// Verify they belong to the user
|
||||
for scratchpad in &user_scratchpads {
|
||||
assert_eq!(scratchpad.user_id, user_id);
|
||||
}
|
||||
|
||||
let archived = Scratchpad::get_archived_by_user(user_id, &db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(archived.len(), 1);
|
||||
assert_eq!(archived[0].id, scratchpad2_id);
|
||||
assert!(archived[0].is_archived);
|
||||
assert!(archived[0].ingested_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_archive_and_restore() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let user_id = "test_user";
|
||||
let scratchpad = Scratchpad::new(user_id.to_string(), "Test".to_string());
|
||||
let scratchpad_id = scratchpad.id.clone();
|
||||
db.store_item(scratchpad).await.unwrap();
|
||||
|
||||
let archived = Scratchpad::archive(&scratchpad_id, user_id, &db, true)
|
||||
.await
|
||||
.expect("Failed to archive");
|
||||
assert!(archived.is_archived);
|
||||
assert!(archived.archived_at.is_some());
|
||||
assert!(archived.ingested_at.is_some());
|
||||
|
||||
let restored = Scratchpad::restore(&scratchpad_id, user_id, &db)
|
||||
.await
|
||||
.expect("Failed to restore");
|
||||
assert!(!restored.is_archived);
|
||||
assert!(restored.archived_at.is_none());
|
||||
assert!(restored.ingested_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_content() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let user_id = "test_user";
|
||||
let scratchpad = Scratchpad::new(user_id.to_string(), "Test".to_string());
|
||||
let scratchpad_id = scratchpad.id.clone();
|
||||
|
||||
db.store_item(scratchpad).await.unwrap();
|
||||
|
||||
let new_content = "Updated content";
|
||||
let updated = Scratchpad::update_content(&scratchpad_id, user_id, new_content, &db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated.content, new_content);
|
||||
assert!(!updated.is_dirty);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_content_unauthorized() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let owner_id = "owner";
|
||||
let other_user = "other_user";
|
||||
let scratchpad = Scratchpad::new(owner_id.to_string(), "Test".to_string());
|
||||
let scratchpad_id = scratchpad.id.clone();
|
||||
|
||||
db.store_item(scratchpad).await.unwrap();
|
||||
|
||||
let result = Scratchpad::update_content(&scratchpad_id, other_user, "Hacked", &db).await;
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(AppError::Auth(_)) => {}
|
||||
_ => panic!("Expected Auth error"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_scratchpad() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let user_id = "test_user";
|
||||
let scratchpad = Scratchpad::new(user_id.to_string(), "Test".to_string());
|
||||
let scratchpad_id = scratchpad.id.clone();
|
||||
|
||||
db.store_item(scratchpad).await.unwrap();
|
||||
|
||||
// Delete should succeed
|
||||
let result = Scratchpad::delete(&scratchpad_id, user_id, &db).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify it's gone
|
||||
let retrieved: Option<Scratchpad> = db.get_item(&scratchpad_id).await.unwrap();
|
||||
assert!(retrieved.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_unauthorized() {
|
||||
let namespace = "test_ns";
|
||||
let database = &Uuid::new_v4().to_string();
|
||||
let db = SurrealDbClient::memory(namespace, database)
|
||||
.await
|
||||
.expect("Failed to start in-memory surrealdb");
|
||||
|
||||
db.apply_migrations()
|
||||
.await
|
||||
.expect("Failed to apply migrations");
|
||||
|
||||
let owner_id = "owner";
|
||||
let other_user = "other_user";
|
||||
let scratchpad = Scratchpad::new(owner_id.to_string(), "Test".to_string());
|
||||
let scratchpad_id = scratchpad.id.clone();
|
||||
|
||||
db.store_item(scratchpad).await.unwrap();
|
||||
|
||||
let result = Scratchpad::delete(&scratchpad_id, other_user, &db).await;
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(AppError::Auth(_)) => {}
|
||||
_ => panic!("Expected Auth error"),
|
||||
}
|
||||
|
||||
// Verify it still exists
|
||||
let retrieved: Option<Scratchpad> = db.get_item(&scratchpad_id).await.unwrap();
|
||||
assert!(retrieved.is_some());
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -36,6 +36,7 @@ where
|
||||
.add_protected_routes(routes::content::router())
|
||||
.add_protected_routes(routes::knowledge::router())
|
||||
.add_protected_routes(routes::ingestion::router())
|
||||
.add_protected_routes(routes::scratchpad::router())
|
||||
.with_compression()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod content;
|
||||
pub mod index;
|
||||
pub mod ingestion;
|
||||
pub mod knowledge;
|
||||
pub mod scratchpad;
|
||||
pub mod search;
|
||||
|
||||
490
html-router/src/routes/scratchpad/handlers.rs
Normal file
490
html-router/src/routes/scratchpad/handlers.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Form,
|
||||
};
|
||||
use axum_htmx::{HxBoosted, HxRequest, HX_TRIGGER};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::html_state::HtmlState;
|
||||
use crate::middlewares::{
|
||||
auth_middleware::RequireUser,
|
||||
response_middleware::{HtmlError, TemplateResponse},
|
||||
};
|
||||
use common::storage::types::{
|
||||
conversation::Conversation, ingestion_payload::IngestionPayload, ingestion_task::IngestionTask,
|
||||
scratchpad::Scratchpad, user::User,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ScratchpadPageData {
|
||||
user: User,
|
||||
scratchpads: Vec<ScratchpadListItem>,
|
||||
archived_scratchpads: Vec<ScratchpadArchiveItem>,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
new_scratchpad: Option<ScratchpadDetail>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ScratchpadListItem {
|
||||
id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
last_saved_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ScratchpadDetailData {
|
||||
user: User,
|
||||
scratchpad: ScratchpadDetail,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ScratchpadArchiveItem {
|
||||
id: String,
|
||||
title: String,
|
||||
archived_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
ingested_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ScratchpadDetail {
|
||||
id: String,
|
||||
title: String,
|
||||
content: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
last_saved_at: String,
|
||||
is_dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AutoSaveResponse {
|
||||
success: bool,
|
||||
last_saved_at_display: String,
|
||||
last_saved_at_iso: String,
|
||||
}
|
||||
|
||||
impl From<&Scratchpad> for ScratchpadListItem {
|
||||
fn from(value: &Scratchpad) -> Self {
|
||||
Self {
|
||||
id: value.id.clone(),
|
||||
title: value.title.clone(),
|
||||
content: value.content.clone(),
|
||||
last_saved_at: value.last_saved_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Scratchpad> for ScratchpadArchiveItem {
|
||||
fn from(value: &Scratchpad) -> Self {
|
||||
Self {
|
||||
id: value.id.clone(),
|
||||
title: value.title.clone(),
|
||||
archived_at: value
|
||||
.archived_at
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string()),
|
||||
ingested_at: value
|
||||
.ingested_at
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Scratchpad> for ScratchpadDetail {
|
||||
fn from(value: &Scratchpad) -> Self {
|
||||
Self {
|
||||
id: value.id.clone(),
|
||||
title: value.title.clone(),
|
||||
content: value.content.clone(),
|
||||
created_at: value.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
updated_at: value.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
last_saved_at: value.last_saved_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||
is_dirty: value.is_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateScratchpadForm {
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateScratchpadForm {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateTitleForm {
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditTitleQuery {
|
||||
edit_title: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn show_scratchpad_page(
|
||||
RequireUser(user): RequireUser,
|
||||
HxRequest(is_htmx): HxRequest,
|
||||
HxBoosted(is_boosted): HxBoosted,
|
||||
State(state): State<HtmlState>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
if is_htmx && !is_boosted {
|
||||
Ok(TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
))
|
||||
} else {
|
||||
Ok(TemplateResponse::new_template(
|
||||
"scratchpad/base.html",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn show_scratchpad_modal(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
Query(query): Query<EditTitleQuery>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_detail = ScratchpadDetail::from(&scratchpad);
|
||||
|
||||
// Handle edit_title query parameter if needed in future
|
||||
let _ = query.edit_title.unwrap_or(false);
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"scratchpad/editor_modal.html",
|
||||
ScratchpadDetailData {
|
||||
user,
|
||||
scratchpad: scratchpad_detail,
|
||||
conversation_archive,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Form(form): Form<CreateScratchpadForm>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
let user_id = user.id.clone();
|
||||
let scratchpad = Scratchpad::new(user_id.clone(), form.title);
|
||||
let _stored = state.db.store_item(scratchpad.clone()).await?;
|
||||
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
Ok(TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: Some(ScratchpadDetail::from(&scratchpad)),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn auto_save_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
Form(form): Form<UpdateScratchpadForm>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
let updated =
|
||||
Scratchpad::update_content(&scratchpad_id, &user.id, &form.content, &state.db).await?;
|
||||
|
||||
// Return a success indicator for auto-save
|
||||
Ok(axum::Json(AutoSaveResponse {
|
||||
success: true,
|
||||
last_saved_at_display: updated
|
||||
.last_saved_at
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string(),
|
||||
last_saved_at_iso: updated.last_saved_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_scratchpad_title(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
Form(form): Form<UpdateTitleForm>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
Scratchpad::update_title(&scratchpad_id, &user.id, &form.title, &state.db).await?;
|
||||
|
||||
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"scratchpad/editor_modal.html",
|
||||
ScratchpadDetailData {
|
||||
user,
|
||||
scratchpad: ScratchpadDetail::from(&scratchpad),
|
||||
conversation_archive,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
Scratchpad::delete(&scratchpad_id, &user.id, &state.db).await?;
|
||||
|
||||
// Return the updated main section content
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
Ok(TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn ingest_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
let scratchpad = Scratchpad::get_by_id(&scratchpad_id, &user.id, &state.db).await?;
|
||||
|
||||
if scratchpad.content.trim().is_empty() {
|
||||
let trigger_payload = serde_json::json!({
|
||||
"toast": {
|
||||
"title": "Ingestion skipped",
|
||||
"description": "Cannot ingest an empty scratchpad.",
|
||||
"type": "warning"
|
||||
}
|
||||
});
|
||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
||||
r#"{"toast":{"title":"Ingestion skipped","description":"Cannot ingest an empty scratchpad.","type":"warning"}}"#.to_string()
|
||||
});
|
||||
|
||||
let mut response = Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap_or_else(|_| Response::new(axum::body::Body::empty()));
|
||||
|
||||
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
||||
response.headers_mut().insert(HX_TRIGGER, header_value);
|
||||
}
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Create ingestion task
|
||||
|
||||
let payload = IngestionPayload::Text {
|
||||
text: scratchpad.content.clone(),
|
||||
context: format!("Scratchpad: {}", scratchpad.title),
|
||||
category: "scratchpad".to_string(),
|
||||
user_id: user.id.clone(),
|
||||
};
|
||||
|
||||
let task = IngestionTask::new(payload, user.id.clone());
|
||||
state.db.store_item(task).await?;
|
||||
|
||||
// Archive the scratchpad once queued for ingestion
|
||||
Scratchpad::archive(&scratchpad_id, &user.id, &state.db, true).await?;
|
||||
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
let trigger_payload = serde_json::json!({
|
||||
"toast": {
|
||||
"title": "Ingestion queued",
|
||||
"description": format!("\"{}\" archived and added to the ingestion queue.", scratchpad.title),
|
||||
"type": "success"
|
||||
}
|
||||
});
|
||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
||||
r#"{"toast":{"title":"Ingestion queued","description":"Scratchpad archived and added to the ingestion queue.","type":"success"}}"#.to_string()
|
||||
});
|
||||
|
||||
let template_response = TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut response = template_response.into_response();
|
||||
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
||||
response.headers_mut().insert(HX_TRIGGER, header_value);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn restore_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
Scratchpad::restore(&scratchpad_id, &user.id, &state.db).await?;
|
||||
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
let trigger_payload = serde_json::json!({
|
||||
"toast": {
|
||||
"title": "Scratchpad restored",
|
||||
"description": "The scratchpad is back in your active list.",
|
||||
"type": "info"
|
||||
}
|
||||
});
|
||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
||||
r#"{"toast":{"title":"Scratchpad restored","description":"The scratchpad is back in your active list.","type":"info"}}"#.to_string()
|
||||
});
|
||||
|
||||
let template_response = TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut response = template_response.into_response();
|
||||
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
||||
response.headers_mut().insert(HX_TRIGGER, header_value);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn archive_scratchpad(
|
||||
RequireUser(user): RequireUser,
|
||||
State(state): State<HtmlState>,
|
||||
Path(scratchpad_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
Scratchpad::archive(&scratchpad_id, &user.id, &state.db, false).await?;
|
||||
|
||||
let scratchpads = Scratchpad::get_by_user(&user.id, &state.db).await?;
|
||||
let archived_scratchpads = Scratchpad::get_archived_by_user(&user.id, &state.db).await?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let scratchpad_list: Vec<ScratchpadListItem> =
|
||||
scratchpads.iter().map(ScratchpadListItem::from).collect();
|
||||
let archived_list: Vec<ScratchpadArchiveItem> = archived_scratchpads
|
||||
.iter()
|
||||
.map(ScratchpadArchiveItem::from)
|
||||
.collect();
|
||||
|
||||
let trigger_payload = serde_json::json!({
|
||||
"toast": {
|
||||
"title": "Scratchpad archived",
|
||||
"description": "You can find it in the archive drawer below.",
|
||||
"type": "warning"
|
||||
}
|
||||
});
|
||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|_| {
|
||||
r#"{"toast":{"title":"Scratchpad archived","description":"You can find it in the archive drawer below.","type":"warning"}}"#.to_string()
|
||||
});
|
||||
|
||||
let template_response = TemplateResponse::new_partial(
|
||||
"scratchpad/base.html",
|
||||
"main",
|
||||
ScratchpadPageData {
|
||||
user,
|
||||
scratchpads: scratchpad_list,
|
||||
archived_scratchpads: archived_list,
|
||||
conversation_archive,
|
||||
new_scratchpad: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut response = template_response.into_response();
|
||||
if let Ok(header_value) = HeaderValue::from_str(&trigger_value) {
|
||||
response.headers_mut().insert(HX_TRIGGER, header_value);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
40
html-router/src/routes/scratchpad/mod.rs
Normal file
40
html-router/src/routes/scratchpad/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
mod handlers;
|
||||
use axum::{
|
||||
extract::FromRef,
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::html_state::HtmlState;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
HtmlState: FromRef<S>,
|
||||
{
|
||||
Router::new()
|
||||
.route("/scratchpad", get(handlers::show_scratchpad_page))
|
||||
.route("/scratchpad", post(handlers::create_scratchpad))
|
||||
.route(
|
||||
"/scratchpad/{id}/modal",
|
||||
get(handlers::show_scratchpad_modal),
|
||||
)
|
||||
.route(
|
||||
"/scratchpad/{id}/auto-save",
|
||||
patch(handlers::auto_save_scratchpad),
|
||||
)
|
||||
.route(
|
||||
"/scratchpad/{id}/title",
|
||||
patch(handlers::update_scratchpad_title),
|
||||
)
|
||||
.route("/scratchpad/{id}", delete(handlers::delete_scratchpad))
|
||||
.route(
|
||||
"/scratchpad/{id}/archive",
|
||||
post(handlers::archive_scratchpad),
|
||||
)
|
||||
.route("/scratchpad/{id}/ingest", post(handlers::ingest_scratchpad))
|
||||
.route(
|
||||
"/scratchpad/{id}/restore",
|
||||
post(handlers::restore_scratchpad),
|
||||
)
|
||||
}
|
||||
5
html-router/templates/icons/pencil_icon.html
Normal file
5
html-router/templates/icons/pencil_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
width="20" height="20" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
5
html-router/templates/icons/scratchpad_icon.html
Normal file
5
html-router/templates/icons/scratchpad_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
width="20" height="20" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
113
html-router/templates/scratchpad/base.html
Normal file
113
html-router/templates/scratchpad/base.html
Normal file
@@ -0,0 +1,113 @@
|
||||
{% extends 'body_base.html' %}
|
||||
|
||||
{% block title %}Minne - Scratchpad{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
|
||||
<div class="container">
|
||||
{% block header %}
|
||||
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-extrabold tracking-tight">Scratchpads</h2>
|
||||
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML" class="flex gap-2">
|
||||
<input type="text" name="title" placeholder="Enter scratchpad title..." class="nb-input nb-input-sm" required>
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
{% include "icons/scratchpad_icon.html" %} Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for scratchpad in scratchpads %}
|
||||
<div class="nb-card p-4 hover:nb-shadow-hover transition-all">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h3 class="font-semibold text-lg truncate flex-1">{{ scratchpad.title }}</h3>
|
||||
<div class="flex gap-1 ml-2">
|
||||
<button hx-get="/scratchpad/{{ scratchpad.id }}/modal" hx-target="#modal" hx-swap="innerHTML"
|
||||
class="nb-btn nb-btn-sm btn-ghost" title="Edit scratchpad">
|
||||
{% include "icons/pencil_icon.html" %}
|
||||
</button>
|
||||
<form hx-post="/scratchpad/{{ scratchpad.id }}/archive" hx-target="#main_section" hx-swap="outerHTML"
|
||||
class="inline-flex">
|
||||
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-warning" title="Archive scratchpad">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/70 mb-2">
|
||||
{{ scratchpad.content[:100] }}{% if scratchpad.content|length > 100 %}...{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/50">
|
||||
Last saved: {{ scratchpad.last_saved_at }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-full nb-panel p-8 text-center">
|
||||
<h3 class="text-lg font-semibold mt-2 mb-2">No scratchpads yet</h3>
|
||||
<p class="text-base-content/70 mb-4">Create your first scratchpad to start jotting down ideas</p>
|
||||
<form hx-post="/scratchpad" hx-target="#main_section" hx-swap="outerHTML"
|
||||
class="inline-flex gap-2">
|
||||
<input type="text" name="title" placeholder="My first scratchpad..." class="nb-input" required>
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
{% include "icons/scratchpad_icon.html" %} Create Scratchpad
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% if archived_scratchpads %}
|
||||
<div class="mt-6">
|
||||
<details class="nb-panel p-3 space-y-4">
|
||||
<summary class="flex items-center justify-between gap-2 text-sm font-semibold cursor-pointer">
|
||||
<span>Archived Scratchpads</span>
|
||||
<span class="nb-badge">{{ archived_scratchpads|length }}</span>
|
||||
</summary>
|
||||
|
||||
<div class="text-sm text-base-content/60">Archived scratchpads were ingested into your knowledge base. You can
|
||||
restore them if you want to keep editing.</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for scratchpad in archived_scratchpads %}
|
||||
<div class="nb-card p-3 space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-semibold text-base truncate" title="{{ scratchpad.title }}">{{ scratchpad.title }}</h4>
|
||||
<div class="text-xs text-base-content/50">Archived {{ scratchpad.archived_at }}</div>
|
||||
{% if scratchpad.ingested_at %}
|
||||
<div class="text-xs text-base-content/40">Ingestion started {{ scratchpad.ingested_at }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 flex-wrap justify-end">
|
||||
<form hx-post="/scratchpad/{{ scratchpad.id }}/restore" hx-target="#main_section" hx-swap="outerHTML"
|
||||
class="inline-flex">
|
||||
<button type="submit" class="nb-btn nb-btn-sm">
|
||||
Restore
|
||||
</button>
|
||||
</form>
|
||||
<form hx-delete="/scratchpad/{{ scratchpad.id }}" hx-target="#main_section" hx-swap="outerHTML"
|
||||
hx-confirm="Permanently delete this scratchpad?" class="inline-flex">
|
||||
<button type="submit" class="nb-btn nb-btn-sm btn-ghost text-error" title="Delete permanently">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% if new_scratchpad %}
|
||||
<div hx-swap-oob="innerHTML:#modal">
|
||||
<div hx-get="/scratchpad/{{ new_scratchpad.id }}/modal" hx-trigger="load" hx-target="#modal" hx-swap="innerHTML"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
286
html-router/templates/scratchpad/editor_modal.html
Normal file
286
html-router/templates/scratchpad/editor_modal.html
Normal file
@@ -0,0 +1,286 @@
|
||||
{% extends "modal_base.html" %}
|
||||
|
||||
{% block modal_class %}w-11/12 max-w-[90ch] max-h-[95%] overflow-y-auto{% endblock %}
|
||||
|
||||
{% block form_attributes %}{% endblock %}
|
||||
|
||||
{% block modal_content %}
|
||||
<h3 class="text-xl font-extrabold tracking-tight">
|
||||
<div class="flex items-center gap-2" id="title-container">
|
||||
<span class="font-semibold text-lg flex-1 truncate" id="title-display">{{ scratchpad.title }}</span>
|
||||
<button type="button" onclick="editTitle()" class="nb-btn nb-btn-sm btn-ghost">
|
||||
{% include "icons/edit_icon.html" %} Edit title
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden title form -->
|
||||
<form id="title-form" hx-patch="/scratchpad/{{ scratchpad.id }}/title" hx-target="#body_modal" hx-swap="outerHTML"
|
||||
class="hidden flex items-center gap-2">
|
||||
<input type="text" name="title" value="{{ scratchpad.title }}"
|
||||
class="nb-input nb-input-sm font-semibold text-lg flex-1" id="title-input">
|
||||
<button type="submit" class="nb-btn nb-btn-sm">{% include "icons/check_icon.html" %}</button>
|
||||
<button type="button" onclick="cancelEditTitle()" class="nb-btn nb-btn-sm btn-ghost">{% include "icons/x_icon.html" %}</button>
|
||||
</form>
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs text-base-content/50 flex items-center gap-2">
|
||||
<span>Last saved: <span id="last-saved">{{ scratchpad.last_saved_at }}</span></span>
|
||||
<span id="save-status"
|
||||
class="inline-flex items-center gap-1 text-success opacity-0 transition-opacity duration-300 pointer-events-none">
|
||||
{% include "icons/check_icon.html" %} <span class="uppercase tracking-wider text-[0.7em]">Saved</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form id="auto-save-form"
|
||||
hx-patch="/scratchpad/{{ scratchpad.id }}/auto-save"
|
||||
hx-trigger="keyup changed delay:2s, focusout"
|
||||
hx-indicator="#save-indicator"
|
||||
hx-swap="none"
|
||||
class="flex flex-col gap-2">
|
||||
<label class="w-full">
|
||||
<textarea name="content" id="scratchpad-content"
|
||||
class="nb-input w-full min-h-[60vh] resize-none font-mono text-sm"
|
||||
placeholder="Start typing your thoughts... (Tab to indent, Shift+Tab to outdent)"
|
||||
autofocus>{{ scratchpad.content }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div id="save-indicator" class="htmx-indicator text-sm text-base-content/50 hidden">
|
||||
{% include "icons/refresh_icon.html" %} Saving...
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/50">
|
||||
<span id="char-count">{{ scratchpad.content|length }}</span> characters
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="action-row" class="flex gap-2 justify-between items-center">
|
||||
<form hx-post="/scratchpad/{{ scratchpad.id }}/ingest"
|
||||
hx-target="#main_section"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()"
|
||||
class="inline flex flex-col gap-3"
|
||||
id="ingest-form">
|
||||
<button type="button" class="nb-btn nb-cta" onclick="toggleIngestConfirmation(true)"
|
||||
data-role="ingest-trigger">
|
||||
{% include "icons/send_icon.html" %} Ingest as Content
|
||||
</button>
|
||||
<div id="ingest-warning"
|
||||
class="nb-card bg-warning/10 border border-warning text-warning-content text-sm leading-relaxed flex flex-col gap-2 p-3 hidden">
|
||||
<div>
|
||||
<strong class="font-semibold text-warning">Before you ingest</strong>
|
||||
<p>
|
||||
This will archive the scratchpad right away. After ingestion finishes you can review the content from the
|
||||
<a href="/content" class="nb-link">Content</a> page, and archived scratchpads remain available below with a restore option.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="submit" class="nb-btn nb-btn-sm nb-cta">
|
||||
Confirm ingest
|
||||
</button>
|
||||
<button type="button" class="nb-btn nb-btn-sm btn-ghost" onclick="toggleIngestConfirmation(false)">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="archive-form" hx-post="/scratchpad/{{ scratchpad.id }}/archive" hx-target="#main_section"
|
||||
hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful) document.getElementById('body_modal').close()"
|
||||
class="inline">
|
||||
<button type="submit" class="nb-btn nb-btn-ghost text-warning">
|
||||
{% include "icons/delete_icon.html" %} Archive
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Title editing functions
|
||||
function editTitle() {
|
||||
const titleContainer = document.getElementById('title-container');
|
||||
const titleForm = document.getElementById('title-form');
|
||||
const titleInput = document.getElementById('title-input');
|
||||
if (!titleContainer || !titleForm) return;
|
||||
|
||||
titleContainer.classList.add('hidden');
|
||||
titleForm.classList.remove('hidden');
|
||||
|
||||
if (titleInput) {
|
||||
titleInput.focus();
|
||||
titleInput.select();
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTitle() {
|
||||
const titleContainer = document.getElementById('title-container');
|
||||
const titleForm = document.getElementById('title-form');
|
||||
if (!titleContainer || !titleForm) return;
|
||||
|
||||
titleContainer.classList.remove('hidden');
|
||||
titleForm.classList.add('hidden');
|
||||
}
|
||||
|
||||
(function initScratchpadModal() {
|
||||
const modal = document.getElementById('body_modal');
|
||||
if (!modal) return;
|
||||
|
||||
const textarea = modal.querySelector('#scratchpad-content');
|
||||
const charCount = modal.querySelector('#char-count');
|
||||
const lastSaved = modal.querySelector('#last-saved');
|
||||
const saveStatus = modal.querySelector('#save-status');
|
||||
const autoSaveForm = modal.querySelector('#auto-save-form');
|
||||
const ingestWarning = modal.querySelector('#ingest-warning');
|
||||
const ingestForm = modal.querySelector('#ingest-form');
|
||||
const actionRow = modal.querySelector('#action-row');
|
||||
let saveStatusTimeout;
|
||||
|
||||
const updateCharCount = () => {
|
||||
if (!textarea || !charCount) return;
|
||||
charCount.textContent = textarea.value.length;
|
||||
};
|
||||
|
||||
const autoResize = () => {
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', () => {
|
||||
updateCharCount();
|
||||
autoResize();
|
||||
});
|
||||
|
||||
// Tab support - insert 4 spaces or handle outdenting
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const value = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: Outdent - remove up to 4 spaces from start of current line
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = value.substring(lineStart, start);
|
||||
const leadingSpaces = currentLine.match(/^ */)?.[0]?.length || 0;
|
||||
const spacesToRemove = Math.min(4, leadingSpaces);
|
||||
|
||||
if (spacesToRemove > 0) {
|
||||
textarea.value = value.substring(0, lineStart) +
|
||||
currentLine.substring(spacesToRemove) +
|
||||
value.substring(start);
|
||||
|
||||
// Adjust cursor position
|
||||
textarea.selectionStart = textarea.selectionEnd = start - spacesToRemove;
|
||||
}
|
||||
} else {
|
||||
// Tab: Indent - insert 4 spaces at cursor position
|
||||
textarea.value = value.substring(0, start) + ' ' + value.substring(end);
|
||||
|
||||
// Restore cursor position after inserted spaces
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 4;
|
||||
}
|
||||
|
||||
// Trigger input event to update character count and auto-resize
|
||||
textarea.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
|
||||
updateCharCount();
|
||||
autoResize();
|
||||
}
|
||||
|
||||
if (autoSaveForm) {
|
||||
autoSaveForm.addEventListener('htmx:beforeRequest', (evt) => {
|
||||
if (evt.detail.elt !== autoSaveForm) return;
|
||||
if (saveStatus) {
|
||||
saveStatus.classList.add('opacity-0');
|
||||
saveStatus.classList.remove('opacity-100');
|
||||
}
|
||||
});
|
||||
|
||||
autoSaveForm.addEventListener('htmx:afterRequest', (evt) => {
|
||||
if (evt.detail.elt !== autoSaveForm) return;
|
||||
if (!evt.detail.successful) return;
|
||||
|
||||
const xhr = evt.detail.xhr;
|
||||
if (xhr && xhr.responseText) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data.last_saved_at_display && lastSaved) {
|
||||
lastSaved.textContent = data.last_saved_at_display;
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
|
||||
if (saveStatus) {
|
||||
if (saveStatusTimeout) {
|
||||
clearTimeout(saveStatusTimeout);
|
||||
}
|
||||
saveStatus.classList.remove('opacity-0');
|
||||
saveStatus.classList.add('opacity-100');
|
||||
saveStatusTimeout = setTimeout(() => {
|
||||
saveStatus.classList.add('opacity-0');
|
||||
saveStatus.classList.remove('opacity-100');
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ingestForm) {
|
||||
ingestForm.addEventListener('htmx:afterRequest', (evt) => {
|
||||
if (evt.detail.elt !== ingestForm) return;
|
||||
toggleIngestConfirmation(false);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function toggleIngestConfirmation(show) {
|
||||
const modal = document.getElementById('body_modal');
|
||||
if (!modal) return;
|
||||
|
||||
const warning = modal.querySelector('#ingest-warning');
|
||||
const actionRow = modal.querySelector('#action-row');
|
||||
const ingestForm = modal.querySelector('#ingest-form');
|
||||
const archiveForm = modal.querySelector('#archive-form');
|
||||
const ingestButton = modal.querySelector('[data-role="ingest-trigger"]');
|
||||
const confirmButton = warning ? warning.querySelector('button[type="submit"]') : null;
|
||||
if (!warning || !ingestButton || !actionRow || !ingestForm) return;
|
||||
|
||||
if (show) {
|
||||
warning.classList.remove('hidden');
|
||||
ingestButton.classList.add('hidden');
|
||||
actionRow.classList.add('flex-col', 'items-stretch');
|
||||
actionRow.classList.remove('items-center', 'justify-between');
|
||||
ingestForm.classList.add('w-full');
|
||||
if (archiveForm) {
|
||||
archiveForm.classList.add('w-full');
|
||||
}
|
||||
if (confirmButton) {
|
||||
confirmButton.focus();
|
||||
}
|
||||
} else {
|
||||
warning.classList.add('hidden');
|
||||
ingestButton.classList.remove('hidden');
|
||||
actionRow.classList.remove('flex-col', 'items-stretch');
|
||||
actionRow.classList.add('items-center', 'justify-between');
|
||||
ingestForm.classList.remove('w-full');
|
||||
if (archiveForm) {
|
||||
archiveForm.classList.remove('w-full');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block primary_actions %}
|
||||
<!-- No additional actions needed -->
|
||||
{% endblock %}
|
||||
@@ -3,131 +3,134 @@
|
||||
{% for result in search_result %}
|
||||
<li class="p-4 u-hairline hover:bg-base-200/40 flex gap-3">
|
||||
{% if result.result_type == "text_content" %}
|
||||
{% set tc = result.text_content %}
|
||||
<div class="w-10 h-10 flex-shrink-0 self-start mt-1 grid place-items-center border-2 border-neutral bg-base-100 shadow-[4px_4px_0_0_#000]">
|
||||
{% if tc.url_info and tc.url_info.url %}
|
||||
<div class="tooltip tooltip-right" data-tip="Web Link">
|
||||
{% include "icons/link_icon.html" %}
|
||||
</div>
|
||||
{% elif tc.file_info and tc.file_info.file_name %}
|
||||
<div class="tooltip tooltip-right" data-tip="File Document">
|
||||
{% include "icons/document_icon.html" %}
|
||||
</div>
|
||||
{% set tc = result.text_content %}
|
||||
<div
|
||||
class="w-10 h-10 flex-shrink-0 self-start mt-1 grid place-items-center border-2 border-neutral bg-base-100 shadow-[4px_4px_0_0_#000]">
|
||||
{% if tc.url_info and tc.url_info.url %}
|
||||
<div class="tooltip tooltip-right" data-tip="Web Link">
|
||||
{% include "icons/link_icon.html" %}
|
||||
</div>
|
||||
{% elif tc.file_info and tc.file_info.file_name %}
|
||||
<div class="tooltip tooltip-right" data-tip="File Document">
|
||||
{% include "icons/document_icon.html" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tooltip tooltip-right" data-tip="Text Content">
|
||||
{% include "icons/bars_icon.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-extrabold mb-1 leading-snug">
|
||||
<a hx-get="/content/{{ tc.id }}/read" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
|
||||
{% set title_text = tc.highlighted_url_title
|
||||
| default(tc.url_info.title if tc.url_info else none, true)
|
||||
| default(tc.highlighted_file_name, true)
|
||||
| default(tc.file_info.file_name if tc.file_info else none, true)
|
||||
| default("Text snippet: " ~ (tc.id | string)[-8:], true) %}
|
||||
{{ title_text }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="markdown-content prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6"
|
||||
data-content="{{tc.highlighted_text | escape}}">
|
||||
{% if tc.highlighted_text %}
|
||||
{{ tc.highlighted_text | escape }}
|
||||
{% elif tc.text %}
|
||||
{{ tc.text | escape }}
|
||||
{% else %}
|
||||
<div class="tooltip tooltip-right" data-tip="Text Content">
|
||||
{% include "icons/bars_icon.html" %}
|
||||
</div>
|
||||
<span class="italic opacity-60">No text preview available.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-extrabold mb-1 leading-snug">
|
||||
<a hx-get="/content/{{ tc.id }}/read" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
|
||||
{% set title_text = tc.highlighted_url_title
|
||||
| default(tc.url_info.title if tc.url_info else none, true)
|
||||
| default(tc.highlighted_file_name, true)
|
||||
| default(tc.file_info.file_name if tc.file_info else none, true)
|
||||
| default("Text snippet: " ~ (tc.id | string)[-8:], true) %}
|
||||
{{ title_text | safe }}
|
||||
<div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Category</span>
|
||||
<span class="nb-badge">{{ tc.highlighted_category | default(tc.category, true) | safe }}</span>
|
||||
</span>
|
||||
|
||||
{% if tc.highlighted_context or tc.context %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Context</span>
|
||||
<span class="nb-badge">{{ tc.highlighted_context | default(tc.context, true) | safe }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if tc.url_info and tc.url_info.url %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Source</span>
|
||||
<a href="{{ tc.url_info.url }}" target="_blank" class="nb-link truncate" title="{{ tc.url_info.url }}">
|
||||
{{ tc.highlighted_url | default(tc.url_info.url ) | safe }}
|
||||
</a>
|
||||
</h3>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="markdown-content prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6" data-content="{{tc.highlighted_text | escape}}">
|
||||
{% if tc.highlighted_text %}
|
||||
{{ tc.highlighted_text | escape }}
|
||||
{% elif tc.text %}
|
||||
{{ tc.text | escape }}
|
||||
{% else %}
|
||||
<span class="italic opacity-60">No text preview available.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Category</span>
|
||||
<span class="nb-badge">{{ tc.highlighted_category | default(tc.category, true) | safe }}</span>
|
||||
</span>
|
||||
|
||||
{% if tc.highlighted_context or tc.context %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Context</span>
|
||||
<span class="nb-badge">{{ tc.highlighted_context | default(tc.context, true) | safe }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if tc.url_info and tc.url_info.url %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Source</span>
|
||||
<a href="{{ tc.url_info.url }}" target="_blank" class="nb-link truncate" title="{{ tc.url_info.url }}">
|
||||
{{ tc.highlighted_url | default(tc.url_info.url ) | safe }}
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Score</span>
|
||||
<span class="nb-badge">{{ result.score }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Score</span>
|
||||
<span class="nb-badge">{{ result.score }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% elif result.result_type == "knowledge_entity" %}
|
||||
{% set entity = result.knowledge_entity %}
|
||||
<div class="w-10 h-10 flex-shrink-0 self-start mt-1 grid place-items-center border-2 border-neutral bg-base-100 shadow-[4px_4px_0_0_#000]">
|
||||
<div class="tooltip tooltip-right" data-tip="Knowledge Entity">
|
||||
{% include "icons/book_icon.html" %}
|
||||
</div>
|
||||
{% set entity = result.knowledge_entity %}
|
||||
<div
|
||||
class="w-10 h-10 flex-shrink-0 self-start mt-1 grid place-items-center border-2 border-neutral bg-base-100 shadow-[4px_4px_0_0_#000]">
|
||||
<div class="tooltip tooltip-right" data-tip="Knowledge Entity">
|
||||
{% include "icons/book_icon.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-extrabold mb-1 leading-snug">
|
||||
<a hx-get="/knowledge-entity/{{ entity.id }}" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
|
||||
{% set entity_title = entity.highlighted_name | default(entity.name, true) %}
|
||||
{{ entity_title }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="prose prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6">
|
||||
{% if entity.highlighted_description %}
|
||||
{{ entity.highlighted_description }}
|
||||
{% elif entity.description %}
|
||||
{{ entity.description | escape }}
|
||||
{% else %}
|
||||
<span class="italic opacity-60">No description available.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-extrabold mb-1 leading-snug">
|
||||
<a hx-get="/knowledge-entity/{{ entity.id }}" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
|
||||
{% set entity_title = entity.highlighted_name | default(entity.name, true) %}
|
||||
{{ entity_title | safe }}
|
||||
</a>
|
||||
</h3>
|
||||
<div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Entity Type</span>
|
||||
<span class="nb-badge">{{ entity.entity_type }}</span>
|
||||
</span>
|
||||
|
||||
<div class="prose prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6">
|
||||
{% if entity.highlighted_description %}
|
||||
{{ entity.highlighted_description | safe }}
|
||||
{% elif entity.description %}
|
||||
{{ entity.description | escape }}
|
||||
{% else %}
|
||||
<span class="italic opacity-60">No description available.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if entity.source_id %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Source ID</span>
|
||||
<span class="nb-badge truncate max-w-xs" title="{{ entity.source_id }}">{{ entity.source_id }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Entity Type</span>
|
||||
<span class="nb-badge">{{ entity.entity_type }}</span>
|
||||
</span>
|
||||
|
||||
{% if entity.source_id %}
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Source ID</span>
|
||||
<span class="nb-badge truncate max-w-xs" title="{{ entity.source_id }}">{{ entity.source_id }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Score</span>
|
||||
<span class="nb-badge">{{ result.score }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Score</span>
|
||||
<span class="nb-badge">{{ result.score }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
{% elif query_param is defined and query_param | trim != "" %}
|
||||
<div class="nb-panel p-5 text-center">
|
||||
<p class="text-xl font-extrabold mb-2">No results for “{{ query_param | escape }}”.</p>
|
||||
<p class="text-sm opacity-70">Try different keywords or check for typos.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="nb-panel p-5 text-center">
|
||||
<p class="text-lg font-semibold">Enter a term above to search your knowledge base.</p>
|
||||
<p class="text-sm opacity-70">Results will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -9,6 +9,8 @@
|
||||
{% include "icons/chat_icon.html" %}
|
||||
{% elif name == "search" %}
|
||||
{% include "icons/search_icon.html" %}
|
||||
{% elif name == "scratchpad" %}
|
||||
{% include "icons/scratchpad_icon.html" %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -26,7 +28,8 @@
|
||||
("/knowledge", "book", "Knowledge"),
|
||||
("/content", "document", "Content"),
|
||||
("/chat", "chat", "Chat"),
|
||||
("/search", "search", "Search")
|
||||
("/search", "search", "Search"),
|
||||
("/scratchpad", "scratchpad", "Scratchpad")
|
||||
] %}
|
||||
<li>
|
||||
<a hx-boost="true" href="{{ url }}" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
|
||||
|
||||
Reference in New Issue
Block a user