refactor: better separation of dependencies to crates

node stuff to html crate only
This commit is contained in:
Per Stark
2025-04-04 12:50:38 +02:00
parent 20fc43638b
commit 5bc48fb30b
160 changed files with 231 additions and 337 deletions

40
html-router/Cargo.toml Normal file
View File

@@ -0,0 +1,40 @@
[package]
name = "html-router"
version = "0.1.0"
edition = "2021"
[dependencies]
# Workspace dependencies
tokio = { workspace = true }
serde = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
async-openai = { workspace = true }
thiserror = { workspace = true }
axum-htmx = "0.6.0"
axum_session = "0.14.4"
axum_session_auth = "0.14.1"
axum_session_surreal = "0.2.1"
axum_typed_multipart = "0.12.1"
futures = "0.3.31"
tempfile = "3.12.0"
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"
tower-http = { version = "0.6.2", features = ["fs"] }
chrono-tz = "0.10.1"
tower-serve-static = "0.1.1"
include_dir = "0.7.4"
common = { path = "../common" }
composite-retrieval = { path = "../composite-retrieval" }
[build-dependencies]
minijinja-embed = { version = "2.8.0" }

61
html-router/app.css Normal file
View File

@@ -0,0 +1,61 @@
@import 'tailwindcss' source(none);
@plugin "daisyui" {
exclude: rootscrollbargutter;
}
@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 {
body {
@apply font-satoshi;
}
html {
scrollbar-gutter: stable;
}
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
/* satoshi.css */
@font-face {
font-family: 'Satoshi';
src: url('fonts/Satoshi-Variable.woff2') format('woff2'),
url('fonts/Satoshi-Variable.woff') format('woff'),
url('fonts/Satoshi-Variable.ttf') format('truetype');
font-weight: 300 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Satoshi';
src: url('fonts/Satoshi-VariableItalic.woff2') format('woff2'),
url('fonts/Satoshi-VariableItalic.woff') format('woff'),
url('fonts/Satoshi-VariableItalic.ttf') format('truetype');
font-weight: 300 900;
font-style: italic;
font-display: swap;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e)}function v(e){return g.getInternalData(e).sseEventSource!=null}})();

1
html-router/assets/htmx-sse.min.js vendored Normal file
View File

@@ -0,0 +1 @@
(function(){var g;htmx.defineExtension("sse",{init:function(e){g=e;if(htmx.createEventSource==undefined){htmx.createEventSource=t}},getSelectors:function(){return["[sse-connect]","[data-sse-connect]","[sse-swap]","[data-sse-swap]"]},onEvent:function(e,t){var r=t.target||t.detail.elt;switch(e){case"htmx:beforeCleanupElement":var n=g.getInternalData(r);var s=n.sseEventSource;if(s){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"nodeReplaced"});n.sseEventSource.close()}return;case"htmx:afterProcessNode":i(r)}}});function t(e){return new EventSource(e,{withCredentials:true})}function a(n){if(g.getAttributeValue(n,"sse-swap")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var t=g.getAttributeValue(n,"sse-swap");var r=t.split(",");for(var i=0;i<r.length;i++){const u=r[i].trim();const c=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(u,c);return}if(!g.triggerEvent(n,"htmx:sseBeforeMessage",e)){return}f(n,e.data);g.triggerEvent(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=c;a.addEventListener(u,c)}}if(g.getAttributeValue(n,"hx-trigger")){var s=g.getClosestMatch(n,v);if(s==null){return null}var e=g.getInternalData(s);var a=e.sseEventSource;var o=g.getTriggerSpecs(n);o.forEach(function(t){if(t.trigger.slice(0,4)!=="sse:"){return}var r=function(e){if(l(s)){return}if(!g.bodyContains(n)){a.removeEventListener(t.trigger.slice(4),r)}htmx.trigger(n,t.trigger,e);htmx.trigger(n,"htmx:sseMessage",e)};g.getInternalData(n).sseEventListener=r;a.addEventListener(t.trigger.slice(4),r)})}}function i(e,t){if(e==null){return null}if(g.getAttributeValue(e,"sse-connect")){var r=g.getAttributeValue(e,"sse-connect");if(r==null){return}n(e,r,t)}a(e)}function n(r,e,n){var s=htmx.createEventSource(e);s.onerror=function(e){g.triggerErrorEvent(r,"htmx:sseError",{error:e,source:s});if(l(r)){return}if(s.readyState===EventSource.CLOSED){n=n||0;n=Math.max(Math.min(n*2,128),1);var t=n*500;window.setTimeout(function(){i(r,n)},t)}};s.onopen=function(e){g.triggerEvent(r,"htmx:sseOpen",{source:s});if(n&&n>0){const t=r.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]");for(let e=0;e<t.length;e++){a(t[e])}n=0}};g.getInternalData(r).sseEventSource=s;var t=g.getAttributeValue(r,"sse-close");if(t){s.addEventListener(t,function(){g.triggerEvent(r,"htmx:sseClose",{source:s,type:"message"});s.close()})}}function l(e){if(!g.bodyContains(e)){var t=g.getInternalData(e).sseEventSource;if(t!=undefined){g.triggerEvent(e,"htmx:sseClose",{source:t,type:"nodeMissing"});t.close();return true}}return false}function f(t,r){g.withExtensions(t,function(e){r=e.transformResponse(r,null,t)});var e=g.getSwapSpecification(t);var n=g.getTarget(t);g.swap(n,r,e)}function v(e){return g.getInternalData(e).sseEventSource!=null}})();

1
html-router/assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -0,0 +1,13 @@
{
"name": "minne",
"short_name": "minne",
"start_url": "/",
"display": "standalone",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,32 @@
const initializeTheme = () => {
const themeToggle = document.querySelector('.theme-controller');
if (!themeToggle) {
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';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
};
// Run the initialization after the DOM is fully loaded
document.addEventListener('DOMContentLoaded', () => {
initializeTheme();
});
// Reinitialize theme toggle after HTMX swaps
document.addEventListener('htmx:afterSwap', initializeTheme);
document.addEventListener('htmx:afterSettle', initializeTheme);

12
html-router/build.rs Normal file
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.");
}
}

1019
html-router/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
html-router/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"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"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@tailwindcss/cli": "^4.1.2",
"@tailwindcss/typography": "^0.5.16",
"daisyui": "^5.0.12",
"tailwindcss": "^4.1.2"
}
}

View File

@@ -0,0 +1,43 @@
use common::storage::db::SurrealDbClient;
use common::utils::template_engine::{ProvidesTemplateEngine, TemplateEngine};
use common::{create_template_engine, storage::db::ProvidesDb};
use std::sync::Arc;
use tracing::debug;
use crate::{OpenAIClientType, SessionStoreType};
#[derive(Clone)]
pub struct HtmlState {
pub db: Arc<SurrealDbClient>,
pub openai_client: Arc<OpenAIClientType>,
pub templates: Arc<TemplateEngine>,
pub session_store: Arc<SessionStoreType>,
}
impl HtmlState {
pub fn new_with_resources(
db: Arc<SurrealDbClient>,
openai_client: Arc<OpenAIClientType>,
session_store: Arc<SessionStoreType>,
) -> Result<Self, Box<dyn std::error::Error>> {
let template_engine = create_template_engine!("templates");
debug!("Template engine created for html_router.");
Ok(Self {
db,
openai_client,
session_store,
templates: Arc::new(template_engine),
})
}
}
impl ProvidesDb for HtmlState {
fn db(&self) -> &Arc<SurrealDbClient> {
&self.db
}
}
impl ProvidesTemplateEngine for HtmlState {
fn template_engine(&self) -> &Arc<TemplateEngine> {
&self.templates
}
}

39
html-router/src/lib.rs Normal file
View File

