design: neobrutalist_theme into main

This commit is contained in:
Per Stark
2025-09-17 10:00:55 +02:00
parent 62d909bb7e
commit 6ea51095e8
57 changed files with 1791 additions and 951 deletions

112
Cargo.lock generated
View File

@@ -116,6 +116,21 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "alloc-no-stdlib"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-stdlib"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
dependencies = [
"alloc-no-stdlib",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -298,6 +313,19 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-compression"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977eb15ea9efd848bb8a4a1a2500347ed7f0bf794edf0dc3ddcf439f43d36b23"
dependencies = [
"compression-codecs",
"compression-core",
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "async-convert" name = "async-convert"
version = "1.0.0" version = "1.0.0"
@@ -886,6 +914,27 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "brotli"
version = "8.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.17.0" version = "3.17.0"
@@ -950,6 +999,8 @@ version = "1.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0"
dependencies = [ dependencies = [
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -1259,6 +1310,26 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "compression-codecs"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "485abf41ac0c8047c07c87c72c8fb3eb5197f6e9d7ded615dfd1a00ae00a0f64"
dependencies = [
"brotli",
"compression-core",
"flate2",
"memchr",
"zstd",
"zstd-safe",
]
[[package]]
name = "compression-core"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -2383,6 +2454,7 @@ dependencies = [
"axum_session_auth", "axum_session_auth",
"axum_session_surreal", "axum_session_surreal",
"axum_typed_multipart", "axum_typed_multipart",
"chrono",
"chrono-tz", "chrono-tz",
"common", "common",
"composite-retrieval", "composite-retrieval",
@@ -2897,6 +2969,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.2",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.77" version = "0.3.77"
@@ -5838,8 +5920,10 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.9.0", "bitflags 2.9.0",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@@ -6926,3 +7010,31 @@ dependencies = [
"quote", "quote",
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@@ -45,7 +45,7 @@ text-splitter = "0.18.1"
thiserror = "1.0.63" thiserror = "1.0.63"
tokio-util = { version = "0.7.15", features = ["io"] } tokio-util = { version = "0.7.15", features = ["io"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6.2", features = ["fs"] } tower-http = { version = "0.6.2", features = ["fs", "compression-full"] }
tower-serve-static = "0.1.1" tower-serve-static = "0.1.1"
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }

View File

@@ -1,4 +1,4 @@
pub mod categories; pub mod categories;
pub mod ingress; pub mod ingress;
pub mod readiness;
pub mod liveness; pub mod liveness;
pub mod readiness;

View File

@@ -1,3 +1,3 @@
pub mod db; pub mod db;
pub mod types;
pub mod store; pub mod store;
pub mod types;

View File

@@ -3,8 +3,8 @@ use std::sync::Arc;
use anyhow::{anyhow, Result as AnyResult}; use anyhow::{anyhow, Result as AnyResult};
use bytes::Bytes; use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
use futures::stream::BoxStream; use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
use object_store::local::LocalFileSystem; use object_store::local::LocalFileSystem;
use object_store::{path::Path as ObjPath, ObjectStore}; use object_store::{path::Path as ObjPath, ObjectStore};
@@ -26,12 +26,12 @@ pub async fn build_store(prefix: &Path, cfg: &AppConfig) -> object_store::Result
match cfg.storage { match cfg.storage {
StorageKind::Local => { StorageKind::Local => {
if !prefix.exists() { if !prefix.exists() {
tokio::fs::create_dir_all(prefix) tokio::fs::create_dir_all(prefix).await.map_err(|e| {
.await object_store::Error::Generic {
.map_err(|e| object_store::Error::Generic {
store: "LocalFileSystem", store: "LocalFileSystem",
source: e.into(), source: e.into(),
})?; }
})?;
} }
let store = LocalFileSystem::new_with_prefix(prefix)?; let store = LocalFileSystem::new_with_prefix(prefix)?;
Ok(Arc::new(store)) Ok(Arc::new(store))
@@ -46,7 +46,8 @@ pub fn resolve_base_dir(cfg: &AppConfig) -> PathBuf {
if cfg.data_dir.starts_with('/') { if cfg.data_dir.starts_with('/') {
PathBuf::from(&cfg.data_dir) PathBuf::from(&cfg.data_dir)
} else { } else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&cfg.data_dir) .join(&cfg.data_dir)
} }
} }
@@ -64,7 +65,12 @@ pub async fn build_store_root(cfg: &AppConfig) -> object_store::Result<DynStore>
/// ///
/// Prefer [`put_bytes_at`] for location-based writes that do not need to compute /// Prefer [`put_bytes_at`] for location-based writes that do not need to compute
/// a separate filesystem prefix. /// a separate filesystem prefix.
pub async fn put_bytes(prefix: &Path, file_name: &str, data: Bytes, cfg: &AppConfig) -> object_store::Result<()> { pub async fn put_bytes(
prefix: &Path,
file_name: &str,
data: Bytes,
cfg: &AppConfig,
) -> object_store::Result<()> {
let store = build_store(prefix, cfg).await?; let store = build_store(prefix, cfg).await?;
let payload = object_store::PutPayload::from_bytes(data); let payload = object_store::PutPayload::from_bytes(data);
store.put(&ObjPath::from(file_name), payload).await?; store.put(&ObjPath::from(file_name), payload).await?;
@@ -75,7 +81,11 @@ pub async fn put_bytes(prefix: &Path, file_name: &str, data: Bytes, cfg: &AppCon
/// ///
/// The store root is taken from `AppConfig::data_dir` for the local backend. /// The store root is taken from `AppConfig::data_dir` for the local backend.
/// This performs an atomic write as guaranteed by `object_store`. /// This performs an atomic write as guaranteed by `object_store`.
pub async fn put_bytes_at(location: &str, data: Bytes, cfg: &AppConfig) -> object_store::Result<()> { pub async fn put_bytes_at(
location: &str,
data: Bytes,
cfg: &AppConfig,
) -> object_store::Result<()> {
let store = build_store_root(cfg).await?; let store = build_store_root(cfg).await?;
let payload = object_store::PutPayload::from_bytes(data); let payload = object_store::PutPayload::from_bytes(data);
store.put(&ObjPath::from(location), payload).await?; store.put(&ObjPath::from(location), payload).await?;
@@ -85,7 +95,11 @@ pub async fn put_bytes_at(location: &str, data: Bytes, cfg: &AppConfig) -> objec
/// Read bytes from `file_name` within a filesystem `prefix` using the configured store. /// Read bytes from `file_name` within a filesystem `prefix` using the configured store.
/// ///
/// Prefer [`get_bytes_at`] for location-based reads. /// Prefer [`get_bytes_at`] for location-based reads.
pub async fn get_bytes(prefix: &Path, file_name: &str, cfg: &AppConfig) -> object_store::Result<Bytes> { pub async fn get_bytes(
prefix: &Path,
file_name: &str,
cfg: &AppConfig,
) -> object_store::Result<Bytes> {
let store = build_store(prefix, cfg).await?; let store = build_store(prefix, cfg).await?;
let r = store.get(&ObjPath::from(file_name)).await?; let r = store.get(&ObjPath::from(file_name)).await?;
let b = r.bytes().await?; let b = r.bytes().await?;
@@ -105,7 +119,10 @@ pub async fn get_bytes_at(location: &str, cfg: &AppConfig) -> object_store::Resu
/// ///
/// Returns a fallible `BoxStream` of `Bytes`, suitable for use with /// Returns a fallible `BoxStream` of `Bytes`, suitable for use with
/// `axum::body::Body::from_stream` to stream responses without buffering. /// `axum::body::Body::from_stream` to stream responses without buffering.
pub async fn get_stream_at(location: &str, cfg: &AppConfig) -> object_store::Result<BoxStream<'static, object_store::Result<Bytes>>> { pub async fn get_stream_at(
location: &str,
cfg: &AppConfig,
) -> object_store::Result<BoxStream<'static, object_store::Result<Bytes>>> {
let store = build_store_root(cfg).await?; let store = build_store_root(cfg).await?;
let r = store.get(&ObjPath::from(location)).await?; let r = store.get(&ObjPath::from(location)).await?;
Ok(r.into_stream()) Ok(r.into_stream())
@@ -119,7 +136,10 @@ pub async fn delete_prefix(prefix: &Path, cfg: &AppConfig) -> object_store::Resu
let store = build_store(prefix, cfg).await?; let store = build_store(prefix, cfg).await?;
// list everything and delete // list everything and delete
let locations = store.list(None).map_ok(|m| m.location).boxed(); let locations = store.list(None).map_ok(|m| m.location).boxed();
store.delete_stream(locations).try_collect::<Vec<_>>().await?; store
.delete_stream(locations)
.try_collect::<Vec<_>>()
.await?;
// Best effort remove the directory itself // Best effort remove the directory itself
if tokio::fs::try_exists(prefix).await.unwrap_or(false) { if tokio::fs::try_exists(prefix).await.unwrap_or(false) {
let _ = tokio::fs::remove_dir_all(prefix).await; let _ = tokio::fs::remove_dir_all(prefix).await;
@@ -134,8 +154,14 @@ pub async fn delete_prefix(prefix: &Path, cfg: &AppConfig) -> object_store::Resu
pub async fn delete_prefix_at(prefix: &str, cfg: &AppConfig) -> object_store::Result<()> { pub async fn delete_prefix_at(prefix: &str, cfg: &AppConfig) -> object_store::Result<()> {
let store = build_store_root(cfg).await?; let store = build_store_root(cfg).await?;
let prefix_path = ObjPath::from(prefix); let prefix_path = ObjPath::from(prefix);
let locations = store.list(Some(&prefix_path)).map_ok(|m| m.location).boxed(); let locations = store
store.delete_stream(locations).try_collect::<Vec<_>>().await?; .list(Some(&prefix_path))
.map_ok(|m| m.location)
.boxed();
store
.delete_stream(locations)
.try_collect::<Vec<_>>()
.await?;
// Best effort remove empty directory on disk for local storage // Best effort remove empty directory on disk for local storage
let base_dir = resolve_base_dir(cfg).join(prefix); let base_dir = resolve_base_dir(cfg).join(prefix);
if tokio::fs::try_exists(&base_dir).await.unwrap_or(false) { if tokio::fs::try_exists(&base_dir).await.unwrap_or(false) {
@@ -209,12 +235,16 @@ mod tests {
let location = format!("{}/{}", &location_prefix, file_name); let location = format!("{}/{}", &location_prefix, file_name);
let payload = Bytes::from_static(b"hello world"); let payload = Bytes::from_static(b"hello world");
put_bytes_at(&location, payload.clone(), &cfg).await.expect("put"); put_bytes_at(&location, payload.clone(), &cfg)
.await
.expect("put");
let got = get_bytes_at(&location, &cfg).await.expect("get"); let got = get_bytes_at(&location, &cfg).await.expect("get");
assert_eq!(got.as_ref(), payload.as_ref()); assert_eq!(got.as_ref(), payload.as_ref());
// Delete the whole prefix and ensure retrieval fails // Delete the whole prefix and ensure retrieval fails
delete_prefix_at(&location_prefix, &cfg).await.expect("delete prefix"); delete_prefix_at(&location_prefix, &cfg)
.await
.expect("delete prefix");
assert!(get_bytes_at(&location, &cfg).await.is_err()); assert!(get_bytes_at(&location, &cfg).await.is_err());
let _ = tokio::fs::remove_dir_all(&base).await; let _ = tokio::fs::remove_dir_all(&base).await;
@@ -244,12 +274,9 @@ mod tests {
assert_eq!(combined, content); assert_eq!(combined, content);
delete_prefix_at( delete_prefix_at(&split_object_path(&location).unwrap().0, &cfg)
&split_object_path(&location).unwrap().0, .await
&cfg, .ok();
)
.await
.ok();
let _ = tokio::fs::remove_dir_all(&base).await; let _ = tokio::fs::remove_dir_all(&base).await;
} }

View File

@@ -1,5 +1,6 @@
use axum_typed_multipart::FieldData; use axum_typed_multipart::FieldData;
use mime_guess::from_path; use mime_guess::from_path;
use object_store::Error as ObjectStoreError;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
io::{BufReader, Read}, io::{BufReader, Read},
@@ -7,7 +8,6 @@ use std::{
}; };
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use thiserror::Error; use thiserror::Error;
use object_store::Error as ObjectStoreError;
// futures imports no longer needed here after abstraction // futures imports no longer needed here after abstraction
use tracing::info; use tracing::info;
use uuid::Uuid; use uuid::Uuid;
@@ -90,8 +90,7 @@ impl FileInfo {
updated_at: now, updated_at: now,
file_name, file_name,
sha256, sha256,
path: Self::persist_file(&uuid, file, &sanitized_file_name, user_id, config) path: Self::persist_file(&uuid, file, &sanitized_file_name, user_id, config).await?,
.await?,
mime_type: Self::guess_mime_type(Path::new(&sanitized_file_name)), mime_type: Self::guess_mime_type(Path::new(&sanitized_file_name)),
user_id: user_id.to_string(), user_id: user_id.to_string(),
}; };
@@ -248,7 +247,10 @@ impl FileInfo {
store::delete_prefix_at(&parent_prefix, config) store::delete_prefix_at(&parent_prefix, config)
.await .await
.map_err(|e| AppError::from(anyhow::anyhow!(e)))?; .map_err(|e| AppError::from(anyhow::anyhow!(e)))?;
info!("Removed object prefix {} and its contents via object_store", parent_prefix); info!(
"Removed object prefix {} and its contents via object_store",
parent_prefix
);
// Delete the FileInfo from the database // Delete the FileInfo from the database
db_client.delete_item::<FileInfo>(id).await?; db_client.delete_item::<FileInfo>(id).await?;
@@ -276,9 +278,9 @@ impl FileInfo {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::utils::config::StorageKind;
use axum::http::HeaderMap; use axum::http::HeaderMap;
use axum_typed_multipart::FieldMetadata; use axum_typed_multipart::FieldMetadata;
use crate::utils::config::StorageKind;
use std::io::Write; use std::io::Write;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
@@ -657,9 +659,22 @@ mod tests {
// Create and persist a test file via FileInfo::new // Create and persist a test file via FileInfo::new
let user_id = "user123"; let user_id = "user123";
let cfg = AppConfig { data_dir: "./data".to_string(), openai_api_key: "".to_string(), surrealdb_address: "".to_string(), surrealdb_username: "".to_string(), surrealdb_password: "".to_string(), surrealdb_namespace: "".to_string(), surrealdb_database: "".to_string(), http_port: 0, openai_base_url: "".to_string(), storage: crate::utils::config::StorageKind::Local }; let cfg = AppConfig {
data_dir: "./data".to_string(),
openai_api_key: "".to_string(),
surrealdb_address: "".to_string(),
surrealdb_username: "".to_string(),
surrealdb_password: "".to_string(),
surrealdb_namespace: "".to_string(),
surrealdb_database: "".to_string(),
http_port: 0,
openai_base_url: "".to_string(),
storage: crate::utils::config::StorageKind::Local,
};
let temp = create_test_file(b"test content", "test_file.txt"); let temp = create_test_file(b"test content", "test_file.txt");
let file_info = FileInfo::new(temp, &db, user_id, &cfg).await.expect("create file"); let file_info = FileInfo::new(temp, &db, user_id, &cfg)
.await
.expect("create file");
// Delete the file // Delete the file
let delete_result = FileInfo::delete_by_id(&file_info.id, &db, &cfg).await; let delete_result = FileInfo::delete_by_id(&file_info.id, &db, &cfg).await;
@@ -695,7 +710,23 @@ mod tests {
.expect("Failed to start in-memory surrealdb"); .expect("Failed to start in-memory surrealdb");
// Try to delete a file that doesn't exist // Try to delete a file that doesn't exist
let result = FileInfo::delete_by_id("nonexistent_id", &db, &AppConfig { data_dir: "./data".to_string(), openai_api_key: "".to_string(), surrealdb_address: "".to_string(), surrealdb_username: "".to_string(), surrealdb_password: "".to_string(), surrealdb_namespace: "".to_string(), surrealdb_database: "".to_string(), http_port: 0, openai_base_url: "".to_string(), storage: crate::utils::config::StorageKind::Local }).await; let result = FileInfo::delete_by_id(
"nonexistent_id",
&db,
&AppConfig {
data_dir: "./data".to_string(),
openai_api_key: "".to_string(),
surrealdb_address: "".to_string(),
surrealdb_username: "".to_string(),
surrealdb_password: "".to_string(),
surrealdb_namespace: "".to_string(),
surrealdb_database: "".to_string(),
http_port: 0,
openai_base_url: "".to_string(),
storage: crate::utils::config::StorageKind::Local,
},
)
.await;
// Should fail with FileNotFound error // Should fail with FileNotFound error
assert!(result.is_err()); assert!(result.is_err());

View File

@@ -4,11 +4,17 @@ use axum_session_auth::Authentication;
use surrealdb::{engine::any::Any, Surreal}; use surrealdb::{engine::any::Any, Surreal};
use uuid::Uuid; use uuid::Uuid;
use super::text_chunk::TextChunk;
use super::{ use super::{
conversation::Conversation, ingestion_task::IngestionTask, knowledge_entity::KnowledgeEntity, conversation::Conversation,
knowledge_relationship::KnowledgeRelationship, system_settings::SystemSettings, ingestion_task::{IngestionTask, MAX_ATTEMPTS},
knowledge_entity::KnowledgeEntity,
knowledge_relationship::KnowledgeRelationship,
system_settings::SystemSettings,
text_content::TextContent, text_content::TextContent,
}; };
use chrono::Duration;
use futures::try_join;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CategoryResponse { pub struct CategoryResponse {
@@ -61,7 +67,93 @@ fn validate_timezone(input: &str) -> String {
} }
} }
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DashboardStats {
pub total_documents: i64,
pub new_documents_week: i64,
pub total_entities: i64,
pub new_entities_week: i64,
pub total_conversations: i64,
pub new_conversations_week: i64,
pub total_text_chunks: i64,
pub new_text_chunks_week: i64,
}
#[derive(Deserialize)]
struct CountResult {
count: i64,
}
impl User { impl User {
async fn count_total<T: crate::storage::types::StoredObject>(
db: &SurrealDbClient,
user_id: &str,
) -> Result<i64, AppError> {
let result: Option<CountResult> = db
.client
.query("SELECT count() as count FROM type::table($table) WHERE user_id = $user_id GROUP ALL")
.bind(("table", T::table_name()))
.bind(("user_id", user_id.to_string()))
.await?
.take(0)?;
Ok(result.map(|r| r.count).unwrap_or(0))
}
async fn count_since<T: crate::storage::types::StoredObject>(
db: &SurrealDbClient,
user_id: &str,
since: chrono::DateTime<chrono::Utc>,
) -> Result<i64, AppError> {
let result: Option<CountResult> = db
.client
.query(
"SELECT count() as count FROM type::table($table) WHERE user_id = $user_id AND created_at >= $since GROUP ALL",
)
.bind(("table", T::table_name()))
.bind(("user_id", user_id.to_string()))
.bind(("since", since))
.await?
.take(0)?;
Ok(result.map(|r| r.count).unwrap_or(0))
}
pub async fn get_dashboard_stats(
user_id: &str,
db: &SurrealDbClient,
) -> Result<DashboardStats, AppError> {
let since = chrono::Utc::now() - Duration::days(7);
let (
total_documents,
new_documents_week,
total_entities,
new_entities_week,
total_conversations,
new_conversations_week,
total_text_chunks,
new_text_chunks_week,
) = try_join!(
Self::count_total::<TextContent>(db, user_id),
Self::count_since::<TextContent>(db, user_id, since),
Self::count_total::<KnowledgeEntity>(db, user_id),
Self::count_since::<KnowledgeEntity>(db, user_id, since),
Self::count_total::<Conversation>(db, user_id),
Self::count_since::<Conversation>(db, user_id, since),
Self::count_total::<TextChunk>(db, user_id),
Self::count_since::<TextChunk>(db, user_id, since)
)?;
Ok(DashboardStats {
total_documents,
new_documents_week,
total_entities,
new_entities_week,
total_conversations,
new_conversations_week,
total_text_chunks,
new_text_chunks_week,
})
}
pub async fn create_new( pub async fn create_new(
email: String, email: String,
password: String, password: String,
@@ -444,17 +536,17 @@ impl User {
"SELECT * FROM type::table($table) "SELECT * FROM type::table($table)
WHERE user_id = $user_id WHERE user_id = $user_id
AND ( AND (
status = 'Created' status.name = 'Created'
OR ( OR (
status.InProgress != NONE status.name = 'InProgress'
AND status.InProgress.attempts < $max_attempts AND status.attempts < $max_attempts
) )
) )
ORDER BY created_at DESC", ORDER BY created_at DESC",
) )
.bind(("table", IngestionTask::table_name())) .bind(("table", IngestionTask::table_name()))
.bind(("user_id", user_id.to_owned())) .bind(("user_id", user_id.to_owned()))
.bind(("max_attempts", 3)) .bind(("max_attempts", MAX_ATTEMPTS))
.await? .await?
.take(0)?; .take(0)?;
@@ -511,6 +603,9 @@ impl User {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::storage::types::ingestion_payload::IngestionPayload;
use crate::storage::types::ingestion_task::{IngestionTask, IngestionTaskStatus, MAX_ATTEMPTS};
use std::collections::HashSet;
// Helper function to set up a test database with SystemSettings // Helper function to set up a test database with SystemSettings
async fn setup_test_db() -> SurrealDbClient { async fn setup_test_db() -> SurrealDbClient {
@@ -596,6 +691,75 @@ mod tests {
assert!(nonexistent.is_err()); assert!(nonexistent.is_err());
} }
#[tokio::test]
async fn test_get_unfinished_ingestion_tasks_filters_correctly() {
let db = setup_test_db().await;
let user_id = "unfinished_user";
let other_user_id = "other_user";
let payload = IngestionPayload::Text {
text: "Test".to_string(),
context: "Context".to_string(),
category: "Category".to_string(),
user_id: user_id.to_string(),
};
let created_task = IngestionTask::new(payload.clone(), user_id.to_string()).await;
db.store_item(created_task.clone())
.await
.expect("Failed to store created task");
let mut in_progress_allowed =
IngestionTask::new(payload.clone(), user_id.to_string()).await;
in_progress_allowed.status = IngestionTaskStatus::InProgress {
attempts: 1,
last_attempt: chrono::Utc::now(),
};
db.store_item(in_progress_allowed.clone())
.await
.expect("Failed to store in-progress task");
let mut in_progress_blocked =
IngestionTask::new(payload.clone(), user_id.to_string()).await;
in_progress_blocked.status = IngestionTaskStatus::InProgress {
attempts: MAX_ATTEMPTS,
last_attempt: chrono::Utc::now(),
};
db.store_item(in_progress_blocked.clone())
.await
.expect("Failed to store blocked task");
let mut completed_task = IngestionTask::new(payload.clone(), user_id.to_string()).await;
completed_task.status = IngestionTaskStatus::Completed;
db.store_item(completed_task.clone())
.await
.expect("Failed to store completed task");
let other_payload = IngestionPayload::Text {
text: "Other".to_string(),
context: "Context".to_string(),
category: "Category".to_string(),
user_id: other_user_id.to_string(),
};
let other_task = IngestionTask::new(other_payload, other_user_id.to_string()).await;
db.store_item(other_task)
.await
.expect("Failed to store other user task");
let unfinished = User::get_unfinished_ingestion_tasks(user_id, &db)
.await
.expect("Failed to fetch unfinished tasks");
let unfinished_ids: HashSet<String> =
unfinished.iter().map(|task| task.id.clone()).collect();
assert!(unfinished_ids.contains(&created_task.id));
assert!(unfinished_ids.contains(&in_progress_allowed.id));
assert!(!unfinished_ids.contains(&in_progress_blocked.id));
assert!(!unfinished_ids.contains(&completed_task.id));
assert_eq!(unfinished_ids.len(), 2);
}
#[tokio::test] #[tokio::test]
async fn test_find_by_email() { async fn test_find_by_email() {
// Setup test database // Setup test database

View File

@@ -30,6 +30,7 @@ tower-http = { workspace = true }
chrono-tz = { workspace = true } chrono-tz = { workspace = true }
tower-serve-static = { workspace = true } tower-serve-static = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
chrono = { workspace = true }
common = { path = "../common" } common = { path = "../common" }
composite-retrieval = { path = "../composite-retrieval" } composite-retrieval = { path = "../composite-retrieval" }

View File

@@ -1,29 +1,108 @@
@import 'tailwindcss' source(none); @import 'tailwindcss';
@source './templates/**/*.html';
@plugin "daisyui" { @plugin "daisyui" {
exclude: rootscrollbargutter; logs: false;
themes: false;
include: [ "properties",
"scrollbar",
"rootscrolllock",
"rootcolor",
"svg",
"button",
"menu",
"navbar",
"drawer",
"modal",
"chat",
"card",
"loading",
"validator",
"fileinput",
"alert",
"swap"
];
} }
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@config './tailwind.config.js';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@view-transition { @view-transition {
navigation: auto; navigation: auto;
} }
@layer base { @layer base {
:root {
--nb-shadow: 4px 4px 0 0 #000;
--nb-shadow-hover: 6px 6px 0 0 #000;
}
[data-theme="light"] {
color-scheme: light;
--color-base-100: oklch(98.42% 0.012 96.42);
--color-base-200: oklch(94.52% 0.0122 96.43);
--color-base-300: oklch(90.96% 0.0125 91.53);
--color-base-content: oklch(17.76% 0 89.88);
--color-primary: oklch(20.77% 0.0398 265.75);
--color-primary-content: oklch(100% 0 89.88);
--color-secondary: oklch(54.61% 0.2152 262.88);
--color-secondary-content: oklch(100% 0 89.88);
--color-accent: oklch(72% 0.19 80);
--color-accent-content: oklch(21% 0.035 80);
--color-neutral: oklch(17.76% 0 89.88);
--color-neutral-content: oklch(96.99% 0.0013 106.42);
--color-info: oklch(60.89% 0.1109 221.72);
--color-info-content: oklch(96.99% 0.0013 106.42);
--color-success: oklch(62.71% 0.1699 149.21);
--color-success-content: oklch(96.99% 0.0013 106.42);
--color-warning: oklch(79.52% 0.1617 86.05);
--color-warning-content: oklch(17.76% 0 89.88);
--color-error: oklch(57.71% 0.2152 27.33);
--color-error-content: oklch(96.99% 0.0013 106.42);
--radius-selector: 0rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 2px;
}
[data-theme="dark"] {
color-scheme: dark;
--color-base-100: oklch(22% 0.015 255);
--color-base-200: oklch(18% 0.014 253);
--color-base-300: oklch(14% 0.012 251);
--color-base-content: oklch(97.2% 0.02 255);
--color-primary: oklch(58% 0.233 277.12);
--color-primary-content: oklch(96% 0.018 272.31);
--color-secondary: oklch(65% 0.241 354.31);
--color-secondary-content: oklch(94% 0.028 342.26);
--color-accent: oklch(78% 0.22 80);
--color-accent-content: oklch(20% 0.035 80);
--color-neutral: oklch(26% 0.02 255);
--color-neutral-content: oklch(97% 0.03 255);
--color-info: oklch(74% 0.16 232.66);
--color-info-content: oklch(29% 0.066 243.16);
--color-success: oklch(76% 0.177 163.22);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.43);
--color-warning-content: oklch(41% 0.112 45.9);
--color-error: oklch(71% 0.194 13.43);
--color-error-content: oklch(27% 0.105 12.09);
--radius-selector: 0rem;
--radius-field: 0rem;
--radius-box: 0rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 2px;
}
body { body {
@apply font-satoshi; background-color: var(--color-base-100);
color: var(--color-base-content);
font-family: 'Satoshi', sans-serif;
-webkit-font-smoothing: antialiased;
@apply selection:bg-yellow-300/40 selection:text-neutral;
} }
html { html {
@@ -37,6 +116,479 @@
::file-selector-button { ::file-selector-button {
border-color: var(--color-gray-200, currentColor); border-color: var(--color-gray-200, currentColor);
} }
.container {
padding-inline: 10px;
}
@media (min-width: 640px) {
.container {
padding-inline: 2rem;
}
}
@media (min-width: 1024px) {
.container {
padding-inline: 4rem;
}
}
@media (min-width: 1280px) {
.container {
padding-inline: 5rem;
}
}
@media (min-width: 1536px) {
.container {
padding-inline: 6rem;
}
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
form.htmx-request {
opacity: 0.5;
}
}
/* Neobrutalist helpers influenced by Tufte principles */
@layer components {
/* Offset, hard-edge shadow; minimal ink with strong contrast */
.nb-shadow {
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
.nb-shadow-hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-card {
@apply bg-base-100 border-2 border-neutral p-4;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
.nb-card:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-panel {
@apply border-2 border-neutral;
background-color: var(--nb-panel-bg, var(--color-base-200));
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
.nb-panel:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-panel-canvas {
--nb-panel-bg: var(--color-base-100);
}
.nb-canvas {
background-color: var(--color-base-100);
}
.nb-btn {
@apply btn rounded-none border-2 border-neutral text-base-content;
--btn-color: var(--color-base-100);
--btn-fg: var(--color-base-content);
--btn-noise: none;
background-image: none;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
.nb-btn:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-link {
@apply underline underline-offset-2 decoration-neutral hover:decoration-4;
}
.nb-stat {
@apply bg-base-100 border-2 border-neutral p-5 flex flex-col gap-1;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
/* Hairline rules and quiet gridlines for Tufte feel */
.u-hairline {
@apply border-t border-neutral/20;
}
.prose-tufte {
@apply prose prose-neutral;
max-width: min(90ch, 100%);
line-height: 1.7;
}
.prose-tufte-compact {
@apply prose prose-neutral;
max-width: min(90ch, 100%);
font-size: 0.875rem;
line-height: 1.6;
}
/* Encourage a consistent card look app-wide */
.card {
@apply border-2 border-neutral rounded-none;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
.card:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
/* Input styling with good dark/light contrast */
.nb-input {
@apply rounded-none border-2 border-neutral bg-base-100 text-base-content placeholder:text-base-content/60 px-3 py-[0.5rem];
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms, border-color 150ms;
}
.nb-input:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-input:focus {
outline: none;
box-shadow: var(--nb-shadow-hover);
}
/* Select styling parallels inputs */
.nb-select {
@apply rounded-none border-2 border-neutral bg-base-100 text-base-content px-3 py-[0.5rem];
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms, border-color 150ms;
}
.nb-select:hover {
transform: translate(-1px, -1px);
box-shadow: var(--nb-shadow-hover);
}
.nb-select:focus {
outline: none;
box-shadow: var(--nb-shadow-hover);
}
/* Compact variants */
.nb-input-sm {
@apply text-sm px-2 py-[0.25rem];
}
.nb-select-sm {
@apply text-sm px-2 py-[0.25rem];
}
.nb-cta {
--btn-color: var(--color-accent);
--btn-fg: var(--color-accent-content);
--btn-noise: none;
background-image: none;
background-color: var(--color-accent);
color: var(--color-accent-content);
}
.nb-cta:hover {
background-color: var(--color-accent);
color: var(--color-accent-content);
filter: saturate(1.1) brightness(1.05);
}
/* Badges */
.nb-badge {
@apply inline-flex items-center uppercase tracking-wide text-[10px] px-2 py-0.5 bg-base-100 border-2 border-neutral rounded-none;
box-shadow: 3px 3px 0 0 #000;
}
.nb-masonry {
column-count: 1;
column-gap: 1rem;
}
.nb-masonry>* {
break-inside: avoid;
display: block;
}
@media (min-width: 768px) {
.nb-masonry {
column-count: 2;
}
}
@media (min-width: 1536px) {
.nb-masonry {
column-count: 3;
}
}
/* Chat bubbles neobrutalist */
.chat .chat-bubble {
@apply rounded-none border-2 border-neutral bg-base-100 text-neutral;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms;
}
/* Remove DaisyUI tail so our rectangle keeps clean borders/shadows */
.chat .chat-bubble::before,
.chat .chat-bubble::after {
display: none !important;
content: none !important;
}
.chat.chat-start .chat-bubble {
@apply bg-secondary text-secondary-content;
}
.chat.chat-end .chat-bubble {
@apply bg-base-100 text-neutral;
}
/* Tables */
.nb-table {
@apply w-full;
border-collapse: separate;
border-spacing: 0;
}
.nb-table thead th {
@apply uppercase tracking-wide text-xs border-b-2 border-neutral;
}
.nb-table th,
.nb-table td {
@apply p-3;
}
.nb-table tbody tr+tr td {
@apply border-t border-neutral/30;
}
.nb-table tbody tr:hover {
@apply bg-base-200/40;
}
.nb-table tbody tr:hover td:first-child {
box-shadow: inset 3px 0 0 0 #000;
}
.kg-overlay {
@apply absolute top-4 left-4 z-10 flex items-center gap-2;
}
.kg-search-input {
@apply pl-2;
height: 2rem;
min-width: 220px;
max-width: 320px;
}
.kg-legend {
@apply absolute bottom-2 left-2 z-10 flex flex-wrap gap-4;
}
.kg-legend-card {
@apply p-2;
}
.kg-legend-heading {
@apply mb-1 text-xs opacity-70;
}
.kg-legend-row {
@apply flex items-center gap-2 text-xs;
}
/* Checkboxes */
.nb-checkbox {
@apply appearance-none inline-block align-middle rounded-none border-2 border-neutral bg-base-100;
width: 1rem;
height: 1rem;
box-shadow: var(--nb-shadow);
transition: transform 150ms, box-shadow 150ms, border-color 150ms, background-color 150ms;
background-repeat: no-repeat;
background-position: center;
background-size: 80% 80%;
cursor: pointer;
}
.nb-checkbox:hover {
transform: translate(-1px, -1px);
box-shadow: 5px 5px 0 0 #000;
}
.nb-checkbox:focus-visible {
outline: 2px solid #000;
outline-offset: 2px;
}
.nb-checkbox:active {
transform: translate(0, 0);
box-shadow: 3px 3px 0 0 #000;
}
/* Tick mark in light mode (black) */
.nb-checkbox:checked {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><polyline points='20 6 9 17 4 12'/></svg>");
}
/* Tick mark in dark mode (white) */
[data-theme="dark"] .nb-checkbox:checked {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><polyline points='20 6 9 17 4 12'/></svg>");
}
/* Compact size */
.nb-checkbox-sm {
width: 0.875rem;
height: 0.875rem;
}
/* Placeholder style for smaller, quieter helper text */
.nb-input::placeholder {
font-size: 0.75rem;
letter-spacing: 0.02em;
opacity: 0.75;
}
.markdown-content {
line-height: 1.5;
word-wrap: break-word;
}
.markdown-content p {
margin-bottom: 0.75em;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0.5em;
margin-bottom: 0.75em;
padding-left: 2em;
}
.markdown-content li {
margin-bottom: 0.25em;
}
.markdown-content pre {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.5em;
border-radius: 4px;
overflow-x: auto;
}
.markdown-content code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content table {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid rgba(0, 0, 0, 0.15);
padding: 6px 12px;
text-align: left;
}
.markdown-content blockquote {
border-left: 4px solid rgba(0, 0, 0, 0.15);
padding-left: 10px;
margin: 0.5em 0 0.5em 0.5em;
color: rgba(0, 0, 0, 0.6);
}
[data-theme="dark"] .markdown-content blockquote {
border-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
.markdown-content hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.15);
margin: 0.75em 0;
}
[data-theme="dark"] .markdown-content pre,
[data-theme="dark"] .markdown-content code {
background-color: rgba(255, 255, 255, 0.07);
}
.brand-mark {
letter-spacing: 0.02em;
}
.reference-tooltip {
@apply bg-base-100 text-base-content border-2 border-neutral p-3 text-sm w-72 max-w-xs;
position: fixed;
z-index: 9999;
box-shadow: var(--nb-shadow);
}
}
/* Theme-aware placeholder contrast tweaks */
@layer base {
/* Light theme keeps default neutral tone via utilities */
[data-theme="dark"] .nb-input::placeholder,
[data-theme="dark"] .input::placeholder,
[data-theme="dark"] .textarea::placeholder,
[data-theme="dark"] textarea::placeholder,
[data-theme="dark"] input::placeholder {
color: rgba(255, 255, 255, 0.78) !important;
opacity: 0.85;
}
} }
/* satoshi.css */ /* satoshi.css */
@@ -58,4 +610,28 @@
font-weight: 300 900; font-weight: 300 900;
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
}
/* Minimal override: prevent DaisyUI .menu hover bg on our nb buttons */
@layer utilities {
/* Let plain nb-btns remain transparent on hover within menus */
.menu li>.nb-btn:hover {
background-color: transparent;
}
/* Keep CTA background on hover within menus */
.menu li>.nb-cta:hover {
background-color: var(--color-accent);
color: var(--color-accent-content);
}
.toast-alert {
@apply mt-2 flex flex-col text-left gap-1;
box-shadow: var(--nb-shadow);
}
.toast-alert-title {
@apply text-lg font-bold;
}
} }

View File

@@ -73,29 +73,29 @@
function attachOverlay(container, { onSearch, onToggleLabels, onCenter }) { function attachOverlay(container, { onSearch, onToggleLabels, onCenter }) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'absolute top-2 left-2 z-10 flex gap-2 items-center'; overlay.className = 'kg-overlay';
// search box // search box
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.placeholder = 'Search nodes…'; input.placeholder = 'Search nodes…';
input.className = 'input input-sm input-bordered'; input.className = 'nb-input kg-search-input';
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') onSearch && onSearch(input.value.trim()); if (e.key === 'Enter') onSearch && onSearch(input.value.trim());
}); });
const searchBtn = document.createElement('button'); const searchBtn = document.createElement('button');
searchBtn.className = 'btn btn-sm'; searchBtn.className = 'nb-btn btn-xs nb-cta';
searchBtn.textContent = 'Go'; searchBtn.textContent = 'Go';
searchBtn.addEventListener('click', () => onSearch && onSearch(input.value.trim())); searchBtn.addEventListener('click', () => onSearch && onSearch(input.value.trim()));
const labelToggle = document.createElement('button'); const labelToggle = document.createElement('button');
labelToggle.className = 'btn btn-sm'; labelToggle.className = 'nb-btn btn-xs';
labelToggle.textContent = 'Labels'; labelToggle.textContent = 'Labels';
labelToggle.addEventListener('click', () => onToggleLabels && onToggleLabels()); labelToggle.addEventListener('click', () => onToggleLabels && onToggleLabels());
const centerBtn = document.createElement('button'); const centerBtn = document.createElement('button');
centerBtn.className = 'btn btn-sm'; centerBtn.className = 'nb-btn btn-xs';
centerBtn.textContent = 'Center'; centerBtn.textContent = 'Center';
centerBtn.addEventListener('click', () => onCenter && onCenter()); centerBtn.addEventListener('click', () => onCenter && onCenter());
@@ -112,15 +112,15 @@
function attachLegends(container, typeColor, relColor) { function attachLegends(container, typeColor, relColor) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'absolute bottom-2 left-2 z-10 flex gap-6 flex-wrap'; wrap.className = 'kg-legend';
function section(title, items) { function section(title, items) {
const sec = document.createElement('div'); const sec = document.createElement('div');
sec.className = 'rounded-box bg-base-100/80 backdrop-blur shadow p-2'; sec.className = 'nb-card kg-legend-card';
const h = document.createElement('div'); h.className = 'text-xs opacity-70 mb-1'; h.textContent = title; sec.appendChild(h); const h = document.createElement('div'); h.className = 'kg-legend-heading'; h.textContent = title; sec.appendChild(h);
items.forEach(([label, color]) => { items.forEach(([label, color]) => {
const row = document.createElement('div'); row.className = 'flex items-center gap-2 text-xs'; const row = document.createElement('div'); row.className = 'kg-legend-row';
const sw = document.createElement('span'); sw.style.background = color; sw.style.width = '10px'; sw.style.height = '10px'; sw.style.borderRadius = '9999px'; const sw = document.createElement('span'); sw.style.background = color; sw.style.width = '12px'; sw.style.height = '12px'; sw.style.border = '2px solid #000';
const t = document.createElement('span'); t.textContent = label || '—'; const t = document.createElement('span'); t.textContent = label || '—';
row.appendChild(sw); row.appendChild(t); sec.appendChild(row); row.appendChild(sw); row.appendChild(t); sec.appendChild(row);
}); });

File diff suppressed because one or more lines are too long

View File

@@ -6,33 +6,31 @@
return; return;
} }
const alert = document.createElement('div'); const alert = document.createElement('div');
// Base classes for the alert alert.className = `alert toast-alert alert-${type}`;
alert.className = `alert alert-${type} mt-2 shadow-md flex flex-col text-start`; alert.style.opacity = '1';
alert.style.transition = 'opacity 0.5s ease-out';
// Build inner HTML based on whether title is provided
let innerHTML = '';
if (title) { if (title) {
innerHTML += `<div class="font-bold text-lg">${title}</div>`; // Title element const titleEl = document.createElement('div');
innerHTML += `<div>${description}</div>`; // Description element titleEl.className = 'toast-alert-title';
} else { titleEl.textContent = title;
// Structure without title alert.appendChild(titleEl);
innerHTML += `<span>${description}</span>`;
} }
alert.innerHTML = innerHTML; const bodyEl = document.createElement(title ? 'div' : 'span');
bodyEl.textContent = description;
alert.appendChild(bodyEl);
container.appendChild(alert); container.appendChild(alert);
// Auto-remove after a delay // Auto-remove after a delay
setTimeout(() => { setTimeout(() => {
// Optional: Add fade-out effect
alert.style.opacity = '0'; alert.style.opacity = '0';
alert.style.transition = 'opacity 0.5s ease-out'; setTimeout(() => alert.remove(), 500);
setTimeout(() => alert.remove(), 500); // Remove after fade }, 3000);
}, 3000); // Start fade-out after 3 seconds
}; };
document.body.addEventListener('toast', function (event) { document.body.addEventListener('toast', function (event) {
console.log(event);
// Extract data from the event detail, matching the Rust payload // Extract data from the event detail, matching the Rust payload
const detail = event.detail; const detail = event.detail;
if (detail && detail.description) { if (detail && detail.description) {
@@ -54,4 +52,3 @@
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
}); });
}) })

View File

@@ -1,7 +1,6 @@
{ {
"name": "html-router", "name": "html-router",
"version": "1.0.0", "version": "1.0.0",
"main": "tailwind.config.js",
"scripts": { "scripts": {
"tailwind": "npx @tailwindcss/cli -i app.css -o assets/style.css -w -m" "tailwind": "npx @tailwindcss/cli -i app.css -o assets/style.css -w -m"
}, },
@@ -14,4 +13,4 @@
"daisyui": "^5.0.12", "daisyui": "^5.0.12",
"tailwindcss": "^4.1.2" "tailwindcss": "^4.1.2"
} }
} }

View File

@@ -35,5 +35,6 @@ where
.add_protected_routes(routes::content::router()) .add_protected_routes(routes::content::router())
.add_protected_routes(routes::knowledge::router()) .add_protected_routes(routes::knowledge::router())
.add_protected_routes(routes::ingestion::router()) .add_protected_routes(routes::ingestion::router())
.with_compression()
.build() .build()
} }

View File

@@ -0,0 +1,7 @@
use tower_http::compression::CompressionLayer;
/// Provides a default compression layer that negotiates encoding based on the
/// `Accept-Encoding` header of the incoming request.
pub fn compression_layer() -> CompressionLayer {
CompressionLayer::new()
}

View File

@@ -1,3 +1,4 @@
pub mod analytics_middleware; pub mod analytics_middleware;
pub mod auth_middleware; pub mod auth_middleware;
pub mod compression;
pub mod response_middleware; pub mod response_middleware;

View File

@@ -13,7 +13,7 @@ use crate::{
html_state::HtmlState, html_state::HtmlState,
middlewares::{ middlewares::{
analytics_middleware::analytics_middleware, auth_middleware::require_auth, analytics_middleware::analytics_middleware, auth_middleware::require_auth,
response_middleware::with_template_response, compression::compression_layer, response_middleware::with_template_response,
}, },
}; };
@@ -48,6 +48,7 @@ pub struct RouterFactory<S> {
nested_protected_routes: Vec<(String, Router<S>)>, nested_protected_routes: Vec<(String, Router<S>)>,
custom_middleware: MiddleWareVecType<S>, custom_middleware: MiddleWareVecType<S>,
public_assets_config: Option<AssetsConfig>, public_assets_config: Option<AssetsConfig>,
compression_enabled: bool,
} }
struct AssetsConfig { struct AssetsConfig {
@@ -69,6 +70,7 @@ where
nested_protected_routes: Vec::new(), nested_protected_routes: Vec::new(),
custom_middleware: Vec::new(), custom_middleware: Vec::new(),
public_assets_config: None, public_assets_config: None,
compression_enabled: false,
} }
} }
@@ -115,6 +117,12 @@ where
self self
} }
/// Enables response compression when building the router.
pub fn with_compression(mut self) -> Self {
self.compression_enabled = true;
self
}
pub fn build(self) -> Router<S> { pub fn build(self) -> Router<S> {
// Start with an empty router // Start with an empty router
let mut public_router = Router::new(); let mut public_router = Router::new();
@@ -169,21 +177,26 @@ where
} }
// Apply common middleware // Apply common middleware
router = router.layer(from_fn_with_state(
self.app_state.clone(),
analytics_middleware::<HtmlState>,
));
router = router.layer(map_response_with_state(
self.app_state.clone(),
with_template_response::<HtmlState>,
));
router = router.layer(
AuthSessionLayer::<User, String, SessionSurrealPool<Any>, Surreal<Any>>::new(Some(
self.app_state.db.client.clone(),
))
.with_config(AuthConfig::<String>::default()),
);
router = router.layer(SessionLayer::new((*self.app_state.session_store).clone()));
if self.compression_enabled {
router = router.layer(compression_layer());
}
router router
.layer(from_fn_with_state(
self.app_state.clone(),
analytics_middleware::<HtmlState>,
))
.layer(map_response_with_state(
self.app_state.clone(),
with_template_response::<HtmlState>,
))
.layer(
AuthSessionLayer::<User, String, SessionSurrealPool<Any>, Surreal<Any>>::new(Some(
self.app_state.db.client.clone(),
))
.with_config(AuthConfig::<String>::default()),
)
.layer(SessionLayer::new((*self.app_state.session_store).clone()))
} }
} }

View File

@@ -406,4 +406,4 @@ pub async fn patch_image_prompt(
settings: new_settings, settings: new_settings,
}, },
)) ))
} }

View File

@@ -3,11 +3,12 @@ use axum::{
response::IntoResponse, response::IntoResponse,
Form, Form,
}; };
use axum_htmx::{HxBoosted, HxRequest}; use axum_htmx::{HxBoosted, HxRequest, HxTarget};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use common::storage::types::{ use common::storage::types::{
conversation::Conversation, file_info::FileInfo, text_content::TextContent, user::User, knowledge_entity::KnowledgeEntity, text_chunk::TextChunk, conversation::Conversation, file_info::FileInfo, knowledge_entity::KnowledgeEntity,
text_chunk::TextChunk, text_content::TextContent, user::User,
}; };
use crate::{ use crate::{
@@ -27,6 +28,12 @@ pub struct ContentPageData {
conversation_archive: Vec<Conversation>, conversation_archive: Vec<Conversation>,
} }
#[derive(Serialize)]
pub struct RecentTextContentData {
pub user: User,
pub text_contents: Vec<TextContent>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct FilterParams { pub struct FilterParams {
category: Option<String>, category: Option<String>,
@@ -102,12 +109,25 @@ pub async fn patch_text_content(
State(state): State<HtmlState>, State(state): State<HtmlState>,
RequireUser(user): RequireUser, RequireUser(user): RequireUser,
Path(id): Path<String>, Path(id): Path<String>,
HxTarget(target): HxTarget,
Form(form): Form<PatchTextContentParams>, Form(form): Form<PatchTextContentParams>,
) -> Result<impl IntoResponse, HtmlError> { ) -> Result<impl IntoResponse, HtmlError> {
User::get_and_validate_text_content(&id, &user.id, &state.db).await?; User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
TextContent::patch(&id, &form.context, &form.category, &form.text, &state.db).await?; TextContent::patch(&id, &form.context, &form.category, &form.text, &state.db).await?;
if target.as_deref() == Some("latest_content_section") {
let text_contents = User::get_latest_text_contents(&user.id, &state.db).await?;
return Ok(TemplateResponse::new_template(
"dashboard/recent_content.html",
RecentTextContentData {
user,
text_contents,
},
));
}
let text_contents = User::get_text_contents(&user.id, &state.db).await?; let text_contents = User::get_text_contents(&user.id, &state.db).await?;
let categories = User::get_user_categories(&user.id, &state.db).await?; let categories = User::get_user_categories(&user.id, &state.db).await?;
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
@@ -187,12 +207,6 @@ pub async fn show_recent_content(
) -> Result<impl IntoResponse, HtmlError> { ) -> Result<impl IntoResponse, HtmlError> {
let text_contents = User::get_latest_text_contents(&user.id, &state.db).await?; let text_contents = User::get_latest_text_contents(&user.id, &state.db).await?;
#[derive(Serialize)]
pub struct RecentTextContentData {
pub user: User,
pub text_contents: Vec<TextContent>,
}
Ok(TemplateResponse::new_template( Ok(TemplateResponse::new_template(
"dashboard/recent_content.html", "dashboard/recent_content.html",
RecentTextContentData { RecentTextContentData {

View File

@@ -4,6 +4,7 @@ use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode}, http::{header, HeaderMap, HeaderValue, StatusCode},
response::IntoResponse, response::IntoResponse,
}; };
use futures::try_join;
use serde::Serialize; use serde::Serialize;
use tokio::join; use tokio::join;
@@ -14,6 +15,8 @@ use crate::{
}, },
AuthSessionType, AuthSessionType,
}; };
use common::storage::store;
use common::storage::types::user::DashboardStats;
use common::{ use common::{
error::AppError, error::AppError,
storage::types::{ storage::types::{
@@ -22,7 +25,6 @@ use common::{
text_chunk::TextChunk, text_content::TextContent, user::User, text_chunk::TextChunk, text_content::TextContent, user::User,
}, },
}; };
use common::storage::store;
use crate::html_state::HtmlState; use crate::html_state::HtmlState;
@@ -30,6 +32,7 @@ use crate::html_state::HtmlState;
pub struct IndexPageData { pub struct IndexPageData {
user: Option<User>, user: Option<User>,
text_contents: Vec<TextContent>, text_contents: Vec<TextContent>,
stats: DashboardStats,
active_jobs: Vec<IngestionTask>, active_jobs: Vec<IngestionTask>,
conversation_archive: Vec<Conversation>, conversation_archive: Vec<Conversation>,
} }
@@ -42,19 +45,21 @@ pub async fn index_handler(
return Ok(TemplateResponse::redirect("/signin")); return Ok(TemplateResponse::redirect("/signin"));
}; };
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?; let (text_contents, conversation_archive, stats, active_jobs) = try_join!(
User::get_latest_text_contents(&user.id, &state.db),
let text_contents = User::get_latest_text_contents(&user.id, &state.db).await?; User::get_user_conversations(&user.id, &state.db),
User::get_dashboard_stats(&user.id, &state.db),
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; User::get_unfinished_ingestion_tasks(&user.id, &state.db)
)?;
Ok(TemplateResponse::new_template( Ok(TemplateResponse::new_template(
"dashboard/base.html", "dashboard/base.html",
IndexPageData { IndexPageData {
user: Some(user), user: Some(user),
text_contents, text_contents,
active_jobs, stats,
conversation_archive, conversation_archive,
active_jobs,
}, },
)) ))
} }
@@ -153,9 +158,8 @@ pub async fn show_active_jobs(
) -> Result<impl IntoResponse, HtmlError> { ) -> Result<impl IntoResponse, HtmlError> {
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?; let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial( Ok(TemplateResponse::new_template(
"dashboard/active_jobs.html", "dashboard/active_jobs.html",
"active_jobs_section",
ActiveJobsData { ActiveJobsData {
user: user.clone(), user: user.clone(),
active_jobs, active_jobs,

View File

@@ -5,6 +5,7 @@ use axum::{
response::IntoResponse, response::IntoResponse,
}; };
use common::storage::types::{ use common::storage::types::{
conversation::Conversation,
text_content::{TextContent, TextContentSearchResult}, text_content::{TextContent, TextContentSearchResult},
user::User, user::User,
}; };
@@ -47,7 +48,9 @@ pub async fn search_result_handler(
search_result: Vec<TextContentSearchResult>, search_result: Vec<TextContentSearchResult>,
query_param: String, query_param: String,
user: User, user: User,
conversation_archive: Vec<Conversation>,
} }
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
let (search_results_for_template, final_query_param_for_template) = let (search_results_for_template, final_query_param_for_template) =
if let Some(actual_query) = params.query { if let Some(actual_query) = params.query {
@@ -72,6 +75,7 @@ pub async fn search_result_handler(
search_result: search_results_for_template, search_result: search_results_for_template,
query_param: final_query_param_for_template, query_param: final_query_param_for_template,
user, user,
conversation_archive,
}, },
)) ))
} }

View File

@@ -1,33 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*',
],
theme: {
container: {
padding: {
DEFAULT: '10px',
sm: '2rem',
lg: '4rem',
xl: '5rem',
'2xl': '6rem',
},
},
extend: {
fontFamily: {
satoshi: ['Satoshi', 'sans-serif'],
},
typography: {
DEFAULT: {
css: {
maxWidth: '90ch', // Override max-width for all prose instances
},
},
},
},
},
daisyui: {
themes: ["light", "dark"],
},
}

View File

@@ -1,207 +1,156 @@
{% extends 'body_base.html' %} {% extends 'body_base.html' %}
{% block title %}Minne - Account{% endblock %} {% block title %}Minne - Admin{% endblock %}
{% block main %} {% block main %}
<main class="container flex-grow flex flex-col mx-auto mt-4 space-y-6"> <div class="flex justify-center grow mt-2 sm:mt-4 pb-4">
<h1 class="text-2xl font-bold mb-2">Admin Dashboard</h1> <div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Admin Dashboard</h1>
</div>
</section>
<div class="stats stats-vertical md:stats-horizontal shadow"> <section class="mb-4">
<div class="stat"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="stat-title font-bold">Page loads</div> <div class="nb-stat">
<div class="stat-value text-secondary">{{analytics.page_loads}}</div> <div class="text-xs opacity-70">Page Loads</div>
<div class="stat-desc">Amount of page loads</div> <div class="text-3xl font-extrabold">{{analytics.page_loads}}</div>
</div> <div class="text-xs opacity-60">Total page load events</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Unique Visitors</div>
<div class="text-3xl font-extrabold">{{analytics.visitors}}</div>
<div class="text-xs opacity-60">Distinct users by fingerprint</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Users</div>
<div class="text-3xl font-extrabold">{{users}}</div>
<div class="text-xs opacity-60">Registered accounts</div>
</div>
</div>
</section>
<div class="stat"> <section class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div class="stat-title font-bold">Unique visitors</div> {% block system_prompt_section %}
<div class="stat-value text-primary">{{analytics.visitors}}</div> <div id="system_prompt_section" class="nb-panel p-4">
<div class="stat-desc">Amount of unique visitors</div> <div class="text-sm font-semibold mb-3">System Prompts</div>
</div>
<div class="stat">
<div class="stat-title font-bold">Users</div>
<div class="stat-value text-accent">{{users}}</div>
<div class="stat-desc">Amount of registered users</div>
</div>
</div>
<!-- Settings in Fieldset -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
{% block system_prompt_section %}
<div id="system_prompt_section">
<fieldset class="fieldset p-4 shadow rounded-box">
<legend class="fieldset-legend">System Prompts</legend>
<div class="flex gap-2 flex-col sm:flex-row"> <div class="flex gap-2 flex-col sm:flex-row">
<button type="button" class="btn btn-primary btn-sm" hx-get="/edit-query-prompt" hx-target="#modal" <button type="button" class="nb-btn btn-sm" hx-get="/edit-query-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Query Prompt</button>
hx-swap="innerHTML"> <button type="button" class="nb-btn btn-sm" hx-get="/edit-ingestion-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Ingestion Prompt</button>
Edit Query Prompt <button type="button" class="nb-btn btn-sm" hx-get="/edit-image-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Image Prompt</button>
</button>
<button type="button" class="btn btn-primary btn-sm" hx-get="/edit-ingestion-prompt" hx-target="#modal"
hx-swap="innerHTML">
Edit Ingestion Prompt
</button>
<button type="button" class="btn btn-primary btn-sm" hx-get="/edit-image-prompt" hx-target="#modal"
hx-swap="innerHTML">
Edit Image Prompt
</button>
</div> </div>
</fieldset> </div>
</div>
{% endblock %}
<fieldset class="fieldset p-4 shadow rounded-box">
<legend class="fieldset-legend">AI Models</legend>
{% block model_settings_form %}
<form hx-patch="/update-model-settings" hx-swap="outerHTML">
<!-- Query Model -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Query Model</span>
</label>
<select name="query_model" class="select select-bordered w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.query_model==model.id %} selected {% endif %}>{{model.id}}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">
Current used:
<span class="font-mono">{{settings.query_model}}</span>
</p>
</div>
<!-- Processing Model -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Processing Model</span>
</label>
<select name="processing_model" class="select select-bordered w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.processing_model==model.id %} selected {% endif %}>{{model.id}}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">
Current used:
<span class="font-mono">{{settings.processing_model}}</span>
</p>
</div>
<!-- Image Processing Model -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Image Processing Model</span>
</label>
<select name="image_processing_model" class="select select-bordered w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.image_processing_model==model.id %} selected {% endif %}>
{{model.id}}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">
Current used:
<span class="font-mono">{{settings.image_processing_model}}</span>
</p>
</div>
<!-- Voice Processing Model -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Voice Processing Model</span>
</label>
<select name="voice_processing_model" class="select select-bordered w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.voice_processing_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">
Current used:
<span class="font-mono">{{settings.voice_processing_model}}</span>
</p>
</div>
<!-- Embedding Model -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Embedding Model</span>
</label>
<select name="embedding_model" class="select select-bordered w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.embedding_model==model.id %} selected {% endif %}>{{model.id}}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500 mt-1">
Current used:
<span class="font-mono">{{settings.embedding_model}} ({{settings.embedding_dimensions}} dims)</span>
</p>
</div>
<!-- Embedding Dimensions (Always Visible) -->
<div class="form-control mb-4">
<label class="label" for="embedding_dimensions">
<span class="label-text">Embedding Dimensions</span>
</label>
<input type="number" id="embedding_dimensions" name="embedding_dimensions" class="input input-bordered w-full"
value="{{ settings.embedding_dimensions }}" required />
</div>
<!-- Conditional Alert -->
<div id="embedding-change-alert" role="alert" class="alert alert-warning mt-2 hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span><strong>Warning:</strong> Changing dimensions will require re-creating all embeddings. Make sure you
look up what dimensions the model uses or use a model that allows specifying embedding dimensions</span>
</div>
<button type="submit" class="btn btn-primary btn-sm mt-4">Save Model Settings</button>
</form>
<script>
// Use a self-executing function to avoid polluting the global scope
// and to ensure it runs correctly after an HTMX swap.
(() => {
const dimensionInput = document.getElementById('embedding_dimensions');
const alertElement = document.getElementById('embedding-change-alert');
// The initial value is read directly from the template each time this script runs.
const initialDimensions = '{{ settings.embedding_dimensions }}';
if (dimensionInput && alertElement) {
// Use the 'input' event for immediate feedback as the user types.
dimensionInput.addEventListener('input', (event) => {
// Show alert if the current value is not the initial value. Hide it otherwise.
if (event.target.value !== initialDimensions) {
alertElement.classList.remove('hidden');
} else {
alertElement.classList.add('hidden');
}
});
}
})();
</script>
{% endblock %} {% endblock %}
</fieldset>
<fieldset class="fieldset p-4 shadow rounded-box"> <div class="nb-panel p-4">
<legend class="fieldset-legend">Registration</legend> <div class="text-sm font-semibold mb-3">AI Models</div>
<label class="flex gap-4 text-center"> {% block model_settings_form %}
{% block registration_status_input %} <form hx-patch="/update-model-settings" hx-swap="outerHTML" class="grid grid-cols-1 gap-4">
<form hx-patch="/toggle-registrations" hx-swap="outerHTML" hx-trigger="change"> <!-- Query Model -->
<input name="registration_open" type="checkbox" class="checkbox" {% if settings.registrations_enabled <div>
%}checked{% endif %} /> <div class="text-sm opacity-80 mb-1">Query Model</div>
<select name="query_model" class="nb-select w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.query_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs opacity-70 mt-1">Current: <span class="font-mono">{{settings.query_model}}</span></p>
</div>
<!-- Processing Model -->
<div>
<div class="text-sm opacity-80 mb-1">Processing Model</div>
<select name="processing_model" class="nb-select w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.processing_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs opacity-70 mt-1">Current: <span class="font-mono">{{settings.processing_model}}</span></p>
</div>
<!-- Image Processing Model -->
<div>
<div class="text-sm opacity-80 mb-1">Image Processing Model</div>
<select name="image_processing_model" class="nb-select w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.image_processing_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs opacity-70 mt-1">Current: <span class="font-mono">{{settings.image_processing_model}}</span></p>
</div>
<!-- Voice Processing Model -->
<div>
<div class="text-sm opacity-80 mb-1">Voice Processing Model</div>
<select name="voice_processing_model" class="nb-select w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.voice_processing_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs opacity-70 mt-1">Current: <span class="font-mono">{{settings.voice_processing_model}}</span></p>
</div>
<!-- Embedding Model -->
<div>
<div class="text-sm opacity-80 mb-1">Embedding Model</div>
<select name="embedding_model" class="nb-select w-full">
{% for model in available_models.data %}
<option value="{{model.id}}" {% if settings.embedding_model==model.id %} selected {% endif %}>{{model.id}}</option>
{% endfor %}
</select>
<p class="text-xs opacity-70 mt-1">Current: <span class="font-mono">{{settings.embedding_model}} ({{settings.embedding_dimensions}} dims)</span></p>
</div>
<!-- Embedding Dimensions -->
<div>
<div class="text-sm opacity-80 mb-1" for="embedding_dimensions">Embedding Dimensions</div>
<input type="number" id="embedding_dimensions" name="embedding_dimensions" class="nb-input w-full" value="{{ settings.embedding_dimensions }}" required />
</div>
<!-- Alert -->
<div id="embedding-change-alert" class="nb-panel p-3 bg-warning/20 hidden">
<div class="text-sm"><strong>Warning:</strong> Changing dimensions will require re-creating all embeddings. Look up your model's required dimensions or use a model that allows specifying them.</div>
</div>
<div class="flex justify-end">
<button type="submit" class="nb-btn nb-cta btn-sm">Save Model Settings</button>
</div>
</form> </form>
{% endblock %}
Enable Registrations
</label>
<div id="registration-status" class="text-sm mt-2"></div>
</fieldset>
<script>
// Rebind after HTMX swaps
(() => {
const dimensionInput = document.getElementById('embedding_dimensions');
const alertElement = document.getElementById('embedding-change-alert');
const initialDimensions = '{{ settings.embedding_dimensions }}';
if (dimensionInput && alertElement) {
dimensionInput.addEventListener('input', (event) => {
if (String(event.target.value) !== String(initialDimensions)) {
alertElement.classList.remove('hidden');
} else {
alertElement.classList.add('hidden');
}
});
}
})();
</script>
{% endblock %}
</div>
<div class="nb-panel p-4">
<div class="text-sm font-semibold mb-3">Registration</div>
<label class="flex items-center gap-3">
{% block registration_status_input %}
<form hx-patch="/toggle-registrations" hx-swap="outerHTML" hx-trigger="change">
<input name="registration_open" type="checkbox" class="nb-checkbox" {% if settings.registrations_enabled %}checked{% endif %} />
</form>
{% endblock %}
<span class="text-sm">Enable Registrations</span>
</label>
<div id="registration-status" class="text-xs opacity-70 mt-2"></div>
</div>
</section>
</div> </div>
</main> </div>
{% endblock %} {% endblock %}

View File

@@ -7,17 +7,17 @@ hx-swap="outerHTML"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold mb-4">Edit Image Processing Prompt</h3> <h3 class="text-xl font-extrabold tracking-tight mb-2">Edit Image Processing Prompt</h3>
<div class="form-control"> <div class="form-control">
<textarea name="image_processing_prompt" class="textarea textarea-bordered h-96 w-full font-mono text-sm">{{ <textarea name="image_processing_prompt" class="nb-input h-96 w-full font-mono text-sm">{{
settings.image_processing_prompt }}</textarea> settings.image_processing_prompt }}</textarea>
<p class="text-xs text-gray-500 mt-1">System prompt used for processing images</p> <p class="text-xs opacity-70 mt-1">System prompt used for processing images</p>
</div> </div>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="button" class="btn btn-outline mr-2" id="reset_prompt_button"> <button type="button" class="nb-btn mr-2" id="reset_prompt_button">
Reset to Default Reset to Default
</button> </button>
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
}); });
</script> </script>
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">
<span class="htmx-indicator hidden"> <span class="htmx-indicator hidden">
<span class="loading loading-spinner loading-xs mr-2"></span> <span class="loading loading-spinner loading-xs mr-2"></span>
</span> </span>
Save Changes Save Changes
</button> </button>
{% endblock %} {% endblock %}

View File

@@ -7,17 +7,17 @@ hx-swap="outerHTML"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold mb-4">Edit Ingestion Prompt</h3> <h3 class="text-xl font-extrabold tracking-tight mb-2">Edit Ingestion Prompt</h3>
<div class="form-control"> <div class="form-control">
<textarea name="ingestion_system_prompt" class="textarea textarea-bordered h-96 w-full font-mono text-sm">{{ <textarea name="ingestion_system_prompt" class="nb-input h-96 w-full font-mono text-sm">{{
settings.ingestion_system_prompt }}</textarea> settings.ingestion_system_prompt }}</textarea>
<p class="text-xs text-gray-500 mt-1">System prompt used for content processing and ingestion</p> <p class="text-xs opacity-70 mt-1">System prompt used for content processing and ingestion</p>
</div> </div>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="button" class="btn btn-outline mr-2" id="reset_prompt_button"> <button type="button" class="nb-btn mr-2" id="reset_prompt_button">
Reset to Default Reset to Default
</button> </button>
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
}); });
</script> </script>
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">
<span class="htmx-indicator hidden"> <span class="htmx-indicator hidden">
<span class="loading loading-spinner loading-xs mr-2"></span> <span class="loading loading-spinner loading-xs mr-2"></span>
</span> </span>
Save Changes Save Changes
</button> </button>
{% endblock %} {% endblock %}

