mirror of
https://github.com/perstarkse/minne.git
synced 2026-03-30 22:32:07 +02:00
feat: scratchpad
additional improvements changelog fix: wording
This commit is contained in:
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