editor agent: fix A 发送中引用 B,随后请求失败, B is lost

This commit is contained in:
2026-07-21 13:50:40 +08:00
parent 4ff482fcc1
commit 791493db0e
4 changed files with 136 additions and 6 deletions
@@ -74,7 +74,7 @@
- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme``UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。
## 工具调用确认展示契约
@@ -1074,6 +1074,136 @@ describe('EditorAgentConversationPanelView', () => {
).toBeNull();
});
it('merges sent attachments with references added while a failed request is pending', async () => {
const client = createClient();
let rejectSend!: (reason?: unknown) => void;
vi.mocked(client.sendMessage).mockImplementationOnce(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
vi.mocked(client.getConversation).mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '失败恢复并发附件',
messages: [
{
id: 9,
role: 'system',
text: 'internal tool result',
attachments: [],
toolCall: {
toolName: 'generate_image',
status: 'completed',
args: {},
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 1 },
},
images: [
{
resourceId: 'resource-b',
objectKey: 'editor/attachment-b.png',
imageSrc: '/generated-editor-images/attachment-b.png',
width: 512,
height: 512,
},
],
},
createdAt: '2026-07-20T00:00:00.000Z',
},
],
createdAt: '2026-07-20T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:10.000Z',
});
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-a',
resourceId: 'resource-a',
title: '附件 A',
src: '/generated-editor-images/attachment-a.png',
objectKey: 'editor/attachment-a.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
{
id: 'layer-b',
resourceId: 'resource-b',
title: '附件 B',
src: '/generated-editor-images/attachment-b.png',
objectKey: 'editor/attachment-b.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 2,
sourceType: 'generated',
},
]}
/>,
);
const messageLog = await screen.findByRole('log', {
name: '画布 Agent 消息流',
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 附件 A',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
attachments: [expect.objectContaining({ referenceId: 'resource-a' })],
}),
expect.any(Object),
);
});
await waitFor(() =>
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
);
fireEvent.contextMenu(
messageLog.querySelector('.grid.grid-cols-3 > div')!,
{ clientX: 30, clientY: 40 },
);
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
expect(await screen.findByText('附件 B')).toBeTruthy();
await act(async () => {
rejectSend(new Error('Network error'));
});
expect(await screen.findByText('Network error')).toBeTruthy();
expect(screen.getByText('附件 A')).toBeTruthy();
expect(screen.getByText('附件 B')).toBeTruthy();
});
it('preserves messages when the panel is collapsed and reopened', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockResolvedValue({
@@ -111,7 +111,7 @@ export function EditorAgentConversationPanelView({
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachmentsIfEmpty,
restoreAttachments,
} = useConversationAttachments({ projectId, layers, assets });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -134,7 +134,7 @@ export function EditorAgentConversationPanelView({
const nextAttachments = consumeAttachments();
void sendMessage(text, nextAttachments).catch(() => {
setDraftText((currentText) => (currentText ? currentText : text));
restoreAttachmentsIfEmpty(nextAttachments);
restoreAttachments(nextAttachments);
});
};
@@ -365,10 +365,10 @@ export function useConversationAttachments({
return currentAttachments;
}, [updateAttachments]);
const restoreAttachmentsIfEmpty = useCallback(
const restoreAttachments = useCallback(
(failedAttachments: EditorAgentAttachmentRef[]) => {
updateAttachments((currentAttachments) =>
currentAttachments.length ? currentAttachments : failedAttachments,
mergeAttachments(failedAttachments, currentAttachments),
);
},
[updateAttachments],
@@ -392,6 +392,6 @@ export function useConversationAttachments({
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachmentsIfEmpty,
restoreAttachments,
};
}