summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers')
-rw-r--r--makima/src/server/handlers/chat.rs246
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/versions.rs207
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, &current_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,
+ &current_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()
+ }
+ }
+}