@@ -0,0 +1,39 @@
pub mod html_state;
pub mod middlewares;
pub mod router_factory;
pub mod routes;
use axum::{extract::FromRef, Router};
use axum_session::{Session, SessionStore};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use common::storage::types::user::User;
use html_state::HtmlState;
use router_factory::RouterFactory;
use surrealdb::{engine::any::Any, Surreal};
pub type AuthSessionType = AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>;
pub type SessionType = Session<SessionSurrealPool<Any>>;
pub type SessionStoreType = SessionStore<SessionSurrealPool<Any>>;
pub type OpenAIClientType = async_openai::Client<async_openai::config::OpenAIConfig>;
/// Html routes
pub fn html_routes<S>(app_state: &HtmlState) -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
RouterFactory::new(app_state)
.add_public_routes(routes::index::public_router())
.add_public_routes(routes::auth::router())
.with_public_assets("/assets", "assets/")
.add_protected_routes(routes::index::protected_router())
.add_protected_routes(routes::search::router())
.add_protected_routes(routes::account::router())
.add_protected_routes(routes::admin::router())
.add_protected_routes(routes::chat::router())
.add_protected_routes(routes::content::router())
.add_protected_routes(routes::knowledge::router())
.add_protected_routes(routes::ingestion::router())
.build()
}

View File

@@ -0,0 +1,30 @@
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use common::storage::{db::ProvidesDb, types::analytics::Analytics};
use crate::SessionType;
/// Middleware to count unique visitors and page loads
pub async fn analytics_middleware<S>(
State(state): State<S>,
session: SessionType,
request: Request,
next: Next,
) -> Response
where
S: ProvidesDb + Clone + Send + Sync + 'static,
{
let path = request.uri().path();
if !path.starts_with("/assets") && !path.contains('.') {
if !session.get::<bool>("counted_visitor").unwrap_or(false) {
let _ = Analytics::increment_visitors(state.db()).await;
session.set("counted_visitor", true);
}
let _ = Analytics::increment_page_loads(state.db()).await;
}
next.run(request).await
}

View File

@@ -0,0 +1,50 @@
use axum::{
async_trait,
extract::{FromRequestParts, Request},
http::request::Parts,
middleware::Next,
response::{IntoResponse, Response},
};
use common::storage::types::user::User;
use crate::AuthSessionType;
use super::response_middleware::TemplateResponse;
#[derive(Debug, Clone)]
pub struct RequireUser(pub User);
// Implement FromRequestParts for RequireUser
#[async_trait]
impl<S> FromRequestParts<S> for RequireUser
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<User>()
.cloned()
.map(RequireUser)
.ok_or_else(|| TemplateResponse::redirect("/signin").into_response())
}
}
// Auth middleware that adds the user to extensions
pub async fn require_auth(auth: AuthSessionType, mut request: Request, next: Next) -> Response {
// Check if user is authenticated
match auth.current_user {
Some(user) => {
// Add user to request extensions
request.extensions_mut().insert(user);
// Continue to the handler
next.run(request).await
}
None => {
// Redirect to login
TemplateResponse::redirect("/signin").into_response()
}
}
}

View File

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

View File

@@ -0,0 +1,208 @@
use axum::{
extract::State,
http::StatusCode,
response::{Html, IntoResponse, Response},
Extension,
};
use common::{error::AppError, utils::template_engine::ProvidesTemplateEngine};
use minijinja::{context, Value};
use serde::Serialize;
use tracing::error;
#[derive(Clone)]
pub enum TemplateKind {
Full(String),
Partial(String, String),
Error(StatusCode),
Redirect(String),
}
#[derive(Clone)]
pub struct TemplateResponse {
template_kind: TemplateKind,
context: Value,
}
impl TemplateResponse {
pub fn new_template<T: Serialize>(name: impl Into<String>, context: T) -> Self {
Self {
template_kind: TemplateKind::Full(name.into()),
context: Value::from_serialize(&context),
}
}
pub fn new_partial<T: Serialize>(
template: impl Into<String>,
block: impl Into<String>,
context: T,
) -> Self {
Self {
template_kind: TemplateKind::Partial(template.into(), block.into()),
context: Value::from_serialize(&context),
}
}
pub fn error(status: StatusCode, title: &str, error: &str, description: &str) -> Self {
let ctx = context! {
status_code => status.as_u16(),
title => title,
error => error,
description => description
};
Self {
template_kind: TemplateKind::Error(status),
context: ctx,
}
}
pub fn not_found() -> Self {
Self::error(
StatusCode::NOT_FOUND,
"Page Not Found",
"Not Found",
"The page you're looking for doesn't exist or was removed.",
)
}
pub fn server_error() -> Self {
Self::error(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal Server Error",
"Internal Server Error",
"Something went wrong on our end.",
)
}
pub fn unauthorized() -> Self {
Self::error(
StatusCode::UNAUTHORIZED,
"Unauthorized",
"Access Denied",
"You need to be logged in to access this page.",
)
}
pub fn bad_request(message: &str) -> Self {
Self::error(
StatusCode::BAD_REQUEST,
"Bad Request",
"Bad Request",
message,
)
}
pub fn redirect(path: impl Into<String>) -> Self {
Self {
template_kind: TemplateKind::Redirect(path.into()),
context: Value::from_serialize(()),
}
}
}
impl IntoResponse for TemplateResponse {
fn into_response(self) -> Response {
Extension(self).into_response()
}
}
pub async fn with_template_response<S>(State(state): State<S>, response: Response) -> Response
where
S: ProvidesTemplateEngine + Clone + Send + Sync + 'static,
{
if let Some(template_response) = response.extensions().get::<TemplateResponse>().cloned() {
let template_engine = state.template_engine();
match &template_response.template_kind {
TemplateKind::Full(name) => {
match template_engine.render(name, &template_response.context) {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render template '{}': {:?}", name, e);
(StatusCode::INTERNAL_SERVER_ERROR, fallback_error()).into_response()
}
}
}
TemplateKind::Partial(template, block) => {
match template_engine.render_block(template, block, &template_response.context) {
Ok(html) => Html(html).into_response(),
Err(e) => {
error!("Failed to render block '{}/{}': {:?}", template, block, e);
(StatusCode::INTERNAL_SERVER_ERROR, fallback_error()).into_response()
}
}
}
TemplateKind::Error(status) => {
match template_engine.render("errors/error.html", &template_response.context) {
Ok(html) => (*status, Html(html)).into_response(),
Err(e) => {
error!("Failed to render error template: {:?}", e);
(*status, fallback_error()).into_response()
}
}
}
TemplateKind::Redirect(path) => {
(StatusCode::OK, [(axum_htmx::HX_REDIRECT, path.clone())], "").into_response()
}
}
} else {
response
}
}
#[derive(Debug)]
pub enum HtmlError {
AppError(AppError),
TemplateError(String),
}
impl From<AppError> for HtmlError {
fn from(err: AppError) -> Self {
HtmlError::AppError(err)
}
}
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) => 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

