wip: chat interface

This commit is contained in:
Per Stark
2025-02-20 21:11:45 +01:00
parent e5dd88fd1c
commit e43b63de9f
15 changed files with 6115 additions and 19 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,124 @@
use axum::{
extract::State,
response::{IntoResponse, Redirect},
Form,
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use surrealdb::{engine::any::Any, Surreal};
use tracing::info;
use crate::{
error::HtmlError,
page_data,
server::{routes::html::render_template, AppState},
storage::types::user::User,
};
// Update your ChatStartParams struct to properly deserialize the references
#[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)
}
page_data!(ChatData, "chat/base.html", {
user: User,
history: Vec<Message>,
});
#[derive(Deserialize, Debug, Serialize)]
pub enum MessageRole {
User,
AI,
System,
}
#[derive(Deserialize, Debug, Serialize)]
pub struct Message {
conversation_id: String,
role: MessageRole,
content: String,
references: Option<Vec<String>>,
}
pub struct Conversation {
user_id: String,
title: String,
}
pub async fn show_initialized_chat(
State(state): State<AppState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
Form(form): Form<ChatStartParams>,
) -> Result<impl IntoResponse, HtmlError> {
info!("Displaying chat start");
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
info!("{:?}", form);
let user_message = Message {
conversation_id: "test".to_string(),
role: MessageRole::User,
content: form.user_query,
references: None,
};
let ai_message = Message {
conversation_id: "test".to_string(),
role: MessageRole::AI,
content: form.llm_response,
references: Some(form.references),
};
let messages = vec![user_message, ai_message];
let output = render_template(
ChatData::template_name(),
ChatData {
history: messages,
user,
},
state.templates.clone(),
)?;
Ok(output.into_response())
}
pub async fn show_chat_base(
State(state): State<AppState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
) -> Result<impl IntoResponse, HtmlError> {
info!("Displaying empty chat start");
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let output = render_template(
ChatData::template_name(),
ChatData {
history: vec![],
user,
},
state.templates.clone(),
)?;
Ok(output.into_response())
}

View File

@@ -7,6 +7,7 @@ use crate::error::{HtmlError, IntoHtmlError};
pub mod account;
pub mod admin_panel;
pub mod chat;
pub mod content;
pub mod documentation;
pub mod gdpr;

View File

@@ -1,15 +1,17 @@
use axum::{
extract::{Query, State},
response::{Html, IntoResponse, Redirect},
response::{IntoResponse, Redirect},
};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use surrealdb::{engine::any::Any, Surreal};
use tracing::info;
use crate::{
error::HtmlError, retrieval::query_helper::get_answer_with_references, server::AppState,
error::HtmlError,
retrieval::query_helper::get_answer_with_references,
server::{routes::html::render_template, AppState},
storage::types::user::User,
};
#[derive(Deserialize)]
@@ -17,6 +19,13 @@ pub struct SearchParams {
query: String,
}
#[derive(Serialize)]
pub struct AnswerData {
user_query: String,
answer_content: String,
answer_references: Vec<String>,
}
pub async fn search_result_handler(
State(state): State<AppState>,
Query(query): Query<SearchParams>,
@@ -26,17 +35,37 @@ pub async fn search_result_handler(
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
None => return Ok(Redirect::to("/signin").into_response()),
};
let answer = get_answer_with_references(
&state.surreal_db_client,
&state.openai_client,
&query.query,
&user.id,
)
.await
.map_err(|e| HtmlError::new(e, state.templates.clone()))?;
// let answer = get_answer_with_references(
// &state.surreal_db_client,
// &state.openai_client,
// &query.query,
// &user.id,
// )
// .await
// .map_err(|e| HtmlError::new(e, state.templates.clone()))?;
Ok(Html(answer.content).into_response())
let answer = "The Minne project is focused on simplifying knowledge management through features such as easy capture, smart analysis, and visualization of connections between ideas. It includes various functionalities like the Smart Analysis Feature, which provides content analysis and organization, and the Easy Capture Feature, which allows users to effortlessly capture and retrieve knowledge in various formats. Additionally, it offers tools like Knowledge Graph Visualization to enhance understanding and organization of knowledge. The project also emphasizes a user-friendly onboarding experience and mobile-friendly options for accessing its services.".to_string();
let references = vec![
"i81cd5be8-557c-4b2b-ba3a-4b8d28e74b9b".to_string(),
"5f72a724-d7a3-467d-8783-7cca6053ddc7".to_string(),
"ad106a1f-ccda-415e-9e87-c3a34e202624".to_string(),
"8797b57d-094d-4ee9-a3a7-c3195b246254".to_string(),
"69763f43-82e6-4cb5-ba3e-f6da13777dab".to_string(),
];
let output = render_template(
"index/signed_in/search_response.html",
AnswerData {
user_query: query.query,
answer_content: answer,
answer_references: references,
},
state.templates,
)?;
Ok(output.into_response())
}

