persist error message in oss doc

This commit is contained in:
2026-07-16 17:18:08 +08:00
parent 9dc65e1d47
commit ca4bf6db5e
9 changed files with 134 additions and 37 deletions
@@ -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 Completionsfunction-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,消息正文整体存 OSSeditor-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">
@@ -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 () => {