diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index b25ca7054..a0b3a8964 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -6,23 +6,39 @@ use module_editor_agent::agent::agent_builder::AgentBuilder; use module_editor_agent::agent::error::PromptError; use module_editor_agent::agent::memory::VecMemory; use module_editor_agent::agent::run::PromptOutput; -use module_editor_agent::derive_conversation_title; +use module_editor_agent::{ + derive_conversation_title, editor_agent_messages_object_key, + EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, +}; use platform_llm::LlmMessage; +use serde::Serialize; use serde_json::{Value, json}; use shared_contracts::editor_agent::{ - EditorAgentConversationMessagesDocument, EditorAgentMessage, EditorAgentMessageRole, + CreateEditorAgentConversationRequest, EditorAgentConversationListResponse, + EditorAgentConversationMessagesDocument, EditorAgentConversationResponse, + EditorAgentConversationSummary, EditorAgentMessage, EditorAgentMessageRole, EditorAgentToolCall, EditorAgentToolCallStatus, StreamEditorAgentMessageRequest, }; -use spacetime_client::EditorAgentConversationTouchRecordInput; +use spacetime_client::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, +}; use crate::auth::AuthenticatedAccessToken; use crate::editor_agent::agent::LlmChatAgentBuilder; use crate::editor_agent::editor_tools::edit_image::EditImageTool; -use crate::editor_agent::utils::{EditorAgentMessageResponse, ImageId, ImageMetadata, now_rfc3339, require_editor_agent_sidebar_enabled, write_messages_document, normalize_editor_agent_attachments, read_messages_document}; -use crate::editor_project::current_utc_micros; +use crate::editor_agent::utils::{ + conversation_detail_from_record, conversation_summary_from_record, empty_messages_document, + ensure_editor_project_access, EditorAgentMessageResponse, ImageId, ImageMetadata, + normalize_editor_agent_attachments, now_rfc3339, read_messages_document, + require_editor_agent_sidebar_enabled, write_messages_document, +}; +use crate::editor_project::{current_utc_micros, map_editor_project_error}; use crate::http_error::AppError; use crate::request_context::RequestContext; +use crate::api_response::json_success_body; use crate::state::AppState; +use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string}; pub async fn editor_agent_message( State(state): State, @@ -72,6 +88,7 @@ pub async fn editor_agent_message( // Save user message to document let was_empty = document.messages.is_empty(); let user_now = now_rfc3339(); + // TODO tell agent info about attachments let user_message = EditorAgentMessage { id: document.messages.len(), role: EditorAgentMessageRole::User, @@ -205,3 +222,140 @@ fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> Val json!({ "images": images }) } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentConversationDeleteResponse { + deleted_conversation_id: String, + conversation: EditorAgentConversationSummary, +} + +pub async fn list_editor_agent_conversations( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + let conversations = state + .spacetime_client() + .list_editor_agent_conversations(project_id, owner_user_id) + .await + .map_err(map_editor_project_error)? + .into_iter() + .map(conversation_summary_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationListResponse { conversations }, + )) +} + +pub async fn create_editor_agent_conversation( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + + let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX); + let messages_object_key = editor_agent_messages_object_key(conversation_id.as_str()); + let title = normalize_optional_string(payload.title) + .unwrap_or_else(|| EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string()); + let now_micros = current_utc_micros(); + let seed_record = EditorAgentConversationRecord { + conversation_id: conversation_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + title: title.clone(), + messages_object_key: messages_object_key.clone(), + deleted: false, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + updated_at_micros: now_micros, + }; + write_messages_document( + &state, + &seed_record, + &empty_messages_document(conversation_id.as_str()), + ) + .await?; + + let conversation = state + .spacetime_client() + .create_editor_agent_conversation(EditorAgentConversationCreateRecordInput { + conversation_id, + project_id, + owner_user_id, + title, + messages_object_key, + created_at_micros: now_micros, + }) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn get_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn delete_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; + let conversation = state + .spacetime_client() + .delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput { + conversation_id, + owner_user_id, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationDeleteResponse { + deleted_conversation_id: conversation.conversation_id.clone(), + conversation: conversation_summary_from_record(conversation), + }, + )) +} diff --git a/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs b/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs index 2d715a9f1..cf5fe40fa 100644 --- a/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs +++ b/server-rs/crates/api-server/src/editor_agent/editor_tools/edit_image.rs @@ -181,6 +181,25 @@ impl EditImageTool { Ok(()) } + // TODO should share with format in run.rs + fn format_execute_message(&self, args: Self::Args, result: EditorImageEditResult) -> String { + let name = Self::NAME.to_string(); + let arg_json = args.to_string(); + + let image_id: ImageId = ImageId { + id: result + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| result.image_src.clone()), + }; + format!( + "[tool_call:{name}] args: {arg_json} output: edited result saved as image: {image_id}" + ) + } + pub async fn execute( &self, state: &AppState, diff --git a/server-rs/crates/api-server/src/editor_agent/mod.rs b/server-rs/crates/api-server/src/editor_agent/mod.rs index 7f3bcb29e..847847d1c 100644 --- a/server-rs/crates/api-server/src/editor_agent/mod.rs +++ b/server-rs/crates/api-server/src/editor_agent/mod.rs @@ -2,3 +2,8 @@ mod editor_tools; mod utils; pub mod api; mod agent; + +pub use api::{ + create_editor_agent_conversation, delete_editor_agent_conversation, + get_editor_agent_conversation, list_editor_agent_conversations, +}; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index b281584d0..e6749896f 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,7 +36,6 @@ mod custom_world_asset_prompts; mod custom_world_foundation_draft; mod custom_world_result_prompts; mod custom_world_rpg_draft_prompts; -mod editor_agent; mod editor_generation_config; mod editor_generation_queue; mod editor_green_screen; @@ -110,6 +109,7 @@ mod wechat; mod wooden_fish; mod work_author; mod work_play_tracking; +mod editor_agent; use shared_logging::{OtelConfig, init_tracing}; use socket2::{Domain, Protocol, Socket, Type}; diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 6cc90c3eb..646228278 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -7,10 +7,10 @@ use axum::{ use crate::{ auth::require_bearer_auth, + editor_agent::api::editor_agent_message, editor_agent::{ create_editor_agent_conversation, delete_editor_agent_conversation, get_editor_agent_conversation, list_editor_agent_conversations, - stream_editor_agent_message, }, editor_project::{ create_editor_asset, create_editor_asset_folder, create_editor_project, @@ -98,8 +98,8 @@ pub fn router(state: AppState) -> Router { )), ) .route( - "/api/editor/agent-conversations/{conversation_id}/messages/stream", - post(stream_editor_agent_message) + "/api/editor/agent-conversations/{conversation_id}/messages", + post(editor_agent_message) .layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)) .route_layer(middleware::from_fn_with_state( state.clone(),