feat: release build bundles templates in bin

This commit is contained in:
Per Stark
2025-03-27 08:32:21 +01:00
parent 0bc147cfc5
commit c8a97d9b52
70 changed files with 204 additions and 296 deletions

View File

@@ -31,3 +31,7 @@ tempfile = "3.12.0"
url = { version = "2.5.2", features = ["serde"] }
uuid = { version = "1.10.0", features = ["v4", "serde"] }
minijinja = { version = "2.5.0", features = ["loader", "multi_template"] }
minijinja-autoreload = "2.5.0"
minijinja-embed = { version = "2.8.0" }
minijinja-contrib = { version = "2.6.0", features = ["datetime", "timezone"] }

View File

@@ -1 +0,0 @@

View File

@@ -1,3 +1,3 @@
pub mod config;
pub mod embedding;
pub mod mailer;
pub mod template_engine;

View File

@@ -0,0 +1,92 @@
pub use minijinja::{path_loader, Environment, Value};
pub use minijinja_autoreload::AutoReloader;
pub use minijinja_contrib;
pub use minijinja_embed;
use std::sync::Arc;
#[derive(Clone)]
pub enum TemplateEngine {
// Use AutoReload for debug builds (debug_assertions is true)
#[cfg(debug_assertions)]
AutoReload(Arc<AutoReloader>),
// Use Embedded for release builds (debug_assertions is false)
#[cfg(not(debug_assertions))]
Embedded(Arc<Environment<'static>>),
}
#[macro_export]
macro_rules! create_template_engine {
// Macro takes the relative path to the templates dir as input
($relative_path:expr) => {{
// Code for debug builds (AutoReload)
#[cfg(debug_assertions)]
{
// These lines execute in the CALLING crate's context
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let template_path = crate_dir.join($relative_path);
let reloader = $crate::utils::template_engine::AutoReloader::new(move |notifier| {
let mut env = $crate::utils::template_engine::Environment::new();
env.set_loader($crate::utils::template_engine::path_loader(&template_path));
notifier.set_fast_reload(true);
notifier.watch_path(&template_path, true);
// Add contrib filters/functions
$crate::utils::template_engine::minijinja_contrib::add_to_environment(&mut env);
Ok(env)
});
$crate::utils::template_engine::TemplateEngine::AutoReload(std::sync::Arc::new(
reloader,
))
}
// Code for release builds (Embedded)
#[cfg(not(debug_assertions))]
{
// These lines also execute in the CALLING crate's context
let mut env = $crate::utils::template_engine::Environment::new();
$crate::utils::template_engine::minijinja_embed::load_templates!(&mut env);
// Add contrib filters/functions
$crate::utils::template_engine::minijinja_contrib::add_to_environment(&mut env);
$crate::utils::template_engine::TemplateEngine::Embedded(std::sync::Arc::new(env))
}
}};
}
impl TemplateEngine {
pub fn render(&self, name: &str, ctx: &Value) -> Result<String, minijinja::Error> {
match self {
// Only compile this arm for debug builds
#[cfg(debug_assertions)]
TemplateEngine::AutoReload(reloader) => {
let env = reloader.acquire_env()?;
env.get_template(name)?.render(ctx)
}
// Only compile this arm for release builds
#[cfg(not(debug_assertions))]
TemplateEngine::Embedded(env) => env.get_template(name)?.render(ctx),
}
}
pub fn render_block(
&self,
template_name: &str,
block_name: &str,
context: &Value,
) -> Result<String, minijinja::Error> {
match self {
// Only compile this arm for debug builds
#[cfg(debug_assertions)]
TemplateEngine::AutoReload(reloader) => {
let env = reloader.acquire_env()?;
let template = env.get_template(template_name)?;
let mut state = template.eval_to_state(context)?;
state.render_block(block_name)
}
// Only compile this arm for release builds
#[cfg(not(debug_assertions))]
TemplateEngine::Embedded(env) => {
let template = env.get_template(template_name)?;
let mut state = template.eval_to_state(context)?;
state.render_block(block_name)
}
}
}
}

View File

@@ -24,6 +24,7 @@ async-stream = "0.3.6"
json-stream-parser = "0.1.4"
minijinja = { version = "2.5.0", features = ["loader", "multi_template"] }
minijinja-autoreload = "2.5.0"
minijinja-embed = { version = "2.8.0" }
minijinja-contrib = { version = "2.6.0", features = ["datetime", "timezone"] }
plotly = "0.12.1"
surrealdb = "2.0.4"
@@ -32,3 +33,6 @@ chrono-tz = "0.10.1"
common = { path = "../common" }
composite-retrieval = { path = "../composite-retrieval" }
[build-dependencies]
minijinja-embed = { version = "2.8.0" }

View File

@@ -0,0 +1,12 @@
fn main() {
// Get the build profile ("debug" or "release")
let profile = std::env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
// Embed templates only for release builds
if profile == "release" {
// Embed templates from the "templates" directory relative to CARGO_MANIFEST_DIR
minijinja_embed::embed_templates!("templates");
} else {
println!("cargo:info=Build: Skipping template embedding for debug build.");
}
}

View File

@@ -1,10 +1,9 @@
use axum_session::SessionStore;
use axum_session_surreal::SessionSurrealPool;
use common::create_template_engine;
use common::storage::db::SurrealDbClient;
use common::utils::config::AppConfig;
use minijinja::{path_loader, Environment};
use minijinja_autoreload::AutoReloader;
use std::path::PathBuf;
use common::utils::template_engine::TemplateEngine;
use std::sync::Arc;
use surrealdb::engine::any::Any;
@@ -12,22 +11,13 @@ use surrealdb::engine::any::Any;
pub struct HtmlState {
pub db: Arc<SurrealDbClient>,
pub openai_client: Arc<async_openai::Client<async_openai::config::OpenAIConfig>>,
pub templates: Arc<AutoReloader>,
pub templates: Arc<TemplateEngine>,
pub session_store: Arc<SessionStore<SessionSurrealPool<Any>>>,
}
impl HtmlState {
pub async fn new(config: &AppConfig) -> Result<Self, Box<dyn std::error::Error>> {
let reloader = AutoReloader::new(move |notifier| {
let template_path = get_templates_dir();
let mut env = Environment::new();
env.set_loader(path_loader(&template_path));
notifier.set_fast_reload(true);
notifier.watch_path(&template_path, true);
minijinja_contrib::add_to_environment(&mut env);
Ok(env)
});
let template_engine = create_template_engine!("templates");
let surreal_db_client = Arc::new(
SurrealDbClient::new(
@@ -48,7 +38,7 @@ impl HtmlState {
let app_state = HtmlState {
db: surreal_db_client.clone(),
templates: Arc::new(reloader),
templates: Arc::new(template_engine),
openai_client: openai_client.clone(),
session_store,
};
@@ -56,23 +46,3 @@ impl HtmlState {
Ok(app_state)
}
}
pub fn get_workspace_root() -> PathBuf {
// Starts from CARGO_MANIFEST_DIR (e.g., /project/crates/html-router/)
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
// Navigate up to /path/to/project/crates
let crates_dir = manifest_dir
.parent()
.expect("Failed to find parent of manifest directory");
// Navigate up to workspace root
crates_dir
.parent()
.expect("Failed to find workspace root")
.to_path_buf()
}
pub fn get_templates_dir() -> PathBuf {
get_workspace_root().join("templates")
}

View File

@@ -1,3 +1,4 @@
use crate::html_state::HtmlState;
use axum::{
extract::State,
http::StatusCode,
@@ -6,19 +7,15 @@ use axum::{
};
use common::error::AppError;
use minijinja::{context, Value};
use minijinja_autoreload::AutoReloader;
use serde::Serialize;
use std::sync::Arc;
use tracing::error;
use crate::html_state::HtmlState;
// Enum for template types
#[derive(Clone)]
pub enum TemplateKind {
Full(String), // Full page template
Partial(String, String), // Template name, block name
Error(StatusCode), // Error template with status code
Redirect(String), // Redirect
Full(String),
Partial(String, String),
Error(StatusCode),
Redirect(String),
}
#[derive(Clone)]
@@ -53,14 +50,12 @@ impl TemplateResponse {
error => error,
description => description
};
Self {
template_kind: TemplateKind::Error(status),
context: ctx,
}
}
// Convenience methods for common errors
pub fn not_found() -> Self {
Self::error(
StatusCode::NOT_FOUND,
@@ -118,25 +113,33 @@ struct TemplateStateWrapper {
impl IntoResponse for TemplateStateWrapper {
fn into_response(self) -> Response {
let templates = self.state.templates;
let template_engine = &self.state.templates;
match &self.template_response.template_kind {
TemplateKind::Full(name) => {
render_template(name, self.template_response.context, templates)
match template_engine.render(name, &self.template_response.context) {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render template: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, fallback_error()).into_response()
}
}
}
TemplateKind::Partial(name, block) => {
render_block(name, block, self.template_response.context, templates)
TemplateKind::Partial(template, block) => {
match template_engine.render_block(template, block, &self.template_response.context)
{
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render block: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, fallback_error()).into_response()
}
}
}
TemplateKind::Error(status) => {
let html = match try_render_template(
"errors/error.html",
self.template_response.context,
templates,
) {
Ok(html_string) => Html(html_string),
Err(_) => fallback_error(),
};
(*status, html).into_response()
match template_engine.render("errors/error.html", &self.template_response.context) {
Ok(html) => (*status, Html(html)).into_response(),
Err(_) => (*status, fallback_error()).into_response(),
}
}
TemplateKind::Redirect(path) => {
(StatusCode::OK, [(axum_htmx::HX_REDIRECT, path.clone())], "").into_response()
@@ -145,93 +148,11 @@ impl IntoResponse for TemplateStateWrapper {
}
}
// Helper functions for rendering with error handling
fn render_template(name: &str, context: Value, templates: Arc<AutoReloader>) -> Response {
match try_render_template(name, context, templates.clone()) {
Ok(html) => Html(html).into_response(),
Err(_) => fallback_error().into_response(),
}
}
fn render_block(name: &str, block: &str, context: Value, templates: Arc<AutoReloader>) -> Response {
match try_render_block(name, block, context, templates.clone()) {
Ok(html) => Html(html).into_response(),
Err(_) => fallback_error().into_response(),
}
}
fn try_render_template(
template_name: &str,
context: Value,
templates: Arc<AutoReloader>,
) -> Result<String, ()> {
let env = templates.acquire_env().map_err(|e| {
tracing::error!("Environment error: {:?}", e);
()
})?;
let tmpl = env.get_template(template_name).map_err(|e| {
tracing::error!("Template error: {:?}", e);
()
})?;
tmpl.render(context).map_err(|e| {
tracing::error!("Render error: {:?}", e);
()
})
}
fn try_render_block(
template_name: &str,
block: &str,
context: Value,
templates: Arc<AutoReloader>,
) -> Result<String, ()> {
let env = templates.acquire_env().map_err(|e| {
tracing::error!("Environment error: {:?}", e);
()
})?;
let tmpl = env.get_template(template_name).map_err(|e| {
tracing::error!("Template error: {:?}", e);
()
})?;
let mut state = tmpl.eval_to_state(context).map_err(|e| {
tracing::error!("Eval error: {:?}", e);
()
})?;
state.render_block(block).map_err(|e| {
tracing::error!("Block render error: {:?}", e);
()
})
}
fn fallback_error() -> Html<String> {
Html(
r#"
<html>
<body>
<div class="container mx-auto p-4">
<h1 class="text-4xl text-error">Error</h1>
<p class="mt-4">Sorry, something went wrong displaying this page.</p>
</div>
</body>
</html>
"#
.to_string(),
)
}
pub async fn with_template_response(
State(state): State<HtmlState>,
response: Response,
) -> Response {
// Clone the TemplateResponse from extensions
let template_response = response.extensions().get::<TemplateResponse>().cloned();
if let Some(template_response) = template_response {
if let Some(template_response) = response.extensions().get::<TemplateResponse>().cloned() {
TemplateStateWrapper {
state,
template_response,
@@ -242,40 +163,60 @@ pub async fn with_template_response(
}
}
// Define HtmlError
#[derive(Debug)]
pub enum HtmlError {
AppError(AppError),
TemplateError(String),
}
// Conversion from AppError to HtmlError
impl From<AppError> for HtmlError {
fn from(err: AppError) -> Self {
HtmlError::AppError(err)
}
}
// Conversion for database error to HtmlError
impl From<surrealdb::Error> for HtmlError {
fn from(err: surrealdb::Error) -> Self {
HtmlError::AppError(AppError::from(err))
}
}
impl From<minijinja::Error> for HtmlError {
fn from(err: minijinja::Error) -> Self {
HtmlError::TemplateError(err.to_string())
}
}
impl IntoResponse for HtmlError {
fn into_response(self) -> Response {
match self {
HtmlError::AppError(err) => {
let template_response = match err {
AppError::NotFound(_) => TemplateResponse::not_found(),
AppError::Auth(_) => TemplateResponse::unauthorized(),
AppError::Validation(msg) => TemplateResponse::bad_request(&msg),
_ => {
tracing::error!("Internal error: {:?}", err);
TemplateResponse::server_error()
}
};
template_response.into_response()
HtmlError::AppError(err) => match err {
AppError::NotFound(_) => TemplateResponse::not_found().into_response(),
AppError::Auth(_) => TemplateResponse::unauthorized().into_response(),
AppError::Validation(msg) => TemplateResponse::bad_request(&msg).into_response(),
_ => {
error!("Internal error: {:?}", err);
TemplateResponse::server_error().into_response()
}
},
HtmlError::TemplateError(err) => {
error!("Template error: {}", err);
TemplateResponse::server_error().into_response()
}
}
}
}
fn fallback_error() -> String {
r#"
<html>
<body>
<div class="container mx-auto p-4">
<h1 class="text-4xl text-error">Error</h1>
<p class="mt-4">Sorry, something went wrong displaying this page.</p>
</div>
</body>
</html>
"#
.to_string()
}

View File

@@ -22,23 +22,24 @@ use futures::{
Stream, StreamExt, TryStreamExt,
};
use json_stream_parser::JsonStreamParser;
use minijinja::Value;
use serde::{Deserialize, Serialize};
use serde_json::from_str;
use surrealdb::{engine::any::Any, Surreal};
use tokio::sync::{mpsc::channel, Mutex};
use tracing::{error, debug};
use tracing::{debug, error};
use common::storage::{
db::SurrealDbClient,
types::{
conversation::Conversation,
message::{Message, MessageRole},
user::User,
system_settings::SystemSettings,
user::User,
},
};
use crate::{html_state::HtmlState, routes::render_template};
use crate::html_state::HtmlState;
// Error handling function
fn create_error_stream(
@@ -272,17 +273,13 @@ pub async fn get_response_stream(
}
// Render template with references
match render_template(
match state.templates.render(
"chat/reference_list.html",
ReferenceData { message },
state.templates.clone(),
&Value::from_serialize(ReferenceData { message }),
) {
Ok(html) => {
// Extract the String from Html<String>
let html_string = html.0;
// Return the rendered HTML
Ok(Event::default().event("references").data(html_string))
Ok(Event::default().event("references").data(html))
}
Err(_) => {
// Handle template rendering error

View File

@@ -1,10 +1,3 @@
use std::sync::Arc;
use axum::response::Html;
use minijinja_autoreload::AutoReloader;
use crate::middlewares::response_middleware::HtmlError;
pub mod account;
pub mod admin;
pub mod auth;
@@ -14,20 +7,3 @@ pub mod index;
pub mod ingestion;
pub mod knowledge;
pub mod search;
// Helper function for render_template
pub fn render_template<T>(
template_name: &str,
context: T,
templates: Arc<AutoReloader>,
) -> Result<Html<String>, HtmlError>
where
T: serde::Serialize,
{
let env = templates.acquire_env().unwrap();
let tmpl = env.get_template(template_name).unwrap();
let context = minijinja::Value::from_serialize(&context);
let output = tmpl.render(context).unwrap();
Ok(Html(output))
}

View File

@@ -0,0 +1,38 @@
{% extends "modal_base.html" %}
{% block form_attributes %}
hx-patch="/update-ingestion-prompt"
hx-target="#system_prompt_section"
hx-swap="outerHTML"
{% endblock %}
{% block modal_content %}
<h3 class="text-lg font-bold mb-4">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">{{
settings.ingestion_system_prompt }}</textarea>
<p class="text-xs text-gray-500 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">
Reset to Default
</button>
<textarea id="default_prompt_content" style="display:none;">{{ default_ingestion_prompt }}</textarea>
<script>
document.getElementById('reset_prompt_button').addEventListener('click', function () {
const defaultContent = document.getElementById('default_prompt_content').value;
document.querySelector('textarea[name=ingestion_system_prompt]').value = defaultContent;
});
</script>
<button type="submit" class="btn btn-primary">
<span class="htmx-indicator hidden">
<span class="loading loading-spinner loading-xs mr-2"></span>
</span>
Save Changes
</button>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "modal_base.html" %}
{% block form_attributes %}
hx-patch="/update-query-prompt"
hx-target="#system_prompt_section"
hx-swap="outerHTML"
{% endblock %}
{% block modal_content %}
<h3 class="text-lg font-bold mb-4">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">{{
settings.query_system_prompt }}</textarea>
<p class="text-xs text-gray-500 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">
Reset to Default
</button>
<textarea id="default_prompt_content" style="display:none;">{{ default_query_prompt }}</textarea>
<script>
document.getElementById('reset_prompt_button').addEventListener('click', function () {
const defaultContent = document.getElementById('default_prompt_content').value;
document.querySelector('textarea[name=query_system_prompt]').value = defaultContent;
});
</script>
<button type="submit" class="btn btn-primary">
<span class="htmx-indicator hidden">
<span class="loading loading-spinner loading-xs mr-2"></span>
</span>
Save Changes
</button>
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends "body_base.html" %}
{% 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-3xl 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="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>
<script>
// Using a single toast element avoids creating many timeouts when clicking repeatedly.
let current_toast = null;
let toast_timeout = null;
function show_toast(message) {
if (current_toast) {
// Update message and reset timeout if a toast is already displayed.
current_toast.querySelector('span').textContent = message;
clearTimeout(toast_timeout);
} else {
current_toast = document.createElement('div');
current_toast.className = 'toast';
current_toast.innerHTML = `<div class="alert alert-success">
<div>
<span>${message}</span>
</div>
</div>`;
document.body.appendChild(current_toast);
}
toast_timeout = setTimeout(() => {
if (current_toast) {
current_toast.remove();
current_toast = null;
}
}, 3000);
}
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!');
}).catch(() => {
show_toast('Copy failed');
});
} else {
show_toast('Copy not supported');
}
}
</script>
<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>
<div class="form-control mt-4 hidden">
<button hx-post="/verify-email" class="btn btn-secondary w-full">
Verify Email
</button>
</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 %}

View File

@@ -0,0 +1,98 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="container flex-grow flex flex-col mx-auto mt-4 space-y-6">
<h1 class="text-3xl font-bold mb-2">Admin Dashboard</h1>
<div class="stats stats-vertical lg: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>
<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 sm:grid-cols-2 gap-6">
<fieldset class="fieldset p-4 shadow rounded-box">
<legend class="fieldset-legend">Registration</legend>
<label class="fieldset-label">
{% 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 %} />
</form>
{% endblock %}
Enable Registrations
</label>
<div id="registration-status" class="text-sm mt-2"></div>
</fieldset>
<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">
<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">
<option value="gpt-4o-mini" {% if settings.query_model=="gpt-4o-mini" %}selected{% endif %}>GPT-4o Mini
</option>
<option value="gpt-4o" {% if settings.query_model=="gpt-4o" %}selected{% endif %}>GPT-4o</option>
<option value="gpt-3.5-turbo" {% if settings.query_model=="gpt-3.5-turbo" %}selected{% endif %}>GPT-3.5
Turbo</option>
</select>
<p class="text-xs text-gray-500 mt-1">Model used for answering user queries</p>
</div>
<div class="form-control my-4">
<label class="label">
<span class="label-text">Processing Model</span>
</label>
<select name="processing_model" class="select select-bordered w-full">
<option value="gpt-4o-mini" {% if settings.processing_model=="gpt-4o-mini" %}selected{% endif %}>GPT-4o Mini
</option>
<option value="gpt-4o" {% if settings.processing_model=="gpt-4o" %}selected{% endif %}>GPT-4o</option>
<option value="gpt-3.5-turbo" {% if settings.processing_model=="gpt-3.5-turbo" %}selected{% endif %}>GPT-3.5
Turbo</option>
</select>
<p class="text-xs text-gray-500 mt-1">Model used for content processing and ingestion</p>
</div>
<button type="submit" class="btn btn-primary btn-sm">Save Model Settings</button>
</form>
{% endblock %}
</fieldset>
{% block system_prompt_section %}
<div id="system_prompt_section">
<fieldset class="fieldset p-4 shadow rounded-box">
<legend class="fieldset-legend">System Prompts</legend>
<div class="flex gap-2 flex-col sm:flex-row">
<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>
</div>
</fieldset>
</div>
{% endblock %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,5 @@
<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>

View File

@@ -0,0 +1,55 @@
{% extends "head_base.html" %}
{% block body %}
<style>
form.htmx-request {
opacity: 0.5;
}
</style>
<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">Login to your account</h2>
<form hx-post="/signin" hx-target="#login-result">
<div class="form-control">
<label class="floating-label">
<span>Email</span>
<input name="email" type="email" placeholder="Email" class="input input-md w-full validator" required />
<div class="validator-hint hidden">Enter valid email address</div>
</label>
</div>
<div 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"
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>
<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>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "head_base.html" %}
{% block body %}
<style>
form.htmx-request {
opacity: 0.5;
}
</style>
<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="">
<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>
</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>
// Detect timezone and set hidden input
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
document.getElementById("timezone").value = timezone;
</script>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "head_base.html" %}
{% block body %}
<body>
<div class="min-h-screen flex flex-col">
<!-- Navbar -->
{% include "navigation_bar.html" %}
<!-- Main Content -->
{% block main %}{% endblock %}
<div id="modal"></div>
</div>
</body>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'body_base.html' %}
{% block main %}
<div class="drawer xl:drawer-open h-[calc(100vh-65px)] overflow-auto">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<!-- Drawer Content -->
<div class="drawer-content flex justify-center ">
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10 max-w-3xl w-full absolute left-0 right-0 mx-auto">
<div class="relative w-full">
{% include "chat/history.html" %}
{% include "chat/new_message_form.html" %}
</div>
</main>
</div>
<!-- Drawer Sidebar -->
{% include "chat/drawer.html" %}
</div>
<style>
/* Custom styles to override DaisyUI defaults */
.drawer-content {
width: 100%;
padding: 0;
}
.drawer-side {
z-index: 20;
/* Ensure drawer is above content */
}
@media (min-width: 1280px) {
/* xl breakpoint */
.drawer-open .drawer-content {
margin-left: 0;
/* Prevent content shift */
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,15 @@
<div class="drawer-side z-50 max-h-[calc(100vh-65px)]">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 text-base-content w-72">
<!-- Sidebar content here -->
<li class="mt-4 cursor-pointer "><a href="/chat" hx-boost="true" class="flex justify-between">Create new
chat<span>{% include
"icons/edit_icon.html" %}
</span></a></li>
<div class="divider"></div>
{% for conversation in conversation_archive %}
<li><a href="/chat/{{conversation.id}}" hx-boost="true">{{conversation.title}} - {{conversation.created_at}}</a>
</li>
{% endfor %}
</ul>
</div>

View File

@@ -0,0 +1,54 @@
<div id="chat_container" class="pl-3 overflow-y-auto h-[calc(100vh-175px)] hide-scrollbar">
{% for message in history %}
{% if message.role == "AI" %}
<div class="chat chat-start">
<div>
<div class="chat-bubble">
{{ message.content }}
</div>
{% if message.references %}
{% include "chat/reference_list.html" %}
{% endif %}
</div>
</div>
{% else %}
<div class="chat chat-end">
<div class="chat-bubble">
{{ message.content }}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<script>
document.body.addEventListener('htmx:afterSwap', function (evt) {
const chatContainer = document.getElementById('chat_container');
if (chatContainer) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 0);
}
});
window.addEventListener('load', function () {
const chatContainer = document.getElementById('chat_container');
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
</script>
<style>
/* Hide scrollbar but keep functionality */
.hide-scrollbar {
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
/* Chrome, Safari and Opera */
}
</style>

View File

@@ -0,0 +1,29 @@
{% 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>
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-1">
{% include "icons/send_icon.html" %}
</button>
<label for="my-drawer-2" class="absolute cursor-pointer top-9 right-0.5 p-2 drawer-button xl:hidden z-20 ">
{% include "icons/hamburger_icon.html" %}
</label>
</form>
<script>
document.getElementById('chat-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
htmx.trigger('#chat-form', 'submit');
}
});
// Clear textarea after successful submission
document.getElementById('chat-form').addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) { // Check if the request was successful
document.getElementById('chat-input').value = ''; // Clear the textarea
}
});
</script>

View File

@@ -0,0 +1,29 @@
<div class="fixed w-full mx-auto max-w-3xl p-0 pb-0 sm:pb-4 left-0 right-0 bottom-0 bg-base-100 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"
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>
<label for="my-drawer-2" class="absolute cursor-pointer top-9 right-0.5 p-2 drawer-button xl:hidden z-20 ">
{% include "icons/hamburger_icon.html" %}
</label>
</form>
</div>
<script>
document.getElementById('chat-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
htmx.trigger('#chat-form', 'submit');
}
});
// Clear textarea after successful submission
document.getElementById('chat-form').addEventListener('htmx:afterRequest', function (e) {
if (e.detail.successful) { // Check if the request was successful
document.getElementById('chat-input').value = ''; // Clear the textarea
}
});
</script>

