use crate::editor_project::{current_utc_micros, map_editor_project_error}; use crate::http_error::AppError; use crate::platform_errors::map_oss_error; use crate::state::AppState; use axum::http::StatusCode; use platform_oss::{ LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, }; use serde_json::{json, Value}; use shared_contracts::assets::{ EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, }; use shared_contracts::editor_agent::{ EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, EditorAgentConversationMessagesDocument, EditorAgentConversationSummary, EditorAgentGeneratedImage, EditorAgentMessage, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, }; use shared_kernel::{normalize_optional_string, normalize_required_string}; use spacetime_client::{ EditorAgentConversationRecord, EditorAssetLibraryRecord, EditorAssetRecord, EditorProjectGetRecordInput, EditorProjectRecord, EditorProjectResourceRecord, }; use std::collections::BTreeMap; use std::sync::{Arc, Mutex, OnceLock}; use platform_editor_agent::agent::asset::ImageId; pub trait IntoDataKey { fn into_data_key(self) -> String; } impl IntoDataKey for EditorAgentAttachmentRef { fn into_data_key(self) -> String { self.object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_start_matches('/').to_string()) // .or_else(|| normalize_required_string(self.reference_id.as_str())) .unwrap_or_else(|| self.image_src.clone()) } } impl IntoDataKey for EditorAgentGeneratedImage { fn into_data_key(self) -> String { self.object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_start_matches('/').to_string()) .unwrap_or(self.image_src) } } impl IntoDataKey for EditorProjectResourceRecord { fn into_data_key(self) -> String { self.object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_start_matches('/').to_string()) .unwrap_or_else(|| self.image_src.clone()) } } impl IntoDataKey for EditorAssetRecord { fn into_data_key(self) -> String { self.object_key .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.trim_start_matches('/').to_string()) .unwrap_or_else(|| self.image_src.clone()) } } pub trait IntoImageId { fn into_image_id(self) -> ImageId; } impl IntoImageId for EditorAgentAttachmentRef { fn into_image_id(self) -> ImageId { ImageId::from_data_key(self.into_data_key()) } } impl IntoImageId for EditorProjectResourceRecord { fn into_image_id(self) -> ImageId { ImageId::from_data_key(self.into_data_key()) } } impl IntoImageId for EditorAssetRecord { fn into_image_id(self) -> ImageId { ImageId::from_data_key(self.into_data_key()) } } // TODO resource id is not traced by now // pub trait IntoResourceId { // fn into_resource_id(self) -> String; // } // // impl IntoResourceId for EditorProjectResourceRecord { // fn into_resource_id(self) -> String { // self.resource_id // } // } // // impl IntoResourceId for EditorAssetRecord { // fn into_resource_id(self) -> String { // self.asset_id // } // } #[cfg(test)] mod image_id_tests { use platform_editor_agent::agent::asset::ImageId; #[test] fn image_id_is_a_stable_hash_of_the_data_key() { let data_key = "editor-projects/proj-1/image.png"; let image_id = ImageId::from_data_key(data_key); assert_eq!(image_id, ImageId::from_data_key(data_key)); assert_ne!(image_id, ImageId::from_data_key("another-image.png")); assert!(image_id.id.starts_with("sha256:")); assert!(!image_id.id.contains(data_key)); } } type EditorAgentConversationLockMap = Mutex>>>; static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); pub fn editor_agent_conversation_lock(conversation_id: &str) -> Arc> { let locks = EDITOR_AGENT_CONVERSATION_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())); let mut locks = locks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); locks .entry(conversation_id.to_string()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() } pub fn now_rfc3339() -> String { shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; pub async fn require_editor_agent_sidebar_enabled( state: &AppState, owner_user_id: &str, ) -> Result<(), AppError> { match state .is_image_editor_agent_sidebar_enabled_for_user(Some(owner_user_id)) .await { Ok(true) => Ok(()), Ok(false) => Err(editor_agent_sidebar_unavailable()), Err(error) => Err(AppError::from_status(StatusCode::BAD_GATEWAY) .with_message("读取画布 Agent 灰度配置失败") .with_details(json!({ "provider": "spacetimedb", "message": error.to_string(), }))), } } fn editor_agent_sidebar_unavailable() -> AppError { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) .with_message("画布 Agent 暂不可用") .with_details(json!({ "provider": "editor-agent", "reason": "image_editor_agent_sidebar_disabled", "gateKey": module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY, })) } // const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; pub fn editor_agent_bad_request(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-agent", "message": message.into(), })) } fn editor_agent_oss_unavailable() -> AppError { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS is not configured for editor agent conversations", })) } fn editor_agent_oss_read_error(message: impl Into) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", "message": message.into(), })) } fn editor_agent_messages_document_too_large() -> AppError { AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ "provider": "editor-agent", "message": "message document is too large", "maxBytes": EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES, })) } pub async fn write_messages_document( state: &AppState, conversation: &EditorAgentConversationRecord, document: &EditorAgentConversationMessagesDocument, ) -> Result<(), AppError> { if document.conversation_id != conversation.conversation_id { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "editor-agent", "message": "message document conversationId does not match metadata", })), ); } let body = serde_json::to_vec(document).map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-agent", "message": format!("failed to serialize message document: {error}"), })) })?; if body.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { return Err(editor_agent_messages_document_too_large()); } let oss_client = state .oss_client() .ok_or_else(editor_agent_oss_unavailable)?; let put_result = oss_client .put_object( &reqwest::Client::new(), OssPutObjectRequest { prefix: LegacyAssetPrefix::EditorAgent, path_segments: Vec::new(), file_name: format!("{}.json", conversation.conversation_id), content_type: Some("application/json; charset=utf-8".to_string()), access: OssObjectAccess::Private, metadata: BTreeMap::from([ ( "conversation-id".to_string(), conversation.conversation_id.clone(), ), ("project-id".to_string(), conversation.project_id.clone()), ( "owner-user-id".to_string(), conversation.owner_user_id.clone(), ), ]), body, }, ) .await .map_err(|error| map_oss_error(error, "aliyun-oss"))?; if put_result.object_key != conversation.messages_object_key { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ "provider": "editor-agent", "message": "OSS object key mismatch while writing message document", "expectedObjectKey": conversation.messages_object_key, "actualObjectKey": put_result.object_key, })), ); } Ok(()) } const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; pub async fn read_messages_document( state: &AppState, conversation: &EditorAgentConversationRecord, ) -> Result { let oss_client = state .oss_client() .ok_or_else(editor_agent_oss_unavailable)?; let signed = oss_client .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { object_key: conversation.messages_object_key.clone(), expire_seconds: Some(EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; let response = reqwest::Client::new() .get(signed.signed_url.as_str()) .send() .await .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; if response.status() == StatusCode::NOT_FOUND { return Ok(empty_messages_document( conversation.conversation_id.as_str(), )); } if !response.status().is_success() { return Err(editor_agent_oss_read_error(format!( "OSS returned non-success status {}", response.status().as_u16() ))); } if response .content_length() .is_some_and(|size| size > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES as u64) { return Err(editor_agent_messages_document_too_large()); } let bytes = response .bytes() .await .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; if bytes.is_empty() { return Ok(empty_messages_document( conversation.conversation_id.as_str(), )); } if bytes.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { return Err(editor_agent_messages_document_too_large()); } let document: EditorAgentConversationMessagesDocument = serde_json::from_slice(&bytes) .map_err(|error| { editor_agent_oss_read_error(format!("message document JSON invalid: {error}")) })?; if document.conversation_id != conversation.conversation_id { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "editor-agent", "message": "message document conversationId does not match metadata", "conversationId": conversation.conversation_id, "documentConversationId": document.conversation_id, })), ); } Ok(document) } pub async fn ensure_editor_project_access( state: &AppState, project_id: &str, owner_user_id: &str, ) -> Result<(), AppError> { state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), }) .await .map(|_| ()) .map_err(map_editor_project_error) } pub async fn normalize_editor_agent_attachments( state: &AppState, conversation: &EditorAgentConversationRecord, attachments: &[EditorAgentAttachmentRef], ) -> Result, AppError> { if attachments.is_empty() { return Ok(Vec::new()); } let needs_canvas_resources = attachments .iter() .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource); let needs_library_assets = attachments .iter() .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset); let project = if needs_canvas_resources { Some( state .spacetime_client() .get_editor_project(EditorProjectGetRecordInput { project_id: conversation.project_id.clone(), owner_user_id: conversation.owner_user_id.clone(), }) .await .map_err(map_editor_project_error)?, ) } else { None }; let library = if needs_library_assets { Some( state .spacetime_client() .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) .await .map_err(map_editor_project_error)?, ) } else { None }; attachments .iter() .map(|attachment| { normalize_editor_agent_attachment( conversation, project.as_ref(), library.as_ref(), attachment, ) }) .collect() } fn normalize_editor_agent_attachment( conversation: &EditorAgentConversationRecord, project: Option<&EditorProjectRecord>, library: Option<&EditorAssetLibraryRecord>, attachment: &EditorAgentAttachmentRef, ) -> Result { let reference_id = normalize_required_string(attachment.reference_id.as_str()) .ok_or_else(|| editor_agent_bad_request("attachment.referenceId is required"))?; match attachment.source { EditorAgentAttachmentSource::CanvasResource => { let project = project.ok_or_else(|| { editor_agent_bad_request("canvas resource attachment project context missing") })?; let resource = project .resources .iter() .find(|resource| resource.resource_id == reference_id) .ok_or_else(|| { editor_agent_bad_request(format!( "canvas resource attachment not found in current project: {reference_id}" )) })?; normalize_canvas_resource_attachment(conversation, attachment, resource) } EditorAgentAttachmentSource::LibraryAsset => { let library = library.ok_or_else(|| { editor_agent_bad_request("library asset attachment context missing") })?; let asset = library .assets .iter() .find(|asset| asset.asset_id == reference_id) .ok_or_else(|| { editor_agent_bad_request(format!( "library asset attachment not found for current user: {reference_id}" )) })?; normalize_library_asset_attachment(attachment, asset) } } } pub fn normalize_canvas_resource_attachment( conversation: &EditorAgentConversationRecord, attachment: &EditorAgentAttachmentRef, resource: &EditorProjectResourceRecord, ) -> Result { if resource.project_id != conversation.project_id || resource.owner_user_id != conversation.owner_user_id { return Err(editor_agent_bad_request( "canvas resource attachment does not belong to this conversation project", )); } validate_attachment_object_key( attachment.object_key.as_deref(), resource.object_key.as_deref(), resource.resource_id.as_str(), )?; Ok(EditorAgentAttachmentRef { source: EditorAgentAttachmentSource::CanvasResource, reference_id: resource.resource_id.clone(), object_key: resource.object_key.clone(), image_src: resource.image_src.clone(), thumbnail_src: None, label: normalize_optional_string(attachment.label.clone()), width: Some(resource.width), height: Some(resource.height), }) } pub fn normalize_library_asset_attachment( attachment: &EditorAgentAttachmentRef, asset: &EditorAssetRecord, ) -> Result { validate_attachment_object_key( attachment.object_key.as_deref(), asset.object_key.as_deref(), asset.asset_id.as_str(), )?; Ok(EditorAgentAttachmentRef { source: EditorAgentAttachmentSource::LibraryAsset, reference_id: asset.asset_id.clone(), object_key: asset.object_key.clone(), image_src: asset.image_src.clone(), thumbnail_src: asset.thumbnail_src.clone(), label: normalize_optional_string(attachment.label.clone()) .or_else(|| Some(asset.label.clone())), width: Some(asset.width), height: Some(asset.height), }) } fn validate_attachment_object_key( submitted_object_key: Option<&str>, stored_object_key: Option<&str>, reference_id: &str, ) -> Result<(), AppError> { let Some(submitted_object_key) = submitted_object_key.and_then(normalize_required_string) else { return Ok(()); }; let Some(stored_object_key) = stored_object_key.and_then(normalize_required_string) else { return Err(editor_agent_bad_request(format!( "attachment objectKey is not available for reference: {reference_id}" ))); }; if submitted_object_key != stored_object_key { return Err(editor_agent_bad_request(format!( "attachment objectKey does not match reference: {reference_id}" ))); } Ok(()) } pub fn conversation_summary_from_record( conversation: EditorAgentConversationRecord, ) -> EditorAgentConversationSummary { EditorAgentConversationSummary { conversation_id: conversation.conversation_id, project_id: conversation.project_id, title: conversation.title, updated_at: conversation.updated_at, } } pub fn conversation_detail_from_record( conversation: EditorAgentConversationRecord, messages: Vec, ) -> EditorAgentConversationDetail { EditorAgentConversationDetail { conversation_id: conversation.conversation_id, project_id: conversation.project_id, title: conversation.title, created_at: conversation.created_at, updated_at: conversation.updated_at, messages, } } pub fn build_editor_agent_canvas_completion( project: &EditorProjectRecord, tool_name: &str, title: &str, ) -> EditorCanvasGenerationCompletionPayload { let (width, height) = editor_agent_tool_display_size(tool_name); let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height); EditorCanvasGenerationCompletionPayload { dialog_id: None, title: title.to_string(), placeholder: EditorCanvasGenerationPlaceholderPayload { x, y, width, height, original_width: width, original_height: height, }, } } fn editor_agent_tool_display_size(tool_name: &str) -> (f64, f64) { match tool_name { "ui-design" | "generate-ui-design" => (640.0, 360.0), "generate-character" => (512.0, 768.0), _ => (512.0, 512.0), } } const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; fn next_editor_agent_canvas_position( layers: serde_json::Value, _width: f64, _height: f64, ) -> (f64, f64) { let mut max_right: Option = None; let mut min_y: Option = None; if let Value::Array(items) = layers { for item in items { if item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { continue; } let Some(x) = item.get("x").and_then(Value::as_f64) else { continue; }; let Some(y) = item.get("y").and_then(Value::as_f64) else { continue; }; let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.0); if !x.is_finite() || !y.is_finite() || !width.is_finite() { continue; } max_right = Some(max_right.map_or(x + width, |value| value.max(x + width))); min_y = Some(min_y.map_or(y, |value| value.min(y))); } } ( max_right.map_or(0.0, |right| right + EDITOR_AGENT_CANVAS_RESULT_GAP), min_y.unwrap_or(0.0), ) } pub fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { EditorAgentConversationMessagesDocument { version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, conversation_id: conversation_id.to_string(), messages: Vec::new(), } }