@@ -0,0 +1,189 @@
use axum::{
extract::FromRef,
middleware::{from_fn_with_state, map_response_with_state},
Router,
};
use axum_session::SessionLayer;
use axum_session_auth::{AuthConfig, AuthSessionLayer};
use axum_session_surreal::SessionSurrealPool;
use common::storage::types::user::User;
use surrealdb::{engine::any::Any, Surreal};
use crate::{
html_state::HtmlState,
middlewares::{
analytics_middleware::analytics_middleware, auth_middleware::require_auth,
response_middleware::with_template_response,
},
};
#[macro_export]
macro_rules! create_asset_service {
// Takes the relative path to the asset directory
($relative_path:expr) => {{
#[cfg(debug_assertions)]
{
let crate_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let assets_path = crate_dir.join($relative_path);
tracing::debug!("Assets: Serving from filesystem: {:?}", assets_path);
tower_http::services::ServeDir::new(assets_path)
}
#[cfg(not(debug_assertions))]
{
tracing::debug!("Assets: Serving embedded directory");
static ASSETS_DIR: include_dir::Dir<'static> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets");
tower_serve_static::ServeDir::new(&ASSETS_DIR)
}
}};
}
pub type MiddleWareVecType<S> = Vec<Box<dyn FnOnce(Router<S>) -> Router<S> + Send>>;
pub struct RouterFactory<S> {
app_state: HtmlState,
public_routers: Vec<Router<S>>,
protected_routers: Vec<Router<S>>,
nested_routes: Vec<(String, Router<S>)>,
nested_protected_routes: Vec<(String, Router<S>)>,
custom_middleware: MiddleWareVecType<S>,
public_assets_config: Option<AssetsConfig>,
}
struct AssetsConfig {
path: String, // URL path for assets
directory: String, // Directory on disk
}
impl<S> RouterFactory<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
pub fn new(app_state: &HtmlState) -> Self {
Self {
app_state: app_state.to_owned(),
public_routers: Vec::new(),
protected_routers: Vec::new(),
nested_routes: Vec::new(),
nested_protected_routes: Vec::new(),
custom_middleware: Vec::new(),
public_assets_config: None,
}
}
// Add a serving of assets
pub fn with_public_assets(mut self, path: &str, directory: &str) -> Self {
self.public_assets_config = Some(AssetsConfig {
path: path.to_string(),
directory: directory.to_string(),
});
self
}
// Add a public router that will be merged at the root level
pub fn add_public_routes(mut self, routes: Router<S>) -> Self {
self.public_routers.push(routes);
self
}
// Add a protected router that will be merged at the root level
pub fn add_protected_routes(mut self, routes: Router<S>) -> Self {
self.protected_routers.push(routes);
self
}
// Nest a public router under a path prefix
pub fn nest_public_routes(mut self, path: &str, routes: Router<S>) -> Self {
self.nested_routes.push((path.to_string(), routes));
self
}
// Nest a protected router under a path prefix
pub fn nest_protected_routes(mut self, path: &str, routes: Router<S>) -> Self {
self.nested_protected_routes
.push((path.to_string(), routes));
self
}
// Add custom middleware to be applied before the standard ones
pub fn with_middleware<F>(mut self, middleware_fn: F) -> Self
where
F: FnOnce(Router<S>) -> Router<S> + Send + 'static,
{
self.custom_middleware.push(Box::new(middleware_fn));
self
}
pub fn build(self) -> Router<S> {
// Start with an empty router
let mut public_router = Router::new();
// Merge all public routers
for router in self.public_routers {
public_router = public_router.merge(router);
}
// Add nested public routes
for (path, router) in self.nested_routes {
public_router = public_router.nest(&path, router);
}
// Add public assets to public router
if let Some(assets_config) = self.public_assets_config {
// Call the macro using the stored relative directory path
let asset_service = create_asset_service!(&assets_config.directory);
// Nest the resulting service under the stored URL path
public_router = public_router.nest_service(&assets_config.path, asset_service);
}
// Start with an empty protected router
let mut protected_router = Router::new();
// Check if there are any protected routers
let has_protected_routes =
!self.protected_routers.is_empty() || !self.nested_protected_routes.is_empty();
// Merge root-level protected routers
for router in self.protected_routers {
protected_router = protected_router.merge(router);
}
// Nest protected routers
for (path, router) in self.nested_protected_routes {
protected_router = protected_router.nest(&path, router);
}
// Apply auth middleware
if has_protected_routes {
protected_router = protected_router
.route_layer(from_fn_with_state(self.app_state.clone(), require_auth));
}
// Combine public and protected routes
let mut router = Router::new().merge(public_router).merge(protected_router);
// Apply custom middleware in order they were added
for middleware_fn in self.custom_middleware {
router = middleware_fn(router);
}
// Apply common middleware
router
.layer(from_fn_with_state(
self.app_state.clone(),
analytics_middleware::<HtmlState>,
))
.layer(map_response_with_state(
self.app_state.clone(),
with_template_response::<HtmlState>,
))
.layer(
AuthSessionLayer::<User, String, SessionSurrealPool<Any>, Surreal<Any>>::new(Some(
self.app_state.db.client.clone(),
))
.with_config(AuthConfig::<String>::default()),
)
.layer(SessionLayer::new((*self.app_state.session_store).clone()))
}
}

View File

@@ -0,0 +1,143 @@
use axum::{extract::State, response::IntoResponse, Form};
use chrono_tz::TZ_VARIANTS;
use serde::{Deserialize, Serialize};
use crate::{
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
AuthSessionType,
};
use common::storage::types::user::User;
use crate::html_state::HtmlState;
#[derive(Serialize)]
pub struct AccountPageData {
user: User,
timezones: Vec<String>,
}
pub async fn show_account_page(
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let timezones = TZ_VARIANTS.iter().map(|tz| tz.to_string()).collect();
Ok(TemplateResponse::new_template(
"auth/account_settings.html",
AccountPageData { user, timezones },
))
}
pub async fn set_api_key(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
auth: AuthSessionType,
) -> Result<impl IntoResponse, HtmlError> {
// Generate and set the API key
let api_key = User::set_api_key(&user.id, &state.db).await?;
// Clear the cache so new requests have access to the user with api key
auth.cache_clear_user(user.id.to_string());
// Update the user's API key
let updated_user = User {
api_key: Some(api_key),
..user.clone()
};
// Render the API key section block
Ok(TemplateResponse::new_partial(
"auth/account_settings.html",
"api_key_section",
AccountPageData {
user: updated_user,
timezones: vec![],
},
))
}
pub async fn delete_account(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
auth: AuthSessionType,
) -> Result<impl IntoResponse, HtmlError> {
state.db.delete_item::<User>(&user.id).await?;
auth.logout_user();
auth.session.destroy();
Ok(TemplateResponse::redirect("/"))
}
#[derive(Deserialize)]
pub struct UpdateTimezoneForm {
timezone: String,
}
pub async fn update_timezone(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
auth: AuthSessionType,
Form(form): Form<UpdateTimezoneForm>,
) -> Result<impl IntoResponse, HtmlError> {
User::update_timezone(&user.id, &form.timezone, &state.db).await?;
// Clear the cache
auth.cache_clear_user(user.id.to_string());
// Update the user's API key
let updated_user = User {
timezone: form.timezone,
..user.clone()
};
let timezones = TZ_VARIANTS.iter().map(|tz| tz.to_string()).collect();
// Render the API key section block
Ok(TemplateResponse::new_partial(
"auth/account_settings.html",
"timezone_section",
AccountPageData {
user: updated_user,
timezones,
},
))
}
pub async fn show_change_password(
RequireUser(_user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
Ok(TemplateResponse::new_template(
"auth/change_password_form.html",
(),
))
}
#[derive(Deserialize)]
pub struct NewPasswordForm {
old_password: String,
new_password: String,
}
pub async fn change_password(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
auth: AuthSessionType,
Form(form): Form<NewPasswordForm>,
) -> Result<impl IntoResponse, HtmlError> {
// Authenticate to make sure the password matches
let authenticated_user = User::authenticate(&user.email, &form.old_password, &state.db).await?;
User::patch_password(&authenticated_user.email, &form.new_password, &state.db).await?;
auth.cache_clear_user(user.id);
Ok(TemplateResponse::new_partial(
"auth/account_settings.html",
"change_password_section",
(),
))
}

View File

@@ -0,0 +1,24 @@
mod handlers;
use axum::{
extract::FromRef,
routing::{delete, get, patch, post},
Router,
};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/account", get(handlers::show_account_page))
.route("/set-api-key", post(handlers::set_api_key))
.route("/update-timezone", patch(handlers::update_timezone))
.route(
"/change-password",
get(handlers::show_change_password).patch(handlers::change_password),
)
.route("/delete-account", delete(handlers::delete_account))
}

View File