View File

@@ -7,17 +7,17 @@ hx-swap="outerHTML"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold mb-4">Edit System Prompt</h3> <h3 class="text-xl font-extrabold tracking-tight mb-2">Edit System Prompt</h3>
<div class="form-control"> <div class="form-control">
<textarea name="query_system_prompt" class="textarea textarea-bordered h-96 w-full font-mono text-sm">{{ <textarea name="query_system_prompt" class="nb-input h-96 w-full font-mono text-sm">{{
settings.query_system_prompt }}</textarea> settings.query_system_prompt }}</textarea>
<p class="text-xs text-gray-500 mt-1">System prompt used for answering user queries</p> <p class="text-xs opacity-70 mt-1">System prompt used for answering user queries</p>
</div> </div>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="button" class="btn btn-outline mr-2" id="reset_prompt_button"> <button type="button" class="nb-btn mr-2" id="reset_prompt_button">
Reset to Default Reset to Default
</button> </button>
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
}); });
</script> </script>
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">
<span class="htmx-indicator hidden"> <span class="htmx-indicator hidden">
<span class="loading loading-spinner loading-xs mr-2"></span> <span class="loading loading-spinner loading-xs mr-2"></span>
</span> </span>
Save Changes Save Changes
</button> </button>
{% endblock %} {% endblock %}

