feat: scratchpad

additional improvements

changelog

fix: wording
This commit is contained in:
Per Stark
2025-10-22 11:53:17 +02:00
parent 07b3e1a0e8
commit 3b805778b4
18 changed files with 1565 additions and 106 deletions

View File

@@ -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

View File

@@ -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"

View 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;

View File

@@ -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}

View 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;

View File

@@ -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;

View 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

View File

@@ -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()
}

View File

@@ -6,4 +6,5 @@ pub mod content;
pub mod index;
pub mod ingestion;
pub mod knowledge;
pub mod scratchpad;
pub mod search;

View 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)
}

View 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),
)
}

View 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

View 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

View 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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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">