@@ -0,0 +1,259 @@
use axum::{extract::State, response::IntoResponse, Form};
use serde::{Deserialize, Serialize};
use common::storage::types::{
analytics::Analytics,
system_prompts::{DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT, DEFAULT_QUERY_SYSTEM_PROMPT},
system_settings::SystemSettings,
user::User,
};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
#[derive(Serialize)]
pub struct AdminPanelData {
user: User,
settings: SystemSettings,
analytics: Analytics,
users: i64,
default_query_prompt: String,
}
pub async fn show_admin_panel(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let settings = SystemSettings::get_current(&state.db).await?;
let analytics = Analytics::get_current(&state.db).await?;
let users_count = Analytics::get_users_amount(&state.db).await?;
Ok(TemplateResponse::new_template(
"auth/admin_panel.html",
AdminPanelData {
user,
settings,
analytics,
users: users_count,
default_query_prompt: DEFAULT_QUERY_SYSTEM_PROMPT.to_string(),
},
))
}
fn checkbox_to_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: serde::Deserializer<'de>,
{
match String::deserialize(deserializer) {
Ok(string) => Ok(string == "on"),
Err(_) => Ok(false),
}
}
#[derive(Deserialize)]
pub struct RegistrationToggleInput {
#[serde(default)]
#[serde(deserialize_with = "checkbox_to_bool")]
registration_open: bool,
}
#[derive(Serialize)]
pub struct RegistrationToggleData {
settings: SystemSettings,
}
pub async fn toggle_registration_status(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(input): Form<RegistrationToggleInput>,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let current_settings = SystemSettings::get_current(&state.db).await?;
let new_settings = SystemSettings {
registrations_enabled: input.registration_open,
..current_settings.clone()
};
SystemSettings::update(&state.db, new_settings.clone()).await?;
Ok(TemplateResponse::new_partial(
"auth/admin_panel.html",
"registration_status_input",
RegistrationToggleData {
settings: new_settings,
},
))
}
#[derive(Deserialize)]
pub struct ModelSettingsInput {
query_model: String,
processing_model: String,
}
#[derive(Serialize)]
pub struct ModelSettingsData {
settings: SystemSettings,
}
pub async fn update_model_settings(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(input): Form<ModelSettingsInput>,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let current_settings = SystemSettings::get_current(&state.db).await?;
let new_settings = SystemSettings {
query_model: input.query_model,
processing_model: input.processing_model,
..current_settings
};
SystemSettings::update(&state.db, new_settings.clone()).await?;
Ok(TemplateResponse::new_partial(
"auth/admin_panel.html",
"model_settings_form",
ModelSettingsData {
settings: new_settings,
},
))
}
#[derive(Serialize)]
pub struct SystemPromptEditData {
settings: SystemSettings,
default_query_prompt: String,
}
pub async fn show_edit_system_prompt(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let settings = SystemSettings::get_current(&state.db).await?;
Ok(TemplateResponse::new_template(
"admin/edit_query_prompt_modal.html",
SystemPromptEditData {
settings,
default_query_prompt: DEFAULT_QUERY_SYSTEM_PROMPT.to_string(),
},
))
}
#[derive(Deserialize)]
pub struct SystemPromptUpdateInput {
query_system_prompt: String,
}
#[derive(Serialize)]
pub struct SystemPromptSectionData {
settings: SystemSettings,
}
pub async fn patch_query_prompt(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(input): Form<SystemPromptUpdateInput>,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let current_settings = SystemSettings::get_current(&state.db).await?;
let new_settings = SystemSettings {
query_system_prompt: input.query_system_prompt,
..current_settings.clone()
};
SystemSettings::update(&state.db, new_settings.clone()).await?;
Ok(TemplateResponse::new_partial(
"auth/admin_panel.html",
"system_prompt_section",
SystemPromptSectionData {
settings: new_settings,
},
))
}
#[derive(Serialize)]
pub struct IngestionPromptEditData {
settings: SystemSettings,
default_ingestion_prompt: String,
}
pub async fn show_edit_ingestion_prompt(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let settings = SystemSettings::get_current(&state.db).await?;
Ok(TemplateResponse::new_template(
"admin/edit_ingestion_prompt_modal.html",
IngestionPromptEditData {
settings,
default_ingestion_prompt: DEFAULT_INGRESS_ANALYSIS_SYSTEM_PROMPT.to_string(),
},
))
}
#[derive(Deserialize)]
pub struct IngestionPromptUpdateInput {
ingestion_system_prompt: String,
}
pub async fn patch_ingestion_prompt(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(input): Form<IngestionPromptUpdateInput>,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not admin
if !user.admin {
return Ok(TemplateResponse::redirect("/"));
};
let current_settings = SystemSettings::get_current(&state.db).await?;
let new_settings = SystemSettings {
ingestion_system_prompt: input.ingestion_system_prompt,
..current_settings.clone()
};
SystemSettings::update(&state.db, new_settings.clone()).await?;
Ok(TemplateResponse::new_partial(
"auth/admin_panel.html",
"system_prompt_section",
SystemPromptSectionData {
settings: new_settings,
},
))
}

View File

@@ -0,0 +1,27 @@
mod handlers;
use axum::{
extract::FromRef,
routing::{get, patch},
Router,
};
use handlers::{
patch_ingestion_prompt, patch_query_prompt, show_admin_panel, show_edit_ingestion_prompt,
show_edit_system_prompt, toggle_registration_status, update_model_settings,
};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/admin", get(show_admin_panel))
.route("/toggle-registrations", patch(toggle_registration_status))
.route("/update-model-settings", patch(update_model_settings))
.route("/edit-query-prompt", get(show_edit_system_prompt))
.route("/update-query-prompt", patch(patch_query_prompt))
.route("/edit-ingestion-prompt", get(show_edit_ingestion_prompt))
.route("/update-ingestion-prompt", patch(patch_ingestion_prompt))
}

View File

@@ -0,0 +1,24 @@
pub mod signin;
pub mod signout;
pub mod signup;
use axum::{extract::FromRef, routing::get, Router};
use signin::{authenticate_user, show_signin_form};
use signout::sign_out_user;
use signup::{process_signup_and_show_verification, show_signup_form};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/signout", get(sign_out_user))
.route("/signin", get(show_signin_form).post(authenticate_user))
.route(
"/signup",
get(show_signup_form).post(process_signup_and_show_verification),
)
}

View File

@@ -0,0 +1,59 @@
use axum::{
extract::State,
response::{Html, IntoResponse},
Form,
};
use axum_htmx::HxBoosted;
use serde::{Deserialize, Serialize};
use crate::{
html_state::HtmlState,
middlewares::response_middleware::{HtmlError, TemplateResponse},
AuthSessionType,
};
use common::storage::types::user::User;
#[derive(Deserialize, Serialize)]
pub struct SignupParams {
pub email: String,
pub password: String,
pub remember_me: Option<String>,
}
pub async fn show_signin_form(
auth: AuthSessionType,
HxBoosted(boosted): HxBoosted,
) -> Result<impl IntoResponse, HtmlError> {
if auth.is_authenticated() {
return Ok(TemplateResponse::redirect("/"));
}
match boosted {
true => Ok(TemplateResponse::new_partial(
"auth/signin_base.html",
"body",
(),
)),
false => Ok(TemplateResponse::new_template("auth/signin_base.html", ())),
}
}
pub async fn authenticate_user(
State(state): State<HtmlState>,
auth: AuthSessionType,
Form(form): Form<SignupParams>,
) -> Result<impl IntoResponse, HtmlError> {
let user = match User::authenticate(&form.email, &form.password, &state.db).await {
Ok(user) => user,
Err(_) => {
return Ok(Html("<p>Incorrect email or password </p>").into_response());
}
};
auth.login_user(user.id);
if form.remember_me.is_some_and(|string| string == *"on") {
auth.remember_user(true);
}
Ok(TemplateResponse::redirect("/").into_response())
}

View File

@@ -0,0 +1,16 @@
use axum::response::IntoResponse;
use crate::{
middlewares::response_middleware::{HtmlError, TemplateResponse},
AuthSessionType,
};
pub async fn sign_out_user(auth: AuthSessionType) -> Result<impl IntoResponse, HtmlError> {
if !auth.is_authenticated() {
return Ok(TemplateResponse::redirect("/"));
}
auth.logout_user();
Ok(TemplateResponse::redirect("/"))
}

View File

@@ -0,0 +1,58 @@
use axum::{
extract::State,
response::{Html, IntoResponse},
Form,
};
use axum_htmx::HxBoosted;
use serde::{Deserialize, Serialize};
use common::storage::types::user::User;
use crate::{
html_state::HtmlState,
middlewares::response_middleware::{HtmlError, TemplateResponse},
AuthSessionType,
};
#[derive(Deserialize, Serialize)]
pub struct SignupParams {
pub email: String,
pub password: String,
pub timezone: String,
}
pub async fn show_signup_form(
auth: AuthSessionType,
HxBoosted(boosted): HxBoosted,
) -> Result<impl IntoResponse, HtmlError> {
if auth.is_authenticated() {
return Ok(TemplateResponse::redirect("/"));
}
match boosted {
true => Ok(TemplateResponse::new_partial(
"auth/signup_form.html",
"body",
(),
)),
false => Ok(TemplateResponse::new_template("auth/signup_form.html", ())),
}
}
pub async fn process_signup_and_show_verification(
State(state): State<HtmlState>,
auth: AuthSessionType,
Form(form): Form<SignupParams>,
) -> Result<impl IntoResponse, HtmlError> {
let user = match User::create_new(form.email, form.password, &state.db, form.timezone).await {
Ok(user) => user,
Err(e) => {
tracing::error!("{:?}", e);
return Ok(Html(format!("<p>{}</p>", e)).into_response());
}
};
auth.login_user(user.id);
Ok(TemplateResponse::redirect("/").into_response())
}