View File

@@ -3,91 +3,86 @@
{% block title %}Minne - Account{% endblock %} {% block title %}Minne - Account{% endblock %}
{% block main %} {% block main %}
<style> <div class="flex justify-center grow mt-2 sm:mt-4 pb-4">
form.htmx-request { <div class="container">
opacity: 0.5; <section class="mb-4">
} <div class="nb-panel p-3 flex items-center justify-between">
</style> <h1 class="text-xl font-extrabold tracking-tight">Account Settings</h1>
<main class="container flex-grow flex flex-col mx-auto mt-4 space-y-1"> </div>
<h1 class="text-2xl font-bold mb-2">Account Settings</h1> </section>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" name="email" value="{{ user.email }}" class="input text-primary-content input-bordered w-full"
disabled />
</div>
<div class="form-control"> <section class="grid grid-cols-1 lg:grid-cols-2 gap-4 space-y-2">
<label class="label"> <!-- Left column -->
<span class="label-text">API key</span> <div class="nb-panel p-4 space-y-2 flex flex-col">
</label> <label class="w-full">
{% block api_key_section %} <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
{% if user.api_key %} <input type="email" name="email" value="{{ user.email }}" class="nb-input w-full" disabled />
<div class="relative"> </label>
<input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
class="input text-primary-content input-bordered w-full pr-12" disabled />
<button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
class="absolute inset-y-0 cursor-pointer right-0 flex items-center pr-3" title="Copy API key">
{% include "icons/clipboard_icon.html" %}
</button>
</div>
<a href="https://www.icloud.com/shortcuts/66985f7b98a74aaeac6ba29c3f1f0960"
class="btn btn-accent mt-4 w-full">Download iOS shortcut</a>
{% else %}
<button hx-post="/set-api-key" class="btn btn-secondary w-full" hx-swap="outerHTML">
Create API-Key
</button>
{% endif %}
{% endblock %}
</div>
<script> <label class="w-full">
function copy_api_key() { <div class="text-xs uppercase tracking-wide opacity-70 mb-1">API Key</div>
const input = document.getElementById('api_key_input'); {% block api_key_section %}
if (!input) return; {% if user.api_key %}
if (navigator.clipboard && window.isSecureContext) { <div class="relative">
navigator.clipboard.writeText(input.value) <input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
.then(() => show_toast('API key copied!', 'success')) class="nb-input w-full pr-14" disabled />
.catch(() => show_toast('Copy failed', 'error')); <button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
} else { class="absolute inset-y-0 right-0 flex items-center px-2 nb-btn btn-sm" aria-label="Copy API key"
show_toast('Copy not supported', 'info'); title="Copy API key">
} {% include "icons/clipboard_icon.html" %}
} </button>
</script> </div>
<a href="https://www.icloud.com/shortcuts/66985f7b98a74aaeac6ba29c3f1f0960"
class="nb-btn nb-cta mt-2 w-full">Download iOS shortcut</a>
{% else %}
<button hx-post="/set-api-key" class="nb-btn nb-cta w-full" hx-swap="outerHTML">Create API-Key</button>
{% endif %}
{% endblock %}
</label>
<div class="form-control mt-4"> <script>
<label class="label"> function copy_api_key() {
<span class="label-text">Timezone</span> const input = document.getElementById('api_key_input');
</label> if (!input) return;
{% block timezone_section %} if (navigator.clipboard && window.isSecureContext) {
<select name="timezone" class="select w-full" hx-patch="/update-timezone" hx-swap="outerHTML"> navigator.clipboard.writeText(input.value)
{% for tz in timezones %} .then(() => show_toast('API key copied!', 'success'))
<option value="{{ tz }}" {% if tz==user.timezone %}selected{% endif %}>{{ tz }}</option> .catch(() => show_toast('Copy failed', 'error'));
{% endfor %} } else {
</select> show_toast('Copy not supported', 'info');
{% endblock %} }
</div> }
</script>
<div class="form-control mt-4 hidden"> <label class="w-full">
<button hx-post="/verify-email" class="btn btn-secondary w-full"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Timezone</div>
Verify Email {% block timezone_section %}
</button> <select name="timezone" class="nb-select w-full" hx-patch="/update-timezone" hx-swap="outerHTML">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz==user.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
{% endblock %}
</label>
</div>
<!-- Right column -->
<div class="nb-panel p-4 space-y-2">
<div>
{% block change_password_section %}
<button hx-get="/change-password" hx-swap="outerHTML" class="nb-btn w-full">Change Password</button>
{% endblock %}
</div>
<div>
<button hx-delete="/delete-account"
hx-confirm="This action will permanently delete your account and all data associated. Are you sure you want to continue?"
class="nb-btn btn-error w-full">Delete Account</button>
</div>
</div>
</section>
<div id="account-result" class="mt-4"></div>
</div> </div>
<div class="form-control mt-4"> </div>
{% block change_password_section %} {% endblock %}
<button hx-get="/change-password" hx-swap="outerHTML" class="btn btn-primary w-full">
Change Password
</button>
{% endblock %}
</div>
<div class="form-control mt-4">
<button hx-delete="/delete-account"
hx-confirm="This action will permanently delete your account and all data associated. Are you sure you want to continue?"
class="btn btn-error w-full">
Delete Account
</button>
</div>
<div id="account-result" class="mt-4"></div>
</main>
{% endblock %}

