mirror of
https://github.com/perstarkse/minne.git
synced 2026-04-25 10:18:38 +02:00
feat: chat conversation list titles, sorting, etc
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
use surrealdb::opt::PatchOp;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
|
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
|
||||||
@@ -46,6 +47,31 @@ impl Conversation {
|
|||||||
|
|
||||||
Ok((conversation, messages))
|
Ok((conversation, messages))
|
||||||
}
|
}
|
||||||
|
pub async fn patch_title(
|
||||||
|
id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
new_title: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// First verify ownership by getting conversation user_id
|
||||||
|
let conversation: Option<Conversation> = db.get_item(id).await?;
|
||||||
|
let conversation =
|
||||||
|
conversation.ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?;
|
||||||
|
|
||||||
|
if conversation.user_id != user_id {
|
||||||
|
return Err(AppError::Auth(
|
||||||
|
"Unauthorized to update this conversation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _updated: Option<Self> = db
|
||||||
|
.update((Self::table_name(), id))
|
||||||
|
.patch(PatchOp::replace("/title", new_title.to_string()))
|
||||||
|
.patch(PatchOp::replace("/updated_at", Utc::now()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -141,6 +167,85 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_patch_title_success() {
|
||||||
|
let namespace = "test_ns";
|
||||||
|
let database = &Uuid::new_v4().to_string();
|
||||||
|
let db = SurrealDbClient::memory(namespace, database)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start in-memory surrealdb");
|
||||||
|
|
||||||
|
let user_id = "user_1";
|
||||||
|
let original_title = "Original Title";
|
||||||
|
let conversation = Conversation::new(user_id.to_string(), original_title.to_string());
|
||||||
|
let conversation_id = conversation.id.clone();
|
||||||
|
|
||||||
|
db.store_item(conversation)
|
||||||
|
.await
|
||||||
|
.expect("Failed to store conversation");
|
||||||
|
|
||||||
|
let new_title = "Updated Title";
|
||||||
|
|
||||||
|
// Patch title successfully
|
||||||
|
let result = Conversation::patch_title(&conversation_id, user_id, new_title, &db).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Retrieve from DB to verify
|
||||||
|
let updated_conversation = db
|
||||||
|
.get_item::<Conversation>(&conversation_id)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get conversation")
|
||||||
|
.expect("Conversation missing");
|
||||||
|
assert_eq!(updated_conversation.title, new_title);
|
||||||
|
assert_eq!(updated_conversation.user_id, user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_patch_title_not_found() {
|
||||||
|
let namespace = "test_ns";
|
||||||
|
let database = &Uuid::new_v4().to_string();
|
||||||
|
let db = SurrealDbClient::memory(namespace, database)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start in-memory surrealdb");
|
||||||
|
|
||||||
|
// Try to patch non-existing conversation
|
||||||
|
let result = Conversation::patch_title("nonexistent", "user_x", "New Title", &db).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(AppError::NotFound(_)) => {}
|
||||||
|
_ => panic!("Expected NotFound error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_patch_title_unauthorized() {
|
||||||
|
let namespace = "test_ns";
|
||||||
|
let database = &Uuid::new_v4().to_string();
|
||||||
|
let db = SurrealDbClient::memory(namespace, database)
|
||||||
|
.await
|
||||||
|
.expect("Failed to start in-memory surrealdb");
|
||||||
|
|
||||||
|
let owner_id = "owner";
|
||||||
|
let other_user_id = "intruder";
|
||||||
|
let conversation = Conversation::new(owner_id.to_string(), "Private".to_string());
|
||||||
|
let conversation_id = conversation.id.clone();
|
||||||
|
|
||||||
|
db.store_item(conversation)
|
||||||
|
.await
|
||||||
|
.expect("Failed to store conversation");
|
||||||
|
|
||||||
|
// Attempt patch with unauthorized user
|
||||||
|
let result =
|
||||||
|
Conversation::patch_title(&conversation_id, other_user_id, "Hacked Title", &db).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
match result {
|
||||||
|
Err(AppError::Auth(_)) => {}
|
||||||
|
_ => panic!("Expected Auth error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_get_complete_conversation_with_messages() {
|
async fn test_get_complete_conversation_with_messages() {
|
||||||
// Setup in-memory database for testing
|
// Setup in-memory database for testing
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ mod tests {
|
|||||||
// Should fail with FileNotFound error
|
// Should fail with FileNotFound error
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
match result {
|
match result {
|
||||||
Err(FileError::FileNotFound(_)) => {
|
Err(AppError::File(_)) => {
|
||||||
// Expected error
|
// Expected error
|
||||||
}
|
}
|
||||||
_ => panic!("Expected FileNotFound error"),
|
_ => panic!("Expected FileNotFound error"),
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ impl IngestionPayload {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
// Create a mock FileInfo for testing
|
// Create a mock FileInfo for testing
|
||||||
|
|||||||
@@ -421,7 +421,10 @@ impl User {
|
|||||||
) -> Result<Vec<Conversation>, AppError> {
|
) -> Result<Vec<Conversation>, AppError> {
|
||||||
let conversations: Vec<Conversation> = db
|
let conversations: Vec<Conversation> = db
|
||||||
.client
|
.client
|
||||||
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id")
|
.query(
|
||||||
|
"SELECT * FROM type::table($table_name) WHERE user_id = $user_id
|
||||||
|
ORDER BY updated_at DESC",
|
||||||
|
)
|
||||||
.bind(("table_name", Conversation::table_name()))
|
.bind(("table_name", Conversation::table_name()))
|
||||||
.bind(("user_id", user_id.to_string()))
|
.bind(("user_id", user_id.to_string()))
|
||||||
.await?
|
.await?
|
||||||
@@ -775,4 +778,41 @@ mod tests {
|
|||||||
let updated_user = updated_user.unwrap();
|
let updated_user = updated_user.unwrap();
|
||||||
assert_eq!(updated_user.timezone, new_timezone);
|
assert_eq!(updated_user.timezone, new_timezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_conversations_order() {
|
||||||
|
let db = setup_test_db().await;
|
||||||
|
let user_id = "user_order_test";
|
||||||
|
|
||||||
|
// Create conversations with varying updated_at timestamps
|
||||||
|
let mut conversations = Vec::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
let mut conv = Conversation::new(user_id.to_string(), format!("Conv {}", i));
|
||||||
|
// Fake updated_at i minutes apart
|
||||||
|
conv.updated_at = chrono::Utc::now() - chrono::Duration::minutes(i);
|
||||||
|
db.store_item(conv.clone())
|
||||||
|
.await
|
||||||
|
.expect("Failed to store conversation");
|
||||||
|
conversations.push(conv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve via get_user_conversations - should be ordered by updated_at DESC
|
||||||
|
let retrieved = User::get_user_conversations(user_id, &db)
|
||||||
|
.await
|
||||||
|
.expect("Failed to get conversations");
|
||||||
|
|
||||||
|
assert_eq!(retrieved.len(), conversations.len());
|
||||||
|
|
||||||
|
for window in retrieved.windows(2) {
|
||||||
|
// Assert each earlier conversation has updated_at >= later conversation
|
||||||
|
assert!(
|
||||||
|
window[0].updated_at >= window[1].updated_at,
|
||||||
|
"Conversations not ordered descending by updated_at"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Optional) Check first conversation title matches the most recently updated
|
||||||
|
let most_recent = conversations.iter().max_by_key(|c| c.updated_at).unwrap();
|
||||||
|
assert_eq!(retrieved[0].id, most_recent.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,3 +224,110 @@ pub async fn new_chat_user_message(
|
|||||||
|
|
||||||
Ok(response.into_response())
|
Ok(response.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PatchConversationTitle {
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DrawerContext {
|
||||||
|
user: User,
|
||||||
|
conversation_archive: Vec<Conversation>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
edit_conversation_id: Option<String>,
|
||||||
|
}
|
||||||
|
pub async fn show_conversation_editing_title(
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
Path(conversation_id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
let owns = conversation_archive
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.id == conversation_id && c.user_id == user.id);
|
||||||
|
if !owns {
|
||||||
|
return Ok(TemplateResponse::unauthorized().into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TemplateResponse::new_template(
|
||||||
|
"chat/drawer.html",
|
||||||
|
DrawerContext {
|
||||||
|
user,
|
||||||
|
conversation_archive,
|
||||||
|
edit_conversation_id: Some(conversation_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn patch_conversation_title(
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
Path(conversation_id): Path<String>,
|
||||||
|
Form(form): Form<PatchConversationTitle>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
Conversation::patch_title(&conversation_id, &user.id, &form.title, &state.db).await?;
|
||||||
|
|
||||||
|
let updated_conversations = User::get_user_conversations(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(TemplateResponse::new_template(
|
||||||
|
"chat/drawer.html",
|
||||||
|
DrawerContext {
|
||||||
|
user,
|
||||||
|
conversation_archive: updated_conversations,
|
||||||
|
edit_conversation_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_conversation(
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
Path(conversation_id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
let conversation: Conversation = state
|
||||||
|
.db
|
||||||
|
.get_item(&conversation_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?;
|
||||||
|
|
||||||
|
if conversation.user_id != user.id {
|
||||||
|
return Ok(TemplateResponse::unauthorized().into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.delete_item::<Conversation>(&conversation_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(TemplateResponse::new_template(
|
||||||
|
"chat/drawer.html",
|
||||||
|
DrawerContext {
|
||||||
|
user,
|
||||||
|
conversation_archive,
|
||||||
|
edit_conversation_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
pub async fn reload_sidebar(
|
||||||
|
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/drawer.html",
|
||||||
|
DrawerContext {
|
||||||
|
user,
|
||||||
|
conversation_archive,
|
||||||
|
edit_conversation_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use chat_handlers::{
|
use chat_handlers::{
|
||||||
new_chat_user_message, new_user_message, show_chat_base, show_existing_chat,
|
delete_conversation, new_chat_user_message, new_user_message, patch_conversation_title,
|
||||||
|
reload_sidebar, show_chat_base, show_conversation_editing_title, show_existing_chat,
|
||||||
show_initialized_chat,
|
show_initialized_chat,
|
||||||
};
|
};
|
||||||
use message_response_stream::get_response_stream;
|
use message_response_stream::get_response_stream;
|
||||||
@@ -23,7 +24,17 @@ where
|
|||||||
{
|
{
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/chat", get(show_chat_base).post(new_chat_user_message))
|
.route("/chat", get(show_chat_base).post(new_chat_user_message))
|
||||||
.route("/chat/:id", get(show_existing_chat).post(new_user_message))
|
.route(
|
||||||
|
"/chat/:id",
|
||||||
|
get(show_existing_chat)
|
||||||
|
.post(new_user_message)
|
||||||
|
.delete(delete_conversation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/chat/:id/title",
|
||||||
|
get(show_conversation_editing_title).patch(patch_conversation_title),
|
||||||
|
)
|
||||||
|
.route("/chat/sidebar", get(reload_sidebar))
|
||||||
.route("/initialized-chat", post(show_initialized_chat))
|
.route("/initialized-chat", post(show_initialized_chat))
|
||||||
.route("/chat/response-stream", get(get_response_stream))
|
.route("/chat/response-stream", get(get_response_stream))
|
||||||
.route("/chat/reference/:id", get(show_reference_tooltip))
|
.route("/chat/reference/:id", get(show_reference_tooltip))
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<div class="drawer-side z-50 max-h-[calc(100vh-65px)]">
|
<div class="drawer-side z-50 max-h-[calc(100vh-65px)]">
|
||||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
<ul class="menu bg-base-200 text-base-content w-72">
|
<ul class="menu bg-base-200 text-base-content w-72 ">
|
||||||
<!-- Sidebar content here -->
|
<!-- Sidebar content here -->
|
||||||
<li class="mt-4 cursor-pointer "><a href="/chat" hx-boost="true" class="flex justify-between">Create new
|
<li class=" mt-4 cursor-pointer "><a href=" /chat" hx-boost="true" class="flex justify-between">Create new
|
||||||
chat<span>{% include
|
chat<span>{% include
|
||||||
"icons/edit_icon.html" %}
|
"icons/edit_icon.html" %}
|
||||||
</span></a></li>
|
</span></a></li>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
{% for conversation in conversation_archive %}
|
{% for conversation in conversation_archive %}
|
||||||
<li><a href="/chat/{{conversation.id}}" hx-boost="true">{{conversation.title}} - {{conversation.created_at}}</a>
|
{% if edit_conversation_id == conversation.id %}
|
||||||
|
<!-- Render the editable title form variant -->
|
||||||
|
<li class="align-center" id="conversation-{{ conversation.id }}">
|
||||||
|
<form hx-patch="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML"
|
||||||
|
class="flex items-center gap-2">
|
||||||
|
<input type="text" name="title" value="{{ conversation.title }}" class="input input-sm" />
|
||||||
|
<button type="submit">{% include "icons/check_icon.html" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" hx-get="/chat/sidebar" hx-target=".drawer-side" hx-swap="outerHTML">{% include
|
||||||
|
"icons/x_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<!-- Render the normal view mode conversation item -->
|
||||||
|
<li class="align-center" id="conversation-{{ conversation.id }}">
|
||||||
|
<div class="justify-between">
|
||||||
|
<a href="/chat/{{ conversation.id }}" hx-boost="true">
|
||||||
|
{{ conversation.title }} - {{ conversation.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||||
|
</a>
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
<button hx-get="/chat/{{ conversation.id }}/title" hx-target=".drawer-side" hx-swap="outerHTML">
|
||||||
|
{% include "icons/edit_icon.html" %}
|
||||||
|
</button>
|
||||||
|
<button hx-delete="/chat/{{ conversation.id }}" hx-target=".drawer-side" hx-swap="outerHTML">
|
||||||
|
{% include "icons/delete_icon.html" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
4
html-router/templates/icons/check_icon.html
Normal file
4
html-router/templates/icons/check_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="m4.5 12.75 6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 221 B |
4
html-router/templates/icons/x_icon.html
Normal file
4
html-router/templates/icons/x_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="M6 18 18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 220 B |
Reference in New Issue
Block a user