View File

@@ -0,0 +1,226 @@
use axum::{
extract::{Path, State},
http::HeaderValue,
response::{IntoResponse, Redirect},
Form,
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use serde::{Deserialize, Serialize};
use surrealdb::{engine::any::Any, Surreal};
use common::{
error::AppError,
storage::types::{
conversation::Conversation,
message::{Message, MessageRole},
user::User,
},
};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
#[derive(Debug, Deserialize)]
pub struct ChatStartParams {
user_query: String,
llm_response: String,
#[serde(deserialize_with = "deserialize_references")]
references: Vec<String>,
}
// Custom deserializer function
fn deserialize_references<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
serde_json::from_str(&s).map_err(serde::de::Error::custom)
}
#[derive(Serialize)]
pub struct ChatPageData {
user: User,
history: Vec<Message>,
conversation: Option<Conversation>,
conversation_archive: Vec<Conversation>,
}
pub async fn show_initialized_chat(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<ChatStartParams>,
) -> Result<impl IntoResponse, HtmlError> {
let conversation = Conversation::new(user.id.clone(), "Test".to_owned());
let user_message = Message::new(
conversation.id.to_string(),
MessageRole::User,
form.user_query,
None,
);
let ai_message = Message::new(
conversation.id.to_string(),
MessageRole::AI,
form.llm_response,
Some(form.references),
);
state.db.store_item(conversation.clone()).await?;
state.db.store_item(ai_message.clone()).await?;
state.db.store_item(user_message.clone()).await?;
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
let messages = vec![user_message, ai_message];
let mut response = TemplateResponse::new_template(
"chat/base.html",
ChatPageData {
history: messages,
user,
conversation_archive,
conversation: Some(conversation.clone()),
},
)
.into_response();
response.headers_mut().insert(
"HX-Push",
HeaderValue::from_str(&format!("/chat/{}", conversation.id)).unwrap(),
);
Ok(response)
}
pub async fn show_chat_base(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"chat/base.html",
ChatPageData {
history: vec![],
user,
conversation_archive,
conversation: None,
},
))
}
#[derive(Deserialize)]
pub struct NewMessageForm {
content: String,
}
pub async fn show_existing_chat(
Path(conversation_id): Path<String>,
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
let (conversation, messages) =
Conversation::get_complete_conversation(conversation_id.as_str(), &user.id, &state.db)
.await?;
Ok(TemplateResponse::new_template(
"chat/base.html",
ChatPageData {
history: messages,
user,
conversation: Some(conversation.clone()),
conversation_archive,
},
))
}
pub async fn new_user_message(
Path(conversation_id): Path<String>,
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<NewMessageForm>,
) -> Result<impl IntoResponse, HtmlError> {
let conversation: Conversation = state
.db
.get_item(&conversation_id)
.await?
.ok_or_else(|| AppError::NotFound("Conversation was not found".into()))?;
if conversation.user_id != user.id {
return Ok(TemplateResponse::unauthorized().into_response());
};
let user_message = Message::new(conversation_id, MessageRole::User, form.content, None);
state.db.store_item(user_message.clone()).await?;
#[derive(Serialize)]
struct SSEResponseInitData {
user_message: Message,
}
let mut response = TemplateResponse::new_template(
"chat/streaming_response.html",
SSEResponseInitData { user_message },
)
.into_response();
response.headers_mut().insert(
"HX-Push",
HeaderValue::from_str(&format!("/chat/{}", conversation.id)).unwrap(),
);
Ok(response)
}
pub async fn new_chat_user_message(
State(state): State<HtmlState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Form(form): Form<NewMessageForm>,
) -> Result<impl IntoResponse, HtmlError> {
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let conversation = Conversation::new(user.id, "New chat".to_string());
let user_message = Message::new(
conversation.id.clone(),
MessageRole::User,
form.content,
None,
);
state.db.store_item(conversation.clone()).await?;
state.db.store_item(user_message.clone()).await?;
#[derive(Serialize)]
struct SSEResponseInitData {
user_message: Message,
conversation: Conversation,
}
let mut response = TemplateResponse::new_template(
"chat/new_chat_first_response.html",
SSEResponseInitData {
user_message,
conversation: conversation.clone(),
},
)
.into_response();
response.headers_mut().insert(
"HX-Push",
HeaderValue::from_str(&format!("/chat/{}", conversation.id)).unwrap(),
);
Ok(response)
}

View File