View File

@@ -1,5 +1,12 @@
<form hx-patch="/change-password" class="flex flex-col gap-1"> <form hx-patch="/change-password" class="flex flex-col gap-3">
<input name="old_password" class="input w-full" type="password" placeholder="Enter old password"></input> <label class="w-full">
<input name="new_password" class="input w-full" type="password" placeholder="Enter new password"></input> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Old Password</div>
<button class="btn btn-primary w-full">Change Password</button> <input name="old_password" class="nb-input w-full" type="password" placeholder="Enter old password"></input>
</form> </label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">New Password</div>
<input name="new_password" class="nb-input w-full" type="password" placeholder="Enter new password"></input>
</label>
<button class="nb-btn w-full">Change Password</button>
</form>

View File

@@ -1,52 +1,43 @@
<style> <div class="container mx-auto px-4 sm:max-w-md flex-1 flex items-center justify-center">
form.htmx-request { <div class="w-full nb-card p-5">
opacity: 0.5; <div class="flex items-center justify-between mb-3">
} <div class="brand-mark text-3xl font-extrabold tracking-tight">MINNE</div>
</style> <span class="nb-badge">Sign In</span>
<div class="flex justify-center grow container mx-auto px-4 sm:px-0 sm:max-w-md flex-col">
<h1
class="text-5xl sm:text-6xl py-4 pt-10 font-bold bg-linear-to-r from-primary to-secondary text-center text-transparent bg-clip-text">
Minne
</h1>
<h2 class="text-2xl font-bold text-center mb-8">Login to your account</h2>
<form hx-post="/signin" hx-target="#login-result">
<div class="form-control">
<label class="floating-label">
<span>Email</span>
<input name="email" type="email" placeholder="Email" class="input input-md w-full validator" required />
<div class="validator-hint hidden">Enter valid email address</div>
</label>
</div> </div>
<div class="u-hairline mb-3"></div>
<div class="form-control mt-4"> <form hx-post="/signin" hx-target="#login-result" class="flex flex-col gap-2">
<label class="floating-label"> <label class="w-full">
<span>Password</span> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
<input name="password" type="password" class="input validator w-full" required placeholder="Password" <input name="email" type="email" placeholder="Email" class="nb-input w-full validator" required />
<div class="validator-hint hidden text-xs opacity-70 mt-1">Enter valid email address</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Password</div>
<input name="password" type="password" class="nb-input w-full validator" required placeholder="Password"
minlength="8" /> minlength="8" />
</div>
<div class="form-control mt-4">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="remember_me" class="checkbox " />
<span class="label-text">Remember me</span>
</label> </label>
<div class="mt-1 text-error" id="login-result"></div>
<div class="form-control mt-1">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" name="remember_me" class="nb-checkbox" />
<span class="label-text">Remember me</span>
</label>
</div>
<div class="form-control mt-1">
<button id="submit-btn" class="nb-btn nb-cta w-full">Login</button>
</div>
</form>
<div class="u-hairline my-3"></div>
<div class="text-center text-sm">
Dont have an account?
<a href="/signup" hx-boost="true" class="nb-link">Sign up</a>
</div> </div>
<div class="mt-4" id="login-result"></div>
<div class="form-control mt-6">
<button id="submit-btn" class="btn btn-primary w-full">
Login
</button>
</div>
</form>
<div class="divider">OR</div>
<div class="text-center text-sm">
Don't have an account?
<a href="/signup" hx-boost="true" class="link link-primary">Sign up</a>
</div> </div>
</div> </div>

