Some checks failed
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #16
742 lines
23 KiB
Rust
742 lines
23 KiB
Rust
//! Chat CRUD server functions for session and message persistence.
|
|
//!
|
|
//! Each function extracts the user's `sub` from the tower-sessions session
|
|
//! to scope all queries to the authenticated user. The `ServerState` provides
|
|
//! access to the MongoDB [`Database`](super::database::Database).
|
|
|
|
use crate::models::{ChatMessage, ChatSession};
|
|
use dioxus::prelude::*;
|
|
|
|
/// Convert a raw BSON document to a `ChatSession`, extracting `_id` as a hex string.
|
|
#[cfg(feature = "server")]
|
|
pub(crate) fn doc_to_chat_session(doc: &mongodb::bson::Document) -> ChatSession {
|
|
use crate::models::ChatNamespace;
|
|
|
|
let id = doc
|
|
.get_object_id("_id")
|
|
.map(|oid| oid.to_hex())
|
|
.unwrap_or_default();
|
|
let namespace = match doc.get_str("namespace").unwrap_or("General") {
|
|
"News" => ChatNamespace::News,
|
|
_ => ChatNamespace::General,
|
|
};
|
|
let article_url = doc
|
|
.get_str("article_url")
|
|
.ok()
|
|
.map(String::from)
|
|
.filter(|s| !s.is_empty());
|
|
|
|
ChatSession {
|
|
id,
|
|
user_sub: doc.get_str("user_sub").unwrap_or_default().to_string(),
|
|
title: doc.get_str("title").unwrap_or_default().to_string(),
|
|
namespace,
|
|
provider: doc.get_str("provider").unwrap_or_default().to_string(),
|
|
model: doc.get_str("model").unwrap_or_default().to_string(),
|
|
created_at: doc.get_str("created_at").unwrap_or_default().to_string(),
|
|
updated_at: doc.get_str("updated_at").unwrap_or_default().to_string(),
|
|
article_url,
|
|
}
|
|
}
|
|
|
|
/// Convert a raw BSON document to a `ChatMessage`, extracting `_id` as a hex string.
|
|
#[cfg(feature = "server")]
|
|
pub(crate) fn doc_to_chat_message(doc: &mongodb::bson::Document) -> ChatMessage {
|
|
use crate::models::ChatRole;
|
|
|
|
let id = doc
|
|
.get_object_id("_id")
|
|
.map(|oid| oid.to_hex())
|
|
.unwrap_or_default();
|
|
let role = match doc.get_str("role").unwrap_or("User") {
|
|
"Assistant" => ChatRole::Assistant,
|
|
"System" => ChatRole::System,
|
|
_ => ChatRole::User,
|
|
};
|
|
ChatMessage {
|
|
id,
|
|
session_id: doc.get_str("session_id").unwrap_or_default().to_string(),
|
|
role,
|
|
content: doc.get_str("content").unwrap_or_default().to_string(),
|
|
attachments: Vec::new(),
|
|
timestamp: doc.get_str("timestamp").unwrap_or_default().to_string(),
|
|
}
|
|
}
|
|
|
|
/// Helper: extract the authenticated user's `sub` from the session.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if the session is missing or unreadable.
|
|
#[cfg(feature = "server")]
|
|
async fn require_user_sub() -> Result<String, ServerFnError> {
|
|
use crate::infrastructure::auth::LOGGED_IN_USER_SESS_KEY;
|
|
use crate::infrastructure::state::UserStateInner;
|
|
use dioxus_fullstack::FullstackContext;
|
|
|
|
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
|
let user: UserStateInner = session
|
|
.get(LOGGED_IN_USER_SESS_KEY)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?
|
|
.ok_or_else(|| ServerFnError::new("not authenticated"))?;
|
|
Ok(user.sub)
|
|
}
|
|
|
|
/// Helper: extract the [`ServerState`] from the request context.
|
|
#[cfg(feature = "server")]
|
|
async fn require_state() -> Result<crate::infrastructure::ServerState, ServerFnError> {
|
|
dioxus_fullstack::FullstackContext::extract().await
|
|
}
|
|
|
|
/// List all chat sessions for the authenticated user, ordered by
|
|
/// `updated_at` descending (most recent first).
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication or the database query fails.
|
|
#[server(endpoint = "list-chat-sessions")]
|
|
pub async fn list_chat_sessions() -> Result<Vec<ChatSession>, ServerFnError> {
|
|
use mongodb::bson::doc;
|
|
use mongodb::options::FindOptions;
|
|
|
|
let user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
let opts = FindOptions::builder()
|
|
.sort(doc! { "updated_at": -1 })
|
|
.build();
|
|
|
|
let mut cursor = state
|
|
.db
|
|
.raw_collection("chat_sessions")
|
|
.find(doc! { "user_sub": &user_sub })
|
|
.with_options(opts)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
|
|
|
let mut sessions = Vec::new();
|
|
use futures::TryStreamExt;
|
|
while let Some(raw_doc) = cursor
|
|
.try_next()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
|
{
|
|
sessions.push(doc_to_chat_session(&raw_doc));
|
|
}
|
|
|
|
Ok(sessions)
|
|
}
|
|
|
|
/// Create a new chat session and return it with the MongoDB-generated ID.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `title` - Display title for the session
|
|
/// * `namespace` - Namespace string: `"General"` or `"News"`
|
|
/// * `provider` - LLM provider name (e.g. "ollama")
|
|
/// * `model` - Model ID (e.g. "llama3.1:8b")
|
|
/// * `article_url` - Source article URL (only for `News` namespace, empty if none)
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication or the insert fails.
|
|
#[server(endpoint = "create-chat-session")]
|
|
pub async fn create_chat_session(
|
|
title: String,
|
|
namespace: String,
|
|
provider: String,
|
|
model: String,
|
|
article_url: String,
|
|
) -> Result<ChatSession, ServerFnError> {
|
|
use crate::models::ChatNamespace;
|
|
|
|
let user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
let ns = if namespace == "News" {
|
|
ChatNamespace::News
|
|
} else {
|
|
ChatNamespace::General
|
|
};
|
|
|
|
let url = if article_url.is_empty() {
|
|
None
|
|
} else {
|
|
Some(article_url)
|
|
};
|
|
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
let session = ChatSession {
|
|
id: String::new(), // MongoDB will generate _id
|
|
user_sub,
|
|
title,
|
|
namespace: ns,
|
|
provider,
|
|
model,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
article_url: url,
|
|
};
|
|
|
|
let result = state
|
|
.db
|
|
.chat_sessions()
|
|
.insert_one(&session)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
|
|
|
// Return the session with the generated ID
|
|
let id = result
|
|
.inserted_id
|
|
.as_object_id()
|
|
.map(|oid| oid.to_hex())
|
|
.unwrap_or_default();
|
|
|
|
Ok(ChatSession { id, ..session })
|
|
}
|
|
|
|
/// Rename a chat session.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `session_id` - The MongoDB document ID of the session
|
|
/// * `new_title` - The new title to set
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication, the session is not found,
|
|
/// or the update fails.
|
|
#[server(endpoint = "rename-chat-session")]
|
|
pub async fn rename_chat_session(
|
|
session_id: String,
|
|
new_title: String,
|
|
) -> Result<(), ServerFnError> {
|
|
use mongodb::bson::{doc, oid::ObjectId};
|
|
|
|
let user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
let oid = ObjectId::parse_str(&session_id)
|
|
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
|
|
|
let result = state
|
|
.db
|
|
.chat_sessions()
|
|
.update_one(
|
|
doc! { "_id": oid, "user_sub": &user_sub },
|
|
doc! { "$set": { "title": &new_title, "updated_at": chrono::Utc::now().to_rfc3339() } },
|
|
)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("update failed: {e}")))?;
|
|
|
|
if result.matched_count == 0 {
|
|
return Err(ServerFnError::new("session not found or not owned by user"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a chat session and all its messages.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `session_id` - The MongoDB document ID of the session
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication or the delete fails.
|
|
#[server(endpoint = "delete-chat-session")]
|
|
pub async fn delete_chat_session(session_id: String) -> Result<(), ServerFnError> {
|
|
use mongodb::bson::{doc, oid::ObjectId};
|
|
|
|
let user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
let oid = ObjectId::parse_str(&session_id)
|
|
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
|
|
|
// Delete the session (scoped to user)
|
|
state
|
|
.db
|
|
.chat_sessions()
|
|
.delete_one(doc! { "_id": oid, "user_sub": &user_sub })
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("delete session failed: {e}")))?;
|
|
|
|
// Delete all messages belonging to this session
|
|
state
|
|
.db
|
|
.chat_messages()
|
|
.delete_many(doc! { "session_id": &session_id })
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("delete messages failed: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all messages for a chat session, ordered by timestamp ascending.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `session_id` - The MongoDB document ID of the session
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication or the query fails.
|
|
#[server(endpoint = "list-chat-messages")]
|
|
pub async fn list_chat_messages(session_id: String) -> Result<Vec<ChatMessage>, ServerFnError> {
|
|
use mongodb::bson::doc;
|
|
use mongodb::options::FindOptions;
|
|
|
|
// Verify the user owns this session
|
|
let user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
// Verify the user owns this session using ObjectId for _id matching
|
|
use mongodb::bson::oid::ObjectId;
|
|
let session_oid = ObjectId::parse_str(&session_id)
|
|
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
|
|
|
let session_exists = state
|
|
.db
|
|
.raw_collection("chat_sessions")
|
|
.count_documents(doc! { "_id": session_oid, "user_sub": &user_sub })
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
|
|
|
if session_exists == 0 {
|
|
return Err(ServerFnError::new("session not found or not owned by user"));
|
|
}
|
|
|
|
let opts = FindOptions::builder().sort(doc! { "timestamp": 1 }).build();
|
|
|
|
let mut cursor = state
|
|
.db
|
|
.raw_collection("chat_messages")
|
|
.find(doc! { "session_id": &session_id })
|
|
.with_options(opts)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?;
|
|
|
|
let mut messages = Vec::new();
|
|
use futures::TryStreamExt;
|
|
while let Some(raw_doc) = cursor
|
|
.try_next()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("cursor error: {e}")))?
|
|
{
|
|
messages.push(doc_to_chat_message(&raw_doc));
|
|
}
|
|
|
|
Ok(messages)
|
|
}
|
|
|
|
/// Persist a single chat message and return it with the MongoDB-generated ID.
|
|
///
|
|
/// Also updates the parent session's `updated_at` timestamp.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `session_id` - The session this message belongs to
|
|
/// * `role` - Message role string: `"user"`, `"assistant"`, or `"system"`
|
|
/// * `content` - Message text content
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if authentication or the insert fails.
|
|
#[server(endpoint = "save-chat-message")]
|
|
pub async fn save_chat_message(
|
|
session_id: String,
|
|
role: String,
|
|
content: String,
|
|
) -> Result<ChatMessage, ServerFnError> {
|
|
use crate::models::ChatRole;
|
|
use mongodb::bson::{doc, oid::ObjectId};
|
|
|
|
let _user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
let chat_role = match role.as_str() {
|
|
"assistant" => ChatRole::Assistant,
|
|
"system" => ChatRole::System,
|
|
_ => ChatRole::User,
|
|
};
|
|
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
let message = ChatMessage {
|
|
id: String::new(),
|
|
session_id: session_id.clone(),
|
|
role: chat_role,
|
|
content,
|
|
attachments: Vec::new(),
|
|
timestamp: now.clone(),
|
|
};
|
|
|
|
let result = state
|
|
.db
|
|
.chat_messages()
|
|
.insert_one(&message)
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("insert failed: {e}")))?;
|
|
|
|
let id = result
|
|
.inserted_id
|
|
.as_object_id()
|
|
.map(|oid| oid.to_hex())
|
|
.unwrap_or_default();
|
|
|
|
// Update session's updated_at timestamp
|
|
if let Ok(session_oid) = ObjectId::parse_str(&session_id) {
|
|
let _ = state
|
|
.db
|
|
.chat_sessions()
|
|
.update_one(
|
|
doc! { "_id": session_oid },
|
|
doc! { "$set": { "updated_at": &now } },
|
|
)
|
|
.await;
|
|
}
|
|
|
|
Ok(ChatMessage { id, ..message })
|
|
}
|
|
|
|
/// Non-streaming chat completion (fallback for article panel).
|
|
///
|
|
/// Sends the full conversation history to the configured LLM provider
|
|
/// and returns the complete response. Used where SSE streaming is not
|
|
/// needed (e.g. dashboard article follow-up panel).
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `session_id` - The chat session ID (loads provider/model config)
|
|
/// * `messages_json` - Conversation history as JSON string:
|
|
/// `[{"role":"user","content":"..."},...]`
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `ServerFnError` if the LLM request fails.
|
|
#[server(endpoint = "chat-complete")]
|
|
pub async fn chat_complete(
|
|
session_id: String,
|
|
messages_json: String,
|
|
) -> Result<String, ServerFnError> {
|
|
use mongodb::bson::{doc, oid::ObjectId};
|
|
|
|
let _user_sub = require_user_sub().await?;
|
|
let state = require_state().await?;
|
|
|
|
// Load the session to get provider/model
|
|
let session_oid = ObjectId::parse_str(&session_id)
|
|
.map_err(|e| ServerFnError::new(format!("invalid session id: {e}")))?;
|
|
|
|
let session_doc = state
|
|
.db
|
|
.raw_collection("chat_sessions")
|
|
.find_one(doc! { "_id": session_oid })
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("db error: {e}")))?
|
|
.ok_or_else(|| ServerFnError::new("session not found"))?;
|
|
let session = doc_to_chat_session(&session_doc);
|
|
|
|
// Resolve provider URL and model
|
|
let (base_url, model) = resolve_provider_url(
|
|
&state.services.ollama_url,
|
|
&state.services.ollama_model,
|
|
&session.provider,
|
|
&session.model,
|
|
);
|
|
|
|
// Parse messages from JSON
|
|
let chat_msgs: Vec<serde_json::Value> = serde_json::from_str(&messages_json)
|
|
.map_err(|e| ServerFnError::new(format!("invalid messages JSON: {e}")))?;
|
|
|
|
let body = serde_json::json!({
|
|
"model": model,
|
|
"messages": chat_msgs,
|
|
"stream": false,
|
|
});
|
|
|
|
let client = reqwest::Client::new();
|
|
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
|
|
|
let resp = client
|
|
.post(&url)
|
|
.header("content-type", "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("LLM request failed: {e}")))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
return Err(ServerFnError::new(format!("LLM returned {status}: {text}")));
|
|
}
|
|
|
|
let json: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| ServerFnError::new(format!("parse error: {e}")))?;
|
|
|
|
json["choices"][0]["message"]["content"]
|
|
.as_str()
|
|
.map(String::from)
|
|
.ok_or_else(|| ServerFnError::new("empty LLM response"))
|
|
}
|
|
|
|
/// Resolve the base URL for a provider, falling back to Ollama defaults.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `ollama_url` - Default Ollama base URL from config
|
|
/// * `ollama_model` - Default Ollama model from config
|
|
/// * `provider` - Provider name (e.g. "openai", "anthropic", "huggingface")
|
|
/// * `model` - Model ID (may be empty for Ollama default)
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A `(base_url, model)` tuple resolved for the given provider.
|
|
#[cfg(feature = "server")]
|
|
pub(crate) fn resolve_provider_url(
|
|
ollama_url: &str,
|
|
ollama_model: &str,
|
|
provider: &str,
|
|
model: &str,
|
|
) -> (String, String) {
|
|
match provider {
|
|
"openai" => ("https://api.openai.com".to_string(), model.to_string()),
|
|
"anthropic" => ("https://api.anthropic.com".to_string(), model.to_string()),
|
|
"huggingface" => (
|
|
format!("https://api-inference.huggingface.co/models/{}", model),
|
|
model.to_string(),
|
|
),
|
|
// Default to Ollama
|
|
_ => (
|
|
ollama_url.to_string(),
|
|
if model.is_empty() {
|
|
ollama_model.to_string()
|
|
} else {
|
|
model.to_string()
|
|
},
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// -----------------------------------------------------------------------
|
|
// BSON document conversion tests (server feature required)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[cfg(feature = "server")]
|
|
mod server_tests {
|
|
use super::super::{doc_to_chat_message, doc_to_chat_session, resolve_provider_url};
|
|
use crate::models::{ChatNamespace, ChatRole};
|
|
use mongodb::bson::{doc, oid::ObjectId, Document};
|
|
use pretty_assertions::assert_eq;
|
|
|
|
// -- doc_to_chat_session --
|
|
|
|
fn sample_session_doc() -> (ObjectId, Document) {
|
|
let oid = ObjectId::new();
|
|
let doc = doc! {
|
|
"_id": oid,
|
|
"user_sub": "user-42",
|
|
"title": "Test Session",
|
|
"namespace": "News",
|
|
"provider": "openai",
|
|
"model": "gpt-4",
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"updated_at": "2025-01-02T00:00:00Z",
|
|
"article_url": "https://example.com/article",
|
|
};
|
|
(oid, doc)
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_extracts_id_as_hex() {
|
|
let (oid, doc) = sample_session_doc();
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.id, oid.to_hex());
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_maps_news_namespace() {
|
|
let (_, doc) = sample_session_doc();
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.namespace, ChatNamespace::News);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_defaults_to_general_for_unknown() {
|
|
let mut doc = sample_session_doc().1;
|
|
doc.insert("namespace", "SomethingElse");
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.namespace, ChatNamespace::General);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_extracts_all_string_fields() {
|
|
let (_, doc) = sample_session_doc();
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.user_sub, "user-42");
|
|
assert_eq!(session.title, "Test Session");
|
|
assert_eq!(session.provider, "openai");
|
|
assert_eq!(session.model, "gpt-4");
|
|
assert_eq!(session.created_at, "2025-01-01T00:00:00Z");
|
|
assert_eq!(session.updated_at, "2025-01-02T00:00:00Z");
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_handles_missing_article_url() {
|
|
let oid = ObjectId::new();
|
|
let doc = doc! {
|
|
"_id": oid,
|
|
"user_sub": "u",
|
|
"title": "t",
|
|
"provider": "ollama",
|
|
"model": "m",
|
|
"created_at": "c",
|
|
"updated_at": "u",
|
|
};
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.article_url, None);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_session_filters_empty_article_url() {
|
|
let oid = ObjectId::new();
|
|
let doc = doc! {
|
|
"_id": oid,
|
|
"user_sub": "u",
|
|
"title": "t",
|
|
"namespace": "News",
|
|
"provider": "ollama",
|
|
"model": "m",
|
|
"created_at": "c",
|
|
"updated_at": "u",
|
|
"article_url": "",
|
|
};
|
|
let session = doc_to_chat_session(&doc);
|
|
assert_eq!(session.article_url, None);
|
|
}
|
|
|
|
// -- doc_to_chat_message --
|
|
|
|
fn sample_message_doc() -> (ObjectId, Document) {
|
|
let oid = ObjectId::new();
|
|
let doc = doc! {
|
|
"_id": oid,
|
|
"session_id": "sess-1",
|
|
"role": "Assistant",
|
|
"content": "Hello there!",
|
|
"timestamp": "2025-01-01T12:00:00Z",
|
|
};
|
|
(oid, doc)
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_extracts_id_as_hex() {
|
|
let (oid, doc) = sample_message_doc();
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert_eq!(msg.id, oid.to_hex());
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_maps_assistant_role() {
|
|
let (_, doc) = sample_message_doc();
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert_eq!(msg.role, ChatRole::Assistant);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_maps_system_role() {
|
|
let mut doc = sample_message_doc().1;
|
|
doc.insert("role", "System");
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert_eq!(msg.role, ChatRole::System);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_defaults_to_user_for_unknown() {
|
|
let mut doc = sample_message_doc().1;
|
|
doc.insert("role", "SomethingElse");
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert_eq!(msg.role, ChatRole::User);
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_extracts_content_and_timestamp() {
|
|
let (_, doc) = sample_message_doc();
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert_eq!(msg.content, "Hello there!");
|
|
assert_eq!(msg.timestamp, "2025-01-01T12:00:00Z");
|
|
assert_eq!(msg.session_id, "sess-1");
|
|
}
|
|
|
|
#[test]
|
|
fn doc_to_chat_message_attachments_always_empty() {
|
|
let (_, doc) = sample_message_doc();
|
|
let msg = doc_to_chat_message(&doc);
|
|
assert!(msg.attachments.is_empty());
|
|
}
|
|
|
|
// -- resolve_provider_url --
|
|
|
|
const TEST_OLLAMA_URL: &str = "http://localhost:11434";
|
|
const TEST_OLLAMA_MODEL: &str = "llama3.1:8b";
|
|
|
|
#[test]
|
|
fn resolve_openai_returns_api_openai() {
|
|
let (url, model) =
|
|
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "openai", "gpt-4o");
|
|
assert_eq!(url, "https://api.openai.com");
|
|
assert_eq!(model, "gpt-4o");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_anthropic_returns_api_anthropic() {
|
|
let (url, model) = resolve_provider_url(
|
|
TEST_OLLAMA_URL,
|
|
TEST_OLLAMA_MODEL,
|
|
"anthropic",
|
|
"claude-3-opus",
|
|
);
|
|
assert_eq!(url, "https://api.anthropic.com");
|
|
assert_eq!(model, "claude-3-opus");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_huggingface_returns_model_url() {
|
|
let (url, model) = resolve_provider_url(
|
|
TEST_OLLAMA_URL,
|
|
TEST_OLLAMA_MODEL,
|
|
"huggingface",
|
|
"meta-llama/Llama-2-7b",
|
|
);
|
|
assert_eq!(
|
|
url,
|
|
"https://api-inference.huggingface.co/models/meta-llama/Llama-2-7b"
|
|
);
|
|
assert_eq!(model, "meta-llama/Llama-2-7b");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_unknown_defaults_to_ollama() {
|
|
let (url, model) =
|
|
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "mistral:7b");
|
|
assert_eq!(url, TEST_OLLAMA_URL);
|
|
assert_eq!(model, "mistral:7b");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_empty_model_falls_back_to_server_default() {
|
|
let (url, model) =
|
|
resolve_provider_url(TEST_OLLAMA_URL, TEST_OLLAMA_MODEL, "ollama", "");
|
|
assert_eq!(url, TEST_OLLAMA_URL);
|
|
assert_eq!(model, TEST_OLLAMA_MODEL);
|
|
}
|
|
}
|
|
}
|