From c1378ff833b8fe6d5506b039131f54ba9831870b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 23 Jul 2026 10:54:53 +0800 Subject: [PATCH] fix: uniformize attachment ref creation --- docs/【编辑器】画布Agent对话面板-2026-07-03.md | 4 ++-- .../EditorAgentConversationPanelView.test.tsx | 10 ++++++---- .../useConversationAttachments.ts | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index dc09953d2..16ce2f33c 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -75,8 +75,8 @@ - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 - 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-`、`_`、`.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。 -- 前端在创建画布、素材库和粘贴上传附件引用时先执行上述归一化;原 label 无有效内容时依次归一化并使用调用方提供的 fallback(默认 `referenceId`)和固定文案「图片」。后端不能信任前端结果:校验当前工程 / 当前账号归属和 `objectKey` 后,必须用同一套字符规则、同一 24 码点上限再次归一化并重建权威附件;素材库附件的提交 label 缺失或过滤为空时,才回退到同样归一化后的素材库 label。前后端常量和规则必须保持同步。 -- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时先保留消息中的展示快照,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;同一 `source + referenceId` 冲突时保留等待期间的当前草稿快照,失败请求快照只补充缺失 identity。合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 +- 前端在创建画布、素材库和粘贴上传附件引用,以及把历史消息附件重新引用到输入区时,先执行上述归一化;原 label 无有效内容时依次归一化并使用调用方提供的 fallback(默认 `referenceId`)和固定文案「图片」。后端不能信任前端结果:校验当前工程 / 当前账号归属和 `objectKey` 后,必须用同一套字符规则、同一 24 码点上限再次归一化并重建权威附件;素材库附件的提交 label 缺失或过滤为空时,才回退到同样归一化后的素材库 label。前后端常量和规则必须保持同步。 +- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。引用历史消息附件时,原消息继续保留存量展示快照;进入输入区的新引用先归一化 label 并保留其他展示快照字段,最终发送前再按 `source + referenceId` 从当前画布和素材库选项刷新,避免提前刷新后又被失败恢复的旧快照覆盖。发送失败时,已发送附件必须与等待期间新增的附件去重合并,不得因输入区已非空而丢弃;同一 `source + referenceId` 冲突时保留等待期间的当前草稿快照,失败请求快照只补充缺失 identity。合并后超过 9 张时优先保留等待期间的最新附件,不恢复失败请求的附件,并立即显示上限错误。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。 ## 工具调用确认展示契约 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 13b53a9cf..576ce756b 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -549,7 +549,7 @@ describe('EditorAgentConversationPanelView', () => { expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull(); }); - it('references a historical pasted attachment outside the current canvas', async () => { + it('normalizes a referenced historical attachment outside the current canvas', async () => { const client = createClient(); vi.mocked(client.getConversation).mockResolvedValue({ conversationId: 'conversation-1', @@ -567,7 +567,7 @@ describe('EditorAgentConversationPanelView', () => { objectKey: 'editor/agent-paste/historical.png', imageSrc: '/generated/historical-paste.png', thumbnailSrc: null, - label: '历史粘贴图', + label: ' 历史(粘贴):图 ', width: 320, height: 240, }, @@ -590,11 +590,11 @@ describe('EditorAgentConversationPanelView', () => { />, ); - const historicalAttachment = await screen.findByText('历史粘贴图'); + const historicalAttachment = await screen.findByText('历史(粘贴):图'); fireEvent.contextMenu(historicalAttachment.closest('.group')!); fireEvent.click(screen.getByRole('menuitem', { name: '引用' })); await waitFor(() => { - expect(screen.getAllByText('历史粘贴图')).toHaveLength(2); + expect(screen.getByText('历史粘贴图')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '发送' })); @@ -618,6 +618,8 @@ describe('EditorAgentConversationPanelView', () => { expect.any(Object), ); }); + expect(screen.getByText('历史(粘贴):图')).toBeTruthy(); + expect(screen.getByText('历史粘贴图')).toBeTruthy(); }); it('refreshes an attachment matched by source and reference id when sending', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts index aca7da5a6..197dbd4aa 100644 --- a/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts +++ b/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts @@ -265,7 +265,7 @@ export function useConversationAttachments({ const referenceContextAsset = useCallback( (asset: EditorAgentContextAsset) => { if (asset.kind === 'attachment') { - const directAttachment: EditorAgentAttachmentRef = { + const directAttachment = createEditorAgentAttachmentRef({ source: asset.source, referenceId: asset.referenceId, objectKey: asset.objectKey, @@ -274,7 +274,7 @@ export function useConversationAttachments({ label: asset.label, width: asset.width, height: asset.height, - }; + }); return directAttachment.referenceId.trim() && directAttachment.imageSrc.trim() ? appendAttachments([directAttachment])