View File

@@ -3,56 +3,48 @@
{% block title %}Minne - Sign up{% endblock %} {% block title %}Minne - Sign up{% endblock %}
{% block body %} {% block body %}
<style> <div class="min-h-[100dvh] flex items-center">
form.htmx-request { <div class="container mx-auto px-4 sm:max-w-md">
opacity: 0.5; <div class="nb-card p-5">
} <div class="flex items-center justify-between mb-3">
</style> <div class="text-3xl font-extrabold tracking-tight">MINNE</div>
<span class="nb-badge">Sign Up</span>
</div>
<div class="u-hairline mb-3"></div>
<div class="min-h-[100dvh] container mx-auto px-4 sm:px-0 sm:max-w-md flex justify-center flex-col"> <form hx-post="/signup" hx-target="#signup-result" class="flex flex-col gap-4">
<h1 <label class="w-full">
class="text-5xl sm:text-6xl py-4 pt-10 font-bold bg-linear-to-r from-primary to-secondary text-center text-transparent bg-clip-text"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
Minne <input type="email" placeholder="Email" name="email" required class="nb-input w-full validator" />
</h1> <div class="validator-hint hidden text-xs opacity-70 mt-1">Enter valid email address</div>
<h2 class="text-2xl font-bold text-center mb-8">Create your account</h2> </label>
<form hx-post="/signup" hx-target="#signup-result" class=""> <label class="w-full">
<div class="form-control"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Password</div>
<label class="floating-label"> <input type="password" name="password" class="nb-input w-full validator" required placeholder="Password"
<span>Email</span> minlength="8" pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
<input type="email" placeholder="Email" name="email" required class="input input-md w-full validator" /> title="Must be more than 8 characters, including number, lowercase letter, uppercase letter" />
<div class="validator-hint hidden">Enter valid email address</div> <p class="validator-hint hidden text-xs opacity-70 mt-1">
</label> Must be more than 8 characters, including
<br />At least one number
<br />At least one lowercase letter
<br />At least one uppercase letter
</p>
</label>
<div class="mt-2 text-error" id="signup-result"></div>
<div class="form-control mt-1">
<button id="submit-btn" class="nb-btn nb-cta w-full">Create Account</button>
</div>
<input type="hidden" name="timezone" id="timezone" />
</form>
<div class="u-hairline my-3"></div>
<div class="text-center text-sm">
Already have an account?
<a href="/signin" hx-boost="true" class="nb-link">Sign in</a>
</div>
</div> </div>
<div class="form-control mt-4">
<label class="floating-label">
<span>Password</span>
<input type="password" name="password" class="input validator w-full" required placeholder="Password"
minlength="8" pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
title="Must be more than 8 characters, including number, lowercase letter, uppercase letter" />
<p class="validator-hint hidden">
Must be more than 8 characters, including
<br />At least one number
<br />At least one lowercase letter
<br />At least one uppercase letter
</p>
</label>
</div>
<div class="mt-4 text-error" id="signup-result"></div>
<div class="form-control mt-6">
<button id="submit-btn" class="btn btn-primary w-full">
Create Account
</button>
</div>
<input type="hidden" name="timezone" id="timezone" />
</form>
<div class="divider">OR</div>
<div class="text-center text-sm">
Already have an account?
<a href="/signin" hx-boost="true" class="link link-primary">Sign in</a>
</div> </div>
</div> </div>
<script> <script>
@@ -60,4 +52,4 @@
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.getElementById("timezone").value = timezone; document.getElementById("timezone").value = timezone;
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -2,7 +2,7 @@
{% block body %} {% block body %}
<body class="bg-base-100 relative" hx-ext="head-support"> <body class="relative" hx-ext="head-support">
<div class="drawer lg:drawer-open"> <div class="drawer lg:drawer-open">
<input id="my-drawer" type="checkbox" class="drawer-toggle" /> <input id="my-drawer" type="checkbox" class="drawer-toggle" />
<!-- Page Content --> <!-- Page Content -->
@@ -10,8 +10,9 @@
<!-- Navbar --> <!-- Navbar -->
{% include "navigation_bar.html" %} {% include "navigation_bar.html" %}
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="flex flex-1 overflow-y-auto"> <main class="flex flex-col flex-1 overflow-y-auto">
{% block main %}{% endblock %} {% block main %}{% endblock %}
<div class="p32 min-h-[10px]"></div>
</main> </main>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
@@ -21,25 +22,5 @@
</div> <!-- End Drawer --> </div> <!-- End Drawer -->
<div id="modal"></div> <div id="modal"></div>
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div> <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
<!-- Add CSS for custom scrollbar -->
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
</style>
</body> </body>
{% endblock %} {% endblock %}

