refactor: better separation of dependencies to crates
node stuff to html crate only
40
html-router/Cargo.toml
Normal 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
@@ -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;
|
||||
}
|
||||
BIN
html-router/assets/fonts/Satoshi-Regular.ttf
Normal file
BIN
html-router/assets/fonts/Satoshi-Regular.woff
Normal file
BIN
html-router/assets/fonts/Satoshi-Regular.woff2
Normal file
BIN
html-router/assets/fonts/Satoshi-Variable.eot
Normal file
BIN
html-router/assets/fonts/Satoshi-Variable.ttf
Normal file
BIN
html-router/assets/fonts/Satoshi-Variable.woff
Normal file
BIN
html-router/assets/fonts/Satoshi-Variable.woff2
Normal file
BIN
html-router/assets/fonts/Satoshi-VariableItalic.eot
Normal file
BIN
html-router/assets/fonts/Satoshi-VariableItalic.ttf
Normal file
BIN
html-router/assets/fonts/Satoshi-VariableItalic.woff
Normal file
BIN
html-router/assets/fonts/Satoshi-VariableItalic.woff2
Normal file
1
html-router/assets/htmx-ext-sse.js
Normal 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
@@ -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
BIN
html-router/assets/icon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
html-router/assets/icon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 252 KiB |
BIN
html-router/assets/icon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
html-router/assets/icon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 790 B |
BIN
html-router/assets/icon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
html-router/assets/icon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
html-router/assets/icon/site.webmanifest
Normal 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"}
|
||||
13
html-router/assets/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
32
html-router/assets/theme-toggle.js
Normal 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
@@ -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
17
html-router/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
html-router/src/html_state.rs
Normal 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
@@ -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()
|
||||
}
|
||||
30
html-router/src/middlewares/analytics_middleware.rs
Normal 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
|
||||
}
|
||||
50
html-router/src/middlewares/auth_middleware.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
3
html-router/src/middlewares/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod analytics_middleware;
|
||||
pub mod auth_middleware;
|
||||
pub mod response_middleware;
|
||||
208
html-router/src/middlewares/response_middleware.rs
Normal 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()
|
||||
}
|
||||
189
html-router/src/router_factory.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
143
html-router/src/routes/account/handlers.rs
Normal 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",
|
||||
(),
|
||||
))
|
||||
}
|
||||
24
html-router/src/routes/account/mod.rs
Normal 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))
|
||||
}
|
||||
259
html-router/src/routes/admin/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
27
html-router/src/routes/admin/mod.rs
Normal 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))
|
||||
}
|
||||
24
html-router/src/routes/auth/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
59
html-router/src/routes/auth/signin.rs
Normal 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())
|
||||
}
|
||||
16
html-router/src/routes/auth/signout.rs
Normal 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("/"))
|
||||
}
|
||||
58
html-router/src/routes/auth/signup.rs
Normal 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())
|
||||
}
|
||||
226
html-router/src/routes/chat/chat_handlers.rs
Normal 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)
|
||||
}
|
||||
355
html-router/src/routes/chat/message_response_stream.rs
Normal 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, ¶ms.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()
|
||||
}
|
||||
}
|
||||
30
html-router/src/routes/chat/mod.rs
Normal 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))
|
||||
}
|
||||
45
html-router/src/routes/chat/references.rs
Normal 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 },
|
||||
))
|
||||
}
|
||||
90
html-router/src/routes/content/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
19
html-router/src/routes/content/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
164
html-router/src/routes/index/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
29
html-router/src/routes/index/mod.rs
Normal 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))
|
||||
}
|
||||
127
html-router/src/routes/ingestion/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
19
html-router/src/routes/ingestion/mod.rs
Normal 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))
|
||||
}
|
||||
291
html-router/src/routes/knowledge/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
33
html-router/src/routes/knowledge/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
9
html-router/src/routes/mod.rs
Normal 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;
|
||||
44
html-router/src/routes/search/handlers.rs
Normal 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,
|
||||
},
|
||||
))
|
||||
}
|
||||
14
html-router/src/routes/search/mod.rs
Normal 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))
|
||||
}
|
||||
33
html-router/tailwind.config.js
Normal 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"],
|
||||
},
|
||||
}
|
||||
|
||||
38
html-router/templates/admin/edit_ingestion_prompt_modal.html
Normal 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 %}
|
||||
38
html-router/templates/admin/edit_query_prompt_modal.html
Normal 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 %}
|
||||
119
html-router/templates/auth/account_settings.html
Normal 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 %}
|
||||
99
html-router/templates/auth/admin_panel.html
Normal 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 %}
|
||||
5
html-router/templates/auth/change_password_form.html
Normal 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>
|
||||
6
html-router/templates/auth/signin_base.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "head_base.html" %}
|
||||
{% block body %}
|
||||
<div class="min-h-[100dvh] flex">
|
||||
{% include "auth/signin_form.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
html-router/templates/auth/signin_form.html
Normal 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>
|
||||
61
html-router/templates/auth/signup_form.html
Normal 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 %}
|
||||
15
html-router/templates/body_base.html
Normal 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 %}
|
||||
42
html-router/templates/chat/base.html
Normal 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 %}
|
||||
15
html-router/templates/chat/drawer.html
Normal 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>
|
||||
54
html-router/templates/chat/history.html
Normal 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>
|
||||
29
html-router/templates/chat/new_chat_first_response.html
Normal 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>
|
||||
29
html-router/templates/chat/new_message_form.html
Normal 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>
|
||||
90
html-router/templates/chat/reference_list.html
Normal 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>
|
||||
3
html-router/templates/chat/reference_tooltip.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>{{entity.name}}</div>
|
||||
<div>{{entity.description}}</div>
|
||||
<div>{{entity.updated_at|datetimeformat(format="short", tz=user.timezone)}} </div>
|
||||
27
html-router/templates/chat/streaming_response.html
Normal 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>
|
||||
12
html-router/templates/content/base.html
Normal 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 %}
|
||||
46
html-router/templates/content/content_list.html
Normal 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>
|
||||
37
html-router/templates/content/edit_text_content_modal.html
Normal 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 %}
|
||||
13
html-router/templates/errors/error.html
Normal 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 %}
|
||||
48
html-router/templates/head_base.html
Normal 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>
|
||||
5
html-router/templates/icons/chat_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
6
html-router/templates/icons/chevron_icon.html
Normal 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 |
5
html-router/templates/icons/clipboard_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
5
html-router/templates/icons/delete_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
5
html-router/templates/icons/document_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
5
html-router/templates/icons/edit_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
5
html-router/templates/icons/globe_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
4
html-router/templates/icons/hamburger_icon.html
Normal 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 |
5
html-router/templates/icons/refresh_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
5
html-router/templates/icons/send_icon.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
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 |
8
html-router/templates/index/index.html
Normal 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 %}
|
||||
50
html-router/templates/index/signed_in/active_jobs.html
Normal 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 %}
|
||||
14
html-router/templates/index/signed_in/base.html
Normal 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>
|
||||
57
html-router/templates/index/signed_in/ingress_modal.html
Normal 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 %}
|
||||
7
html-router/templates/index/signed_in/quick_actions.html
Normal 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>
|
||||
42
html-router/templates/index/signed_in/recent_content.html
Normal 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 %}
|
||||
32
html-router/templates/index/signed_in/search_response.html
Normal 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>
|
||||
8
html-router/templates/index/signed_in/searchbar.html
Normal 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>
|
||||
19
html-router/templates/knowledge/base.html
Normal 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 %}
|
||||