@@ -0,0 +1,355 @@
use std::{pin::Pin, sync::Arc, time::Duration};
use async_stream::stream;
use axum::{
extract::{Query, State},
response::{
sse::{Event, KeepAlive},
Sse,
},
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use composite_retrieval::{
answer_retrieval::{
create_chat_request, create_user_message_with_history, format_entities_json,
LLMResponseFormat,
},
retrieve_entities,
};
use futures::{
stream::{self, once},
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::{debug, error};
use common::storage::{
db::SurrealDbClient,
types::{
conversation::Conversation,
message::{Message, MessageRole},
system_settings::SystemSettings,
user::User,
},
};
use crate::html_state::HtmlState;
// Error handling function
fn create_error_stream(
message: impl Into<String>,
) -> Pin<Box<dyn Stream<Item = Result<Event, axum::Error>> + Send>> {
let message = message.into();
stream::once(async move { Ok(Event::default().event("error").data(message)) }).boxed()
}
// Helper function to get message and user
async fn get_message_and_user(
db: &SurrealDbClient,
current_user: Option<User>,
message_id: &str,
) -> Result<
(Message, User, Conversation, Vec<Message>),
Sse<Pin<Box<dyn Stream<Item = Result<Event, axum::Error>> + Send>>>,
> {
// Check authentication
let user = match current_user {
Some(user) => user,
None => {
return Err(Sse::new(create_error_stream(
"You must be signed in to use this feature",
)))
}
};
// Retrieve message
let message = match db.get_item::<Message>(message_id).await {
Ok(Some(message)) => message,
Ok(None) => {
return Err(Sse::new(create_error_stream(
"Message not found: the specified message does not exist",
)))
}
Err(e) => {
error!("Database error retrieving message {}: {:?}", message_id, e);
return Err(Sse::new(create_error_stream(
"Failed to retrieve message: database error",
)));
}
};
// Get conversation history
let (conversation, mut history) =
match Conversation::get_complete_conversation(&message.conversation_id, &user.id, db).await
{
Err(e) => {
error!("Database error retrieving message {}: {:?}", message_id, e);
return Err(Sse::new(create_error_stream(
"Failed to retrieve message: database error",
)));
}
Ok((conversation, history)) => (conversation, history),
};
// Remove the last message, its the same as the message
history.pop();
Ok((message, user, conversation, history))
}
#[derive(Deserialize)]
pub struct QueryParams {
message_id: String,
}
pub async fn get_response_stream(
State(state): State<HtmlState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Query(params): Query<QueryParams>,
) -> Sse<Pin<Box<dyn Stream<Item = Result<Event, axum::Error>> + Send>>> {
// 1. Authentication and initial data validation
let (user_message, user, _conversation, history) =
match get_message_and_user(&state.db, auth.current_user, &params.message_id).await {
Ok((user_message, user, conversation, history)) => {
(user_message, user, conversation, history)
}
Err(error_stream) => return error_stream,
};
// 2. Retrieve knowledge entities
let entities = match retrieve_entities(
&state.db,
&state.openai_client,
&user_message.content,
&user.id,
)
.await
{
Ok(entities) => entities,
Err(_e) => {
return Sse::new(create_error_stream("Failed to retrieve knowledge entities"));
}
};
// 3. Create the OpenAI request
let entities_json = format_entities_json(&entities);
let formatted_user_message =
create_user_message_with_history(&entities_json, &history, &user_message.content);
let settings = match SystemSettings::get_current(&state.db).await {
Ok(s) => s,
Err(_) => {
return Sse::new(create_error_stream("Failed to retrieve system settings"));
}
};
let request = match create_chat_request(formatted_user_message, &settings) {
Ok(req) => req,
Err(..) => {
return Sse::new(create_error_stream("Failed to create chat request"));
}
};
// 4. Set up the OpenAI stream
let openai_stream = match state.openai_client.chat().create_stream(request).await {
Ok(stream) => stream,
Err(_e) => {
return Sse::new(create_error_stream("Failed to create OpenAI stream"));
}
};
// 5. Create channel for collecting complete response
let (tx, mut rx) = channel::<String>(1000);
let tx_clone = tx.clone();
let (tx_final, mut rx_final) = channel::<Message>(1);
// 6. Set up the collection task for DB storage
let db_client = state.db.clone();
tokio::spawn(async move {
drop(tx); // Close sender when no longer needed
// Collect full response
let mut full_json = String::new();
while let Some(chunk) = rx.recv().await {
full_json.push_str(&chunk);
}
// Try to extract structured data
if let Ok(response) = from_str::<LLMResponseFormat>(&full_json) {
let references: Vec<String> = response
.references
.into_iter()
.map(|r| r.reference)
.collect();
let ai_message = Message::new(
user_message.conversation_id,
MessageRole::AI,
response.answer,
Some(references),
);
let _ = tx_final.send(ai_message.clone()).await;
match db_client.store_item(ai_message).await {
Ok(_) => debug!("Successfully stored AI message with references"),
Err(e) => error!("Failed to store AI message: {:?}", e),
}
} else {
error!("Failed to parse LLM response as structured format");
// Fallback - store raw response
let ai_message = Message::new(
user_message.conversation_id,
MessageRole::AI,
full_json,
None,
);
let _ = db_client.store_item(ai_message).await;
}
});
// Create a shared state for tracking the JSON parsing
let json_state = Arc::new(Mutex::new(StreamParserState::new()));
// 7. Create the response event stream
let event_stream = openai_stream
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)
.map(move |result| {
let tx_storage = tx_clone.clone();
let json_state = json_state.clone();
stream! {
match result {
Ok(response) => {
let content = response
.choices
.first()
.and_then(|choice| choice.delta.content.clone())
.unwrap_or_default();
if !content.is_empty() {
// Always send raw content to storage
let _ = tx_storage.send(content.clone()).await;
// Process through JSON parser
let mut state = json_state.lock().await;
let display_content = state.process_chunk(&content);
drop(state);
if !display_content.is_empty() {
yield Ok(Event::default()
.event("chat_message")
.data(display_content));
}
// If display_content is empty, don't yield anything
}
// If content is empty, don't yield anything
}
Err(e) => {
yield Ok(Event::default()
.event("error")
.data(format!("Stream error: {}", e)));
}
}
}
})
.flatten()
.chain(stream::once(async move {
if let Some(message) = rx_final.recv().await {
// Don't send any event if references is empty
if message.references.as_ref().is_some_and(|x| x.is_empty()) {
return Ok(Event::default().event("empty")); // This event won't be sent
}
// Prepare data for template
#[derive(Serialize)]
struct ReferenceData {
message: Message,
}
// Render template with references
match state.templates.render(
"chat/reference_list.html",
&Value::from_serialize(ReferenceData { message }),
) {
Ok(html) => {
// Return the rendered HTML
Ok(Event::default().event("references").data(html))
}
Err(_) => {
// Handle template rendering error
Ok(Event::default()
.event("error")
.data("Failed to render references"))
}
}
} else {
// Handle case where no references were received
Ok(Event::default()
.event("error")
.data("Failed to retrieve references"))
}
}))
.chain(once(async {
Ok(Event::default()
.event("close_stream")
.data("Stream complete"))
}));
Sse::new(event_stream.boxed()).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}
struct StreamParserState {
parser: JsonStreamParser,
last_answer_content: String,
in_answer_field: bool,
}
impl StreamParserState {
fn new() -> Self {
Self {
parser: JsonStreamParser::new(),
last_answer_content: String::new(),
in_answer_field: false,
}
}
fn process_chunk(&mut self, chunk: &str) -> String {
// Feed all characters into the parser
for c in chunk.chars() {
let _ = self.parser.add_char(c);
}
// Get the current state of the JSON
let json = self.parser.get_result();
// Check if we're in the answer field
if let Some(obj) = json.as_object() {
if let Some(answer) = obj.get("answer") {
self.in_answer_field = true;
// Get current answer content
let current_content = answer.as_str().unwrap_or_default().to_string();
// Calculate difference to send only new content
if current_content.len() > self.last_answer_content.len() {
let new_content = current_content[self.last_answer_content.len()..].to_string();
self.last_answer_content = current_content;
return new_content;
}
}
}
// No new content to return
String::new()
}
}

View File

@@ -0,0 +1,30 @@
mod chat_handlers;
mod message_response_stream;
mod references;
use axum::{
extract::FromRef,
routing::{get, post},
Router,
};
use chat_handlers::{
new_chat_user_message, new_user_message, show_chat_base, show_existing_chat,
show_initialized_chat,
};
use message_response_stream::get_response_stream;
use references::show_reference_tooltip;
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/chat", get(show_chat_base).post(new_chat_user_message))
.route("/chat/:id", get(show_existing_chat).post(new_user_message))
.route("/initialized-chat", post(show_initialized_chat))
.route("/chat/response-stream", get(get_response_stream))
.route("/chat/reference/:id", get(show_reference_tooltip))
}

View File

@@ -0,0 +1,45 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
};
use serde::Serialize;
use common::{
error::AppError,
storage::types::{knowledge_entity::KnowledgeEntity, user::User},
};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
pub async fn show_reference_tooltip(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(reference_id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
let entity: KnowledgeEntity = state
.db
.get_item(&reference_id)
.await?
.ok_or_else(|| AppError::NotFound("Item was not found".to_string()))?;
if entity.user_id != user.id {
return Ok(TemplateResponse::unauthorized());
}
#[derive(Serialize)]
struct ReferenceTooltipData {
entity: KnowledgeEntity,
user: User,
}
Ok(TemplateResponse::new_template(
"chat/reference_tooltip.html",
ReferenceTooltipData { entity, user },
))
}

View File

@@ -0,0 +1,90 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
Form,
};
use serde::{Deserialize, Serialize};
use common::storage::types::{text_content::TextContent, user::User};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
#[derive(Serialize)]
pub struct ContentPageData {
user: User,
text_contents: Vec<TextContent>,
}
pub async fn show_content_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"content/base.html",
ContentPageData {
user,
text_contents,
},
))
}
pub async fn show_text_content_edit_form(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
#[derive(Serialize)]
pub struct TextContentEditModal {
pub user: User,
pub text_content: TextContent,
}
Ok(TemplateResponse::new_template(
"content/edit_text_content_modal.html",
TextContentEditModal { user, text_content },
))
}
#[derive(Deserialize)]
pub struct PatchTextContentParams {
instructions: String,
category: String,
text: String,
}
pub async fn patch_text_content(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
Form(form): Form<PatchTextContentParams>,
) -> Result<impl IntoResponse, HtmlError> {
User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
TextContent::patch(
&id,
&form.instructions,
&form.category,
&form.text,
&state.db,
)
.await?;
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"content/content_list.html",
ContentPageData {
user,
text_contents,
},
))
}

View File

@@ -0,0 +1,19 @@
mod handlers;
use axum::{extract::FromRef, routing::get, Router};
use handlers::{patch_text_content, show_content_page, show_text_content_edit_form};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/content", get(show_content_page))
.route(
"/content/:id",
get(show_text_content_edit_form).patch(patch_text_content),
)
}

View File

