feat: chat conversation list titles, sorting, etc

This commit is contained in:
Per Stark
2025-04-15 16:34:44 +02:00
parent d4f097ab98
commit 5af217170c
9 changed files with 309 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
use surrealdb::opt::PatchOp;
use uuid::Uuid;
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
@@ -46,6 +47,31 @@ impl Conversation {
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)]
@@ -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]
async fn test_get_complete_conversation_with_messages() {
// Setup in-memory database for testing

View File

@@ -580,7 +580,7 @@ mod tests {
// Should fail with FileNotFound error
assert!(result.is_err());
match result {
Err(FileError::FileNotFound(_)) => {
Err(AppError::File(_)) => {
// Expected error
}
_ => panic!("Expected FileNotFound error"),

View File

@@ -96,6 +96,8 @@ impl IngestionPayload {
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
// Create a mock FileInfo for testing

View File

@@ -421,7 +421,10 @@ impl User {
) -> Result<Vec<Conversation>, AppError> {
let conversations: Vec<Conversation> = db
.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(("user_id", user_id.to_string()))
.await?
@@ -775,4 +778,41 @@ mod tests {
let updated_user = updated_user.unwrap();
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);
}
}

View File

@@ -224,3 +224,110 @@ pub async fn new_chat_user_message(
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())
}

View File

@@ -8,7 +8,8 @@ use axum::{
Router,
};
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,
};
use message_response_stream::get_response_stream;
@@ -23,7 +24,17 @@ where
{
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(
"/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("/chat/response-stream", get(get_response_stream))
.route("/chat/reference/:id", get(show_reference_tooltip))

View File

@@ -1,15 +1,44 @@
<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">
<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
<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>
{% 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>
{% 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 %}
</ul>
</div>

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="m4.5 12.75 6 6 9-13.5" />
</svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>

After

Width:  |  Height:  |  Size: 220 B