d7d98acfd4
切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。 为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。 补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。 同步前后端契约、测试脚本、环境示例和项目文档。
397 lines
13 KiB
Rust
397 lines
13 KiB
Rust
//! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。
|
||
//! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9;
|
||
pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20;
|
||
pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话";
|
||
pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 1;
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentStage {
|
||
Idle,
|
||
Thinking,
|
||
Responding,
|
||
Generating,
|
||
Completed,
|
||
Failed,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum EditorAgentMessageRole {
|
||
User,
|
||
Assistant,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentMessageKind {
|
||
Chat,
|
||
Stage,
|
||
Error,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentMessageStatus {
|
||
Streaming,
|
||
Generating,
|
||
Completed,
|
||
Failed,
|
||
Stopped,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentAttachmentSource {
|
||
CanvasResource,
|
||
LibraryAsset,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentToolName {
|
||
GenerateImage,
|
||
EditImage,
|
||
GenerateCharacter,
|
||
GenerateIconSpritesheet,
|
||
GenerateUiDesign,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentGenerationStatus {
|
||
Generating,
|
||
Completed,
|
||
Failed,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentAttachmentRef {
|
||
pub source: EditorAgentAttachmentSource,
|
||
pub reference_id: String,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub label: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGeneratedImage {
|
||
#[serde(default)]
|
||
pub resource_id: Option<String>,
|
||
#[serde(default)]
|
||
pub object_key: Option<String>,
|
||
#[serde(default)]
|
||
pub asset_object_id: Option<String>,
|
||
pub image_src: String,
|
||
#[serde(default)]
|
||
pub thumbnail_src: Option<String>,
|
||
#[serde(default)]
|
||
pub width: Option<u32>,
|
||
#[serde(default)]
|
||
pub height: Option<u32>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGenerationRecord {
|
||
pub tool_call_id: String,
|
||
pub tool_name: EditorAgentToolName,
|
||
#[serde(default)]
|
||
pub summary: Option<String>,
|
||
#[serde(default)]
|
||
pub task_id: Option<String>,
|
||
pub status: EditorAgentGenerationStatus,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
#[serde(default)]
|
||
pub images: Vec<EditorAgentGeneratedImage>,
|
||
#[serde(default)]
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessage {
|
||
pub id: String,
|
||
pub role: EditorAgentMessageRole,
|
||
pub kind: EditorAgentMessageKind,
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
#[serde(default)]
|
||
pub generations: Vec<EditorAgentGenerationRecord>,
|
||
pub status: EditorAgentMessageStatus,
|
||
pub created_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationSummary {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub created_at: String,
|
||
pub updated_at: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationDetail {
|
||
pub conversation_id: String,
|
||
pub project_id: String,
|
||
pub title: String,
|
||
pub created_at: String,
|
||
pub updated_at: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationMessagesDocument {
|
||
pub version: u32,
|
||
pub conversation_id: String,
|
||
pub messages: Vec<EditorAgentMessage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CreateEditorAgentConversationRequest {
|
||
#[serde(default)]
|
||
pub title: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationListResponse {
|
||
pub conversations: Vec<EditorAgentConversationSummary>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentConversationResponse {
|
||
pub conversation: EditorAgentConversationDetail,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct StreamEditorAgentMessageRequest {
|
||
pub client_message_id: String,
|
||
pub text: String,
|
||
#[serde(default)]
|
||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum EditorAgentSseEventType {
|
||
Stage,
|
||
MessageDelta,
|
||
ToolStarted,
|
||
ToolCompleted,
|
||
GenerationResult,
|
||
Error,
|
||
Done,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentSseEnvelope {
|
||
pub event: EditorAgentSseEventType,
|
||
pub data: serde_json::Value,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentStageEvent {
|
||
pub conversation_id: String,
|
||
pub stage: EditorAgentStage,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentMessageDeltaEvent {
|
||
pub conversation_id: String,
|
||
pub message_id: String,
|
||
pub role: EditorAgentMessageRole,
|
||
pub kind: EditorAgentMessageKind,
|
||
pub text_delta: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentToolEvent {
|
||
pub conversation_id: String,
|
||
pub message_id: String,
|
||
pub tool_call_id: String,
|
||
pub tool_name: EditorAgentToolName,
|
||
#[serde(default)]
|
||
pub summary: Option<String>,
|
||
#[serde(default)]
|
||
pub task_id: Option<String>,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
#[serde(default)]
|
||
pub status: Option<EditorAgentGenerationStatus>,
|
||
#[serde(default)]
|
||
pub error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentGenerationResultEvent {
|
||
pub conversation_id: String,
|
||
pub message_id: String,
|
||
pub tool_call_id: String,
|
||
pub tool_name: EditorAgentToolName,
|
||
#[serde(default)]
|
||
pub model: Option<String>,
|
||
pub images: Vec<EditorAgentGeneratedImage>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentErrorEvent {
|
||
#[serde(default)]
|
||
pub conversation_id: Option<String>,
|
||
pub code: String,
|
||
pub message: String,
|
||
pub recoverable: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorAgentDoneEvent {
|
||
pub conversation_id: String,
|
||
#[serde(default)]
|
||
pub title: Option<String>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn editor_agent_conversation_detail_uses_camel_case() {
|
||
let detail = EditorAgentConversationDetail {
|
||
conversation_id: "editor-agent-conv-1".to_string(),
|
||
project_id: "project-1".to_string(),
|
||
title: "新对话".to_string(),
|
||
created_at: "2026-07-03T00:00:00Z".to_string(),
|
||
updated_at: "2026-07-03T00:00:01Z".to_string(),
|
||
messages: vec![EditorAgentMessage {
|
||
id: "message-1".to_string(),
|
||
role: EditorAgentMessageRole::User,
|
||
kind: EditorAgentMessageKind::Chat,
|
||
text: "帮我生成一张森林背景".to_string(),
|
||
attachments: vec![EditorAgentAttachmentRef {
|
||
source: EditorAgentAttachmentSource::CanvasResource,
|
||
reference_id: "resource-1".to_string(),
|
||
object_key: Some("editor/resource-1.png".to_string()),
|
||
image_src: "https://example.test/resource-1.png".to_string(),
|
||
thumbnail_src: None,
|
||
label: Some("森林参考".to_string()),
|
||
width: Some(1024),
|
||
height: Some(768),
|
||
}],
|
||
generations: vec![],
|
||
status: EditorAgentMessageStatus::Completed,
|
||
created_at: "2026-07-03T00:00:00Z".to_string(),
|
||
}],
|
||
};
|
||
|
||
let payload = serde_json::to_value(&detail).expect("detail should serialize");
|
||
assert_eq!(payload["conversationId"], json!("editor-agent-conv-1"));
|
||
assert_eq!(payload["messages"][0]["role"], json!("user"));
|
||
assert_eq!(
|
||
payload["messages"][0]["attachments"][0]["source"],
|
||
json!("canvas_resource")
|
||
);
|
||
assert_eq!(
|
||
payload["messages"][0]["attachments"][0]["referenceId"],
|
||
json!("resource-1")
|
||
);
|
||
assert_eq!(payload["messages"][0]["status"], json!("completed"));
|
||
|
||
let decoded: EditorAgentConversationDetail =
|
||
serde_json::from_value(payload).expect("detail should deserialize");
|
||
assert_eq!(decoded, detail);
|
||
}
|
||
|
||
#[test]
|
||
fn editor_agent_sse_events_serialize_event_names() {
|
||
let envelope = EditorAgentSseEnvelope {
|
||
event: EditorAgentSseEventType::GenerationResult,
|
||
data: serde_json::to_value(EditorAgentGenerationResultEvent {
|
||
conversation_id: "editor-agent-conv-1".to_string(),
|
||
message_id: "message-2".to_string(),
|
||
tool_call_id: "tool-call-1".to_string(),
|
||
tool_name: EditorAgentToolName::GenerateImage,
|
||
model: Some("z-image".to_string()),
|
||
images: vec![EditorAgentGeneratedImage {
|
||
resource_id: Some("resource-2".to_string()),
|
||
object_key: Some("generated/result.png".to_string()),
|
||
asset_object_id: Some("asset-object-2".to_string()),
|
||
image_src: "https://example.test/generated.png".to_string(),
|
||
thumbnail_src: None,
|
||
width: Some(1024),
|
||
height: Some(1024),
|
||
}],
|
||
})
|
||
.expect("event data should serialize"),
|
||
};
|
||
|
||
let payload = serde_json::to_value(envelope).expect("envelope should serialize");
|
||
assert_eq!(payload["event"], json!("generation_result"));
|
||
assert_eq!(payload["data"]["toolName"], json!("generate_image"));
|
||
assert_eq!(
|
||
payload["data"]["images"][0]["resourceId"],
|
||
json!("resource-2")
|
||
);
|
||
assert_eq!(
|
||
payload["data"]["images"][0]["objectKey"],
|
||
json!("generated/result.png")
|
||
);
|
||
|
||
let error_envelope = EditorAgentSseEnvelope {
|
||
event: EditorAgentSseEventType::Error,
|
||
data: serde_json::to_value(EditorAgentErrorEvent {
|
||
conversation_id: Some("editor-agent-conv-1".to_string()),
|
||
code: "INSUFFICIENT_BALANCE".to_string(),
|
||
message: "泥点余额不足,本次生成未执行".to_string(),
|
||
recoverable: true,
|
||
})
|
||
.expect("event data should serialize"),
|
||
};
|
||
let error_payload =
|
||
serde_json::to_value(error_envelope).expect("envelope should serialize");
|
||
assert_eq!(error_payload["event"], json!("error"));
|
||
assert_eq!(error_payload["data"]["recoverable"], json!(true));
|
||
}
|
||
|
||
#[test]
|
||
fn stream_request_defaults_attachments_to_empty() {
|
||
let payload = json!({
|
||
"clientMessageId": "client-message-1",
|
||
"text": "随便聊聊"
|
||
});
|
||
let decoded: StreamEditorAgentMessageRequest =
|
||
serde_json::from_value(payload).expect("request should deserialize");
|
||
assert!(decoded.attachments.is_empty());
|
||
}
|
||
}
|