View File

@@ -9,94 +9,40 @@
{% block main %} {% block main %}
<div class="flex grow relative justify-center mt-2 sm:mt-4"> <div class="flex grow relative justify-center mt-2 sm:mt-4">
<div class="container"> <div class="container">
<div class="overflow-auto hide-scrollbar"> <section class="mb-3">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Chat</h1>
<div class="text-xs opacity-70">Converse with your knowledge</div>
</div>
</section>
<div id="chat-scroll-container" class="overflow-auto hide-scrollbar">
{% include "chat/history.html" %} {% include "chat/history.html" %}
{% include "chat/new_message_form.html" %} {% include "chat/new_message_form.html" %}
</div> </div>
</div> </div>
</div> </div>
<style>
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.markdown-content p {
margin-bottom: 0.75em;
}
.markdown-content p:last-child {
margin-bottom: 0;
}
.markdown-content ul,
.markdown-content ol {
margin-top: 0.5em;
margin-bottom: 0.75em;
padding-left: 2em;
}
.markdown-content li {
margin-bottom: 0.25em;
}
.markdown-content pre {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.5em;
border-radius: 4px;
overflow-x: auto;
}
.markdown-content code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.markdown-content {
line-height: 1.5;
word-wrap: break-word;
}
.markdown-content table {
border-collapse: collapse;
margin: 0.75em 0;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #ddd;
padding: 6px 12px;
text-align: left;
}
.markdown-content blockquote {
border-left: 4px solid #ddd;
padding-left: 10px;
margin: 0.5em 0 0.5em 0.5em;
color: #666;
}
.markdown-content hr {
border: none;
border-top: 1px solid #ddd;
margin: 0.75em 0;
}
</style>
<script> <script>
function scrollChatToBottom() { function scrollChatToBottom() {
const chatContainer = document.getElementById('chat_container'); requestAnimationFrame(() => {
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight; const mainScroll = document.querySelector('main');
if (mainScroll) mainScroll.scrollTop = mainScroll.scrollHeight;
const chatScroll = document.getElementById('chat-scroll-container');
if (chatScroll) chatScroll.scrollTop = chatScroll.scrollHeight;
const chatContainer = document.getElementById('chat_container');
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
window.scrollTo(0, document.body.scrollHeight);
});
} }
window.scrollChatToBottom = scrollChatToBottom;
document.addEventListener('DOMContentLoaded', scrollChatToBottom); document.addEventListener('DOMContentLoaded', scrollChatToBottom);
document.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom); document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,4 @@
<div id="chat_container" class="pl-3 overflow-y-auto h-[calc(100vh-170px)] sm:h-[calc(100vh-190px)] hide-scrollbar"> <div id="chat_container" class="px-3 pb-44 space-y-3">
{% for message in history %} {% for message in history %}
{% if message.role == "AI" %} {% if message.role == "AI" %}
<div class="chat chat-start"> <div class="chat chat-start">

View File

@@ -1,11 +1,9 @@
{% include "chat/streaming_response.html" %} {% include "chat/streaming_response.html" %}
<!-- OOB swap targeting the form element directly --> <!-- OOB swap targeting the form element directly -->
<form id="chat-form" hx-post="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend" <form id="chat-form" hx-post="/chat/{{conversation.id}}" hx-target="#chat_container" hx-swap="beforeend"
class="relative flex gap-2" hx-swap-oob="true"> class="relative flex gap-2" hx-swap-oob="true">
<textarea autofocus required name="content" placeholder="Type your message..." rows="2" <textarea autofocus required name="content" placeholder="Type your message..." rows="2"
class="textarea textarea-ghost rounded-2xl rounded-b-none h-24 sm:rounded-b-2xl pr-8 bg-base-200 flex-grow resize-none" class="nb-input h-24 pr-8 pl-2 pt-2 pb-2 flex-grow resize-none" id="chat-input"></textarea>
id="chat-input"></textarea>
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1"> <button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1">
{% include "icons/send_icon.html" %} {% include "icons/send_icon.html" %}
</button> </button>

View File

@@ -1,13 +1,14 @@
<div class="absolute w-full mx-auto max-w-3xl p-0 pb-0 sm:pb-4 left-0 right-0 bottom-0 z-10"> <div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}" <div class="mx-auto max-w-3xl px-4 pb-3">
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2" id="chat-form"> <div class="nb-panel p-2">
<textarea autofocus required name="content" placeholder="Type your message..." rows="2" <form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
class="textarea textarea-ghost rounded-2xl rounded-b-none h-24 sm:rounded-b-2xl pr-8 bg-base-200 flex-grow resize-none focus:outline-none focus:bg-base-200" hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
id="chat-input"></textarea> <textarea autofocus required name="content" placeholder="Type your message…" rows="3"
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-6">{% include class="nb-input flex-grow min-h-24 pr-10 pl-2 pt-2 pb-2 resize-none" id="chat-input"></textarea>
"icons/send_icon.html" %} <button type="submit" class="nb-btn nb-cta h-10 px-3">{% include "icons/send_icon.html" %}</button>
</button> </form>
</form> </div>
</div>
</div> </div>
<script> <script>
@@ -23,4 +24,4 @@
document.getElementById('chat-input').value = ''; // Clear the textarea document.getElementById('chat-input').value = ''; // Clear the textarea
} }
}); });
</script> </script>

View File

@@ -1,8 +1,8 @@
<div class="relative my-2"> <div class="relative my-2">
<button id="references-toggle-{{message.id}}" <button id="references-toggle-{{message.id}}"
class="text-xs text-blue-500 hover:text-blue-700 hover:underline focus:outline-none flex items-center" class="nb-btn btn-xs bg-base-100 hover:bg-base-200 flex items-center"
onclick="toggleReferences('{{message.id}}')"> onclick="toggleReferences('{{message.id}}')">
References REFERENCES
{% include "icons/chevron_icon.html" %} {% include "icons/chevron_icon.html" %}
</button> </button>
<div id="references-content-{{message.id}}" class="hidden max-w-full mt-1"> <div id="references-content-{{message.id}}" class="hidden max-w-full mt-1">
@@ -10,7 +10,7 @@
{% for reference in message.references %} {% for reference in message.references %}
<div class="reference-badge-container" data-reference="{{reference}}" data-message-id="{{message.id}}" <div class="reference-badge-container" data-reference="{{reference}}" data-message-id="{{message.id}}"
data-index="{{loop.index}}"> data-index="{{loop.index}}">
<span class="badge badge-xs badge-neutral truncate max-w-[20ch] overflow-hidden text-left block cursor-pointer"> <span class="nb-badge truncate max-w-[20ch] overflow-hidden text-left block cursor-pointer">
{{reference}} {{reference}}
</span> </span>
</div> </div>
@@ -80,7 +80,7 @@
function createTooltip() { function createTooltip() {
const tooltip = document.createElement('div'); const tooltip = document.createElement('div');
tooltip.id = tooltipId; tooltip.id = tooltipId;
tooltip.className = 'fixed z-[9999] bg-neutral-800 text-white p-3 rounded-md shadow-lg text-sm w-72 max-w-xs border border-neutral-700 hidden'; tooltip.className = 'reference-tooltip hidden';
tooltip.innerHTML = '<div class="animate-pulse">Loading...</div>'; tooltip.innerHTML = '<div class="animate-pulse">Loading...</div>';
document.body.appendChild(tooltip); document.body.appendChild(tooltip);
return tooltip; return tooltip;
@@ -135,15 +135,3 @@
}); });
} }
</script> </script>
<style>
#references-toggle- {
{
message.id
}
}
svg {
transition: transform 0.2s ease;
}
</style>

View File

@@ -25,7 +25,7 @@
e.preventDefault(); e.preventDefault();
window.markdownBuffer[msgId] = (window.markdownBuffer[msgId] || '') + (e.detail.data || ''); window.markdownBuffer[msgId] = (window.markdownBuffer[msgId] || '') + (e.detail.data || '');
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n')); el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
if (typeof scrollChatToBottom === "function") scrollChatToBottom(); if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
}); });
document.body.addEventListener('htmx:sseClose', function () { document.body.addEventListener('htmx:sseClose', function () {
const msgId = '{{ user_message.id }}'; const msgId = '{{ user_message.id }}';
@@ -33,7 +33,7 @@
if (el && window.markdownBuffer[msgId]) { if (el && window.markdownBuffer[msgId]) {
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n')); el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
delete window.markdownBuffer[msgId]; delete window.markdownBuffer[msgId];
if (typeof scrollChatToBottom === "function") scrollChatToBottom(); if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
} }
}); });
</script> </script>

View File

@@ -3,14 +3,14 @@
{% block title %}Minne - Content{% endblock %} {% block title %}Minne - Content{% endblock %}
{% block main %} {% block main %}
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10"> <main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 w-full">
<div class="container"> <div class="container">
<div class="flex items-center justify-between mb-4"> <div class="nb-panel p-3 mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold">Content</h2> <h2 class="text-xl font-extrabold tracking-tight">Content</h2>
<form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true" <form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true"
class="flex items-center gap-2 mt-2 sm:mt-0"> class="flex items-center gap-2 mt-2 sm:mt-0">
<div class="form-control"> <div>
<select name="category" class="select select-bordered"> <select name="category" class="nb-select">
<option value="">All Categories</option> <option value="">All Categories</option>
{% for category in categories %} {% for category in categories %}
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }} <option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
@@ -18,13 +18,11 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button> <button type="submit" class="nb-btn btn-sm">Filter</button>
</form> </form>
</div> </div>
<div id="text_content_cards"> {% include "content/content_list.html" %}
{% include "content/content_list.html" %}
</div>
</div> </div>
</main> </main>
{% endblock %} {% endblock %}

View File

@@ -1,19 +1,19 @@
<div class="columns-1 md:columns-2 2xl:columns-3 gap-4" id="text_content_cards"> <div class="nb-masonry w-full" id="text_content_cards">
{% for text_content in text_contents %} {% for text_content in text_contents %}
<div class="card cursor-pointer mb-4 bg-base-100 shadow break-inside-avoid-column" <article class="nb-card cursor-pointer mx-auto mb-4 w-full max-w-[92vw] space-y-3 sm:max-w-none"
hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML"> hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML">
{% if text_content.url_info %} {% if text_content.url_info %}
<figure> <figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<img src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" /> <img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
</figure> </figure>
{% endif %} {% endif %}
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %} {% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
<figure> <figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
<img src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" /> <img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
</figure> </figure>
{% endif %} {% endif %}
<div class="card-body max-w-[95vw]"> <div class="space-y-3 break-words">
<h2 class="card-title truncate"> <h2 class="text-lg font-extrabold tracking-tight truncate">
{% if text_content.url_info %} {% if text_content.url_info %}
{{text_content.url_info.title}} {{text_content.url_info.title}}
{% elif text_content.file_info %} {% elif text_content.file_info %}
@@ -22,37 +22,36 @@
{{text_content.text}} {{text_content.text}}
{% endif %} {% endif %}
</h2> </h2>
<div class="flex items-center justify-between"> <div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-xs opacity-60"> <p class="text-xs opacity-60 shrink-0">
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }} {{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
</p> </p>
<div class="badge badge-soft badge-secondary mr-2">{{ text_content.category }}</div> <span class="nb-badge">{{ text_content.category }}</span>
<div class="flex gap-2" hx-on:click="event.stopPropagation()"> <div class="flex gap-2" hx-on:click="event.stopPropagation()">
{% if text_content.url_info %} {% if text_content.url_info %}
<button class="btn-btn-square btn-ghost btn-sm"> <a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"
<a href="{{text_content.url_info.url}}" target="_blank" rel="noopener noreferrer"> class="nb-btn btn-square btn-sm" aria-label="Open source link">
{% include "icons/link_icon.html" %} {% include "icons/link_icon.html" %}
</a> </a>
</button>
{% endif %} {% endif %}
<button hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML" <button hx-get="/content/{{ text_content.id }}/read" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm"> class="nb-btn btn-square btn-sm" aria-label="Read content">
{% include "icons/read_icon.html" %} {% include "icons/read_icon.html" %}
</button> </button>
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML" <button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm"> class="nb-btn btn-square btn-sm" aria-label="Edit content">
{% include "icons/edit_icon.html" %} {% include "icons/edit_icon.html" %}
</button> </button>
<button hx-delete="/content/{{ text_content.id }}" hx-target="#text_content_cards" hx-swap="outerHTML" <button hx-delete="/content/{{ text_content.id }}" hx-target="#text_content_cards" hx-swap="outerHTML"
class="btn btn-square btn-ghost btn-sm"> class="nb-btn btn-square btn-sm" aria-label="Delete content">
{% include "icons/delete_icon.html" %} {% include "icons/delete_icon.html" %}
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2"> <p class="text-sm leading-relaxed">
{{ text_content.instructions }} {{ text_content.instructions }}
</p> </p>
</div> </div>
</div> </article>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -8,34 +8,49 @@ flex flex-col min-h-[95%] w-11/12 max-w-[90ch] max-h-[95%]
hx-patch="/content/{{text_content.id}}" hx-patch="/content/{{text_content.id}}"
hx-target="#main_section" hx-target="#main_section"
hx-swap="outerHTML" hx-swap="outerHTML"
class="flex flex-col flex-1 h-full" class="flex flex-col flex-1 h-full min-h-0"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold">Edit Content</h3> <h3 class="text-xl font-extrabold tracking-tight">Edit Content</h3>
<div class="form-control"> <div class="flex flex-col gap-3 flex-1 min-h-0">
<label class="floating-label"> <label class="w-full">
<span class="label-text">Context</span> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
<input type="text" name="context" value="{{ text_content.context }}" class="w-full input input-bordered"> <input type="text" name="context" value="{{ text_content.context }}" class="nb-input w-full">
</label> </label>
</div> <label class="w-full">
<div class="form-control"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
<label class="floating-label"> <input type="text" name="category" value="{{ text_content.category }}" class="nb-input w-full">
<span class="label-text">Category</span> </label>
<input type="text" name="category" value="{{ text_content.category }}" class="w-full input input-bordered"> <label class="w-full flex-1 flex flex-col min-h-0">
</label> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Text</div>
</div> <textarea name="text" class="nb-input w-full flex-1 min-h-0 h-full resize-none overflow-y-auto">{{ text_content.text
<div class="form-control flex-1 flex flex-col min-h-0"> }}</textarea>
<label class="floating-label flex-1 flex flex-col min-h-0">
<span class="label-text">Text</span>
<textarea name="text" class="textarea textarea-bordered w-full flex-1 min-h-[200px] h-full resize-none">{{
text_content.text }}</textarea>
</label> </label>
</div> </div>
<script>
(function () {
const form = document.getElementById('modal_form');
if (!form) return;
if (document.getElementById('main_section')) {
form.setAttribute('hx-target', '#main_section');
form.setAttribute('hx-swap', 'outerHTML');
return;
}
if (document.getElementById('latest_content_section')) {
form.setAttribute('hx-target', '#latest_content_section');
form.setAttribute('hx-swap', 'outerHTML');
return;
}
form.removeAttribute('hx-target');
form.setAttribute('hx-swap', 'none');
})();
</script>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">Save Changes</button>
Save Changes {% endblock %}
</button>
{% endblock %}

View File

@@ -4,14 +4,14 @@
{% block modal_content %} {% block modal_content %}
{% if text_content.url_info.image_id %} {% if text_content.url_info.image_id %}
<img class="rounded-t-md overflow-clip" src="/file/{{text_content.url_info.image_id}}" alt="Screenshot of the site" /> <img class="w-full border-b-2 border-neutral" src="/file/{{text_content.url_info.image_id}}" alt="Screenshot of the site" />
{% endif %} {% endif %}
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %} {% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
<figure> <figure>
<img src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" /> <img src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
</figure> </figure>
{% endif %} {% endif %}
<div id="reader-{{text_content.id}}" class="markdown-content prose" data-content="{{text_content.text | escape }}"> <div id="reader-{{text_content.id}}" class="markdown-content prose-tufte" data-content="{{text_content.text | escape }}">
{{text_content.text | escape }} {{text_content.text | escape }}
</div> </div>
@@ -39,4 +39,4 @@
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
{% endblock %} {% endblock %}

View File

@@ -1,49 +1,63 @@
{% block active_jobs_section %} {% block active_jobs_section %}
<ul id="active_jobs_section" class="list"> <section id="active_jobs_section" class="nb-panel p-4 space-y-4 mt-6 sm:mt-8">
<div class="flex items-center gap-4"> <header class="flex flex-wrap items-center justify-between gap-3">
<li class="py-4 text-2xl font-bold tracking-wide">Active Tasks</li> <h2 class="text-xl font-extrabold tracking-tight">Active Tasks</h2>
<button class="cursor-pointer scale-75" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML"> <button class="nb-btn btn-square btn-sm" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML"
aria-label="Refresh active tasks">
{% include "icons/refresh_icon.html" %} {% include "icons/refresh_icon.html" %}
</button> </button>
</div> </header>
{% for item in active_jobs %} {% if active_jobs %}
<li class="list-row"> <ul class="flex flex-col gap-3 list-none p-0 m-0">
<div class="bg-secondary rounded-box size-10 flex justify-center items-center text-secondary-content"> {% for item in active_jobs %}
{% if item.content.Url %} <li class="nb-panel p-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
{% include "icons/link_icon.html" %} <div class="flex items-center gap-3 flex-1 min-w-0">
{% elif item.content.File %} <div class="size-10 shrink-0 flex items-center justify-center border-2 border-neutral bg-transparent">
{% include "icons/document_icon.html" %} {% if item.content.Url %}
{% else %} {% include "icons/link_icon.html" %}
{% include "icons/bars_icon.html" %} {% elif item.content.File %}
{% endif %} {% include "icons/document_icon.html" %}
</div> {% else %}
<div> {% include "icons/bars_icon.html" %}
<div class="[&:before]:content-['Status:_'] [&:before]:opacity-60"> {% endif %}
{% if item.status.name == "InProgress" %} </div>
In Progress, attempt {{item.status.attempts}} <div class="space-y-1">
{% elif item.status.name == "Error" %} <div class="text-sm font-semibold">
Error: {{item.status.message}} {% if item.status.name == "InProgress" %}
{% else %} In progress, attempt {{ item.status.attempts }}
{{item.status.name}} {% elif item.status.name == "Error" %}
{% endif %} Error: {{ item.status.message }}
{% else %}
{{ item.status.name }}
{% endif %}
</div>
<div class="text-xs font-semibold opacity-60">
{{ item.created_at|datetimeformat(format="short", tz=user.timezone) }}
</div>
</div>
</div> </div>
<div class="text-xs font-semibold opacity-60">
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}} </div> <div class="sm:flex-1 sm:text-right">
</div> <p class="text-xs opacity-80 leading-snug break-words">
<p class="list-col-wrap text-xs [&:before]:content-['Content:_'] [&:before]:opacity-60"> {% if item.content.Url %}
{% if item.content.Url %} {{ item.content.Url.url }}
{{item.content.Url.url}} {% elif item.content.File %}
{% elif item.content.File %} {{ item.content.File.file_info.file_name }}
{{item.content.File.file_info.file_name}} {% else %}
{% else %} {{ item.content.Text.text }}
{{item.content.Text.text}} {% endif %}
{% endif %} </p>
</p> </div>
<button hx-delete="/jobs/{{item.id}}" hx-target="#active_jobs_section" hx-swap="outerHTML"
class="btn btn-square btn-ghost btn-sm"> <div class="flex items-center justify-end gap-2">
{% include "icons/delete_icon.html" %} <button hx-delete="/jobs/{{ item.id }}" hx-target="#active_jobs_section" hx-swap="outerHTML"
</button> class="nb-btn btn-square btn-sm" aria-label="Cancel task">
</li> {% include "icons/delete_icon.html" %}
{% endfor %} </button>
</ul> </div>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endblock %} {% endblock %}

