mirror of
https://github.com/perstarkse/minne.git
synced 2026-05-10 01:43:56 +02:00
design: neobrutalist_theme into main
This commit is contained in:
112
Cargo.lock
generated
112
Cargo.lock
generated
@@ -116,6 +116,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -298,6 +313,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "async-convert"
|
||||
version = "1.0.0"
|
||||
@@ -886,6 +914,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "bumpalo"
|
||||
version = "3.17.0"
|
||||
@@ -950,6 +999,8 @@ version = "1.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -1259,6 +1310,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -2383,6 +2454,7 @@ dependencies = [
|
||||
"axum_session_auth",
|
||||
"axum_session_surreal",
|
||||
"axum_typed_multipart",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"common",
|
||||
"composite-retrieval",
|
||||
@@ -2897,6 +2969,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
@@ -5838,8 +5920,10 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.9.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -6926,3 +7010,31 @@ dependencies = [
|
||||
"quote",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -45,7 +45,7 @@ text-splitter = "0.18.1"
|
||||
thiserror = "1.0.63"
|
||||
tokio-util = { version = "0.7.15", features = ["io"] }
|
||||
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"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod categories;
|
||||
pub mod ingress;
|
||||
pub mod readiness;
|
||||
pub mod liveness;
|
||||
pub mod readiness;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod db;
|
||||
pub mod types;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result as AnyResult};
|
||||
use bytes::Bytes;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use futures::stream::BoxStream;
|
||||
use futures::{StreamExt, TryStreamExt};
|
||||
use object_store::local::LocalFileSystem;
|
||||
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 {
|
||||
StorageKind::Local => {
|
||||
if !prefix.exists() {
|
||||
tokio::fs::create_dir_all(prefix)
|
||||
.await
|
||||
.map_err(|e| object_store::Error::Generic {
|
||||
tokio::fs::create_dir_all(prefix).await.map_err(|e| {
|
||||
object_store::Error::Generic {
|
||||
store: "LocalFileSystem",
|
||||
source: e.into(),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let store = LocalFileSystem::new_with_prefix(prefix)?;
|
||||
Ok(Arc::new(store))
|
||||
@@ -46,7 +46,8 @@ pub fn resolve_base_dir(cfg: &AppConfig) -> PathBuf {
|
||||
if cfg.data_dir.starts_with('/') {
|
||||
PathBuf::from(&cfg.data_dir)
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.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
|
||||
/// 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 payload = object_store::PutPayload::from_bytes(data);
|
||||
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.
|
||||
/// 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 payload = object_store::PutPayload::from_bytes(data);
|
||||
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.
|
||||
///
|
||||
/// 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 r = store.get(&ObjPath::from(file_name)).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
|
||||
/// `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 r = store.get(&ObjPath::from(location)).await?;
|
||||
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?;
|
||||
// list everything and delete
|
||||
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
|
||||
if tokio::fs::try_exists(prefix).await.unwrap_or(false) {
|
||||
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<()> {
|
||||
let store = build_store_root(cfg).await?;
|
||||
let prefix_path = ObjPath::from(prefix);
|
||||
let locations = store.list(Some(&prefix_path)).map_ok(|m| m.location).boxed();
|
||||
store.delete_stream(locations).try_collect::<Vec<_>>().await?;
|
||||
let locations = store
|
||||
.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
|
||||
let base_dir = resolve_base_dir(cfg).join(prefix);
|
||||
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 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");
|
||||
assert_eq!(got.as_ref(), payload.as_ref());
|
||||
|
||||
// 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());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||
@@ -244,12 +274,9 @@ mod tests {
|
||||
|
||||
assert_eq!(combined, content);
|
||||
|
||||
delete_prefix_at(
|
||||
&split_object_path(&location).unwrap().0,
|
||||
&cfg,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
delete_prefix_at(&split_object_path(&location).unwrap().0, &cfg)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum_typed_multipart::FieldData;
|
||||
use mime_guess::from_path;
|
||||
use object_store::Error as ObjectStoreError;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
io::{BufReader, Read},
|
||||
@@ -7,7 +8,6 @@ use std::{
|
||||
};
|
||||
use tempfile::NamedTempFile;
|
||||
use thiserror::Error;
|
||||
use object_store::Error as ObjectStoreError;
|
||||
// futures imports no longer needed here after abstraction
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
@@ -90,8 +90,7 @@ impl FileInfo {
|
||||
updated_at: now,
|
||||
file_name,
|
||||
sha256,
|
||||
path: Self::persist_file(&uuid, file, &sanitized_file_name, user_id, config)
|
||||
.await?,
|
||||
path: Self::persist_file(&uuid, file, &sanitized_file_name, user_id, config).await?,
|
||||
mime_type: Self::guess_mime_type(Path::new(&sanitized_file_name)),
|
||||
user_id: user_id.to_string(),
|
||||
};
|
||||
@@ -248,7 +247,10 @@ impl FileInfo {
|
||||
store::delete_prefix_at(&parent_prefix, config)
|
||||
.await
|
||||
.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
|
||||
db_client.delete_item::<FileInfo>(id).await?;
|
||||
@@ -276,9 +278,9 @@ impl FileInfo {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::config::StorageKind;
|
||||
use axum::http::HeaderMap;
|
||||
use axum_typed_multipart::FieldMetadata;
|
||||
use crate::utils::config::StorageKind;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
@@ -657,9 +659,22 @@ mod tests {
|
||||
|
||||
// Create and persist a test file via FileInfo::new
|
||||
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 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
|
||||
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");
|
||||
|
||||
// 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
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -4,11 +4,17 @@ use axum_session_auth::Authentication;
|
||||
use surrealdb::{engine::any::Any, Surreal};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::text_chunk::TextChunk;
|
||||
use super::{
|
||||
conversation::Conversation, ingestion_task::IngestionTask, knowledge_entity::KnowledgeEntity,
|
||||
knowledge_relationship::KnowledgeRelationship, system_settings::SystemSettings,
|
||||
conversation::Conversation,
|
||||
ingestion_task::{IngestionTask, MAX_ATTEMPTS},
|
||||
knowledge_entity::KnowledgeEntity,
|
||||
knowledge_relationship::KnowledgeRelationship,
|
||||
system_settings::SystemSettings,
|
||||
text_content::TextContent,
|
||||
};
|
||||
use chrono::Duration;
|
||||
use futures::try_join;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
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 {
|
||||
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(
|
||||
email: String,
|
||||
password: String,
|
||||
@@ -444,17 +536,17 @@ impl User {
|
||||
"SELECT * FROM type::table($table)
|
||||
WHERE user_id = $user_id
|
||||
AND (
|
||||
status = 'Created'
|
||||
status.name = 'Created'
|
||||
OR (
|
||||
status.InProgress != NONE
|
||||
AND status.InProgress.attempts < $max_attempts
|
||||
status.name = 'InProgress'
|
||||
AND status.attempts < $max_attempts
|
||||
)
|
||||
)
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(("table", IngestionTask::table_name()))
|
||||
.bind(("user_id", user_id.to_owned()))
|
||||
.bind(("max_attempts", 3))
|
||||
.bind(("max_attempts", MAX_ATTEMPTS))
|
||||
.await?
|
||||
.take(0)?;
|
||||
|
||||
@@ -511,6 +603,9 @@ impl User {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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
|
||||
async fn setup_test_db() -> SurrealDbClient {
|
||||
@@ -596,6 +691,75 @@ mod tests {
|
||||
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]
|
||||
async fn test_find_by_email() {
|
||||
// Setup test database
|
||||
|
||||
@@ -30,6 +30,7 @@ tower-http = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
tower-serve-static = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
common = { path = "../common" }
|
||||
composite-retrieval = { path = "../composite-retrieval" }
|
||||
|
||||
@@ -1,29 +1,108 @@
|
||||
@import 'tailwindcss' source(none);
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source './templates/**/*.html';
|
||||
|
||||
@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";
|
||||
|
||||
@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 {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
@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 {
|
||||
@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 {
|
||||
@@ -37,6 +116,479 @@
|
||||
::file-selector-button {
|
||||
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 */
|
||||
@@ -58,4 +610,28 @@
|
||||
font-weight: 300 900;
|
||||
font-style: italic;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -73,29 +73,29 @@
|
||||
|
||||
function attachOverlay(container, { onSearch, onToggleLabels, onCenter }) {
|
||||
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
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search nodes…';
|
||||
input.className = 'input input-sm input-bordered';
|
||||
input.className = 'nb-input kg-search-input';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') onSearch && onSearch(input.value.trim());
|
||||
});
|
||||
|
||||
const searchBtn = document.createElement('button');
|
||||
searchBtn.className = 'btn btn-sm';
|
||||
searchBtn.className = 'nb-btn btn-xs nb-cta';
|
||||
searchBtn.textContent = 'Go';
|
||||
searchBtn.addEventListener('click', () => onSearch && onSearch(input.value.trim()));
|
||||
|
||||
const labelToggle = document.createElement('button');
|
||||
labelToggle.className = 'btn btn-sm';
|
||||
labelToggle.className = 'nb-btn btn-xs';
|
||||
labelToggle.textContent = 'Labels';
|
||||
labelToggle.addEventListener('click', () => onToggleLabels && onToggleLabels());
|
||||
|
||||
const centerBtn = document.createElement('button');
|
||||
centerBtn.className = 'btn btn-sm';
|
||||
centerBtn.className = 'nb-btn btn-xs';
|
||||
centerBtn.textContent = 'Center';
|
||||
centerBtn.addEventListener('click', () => onCenter && onCenter());
|
||||
|
||||
@@ -112,15 +112,15 @@
|
||||
|
||||
function attachLegends(container, typeColor, relColor) {
|
||||
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) {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'rounded-box bg-base-100/80 backdrop-blur shadow p-2';
|
||||
const h = document.createElement('div'); h.className = 'text-xs opacity-70 mb-1'; h.textContent = title; sec.appendChild(h);
|
||||
sec.className = 'nb-card kg-legend-card';
|
||||
const h = document.createElement('div'); h.className = 'kg-legend-heading'; h.textContent = title; sec.appendChild(h);
|
||||
items.forEach(([label, color]) => {
|
||||
const row = document.createElement('div'); row.className = 'flex items-center gap-2 text-xs';
|
||||
const sw = document.createElement('span'); sw.style.background = color; sw.style.width = '10px'; sw.style.height = '10px'; sw.style.borderRadius = '9999px';
|
||||
const row = document.createElement('div'); row.className = 'kg-legend-row';
|
||||
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 || '—';
|
||||
row.appendChild(sw); row.appendChild(t); sec.appendChild(row);
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,33 +6,31 @@
|
||||
return;
|
||||
}
|
||||
const alert = document.createElement('div');
|
||||
// Base classes for the alert
|
||||
alert.className = `alert alert-${type} mt-2 shadow-md flex flex-col text-start`;
|
||||
alert.className = `alert toast-alert alert-${type}`;
|
||||
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) {
|
||||
innerHTML += `<div class="font-bold text-lg">${title}</div>`; // Title element
|
||||
innerHTML += `<div>${description}</div>`; // Description element
|
||||
} else {
|
||||
// Structure without title
|
||||
innerHTML += `<span>${description}</span>`;
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'toast-alert-title';
|
||||
titleEl.textContent = title;
|
||||
alert.appendChild(titleEl);
|
||||
}
|
||||
|
||||
alert.innerHTML = innerHTML;
|
||||
const bodyEl = document.createElement(title ? 'div' : 'span');
|
||||
bodyEl.textContent = description;
|
||||
alert.appendChild(bodyEl);
|
||||
|
||||
container.appendChild(alert);
|
||||
|
||||
// Auto-remove after a delay
|
||||
setTimeout(() => {
|
||||
// Optional: Add fade-out effect
|
||||
alert.style.opacity = '0';
|
||||
alert.style.transition = 'opacity 0.5s ease-out';
|
||||
setTimeout(() => alert.remove(), 500); // Remove after fade
|
||||
}, 3000); // Start fade-out after 3 seconds
|
||||
setTimeout(() => alert.remove(), 500);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
document.body.addEventListener('toast', function (event) {
|
||||
console.log(event);
|
||||
// Extract data from the event detail, matching the Rust payload
|
||||
const detail = event.detail;
|
||||
if (detail && detail.description) {
|
||||
@@ -54,4 +52,3 @@
|
||||
if (container) container.innerHTML = '';
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "html-router",
|
||||
"version": "1.0.0",
|
||||
"main": "tailwind.config.js",
|
||||
"scripts": {
|
||||
"tailwind": "npx @tailwindcss/cli -i app.css -o assets/style.css -w -m"
|
||||
},
|
||||
@@ -14,4 +13,4 @@
|
||||
"daisyui": "^5.0.12",
|
||||
"tailwindcss": "^4.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ where
|
||||
.add_protected_routes(routes::content::router())
|
||||
.add_protected_routes(routes::knowledge::router())
|
||||
.add_protected_routes(routes::ingestion::router())
|
||||
.with_compression()
|
||||
.build()
|
||||
}
|
||||
|
||||
7
html-router/src/middlewares/compression.rs
Normal file
7
html-router/src/middlewares/compression.rs
Normal 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()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod analytics_middleware;
|
||||
pub mod auth_middleware;
|
||||
pub mod compression;
|
||||
pub mod response_middleware;
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
html_state::HtmlState,
|
||||
middlewares::{
|
||||
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>)>,
|
||||
custom_middleware: MiddleWareVecType<S>,
|
||||
public_assets_config: Option<AssetsConfig>,
|
||||
compression_enabled: bool,
|
||||
}
|
||||
|
||||
struct AssetsConfig {
|
||||
@@ -69,6 +70,7 @@ where
|
||||
nested_protected_routes: Vec::new(),
|
||||
custom_middleware: Vec::new(),
|
||||
public_assets_config: None,
|
||||
compression_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +117,12 @@ where
|
||||
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> {
|
||||
// Start with an empty router
|
||||
let mut public_router = Router::new();
|
||||
@@ -169,21 +177,26 @@ where
|
||||
}
|
||||
|
||||
// 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
|
||||
.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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,4 +406,4 @@ pub async fn patch_image_prompt(
|
||||
settings: new_settings,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
Form,
|
||||
};
|
||||
use axum_htmx::{HxBoosted, HxRequest};
|
||||
use axum_htmx::{HxBoosted, HxRequest, HxTarget};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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::{
|
||||
@@ -27,6 +28,12 @@ pub struct ContentPageData {
|
||||
conversation_archive: Vec<Conversation>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RecentTextContentData {
|
||||
pub user: User,
|
||||
pub text_contents: Vec<TextContent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FilterParams {
|
||||
category: Option<String>,
|
||||
@@ -102,12 +109,25 @@ pub async fn patch_text_content(
|
||||
State(state): State<HtmlState>,
|
||||
RequireUser(user): RequireUser,
|
||||
Path(id): Path<String>,
|
||||
HxTarget(target): HxTarget,
|
||||
Form(form): Form<PatchTextContentParams>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
User::get_and_validate_text_content(&id, &user.id, &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 categories = User::get_user_categories(&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> {
|
||||
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(
|
||||
"dashboard/recent_content.html",
|
||||
RecentTextContentData {
|
||||
|
||||
@@ -4,6 +4,7 @@ use axum::{
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::try_join;
|
||||
use serde::Serialize;
|
||||
use tokio::join;
|
||||
|
||||
@@ -14,6 +15,8 @@ use crate::{
|
||||
},
|
||||
AuthSessionType,
|
||||
};
|
||||
use common::storage::store;
|
||||
use common::storage::types::user::DashboardStats;
|
||||
use common::{
|
||||
error::AppError,
|
||||
storage::types::{
|
||||
@@ -22,7 +25,6 @@ use common::{
|
||||
text_chunk::TextChunk, text_content::TextContent, user::User,
|
||||
},
|
||||
};
|
||||
use common::storage::store;
|
||||
|
||||
use crate::html_state::HtmlState;
|
||||
|
||||
@@ -30,6 +32,7 @@ use crate::html_state::HtmlState;
|
||||
pub struct IndexPageData {
|
||||
user: Option<User>,
|
||||
text_contents: Vec<TextContent>,
|
||||
stats: DashboardStats,
|
||||
active_jobs: Vec<IngestionTask>,
|
||||
conversation_archive: Vec<Conversation>,
|
||||
}
|
||||
@@ -42,19 +45,21 @@ pub async fn index_handler(
|
||||
return Ok(TemplateResponse::redirect("/signin"));
|
||||
};
|
||||
|
||||
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
|
||||
|
||||
let text_contents = User::get_latest_text_contents(&user.id, &state.db).await?;
|
||||
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
let (text_contents, conversation_archive, stats, active_jobs) = try_join!(
|
||||
User::get_latest_text_contents(&user.id, &state.db),
|
||||
User::get_user_conversations(&user.id, &state.db),
|
||||
User::get_dashboard_stats(&user.id, &state.db),
|
||||
User::get_unfinished_ingestion_tasks(&user.id, &state.db)
|
||||
)?;
|
||||
|
||||
Ok(TemplateResponse::new_template(
|
||||
"dashboard/base.html",
|
||||
IndexPageData {
|
||||
user: Some(user),
|
||||
text_contents,
|
||||
active_jobs,
|
||||
stats,
|
||||
conversation_archive,
|
||||
active_jobs,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -153,9 +158,8 @@ pub async fn show_active_jobs(
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
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",
|
||||
"active_jobs_section",
|
||||
ActiveJobsData {
|
||||
user: user.clone(),
|
||||
active_jobs,
|
||||
|
||||
@@ -5,6 +5,7 @@ use axum::{
|
||||
response::IntoResponse,
|
||||
};
|
||||
use common::storage::types::{
|
||||
conversation::Conversation,
|
||||
text_content::{TextContent, TextContentSearchResult},
|
||||
user::User,
|
||||
};
|
||||
@@ -47,7 +48,9 @@ pub async fn search_result_handler(
|
||||
search_result: Vec<TextContentSearchResult>,
|
||||
query_param: String,
|
||||
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) =
|
||||
if let Some(actual_query) = params.query {
|
||||
@@ -72,6 +75,7 @@ pub async fn search_result_handler(
|
||||
search_result: search_results_for_template,
|
||||
query_param: final_query_param_for_template,
|
||||
user,
|
||||
conversation_archive,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,207 +1,156 @@
|
||||
{% extends 'body_base.html' %}
|
||||
|
||||
{% block title %}Minne - Account{% endblock %}
|
||||
{% block title %}Minne - Admin{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<main class="container flex-grow flex flex-col mx-auto mt-4 space-y-6">
|
||||
<h1 class="text-2xl font-bold mb-2">Admin Dashboard</h1>
|
||||
<div class="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
||||
<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">
|
||||
<div class="stat">
|
||||
<div class="stat-title font-bold">Page loads</div>
|
||||
<div class="stat-value text-secondary">{{analytics.page_loads}}</div>
|
||||
<div class="stat-desc">Amount of page loads</div>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="nb-stat">
|
||||
<div class="text-xs opacity-70">Page Loads</div>
|
||||
<div class="text-3xl font-extrabold">{{analytics.page_loads}}</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">
|
||||
<div class="stat-title font-bold">Unique visitors</div>
|
||||
<div class="stat-value text-primary">{{analytics.visitors}}</div>
|
||||
<div class="stat-desc">Amount of unique visitors</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>
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{% block system_prompt_section %}
|
||||
<div id="system_prompt_section" class="nb-panel p-4">
|
||||
<div class="text-sm font-semibold mb-3">System Prompts</div>
|
||||
<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"
|
||||
hx-swap="innerHTML">
|
||||
Edit Query Prompt
|
||||
</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>
|
||||
<button type="button" class="nb-btn btn-sm" hx-get="/edit-query-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Query Prompt</button>
|
||||
<button type="button" class="nb-btn btn-sm" hx-get="/edit-ingestion-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Ingestion Prompt</button>
|
||||
<button type="button" class="nb-btn btn-sm" hx-get="/edit-image-prompt" hx-target="#modal" hx-swap="innerHTML">Edit Image Prompt</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset p-4 shadow rounded-box">
|
||||
<legend class="fieldset-legend">Registration</legend>
|
||||
<label class="flex gap-4 text-center">
|
||||
{% block registration_status_input %}
|
||||
<form hx-patch="/toggle-registrations" hx-swap="outerHTML" hx-trigger="change">
|
||||
<input name="registration_open" type="checkbox" class="checkbox" {% if settings.registrations_enabled
|
||||
%}checked{% endif %} />
|
||||
<div class="nb-panel p-4">
|
||||
<div class="text-sm font-semibold mb-3">AI Models</div>
|
||||
{% block model_settings_form %}
|
||||
<form hx-patch="/update-model-settings" hx-swap="outerHTML" class="grid grid-cols-1 gap-4">
|
||||
<!-- Query Model -->
|
||||
<div>
|
||||
<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>
|
||||
{% 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>
|
||||
</main>
|
||||
{% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,17 +7,17 @@ hx-swap="outerHTML"
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% 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
|
||||
</button>
|
||||
|
||||
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
<span class="htmx-indicator hidden">
|
||||
<span class="loading loading-spinner loading-xs mr-2"></span>
|
||||
</span>
|
||||
Save Changes
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,17 +7,17 @@ hx-swap="outerHTML"
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% 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
|
||||
</button>
|
||||
|
||||
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
<span class="htmx-indicator hidden">
|
||||
<span class="loading loading-spinner loading-xs mr-2"></span>
|
||||
</span>
|
||||
Save Changes
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,17 +7,17 @@ hx-swap="outerHTML"
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% 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
|
||||
</button>
|
||||
|
||||
@@ -29,10 +29,10 @@ hx-swap="outerHTML"
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
<span class="htmx-indicator hidden">
|
||||
<span class="loading loading-spinner loading-xs mr-2"></span>
|
||||
</span>
|
||||
Save Changes
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,91 +3,86 @@
|
||||
{% block title %}Minne - Account{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<style>
|
||||
form.htmx-request {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
<main class="container flex-grow flex flex-col mx-auto mt-4 space-y-1">
|
||||
<h1 class="text-2xl font-bold mb-2">Account Settings</h1>
|
||||
<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="flex justify-center grow mt-2 sm:mt-4 pb-4">
|
||||
<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">Account Settings</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">API key</span>
|
||||
</label>
|
||||
{% block api_key_section %}
|
||||
{% if user.api_key %}
|
||||
<div class="relative">
|
||||
<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>
|
||||
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 space-y-2">
|
||||
<!-- Left column -->
|
||||
<div class="nb-panel p-4 space-y-2 flex flex-col">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||
<input type="email" name="email" value="{{ user.email }}" class="nb-input w-full" disabled />
|
||||
</label>
|
||||
|
||||
<script>
|
||||
function copy_api_key() {
|
||||
const input = document.getElementById('api_key_input');
|
||||
if (!input) return;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(input.value)
|
||||
.then(() => show_toast('API key copied!', 'success'))
|
||||
.catch(() => show_toast('Copy failed', 'error'));
|
||||
} else {
|
||||
show_toast('Copy not supported', 'info');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">API Key</div>
|
||||
{% block api_key_section %}
|
||||
{% if user.api_key %}
|
||||
<div class="relative">
|
||||
<input id="api_key_input" type="text" name="api_key" value="{{ user.api_key }}"
|
||||
class="nb-input w-full pr-14" disabled />
|
||||
<button type="button" id="copy_api_key_btn" onclick="copy_api_key()"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-2 nb-btn btn-sm" aria-label="Copy API key"
|
||||
title="Copy API key">
|
||||
{% include "icons/clipboard_icon.html" %}
|
||||
</button>
|
||||
</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">
|
||||
<label class="label">
|
||||
<span class="label-text">Timezone</span>
|
||||
</label>
|
||||
{% block timezone_section %}
|
||||
<select name="timezone" class="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 %}
|
||||
</div>
|
||||
<script>
|
||||
function copy_api_key() {
|
||||
const input = document.getElementById('api_key_input');
|
||||
if (!input) return;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(input.value)
|
||||
.then(() => show_toast('API key copied!', 'success'))
|
||||
.catch(() => show_toast('Copy failed', 'error'));
|
||||
} else {
|
||||
show_toast('Copy not supported', 'info');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-control mt-4 hidden">
|
||||
<button hx-post="/verify-email" class="btn btn-secondary w-full">
|
||||
Verify Email
|
||||
</button>
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Timezone</div>
|
||||
{% block timezone_section %}
|
||||
<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 class="form-control mt-4">
|
||||
{% block change_password_section %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<form hx-patch="/change-password" class="flex flex-col gap-1">
|
||||
<input name="old_password" class="input w-full" type="password" placeholder="Enter old password"></input>
|
||||
<input name="new_password" class="input w-full" type="password" placeholder="Enter new password"></input>
|
||||
<button class="btn btn-primary w-full">Change Password</button>
|
||||
</form>
|
||||
<form hx-patch="/change-password" class="flex flex-col gap-3">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Old Password</div>
|
||||
<input name="old_password" class="nb-input w-full" type="password" placeholder="Enter old password"></input>
|
||||
</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>
|
||||
|
||||
@@ -1,52 +1,43 @@
|
||||
<style>
|
||||
form.htmx-request {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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 class="container mx-auto px-4 sm:max-w-md flex-1 flex items-center justify-center">
|
||||
<div class="w-full nb-card p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="brand-mark text-3xl font-extrabold tracking-tight">MINNE</div>
|
||||
<span class="nb-badge">Sign In</span>
|
||||
</div>
|
||||
<div class="u-hairline mb-3"></div>
|
||||
|
||||
<div class="form-control mt-4">
|
||||
<label class="floating-label">
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" class="input validator w-full" required placeholder="Password"
|
||||
<form hx-post="/signin" hx-target="#login-result" class="flex flex-col gap-2">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
<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">
|
||||
Don’t have an account?
|
||||
<a href="/signup" hx-boost="true" class="nb-link">Sign up</a>
|
||||
</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>
|
||||
|
||||
@@ -3,56 +3,48 @@
|
||||
{% block title %}Minne - Sign up{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<style>
|
||||
form.htmx-request {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
<div class="min-h-[100dvh] flex items-center">
|
||||
<div class="container mx-auto px-4 sm:max-w-md">
|
||||
<div class="nb-card p-5">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<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">
|
||||
<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">Create your account</h2>
|
||||
<form hx-post="/signup" hx-target="#signup-result" class="flex flex-col gap-4">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Email</div>
|
||||
<input type="email" placeholder="Email" name="email" required class="nb-input w-full validator" />
|
||||
<div class="validator-hint hidden text-xs opacity-70 mt-1">Enter valid email address</div>
|
||||
</label>
|
||||
|
||||
<form hx-post="/signup" hx-target="#signup-result" class="">
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span>Email</span>
|
||||
<input type="email" placeholder="Email" name="email" required class="input input-md w-full validator" />
|
||||
<div class="validator-hint hidden">Enter valid email address</div>
|
||||
</label>
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Password</div>
|
||||
<input type="password" name="password" class="nb-input w-full validator" 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 text-xs opacity-70 mt-1">
|
||||
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 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>
|
||||
<script>
|
||||
@@ -60,4 +52,4 @@
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
document.getElementById("timezone").value = timezone;
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% 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">
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<!-- Page Content -->
|
||||
@@ -10,8 +10,9 @@
|
||||
<!-- Navbar -->
|
||||
{% include "navigation_bar.html" %}
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex flex-1 overflow-y-auto">
|
||||
<main class="flex flex-col flex-1 overflow-y-auto">
|
||||
{% block main %}{% endblock %}
|
||||
<div class="p32 min-h-[10px]"></div>
|
||||
</main>
|
||||
</div>
|
||||
<!-- Sidebar -->
|
||||
@@ -21,25 +22,5 @@
|
||||
</div> <!-- End Drawer -->
|
||||
<div id="modal"></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>
|
||||
{% endblock %}
|
||||
@@ -9,94 +9,40 @@
|
||||
{% block main %}
|
||||
<div class="flex grow relative justify-center mt-2 sm:mt-4">
|
||||
<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/new_message_form.html" %}
|
||||
</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>
|
||||
function scrollChatToBottom() {
|
||||
const chatContainer = document.getElementById('chat_container');
|
||||
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||
requestAnimationFrame(() => {
|
||||
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.body.addEventListener('htmx:afterSwap', scrollChatToBottom);
|
||||
document.body.addEventListener('htmx:afterSettle', scrollChatToBottom);
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
{% if message.role == "AI" %}
|
||||
<div class="chat chat-start">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{% include "chat/streaming_response.html" %}
|
||||
|
||||
<!-- OOB swap targeting the form element directly -->
|
||||
<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">
|
||||
<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"
|
||||
id="chat-input"></textarea>
|
||||
class="nb-input h-24 pr-8 pl-2 pt-2 pb-2 flex-grow resize-none" id="chat-input"></textarea>
|
||||
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1">
|
||||
{% include "icons/send_icon.html" %}
|
||||
</button>
|
||||
|
||||
@@ -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">
|
||||
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
|
||||
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2" id="chat-form">
|
||||
<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 focus:outline-none focus:bg-base-200"
|
||||
id="chat-input"></textarea>
|
||||
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-6">{% include
|
||||
"icons/send_icon.html" %}
|
||||
</button>
|
||||
</form>
|
||||
<div class="fixed bottom-0 left-0 right-0 lg:left-72 z-20">
|
||||
<div class="mx-auto max-w-3xl px-4 pb-3">
|
||||
<div class="nb-panel p-2">
|
||||
<form hx-post="{% if conversation %} /chat/{{conversation.id}} {% else %} /chat {% endif %}"
|
||||
hx-target="#chat_container" hx-swap="beforeend" class="relative flex gap-2 items-end" id="chat-form">
|
||||
<textarea autofocus required name="content" placeholder="Type your message…" rows="3"
|
||||
class="nb-input flex-grow min-h-24 pr-10 pl-2 pt-2 pb-2 resize-none" id="chat-input"></textarea>
|
||||
<button type="submit" class="nb-btn nb-cta h-10 px-3">{% include "icons/send_icon.html" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -23,4 +24,4 @@
|
||||
document.getElementById('chat-input').value = ''; // Clear the textarea
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="relative my-2">
|
||||
<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}}')">
|
||||
References
|
||||
REFERENCES
|
||||
{% include "icons/chevron_icon.html" %}
|
||||
</button>
|
||||
<div id="references-content-{{message.id}}" class="hidden max-w-full mt-1">
|
||||
@@ -10,7 +10,7 @@
|
||||
{% for reference in message.references %}
|
||||
<div class="reference-badge-container" data-reference="{{reference}}" data-message-id="{{message.id}}"
|
||||
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}}
|
||||
</span>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
function createTooltip() {
|
||||
const tooltip = document.createElement('div');
|
||||
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>';
|
||||
document.body.appendChild(tooltip);
|
||||
return tooltip;
|
||||
@@ -135,15 +135,3 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#references-toggle- {
|
||||
{
|
||||
message.id
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
e.preventDefault();
|
||||
window.markdownBuffer[msgId] = (window.markdownBuffer[msgId] || '') + (e.detail.data || '');
|
||||
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 () {
|
||||
const msgId = '{{ user_message.id }}';
|
||||
@@ -33,7 +33,7 @@
|
||||
if (el && window.markdownBuffer[msgId]) {
|
||||
el.innerHTML = marked.parse(window.markdownBuffer[msgId].replace(/\\n/g, '\n'));
|
||||
delete window.markdownBuffer[msgId];
|
||||
if (typeof scrollChatToBottom === "function") scrollChatToBottom();
|
||||
if (typeof window.scrollChatToBottom === "function") window.scrollChatToBottom();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
{% block title %}Minne - Content{% endblock %}
|
||||
|
||||
{% 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="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold">Content</h2>
|
||||
<div class="nb-panel p-3 mb-4 flex items-center justify-between">
|
||||
<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"
|
||||
class="flex items-center gap-2 mt-2 sm:mt-0">
|
||||
<div class="form-control">
|
||||
<select name="category" class="select select-bordered">
|
||||
<div>
|
||||
<select name="category" class="nb-select">
|
||||
<option value="">All Categories</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
|
||||
@@ -18,13 +18,11 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="text_content_cards">
|
||||
{% include "content/content_list.html" %}
|
||||
</div>
|
||||
{% include "content/content_list.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
<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">
|
||||
{% if text_content.url_info %}
|
||||
<figure>
|
||||
<img src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<img class="w-full h-auto" src="/file/{{text_content.url_info.image_id}}" alt="website screenshot" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
|
||||
<figure>
|
||||
<img src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
||||
<figure class="-mx-4 -mt-4 border-b-2 border-neutral bg-base-200">
|
||||
<img class="w-full h-auto" src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
||||
</figure>
|
||||
{% endif %}
|
||||
<div class="card-body max-w-[95vw]">
|
||||
<h2 class="card-title truncate">
|
||||
<div class="space-y-3 break-words">
|
||||
<h2 class="text-lg font-extrabold tracking-tight truncate">
|
||||
{% if text_content.url_info %}
|
||||
{{text_content.url_info.title}}
|
||||
{% elif text_content.file_info %}
|
||||
@@ -22,37 +22,36 @@
|
||||
{{text_content.text}}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs opacity-60">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-xs opacity-60 shrink-0">
|
||||
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||
</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()">
|
||||
{% 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">
|
||||
{% include "icons/link_icon.html" %}
|
||||
</a>
|
||||
</button>
|
||||
<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" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<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" %}
|
||||
</button>
|
||||
<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" %}
|
||||
</button>
|
||||
<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" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2">
|
||||
<p class="text-sm leading-relaxed">
|
||||
{{ text_content.instructions }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -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-target="#main_section"
|
||||
hx-swap="outerHTML"
|
||||
class="flex flex-col flex-1 h-full"
|
||||
class="flex flex-col flex-1 h-full min-h-0"
|
||||
{% endblock %}
|
||||
|
||||
{% block modal_content %}
|
||||
<h3 class="text-lg font-bold">Edit Content</h3>
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span class="label-text">Context</span>
|
||||
<input type="text" name="context" value="{{ text_content.context }}" class="w-full input input-bordered">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span class="label-text">Category</span>
|
||||
<input type="text" name="category" value="{{ text_content.category }}" class="w-full input input-bordered">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control flex-1 flex flex-col min-h-0">
|
||||
<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>
|
||||
<h3 class="text-xl font-extrabold tracking-tight">Edit Content</h3>
|
||||
<div class="flex flex-col gap-3 flex-1 min-h-0">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
|
||||
<input type="text" name="context" value="{{ text_content.context }}" class="nb-input w-full">
|
||||
</label>
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
|
||||
<input type="text" name="category" value="{{ text_content.category }}" class="nb-input w-full">
|
||||
</label>
|
||||
<label class="w-full flex-1 flex flex-col min-h-0">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Text</div>
|
||||
<textarea name="text" class="nb-input w-full flex-1 min-h-0 h-full resize-none overflow-y-auto">{{ text_content.text
|
||||
}}</textarea>
|
||||
</label>
|
||||
</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 %}
|
||||
|
||||
{% block primary_actions %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
{% endblock %}
|
||||
<button type="submit" class="nb-btn nb-cta">Save Changes</button>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
{% block modal_content %}
|
||||
{% 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 %}
|
||||
{% if text_content.file_info.mime_type == "image/png" or text_content.file_info.mime_type == "image/jpeg" %}
|
||||
<figure>
|
||||
<img src="/file/{{text_content.file_info.id}}" alt="{{text_content.file_info.file_name}}" />
|
||||
</figure>
|
||||
{% 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 }}
|
||||
</div>
|
||||
|
||||
@@ -39,4 +39,4 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block primary_actions %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,49 +1,63 @@
|
||||
{% block active_jobs_section %}
|
||||
<ul id="active_jobs_section" class="list">
|
||||
<div class="flex items-center gap-4">
|
||||
<li class="py-4 text-2xl font-bold tracking-wide">Active Tasks</li>
|
||||
<button class="cursor-pointer scale-75" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML">
|
||||
<section id="active_jobs_section" class="nb-panel p-4 space-y-4 mt-6 sm:mt-8">
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-xl font-extrabold tracking-tight">Active Tasks</h2>
|
||||
<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" %}
|
||||
</button>
|
||||
</div>
|
||||
{% for item in active_jobs %}
|
||||
<li class="list-row">
|
||||
<div class="bg-secondary rounded-box size-10 flex justify-center items-center text-secondary-content">
|
||||
{% if item.content.Url %}
|
||||
{% include "icons/link_icon.html" %}
|
||||
{% elif item.content.File %}
|
||||
{% include "icons/document_icon.html" %}
|
||||
{% else %}
|
||||
{% include "icons/bars_icon.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="[&:before]:content-['Status:_'] [&:before]:opacity-60">
|
||||
{% if item.status.name == "InProgress" %}
|
||||
In Progress, attempt {{item.status.attempts}}
|
||||
{% elif item.status.name == "Error" %}
|
||||
Error: {{item.status.message}}
|
||||
{% else %}
|
||||
{{item.status.name}}
|
||||
{% endif %}
|
||||
</header>
|
||||
{% if active_jobs %}
|
||||
<ul class="flex flex-col gap-3 list-none p-0 m-0">
|
||||
{% for item in active_jobs %}
|
||||
<li class="nb-panel p-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="size-10 shrink-0 flex items-center justify-center border-2 border-neutral bg-transparent">
|
||||
{% if item.content.Url %}
|
||||
{% include "icons/link_icon.html" %}
|
||||
{% elif item.content.File %}
|
||||
{% include "icons/document_icon.html" %}
|
||||
{% else %}
|
||||
{% include "icons/bars_icon.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold">
|
||||
{% if item.status.name == "InProgress" %}
|
||||
In progress, attempt {{ item.status.attempts }}
|
||||
{% elif item.status.name == "Error" %}
|
||||
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 class="text-xs font-semibold opacity-60">
|
||||
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}} </div>
|
||||
</div>
|
||||
<p class="list-col-wrap text-xs [&:before]:content-['Content:_'] [&:before]:opacity-60">
|
||||
{% if item.content.Url %}
|
||||
{{item.content.Url.url}}
|
||||
{% elif item.content.File %}
|
||||
{{item.content.File.file_info.file_name}}
|
||||
{% else %}
|
||||
{{item.content.Text.text}}
|
||||
{% endif %}
|
||||
</p>
|
||||
<button hx-delete="/jobs/{{item.id}}" hx-target="#active_jobs_section" hx-swap="outerHTML"
|
||||
class="btn btn-square btn-ghost btn-sm">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="sm:flex-1 sm:text-right">
|
||||
<p class="text-xs opacity-80 leading-snug break-words">
|
||||
{% if item.content.Url %}
|
||||
{{ item.content.Url.url }}
|
||||
{% elif item.content.File %}
|
||||
{{ item.content.File.file_info.file_name }}
|
||||
{% else %}
|
||||
{{ item.content.Text.text }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button hx-delete="/jobs/{{ item.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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -7,8 +7,20 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<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/active_jobs.html" %}
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
{% for task in tasks %}
|
||||
<li class="list-row" hx-ext="sse" sse-connect="/task/status-stream?task_id={{task.id}}" sse-close="close_stream">
|
||||
<div class="bg-secondary rounded-box size-10 flex justify-center items-center text-secondary-content"
|
||||
sse-swap="stop_loading" hx-swap="innerHTML">
|
||||
<span class="loading loading-spinner loading-xl"></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex gap-1">
|
||||
<div sse-swap="status" hx-swap="innerHTML">
|
||||
Created
|
||||
</div>
|
||||
<div hx-get="/content/recent" hx-target="#latest_content_section" hx-swap="outerHTML"
|
||||
hx-trigger="sse:update_latest_content"></div>
|
||||
<li class="nb-panel p-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
hx-ext="sse" sse-connect="/task/status-stream?task_id={{task.id}}" sse-close="close_stream">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="size-10 flex items-center justify-center border-2 border-neutral bg-transparent"
|
||||
sse-swap="stop_loading" hx-swap="innerHTML">
|
||||
<span class="loading loading-spinner loading-md"></span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold flex gap-2 items-center">
|
||||
<span sse-swap="status" hx-swap="innerHTML">Created</span>
|
||||
<div hx-get="/content/recent" hx-target="#latest_content_section" hx-swap="outerHTML"
|
||||
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 class="text-xs font-semibold opacity-60">
|
||||
{{task.created_at|datetimeformat(format="short", tz=user.timezone)}} </div>
|
||||
</div>
|
||||
<p class="list-col-wrap text-xs [&:before]:content-['Content:_'] [&:before]:opacity-60">
|
||||
{% if task.content.Url %}
|
||||
{{task.content.Url.url}}
|
||||
{% elif task.content.File %}
|
||||
{{task.content.File.file_info.file_name}}
|
||||
{% else %}
|
||||
{{task.content.Text.text}}
|
||||
{% endif %}
|
||||
</p>
|
||||
<button hx-delete="/jobs/{{task.id}}" hx-target="#active_jobs_section" hx-swap="outerHTML"
|
||||
class="btn btn-square btn-ghost btn-sm">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
|
||||
<div class="sm:flex-1 sm:text-right">
|
||||
<p class="text-xs opacity-80 leading-snug break-words">
|
||||
{% if task.content.Url %}
|
||||
{{task.content.Url.url}}
|
||||
{% elif task.content.File %}
|
||||
{{task.content.File.file_info.file_name}}
|
||||
{% else %}
|
||||
{{task.content.Text.text}}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
25
html-router/templates/dashboard/statistics.html
Normal file
25
html-router/templates/dashboard/statistics.html
Normal 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>
|
||||
@@ -4,39 +4,37 @@ hx-post="/ingress-form"
|
||||
enctype="multipart/form-data"
|
||||
{% endblock %}
|
||||
{% block modal_content %}
|
||||
<h3 class="text-lg font-bold">Add new content</h3>
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span>Content</span>
|
||||
<textarea name="content" class="textarea input-bordered w-full"
|
||||
placeholder="Enter the content you want to ingest, it can be an URL or a text snippet">{{ content }}</textarea>
|
||||
<h3 class="text-xl font-extrabold tracking-tight">Add New Content</h3>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Content</div>
|
||||
<textarea name="content" class="nb-input w-full min-h-28"
|
||||
placeholder="Paste a URL or type/paste text to ingest…">{{ content }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span>Context</span>
|
||||
<textarea name="context" class="textarea w-full"
|
||||
placeholder="Enter context for the AI here, help it understand what its seeing or how it should relate to the database">{{
|
||||
context }}</textarea>
|
||||
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Context</div>
|
||||
<textarea name="context" class="nb-input w-full min-h-24"
|
||||
placeholder="Optional: add context to guide how the content should be interpreted…">{{ context }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span>Category</span>
|
||||
<input type="text" name="category" class="input input-bordered validator w-full" value="{{ category }}"
|
||||
list="category-list" required />
|
||||
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Category</div>
|
||||
<input type="text" name="category" class="nb-input validator w-full" value="{{ category }}" list="category-list" required />
|
||||
<datalist id="category-list">
|
||||
{% for category in user_categories %}
|
||||
<option value="{{ category }}" />
|
||||
{% endfor %}
|
||||
</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>
|
||||
</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>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -54,7 +52,7 @@ enctype="multipart/form-data"
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block primary_actions %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
<button type="submit" class="nb-btn nb-cta">
|
||||
Add Content
|
||||
</button>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
{% block title %}Minne - Knowledge{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 ">
|
||||
<div class="container overflow-y-auto">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Knowledge Entities</h2>
|
||||
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6">
|
||||
<div class="container">
|
||||
<div class="nb-panel p-3 mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center">
|
||||
<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"
|
||||
class="flex items-center gap-4 mt-2 sm:mt-0">
|
||||
<div class="form-control">
|
||||
<select name="entity_type" class="select select-bordered">
|
||||
class="flex items-center gap-2 mt-2 sm:mt-0">
|
||||
<div>
|
||||
<select name="entity_type" class="nb-select">
|
||||
<option value="">All Types</option>
|
||||
{% for type in entity_types %}
|
||||
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<select name="content_category" class="select select-bordered">
|
||||
<div>
|
||||
<select name="content_category" class="nb-select">
|
||||
<option value="">All Categories</option>
|
||||
{% for category in content_categories %}
|
||||
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
|
||||
@@ -26,12 +26,12 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||
<button type="submit" class="nb-btn btn-sm">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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;"
|
||||
data-entity-type="{{ selected_entity_type | default(value='') }}"
|
||||
data-content-category="{{ selected_content_category | default(value='') }}">
|
||||
|
||||
@@ -7,18 +7,18 @@ hx-swap="outerHTML"
|
||||
{% endblock %}
|
||||
|
||||
{% 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">
|
||||
<label class="floating-label">
|
||||
<span class="label-text">Name</span>
|
||||
<input type="text" name="name" value="{{ entity.name }}" class="input input-bordered w-full">
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Name</div>
|
||||
<input type="text" name="name" value="{{ entity.name }}" class="nb-input w-full">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<select name="entity_type" class="select w-full">
|
||||
<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="nb-select w-full">
|
||||
<option disabled>You must select a type</option>
|
||||
{% for et in entity_types %}
|
||||
<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">
|
||||
|
||||
<div class="form-control">
|
||||
<label class="floating-label">
|
||||
<span class="label-text">Description</span>
|
||||
<textarea name="description" class="w-full textarea textarea-bordered h-32">{{ entity.description }}</textarea>
|
||||
<label class="w-full">
|
||||
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Description</div>
|
||||
<textarea name="description" class="nb-input w-full h-32">{{ entity.description }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block primary_actions %}
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
{% endblock %}
|
||||
<button type="submit" class="nb-btn nb-cta">Save Changes</button>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
<div class="card min-w-72 bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
@@ -22,4 +22,4 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<div id="relationship_table_section"
|
||||
class="overflow-x-auto shadow rounded-box border border-base-content/5 bg-base-100 mb-10">
|
||||
<table class="table">
|
||||
<div id="relationship_table_section" class="overflow-x-auto nb-card mb-10">
|
||||
<table class="nb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Origin</th>
|
||||
<th>Target</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
<th class="text-left">Origin</th>
|
||||
<th class="text-left">Target</th>
|
||||
<th class="text-left">Type</th>
|
||||
<th class="text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -31,9 +30,9 @@
|
||||
{{ relationship.out }}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>{{ relationship.metadata.relationship_type }}</td>
|
||||
<td class="uppercase tracking-wide text-xs">{{ relationship.metadata.relationship_type }}</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">
|
||||
{% include "icons/delete_icon.html" %}
|
||||
</button>
|
||||
@@ -43,7 +42,7 @@
|
||||
<!-- New linking row -->
|
||||
<tr id="new_relationship">
|
||||
<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>
|
||||
{% for entity in entities %}
|
||||
<option value="{{ entity.id }}">
|
||||
@@ -53,7 +52,7 @@
|
||||
</select>
|
||||
</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>
|
||||
{% for entity in entities %}
|
||||
<option value="{{ entity.id }}">{{ entity.name }}</option>
|
||||
@@ -62,12 +61,11 @@
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
<button id="save_relationship_button" type="button" class="btn btn-primary btn-sm"
|
||||
hx-post="/knowledge-relationship" hx-target="#relationship_table_section" hx-swap="outerHTML"
|
||||
hx-include=".new_relationship_input">
|
||||
<button id="save_relationship_button" type="button" class="nb-btn btn-sm" hx-post="/knowledge-relationship"
|
||||
hx-target="#relationship_table_section" hx-swap="outerHTML" hx-include=".new_relationship_input">
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
<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 %}>
|
||||
<div class="flex flex-col flex-1 space-y-4">
|
||||
{% block modal_content %} <!-- Form fields go here in child templates -->
|
||||
{% endblock %}
|
||||
<div class="flex flex-col flex-1 gap-4">
|
||||
{% block modal_content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<div class="u-hairline mt-4 pt-3 flex justify-end gap-2">
|
||||
<!-- 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
|
||||
</button>
|
||||
|
||||
<!-- Primary actions block -->
|
||||
{% block primary_actions %}
|
||||
<!-- Submit/Save buttons go here in child templates -->
|
||||
{% endblock %}
|
||||
{% block primary_actions %}{% endblock %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -38,4 +35,4 @@
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</dialog>
|
||||
|
||||
@@ -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="mr-2 flex-1">
|
||||
{% include "searchbar.html" %}
|
||||
@@ -12,4 +12,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
{% block main %}
|
||||
<div class="flex justify-center grow mt-2 sm:mt-4">
|
||||
<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" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% if search_result is defined and search_result %}
|
||||
<ul class="list shadow">
|
||||
<ul class="nb-card p-0">
|
||||
{% for result in search_result %}
|
||||
<li class="list-row hover:bg-base-200/50 p-4">
|
||||
<div class="w-10 h-10 flex-shrink-0 mr-4 self-start mt-1">
|
||||
<li class="p-4 u-hairline hover:bg-base-200/40 flex gap-3">
|
||||
<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 %}
|
||||
<div class="tooltip tooltip-right" data-tip="Web Link">
|
||||
{% include "icons/link_icon.html" %}
|
||||
@@ -17,10 +17,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow min-w-0">
|
||||
<h3 class="text-lg font-semibold mb-1">
|
||||
<a hx-get="/content/{{ result.id }}/read" hx-target="#modal" hx-swap="innerHTML"
|
||||
class="link link-hover link-primary">
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-extrabold mb-1 leading-snug">
|
||||
<a hx-get="/content/{{ result.id }}/read" hx-target="#modal" hx-swap="innerHTML" class="nb-link">
|
||||
{% set title_text = result.highlighted_url_title
|
||||
| default(result.url_info.title if result.url_info else none, true)
|
||||
| default(result.highlighted_file_name, true)
|
||||
@@ -30,8 +30,7 @@
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div class="markdown-content prose prose-sm text-sm text-base-content/80 mb-3 overflow-hidden line-clamp-6"
|
||||
data-content="{{result.highlighted_text | escape}}">
|
||||
<div class="markdown-content prose-tufte-compact text-base-content/80 mb-3 overflow-hidden line-clamp-6" data-content="{{result.highlighted_text | escape}}">
|
||||
{% if result.highlighted_text %}
|
||||
{{ result.highlighted_text | escape }}
|
||||
{% elif result.text %}
|
||||
@@ -41,43 +40,46 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-base-content/70 flex flex-wrap gap-x-4 gap-y-1 items-center">
|
||||
<span class="inline-flex items-center"><strong class="font-medium mr-1">Category:</strong>
|
||||
<span class="badge badge-soft badge-secondary badge-sm">{{ result.highlighted_category |
|
||||
default(result.category, true) |
|
||||
safe }}</span>
|
||||
<div class="text-xs flex flex-wrap gap-x-4 gap-y-2 items-center">
|
||||
<span class="inline-flex items-center">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Category</span>
|
||||
<span class="nb-badge">{{ result.highlighted_category | default(result.category, true) | safe }}</span>
|
||||
</span>
|
||||
|
||||
{% if result.highlighted_context or result.context %}
|
||||
<span class="inline-flex items-center"><strong class="font-medium mr-1">Context:</strong>
|
||||
<span class="badge badge-sm badge-outline">{{ result.highlighted_context | default(result.context, true) |
|
||||
safe }}</span>
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Context</span>
|
||||
<span class="nb-badge">{{ result.highlighted_context | default(result.context, true) | safe }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<a href="{{ result.url_info.url }}" target="_blank" class="link link-hover link-xs truncate"
|
||||
title="{{ result.url_info.url }}">
|
||||
<span class="inline-flex items-center min-w-0">
|
||||
<span class="uppercase tracking-wide opacity-60 mr-2">Source</span>
|
||||
<a href="{{ result.url_info.url }}" target="_blank" class="nb-link truncate" title="{{ result.url_info.url }}">
|
||||
{{ result.highlighted_url | default(result.url_info.url ) | safe }}
|
||||
</a>
|
||||
</span>
|
||||
{% 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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</ul>
|
||||
|
||||
{% elif query_param is defined and query_param | trim != "" %}
|
||||
<div class="p-4 text-center text-base-content/70">
|
||||
<p class="text-xl font-semibold mb-2">No results found for "<strong>{{ query_param | escape }}</strong>".</p>
|
||||
<p class="text-sm">Try using different keywords or checking for typos.</p>
|
||||
</div>
|
||||
<div class="nb-panel p-5 text-center">
|
||||
<p class="text-xl font-extrabold mb-2">No results for “{{ query_param | escape }}”.</p>
|
||||
<p class="text-sm opacity-70">Try different keywords or check for typos.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-4 text-center text-base-content/70">
|
||||
<p class="text-lg font-medium">Enter a term above to search your knowledge base.</p>
|
||||
<p class="text-sm">Results will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="nb-panel p-5 text-center">
|
||||
<p class="text-lg font-semibold">Enter a term above to search your knowledge base.</p>
|
||||
<p class="text-sm opacity-70">Results will appear here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<div class="flex items-center gap-2 min-w-[90px]">
|
||||
<form class="w-full" hx-boost="true" method="get" action="/search"
|
||||
<div class="flex items-center gap-2 min-w-[90px] w-full">
|
||||
<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">
|
||||
<input id="search-input" type="search" placeholder="Search for anything..."
|
||||
class="input input-sm input-bordered input-primary w-full" name="query" autocomplete="off"
|
||||
value="{{ query_param | default('', true) }}" />
|
||||
<input id="search-input" type="search" aria-label="Search" class=" nb-input w-full pl-9 ml-2" name="query"
|
||||
autocomplete="off" 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>
|
||||
</div>
|
||||
@@ -15,11 +15,12 @@
|
||||
<div class="drawer-side z-20">
|
||||
<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">
|
||||
<!-- <a class="px-2 mt-4 text-center text-2xl text-primary font-bold" href="/" hx-boost="true">Minne</a> -->
|
||||
<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-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 === -->
|
||||
<div class="px-2 mt-14">
|
||||
<div class="px-2 mt-4 space-y-3">
|
||||
{% for url, name, label in [
|
||||
("/", "home", "Dashboard"),
|
||||
("/knowledge", "book", "Knowledge"),
|
||||
@@ -28,22 +29,22 @@
|
||||
("/search", "search", "Search")
|
||||
] %}
|
||||
<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) }}
|
||||
<span>{{ label }}</span>
|
||||
<span class="uppercase tracking-wide">{{ label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<button class="btn btn-primary btn-outline w-full flex items-center gap-3 justify-start mt-2"
|
||||
hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
|
||||
<button class="nb-btn nb-cta w-full flex items-center gap-3 justify-start mt-2" hx-get="/ingress-form"
|
||||
hx-target="#modal" hx-swap="innerHTML">{% include "icons/send_icon.html" %} Add
|
||||
Content</button>
|
||||
</li>
|
||||
<div class="divider "></div>
|
||||
<div class="u-hairline mt-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- === 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">
|
||||
{% if conversation_archive is defined and conversation_archive %}
|
||||
{% for conversation in conversation_archive %}
|
||||
@@ -51,12 +52,12 @@
|
||||
{% if edit_conversation_id == conversation.id %}
|
||||
<!-- Edit mode -->
|
||||
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||
class="flex items-center gap-1 px-2 py-2">
|
||||
<input type="text" name="title" value="{{ conversation.title }}" class="input input-sm flex-grow" />
|
||||
<div class="flex gap-0.5">
|
||||
<button type="submit" class="btn btn-ghost btn-xs">{% include "icons/check_icon.html" %}</button>
|
||||
class="flex items-center gap-1 px-2 py-2 max-w-72 relative">
|
||||
<input type="text" name="title" value="{{ conversation.title }}" class="nb-input nb-input-sm max-w-52" />
|
||||
<div class="flex gap-0.5 absolute right-2">
|
||||
<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"
|
||||
class="btn btn-ghost btn-xs">
|
||||
class="btn btn-ghost btn-xs !p-0">
|
||||
{% include "icons/x_icon.html" %}
|
||||
</button>
|
||||
</div>
|
||||
@@ -86,29 +87,30 @@
|
||||
</div>
|
||||
|
||||
<!-- === BOTTOM FIXED SECTION === -->
|
||||
<div class="px-2 pb-4">
|
||||
<div class="divider "></div>
|
||||
<div class="px-2 pb-4 space-y-3">
|
||||
<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" %}
|
||||
<span>Account</span>
|
||||
<span class="uppercase tracking-wide">Account</span>
|
||||
</a>
|
||||
</li>
|
||||
{% if user.admin %}
|
||||
<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" %}
|
||||
<span>Admin</span>
|
||||
<span class="uppercase tracking-wide">Admin</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<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" %}
|
||||
<span>Logout</span>
|
||||
<span class="uppercase tracking-wide">Logout</span>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use async_openai::types::{CreateTranscriptionRequestArgs, AudioResponseFormat};
|
||||
use async_openai::types::{AudioResponseFormat, CreateTranscriptionRequestArgs};
|
||||
use common::{
|
||||
error::AppError,
|
||||
storage::{
|
||||
db::SurrealDbClient,
|
||||
types::system_settings::SystemSettings,
|
||||
},
|
||||
storage::{db::SurrealDbClient, types::system_settings::SystemSettings},
|
||||
};
|
||||
|
||||
/// 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)))?;
|
||||
Ok(response.text)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user