View File

@@ -16,6 +16,7 @@ use axum_session_surreal::SessionSurrealPool;
use html::{
account::{delete_account, set_api_key, show_account_page, update_timezone},
admin_panel::{show_admin_panel, toggle_registration_status},
chat::{show_chat_base, show_initialized_chat},
content::{patch_text_content, show_content_page, show_text_content_edit_form},
documentation::{
show_documentation_index, show_get_started, show_mobile_friendly, show_privacy_policy,
@@ -63,6 +64,7 @@ pub fn html_routes(app_state: &AppState) -> Router<AppState> {
.route("/gdpr/accept", post(accept_gdpr))
.route("/gdpr/deny", post(deny_gdpr))
.route("/search", get(search_result_handler))
.route("/chat", get(show_chat_base).post(show_initialized_chat))
.route("/signout", get(sign_out_user))
.route("/signin", get(show_signin_form).post(authenticate_user))
.route(

44
templates/chat/base.html Normal file
View File

@@ -0,0 +1,44 @@
{% extends 'body_base.html' %}
{% block main %}
<div class="drawer xl:drawer-open">
<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" %}
<div class="fixed w-full mx-auto max-w-3xl left-0 right-0 bottom-0">
{% include "chat/input_field.html" %}
</div>
</div>
</main>
</div>
<!-- Drawer Sidebar -->
{% include "chat/drawer.html" %}
</div>
<style>
/* Custom styles to override DaisyUI defaults */
.drawer-content {
width: 100%;
padding: 0;
}
.drawer-side {
z-index: 20;
/* Ensure drawer is above content */
}
@media (min-width: 1280px) {
/* xl breakpoint */
.drawer-open .drawer-content {
margin-left: 0;
/* Prevent content shift */
}
}
</style>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 text-base-content min-h-full 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>
<li><a>Sidebar Item 1</a></li>
<li><a>Sidebar Item 2</a></li>
</ul>
</div>

View File

@@ -0,0 +1,19 @@
<div id="chat_container">
{% for message in history %}
{% if message.role == "AI" %}
<div class="chat chat-start">
<div class="chat-header">{{ message.role }}</div>
<div class="chat-bubble">
{{ message.content }}
</div>
</div>
{% else %}
<div class="chat chat-end">
<div class="chat-header">{{ message.role }}</div>
<div class="chat-bubble">
{{ message.content }}
</div>
</div>
{% endif %}
{% endfor %}
</div>

View File

@@ -0,0 +1,22 @@
<form hx-post="/chat/{{conversation_id}}" hx-target="#chat_container" hx-swap="afterend" class="relative flex gap-2"
id="chat-form">
<textarea name="content" placeholder="Type your message..." rows="2"
class="textarea rounded-t-2xl rounded-b-none border-2 flex-grow resize-none" id="chat-input"></textarea>
<button type="submit" class="absolute p-2 cursor-pointer right-0.5 btn-ghost btn-sm top-2">{% include
"icons/send_icon.html" %}
</button>
<label for="my-drawer-2" class="absolute cursor-pointer top-10 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) {
// Check if Enter is pressed without Shift
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); // Prevent default Enter behavior (new line)
document.getElementById('chat-form').submit(); // Submit the form
}
// Shift + Enter will naturally create a new line due to browser default behavior
});
</script>

View File

@@ -13,7 +13,13 @@
{% endif %}
</div>
<h2 class="card-title truncate">
{{ text_content.text }}
{% 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">

View File

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

After

Width:  |  Height:  |  Size: 244 B

View File

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

After

Width:  |  Height:  |  Size: 301 B

View File

@@ -0,0 +1,31 @@
<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="/chat" hx-target="body" hx-swap="outerHTML" method="POST" class="flex items-center space-x-4">
<input type="hidden" name="user_query" value="{{ user_query }}">
<input type="hidden" name="llm_response" value="{{ answer_content }}">
<input type="hidden" name="references" value="{{ answer_references }}">
<button type="submit" class="btn btn-primary">Continue with chat</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<h2>
<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"

View File

@@ -17,12 +17,13 @@ hx-swap="outerHTML"
</div>
<div class="form-control relative" style="margin-top: -1.5rem;">
<span class="absolute left-2.5 top-2.5 z-[100] p-0.5 bg-white text-xs text-light">Type</span>
<select name="entity_type" class="select select-bordered w-full">
<div class="absolute !left-3 !top-2.5 z-50 p-0.5 bg-white text-xs text-light">Type</div>
<select name="entity_type" class="select w-full">
<option disabled>You must select a type</option>
{% for et in entity_types %}
<option value="{{ et }}" {% if entity.entity_type==et %}selected{% endif %}>{{ et }}</option>
{% endfor %}
</select>
</select>
</div>
<input type="text" name="id" value="{{ entity.id }}" class="hidden">