View File

@@ -7,8 +7,20 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4"> <div class="flex justify-center grow mt-2 sm:mt-4 pb-4 w-full">
<div class="container"> <div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Dashboard</h1>
<button class="nb-btn nb-cta" hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">
{% include "icons/send_icon.html" %}
<span class="ml-2">Add Content</span>
</button>
</div>
</section>
{% include "dashboard/statistics.html" %}
{% include "dashboard/recent_content.html" %} {% include "dashboard/recent_content.html" %}
{% include "dashboard/active_jobs.html" %} {% include "dashboard/active_jobs.html" %}

View File

@@ -1,32 +1,40 @@
{% for task in tasks %} {% for task in tasks %}
<li class="list-row" hx-ext="sse" sse-connect="/task/status-stream?task_id={{task.id}}" sse-close="close_stream"> <li class="nb-panel p-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
<div class="bg-secondary rounded-box size-10 flex justify-center items-center text-secondary-content" hx-ext="sse" sse-connect="/task/status-stream?task_id={{task.id}}" sse-close="close_stream">
sse-swap="stop_loading" hx-swap="innerHTML"> <div class="flex items-center gap-3 flex-1 min-w-0">
<span class="loading loading-spinner loading-xl"></span> <div class="size-10 flex items-center justify-center border-2 border-neutral bg-transparent"
</div> sse-swap="stop_loading" hx-swap="innerHTML">
<div> <span class="loading loading-spinner loading-md"></span>
<div class="flex gap-1"> </div>
<div sse-swap="status" hx-swap="innerHTML"> <div class="space-y-1">
Created <div class="text-sm font-semibold flex gap-2 items-center">
</div> <span sse-swap="status" hx-swap="innerHTML">Created</span>
<div hx-get="/content/recent" hx-target="#latest_content_section" hx-swap="outerHTML" <div hx-get="/content/recent" hx-target="#latest_content_section" hx-swap="outerHTML"
hx-trigger="sse:update_latest_content"></div> hx-trigger="sse:update_latest_content"></div>
</div>
<div class="text-xs font-semibold opacity-60">
{{task.created_at|datetimeformat(format="short", tz=user.timezone)}}
</div>
</div> </div>
<div class="text-xs font-semibold opacity-60">
{{task.created_at|datetimeformat(format="short", tz=user.timezone)}} </div>
</div> </div>
<p class="list-col-wrap text-xs [&:before]:content-['Content:_'] [&:before]:opacity-60">
{% if task.content.Url %} <div class="sm:flex-1 sm:text-right">
{{task.content.Url.url}} <p class="text-xs opacity-80 leading-snug break-words">
{% elif task.content.File %} {% if task.content.Url %}
{{task.content.File.file_info.file_name}} {{task.content.Url.url}}
{% else %} {% elif task.content.File %}
{{task.content.Text.text}} {{task.content.File.file_info.file_name}}
{% endif %} {% else %}
</p> {{task.content.Text.text}}
<button hx-delete="/jobs/{{task.id}}" hx-target="#active_jobs_section" hx-swap="outerHTML" {% endif %}
class="btn btn-square btn-ghost btn-sm"> </p>
{% include "icons/delete_icon.html" %} </div>
</button>
<div class="flex items-center justify-end gap-2">
<button hx-delete="/jobs/{{task.id}}" hx-target="#active_jobs_section" hx-swap="outerHTML"
class="nb-btn btn-square btn-sm" aria-label="Cancel task">
{% include "icons/delete_icon.html" %}
</button>
</div>
</li> </li>
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,25 @@
<section class="mb-4 sm:mt-4">
<h2 class="text-2xl font-extrabold tracking-tight mb-3">Overview</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="nb-stat">
<div class="text-xs opacity-70">Total Documents</div>
<div class="text-3xl font-extrabold">{{ stats.total_documents }}</div>
<div class="text-xs opacity-60">+{{ stats.new_documents_week }} this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Text Chunks</div>
<div class="text-3xl font-extrabold">{{ stats.total_text_chunks }}</div>
<div class="text-xs opacity-60">+{{ stats.new_text_chunks_week }} this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Knowledge Entities</div>
<div class="text-3xl font-extrabold">{{ stats.total_entities }}</div>
<div class="text-xs opacity-60">+{{ stats.new_entities_week }} this week</div>
</div>
<div class="nb-stat">
<div class="text-xs opacity-70">Conversations</div>
<div class="text-3xl font-extrabold">{{ stats.total_conversations }}</div>
<div class="text-xs opacity-60">+{{ stats.new_conversations_week }} this week</div>
</div>
</div>
</section>

View File

