diff options
Diffstat (limited to 'makima/src/server/handlers')
| -rw-r--r-- | makima/src/server/handlers/chat.rs | 246 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/versions.rs | 207 |
3 files changed, 449 insertions, 5 deletions
diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs index 3bdbc74..396c973 100644 --- a/makima/src/server/handlers/chat.rs +++ b/makima/src/server/handlers/chat.rs @@ -10,12 +10,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::{models::BodyElement, repository}; +use crate::db::{models::BodyElement, repository::{self, RepositoryError}}; use crate::llm::{ claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, execute_tool_call, groq::{GroqClient, GroqError, Message, ToolCallResponse}, - LlmModel, ToolCall, ToolResult, AVAILABLE_TOOLS, + LlmModel, ToolCall, ToolResult, VersionToolRequest, AVAILABLE_TOOLS, }; use crate::server::state::{FileUpdateNotification, SharedState}; @@ -236,6 +236,10 @@ pub async fn chat_handler( let mut current_summary = file.summary.clone(); let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); let mut final_response: Option<String> = None; + // Track if a version restore already happened (to avoid double-saving) + let mut version_restored = false; + // Track if there were modifications after a restore + let mut has_changes_after_restore = false; // Multi-turn tool calling loop for round in 0..MAX_TOOL_ROUNDS { @@ -320,15 +324,49 @@ pub async fn chat_handler( // Execute each tool call and add results to conversation for (i, tool_call) in result.tool_calls.iter().enumerate() { - let execution_result = + let mut execution_result = execute_tool_call(tool_call, ¤t_body, current_summary.as_deref()); - // Apply state changes + // Handle version tool requests that need async database access + if let Some(version_request) = &execution_result.version_request { + let version_result = handle_version_request( + pool, + id, + version_request, + ¤t_body, + current_summary.as_deref(), + file.version, + ) + .await; + + // Update execution result with actual version operation result + execution_result.result = version_result.result; + execution_result.parsed_data = version_result.data; + + // Apply state changes from restore operation + if let Some(new_body) = version_result.new_body { + current_body = new_body; + // Mark that a restore happened - file was already saved + version_restored = true; + } + if let Some(new_summary) = version_result.new_summary { + current_summary = Some(new_summary); + } + } + + // Apply state changes from regular tools if let Some(new_body) = execution_result.new_body { current_body = new_body; + // If this is a regular tool (not a version operation), track it + if execution_result.version_request.is_none() && version_restored { + has_changes_after_restore = true; + } } if let Some(new_summary) = execution_result.new_summary { current_summary = Some(new_summary); + if execution_result.version_request.is_none() && version_restored { + has_changes_after_restore = true; + } } // Build tool result message content @@ -378,7 +416,9 @@ pub async fn chat_handler( } // Save changes to database if any tools were executed - if !all_tool_call_infos.is_empty() { + // Skip if a version restore already happened (file was already saved during restore) + // UNLESS there were additional modifications after the restore + if !all_tool_call_infos.is_empty() && (!version_restored || has_changes_after_restore) { let update_req = crate::db::models::UpdateFileRequest { name: None, description: None, @@ -506,3 +546,199 @@ fn build_file_context(file: &crate::db::models::File) -> String { context } + +/// Result of handling a version tool request +struct VersionRequestResult { + result: ToolResult, + data: Option<serde_json::Value>, + new_body: Option<Vec<BodyElement>>, + new_summary: Option<String>, +} + +/// Handle version tool requests that require async database access +async fn handle_version_request( + pool: &sqlx::PgPool, + file_id: Uuid, + request: &VersionToolRequest, + _current_body: &[BodyElement], + _current_summary: Option<&str>, + current_version: i32, +) -> VersionRequestResult { + match request { + VersionToolRequest::ListVersions => { + match repository::list_file_versions(pool, file_id).await { + Ok(versions) => { + let version_data: Vec<serde_json::Value> = versions + .iter() + .map(|v| { + serde_json::json!({ + "version": v.version, + "source": v.source, + "createdAt": v.created_at.to_rfc3339(), + "changeDescription": v.change_description, + }) + }) + .collect(); + + VersionRequestResult { + result: ToolResult { + success: true, + message: format!("Found {} versions. Current version is {}.", versions.len(), current_version), + }, + data: Some(serde_json::json!({ + "currentVersion": current_version, + "versions": version_data, + })), + new_body: None, + new_summary: None, + } + } + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to list versions: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + VersionToolRequest::ReadVersion { version } => { + match repository::get_file_version(pool, file_id, *version).await { + Ok(Some(ver)) => { + // Convert body elements to a readable format + let body_preview: Vec<String> = ver + .body + .iter() + .enumerate() + .map(|(i, element)| { + let desc = match element { + BodyElement::Heading { level, text } => format!("H{}: {}", level, text), + BodyElement::Paragraph { text } => { + let preview = if text.len() > 100 { + format!("{}...", &text[..100]) + } else { + text.clone() + }; + format!("Paragraph: {}", preview) + } + BodyElement::Chart { chart_type, title, .. } => { + format!( + "Chart ({:?}){}", + chart_type, + title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default() + ) + } + BodyElement::Image { alt, .. } => { + format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) + } + }; + format!("[{}] {}", i, desc) + }) + .collect(); + + VersionRequestResult { + result: ToolResult { + success: true, + message: format!( + "Version {} from {} (source: {}). {} body elements.", + ver.version, + ver.created_at.format("%Y-%m-%d %H:%M"), + ver.source, + ver.body.len() + ), + }, + data: Some(serde_json::json!({ + "version": ver.version, + "source": ver.source, + "createdAt": ver.created_at.to_rfc3339(), + "summary": ver.summary, + "bodyPreview": body_preview, + "changeDescription": ver.change_description, + })), + new_body: None, + new_summary: None, + } + } + Ok(None) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Version {} not found", version), + }, + data: None, + new_body: None, + new_summary: None, + }, + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to read version: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + VersionToolRequest::RestoreVersion { target_version, reason } => { + // Set change description if provided + if let Some(reason) = reason { + let _ = repository::set_change_description(pool, reason).await; + } + + match repository::restore_file_version(pool, file_id, *target_version, current_version).await { + Ok(Some(restored_file)) => { + VersionRequestResult { + result: ToolResult { + success: true, + message: format!( + "Restored to version {}. New version is {}.", + target_version, restored_file.version + ), + }, + data: Some(serde_json::json!({ + "previousVersion": current_version, + "restoredFromVersion": target_version, + "newVersion": restored_file.version, + })), + new_body: Some(restored_file.body), + new_summary: restored_file.summary, + } + } + Ok(None) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Version {} not found", target_version), + }, + data: None, + new_body: None, + new_summary: None, + }, + Err(RepositoryError::VersionConflict { expected, actual }) => { + VersionRequestResult { + result: ToolResult { + success: false, + message: format!( + "Version conflict: expected {}, actual {}. Document was modified.", + expected, actual + ), + }, + data: None, + new_body: None, + new_summary: None, + } + } + Err(e) => VersionRequestResult { + result: ToolResult { + success: false, + message: format!("Failed to restore version: {}", e), + }, + data: None, + new_body: None, + new_summary: None, + }, + } + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index c08f1bd..3211f94 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -4,3 +4,4 @@ pub mod chat; pub mod file_ws; pub mod files; pub mod listen; +pub mod versions; diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs new file mode 100644 index 0000000..15118d6 --- /dev/null +++ b/makima/src/server/handlers/versions.rs @@ -0,0 +1,207 @@ +//! HTTP handlers for file version history operations. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use uuid::Uuid; + +use crate::db::models::{FileVersionListResponse, FileVersionSummary, RestoreVersionRequest}; +use crate::db::repository::{self, RepositoryError}; +use crate::server::messages::ApiError; +use crate::server::state::{FileUpdateNotification, SharedState}; + +/// List all versions of a file. +#[utoipa::path( + get, + path = "/api/v1/files/{id}/versions", + params( + ("id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 200, description = "List of file versions", body = FileVersionListResponse), + (status = 404, description = "File not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn list_versions( + State(state): State<SharedState>, + Path(file_id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Check if file exists + match repository::get_file(pool, file_id).await { + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to check file {}: {}", file_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + Ok(Some(_)) => {} + } + + match repository::list_file_versions(pool, file_id).await { + Ok(versions) => { + let summaries: Vec<FileVersionSummary> = + versions.into_iter().map(FileVersionSummary::from).collect(); + let total = summaries.len() as i64; + Json(FileVersionListResponse { + versions: summaries, + total, + }) + .into_response() + } + Err(e) => { + tracing::error!("Failed to list versions for file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific version of a file. +#[utoipa::path( + get, + path = "/api/v1/files/{id}/versions/{version}", + params( + ("id" = Uuid, Path, description = "File ID"), + ("version" = i32, Path, description = "Version number") + ), + responses( + (status = 200, description = "Version details", body = crate::db::models::FileVersion), + (status = 404, description = "Version not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn get_version( + State(state): State<SharedState>, + Path((file_id, version)): Path<(Uuid, i32)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_file_version(pool, file_id, version).await { + Ok(Some(version)) => Json(version).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Version not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get version {} for file {}: {}", version, file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Restore a file to a previous version. +#[utoipa::path( + post, + path = "/api/v1/files/{id}/versions/restore", + params( + ("id" = Uuid, Path, description = "File ID") + ), + request_body = RestoreVersionRequest, + responses( + (status = 200, description = "File restored to previous version", body = crate::db::models::File), + (status = 404, description = "File or version not found", body = ApiError), + (status = 409, description = "Version conflict", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + tag = "Versions" +)] +pub async fn restore_version( + State(state): State<SharedState>, + Path(file_id): Path<Uuid>, + Json(req): Json<RestoreVersionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::restore_file_version(pool, file_id, req.target_version, req.current_version).await { + Ok(Some(file)) => { + // Broadcast update notification + state.broadcast_file_update(FileUpdateNotification { + file_id, + version: file.version, + updated_fields: vec!["body".to_string(), "summary".to_string()], + updated_by: "system".to_string(), + }); + Json(file).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File or version not found")), + ) + .into_response(), + Err(RepositoryError::VersionConflict { expected, actual }) => { + tracing::info!( + "Version conflict on file {} restore: expected {}, actual {}", + file_id, + expected, + actual + ); + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "code": "VERSION_CONFLICT", + "message": format!( + "File was modified by another user. Expected version {}, actual version {}", + expected, actual + ), + "expectedVersion": expected, + "actualVersion": actual, + })), + ) + .into_response() + } + Err(RepositoryError::Database(e)) => { + tracing::error!("Failed to restore file {} to version {}: {}", file_id, req.target_version, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} |
