persist error message in oss doc
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
- 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。
|
||||
- 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。
|
||||
- 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。
|
||||
- Agent 规划或工具调用失败时,仍保留 assistant / system 消息和 `status=failed` 工具记录,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不能只放在本次 JSON 响应的瞬时 `errorMessage` 中。
|
||||
- Agent 规划失败使用 `role=system`、正文以 `ERROR ` 开头的消息持久化;首次响应和同 `clientMessageId` 重放都通过 `deltaMessages` 返回该消息,`errorMessage` 不重复携带。前端隐藏前缀并显示红色错误气泡,后端构建后续 LLM memory 时仍保留该消息,让 Agent 获取上一轮失败上下文。工具调用失败继续保留 `status=failed` 工具记录、模型和错误信息;失败记录都是会话历史的一部分。
|
||||
- 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。
|
||||
|
||||
## 备选方案与取舍
|
||||
@@ -31,5 +31,5 @@
|
||||
- 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。
|
||||
- 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。
|
||||
- 会话软删只打表标记,OSS 对象保留,便于恢复与审计。
|
||||
- 规划或工具生成失败也必须写入消息文档:assistant / system 消息保留失败状态、模型和错误信息,便于用户回看失败原因和后续排障。
|
||||
- 规划或工具生成失败也必须写入消息文档:规划失败保存 `ERROR ` system 消息,工具失败保存失败状态、模型和错误信息,便于用户回看失败原因和后续排障。
|
||||
- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
- `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。
|
||||
- `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。
|
||||
- `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。规划或工具失败仍必须把失败 assistant / system 消息、工具状态和错误信息写入 OSS 会话历史,不得只返回一次性 `errorMessage`。
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。LLM / 规划失败写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入 `deltaMessages`,不再重复设置 `errorMessage`;前端隐藏前缀后显示红色错误气泡。工具失败继续保存工具状态和错误信息。
|
||||
- `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。
|
||||
- `POST /api/editor/assets/folders`:新建素材文件夹。
|
||||
- `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。
|
||||
|
||||
@@ -81,7 +81,7 @@ npm run check:server-rs-ddd
|
||||
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
|
||||
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
|
||||
- `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。规划或工具失败必须形成可回读的失败消息,不能只返回瞬时 `errorMessage`。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM / 规划失败必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
|
||||
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
|
||||
- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
## 当前分支落地状态
|
||||
|
||||
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。
|
||||
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。工具失败时仍应把失败 assistant / system 消息、`status=failed`、模型和错误信息写入 OSS 会话历史;不能只在本次 JSON 响应中返回瞬时 `errorMessage`。
|
||||
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。
|
||||
- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。
|
||||
|
||||
## 会话与持久化
|
||||
@@ -92,7 +92,7 @@
|
||||
## LLM 与计费
|
||||
|
||||
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入明确错误消息,不使用本地关键词或“收到:...”回显兜底。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
|
||||
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
|
||||
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。
|
||||
|
||||
export const EDITOR_AGENT_MAX_ATTACHMENTS = 9;
|
||||
export const EDITOR_AGENT_ERROR_MESSAGE_PREFIX = 'ERROR ';
|
||||
|
||||
export type EditorAgentMessageRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ use shared_contracts::assets::{
|
||||
EditorVideoGenerateRequest,
|
||||
};
|
||||
use shared_contracts::editor_agent::{
|
||||
CreateEditorAgentConversationRequest, EditorAgentConversationListResponse,
|
||||
EditorAgentConversationMessagesDocument, EditorAgentConversationResponse,
|
||||
EditorAgentConversationSummary, EditorAgentMessage, EditorAgentMessageRequest,
|
||||
EditorAgentMessageResponse, EditorAgentMessageRole, EditorAgentToolCall,
|
||||
EditorAgentToolCallStatus,
|
||||
CreateEditorAgentConversationRequest, EDITOR_AGENT_ERROR_MESSAGE_PREFIX,
|
||||
EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument,
|
||||
EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentMessage,
|
||||
EditorAgentMessageRequest, EditorAgentMessageResponse, EditorAgentMessageRole,
|
||||
EditorAgentToolCall, EditorAgentToolCallStatus,
|
||||
};
|
||||
use spacetime_client::{
|
||||
EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput,
|
||||
@@ -210,17 +210,30 @@ pub async fn editor_agent_message(
|
||||
let tool_context = context::build_tool_context(&document);
|
||||
|
||||
// Build and run agent
|
||||
let llm_client = state.creative_agent_gpt5_client().ok_or_else(|| {
|
||||
AppError::from_status(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_details(json!({ "message": "Creative Agent GPT-5 client not configured" }))
|
||||
})?;
|
||||
let Some(llm_client) = state.creative_agent_gpt5_client() else {
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
"Creative Agent GPT-5 client not configured",
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let llm_client = llm_client.clone();
|
||||
let pricing = state.editor_generation_pricing().await.map_err(|error| {
|
||||
AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-pricing",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
let pricing = match state.editor_generation_pricing().await {
|
||||
Ok(pricing) => pricing,
|
||||
Err(error) => {
|
||||
return persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
format!("failed to load editor generation pricing: {error}"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
|
||||
let memory = VecMemory::new(previous_messages);
|
||||
|
||||
@@ -265,11 +278,16 @@ pub async fn editor_agent_message(
|
||||
&tool_context,
|
||||
&pricing,
|
||||
) {
|
||||
Err(err) => Ok(Json(EditorAgentMessageResponse {
|
||||
conversation: conversation_summary,
|
||||
delta_messages: Vec::new(),
|
||||
error_message: Some(err.to_string()),
|
||||
})),
|
||||
Err(error) => {
|
||||
persist_editor_agent_planning_error(
|
||||
&state,
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
error.to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(delta_messages) => {
|
||||
for msg in &delta_messages {
|
||||
document.messages.push(msg.clone());
|
||||
@@ -285,6 +303,39 @@ pub async fn editor_agent_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_editor_agent_error_message(
|
||||
message_id: usize,
|
||||
error: impl std::fmt::Display,
|
||||
) -> EditorAgentMessage {
|
||||
EditorAgentMessage {
|
||||
id: message_id,
|
||||
client_message_id: None,
|
||||
role: EditorAgentMessageRole::System,
|
||||
text: format!("{EDITOR_AGENT_ERROR_MESSAGE_PREFIX}{error}"),
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: now_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_editor_agent_planning_error(
|
||||
state: &AppState,
|
||||
conversation: &EditorAgentConversationRecord,
|
||||
document: &mut EditorAgentConversationMessagesDocument,
|
||||
conversation_summary: EditorAgentConversationSummary,
|
||||
error: impl std::fmt::Display,
|
||||
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
|
||||
let error_message = build_editor_agent_error_message(document.messages.len(), error);
|
||||
document.messages.push(error_message.clone());
|
||||
write_messages_document(state, conversation, document).await?;
|
||||
|
||||
Ok(Json(EditorAgentMessageResponse {
|
||||
conversation: conversation_summary,
|
||||
delta_messages: vec![error_message],
|
||||
error_message: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn validate_editor_agent_message_request(
|
||||
payload: &EditorAgentMessageRequest,
|
||||
) -> Result<String, AppError> {
|
||||
@@ -451,6 +502,16 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_system_error_message_with_wire_prefix() {
|
||||
let message = build_editor_agent_error_message(3, "planning failed");
|
||||
|
||||
assert_eq!(message.id, 3);
|
||||
assert_eq!(message.role, EditorAgentMessageRole::System);
|
||||
assert_eq!(message.text, "ERROR planning failed");
|
||||
assert!(message.tool_call.is_none());
|
||||
}
|
||||
}
|
||||
fn editor_agent_system_prompt() -> &'static str {
|
||||
r#"
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9;
|
||||
pub const EDITOR_AGENT_ERROR_MESSAGE_PREFIX: &str = "ERROR ";
|
||||
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 = 2;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
|
||||
import {
|
||||
EDITOR_AGENT_ERROR_MESSAGE_PREFIX,
|
||||
type EditorAgentMessage,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
|
||||
@@ -51,7 +54,14 @@ export function MessageBubble({
|
||||
onCancelToolCall,
|
||||
onJobCompleted,
|
||||
}: MessageBubbleProps) {
|
||||
if (message.role === 'system' && !message.toolCall) {
|
||||
const systemErrorText =
|
||||
message.role === 'system' &&
|
||||
!message.toolCall &&
|
||||
message.text.startsWith(EDITOR_AGENT_ERROR_MESSAGE_PREFIX)
|
||||
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
|
||||
: null;
|
||||
|
||||
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
@@ -73,29 +83,36 @@ export function MessageBubble({
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const isSystemError = systemErrorText !== null;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
aria-label={
|
||||
isSystem ? 'Agent操作' : `${messageRoleLabel(message.role)}消息`
|
||||
isSystemError
|
||||
? 'Agent错误'
|
||||
: isSystem
|
||||
? 'Agent操作'
|
||||
: `${messageRoleLabel(message.role)}消息`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
isSystem
|
||||
isSystem && !isSystemError
|
||||
? 'max-w-[86%]'
|
||||
: `max-w-[86%] rounded-3xl px-3.5 py-3 text-sm leading-6 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-slate-900 text-white'
|
||||
: message.toolCall?.status === 'failed'
|
||||
: isSystemError || message.toolCall?.status === 'failed'
|
||||
? 'border border-red-200 bg-red-50 text-red-700'
|
||||
: 'border border-slate-200 bg-white text-slate-700'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{!isSystem && message.text ? (
|
||||
<div className="whitespace-pre-wrap break-words">{message.text}</div>
|
||||
{(!isSystem || isSystemError) && (systemErrorText ?? message.text) ? (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{systemErrorText ?? message.text}
|
||||
</div>
|
||||
) : null}
|
||||
{!isSystem && message.attachments.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
|
||||
+21
-4
@@ -301,7 +301,7 @@ describe('useEditorAgentConversation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('handles backend error responses', async () => {
|
||||
it('applies persisted backend planning errors as system messages', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockResolvedValue({
|
||||
conversation: {
|
||||
@@ -310,8 +310,17 @@ describe('useEditorAgentConversation', () => {
|
||||
title: '这是美术素材',
|
||||
updatedAt: '2026-07-03T00:00:01.000Z',
|
||||
},
|
||||
deltaMessages: [],
|
||||
errorMessage: 'LLM 未配置,无法处理这句话。',
|
||||
deltaMessages: [
|
||||
{
|
||||
id: 2,
|
||||
role: 'system',
|
||||
text: 'ERROR LLM 未配置,无法处理这句话。',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-16T00:00:01.000Z',
|
||||
},
|
||||
],
|
||||
errorMessage: null,
|
||||
} as EditorAgentMessageResponse);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
@@ -327,7 +336,15 @@ describe('useEditorAgentConversation', () => {
|
||||
await result.current.sendMessage('这是美术素材');
|
||||
});
|
||||
|
||||
expect(result.current.errorMessage).toBe('LLM 未配置,无法处理这句话。');
|
||||
expect(result.current.errorMessage).toBeNull();
|
||||
expect(result.current.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: 'system',
|
||||
text: 'ERROR LLM 未配置,无法处理这句话。',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('confirms a pending tool call, replaces its message and requests a canvas refresh', async () => {
|
||||
|
||||
Reference in New Issue
Block a user