@@ -0,0 +1,164 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
};
use serde::Serialize;
use tokio::join;
use crate::{
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
AuthSessionType,
};
use common::{
error::AppError,
storage::types::{
file_info::FileInfo, ingestion_task::IngestionTask, knowledge_entity::KnowledgeEntity,
knowledge_relationship::KnowledgeRelationship, text_chunk::TextChunk,
text_content::TextContent, user::User,
},
};
use crate::html_state::HtmlState;
#[derive(Serialize)]
pub struct IndexPageData {
user: Option<User>,
latest_text_contents: Vec<TextContent>,
active_jobs: Vec<IngestionTask>,
}
pub async fn index_handler(
State(state): State<HtmlState>,
auth: AuthSessionType,
) -> Result<impl IntoResponse, HtmlError> {
let Some(user) = auth.current_user else {
return Ok(TemplateResponse::new_template(
"index/index.html",
IndexPageData {
user: None,
latest_text_contents: vec![],
active_jobs: vec![],
},
));
};
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
let latest_text_contents = User::get_latest_text_contents(user.id.as_str(), &state.db).await?;
Ok(TemplateResponse::new_template(
"index/index.html",
IndexPageData {
user: Some(user),
latest_text_contents,
active_jobs,
},
))
}
#[derive(Serialize)]
pub struct LatestTextContentData {
latest_text_contents: Vec<TextContent>,
user: User,
}
pub async fn delete_text_content(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
// Get and validate TextContent
let text_content = get_and_validate_text_content(&state, &id, &user).await?;
// Perform concurrent deletions
let (_res1, _res2, _res3, _res4, _res5) = join!(
async {
if let Some(file_info) = text_content.file_info {
FileInfo::delete_by_id(&file_info.id, &state.db).await
} else {
Ok(())
}
},
state.db.delete_item::<TextContent>(&text_content.id),
TextChunk::delete_by_source_id(&text_content.id, &state.db),
KnowledgeEntity::delete_by_source_id(&text_content.id, &state.db),
KnowledgeRelationship::delete_relationships_by_source_id(&text_content.id, &state.db)
);
// Render updated content
let latest_text_contents = User::get_latest_text_contents(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial(
"index/signed_in/recent_content.html",
"latest_content_section",
LatestTextContentData {
user: user.to_owned(),
latest_text_contents,
},
))
}
// Helper function to get and validate text content
async fn get_and_validate_text_content(
state: &HtmlState,
id: &str,
user: &User,
) -> Result<TextContent, AppError> {
let text_content = state
.db
.get_item::<TextContent>(id)
.await?
.ok_or_else(|| AppError::NotFound("Item was not found".to_string()))?;
if text_content.user_id != user.id {
return Err(AppError::Auth(
"You are not the owner of that content".to_string(),
));
}
Ok(text_content)
}
#[derive(Serialize)]
pub struct ActiveJobsData {
pub active_jobs: Vec<IngestionTask>,
pub user: User,
}
pub async fn delete_job(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
User::validate_and_delete_job(&id, &user.id, &state.db).await?;
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial(
"index/signed_in/active_jobs.html",
"active_jobs_section",
ActiveJobsData {
user: user.clone(),
active_jobs,
},
))
}
pub async fn show_active_jobs(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial(
"index/signed_in/active_jobs.html",
"active_jobs_section",
ActiveJobsData {
user: user.clone(),
active_jobs,
},
))
}

View File

@@ -0,0 +1,29 @@
pub mod handlers;
use axum::{
extract::FromRef,
routing::{delete, get},
Router,
};
use handlers::{delete_job, delete_text_content, index_handler, show_active_jobs};
use crate::html_state::HtmlState;
pub fn public_router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new().route("/", get(index_handler))
}
pub fn protected_router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/jobs/:job_id", delete(delete_job))
.route("/active-jobs", get(show_active_jobs))
.route("/text-content/:id", delete(delete_text_content))
}

View File

@@ -0,0 +1,127 @@
use axum::{
extract::State,
response::{Html, IntoResponse},
};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use futures::{future::try_join_all, TryFutureExt};
use serde::Serialize;
use tempfile::NamedTempFile;
use tracing::info;
use common::{
error::AppError,
storage::types::{
file_info::FileInfo, ingestion_payload::IngestionPayload, ingestion_task::IngestionTask,
user::User,
},
};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
routes::index::handlers::ActiveJobsData,
};
pub async fn show_ingress_form(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let user_categories = User::get_user_categories(&user.id, &state.db).await?;
#[derive(Serialize)]
pub struct ShowIngressFormData {
user_categories: Vec<String>,
}
Ok(TemplateResponse::new_template(
"index/signed_in/ingress_modal.html",
ShowIngressFormData { user_categories },
))
}
pub async fn hide_ingress_form(
RequireUser(_user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
Ok(Html(
"<a class='btn btn-primary' hx-get='/ingress-form' hx-swap='outerHTML'>Add Content</a>",
)
.into_response())
}
#[derive(Debug, TryFromMultipart)]
pub struct IngressParams {
pub content: Option<String>,
pub instructions: String,
pub category: String,
#[form_data(limit = "10000000")] // Adjust limit as needed
#[form_data(default)]
pub files: Vec<FieldData<NamedTempFile>>,
}
pub async fn process_ingress_form(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
TypedMultipart(input): TypedMultipart<IngressParams>,
) -> Result<impl IntoResponse, HtmlError> {
#[derive(Serialize)]
pub struct IngressFormData {
instructions: String,
content: String,
category: String,
error: String,
}
if input.content.as_ref().map_or(true, |c| c.len() < 2) && input.files.is_empty() {
return Ok(TemplateResponse::new_template(
"index/signed_in/ingress_form.html",
IngressFormData {
instructions: input.instructions.clone(),
content: input.content.clone().unwrap_or_default(),
category: input.category.clone(),
error: "You need to either add files or content".to_string(),
},
));
}
info!("{:?}", input);
let file_infos = try_join_all(
input
.files
.into_iter()
.map(|file| FileInfo::new(file, &state.db, &user.id).map_err(AppError::from)),
)
.await?;
let payloads = IngestionPayload::create_ingestion_payload(
input.content,
input.instructions,
input.category,
file_infos,
user.id.as_str(),
)?;
let futures: Vec<_> = payloads
.into_iter()
.map(|object| {
IngestionTask::create_and_add_to_db(object.clone(), user.id.clone(), &state.db)
})
.collect();
try_join_all(futures).await?;
// Update the active jobs page with the newly created job
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial(
"index/signed_in/active_jobs.html",
"active_jobs_section",
ActiveJobsData {
user: user.clone(),
active_jobs,
},
))
}

View File

@@ -0,0 +1,19 @@
mod handlers;
use axum::{extract::FromRef, routing::get, Router};
use handlers::{hide_ingress_form, process_ingress_form, show_ingress_form};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route(
"/ingress-form",
get(show_ingress_form).post(process_ingress_form),
)
.route("/hide-ingress-form", get(hide_ingress_form))
}

View File