View File

@@ -0,0 +1,90 @@
<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">
References
{% include "icons/chevron_icon.html" %}
</button>
<div id="references-content-{{message.id}}" class="hidden max-w-full mt-1">
<div class="flex flex-wrap gap-1">
{% 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">
{{reference}}
</span>
</div>
{% endfor %}
</div>
</div>
</div>
<script>
document.getElementById('references-toggle-{{message.id}}').addEventListener('click', function () {
const content = document.getElementById('references-content-{{message.id}}');
const icon = document.getElementById('toggle-icon');
content.classList.toggle('hidden');
icon.classList.toggle('rotate-180');
});
// Initialize portal tooltips
document.querySelectorAll('.reference-badge-container').forEach(container => {
const reference = container.dataset.reference;
const messageId = container.dataset.messageId;
const index = container.dataset.index;
let tooltipId = `tooltip-${messageId}-${index}`;
let tooltipContent = null;
let tooltipTimeout;
// Create tooltip element (initially hidden)
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.innerHTML = '<div class="animate-pulse">Loading...</div>';
document.body.appendChild(tooltip);
return tooltip;
}
container.addEventListener('mouseenter', function () {
// Clear any existing timeout
if (tooltipTimeout) clearTimeout(tooltipTimeout);
// Get or create tooltip
let tooltip = document.getElementById(tooltipId);
if (!tooltip) tooltip = createTooltip();
// Position tooltip
const rect = container.getBoundingClientRect();
tooltip.style.top = `${rect.bottom + window.scrollY + 5}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
// Adjust position if it would overflow viewport
const tooltipRect = tooltip.getBoundingClientRect();
if (rect.left + tooltipRect.width > window.innerWidth - 20) {
tooltip.style.left = `${window.innerWidth - tooltipRect.width - 20 + window.scrollX}px`;
}
// Show tooltip
tooltip.classList.remove('hidden');
// Load content if needed
if (!tooltipContent) {
fetch(`/chat/reference/${encodeURIComponent(reference)}`)
.then(response => response.text())
.then(html => {
tooltipContent = html;
if (document.getElementById(tooltipId)) {
document.getElementById(tooltipId).innerHTML = html;
}
});
}
});
container.addEventListener('mouseleave', function () {
tooltipTimeout = setTimeout(() => {
const tooltip = document.getElementById(tooltipId);
if (tooltip) tooltip.classList.add('hidden');
}, 200);
});
});
</script>

View File

@@ -0,0 +1,3 @@
<div>{{entity.name}}</div>
<div>{{entity.description}}</div>
<div>{{entity.updated_at|datetimeformat(format="short", tz=user.timezone)}} </div>

View File

@@ -0,0 +1,27 @@
<div class="chat chat-end">
<div class="chat-bubble">
{{user_message.content}}
</div>
</div>
<div class="chat chat-start">
<div hx-ext="sse" sse-connect="/chat/response-stream?message_id={{user_message.id}}" sse-close="close_stream"
hx-swap="beforeend">
<div class="chat-bubble" sse-swap="chat_message">
<span class="loading loading-dots loading-sm loading-id-{{user_message.id}}"></span>
</div>
<div class="chat-footer opacity-50 max-w-[90%] flex-wrap" sse-swap="references">
</div>
</div>
</div>
<script>
document.body.addEventListener('htmx:sseBeforeMessage', (e) => {
const targetElement = e.detail.elt;
const loadingSpinner = targetElement.querySelector('.loading-id-{{user_message.id}}');
// Hiding the loading spinner before data is swapped in
if (loadingSpinner) {
loadingSpinner.style.display = 'none';
}
}
)
</script>

View File

@@ -0,0 +1,12 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<div class="container">
<h2 class="text-2xl font-bold mb-2">Text Contents</h2>
{% include "content/content_list.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,46 @@
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" id="text_content_cards">
{% for text_content in text_contents %}
<div class="card min-w-72 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center space-x-2">
<div class="flex-shrink-0">
{% if text_content.url %}
{% include "icons/globe_icon.html" %}
{% elif text_content.file_info %}
{% include "icons/document_icon.html" %}
{% else %}
{% include "icons/chat_icon.html" %}
{% endif %}
</div>
<h2 class="card-title truncate">
{% if text_content.url %}
<a href="{{ text_content.url}}">{{text_content.url}}</a>
{% elif text_content.file_info %}
{{text_content.file_info.file_name}}
{% else %}
{{text_content.text}}
{% endif %}
</h2>
</div>
<div class="flex items-center">
<p class="text-xs opacity-60">
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
</p>
<div class="flex gap-2">
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm">
{% 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">
{% include "icons/delete_icon.html" %}
</button>
</div>
</div>
<p class="mt-2">
{{ text_content.instructions }}
</p>
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,37 @@
{% extends "modal_base.html" %}
{% block form_attributes %}
hx-patch="/content/{{text_content.id}}"
hx-target="#text_content_cards"
hx-swap="outerHTML"
{% 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">Instructions</span>
<input type="text" name="instructions" value="{{ text_content.instructions}}" 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">
<label class="floating-label">
<span class="label-text">Text</span>
<textarea name="text" class="textarea textarea-bordered h-32 w-full">{{ text_content.text}}</textarea>
</label>
</div>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="btn btn-primary">
Save Changes
</button>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "body_base.html" %}
{% block main %}
<main class="flex justify-center grow mt-2 sm:mt-4 pb-10">
<div class="container">
<div class="grid grid-cols-1 lg:grid-cols-[auto_1fr] gap-4">
<!-- Documentation Menu -->
<aside class="bg-base-200 rounded-lg p-4">
{% include "documentation/menu.html" %}
</aside>
<!-- Main Content -->
<article
class="prose prose-sm md:prose-base prose-h1:mb-2 prose-h2:my-2 prose-p:my-2 prose-ul:my-2 prose-pre:my-2 flex mx-auto justify-center flex-col">
{% block article %}
{% endblock %}
</article>
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends 'documentation/base.html' %}
{% block article %}
<h1>Get Started with Minne</h1>
<p>Minne offers two installation options to suit your needs:</p>
<ol>
<li>
<strong>Hosted Version:</strong> Enjoy a hasslefree experience by signing up for the readytouse service.
Simply navigate to <code>/signup</code> to create an account.
</li>
<li>
<strong>Self-Hosted:</strong> Gain full control by running Minne on your own infrastructure. Visit
<a href="https://github.com/perstarkse/minne">GitHub</a> to download the latest release. After extracting the
release, open the <code>config.yaml</code> file and set the following configurations:
</li>
</ol>
<pre class="overflow-x-auto text-sm">
<code class="break-words whitespace-pre-wrap">
OPENAI_API_KEY: your_api_key
DB_ADDRESS: your_db_address
DB_USER: your_db_user
DB_PASSWORD: your_db_password
</code>
</pre>
<p>The database settings relate to a running instance of SurrealDB. You can opt for their cloud solution or run your
own instance.</p>
<p>Once your configuration is complete, start both the server and the worker. They can be hosted on separate
machines, with different resource requirements:</p>
<ul>
<li>
<strong>Server:</strong> Lightweight. A minimum of 1 core and 256MB of RAM is recommended.
</li>
<li>
<strong>Worker:</strong> Handles content parsing and creation of database entities. It's recommended to allocate at
least two cores and 1024 MB RAM. It will run on less but might run into constraints depending on the content being
parsed.
</li>
</ul>
<p>After launching the services, navigate to <code>&lt;your_url&gt;:3000/signup</code> to register. The first
account created will automatically receive admin permissions, allowing you to later disable further registrations
via the <code>/admin</code> page if desired.</p>
<p>From the homepage (<code>/</code>), you can:</p>
<ul>
<li>Submit content, including files, videos, and URLs for ingestion.</li>
<li>Monitor job statuses and manage your existing content.</li>
<li>Search your content or start a chat conversation for assistance.</li>
</ul>
<p>Visit the <code>/knowledge</code> page to view your content organized by different sections. This page also
provides a visual demonstration of the graph database structure, enhancing your understanding of content
relationships.</p>
<p>This streamlined setup ensures intuitive onboarding while offering robust customization options. Whether you are
a novice or an advanced user, Minne is designed to deliver a smooth experience and reliable performance.</p>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "documentation/base.html" %}
{% block article %}
<h1 class="text-3xl mb-2">Documentation</h1>
<p>
Personalised Knowledge Management (PKM) is a system designed to help individuals organise, store, and retrieve
information effectively. It empowers users to create a personalised workflow for managing knowledge, enabling
better decision-making and productivity.
</p>
<p>
This documentation will guide you through the core concepts, tools, and best practices for building and
maintaining your own PKM system.
</p>
<div class="card bg-base-200 rounded-lg shadow-md">
<div class="card-body">
<h3 class="card-title not-prose">Getting Started</h3>
<p>
To begin, explore the sections in the navigation menu. Each section provides detailed insights into
different
aspects of PKM, from foundational principles to advanced techniques.
</p>
<div class="card-actions">
<a href="/documentation/quick-start" class="btn btn-primary text-primary-content" hx-boost="true">Learn More</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
<ul class="menu bg-base-200 rounded-box w-full ">
<li><a hx-boost="true" class="{{'menu-active' if current_path=='/index' }}" href="/documentation">Start</a></li>
<li><a hx-boost="true" class="{{'menu-active' if current_path=='/get-started' }}"
href="/documentation/get-started">Get Started</a></li>
<li><a hx-boost="true" class="{{'menu-active' if current_path=='/mobile-friendly' }}"
href="/documentation/mobile-friendly">Mobile friendly</a></li>
<li><a hx-boost="true" class="{{'menu-active' if current_path=='/privacy-policy' }}"
href="/documentation/privacy-policy">Privacy Policy</a></li>
<li>
<details open>
<summary>Core Concepts</summary>
<ul>
<li><a hx-boost="true" href="/documentation/submenu1">What is PKM?</a></li>
<li><a hx-boost="true" href="/documentation/submenu2">Benefits of PKM</a></li>
</ul>
</details>
</li>
<li>
<details>
<summary>Tools & Techniques</summary>
<ul>
<li><a hx-boost="true" href="/documentation/tools">Tools for PKM</a></li>
<li><a hx-boost="true" href="/documentation/techniques">Effective Techniques</a></li>
</ul>
</details>
</li>
<li><a hx-boost="true" href="/documentation/faq">FAQ</a></li>
</ul>

View File

@@ -0,0 +1,20 @@
{% extends 'documentation/base.html' %}
{% block article %}
<h1>Mobile Friendly Ingression: How to Submit Content from iOS to Minne</h1>
<p>Minne is built with simplicity in mind. Whether you wish to save a file, capture a thought, or share a page,
submitting content is effortless. Our server provides API access that enables users to perform actions using a
personalized API key.</p>
<p>An iOS shortcut has been developed to streamline the process of sending content. To begin, navigate to
<code>/account</code> and generate an API key. Once created, you will see an option to download the iOS shortcut.
</p>
<p>After downloading the shortcut, update the "Get response from URL" authentication headers with your API key. If
you are self-hosting, ensure the URL is adjusted accordingly.</p>
<p>The shortcut integrates seamlessly with iOS. When you "share with Minne," you will be prompted to provide
instructions to the AI and to either choose an existing category or create a new one for your submission.</p>
<p>While an Android solution is in the works, for now you can add the web app to your home screen as a Progressive
Web App (PWA) for a similar mobile-friendly experience.</p>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends 'documentation/base.html' %}
{% block article %}
<h1>Privacy Policy</h1>
<p>We value your privacy and are committed to protecting your personal information. This policy
outlines how we handle your data and ensures transparency in our practices.</p>
<h2>Data Collection</h2>
<p>We only collect data that is necessary for the functionality of our services. Any data you upload to
our site remains your property and will not be shared with third parties unless required by law.</p>
<h2>Cookies</h2>
<p>We do not use cookies for tracking or analytics. The cookies we employ are strictly for session
management, ensuring a smooth and secure user experience.</p>
<h2>No Unnecessary Data Extraction</h2>
<p>We believe that unnecessary data extraction is unethical and a poor practice. We only collect the
minimum amount of data required to provide our services effectively, ensuring your privacy is respected at all
times.</p>
<h2>Your Rights</h2>
<p>You have the right to access, modify, or delete your data at any time. If you have any concerns
about how your data is handled, please contact us.</p>
<h2>Changes to This Policy</h2>
<p>We may update this privacy policy from time to time. Any changes will be posted on this page, and we
encourage you to review it periodically.</p>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="container justify-center flex-grow flex mx-auto mt-4">
<div class="flex flex-col space-y-4 text-center">
<h1 class="text-2xl font-bold text-error">
{{ status_code }}
</h1>
<p class="text-2xl my-4">{{ error }}</p>
<p class="text-base-content/60">{{ description }}</p>
<a href="/" class="btn btn-primary mt-8">Go Home</a>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,15 @@
<div id="gdpr-banner" class="fixed inset-x-0 mx-auto card max-w-(--breakpoint-sm) mb-2 bg-base-200 bottom-0">
<div class="card-body items-center text-center">
<p class="text-sm text-base-content"> We use cookies to enhance your experience. By continuing to visit this site,
you agree to
our use cookies.
<a href="/documentation/privacy-policy" class="link link-primary">Learn more</a>
</p>
<div class="card-actions justify-end mt-2">
<button class="btn btn-outline btn-sm text-base-content" hx-post="/gdpr/deny" hx-target="#gdpr-banner"
hx-swap="outerHTML">Deny</button>
<button class="btn btn-primary btn-sm" hx-post="/gdpr/accept" hx-target="#gdpr-banner"
hx-swap="outerHTML">Accept</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}Minne{% endblock %}</title>
<!-- <meta http-equiv=" refresh" content="4"> -->
<!-- Preload critical assets -->
<link rel="preload" href="/assets/htmx.min.js" as="script">
<link rel="preload" href="/assets/htmx-ext-sse.js" as="script">
<link rel="preload" href="/assets/style.css" as="style">
<!-- Core styles -->
<link rel="stylesheet" href="/assets/style.css">
<!-- Scripts -->
<script src="/assets/htmx.min.js" defer></script>
<script src="/assets/htmx-ext-sse.js" defer></script>
<script src="/assets/theme-toggle.js" defer></script>
<!-- Icons -->
<link rel="icon" href="/assets/icon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon.png" media="(device-width: 320px)">
<!-- PWA -->
<link rel="manifest" href="/assets/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
{% block head %}{% endblock %}
</head>
{% block body %}{% endblock %}
<script>
(function wait_for_htmx() {
if (window.htmx) {
htmx.config.globalViewTransitions = true;
} else {
setTimeout(wait_for_htmx, 50);
}
})();
</script>
</html>

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,6 @@
<svg class="w-3 h-3 ml-1 transition-transform" id="toggle-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>

After

Width:  |  Height:  |  Size: 572 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>

After

Width:  |  Height:  |  Size: 617 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>

After

Width:  |  Height:  |  Size: 679 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
</svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -0,0 +1,51 @@
<main class="hero grow flex justify-center ">
<div class="container hero-content text-center">
<div class="space-y-8">
<!-- Hero Section -->
<h1
class="text-5xl sm:text-6xl py-4 pt-10 font-extrabold bg-linear-to-r from-primary to-secondary text-transparent bg-clip-text font-satoshi">
Your Second Brain, Built to Remember
<div class="text-xl font-light mt-4">
Minne <span class="text-base-content opacity-70">/ˈmɪnɛ/ [Swedish: memory]</span>
</div>
</h1>
<p class="text-xl ">
Capture, connect, and retrieve your knowledge effortlessly with Minne
</p>
<!-- Features Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 my-12">
<div class="card bg-base-100 shadow-hover">
<div class="card-body items-center">
<div class="skeleton h-32 w-32 rounded-full"></div>
<h3 class="card-title text-xl">Easy Capture</h3>
<p>Save anything instantly - texts, links, images, and more</p>
</div>
</div>
<div class="card bg-base-100 shadow-hover">
<div class="card-body items-center">
<div class="skeleton h-32 w-32 rounded-full"></div>
<h3 class="card-title text-xl">Smart Analysis</h3>
<p>AI-powered content analysis and organization</p>
</div>
</div>
<div class="card bg-base-100 shadow-hover">
<div class="card-body items-center">
<div class="skeleton h-32 w-32 rounded-full"></div>
<h3 class="card-title text-xl">Knowledge Graph</h3>
<p>Visualize connections between your ideas</p>
</div>
</div>
</div>
<!-- CTA -->
<div class="space-y-4">
<div class="flex justify-center gap-4">
<a class="btn btn-primary btn-lg" hx-boost="true" href="/signup">Get Started</a>
<a class="btn btn-outline btn-lg" hx-boost="true" href="/documentation">Learn More</a>
</div>
</div>
</div>
</div>
</main>

View File

@@ -0,0 +1,11 @@
{% extends "body_base.html" %}
{% block main %}
{% if user %}
{% include 'index/signed_in/base.html' %}
{% else %}
{% include 'index/hero.html' %}
{% if not gdpr_accepted %}
{% include "gdpr.html" %}
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% block active_jobs_section %}
<ul id="active_jobs_section" class="list">
<div class="flex justify-center items-center gap-4">
<li class="py-4 text-center font-bold tracking-wide">Active Jobs</li>
<button class="cursor-pointer scale-75" hx-get="/active-jobs" hx-target="#active_jobs_section" hx-swap="outerHTML">
{% 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/globe_icon.html" %}
{% elif item.content.File %}
{% include "icons/document_icon.html" %}
{% else %}
{% include "icons/chat_icon.html" %}
{% endif %}
</div>
<div>
<div class="[&:before]:content-['Status:_'] [&:before]:opacity-60">
{% if item.status.InProgress %}
In Progress, attempt {{item.status.InProgress.attempts}}
{% else %}
{{item.status}}
{% endif %}
</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 class="btn disabled btn-square btn-ghost btn-sm"> -->
<!-- {% include "icons/edit_icon.html" %} -->
<!-- </button> -->
<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>
{% endblock %}

View File

@@ -0,0 +1,14 @@
<div class="flex justify-center grow mt-2 sm:mt-4 gap-6">
<div class="container">
{% include 'index/signed_in/searchbar.html' %}
{% include "index/signed_in/quick_actions.html" %}
<div class="grid grid-cols-1 md:grid-cols-2 shadow my-10">
{% include "index/signed_in/active_jobs.html" %}
{% include "index/signed_in/recent_content.html" %}
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
{% extends "modal_base.html" %}
{% block form_attributes %}
hx-post="/ingress-form"
enctype="multipart/form-data"
hx-target="#active_jobs_section"
hx-swap="outerHTML"
{% endblock %}
{% block modal_content %}
<h3 class="text-lg font-bold">Add new content</h3>
<div class="form-control">
<label class="floating-label">
<span>Instructions</span>
<textarea name="instructions" class="textarea w-full validator"
placeholder="Enter instructions for the AI here, help it understand what its seeing or how it should relate to the database"
required>{{ instructions }}</textarea>
<div class="validator-hint hidden">Instructions are required</div>
</label>
</div>
<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 ingress, it can be an URL or a text snippet">{{ content }}</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 />
<datalist id="category-list">
{% for category in user_categories %}
<option value="{{ category }}" />
{% endfor %}
</datalist>
<div class="validator-hint hidden">Category is required</div>
</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>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="btn btn-primary">
Save Changes
</button>
{% endblock %}

View File

@@ -0,0 +1,7 @@
<div class="flex gap-4 flex-col sm:flex-row">
<a class="btn btn-secondary" href="/knowledge" hx-boost="true">View Knowledge</a>
<a class="btn btn-accent" href="/content" hx-boost="true">View Content</a>
<a class="btn btn-accent" href="/chat" hx-boost="true">Chat</a>
<button class="btn btn-primary" hx-get="/ingress-form" hx-target="#modal" hx-swap="innerHTML">Add
Content</button>
</div>

View File

@@ -0,0 +1,42 @@
{% block latest_content_section %}
<ul id="latest_content_section" class="list">
<li class="py-4 text-center font-bold tracking-wide">Recently added content</li>
{% for item in latest_text_contents %}
<li class="list-row">
<div class="bg-accent rounded-box size-10 flex justify-center items-center text-accent-content">
{% if item.url %}
{% include "icons/globe_icon.html" %}
{% elif item.file_info %}
{% include "icons/document_icon.html" %}
{% else %}
{% include "icons/chat_icon.html" %}
{% endif %}
</div>
<div>
<div class="truncate max-w-[160px]">
{% if item.url %}
{{item.url}}
{% elif item.file_info%}
{{item.file_info.file_name}}
{% else %}
{{item.text}}
{% endif %}
</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-['Instructions:_'] [&:before]:opacity-60">
{{item.instructions}}
</p>
<button class="btn btn-disabled btn-square btn-ghost btn-sm">
{% include "icons/edit_icon.html" %}
</button>
<button hx-delete="/text-content/{{item.id}}" hx-target="#latest_content_section" hx-swap="outerHTML"
class="btn btn-square btn-ghost btn-sm">
{% include "icons/delete_icon.html" %}
</button>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,32 @@
<div class="mx-auto mb-6">
<div class="card bg-base-200 shadow">
<div class="card-body">
<h2 class="card-title">Result</h2>
<div class="prose !max-w-none">
{{ answer_content | safe}}
</div>
{% if answer_references %}
<div class="mt-4">
<h2 class="card-title mb-2">References</h2>
<div class="flex flex-wrap gap-2 max-w-full">
{% for ref in answer_references %}
<div class="tooltip" data-tip="More info about {{ ref }}">
<button class="badge truncate badge-outline cursor-pointer text-gray-500 hover:text-gray-700">
{{ ref }}
</button>
</div>
{% endfor %}
</div>
</div>{% endif %}
<div class="mt-4">
<form hx-post="/initialized-chat" hx-target="body" hx-swap="outerHTML" method="POST"
class="flex items-center space-x-4">
<input type="hidden" name="user_query" value="{{ user_query }}">
<input type="hidden" name="llm_response" value="{{ answer_content }}">
<input type="hidden" name="references" value="{{ answer_references }}">
<button type="submit" class="btn btn-primary">Continue with chat</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,8 @@
<h2 class="font-bold mb-2">
Search your content
</h2>
<input type="text" placeholder="Search your knowledge base" class="input input-bordered w-full" name="query"
hx-get="/search" hx-target="#search-results" />
<div id="search-results" class="mt-4">
<!-- Results will be populated here by HTMX -->
</div>

View File

@@ -0,0 +1,19 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<div class="container">
<h2 class="text-2xl font-bold mb-2">Entities</h2>
{% include "knowledge/entity_list.html" %}
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
{% include "knowledge/relationship_table.html" %}
<div class="rounded-box overflow-clip mt-10 shadow">
{{plot_html|safe}}
</div>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "modal_base.html" %}
{% block form_attributes %}
hx-patch="/knowledge-entity/{{entity.id}}"
hx-target="#entity-list"
hx-swap="outerHTML"
{% endblock %}
{% block modal_content %}
<h3 class="text-lg font-bold">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>
</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">
<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>
{% endfor %}
</select>
</div>
<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>
</div>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="btn btn-primary">
Save Changes
</button>
{% endblock %}

View File

@@ -0,0 +1,25 @@
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" id="entity-list">
{% for entity in entities %}
<div class="card min-w-72 bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">{{entity.name}}
<span class="badge badge-xs badge-primary">{{entity.entity_type}}</span>
</h2>
<div class="flex justify-between items-center">
<p>{{entity.updated_at | datetimeformat(format="short", tz=user.timezeone)}}</p>
<div>
<button hx-get="/knowledge-entity/{{entity.id}}" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm">
{% include "icons/edit_icon.html" %}
</button>
<button hx-delete="/knowledge-entity/{{entity.id}}" hx-target="#entity-list" hx-swap="outerHTML"
class="btn btn-square btn-ghost btn-sm">
{% include "icons/delete_icon.html" %}
</button>
</div>
</div>
<p>{{entity.description}}</p>
</div>
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,85 @@
<div id="relationship_table_section"
class="overflow-x-auto shadow rounded-box border border-base-content/5 bg-base-100">
<table class="table">
<thead>
<tr>
<th>Origin</th>
<th>Target</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for relationship in relationships %}
<tr>
<!-- Origin column -->
<td>
{% for entity in entities if entity.id == relationship.in %}
<span> {{ entity.name }}
</span>
{% else %}
{{ relationship.in }}
{% endfor %}
</td>
<!-- Target column -->
<td>
{% for entity in entities if entity.id == relationship.out %}
<span>
{{ entity.name }}
</span>
{% else %}
{{ relationship.out }}
{% endfor %}
</td>
<td>{{ relationship.metadata.relationship_type }}</td>
<td>
<button class="btn btn-sm btn-outline" hx-delete="/knowledge-relationship/{{ relationship.id }}"
hx-target="#relationship_table_section" hx-swap="outerHTML">
{% include "icons/delete_icon.html" %}
</button>
</td>
</tr>
{% endfor %}
<!-- New linking row -->
<tr id="new_relationship">
<td>
<select name="in_" class="select select-bordered w-full new_relationship_input">
<option disabled selected>Select Origin</option>
{% for entity in entities %}
<option value="{{ entity.id }}">
{{ entity.name }}
</option>
{% endfor %}
</select>
</td>
<td>
<select name="out" class="select select-bordered w-full new_relationship_input">
<option disabled selected>Select Target</option>
{% for entity in entities %}
<option value="{{ entity.id }}">{{ entity.name }}</option>
{% endfor %}
</select>
</td>
<td>
<input id="relationship_type_input" name="relationship_type" type="text" placeholder="RelatedTo"
class="input input-bordered 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">
Save
</button>
</td>
</tr>
</tbody>
</table>
</div>
<script>
document.getElementById('relationship_type_input').addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault(); // Prevent form submission if within a form
document.getElementById('save_relationship_button').click();
}
});
</script>

View File

@@ -0,0 +1,38 @@
<dialog id="body_modal" class="modal">
<div class="modal-box">
<form id="modal_form" {% block form_attributes %}{% endblock %}>
<div class="flex flex-col space-y-4">
{% block modal_content %} <!-- Form fields go here in child templates -->
{% endblock %}
</div>
<div class="modal-action">
<!-- Close button (always visible) -->
<button type="button" class="btn" onclick="document.getElementById('body_modal').close()">
Close
</button>
<!-- Primary actions block -->
{% block primary_actions %}
<!-- Submit/Save buttons go here in child templates -->
{% endblock %}
</div>
</form>
</div>
<script>
// Auto-open modal when injected
document.getElementById('body_modal').showModal();
// Close modal on successful form submission
document.getElementById('modal_form').addEventListener('htmx:afterRequest', (evt) => {
if (evt.detail.successful) {
document.getElementById('body_modal').close();
}
});
// Clear modal content on close to prevent browser back from reopening it
document.getElementById('body_modal').addEventListener('close', (evt) => {
evt.target.innerHTML = '';
});
</script>
</dialog>

View File

@@ -0,0 +1,29 @@
<nav class="navbar bg-base-200 !p-0">
<div class="container flex mx-auto">
<div class="flex-1 flex items-center">
<a class="text-2xl p-2 text-primary font-bold" href="/" hx-boost="true">Minne</a>
</div>
<div>
<ul class="menu menu-horizontal px-1">
{% include "theme_toggle.html" %}
<li><a hx-boost="true" class="" href="/documentation">Docs</a></li>
{% if user %}
<li>
<details>
<summary>Account</summary>
<ul class="bg-base-100 rounded-t-none p-2 z-50">
<li><a hx-boost="true" href="/account">Account</a></li>
{% if user.admin %}
<li><a hx-boost="true" href="/admin">Admin</a></li>
{% endif %}
<li><a hx-boost="true" href="/signout">Sign out</a></li>
</ul>
</details>
</li>
{% else %}
<li><a hx-boost="true" class="" href="/signin">Sign in</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@@ -0,0 +1,41 @@
<!-- Theme switch script -->
<script>
const initializeTheme = () => {
console.log("Initializing theme toggle...");
const themeToggle = document.querySelector('.theme-controller');
if (!themeToggle) {
console.log("Theme toggle not found.");
return;
}
// Detect system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Initialize theme from local storage or system preference
const savedTheme = localStorage.getItem('theme');
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', initialTheme);
themeToggle.checked = initialTheme === 'dark';
// Update theme and local storage on toggle
themeToggle.addEventListener('change', () => {
const theme = themeToggle.checked ? 'dark' : 'light';
console.log("Theme switched to:", theme);
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
console.log("Theme toggle initialized.");
};
// Run the initialization after the DOM is fully loaded
document.addEventListener('DOMContentLoaded', () => {
console.log("DOM fully loaded. Initializing theme toggle...");
initializeTheme();
});
// Reinitialize theme toggle after HTMX swaps
document.addEventListener('htmx:afterSwap', initializeTheme);
document.addEventListener('htmx:afterSettle', initializeTheme);
</script>

View File

@@ -0,0 +1,9 @@
<div class="border-">
<div class="chat chat-start">
<div class="chat-bubble">
{{result}}
<hr />
{{references}}
</div>
</div>
</div>

View File

@@ -0,0 +1,18 @@
<label class="swap swap-rotate mr-1">
<!-- this hidden checkbox controls the state -->
<input type="checkbox" class="theme-controller" value="dark" />
<!-- sun icon -->
<svg width="20" height="20" class="swap-off h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" />
</svg>
<!-- moon icon -->
<svg width="20" height="20" class="swap-on h-5 w-5 fill-current" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24">
<path
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z" />
</svg>
</label>