From 37b4329fee5a40028a20749fc3866e19b8e8e1b2 Mon Sep 17 00:00:00 2001 From: kvtodev Date: Fri, 10 Jul 2026 10:26:08 +0800 Subject: [PATCH] remove old `editor_agent.rs` --- .../crates/api-server/src/editor_agent.rs | 2809 ----------------- 1 file changed, 2809 deletions(-) delete mode 100644 server-rs/crates/api-server/src/editor_agent.rs diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs deleted file mode 100644 index 6ac84f022..000000000 --- a/server-rs/crates/api-server/src/editor_agent.rs +++ /dev/null @@ -1,2809 +0,0 @@ -use std::{ - collections::BTreeMap, - convert::Infallible, - sync::{Arc, Mutex, OnceLock}, -}; - -use axum::{ - Json, - extract::{Extension, Path, State}, - http::StatusCode, - response::{ - IntoResponse, Response, - sse::{Event, Sse}, - }, -}; -use module_editor_agent::{ - EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, - EDITOR_AGENT_MESSAGE_ID_PREFIX, derive_conversation_title, editor_agent_messages_object_key, - validate_user_message, -}; -use platform_llm::{LlmError, LlmErrorKind, LlmMessage, LlmMessageContentPart, LlmTextRequest}; -use platform_oss::{ - LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use shared_contracts::assets::{ - EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, -}; -use shared_contracts::editor_agent::{ - CreateEditorAgentConversationRequest, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, - EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, - EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, - EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentDoneEvent, - EditorAgentErrorEvent, EditorAgentGeneratedImage, EditorAgentGenerationRecord, - EditorAgentGenerationResultEvent, EditorAgentGenerationStatus, EditorAgentMessage, - EditorAgentMessageDeltaEvent, EditorAgentMessageKind, EditorAgentMessageRole, - EditorAgentMessageStatus, EditorAgentStage, EditorAgentStageEvent, EditorAgentToolEvent, - EditorAgentToolName, StreamEditorAgentMessageRequest, -}; -use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; -use spacetime_client::{ - EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, - EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, - EditorAssetLibraryRecord, EditorAssetRecord, EditorProjectGetRecordInput, EditorProjectRecord, - EditorProjectResourceRecord, -}; - -use crate::{ - api_response::json_success_body, - auth::AuthenticatedAccessToken, - editor_project::{ - EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, - EditorImageGenerationRequest, current_utc_micros, edit_editor_image_for_owner, - generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, - map_editor_project_error, - }, - http_error::AppError, - platform_errors::map_oss_error, - request_context::RequestContext, - state::AppState, -}; - -const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; -const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; -const EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS: u64 = 300; -const EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES: usize = 12; -const EDITOR_AGENT_LLM_PLANNING_MODEL: &str = platform_agent::CREATIVE_AGENT_GPT5_MODEL; -const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; -const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000; -const EDITOR_AGENT_TOOL_CALL_ID_PREFIX: &str = "editor-agent-tool"; -const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; -type EditorAgentConversationLockMap = Mutex>>>; -static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); - -#[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), - }, - )) -} - -pub async fn stream_editor_agent_message( - State(state): State, - Path(conversation_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, -) -> Result { - let owner_user_id = authenticated.claims().user_id().to_string(); - require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?; - let client_message_id = normalize_required_string(payload.client_message_id.as_str()) - .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; - let attachment_reference_ids = payload - .attachments - .iter() - .map(|attachment| attachment.reference_id.clone()) - .collect::>(); - validate_user_message(payload.text.as_str(), &attachment_reference_ids) - .map_err(|error| editor_agent_bad_request(error.to_string()))?; - - let conversation = state - .spacetime_client() - .get_editor_agent_conversation(conversation_id, owner_user_id.clone()) - .await - .map_err(map_editor_project_error)?; - let attachments = - normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice()) - .await?; - let conversation_lock = editor_agent_conversation_lock(conversation.conversation_id.as_str()); - let conversation_lock_guard = conversation_lock.lock_owned().await; - let mut document = read_messages_document(&state, &conversation).await?; - if document - .messages - .iter() - .any(|message| message.id == client_message_id) - { - return Err( - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ - "provider": "editor-agent", - "field": "clientMessageId", - "message": "clientMessageId already exists in this conversation", - })), - ); - } - - let was_empty = document.messages.is_empty(); - let user_message = EditorAgentMessage { - id: client_message_id, - role: EditorAgentMessageRole::User, - kind: EditorAgentMessageKind::Chat, - text: payload.text.trim().to_string(), - attachments, - generations: Vec::new(), - status: EditorAgentMessageStatus::Completed, - created_at: now_rfc3339(), - }; - document.messages.push(user_message.clone()); - write_messages_document(&state, &conversation, &document).await?; - let latest_generation_reference = latest_editor_agent_generated_image_reference(&document); - - let assistant_message_id = build_prefixed_uuid_id(EDITOR_AGENT_MESSAGE_ID_PREFIX); - let stream_state = state.clone(); - let stream_conversation = conversation.clone(); - let stream_title = was_empty.then(|| derive_conversation_title(user_message.text.as_str())); - let stream_assistant_message_id = assistant_message_id.clone(); - let stream_user_message = user_message.clone(); - let stream_latest_generation_reference = latest_generation_reference.clone(); - let stream_request_context = request_context.clone(); - let stream_conversation_lock_guard = conversation_lock_guard; - - let stream = async_stream::stream! { - let _conversation_lock_guard = stream_conversation_lock_guard; - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Thinking, - }, - )); - - let mut assistant_status = EditorAgentMessageStatus::Completed; - let mut generation_records = Vec::new(); - let mut planning_failed = false; - - let turn_plan = match plan_editor_agent_turn( - &stream_state, - &stream_conversation, - &document, - &stream_user_message, - stream_latest_generation_reference.as_ref(), - ) - .await { - Ok(turn_plan) => turn_plan, - Err(error) => { - let app_error = error.into_app_error(); - let error_message = app_error.body_text(); - assistant_status = EditorAgentMessageStatus::Failed; - planning_failed = true; - EditorAgentTurnPlan { - reply_text: error_message, - tool_call: None, - } - } - }; - let assistant_text = turn_plan.reply_text; - let assistant_kind = if assistant_status == EditorAgentMessageStatus::Failed { - EditorAgentMessageKind::Error - } else { - EditorAgentMessageKind::Chat - }; - - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Responding, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "message_delta", - EditorAgentMessageDeltaEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - role: EditorAgentMessageRole::Assistant, - kind: assistant_kind, - text_delta: assistant_text.clone(), - }, - )); - if planning_failed { - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - } - - if let Some(tool_call) = turn_plan.tool_call { - let tool_call_id = build_prefixed_uuid_id(EDITOR_AGENT_TOOL_CALL_ID_PREFIX); - let tool_summary = tool_call - .summary - .clone() - .or_else(|| Some(editor_agent_tool_default_summary(tool_call.tool_name))); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_started", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id: tool_call_id.clone(), - tool_name: tool_call.tool_name, - summary: tool_summary.clone(), - task_id: None, - model: tool_call.model.clone(), - status: Some(EditorAgentGenerationStatus::Generating), - error: None, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Generating, - }, - )); - - match execute_editor_agent_tool_call( - &stream_state, - &stream_request_context, - &stream_conversation, - &stream_user_message, - &tool_call_id, - stream_latest_generation_reference.as_ref(), - tool_call, - ) - .await - { - Ok(execution) => { - generation_records.push(execution.record.clone()); - yield Ok::(editor_agent_sse_json_event_or_error( - "generation_result", - EditorAgentGenerationResultEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id: tool_call_id.clone(), - tool_name: execution.record.tool_name, - model: execution.record.model.clone(), - images: execution.record.images.clone(), - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_completed", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id, - tool_name: execution.record.tool_name, - summary: Some(execution.summary), - task_id: execution.record.task_id.clone(), - model: execution.record.model.clone(), - status: Some(EditorAgentGenerationStatus::Completed), - error: None, - }, - )); - } - Err(error) => { - let error_code = error.error.code().to_string(); - let error_message = error.error.body_text(); - let error_model = error.model.clone(); - assistant_status = EditorAgentMessageStatus::Failed; - generation_records.push(EditorAgentGenerationRecord { - tool_call_id: tool_call_id.clone(), - tool_name: error.tool_name, - summary: tool_summary.clone(), - task_id: None, - status: EditorAgentGenerationStatus::Failed, - model: error_model.clone(), - images: Vec::new(), - error: Some(error_message.clone()), - }); - yield Ok::(editor_agent_sse_json_event_or_error( - "tool_completed", - EditorAgentToolEvent { - conversation_id: stream_conversation.conversation_id.clone(), - message_id: stream_assistant_message_id.clone(), - tool_call_id, - tool_name: error.tool_name, - summary: tool_summary, - task_id: None, - model: error_model, - status: Some(EditorAgentGenerationStatus::Failed), - error: Some(error_message.clone()), - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: error_code, - message: error_message, - recoverable: true, - }, - )); - } - } - } - - let write_result = async { - let mut next_document = read_messages_document(&stream_state, &stream_conversation).await?; - if !next_document - .messages - .iter() - .any(|message| message.id == stream_assistant_message_id) - { - next_document.messages.push(EditorAgentMessage { - id: stream_assistant_message_id.clone(), - role: EditorAgentMessageRole::Assistant, - kind: if assistant_status == EditorAgentMessageStatus::Failed { - EditorAgentMessageKind::Error - } else { - EditorAgentMessageKind::Chat - }, - text: assistant_text, - attachments: Vec::new(), - generations: generation_records, - status: assistant_status, - created_at: now_rfc3339(), - }); - } - write_messages_document(&stream_state, &stream_conversation, &next_document).await - } - .await; - match write_result { - Ok(()) => { - let mut done_title = None; - if let Some(title) = stream_title { - match stream_state.spacetime_client().touch_editor_agent_conversation( - EditorAgentConversationTouchRecordInput { - conversation_id: stream_conversation.conversation_id.clone(), - owner_user_id: stream_conversation.owner_user_id.clone(), - title: Some(title.clone()), - updated_at_micros: current_utc_micros(), - }, - ).await { - Ok(updated) => done_title = Some(updated.title), - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: "SPACETIME_UPDATE_FAILED".to_string(), - message: error.to_string(), - recoverable: true, - }, - )); - } - } - } else { - match stream_state.spacetime_client().touch_editor_agent_conversation( - EditorAgentConversationTouchRecordInput { - conversation_id: stream_conversation.conversation_id.clone(), - owner_user_id: stream_conversation.owner_user_id.clone(), - title: None, - updated_at_micros: current_utc_micros(), - }, - ).await { - Ok(_) => {} - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: "SPACETIME_UPDATE_FAILED".to_string(), - message: error.to_string(), - recoverable: true, - }, - )); - } - } - } - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Completed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "done", - EditorAgentDoneEvent { - conversation_id: stream_conversation.conversation_id.clone(), - title: done_title, - }, - )); - } - Err(error) => { - yield Ok::(editor_agent_sse_json_event_or_error( - "stage", - EditorAgentStageEvent { - conversation_id: stream_conversation.conversation_id.clone(), - stage: EditorAgentStage::Failed, - }, - )); - yield Ok::(editor_agent_sse_json_event_or_error( - "error", - EditorAgentErrorEvent { - conversation_id: Some(stream_conversation.conversation_id.clone()), - code: error.code().to_string(), - message: error.body_text(), - recoverable: true, - }, - )); - } - } - }; - - Ok(Sse::new(stream).into_response()) -} - -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() -} - -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) -} - -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) - } - } -} - -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), - }) -} - -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(()) -} - -fn conversation_summary_from_record( - conversation: EditorAgentConversationRecord, -) -> EditorAgentConversationSummary { - EditorAgentConversationSummary { - conversation_id: conversation.conversation_id, - project_id: conversation.project_id, - title: conversation.title, - created_at: conversation.created_at, - updated_at: conversation.updated_at, - } -} - -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, - } -} - -fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { - EditorAgentConversationMessagesDocument { - version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, - conversation_id: conversation_id.to_string(), - messages: Vec::new(), - } -} - -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() == reqwest::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) -} - -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(()) -} - -#[derive(Clone, Debug, PartialEq)] -struct EditorAgentTurnPlan { - reply_text: String, - tool_call: Option, -} - -#[derive(Clone, Debug, PartialEq)] -struct EditorAgentToolCallPlan { - tool_name: EditorAgentToolName, - prompt: String, - summary: Option, - model: Option, - aspect_ratio: Option, - image_size: Option, - icon_descriptions: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EditorAgentRawTurnPlan { - reply_text: Option, - tool_call: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EditorAgentRawToolCallPlan { - tool_name: EditorAgentToolName, - prompt: Option, - summary: Option, - model: Option, - aspect_ratio: Option, - image_size: Option, - icon_descriptions: Option>, -} - -#[derive(Debug)] -struct EditorAgentToolExecution { - record: EditorAgentGenerationRecord, - summary: String, -} - -#[derive(Clone, Debug)] -struct EditorAgentGeneratedImageReference { - source_message_id: String, - tool_call_id: String, - tool_name: EditorAgentToolName, - summary: Option, - model: Option, - resource_id: Option, - object_key: Option, - asset_object_id: Option, - image_src: String, - thumbnail_src: Option, - width: Option, - height: Option, -} - -#[derive(Debug)] -struct EditorAgentToolExecutionError { - tool_name: EditorAgentToolName, - model: Option, - error: AppError, -} - -#[derive(Debug)] -enum EditorAgentPlanningError { - LlmUnavailable, - LlmRequestFailed(LlmError), - InvalidLlmResponse, -} - -impl EditorAgentPlanningError { - fn into_app_error(self) -> AppError { - match self { - Self::LlmUnavailable => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_details(json!({ - "provider": "editor-agent-llm", - "message": "画布 Agent 的 LLM 未配置,无法处理这句话。", - })), - Self::LlmRequestFailed(error) => { - let status = match error.kind() { - LlmErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT, - LlmErrorKind::InvalidConfig | LlmErrorKind::InvalidRequest => { - StatusCode::SERVICE_UNAVAILABLE - } - LlmErrorKind::Connectivity - | LlmErrorKind::Upstream - | LlmErrorKind::StreamUnavailable - | LlmErrorKind::EmptyResponse - | LlmErrorKind::Transport - | LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY, - }; - AppError::from_status(status).with_details(json!({ - "provider": "editor-agent-llm", - "message": format!("画布 Agent 调用 LLM 失败:{error}"), - })) - } - Self::InvalidLlmResponse => AppError::from_status(StatusCode::BAD_GATEWAY) - .with_details(json!({ - "provider": "editor-agent-llm", - "message": "画布 Agent 的 LLM 返回格式错误,未能解析回复。", - })), - } - } -} - -async fn plan_editor_agent_turn( - state: &AppState, - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> Result { - let Some(llm_client) = state.creative_agent_gpt5_client() else { - return Err(EditorAgentPlanningError::LlmUnavailable); - }; - - let request = build_editor_agent_llm_request( - state, - conversation, - document, - user_message, - latest_generation_reference, - ) - .await; - let response = llm_client - .request_text(request) - .await - .map_err(EditorAgentPlanningError::LlmRequestFailed)?; - parse_editor_agent_turn_plan(response.content.as_str()) - .map(|plan| { - apply_previous_generation_edit_default(plan, user_message, latest_generation_reference) - }) - .ok_or(EditorAgentPlanningError::InvalidLlmResponse) -} - -async fn build_editor_agent_llm_request( - state: &AppState, - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> LlmTextRequest { - let mut user_parts = vec![LlmMessageContentPart::InputText { - text: build_editor_agent_llm_user_prompt( - conversation, - document, - user_message, - latest_generation_reference, - ), - }]; - for attachment in &user_message.attachments { - if let Some(image_url) = sign_editor_agent_attachment_image_url(state, attachment) { - user_parts.push(LlmMessageContentPart::InputImage { image_url }); - } - } - if user_message.attachments.is_empty() - && editor_agent_text_mentions_previous_generation(user_message.text.as_str()) - { - if let Some(reference) = latest_generation_reference { - if let Some(image_url) = sign_editor_agent_generated_image_url(state, reference) { - user_parts.push(LlmMessageContentPart::InputImage { image_url }); - } - } - } - - LlmTextRequest::new(vec![ - LlmMessage::system(editor_agent_llm_system_prompt()), - LlmMessage::user_multimodal(user_parts), - ]) - .with_model(EDITOR_AGENT_LLM_PLANNING_MODEL) - .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS) - .with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) -} - -fn editor_agent_llm_system_prompt() -> String { - [ - "你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。", - "必须只输出一个 JSON 对象,不要输出 Markdown、解释或额外文本。", - "JSON 结构:{\"replyText\":\"给用户看的中文回复\",\"toolCall\":null 或 {\"toolName\":\"generate_image|edit_image|generate_character|generate_icon_spritesheet|generate_ui_design\",\"prompt\":\"生成或修改提示词\",\"summary\":\"短动作摘要\",\"model\":null,\"aspectRatio\":null,\"imageSize\":\"1K\",\"iconDescriptions\":[\"图标描述\"]}}。", - "只有用户明确要求生成、重绘、修改、画 UI、出角色或图标时才给 toolCall;普通咨询、评价、解释时 toolCall 为 null。", - "工具说明:", - "generate_image:从文字生成一张全新图片,适合新场景、新物体、新插画、新背景、规范图/视觉规范图/风格规范图/素材规范展板;不要用于修改上一张图或附件图。prompt 必须写完整画面、主体、风格、构图和背景。", - "edit_image:修改已有图片,适合换衣服、改颜色、替换背景、局部重绘、保持主体/构图/姿势不变的编辑请求;必须使用 latestAttachments 或 latestGeneratedImage 作为源图。", - "generate_character:生成新的角色形象、人物立绘或角色设定图;如果用户是在改上一张角色图的服装、颜色、表情、姿势或背景,应改用 edit_image。", - "generate_icon_spritesheet:生成一组图标素材或图标图集,适合用户明确要多个 icon / 图标 / spritesheet;必须有 latestAttachments 作为图标规范或风格参考,并填写 iconDescriptions。", - "generate_ui_design:生成一张完整 UI 设计图或界面稿,适合 HUD、弹窗、面板、按钮组合和整页界面;不要用于提取图标、拆素材或修改上一张图。", - "规范图要求:用户要求生成规范图/视觉规范图/风格规范图/素材规范展板时,当前使用 generate_image;prompt 必须明确这是规范展板,并写入统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡/色号、尺寸标注和排版层级。", - "角色规范图/角色美术视觉规范设定图属于规范展板时使用 generate_image,prompt 要包含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属角色色卡;只有用户要生成单个全新角色形象/立绘/普通角色设定图时才使用 generate_character。", - "图标规范图/图标视觉规范展板属于规范展板时使用 generate_image;只有用户明确要生成多个图标成品、图标素材图集或 spritesheet,并提供图标规范/风格参考附件时才使用 generate_icon_spritesheet。", - "UI 规范图/组件规范展板如果是规范展板而非完整可用界面稿,也使用 generate_image;完整界面稿/HUD/弹窗/面板才使用 generate_ui_design。", - "工具选择优先级:明确修改/指代已有图 => edit_image;规范图/视觉规范图/风格规范图/素材规范展板 => generate_image;明确新角色且不是规范展板或修改已有图 => generate_character;多个图标成品/图标图集 => generate_icon_spritesheet;完整界面稿 => generate_ui_design;其他全新图片 => generate_image。", - "edit_image 必须依赖 latestAttachments 或 latestGeneratedImage;generate_icon_spritesheet 必须依赖 latestAttachments。", - "当 latestGeneratedImage 存在,且用户说“这张/刚才那个/上一张/把衣服换成/改成/换成/修改上一张图”等指代或修改上一轮结果的话,必须选择 edit_image,prompt 保留用户修改要求,不要要求用户重新选择参考图,也不要降级成 generate_image。", - "没有 latestAttachments 且没有 latestGeneratedImage 时,不要调用 edit_image 或 generate_icon_spritesheet,应在 replyText 中提示先选择参考图。", - "generate_ui_design 表示生成一张 UI 设计图,不是提取 UI 素材。", - "对话回复要简短,不能承诺免费生成;生成工具由后端按模型定价扣泥点。", - ] - .join("\n") -} - -fn build_editor_agent_llm_user_prompt( - conversation: &EditorAgentConversationRecord, - document: &EditorAgentConversationMessagesDocument, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> String { - let history = document - .messages - .iter() - .rev() - .take(EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES) - .collect::>() - .into_iter() - .rev() - .map(|message| { - json!({ - "role": message.role, - "kind": message.kind, - "text": message.text, - "attachmentCount": message.attachments.len(), - "generationCount": message.generations.len(), - "generations": message - .generations - .iter() - .map(editor_agent_generation_context_json) - .collect::>(), - }) - }) - .collect::>(); - let attachments = user_message - .attachments - .iter() - .map(|attachment| { - json!({ - "source": attachment.source, - "referenceId": attachment.reference_id, - "label": attachment.label, - "width": attachment.width, - "height": attachment.height, - "hasObjectKey": attachment.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - }) - }) - .collect::>(); - - json!({ - "conversationId": conversation.conversation_id, - "projectId": conversation.project_id, - "latestUserText": user_message.text, - "latestAttachments": attachments, - "latestGeneratedImage": latest_generation_reference - .map(editor_agent_generated_image_reference_context_json), - "recentMessages": history, - }) - .to_string() -} - -fn editor_agent_generation_context_json(generation: &EditorAgentGenerationRecord) -> Value { - json!({ - "toolCallId": generation.tool_call_id, - "toolName": generation.tool_name, - "summary": generation.summary, - "status": generation.status, - "model": generation.model, - "images": generation.images.iter().take(4).map(|image| json!({ - "resourceId": image.resource_id, - "objectKey": image.object_key, - "assetObjectId": image.asset_object_id, - "imageSrc": image.image_src, - "thumbnailSrc": image.thumbnail_src, - "width": image.width, - "height": image.height, - "hasObjectKey": image.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - })).collect::>(), - }) -} - -fn editor_agent_generated_image_reference_context_json( - reference: &EditorAgentGeneratedImageReference, -) -> Value { - json!({ - "sourceMessageId": reference.source_message_id, - "toolCallId": reference.tool_call_id, - "toolName": reference.tool_name, - "summary": reference.summary, - "model": reference.model, - "resourceId": reference.resource_id, - "objectKey": reference.object_key, - "assetObjectId": reference.asset_object_id, - "imageSrc": reference.image_src, - "thumbnailSrc": reference.thumbnail_src, - "width": reference.width, - "height": reference.height, - "hasObjectKey": reference.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), - }) -} - -fn sign_editor_agent_attachment_image_url( - state: &AppState, - attachment: &EditorAgentAttachmentRef, -) -> Option { - let object_key = attachment - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty())?; - state - .oss_client()? - .sign_get_object_url(OssSignedGetObjectUrlRequest { - object_key: object_key.trim_start_matches('/').to_string(), - expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), - }) - .ok() - .map(|signed| signed.signed_url) -} - -fn sign_editor_agent_generated_image_url( - state: &AppState, - reference: &EditorAgentGeneratedImageReference, -) -> Option { - let object_key = reference - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty())?; - state - .oss_client()? - .sign_get_object_url(OssSignedGetObjectUrlRequest { - object_key: object_key.trim_start_matches('/').to_string(), - expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), - }) - .ok() - .map(|signed| signed.signed_url) -} - -fn latest_editor_agent_generated_image_reference( - document: &EditorAgentConversationMessagesDocument, -) -> Option { - document.messages.iter().rev().find_map(|message| { - message - .generations - .iter() - .rev() - .filter(|generation| generation.status == EditorAgentGenerationStatus::Completed) - .find_map(|generation| { - generation.images.iter().rev().find_map(|image| { - let has_reference_source = image - .object_key - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || image - .resource_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - || !image.image_src.trim().is_empty(); - has_reference_source.then(|| EditorAgentGeneratedImageReference { - source_message_id: message.id.clone(), - tool_call_id: generation.tool_call_id.clone(), - tool_name: generation.tool_name, - summary: generation.summary.clone(), - model: generation.model.clone(), - resource_id: image.resource_id.clone(), - object_key: image.object_key.clone(), - asset_object_id: image.asset_object_id.clone(), - image_src: image.image_src.clone(), - thumbnail_src: image.thumbnail_src.clone(), - width: image.width, - height: image.height, - }) - }) - }) - }) -} - -fn editor_agent_generated_reference_source( - reference: &EditorAgentGeneratedImageReference, -) -> Option { - reference - .object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| value.trim_start_matches('/').to_string()) - .or_else(|| { - reference - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - }) - .or_else(|| { - reference - .image_src - .trim() - .strip_prefix('/') - .map(str::to_string) - .filter(|value| !value.is_empty()) - }) -} - -fn editor_agent_text_mentions_previous_generation(text: &str) -> bool { - let normalized = text.trim().to_lowercase(); - contains_any( - normalized.as_str(), - &[ - "这张", - "这个图", - "这幅", - "这张图", - "刚才", - "刚刚", - "刚才那个", - "刚才那张", - "上一张", - "上张", - "上一幅", - "上一个", - "前一张", - "上次生成", - "刚生成", - "latest image", - "previous image", - "last image", - ], - ) -} - -fn editor_agent_text_requests_previous_generation_edit(text: &str) -> bool { - let normalized = text.trim().to_lowercase(); - if editor_agent_text_mentions_previous_generation(normalized.as_str()) - && contains_any( - normalized.as_str(), - &[ - "修改", "改成", "换成", "替换", "重绘", "编辑", "优化", "去掉", "加上", "增加", - "变成", "调整", "换一", "换掉", "remove", "change", "edit", "redraw", - ], - ) - { - return true; - } - - let has_visual_target = contains_any( - normalized.as_str(), - &[ - "衣服", "服装", "发型", "头发", "背景", "颜色", "色调", "表情", "姿势", "主体", "风格", - "脸", "眼睛", "帽子", "鞋", "裙子", "外套", - ], - ); - let has_edit_verb = contains_any( - normalized.as_str(), - &[ - "换", "改", "变", "加", "去", "删", "替换", "调整", "change", "edit", "remove", - ], - ); - has_visual_target && has_edit_verb -} - -fn apply_previous_generation_edit_default( - mut plan: EditorAgentTurnPlan, - user_message: &EditorAgentMessage, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, -) -> EditorAgentTurnPlan { - if latest_generation_reference.is_none() - || !user_message.attachments.is_empty() - || !editor_agent_text_requests_previous_generation_edit(user_message.text.as_str()) - { - return plan; - } - - let should_force_edit = plan - .tool_call - .as_ref() - .is_none_or(|tool_call| tool_call.tool_name != EditorAgentToolName::EditImage); - if !should_force_edit { - return plan; - } - - let original_tool = plan.tool_call.take(); - let prompt = original_tool - .as_ref() - .map(|tool_call| tool_call.prompt.clone()) - .unwrap_or_else(|| user_message.text.trim().to_string()); - let summary = original_tool - .as_ref() - .and_then(|tool_call| tool_call.summary.clone()) - .or_else(|| Some("修改上一张图".to_string())); - let model = original_tool - .as_ref() - .and_then(|tool_call| tool_call.model.clone()); - let image_size = original_tool - .as_ref() - .and_then(|tool_call| tool_call.image_size.clone()) - .or_else(|| Some("1K".to_string())); - - plan.reply_text = "我会基于上一张生成图按你的要求修改。".to_string(); - plan.tool_call = Some(EditorAgentToolCallPlan { - tool_name: EditorAgentToolName::EditImage, - prompt, - summary, - model, - aspect_ratio: None, - image_size, - icon_descriptions: Vec::new(), - }); - plan -} - -fn parse_editor_agent_turn_plan(raw_text: &str) -> Option { - let json_text = extract_editor_agent_json_object(raw_text)?; - let raw: EditorAgentRawTurnPlan = serde_json::from_str(json_text).ok()?; - let tool_call = match raw.tool_call { - Some(tool) => { - let prompt = normalize_optional_string(tool.prompt)?; - Some(EditorAgentToolCallPlan { - tool_name: tool.tool_name, - prompt, - summary: normalize_optional_string(tool.summary), - model: normalize_optional_string(tool.model), - aspect_ratio: normalize_optional_string(tool.aspect_ratio), - image_size: normalize_optional_string(tool.image_size), - icon_descriptions: tool - .icon_descriptions - .unwrap_or_default() - .into_iter() - .filter_map(|description| normalize_optional_string(Some(description))) - .collect(), - }) - } - None => None, - }; - let reply_text = normalize_editor_agent_reply_text(raw.reply_text, tool_call.as_ref())?; - - Some(EditorAgentTurnPlan { - reply_text, - tool_call, - }) -} - -fn extract_editor_agent_json_object(raw_text: &str) -> Option<&str> { - let trimmed = raw_text.trim(); - let without_fence = if let Some(rest) = trimmed.strip_prefix("```json") { - rest.strip_suffix("```").unwrap_or(rest).trim() - } else if let Some(rest) = trimmed.strip_prefix("```") { - rest.strip_suffix("```").unwrap_or(rest).trim() - } else { - trimmed - }; - let start = without_fence.find('{')?; - let end = without_fence.rfind('}')?; - (start <= end).then_some(&without_fence[start..=end]) -} - -fn normalize_editor_agent_reply_text( - reply_text: Option, - tool_call: Option<&EditorAgentToolCallPlan>, -) -> Option { - let reply_text = normalize_optional_string(reply_text)?; - if !editor_agent_reply_text_looks_structural(reply_text.as_str()) { - return Some(reply_text); - } - tool_call.map(editor_agent_tool_call_reply_text) -} - -fn editor_agent_reply_text_looks_structural(text: &str) -> bool { - let trimmed = text.trim(); - if trimmed.is_empty() { - return true; - } - let lower = trimmed.to_ascii_lowercase(); - if matches!(lower.as_str(), "null" | "undefined") { - return true; - } - trimmed.len() <= 2 - && trimmed - .chars() - .all(|character| matches!(character, '{' | '}' | '[' | ']' | '"' | ':' | ',')) -} - -fn editor_agent_tool_call_reply_text(tool_call: &EditorAgentToolCallPlan) -> String { - match tool_call.tool_name { - EditorAgentToolName::GenerateImage => "我来生成图片。".to_string(), - EditorAgentToolName::EditImage => "我来修改图片。".to_string(), - EditorAgentToolName::GenerateCharacter => "我来生成角色形象。".to_string(), - EditorAgentToolName::GenerateIconSpritesheet => "我来生成图标素材。".to_string(), - EditorAgentToolName::GenerateUiDesign => "我来生成 UI 设计图。".to_string(), - } -} - -#[cfg(test)] -fn heuristic_editor_agent_turn_plan( - text: &str, - attachments: &[EditorAgentAttachmentRef], -) -> EditorAgentTurnPlan { - let trimmed = text.trim(); - let normalized = trimmed.to_lowercase(); - let wants_generation = contains_any( - normalized.as_str(), - &[ - "生成", "出图", "绘制", "设计", "画一", "画个", "画张", "generate", "create", - ], - ); - let wants_edit = contains_any( - normalized.as_str(), - &[ - "修改", "改成", "重绘", "换成", "编辑", "优化", "edit", "redraw", - ], - ); - let wants_character = contains_any( - normalized.as_str(), - &["角色", "人物", "立绘", "形象", "character"], - ); - let wants_icons = contains_any( - normalized.as_str(), - &["图标", "icon", "spritesheet", "素材图集"], - ); - let wants_ui = contains_any( - normalized.as_str(), - &["ui", "界面", "hud", "按钮", "面板", "弹窗"], - ); - - let tool_name = if wants_icons && !attachments.is_empty() { - Some(EditorAgentToolName::GenerateIconSpritesheet) - } else if wants_edit && !attachments.is_empty() { - Some(EditorAgentToolName::EditImage) - } else if wants_character && wants_generation { - Some(EditorAgentToolName::GenerateCharacter) - } else if wants_ui && wants_generation { - Some(EditorAgentToolName::GenerateUiDesign) - } else if wants_generation { - Some(EditorAgentToolName::GenerateImage) - } else { - None - }; - - let reply_text = match tool_name { - Some(EditorAgentToolName::EditImage) => "我来按你的要求修改这张图。".to_string(), - Some(EditorAgentToolName::GenerateCharacter) => "我来生成角色形象。".to_string(), - Some(EditorAgentToolName::GenerateIconSpritesheet) => "我来生成图标素材图集。".to_string(), - Some(EditorAgentToolName::GenerateUiDesign) => "我来生成 UI 设计图。".to_string(), - Some(EditorAgentToolName::GenerateImage) => "我来生成图片。".to_string(), - None => build_echo_assistant_text(trimmed, attachments.len()), - }; - let tool_call = tool_name.map(|tool_name| EditorAgentToolCallPlan { - tool_name, - prompt: if trimmed.is_empty() { - "根据附件生成适合当前画布的图片".to_string() - } else { - trimmed.to_string() - }, - summary: Some(editor_agent_tool_default_summary(tool_name)), - model: None, - aspect_ratio: None, - image_size: Some("1K".to_string()), - icon_descriptions: derive_icon_descriptions(trimmed), - }); - - EditorAgentTurnPlan { - reply_text, - tool_call, - } -} - -fn contains_any(text: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| text.contains(needle)) -} - -fn derive_icon_descriptions(prompt: &str) -> Vec { - let normalized = prompt.trim(); - if normalized.is_empty() { - return vec![ - "主按钮图标".to_string(), - "奖励图标".to_string(), - "关闭图标".to_string(), - ]; - } - normalized - .split(['、', ',', ',', '\n', ';', ';']) - .map(str::trim) - .filter(|item| !item.is_empty()) - .take(8) - .map(ToOwned::to_owned) - .collect::>() - .into_iter() - .filter(|item| item.chars().count() <= 80) - .collect::>() - .pipe_non_empty_or_else(|| vec![normalized.to_string()]) -} - -trait VecPipeNonEmpty { - fn pipe_non_empty_or_else(self, fallback: F) -> Self - where - F: FnOnce() -> Self; -} - -impl VecPipeNonEmpty for Vec { - fn pipe_non_empty_or_else(self, fallback: F) -> Self - where - F: FnOnce() -> Self, - { - if self.is_empty() { fallback() } else { self } - } -} - -async fn execute_editor_agent_tool_call( - state: &AppState, - request_context: &RequestContext, - conversation: &EditorAgentConversationRecord, - user_message: &EditorAgentMessage, - tool_call_id: &str, - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - tool_call: EditorAgentToolCallPlan, -) -> Result { - let project = 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) - .map_err(|error| EditorAgentToolExecutionError { - tool_name: tool_call.tool_name, - model: tool_call.model.clone(), - error, - })?; - let completion = - build_editor_agent_canvas_completion(&project, tool_call.tool_name, &tool_call.prompt); - let caller = EditorGenerationCaller { - owner_user_id: conversation.owner_user_id.clone(), - audit_subject_user_id: Some(conversation.owner_user_id.clone()), - audit_project_id: Some(conversation.project_id.clone()), - }; - let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); - let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); - let previous_generation_source = (tool_call.tool_name == EditorAgentToolName::EditImage - && user_message.attachments.is_empty()) - .then(|| latest_generation_reference.and_then(editor_agent_generated_reference_source)) - .flatten(); - let tool_reference_sources = previous_generation_source - .clone() - .map(|source| vec![source]) - .unwrap_or_else(|| attachment_sources.clone()); - let generation_reference_context = editor_agent_tool_generation_reference_context( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ); - let generation_inputs = Some(json!({ - "source": "editor-agent", - "conversationId": conversation.conversation_id, - "messageId": user_message.id, - "toolCallId": tool_call_id, - "fields": [{ "title": "用户指令", "value": tool_call.prompt }], - "references": generation_reference_context, - })); - - let tool_name = tool_call.tool_name; - let tool_model = tool_call.model.clone(); - let response = match tool_name { - EditorAgentToolName::GenerateImage - | EditorAgentToolName::GenerateCharacter - | EditorAgentToolName::GenerateUiDesign => { - let kind = match tool_name { - EditorAgentToolName::GenerateCharacter => Some("character".to_string()), - EditorAgentToolName::GenerateUiDesign => Some("ui-design".to_string()), - _ => None, - }; - generate_editor_image_for_owner( - state, - &tool_request_context, - caller, - EditorImageGenerationRequest { - prompt: tool_call.prompt.clone(), - size: None, - kind, - model: tool_call.model.clone(), - screen_color: Some("auto".to_string()), - seg_model: Some("birefnet".to_string()), - aspect_ratio: tool_call.aspect_ratio.clone(), - image_size: tool_call.image_size.clone(), - reference_image_srcs: Some(tool_reference_sources.clone()), - project_id: Some(conversation.project_id.clone()), - asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - asset_label: tool_call.summary.clone(), - source_resource_id: editor_agent_tool_source_resource_id( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ), - canvas_completion: Some(completion), - }, - ) - .await - } - EditorAgentToolName::EditImage => { - let (source_image_src, reference_image_srcs) = split_editor_agent_edit_sources( - tool_reference_sources.clone(), - ) - .ok_or_else(|| EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error: editor_agent_bad_request("修改图片需要先选择一张画布或素材库图片附件"), - })?; - edit_editor_image_for_owner( - state, - &tool_request_context, - caller, - EditorImageEditRequest { - prompt: tool_call.prompt.clone(), - source_image_src, - size: None, - model: tool_call.model.clone(), - reference_image_srcs: Some(reference_image_srcs), - project_id: Some(conversation.project_id.clone()), - asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - asset_label: tool_call.summary.clone(), - source_resource_id: editor_agent_tool_source_resource_id( - user_message.attachments.as_slice(), - latest_generation_reference, - previous_generation_source.as_deref(), - ), - target_layer_id: None, - canvas_completion: Some(completion), - }, - ) - .await - } - EditorAgentToolName::GenerateIconSpritesheet => { - let (reference_image_src, extra_references) = - split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { - EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error: editor_agent_bad_request("生成图标素材需要先选择一张图标规范参考图"), - } - })?; - let icon_descriptions = if tool_call.icon_descriptions.is_empty() { - derive_icon_descriptions(tool_call.prompt.as_str()) - } else { - tool_call.icon_descriptions.clone() - }; - generate_editor_icon_spritesheet_for_owner( - state, - &tool_request_context, - caller, - EditorIconSpritesheetGenerationRequest { - reference_image_src, - reference_image_srcs: Some(extra_references), - icon_descriptions, - model: tool_call.model.clone(), - screen_color: Some("auto".to_string()), - seg_model: Some("birefnet".to_string()), - aspect_ratio: tool_call.aspect_ratio.clone(), - image_size: tool_call.image_size.clone(), - project_id: Some(conversation.project_id.clone()), - generation_inputs, - asset_folder_id: Some(editor_agent_default_asset_folder_id()), - canvas_completion: Some(completion), - }, - ) - .await - } - } - .map_err(|error| EditorAgentToolExecutionError { - tool_name, - model: tool_model.clone(), - error, - })?; - - Ok(build_editor_agent_tool_execution( - tool_call_id, - tool_name, - tool_call.summary, - response.0, - )) -} - -fn editor_agent_tool_request_context(base: &RequestContext, tool_call_id: &str) -> RequestContext { - RequestContext::new( - format!("{}:{tool_call_id}", base.request_id()), - format!("{} editor-agent-tool", base.operation()), - std::time::Duration::ZERO, - false, - ) -} - -fn build_editor_agent_tool_execution( - tool_call_id: &str, - tool_name: EditorAgentToolName, - summary: Option, - response_body: Value, -) -> EditorAgentToolExecution { - let data = response_body - .get("data") - .filter(|_| response_body.get("ok").and_then(Value::as_bool) == Some(true)) - .unwrap_or(&response_body); - let images = editor_agent_generated_images_from_response(tool_name, data); - let task_id = data - .get("taskId") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - images - .iter() - .find_map(|image| image.resource_id.as_ref().map(|_| ())) - .and_then(|_| { - data.get("taskId") - .and_then(Value::as_str) - .map(str::to_string) - }) - }); - let model = data - .get("model") - .and_then(Value::as_str) - .map(str::to_string); - let resolved_summary = summary.unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)); - - EditorAgentToolExecution { - record: EditorAgentGenerationRecord { - tool_call_id: tool_call_id.to_string(), - tool_name, - summary: Some(resolved_summary.clone()), - task_id, - status: EditorAgentGenerationStatus::Completed, - model, - images, - error: None, - }, - summary: resolved_summary, - } -} - -fn editor_agent_generated_images_from_response( - tool_name: EditorAgentToolName, - data: &Value, -) -> Vec { - if tool_name == EditorAgentToolName::GenerateIconSpritesheet { - let mut images = Vec::new(); - if let Some(resource) = data.get("spritesheetResource") { - images.push(editor_agent_generated_image_from_resource( - resource, - data.get("spritesheetImageSrc").and_then(Value::as_str), - data.get("spritesheetWidth").and_then(Value::as_u64), - data.get("spritesheetHeight").and_then(Value::as_u64), - )); - } - if let Some(icon_images) = data.get("iconImageSrcs").and_then(Value::as_array) { - for icon in icon_images { - if let Some(resource) = icon.get("resource") { - images.push(editor_agent_generated_image_from_resource( - resource, - icon.get("imageSrc").and_then(Value::as_str), - icon.get("width").and_then(Value::as_u64), - icon.get("height").and_then(Value::as_u64), - )); - } - } - } - return images; - } - - data.get("resource") - .map(|resource| { - vec![editor_agent_generated_image_from_resource( - resource, - data.get("imageSrc").and_then(Value::as_str), - data.get("width").and_then(Value::as_u64), - data.get("height").and_then(Value::as_u64), - )] - }) - .unwrap_or_else(|| { - data.get("imageSrc") - .and_then(Value::as_str) - .map(|image_src| { - vec![EditorAgentGeneratedImage { - resource_id: None, - object_key: None, - asset_object_id: None, - image_src: image_src.to_string(), - thumbnail_src: None, - width: data - .get("width") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()), - height: data - .get("height") - .and_then(Value::as_u64) - .and_then(|value| u32::try_from(value).ok()), - }] - }) - .unwrap_or_default() - }) -} - -fn editor_agent_generated_image_from_resource( - resource: &Value, - fallback_image_src: Option<&str>, - fallback_width: Option, - fallback_height: Option, -) -> EditorAgentGeneratedImage { - EditorAgentGeneratedImage { - resource_id: resource - .get("resourceId") - .and_then(Value::as_str) - .map(str::to_string), - object_key: resource - .get("objectKey") - .and_then(Value::as_str) - .map(str::to_string), - asset_object_id: resource - .get("assetObjectId") - .and_then(Value::as_str) - .map(str::to_string), - image_src: resource - .get("imageSrc") - .and_then(Value::as_str) - .or(fallback_image_src) - .unwrap_or("") - .to_string(), - thumbnail_src: resource - .get("thumbnailSrc") - .and_then(Value::as_str) - .map(str::to_string), - width: resource - .get("width") - .and_then(Value::as_u64) - .or(fallback_width) - .and_then(|value| u32::try_from(value).ok()), - height: resource - .get("height") - .and_then(Value::as_u64) - .or(fallback_height) - .and_then(|value| u32::try_from(value).ok()), - } -} - -fn editor_agent_attachment_sources(attachments: &[EditorAgentAttachmentRef]) -> Vec { - attachments - .iter() - .filter_map(|attachment| { - attachment - .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(attachment.reference_id.as_str())) - }) - .collect() -} - -fn editor_agent_tool_generation_reference_context( - attachments: &[EditorAgentAttachmentRef], - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - previous_generation_source: Option<&str>, -) -> Vec { - if previous_generation_source.is_some() { - return latest_generation_reference - .map(|reference| { - vec![json!({ - "title": reference - .summary - .clone() - .unwrap_or_else(|| "上一张生成图".to_string()), - "label": reference.summary, - "refType": "previous-generation", - "refId": reference.resource_id, - "resourceId": reference.resource_id, - "objectKey": reference.object_key, - "assetObjectId": reference.asset_object_id, - "toolCallId": reference.tool_call_id, - "toolName": reference.tool_name, - "sourceMessageId": reference.source_message_id, - "source": previous_generation_source, - "implicit": true, - })] - }) - .unwrap_or_default(); - } - - attachments - .iter() - .map(|attachment| { - json!({ - "title": attachment.label.clone().unwrap_or_else(|| "对话附件".to_string()), - "label": attachment.label, - "refType": match attachment.source { - EditorAgentAttachmentSource::CanvasResource => "project-resource", - EditorAgentAttachmentSource::LibraryAsset => "asset", - }, - "refId": attachment.reference_id, - "resourceId": (attachment.source == EditorAgentAttachmentSource::CanvasResource) - .then(|| attachment.reference_id.clone()), - "objectKey": attachment.object_key, - }) - }) - .collect() -} - -fn editor_agent_tool_source_resource_id( - attachments: &[EditorAgentAttachmentRef], - latest_generation_reference: Option<&EditorAgentGeneratedImageReference>, - previous_generation_source: Option<&str>, -) -> Option { - if previous_generation_source.is_some() { - return latest_generation_reference.and_then(|reference| reference.resource_id.clone()); - } - attachments - .first() - .map(|attachment| attachment.reference_id.clone()) -} - -fn split_editor_agent_edit_sources(sources: Vec) -> Option<(String, Vec)> { - let mut iter = sources.into_iter(); - let first = iter.next()?; - Some((first, iter.collect())) -} - -fn editor_agent_tool_asset_kind(tool_name: EditorAgentToolName) -> &'static str { - match tool_name { - EditorAgentToolName::GenerateCharacter => "character", - EditorAgentToolName::GenerateUiDesign => "ui-design", - EditorAgentToolName::EditImage => "editor_agent_edit_image", - EditorAgentToolName::GenerateIconSpritesheet => "icon-spritesheet", - EditorAgentToolName::GenerateImage => "editor_agent_generated_image", - } -} - -fn editor_agent_default_asset_folder_id() -> String { - "project".to_string() -} - -fn editor_agent_tool_default_summary(tool_name: EditorAgentToolName) -> String { - match tool_name { - EditorAgentToolName::GenerateImage => "生成图片", - EditorAgentToolName::EditImage => "修改图片", - EditorAgentToolName::GenerateCharacter => "生成角色形象", - EditorAgentToolName::GenerateIconSpritesheet => "生成图标素材", - EditorAgentToolName::GenerateUiDesign => "生成 UI 设计图", - } - .to_string() -} - -fn build_editor_agent_canvas_completion( - project: &EditorProjectRecord, - tool_name: EditorAgentToolName, - 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: normalize_optional_string(Some(title.to_string())) - .unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), - placeholder: EditorCanvasGenerationPlaceholderPayload { - x, - y, - width, - height, - original_width: width, - original_height: height, - }, - } -} - -fn editor_agent_tool_display_size(tool_name: EditorAgentToolName) -> (f64, f64) { - match tool_name { - EditorAgentToolName::GenerateUiDesign => (640.0, 360.0), - EditorAgentToolName::GenerateCharacter => (512.0, 768.0), - _ => (512.0, 512.0), - } -} - -fn next_editor_agent_canvas_position(layers: 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), - ) -} - -#[cfg(test)] -fn build_echo_assistant_text(text: &str, attachment_count: usize) -> String { - let trimmed = text.trim(); - if trimmed.is_empty() { - return format!("已收到 {attachment_count} 个附件。"); - } - format!("收到:{trimmed}") -} - -fn now_rfc3339() -> String { - shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) -} - -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, - })) -} - -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, - })) -} - -fn editor_agent_sse_json_event_or_error(event_name: &str, payload: T) -> Event -where - T: Serialize, -{ - match serde_json::to_string(&payload) { - Ok(data) => Event::default().event(event_name).data(data), - Err(error) => Event::default().event("error").data( - serde_json::to_string(&EditorAgentErrorEvent { - conversation_id: None, - code: "SERIALIZE_EVENT_FAILED".to_string(), - message: error.to_string(), - recoverable: false, - }) - .unwrap_or_else(|_| "{}".to_string()), - ), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AppConfig; - use platform_llm::LlmTextProtocol; - - #[test] - fn echo_assistant_text_uses_attachment_fallback() { - assert_eq!( - build_echo_assistant_text(" 画一棵树 ", 0), - "收到:画一棵树" - ); - assert_eq!(build_echo_assistant_text(" ", 2), "已收到 2 个附件。"); - } - - #[test] - fn empty_document_uses_contract_version() { - let document = empty_messages_document("editor-agent-conv-1"); - assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); - assert_eq!(document.conversation_id, "editor-agent-conv-1"); - assert!(document.messages.is_empty()); - } - - #[test] - fn canvas_attachment_normalization_uses_project_resource_truth() { - let conversation = test_conversation_record(); - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: " resource-1 ".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - image_src: "https://client.example/stale.png".to_string(), - thumbnail_src: Some("https://client.example/thumb.png".to_string()), - label: Some(" 参考图 ".to_string()), - width: Some(1), - height: Some(1), - }; - let resource = test_project_resource_record(); - - let normalized = - normalize_canvas_resource_attachment(&conversation, &attachment, &resource) - .expect("attachment should normalize"); - - assert_eq!(normalized.reference_id, "resource-1"); - assert_eq!( - normalized.object_key.as_deref(), - Some("generated-editor-assets/resource-1.png") - ); - assert_eq!(normalized.image_src, "/api/assets/read/resource-1.png"); - assert_eq!(normalized.thumbnail_src, None); - assert_eq!(normalized.label.as_deref(), Some("参考图")); - assert_eq!(normalized.width, Some(512)); - assert_eq!(normalized.height, Some(256)); - } - - #[test] - fn attachment_normalization_rejects_mismatched_object_key() { - let conversation = test_conversation_record(); - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: "resource-1".to_string(), - object_key: Some("generated-editor-assets/other.png".to_string()), - image_src: "/api/assets/read/resource-1.png".to_string(), - thumbnail_src: None, - label: None, - width: None, - height: None, - }; - let resource = test_project_resource_record(); - - assert!( - normalize_canvas_resource_attachment(&conversation, &attachment, &resource).is_err() - ); - } - - #[test] - fn library_attachment_normalization_uses_asset_label_fallback() { - let attachment = EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::LibraryAsset, - reference_id: "asset-1".to_string(), - object_key: None, - image_src: "https://client.example/stale.png".to_string(), - thumbnail_src: None, - label: None, - width: None, - height: None, - }; - let asset = EditorAssetRecord { - asset_id: "asset-1".to_string(), - folder_id: "project".to_string(), - label: "素材库图".to_string(), - asset_object_id: Some("asset-object-1".to_string()), - image_src: "/api/assets/read/asset-1.png".to_string(), - object_key: Some("generated-editor-assets/asset-1.png".to_string()), - width: 128, - height: 128, - source_type: "uploaded".to_string(), - prompt: None, - actual_prompt: None, - model: None, - provider: None, - task_id: None, - asset_kind: None, - generation_inputs: None, - source_resource_id: None, - public_showcase_enabled: None, - thumbnail_src: Some("/api/assets/read/asset-1-thumb.png".to_string()), - generation_cost_mud_points: 0, - showcase_id: None, - showcase_review_status: None, - showcase_display_enabled: None, - showcase_like_count: None, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }; - - let normalized = normalize_library_asset_attachment(&attachment, &asset) - .expect("library attachment should normalize"); - - assert_eq!(normalized.label.as_deref(), Some("素材库图")); - assert_eq!( - normalized.thumbnail_src.as_deref(), - Some("/api/assets/read/asset-1-thumb.png") - ); - assert_eq!(normalized.width, Some(128)); - assert_eq!(normalized.height, Some(128)); - } - - #[test] - fn generated_image_from_resource_keeps_private_asset_keys() { - let image = editor_agent_generated_image_from_resource( - &json!({ - "resourceId": "resource-1", - "imageSrc": "/generated-editor-assets/result.png", - "thumbnailSrc": "/generated-editor-assets/result-thumb.png", - "objectKey": "generated-editor-assets/result.png", - "assetObjectId": "asset-object-result", - "width": 1024, - "height": 768 - }), - None, - None, - None, - ); - - assert_eq!(image.resource_id.as_deref(), Some("resource-1")); - assert_eq!( - image.object_key.as_deref(), - Some("generated-editor-assets/result.png") - ); - assert_eq!( - image.asset_object_id.as_deref(), - Some("asset-object-result") - ); - assert_eq!(image.width, Some(1024)); - assert_eq!(image.height, Some(768)); - } - - #[test] - fn editor_agent_generations_target_default_asset_library_folder() { - assert_eq!(editor_agent_default_asset_folder_id(), "project"); - } - - #[test] - fn editor_agent_turn_plan_parses_fenced_json_tool_call() { - let plan = parse_editor_agent_turn_plan( - r#"```json -{ - "replyText": "我来生成一张像素风角色图。", - "toolCall": { - "toolName": "generate_character", - "prompt": "像素风银发游侠", - "summary": "生成像素角色", - "imageSize": "1K" - } -} -```"#, - ) - .expect("fenced JSON should parse"); - - assert_eq!(plan.reply_text, "我来生成一张像素风角色图。"); - let tool_call = plan.tool_call.expect("tool call should exist"); - assert_eq!(tool_call.tool_name, EditorAgentToolName::GenerateCharacter); - assert_eq!(tool_call.prompt, "像素风银发游侠"); - assert_eq!(tool_call.summary.as_deref(), Some("生成像素角色")); - assert_eq!(tool_call.image_size.as_deref(), Some("1K")); - } - - #[test] - fn editor_agent_turn_plan_rejects_invalid_llm_response() { - assert!(parse_editor_agent_turn_plan("收到:画一棵树").is_none()); - assert!( - parse_editor_agent_turn_plan( - r#"{"toolCall":{"toolName":"generate_image","prompt":"画一棵树"}}"#, - ) - .is_none() - ); - assert!( - parse_editor_agent_turn_plan( - r#"{"replyText":"我来生成图片。","toolCall":{"toolName":"generate_image"}}"#, - ) - .is_none() - ); - } - - #[test] - fn editor_agent_turn_plan_replaces_structural_reply_fragment() { - let plan = parse_editor_agent_turn_plan( - r#"{"replyText":"{","toolCall":{"toolName":"generate_image","prompt":"生成一张角色规范图","summary":"生成角色规范图"}}"#, - ) - .expect("valid tool call should parse"); - - assert_eq!(plan.reply_text, "我来生成图片。"); - assert_eq!( - plan.tool_call.as_ref().map(|tool_call| tool_call.tool_name), - Some(EditorAgentToolName::GenerateImage) - ); - } - - #[test] - fn editor_agent_llm_system_prompt_describes_each_tool() { - let prompt = editor_agent_llm_system_prompt(); - - for expected in [ - "generate_image:从文字生成一张全新图片", - "edit_image:修改已有图片", - "generate_character:生成新的角色形象", - "generate_icon_spritesheet:生成一组图标素材或图标图集", - "generate_ui_design:生成一张完整 UI 设计图或界面稿", - "规范图/视觉规范图/风格规范图/素材规范展板", - "当前使用 generate_image", - "角色规范图/角色美术视觉规范设定图", - "图标规范图/图标视觉规范展板", - "统一视角、线条粗细", - "色卡/色号", - "工具选择优先级", - "明确修改/指代已有图 => edit_image", - "规范图/视觉规范图/风格规范图/素材规范展板 => generate_image", - ] { - assert!( - prompt.contains(expected), - "system prompt should contain {expected}" - ); - } - } - - #[test] - fn latest_generation_reference_enters_llm_prompt() { - let conversation = test_conversation_record(); - let user_message = test_user_message("把刚才那个衣服换成蓝色"); - let mut document = empty_messages_document(conversation.conversation_id.as_str()); - document.messages.push(EditorAgentMessage { - id: "editor-agent-message-assistant-1".to_string(), - role: EditorAgentMessageRole::Assistant, - kind: EditorAgentMessageKind::Chat, - text: "已生成角色图。".to_string(), - attachments: Vec::new(), - generations: vec![test_completed_generation_record()], - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - }); - document.messages.push(user_message.clone()); - - let reference = latest_editor_agent_generated_image_reference(&document) - .expect("latest generated image should be available"); - let prompt = build_editor_agent_llm_user_prompt( - &conversation, - &document, - &user_message, - Some(&reference), - ); - let prompt_json: Value = serde_json::from_str(prompt.as_str()).expect("prompt is JSON"); - - assert_eq!( - prompt_json["latestGeneratedImage"]["toolName"], - json!("generate_image") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["summary"], - json!("生成红衣角色") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["resourceId"], - json!("resource-generated-1") - ); - assert_eq!( - prompt_json["latestGeneratedImage"]["objectKey"], - json!("generated-editor-assets/result.png") - ); - assert_eq!( - prompt_json["recentMessages"][0]["generations"][0]["images"][0]["objectKey"], - json!("generated-editor-assets/result.png") - ); - } - - #[test] - fn previous_generation_edit_default_coerces_generate_image_to_edit_image() { - let user_message = test_user_message("把衣服换成蓝色"); - let reference = test_generated_image_reference(); - let plan = EditorAgentTurnPlan { - reply_text: "我来生成图片。".to_string(), - tool_call: Some(EditorAgentToolCallPlan { - tool_name: EditorAgentToolName::GenerateImage, - prompt: "把衣服换成蓝色".to_string(), - summary: Some("生成蓝色衣服".to_string()), - model: None, - aspect_ratio: Some("1:1".to_string()), - image_size: Some("1K".to_string()), - icon_descriptions: Vec::new(), - }), - }; - - let adjusted = - apply_previous_generation_edit_default(plan, &user_message, Some(&reference)); - let tool_call = adjusted.tool_call.expect("tool should be forced"); - - assert_eq!(tool_call.tool_name, EditorAgentToolName::EditImage); - assert_eq!(tool_call.prompt, "把衣服换成蓝色"); - assert_eq!(adjusted.reply_text, "我会基于上一张生成图按你的要求修改。"); - } - - #[test] - fn previous_generation_reference_becomes_implicit_edit_source() { - let reference = test_generated_image_reference(); - - let sources = editor_agent_generated_reference_source(&reference); - let context = editor_agent_tool_generation_reference_context( - &[], - Some(&reference), - sources.as_deref(), - ); - - assert_eq!( - sources.as_deref(), - Some("generated-editor-assets/result.png") - ); - assert_eq!(context[0]["refType"], json!("previous-generation")); - assert_eq!(context[0]["resourceId"], json!("resource-generated-1")); - assert_eq!( - context[0]["objectKey"], - json!("generated-editor-assets/result.png") - ); - assert_eq!(context[0]["implicit"], json!(true)); - } - - #[test] - fn editor_agent_tool_execution_persists_summary() { - let execution = build_editor_agent_tool_execution( - "editor-agent-tool-1", - EditorAgentToolName::EditImage, - Some("换蓝色衣服".to_string()), - json!({ - "ok": true, - "data": { - "model": "gpt-image-2", - "resource": { - "resourceId": "resource-generated-2", - "imageSrc": "/generated-editor-assets/blue.png", - "objectKey": "generated-editor-assets/blue.png", - "assetObjectId": "asset-object-blue", - "width": 1024, - "height": 1024 - } - } - }), - ); - - assert_eq!(execution.summary, "换蓝色衣服"); - assert_eq!(execution.record.summary.as_deref(), Some("换蓝色衣服")); - } - - #[tokio::test] - async fn editor_agent_llm_request_uses_vector_engine_chat_model() { - let conversation = test_conversation_record(); - let document = empty_messages_document(conversation.conversation_id.as_str()); - let user_message = test_user_message("帮我看看这张图还能怎么改"); - let state = AppState::new(AppConfig::default()).expect("state should build"); - - let request = - build_editor_agent_llm_request(&state, &conversation, &document, &user_message, None) - .await; - - assert_eq!(request.protocol, LlmTextProtocol::ChatCompletions); - assert_eq!( - request.model.as_deref(), - Some(platform_agent::CREATIVE_AGENT_GPT5_MODEL) - ); - assert_eq!(request.max_tokens, Some(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS)); - assert_eq!( - request.request_timeout_ms, - Some(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) - ); - } - - #[test] - fn editor_agent_heuristic_routes_tools_from_user_text() { - let character_plan = - heuristic_editor_agent_turn_plan("帮我生成一个角色形象:猫耳骑士", &[]); - assert_eq!( - character_plan - .tool_call - .as_ref() - .expect("character tool expected") - .tool_name, - EditorAgentToolName::GenerateCharacter - ); - - let edit_plan = heuristic_editor_agent_turn_plan( - "把附件这张图改成赛博朋克风格", - &[EditorAgentAttachmentRef { - source: EditorAgentAttachmentSource::CanvasResource, - reference_id: "resource-1".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - image_src: "/generated-editor-assets/resource-1.png".to_string(), - thumbnail_src: None, - label: None, - width: Some(512), - height: Some(512), - }], - ); - assert_eq!( - edit_plan - .tool_call - .as_ref() - .expect("edit tool expected") - .tool_name, - EditorAgentToolName::EditImage - ); - - let chat_plan = heuristic_editor_agent_turn_plan("这张图适合做头像吗?", &[]); - assert!(chat_plan.tool_call.is_none()); - } - - #[test] - fn editor_agent_canvas_completion_places_result_after_existing_layers() { - let project = EditorProjectRecord { - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - title: "画布".to_string(), - canvas: spacetime_client::EditorCanvasRecord { - canvas_id: "canvas-1".to_string(), - project_id: "project-1".to_string(), - title: "画布".to_string(), - viewport: spacetime_client::EditorCanvasViewportRecord { - x: 0.0, - y: 0.0, - scale: 1.0, - }, - layers: json!([]), - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }, - viewport: spacetime_client::EditorCanvasViewportRecord { - x: 0.0, - y: 0.0, - scale: 1.0, - }, - layers: json!([ - { - "layerId": "layer-1", - "resourceId": "resource-1", - "x": 10.0, - "y": 20.0, - "width": 180.0, - "height": 120.0 - } - ]), - resources: Vec::new(), - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - }; - - let completion = build_editor_agent_canvas_completion( - &project, - EditorAgentToolName::GenerateUiDesign, - "UI 设计图", - ); - - assert_eq!(completion.dialog_id, None); - assert_eq!(completion.title, "UI 设计图"); - assert_eq!(completion.placeholder.x, 222.0); - assert_eq!(completion.placeholder.y, 20.0); - assert_eq!(completion.placeholder.width, 640.0); - assert_eq!(completion.placeholder.height, 360.0); - } - - fn test_conversation_record() -> EditorAgentConversationRecord { - EditorAgentConversationRecord { - conversation_id: "editor-agent-conv-1".to_string(), - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - title: "新对话".to_string(), - messages_object_key: "editor-agent/editor-agent-conv-1.json".to_string(), - deleted: false, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - updated_at_micros: 1, - } - } - - fn test_project_resource_record() -> EditorProjectResourceRecord { - EditorProjectResourceRecord { - resource_id: "resource-1".to_string(), - project_id: "project-1".to_string(), - owner_user_id: "user-1".to_string(), - asset_object_id: Some("asset-object-1".to_string()), - image_src: "/api/assets/read/resource-1.png".to_string(), - object_key: Some("generated-editor-assets/resource-1.png".to_string()), - width: 512, - height: 256, - source_type: "generated".to_string(), - prompt: Some("森林".to_string()), - actual_prompt: None, - model: Some("gpt-image-2".to_string()), - provider: Some("VectorEngine".to_string()), - task_id: Some("task-1".to_string()), - source_resource_id: None, - asset_kind: Some("editor_generated_image".to_string()), - generation_inputs: None, - public_showcase_enabled: false, - created_at: "2026-07-03T00:00:00Z".to_string(), - updated_at: "2026-07-03T00:00:00Z".to_string(), - } - } - - fn test_user_message(text: &str) -> EditorAgentMessage { - EditorAgentMessage { - id: "editor-agent-message-user-1".to_string(), - role: EditorAgentMessageRole::User, - kind: EditorAgentMessageKind::Chat, - text: text.to_string(), - attachments: Vec::new(), - generations: Vec::new(), - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - } - } - - fn test_completed_generation_record() -> EditorAgentGenerationRecord { - EditorAgentGenerationRecord { - tool_call_id: "editor-agent-tool-generated-1".to_string(), - tool_name: EditorAgentToolName::GenerateImage, - summary: Some("生成红衣角色".to_string()), - task_id: Some("task-generated-1".to_string()), - status: EditorAgentGenerationStatus::Completed, - model: Some("gpt-image-2".to_string()), - images: vec![EditorAgentGeneratedImage { - resource_id: Some("resource-generated-1".to_string()), - object_key: Some("generated-editor-assets/result.png".to_string()), - asset_object_id: Some("asset-object-result".to_string()), - image_src: "/generated-editor-assets/result.png".to_string(), - thumbnail_src: Some("/generated-editor-assets/result-thumb.png".to_string()), - width: Some(1024), - height: Some(1024), - }], - error: None, - } - } - - fn test_generated_image_reference() -> EditorAgentGeneratedImageReference { - let mut document = empty_messages_document("editor-agent-conv-1"); - document.messages.push(EditorAgentMessage { - id: "editor-agent-message-assistant-1".to_string(), - role: EditorAgentMessageRole::Assistant, - kind: EditorAgentMessageKind::Chat, - text: "已生成角色图。".to_string(), - attachments: Vec::new(), - generations: vec![test_completed_generation_record()], - status: EditorAgentMessageStatus::Completed, - created_at: "2026-07-05T00:00:00Z".to_string(), - }); - latest_editor_agent_generated_image_reference(&document) - .expect("latest generated image should be available") - } -}