@@ -0,0 +1,291 @@
use axum::{
extract::{Path, State},
response::IntoResponse,
Form,
};
use plotly::{
common::{Line, Marker, Mode},
layout::{Axis, Camera, LayoutScene, ProjectionType},
Layout, Plot, Scatter3D,
};
use serde::{Deserialize, Serialize};
use common::storage::types::{
knowledge_entity::{KnowledgeEntity, KnowledgeEntityType},
knowledge_relationship::KnowledgeRelationship,
user::User,
};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
pub async fn show_knowledge_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
#[derive(Serialize)]
pub struct KnowledgeBaseData {
entities: Vec<KnowledgeEntity>,
relationships: Vec<KnowledgeRelationship>,
user: User,
plot_html: String,
}
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
let mut plot = Plot::new();
// Fibonacci sphere distribution
let node_count = entities.len();
let golden_ratio = (1.0 + 5.0_f64.sqrt()) / 2.0;
let node_positions: Vec<(f64, f64, f64)> = (0..node_count)
.map(|i| {
let i = i as f64;
let theta = 2.0 * std::f64::consts::PI * i / golden_ratio;
let phi = (1.0 - 2.0 * (i + 0.5) / node_count as f64).acos();
let x = phi.sin() * theta.cos();
let y = phi.sin() * theta.sin();
let z = phi.cos();
(x, y, z)
})
.collect();
let node_x: Vec<f64> = node_positions.iter().map(|(x, _, _)| *x).collect();
let node_y: Vec<f64> = node_positions.iter().map(|(_, y, _)| *y).collect();
let node_z: Vec<f64> = node_positions.iter().map(|(_, _, z)| *z).collect();
// Nodes trace
let nodes = Scatter3D::new(node_x.clone(), node_y.clone(), node_z.clone())
.mode(Mode::Markers)
.marker(Marker::new().size(8).color("#1f77b4"))
.text_array(
entities
.iter()
.map(|e| e.description.clone())
.collect::<Vec<_>>(),
)
.hover_template("Entity: %{text}<br>");
// Edges traces
for rel in &relationships {
let from_idx = entities.iter().position(|e| e.id == rel.out).unwrap_or(0);
let to_idx = entities.iter().position(|e| e.id == rel.in_).unwrap_or(0);
let edge_x = vec![node_x[from_idx], node_x[to_idx]];
let edge_y = vec![node_y[from_idx], node_y[to_idx]];
let edge_z = vec![node_z[from_idx], node_z[to_idx]];
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
.mode(Mode::Lines)
.line(Line::new().color("#888").width(2.0))
.hover_template(format!(
"Relationship: {}<br>",
rel.metadata.relationship_type
))
.show_legend(false);
plot.add_trace(edge_trace);
}
plot.add_trace(nodes);
// Layout
let layout = Layout::new()
.scene(
LayoutScene::new()
.x_axis(Axis::new().visible(false))
.y_axis(Axis::new().visible(false))
.z_axis(Axis::new().visible(false))
.camera(
Camera::new()
.projection(ProjectionType::Perspective.into())
.eye((1.5, 1.5, 1.5).into()),
),
)
.show_legend(false)
.paper_background_color("rbga(250,100,0,0)")
.plot_background_color("rbga(0,0,0,0)");
plot.set_layout(layout);
// Convert to HTML
let html = plot.to_html();
Ok(TemplateResponse::new_template(
"knowledge/base.html",
KnowledgeBaseData {
entities,
relationships,
user,
plot_html: html,
},
))
}
pub async fn show_edit_knowledge_entity_form(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
#[derive(Serialize)]
pub struct EntityData {
entity: KnowledgeEntity,
entity_types: Vec<String>,
user: User,
}
// Get entity types
let entity_types: Vec<String> = KnowledgeEntityType::variants()
.iter()
.map(|s| s.to_string())
.collect();
// Get the entity and validate ownership
let entity = User::get_and_validate_knowledge_entity(&id, &user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"knowledge/edit_knowledge_entity_modal.html",
EntityData {
entity,
user,
entity_types,
},
))
}
#[derive(Debug, Deserialize)]
pub struct PatchKnowledgeEntityParams {
pub id: String,
pub name: String,
pub entity_type: String,
pub description: String,
}
#[derive(Serialize)]
pub struct EntityListData {
entities: Vec<KnowledgeEntity>,
user: User,
}
pub async fn patch_knowledge_entity(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<PatchKnowledgeEntityParams>,
) -> Result<impl IntoResponse, HtmlError> {
// Get the existing entity and validate that the user is allowed
User::get_and_validate_knowledge_entity(&form.id, &user.id, &state.db).await?;
let entity_type: KnowledgeEntityType = KnowledgeEntityType::from(form.entity_type);
// Update the entity
KnowledgeEntity::patch(
&form.id,
&form.name,
&form.description,
&entity_type,
&state.db,
&state.openai_client,
)
.await?;
// Get updated list of entities
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
// Render updated list
Ok(TemplateResponse::new_template(
"knowledge/entity_list.html",
EntityListData { entities, user },
))
}
pub async fn delete_knowledge_entity(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
// Get the existing entity and validate that the user is allowed
User::get_and_validate_knowledge_entity(&id, &user.id, &state.db).await?;
// Delete the entity
state.db.delete_item::<KnowledgeEntity>(&id).await?;
// Get updated list of entities
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"knowledge/entity_list.html",
EntityListData { entities, user },
))
}
#[derive(Serialize)]
pub struct RelationshipTableData {
entities: Vec<KnowledgeEntity>,
relationships: Vec<KnowledgeRelationship>,
}
pub async fn delete_knowledge_relationship(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
// GOTTA ADD AUTH VALIDATION
KnowledgeRelationship::delete_relationship_by_id(&id, &state.db).await?;
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
// Render updated list
Ok(TemplateResponse::new_template(
"knowledge/relationship_table.html",
RelationshipTableData {
entities,
relationships,
},
))
}
#[derive(Deserialize)]
pub struct SaveKnowledgeRelationshipInput {
pub in_: String,
pub out: String,
pub relationship_type: String,
}
pub async fn save_knowledge_relationship(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<SaveKnowledgeRelationshipInput>,
) -> Result<impl IntoResponse, HtmlError> {
// Construct relationship
let relationship = KnowledgeRelationship::new(
form.in_,
form.out,
user.id.clone(),
"manual".into(),
form.relationship_type,
);
relationship.store_relationship(&state.db).await?;
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
// Render updated list
Ok(TemplateResponse::new_template(
"knowledge/relationship_table.html",
RelationshipTableData {
entities,
relationships,
},
))
}

View File

@@ -0,0 +1,33 @@
mod handlers;
use axum::{
extract::FromRef,
routing::{delete, get, post},
Router,
};
use handlers::{
delete_knowledge_entity, delete_knowledge_relationship, patch_knowledge_entity,
save_knowledge_relationship, show_edit_knowledge_entity_form, show_knowledge_page,
};
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new()
.route("/knowledge", get(show_knowledge_page))
.route(
"/knowledge-entity/:id",
get(show_edit_knowledge_entity_form)
.delete(delete_knowledge_entity)
.patch(patch_knowledge_entity),
)
.route("/knowledge-relationship", post(save_knowledge_relationship))
.route(
"/knowledge-relationship/:id",
delete(delete_knowledge_relationship),
)
}

View File

@@ -0,0 +1,9 @@
pub mod account;
pub mod admin;
pub mod auth;
pub mod chat;
pub mod content;
pub mod index;
pub mod ingestion;
pub mod knowledge;
pub mod search;

View File

@@ -0,0 +1,44 @@
use axum::{
extract::{Query, State},
response::IntoResponse,
};
use composite_retrieval::answer_retrieval::get_answer_with_references;
use serde::{Deserialize, Serialize};
use crate::{
html_state::HtmlState,
middlewares::{
auth_middleware::RequireUser,
response_middleware::{HtmlError, TemplateResponse},
},
};
#[derive(Deserialize)]
pub struct SearchParams {
query: String,
}
pub async fn search_result_handler(
State(state): State<HtmlState>,
Query(query): Query<SearchParams>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
#[derive(Serialize)]
pub struct AnswerData {
user_query: String,
answer_content: String,
answer_references: Vec<String>,
}
let answer =
get_answer_with_references(&state.db, &state.openai_client, &query.query, &user.id).await?;
Ok(TemplateResponse::new_template(
"index/signed_in/search_response.html",
AnswerData {
user_query: query.query,
answer_content: answer.content,
answer_references: answer.references,
},
))
}

View File

@@ -0,0 +1,14 @@
mod handlers;
use axum::{extract::FromRef, routing::get, Router};
use handlers::search_result_handler;
use crate::html_state::HtmlState;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
HtmlState: FromRef<S>,
{
Router::new().route("/search", get(search_result_handler))
}

View File

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

View File

@@ -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,99 @@
{% 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">
{% 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 %}
<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>
<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 %} />
</form>
{% endblock %}
Enable Registrations
</label>
<div id="registration-status" class="text-sm mt-2"></div>
</fieldset>
</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,6 @@
{% extends "head_base.html" %}
{% block body %}
<div class="min-h-[100dvh] flex">
{% include "auth/signin_form.html" %}
</div>
{% endblock %}

View File

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

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,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,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,8 @@
{% extends "body_base.html" %}
{% block main %}
{% if user %}
{% include 'index/signed_in/base.html' %}
{% else %}
{% include 'auth/signin_form.html' %}
{% 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 %}

Some files were not shown because too many files have changed in this diff Show More