@@ -4,39 +4,37 @@ hx-post="/ingress-form"
enctype="multipart/form-data" enctype="multipart/form-data"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold">Add new content</h3> <h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
<div class="form-control"> <div class="flex flex-col gap-3">
<label class="floating-label"> <label class="w-full">
<span>Content</span> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
<textarea name="content" class="textarea input-bordered w-full" <textarea name="content" class="nb-input w-full min-h-28"
placeholder="Enter the content you want to ingest, it can be an URL or a text snippet">{{ content }}</textarea> placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
</label> </label>
</div>
<div class="form-control"> <label class="w-full">
<label class="floating-label"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
<span>Context</span> <textarea name="context" class="nb-input w-full min-h-24"
<textarea name="context" class="textarea w-full" placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
placeholder="Enter context for the AI here, help it understand what its seeing or how it should relate to the database">{{
context }}</textarea>
</label> </label>
</div>
<div class="form-control"> <label class="w-full">
<label class="floating-label"> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
<span>Category</span> <input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
<input type="text" name="category" class="input input-bordered validator w-full" value="{{ category }}"
list="category-list" required />
<datalist id="category-list"> <datalist id="category-list">
{% for category in user_categories %} {% for category in user_categories %}
<option value="{{ category }}" /> <option value="{{ category }}" />
{% endfor %} {% endfor %}
</datalist> </datalist>
<div class="validator-hint hidden">Category is required</div> <div class="validator-hint hidden text-xs opacity-70 mt-1">Category is required</div>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Files</div>
<input type="file" name="files" multiple class="file-input w-full rounded-none border-2 border-neutral" />
</label> </label>
</div> </div>
<div class="form-control">
<label class="label label-text">Files</label>
<input type="file" name="files" multiple class="file-input file-input-bordered w-full" />
</div>
<div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div> <div id="error-message" class="text-error text-center {% if not error %}hidden{% endif %}">{{ error }}</div>
<script> <script>
(function () { (function () {
@@ -54,7 +52,7 @@ enctype="multipart/form-data"
</script> </script>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">
Save Changes Add Content
</button> </button>
{% endblock %} {% endblock %}

View File

@@ -3,22 +3,22 @@
{% block title %}Minne - Knowledge{% endblock %} {% block title %}Minne - Knowledge{% endblock %}
{% block main %} {% block main %}
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 "> <div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6">
<div class="container overflow-y-auto"> <div class="container">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4"> <div class="nb-panel p-3 mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center">
<h2 class="text-2xl font-bold">Knowledge Entities</h2> <h2 class="text-xl font-extrabold tracking-tight">Knowledge Entities</h2>
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML" <form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
class="flex items-center gap-4 mt-2 sm:mt-0"> class="flex items-center gap-2 mt-2 sm:mt-0">
<div class="form-control"> <div>
<select name="entity_type" class="select select-bordered"> <select name="entity_type" class="nb-select">
<option value="">All Types</option> <option value="">All Types</option>
{% for type in entity_types %} {% for type in entity_types %}
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option> <option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="form-control"> <div>
<select name="content_category" class="select select-bordered"> <select name="content_category" class="nb-select">
<option value="">All Categories</option> <option value="">All Categories</option>
{% for category in content_categories %} {% for category in content_categories %}
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category <option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
@@ -26,12 +26,12 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button> <button type="submit" class="nb-btn btn-sm">Filter</button>
</form> </form>
</div> </div>
<h2 class="text-2xl font-bold mb-2 mt-10">Graph</h2> <h2 class="text-2xl font-bold mb-2 mt-10">Graph</h2>
<div class="rounded-box overflow-clip mt-4 shadow p-2 mb-10"> <div class="nb-card mt-4 p-2 mb-30">
<div id="knowledge-graph" class="w-full" style="height: 640px;" <div id="knowledge-graph" class="w-full" style="height: 640px;"
data-entity-type="{{ selected_entity_type | default(value='') }}" data-entity-type="{{ selected_entity_type | default(value='') }}"
data-content-category="{{ selected_content_category | default(value='') }}"> data-content-category="{{ selected_content_category | default(value='') }}">

View File

@@ -7,18 +7,18 @@ hx-swap="outerHTML"
{% endblock %} {% endblock %}
{% block modal_content %} {% block modal_content %}
<h3 class="text-lg font-bold">Edit Entity</h3> <h3 class="text-xl font-extrabold tracking-tight">Edit Entity</h3>
<div class="form-control"> <div class="form-control">
<label class="floating-label"> <label class="w-full">
<span class="label-text">Name</span> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Name</div>
<input type="text" name="name" value="{{ entity.name }}" class="input input-bordered w-full"> <input type="text" name="name" value="{{ entity.name }}" class="nb-input w-full">
</label> </label>
</div> </div>
<div class="form-control relative" style="margin-top: -1.5rem;"> <div class="form-control relative" style="margin-top: -1.5rem;">
<div class="absolute !left-3 !top-2.5 z-50 p-0.5 bg-white text-xs text-light">Type</div> <div class="absolute !left-3 !top-2.5 z-50 p-0.5 bg-base-100 text-xs">Type</div>
<select name="entity_type" class="select w-full"> <select name="entity_type" class="nb-select w-full">
<option disabled>You must select a type</option> <option disabled>You must select a type</option>
{% for et in entity_types %} {% for et in entity_types %}
<option value="{{ et }}" {% if entity.entity_type==et %}selected{% endif %}>{{ et }}</option> <option value="{{ et }}" {% if entity.entity_type==et %}selected{% endif %}>{{ et }}</option>
@@ -29,15 +29,13 @@ hx-swap="outerHTML"
<input type="text" name="id" value="{{ entity.id }}" class="hidden"> <input type="text" name="id" value="{{ entity.id }}" class="hidden">
<div class="form-control"> <div class="form-control">
<label class="floating-label"> <label class="w-full">
<span class="label-text">Description</span> <div class="text-xs uppercase tracking-wide opacity-70 mb-1">Description</div>
<textarea name="description" class="w-full textarea textarea-bordered h-32">{{ entity.description }}</textarea> <textarea name="description" class="nb-input w-full h-32">{{ entity.description }}</textarea>
</label> </label>
</div> </div>
{% endblock %} {% endblock %}
{% block primary_actions %} {% block primary_actions %}
<button type="submit" class="btn btn-primary"> <button type="submit" class="nb-btn nb-cta">Save Changes</button>
Save Changes {% endblock %}
</button>
{% endblock %}

View File

@@ -1,4 +1,4 @@
<div class="grid md:grid-cols-2 2xl:grid-cols-3 gap-4" id="entity-list"> <div class="grid md:grid-cols-2 2xl:grid-cols-3 gap-4 mt-6" id="entity-list">
{% for entity in entities %} {% for entity in entities %}
<div class="card min-w-72 bg-base-100 shadow"> <div class="card min-w-72 bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
@@ -22,4 +22,4 @@
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -1,12 +1,11 @@
<div id="relationship_table_section" <div id="relationship_table_section" class="overflow-x-auto nb-card mb-10">
class="overflow-x-auto shadow rounded-box border border-base-content/5 bg-base-100 mb-10"> <table class="nb-table">
<table class="table">
<thead> <thead>
<tr> <tr>
<th>Origin</th> <th class="text-left">Origin</th>
<th>Target</th> <th class="text-left">Target</th>
<th>Type</th> <th class="text-left">Type</th>
<th>Actions</th> <th class="text-left">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -31,9 +30,9 @@
{{ relationship.out }} {{ relationship.out }}
{% endfor %} {% endfor %}
</td> </td>
<td>{{ relationship.metadata.relationship_type }}</td> <td class="uppercase tracking-wide text-xs">{{ relationship.metadata.relationship_type }}</td>
<td> <td>
<button class="btn btn-sm btn-outline" hx-delete="/knowledge-relationship/{{ relationship.id }}" <button class="nb-btn btn-xs" hx-delete="/knowledge-relationship/{{ relationship.id }}"
hx-target="#relationship_table_section" hx-swap="outerHTML"> hx-target="#relationship_table_section" hx-swap="outerHTML">
{% include "icons/delete_icon.html" %} {% include "icons/delete_icon.html" %}
</button> </button>
@@ -43,7 +42,7 @@
<!-- New linking row --> <!-- New linking row -->
<tr id="new_relationship"> <tr id="new_relationship">
<td> <td>
<select name="in_" class="select select-bordered w-full new_relationship_input"> <select name="in_" class="nb-select w-full new_relationship_input">
<option disabled selected>Select Origin</option> <option disabled selected>Select Origin</option>
{% for entity in entities %} {% for entity in entities %}
<option value="{{ entity.id }}"> <option value="{{ entity.id }}">
@@ -53,7 +52,7 @@
</select> </select>
</td> </td>
<td> <td>
<select name="out" class="select select-bordered w-full new_relationship_input"> <select name="out" class="nb-select w-full new_relationship_input">
<option disabled selected>Select Target</option> <option disabled selected>Select Target</option>
{% for entity in entities %} {% for entity in entities %}
<option value="{{ entity.id }}">{{ entity.name }}</option> <option value="{{ entity.id }}">{{ entity.name }}</option>
@@ -62,12 +61,11 @@
</td> </td>
<td> <td>
<input id="relationship_type_input" name="relationship_type" type="text" placeholder="RelatedTo" <input id="relationship_type_input" name="relationship_type" type="text" placeholder="RelatedTo"
class="input input-bordered w-full new_relationship_input" /> class="nb-input w-full new_relationship_input" />
</td> </td>
<td> <td>
<button id="save_relationship_button" type="button" class="btn btn-primary btn-sm" <button id="save_relationship_button" type="button" class="nb-btn btn-sm" hx-post="/knowledge-relationship"
hx-post="/knowledge-relationship" hx-target="#relationship_table_section" hx-swap="outerHTML" hx-target="#relationship_table_section" hx-swap="outerHTML" hx-include=".new_relationship_input">
hx-include=".new_relationship_input">
Save Save
</button> </button>
</td> </td>

View File

@@ -1,21 +1,18 @@
<dialog id="body_modal" class="modal"> <dialog id="body_modal" class="modal">
<div class="modal-box {% block modal_class %}{% endblock %} "> <div class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
<form id="modal_form" {% block form_attributes %}{% endblock %}> <form id="modal_form" {% block form_attributes %}{% endblock %}>
<div class="flex flex-col flex-1 space-y-4"> <div class="flex flex-col flex-1 gap-4">
{% block modal_content %} <!-- Form fields go here in child templates --> {% block modal_content %}{% endblock %}
{% endblock %}
</div> </div>
<div class="modal-action"> <div class="u-hairline mt-4 pt-3 flex justify-end gap-2">
<!-- Close button (always visible) --> <!-- Close button (always visible) -->
<button type="button" class="btn" onclick="document.getElementById('body_modal').close()"> <button type="button" class="nb-btn" onclick="document.getElementById('body_modal').close()">
Close Close
</button> </button>
<!-- Primary actions block --> <!-- Primary actions block -->
{% block primary_actions %} {% block primary_actions %}{% endblock %}
<!-- Submit/Save buttons go here in child templates -->
{% endblock %}
</div> </div>
</form> </form>
</div> </div>
@@ -38,4 +35,4 @@
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button>close</button>
</form> </form>
</dialog> </dialog>

View File

@@ -1,4 +1,4 @@
<nav class="bg-base-200 sticky top-0 z-10"> <nav class="sticky top-0 z-10 nb-panel nb-panel-canvas border-t-0">
<div class="container mx-auto navbar"> <div class="container mx-auto navbar">
<div class="mr-2 flex-1"> <div class="mr-2 flex-1">
{% include "searchbar.html" %} {% include "searchbar.html" %}
@@ -12,4 +12,4 @@
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -5,7 +5,13 @@
{% block main %} {% block main %}
<div class="flex justify-center grow mt-2 sm:mt-4"> <div class="flex justify-center grow mt-2 sm:mt-4">
<div class="container"> <div class="container">
<section class="mb-4">
<div class="nb-panel p-3 flex items-center justify-between">
<h1 class="text-xl font-extrabold tracking-tight">Search</h1>
<div class="text-xs opacity-70">Find documents, entities, and snippets</div>
</div>
</section>
{% include "search/response.html" %} {% include "search/response.html" %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,8 @@
{% if search_result is defined and search_result %} {% if search_result is defined and search_result %}
<ul class="list shadow"> <ul class="nb-card p-0">
{% for result in search_result %} {% for result in search_result %}
<li class="list-row hover:bg-base-200/50 p-4"> <li class="p-4 u-hairline hover:bg-base-200/40 flex gap-3">
<div class="w-10 h-10 flex-shrink-0 mr-4 self-start mt-1"> <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 result.url_info and result.url_info.url %} {% if result.url_info and result.url_info.url %}
<div class="tooltip tooltip-right" data-tip="Web Link"> <div class="tooltip tooltip-right" data-tip="Web Link">
{% include "icons/link_icon.html" %} {% include "icons/link_icon.html" %}
@@ -17,10 +17,10 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="flex-grow min-w-0">
<h3 class="text-lg font-semibold mb-1"> <div class="flex-1 min-w-0">
<a hx-get="/content/{{ result.id }}/read" hx-target="#modal" hx-swap="innerHTML" <h3 class="text-lg font-extrabold mb-1 leading-snug">
class="link link-hover link-primary"> <a hx-get="/content/{{ result.id }}/read" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
{% set title_text = result.highlighted_url_title {% set title_text = result.highlighted_url_title
| default(result.url_info.title if result.url_info else none, true) | default(result.url_info.title if result.url_info else none, true)
| default(result.highlighted_file_name, true) | default(result.highlighted_file_name, true)
@@ -30,8 +30,7 @@
</a> </a>
</h3> </h3>
<div class="markdown-content prose prose-sm text-sm text-base-content/80 mb-3 overflow-hidden line-clamp-6" <div class="markdown-content prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6" data-content="{{result.highlighted_text | escape}}">
data-content="{{result.highlighted_text | escape}}">
{% if result.highlighted_text %} {% if result.highlighted_text %}
{{ result.highlighted_text | escape }} {{ result.highlighted_text | escape }}
{% elif result.text %} {% elif result.text %}
@@ -41,43 +40,46 @@
{% endif %} {% endif %}
</div> </div>
<div class="text-xs text-base-content/70 flex flex-wrap gap-x-4 gap-y-1 items-center"> <div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
<span class="inline-flex items-center"><strong class="font-medium mr-1">Category:</strong> <span class="inline-flex items-center">
<span class="badge badge-soft badge-secondary badge-sm">{{ result.highlighted_category | <span class="uppercase tracking-wide opacity-60 mr-2">Category</span>
default(result.category, true) | <span class="nb-badge">{{ result.highlighted_category | default(result.category, true) | safe }}</span>
safe }}</span>
</span> </span>
{% if result.highlighted_context or result.context %} {% if result.highlighted_context or result.context %}
<span class="inline-flex items-center"><strong class="font-medium mr-1">Context:</strong> <span class="inline-flex items-center min-w-0">
<span class="badge badge-sm badge-outline">{{ result.highlighted_context | default(result.context, true) | <span class="uppercase tracking-wide opacity-60 mr-2">Context</span>
safe }}</span> <span class="nb-badge">{{ result.highlighted_context | default(result.context, true) | safe }}</span>
</span> </span>
{% endif %} {% endif %}
{% if result.url_info and result.url_info.url %} {% if result.url_info and result.url_info.url %}
<span class="inline-flex items-center min-w-0"><strong class="font-medium mr-1">Source:</strong> <span class="inline-flex items-center min-w-0">
<a href="{{ result.url_info.url }}" target="_blank" class="link link-hover link-xs truncate" <span class="uppercase tracking-wide opacity-60 mr-2">Source</span>
title="{{ result.url_info.url }}"> <a href="{{ result.url_info.url }}" target="_blank" class="nb-link truncate" title="{{ result.url_info.url }}">
{{ result.highlighted_url | default(result.url_info.url ) | safe }} {{ result.highlighted_url | default(result.url_info.url ) | safe }}
</a> </a>
</span> </span>
{% endif %} {% endif %}
<span class="badge badge-ghost badge-sm">Score: {{ result.score }}</span>
<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>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% elif query_param is defined and query_param | trim != "" %} {% elif query_param is defined and query_param | trim != "" %}
<div class="p-4 text-center text-base-content/70"> <div class="nb-panel p-5 text-center">
<p class="text-xl font-semibold mb-2">No results found for "<strong>{{ query_param | escape }}</strong>".</p> <p class="text-xl font-extrabold mb-2">No results for “{{ query_param | escape }}.</p>
<p class="text-sm">Try using different keywords or checking for typos.</p> <p class="text-sm opacity-70">Try different keywords or check for typos.</p>
</div> </div>
{% else %} {% else %}
<div class="p-4 text-center text-base-content/70"> <div class="nb-panel p-5 text-center">
<p class="text-lg font-medium">Enter a term above to search your knowledge base.</p> <p class="text-lg font-semibold">Enter a term above to search your knowledge base.</p>
<p class="text-sm">Results will appear here.</p> <p class="text-sm opacity-70">Results will appear here.</p>
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,8 +1,14 @@
<div class="flex items-center gap-2 min-w-[90px]"> <div class="flex items-center gap-2 min-w-[90px] w-full">
<form class="w-full" hx-boost="true" method="get" action="/search" <form class="w-full relative" hx-boost="true" method="get" action="/search"
hx-trigger="keyup changed delay:500ms from:#search-input, search from:#search-input" hx-push-url="true"> hx-trigger="keyup changed delay:500ms from:#search-input, search from:#search-input" hx-push-url="true">
<input id="search-input" type="search" placeholder="Search for anything..." <input id="search-input" type="search" aria-label="Search" class=" nb-input w-full pl-9 ml-2" name="query"
class="input input-sm input-bordered input-primary w-full" name="query" autocomplete="off" autocomplete="off" value="{{ query_param | default('', true) }}" />
value="{{ query_param | default('', true) }}" /> <button type="submit"
class="absolute right-1 top-1/2 -translate-y-1/2 nb-btn btn-xs px-3 h-7 bg-base-100 hover:bg-base-200">
Search
</button>
<span class="hidden md:inline absolute right-24 top-1/2 -translate-y-1/2 text-xs opacity-60">
press <kbd class="kbd kbd-xs">Enter</kbd>
</span>
</form> </form>
</div> </div>

View File

@@ -15,11 +15,12 @@
<div class="drawer-side z-20"> <div class="drawer-side z-20">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-0 w-72 h-full bg-base-200 text-base-content flex flex-col"> <ul class="menu p-0 w-72 h-full nb-canvas text-base-content flex flex-col border-r-2 border-neutral">
<!-- <a class="px-2 mt-4 text-center text-2xl text-primary font-bold" href="/" hx-boost="true">Minne</a> --> <!-- <a class="px-4 py-4 text-2xl font-extrabold tracking-tight text-primary border-b-2 border-neutral bg-base-100 nb-shadow" -->
<!-- href="/" hx-boost="true">Minne</a> -->
<!-- === TOP FIXED SECTION === --> <!-- === TOP FIXED SECTION === -->
<div class="px-2 mt-14"> <div class="px-2 mt-4 space-y-3">
{% for url, name, label in [ {% for url, name, label in [
("/", "home", "Dashboard"), ("/", "home", "Dashboard"),
("/knowledge", "book", "Knowledge"), ("/knowledge", "book", "Knowledge"),
@@ -28,22 +29,22 @@
("/search", "search", "Search") ("/search", "search", "Search")
] %} ] %}
<li> <li>
<a hx-boost="true" href="{{ url }}" class="flex items-center gap-3"> <a hx-boost="true" href="{{ url }}" class="nb-btn w-full justify-start gap-3 bg-base-100 hover:bg-base-200">
{{ icon(name) }} {{ icon(name) }}
<span>{{ label }}</span> <span class="uppercase tracking-wide">{{ label }}</span>
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
<li> <li>
<button class="btn btn-primary btn-outline w-full flex items-center gap-3 justify-start mt-2" <button class="nb-btn nb-cta w-full flex items-center gap-3 justify-start mt-2" hx-get="/ingress-form"
hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
Content</button> Content</button>
</li> </li>
<div class="divider "></div> <div class="u-hairline mt-4"></div>
</div> </div>
<!-- === MIDDLE SCROLLABLE SECTION === --> <!-- === MIDDLE SCROLLABLE SECTION === -->
<span class="menu-title pb-4 ">Recent Chats</span> <span class="px-4 py-2 font-semibold tracking-wide">Recent Chats</span>
<div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar"> <div class="flex-1 overflow-y-auto space-y-1 custom-scrollbar">
{% if conversation_archive is defined and conversation_archive %} {% if conversation_archive is defined and conversation_archive %}
{% for conversation in conversation_archive %} {% for conversation in conversation_archive %}
@@ -51,12 +52,12 @@
{% if edit_conversation_id == conversation.id %} {% if edit_conversation_id == conversation.id %}
<!-- Edit mode --> <!-- Edit mode -->
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML" <form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
class="flex items-center gap-1 px-2 py-2"> class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
<input type="text" name="title" value="{{ conversation.title }}" class="input input-sm flex-grow" /> <input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
<div class="flex gap-0.5"> <div class="flex gap-0.5 absolute right-2">
<button type="submit" class="btn btn-ghost btn-xs">{% include "icons/check_icon.html" %}</button> <button type="submit" class="btn btn-ghost btn-xs !p-0">{% include "icons/check_icon.html" %}</button>
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML" <button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML"
class="btn btn-ghost btn-xs"> class="btn btn-ghost btn-xs !p-0">
{% include "icons/x_icon.html" %} {% include "icons/x_icon.html" %}
</button> </button>
</div> </div>
@@ -86,29 +87,30 @@
</div> </div>
<!-- === BOTTOM FIXED SECTION === --> <!-- === BOTTOM FIXED SECTION === -->
<div class="px-2 pb-4"> <div class="px-2 pb-4 space-y-3">
<div class="divider "></div>
<li> <li>
<a hx-boost="true" href="/account" class="flex btn btn-ghost justify-start items-center gap-3"> <a hx-boost="true" href="/account"
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
{% include "icons/user_icon.html" %} {% include "icons/user_icon.html" %}
<span>Account</span> <span class="uppercase tracking-wide">Account</span>
</a> </a>
</li> </li>
{% if user.admin %} {% if user.admin %}
<li> <li>
<a hx-boost="true" href="/admin" class="flex btn btn-ghost justify-start items-center gap-3"> <a hx-boost="true" href="/admin"
class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200">
{% include "icons/wrench_screwdriver_icon.html" %} {% include "icons/wrench_screwdriver_icon.html" %}
<span>Admin</span> <span class="uppercase tracking-wide">Admin</span>
</a> </a>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a hx-boost="true" href="/signout" <a hx-boost="true" href="/signout"
class="btn btn-error btn-outline w-full flex items-center gap-3 justify-start !mt-2"> class="nb-btn w-full justify-start items-center gap-3 bg-base-100 hover:bg-base-200 border-error text-error">
{% include "icons/logout_icon.html" %} {% include "icons/logout_icon.html" %}
<span>Logout</span> <span class="uppercase tracking-wide">Logout</span>
</a> </a>
</li> </li>
</div> </div>
</ul> </ul>
</div> </div>

View File

@@ -1,10 +1,7 @@
use async_openai::types::{CreateTranscriptionRequestArgs, AudioResponseFormat}; use async_openai::types::{AudioResponseFormat, CreateTranscriptionRequestArgs};
use common::{ use common::{
error::AppError, error::AppError,
storage::{ storage::{db::SurrealDbClient, types::system_settings::SystemSettings},
db::SurrealDbClient,
types::system_settings::SystemSettings,
},
}; };
/// Transcribes an audio file using the configured OpenAI Whisper model. /// Transcribes an audio file using the configured OpenAI Whisper model.
@@ -29,4 +26,3 @@ pub async fn transcribe_audio_file(
.map_err(|e| AppError::Processing(format!("Audio transcription failed: {}", e)))?; .map_err(|e| AppError::Processing(format!("Audio transcription failed: {}", e)))?;
Ok(response.text) Ok(response.text)
} }