From 7f9984d12d936778a229a83788bdcd60d189d9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 31 Jul 2026 16:46:47 +0800 Subject: [PATCH] =?UTF-8?q?fix=20=E4=BF=AE=E6=AD=A3=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=AE=9A=E4=BD=8D=E7=BB=93=E6=9E=9C=E4=B8=8E?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽取并透传画布操作结构化结果 区分画布资源不存在与其他定位失败提示 明确重复资源仅定位首个图层并补充文档与测试 --- .../【编辑器】画布Agent对话面板-2026-07-03.md | 2 +- .../MessageBubble.test.tsx | 21 ++++++-- .../MessageBubbleRightClickMenu.tsx | 44 ++++++++++------ .../useRightClickMenu.ts | 50 +++++++++++++------ .../ImageCanvasActionsContext.test.tsx | 6 +-- .../image-editor/ImageCanvasActionsContext.ts | 7 ++- .../image-editor/ImageCanvasEditorView.tsx | 10 ++-- 7 files changed, 98 insertions(+), 42 deletions(-) diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 0ea41ceb5..ade82fdd1 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -50,7 +50,7 @@ ## 生成结果落画板(对现有占位规则的例外) - 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。 -- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;同一 `resourceId` 对应多个画布图层时只聚焦当前图层顺序中的首个,不同时聚焦或轮询多个图层。结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。定位返回 `{ successed, reason? }`:画布中无对应图层时为 `reason="not-found-on-canva"`,右键菜单显示「画布上不存在」;其余失败使用 `reason="other"`,仅显示「失败」。 - 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 ``。 - 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。 - 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 3608746d6..2702e41fb 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -51,7 +51,7 @@ function renderMessage( describe('MessageBubble', () => { beforeEach(() => { vi.clearAllMocks(); - focusResourceMock.mockReturnValue({ found: true }); + focusResourceMock.mockReturnValue({ successed: true }); vi.mocked(copyTextToClipboard).mockResolvedValue(true); }); @@ -451,12 +451,27 @@ describe('MessageBubble', () => { } expect(focusResourceMock).toHaveBeenCalledTimes(4); - focusResourceMock.mockReturnValueOnce({ found: false }); + focusResourceMock.mockReturnValueOnce({ + successed: false, + reason: 'not-found-on-canva', + }); fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 }); fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' })); expect(focusResourceMock).toHaveBeenLastCalledWith('resource-image'); - expect(screen.getByRole('menuitem', { name: '定位失败' })).toBeTruthy(); + expect( + screen.getByRole('menuitem', { name: '画布上不存在' }), + ).toBeTruthy(); + + fireEvent.keyDown(window, { key: 'Escape' }); + focusResourceMock.mockReturnValueOnce({ + successed: false, + reason: 'other', + }); + fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 }); + fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' })); + + expect(screen.getByRole('menuitem', { name: '失败' })).toBeTruthy(); }); it('exports right-click images through the native HostBridge', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 9b98c29ed..39fe51442 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -1,6 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; + import { contextAssetMediaSrc, type EditorAgentContextAsset, @@ -14,7 +16,7 @@ type MessageBubbleRightClickMenuProps = { target: RightClickMenuTarget; pendingAction: EditorAgentRightClickAction | null; resultAction: EditorAgentRightClickAction | null; - result: 'success' | 'error' | null; + result: ImageCanvasActionResult | null; onAction: (action: EditorAgentRightClickAction) => void; onClose: () => void; }; @@ -32,36 +34,46 @@ function actionLabel({ idleLabel: string; pendingAction: EditorAgentRightClickAction | null; resultAction: EditorAgentRightClickAction | null; - result: 'success' | 'error' | null; + result: ImageCanvasActionResult | null; }) { - let actionState: 'pending' | 'success' | 'error' | null = null; if (pendingAction === action) { - actionState = 'pending'; - } else if (resultAction === action) { - actionState = result; + switch (action) { + case EditorAgentRightClickAction.FocusCanvas: + return '定位中'; + case EditorAgentRightClickAction.DownloadAsset: + return '下载中'; + case EditorAgentRightClickAction.ReferenceImage: + return '引用中'; + case EditorAgentRightClickAction.CopyText: + case EditorAgentRightClickAction.CopyImage: + return '复制中'; + } } - if (!actionState) { + const actionResult = resultAction === action ? result : null; + if (!actionResult) { return idleLabel; } switch (action) { case EditorAgentRightClickAction.FocusCanvas: - if (actionState === 'pending') return '定位中'; - if (actionState === 'success') return '已定位'; - return '定位失败'; + if (actionResult.successed) return '已定位'; + switch (actionResult.reason) { + case 'not-found-on-canva': + return '画布上不存在'; + case 'other': + default: + return '失败'; + } case EditorAgentRightClickAction.DownloadAsset: - if (actionState === 'pending') return '下载中'; - if (actionState === 'success') return '已下载'; + if (actionResult.successed) return '已下载'; return '下载失败'; case EditorAgentRightClickAction.ReferenceImage: - if (actionState === 'pending') return '引用中'; - if (actionState === 'success') return '已引用'; + if (actionResult.successed) return '已引用'; return '引用失败'; case EditorAgentRightClickAction.CopyText: case EditorAgentRightClickAction.CopyImage: - if (actionState === 'pending') return '复制中'; - if (actionState === 'success') return '已复制'; + if (actionResult.successed) return '已复制'; return '复制失败'; } } diff --git a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts index 1f20e6d29..1ca85154d 100644 --- a/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts +++ b/src/components/image-editor/EditorAgentConversation/useRightClickMenu.ts @@ -4,7 +4,10 @@ import { useState, } from 'react'; -import type { ImageCanvasActions } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; +import type { + ImageCanvasActionResult, + ImageCanvasActions, +} from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; import { readAssetBytes } from '@/src/services/assetReadUrlService.ts'; import { copyTextToClipboard } from '@/src/services/clipboard.ts'; import { @@ -30,7 +33,7 @@ type RightClickMenuState = { target: RightClickMenuTarget; pendingAction: EditorAgentRightClickAction | null; resultAction: EditorAgentRightClickAction | null; - result: 'success' | 'error' | null; + result: ImageCanvasActionResult | null; }; function sanitizeDownloadName(value: string, extension: string) { @@ -236,15 +239,23 @@ async function downloadAsset(asset: EditorAgentContextAsset) { function focusAssetOnCanvas( asset: EditorAgentContextAsset, onFocusResource?: ImageCanvasActions['focusResource'], -) { +): ReturnType { if (asset.kind !== 'generated_media' || !onFocusResource) { - return false; + return { successed: false, reason: 'other' }; } const resourceId = asset.resourceId?.trim(); if (!resourceId) { - return false; + return { successed: false, reason: 'other' }; } - return onFocusResource(resourceId).found; + try { + return onFocusResource(resourceId); + } catch { + return { successed: false, reason: 'other' }; + } +} + +function actionResultFromSuccess(successed: boolean): ImageCanvasActionResult { + return successed ? { successed: true } : { successed: false, reason: 'other' }; } export function useRightClickMenu({ @@ -293,16 +304,23 @@ export function useRightClickMenu({ } : current, ); - let succeeded = false; + let actionResult: ImageCanvasActionResult = { + successed: false, + reason: 'other', + }; switch (action) { case EditorAgentRightClickAction.CopyText: if (target.kind === 'text') { - succeeded = await copyTextToClipboard(target.text); + actionResult = actionResultFromSuccess( + await copyTextToClipboard(target.text), + ); } break; case EditorAgentRightClickAction.CopyImage: if (target.kind === 'asset' && target.asset.mediaType === 'image') { - succeeded = await copyAssetImage(target.asset); + actionResult = actionResultFromSuccess( + await copyAssetImage(target.asset), + ); } break; case EditorAgentRightClickAction.ReferenceImage: @@ -311,17 +329,21 @@ export function useRightClickMenu({ target.asset.mediaType === 'image' && contextAssetMediaSrc(target.asset).trim() ) { - succeeded = onReferenceImage?.(target.asset) ?? false; + actionResult = actionResultFromSuccess( + onReferenceImage?.(target.asset) ?? false, + ); } break; case EditorAgentRightClickAction.FocusCanvas: if (target.kind === 'asset') { - succeeded = focusAssetOnCanvas(target.asset, onFocusResource); + actionResult = focusAssetOnCanvas(target.asset, onFocusResource); } break; case EditorAgentRightClickAction.DownloadAsset: if (target.kind === 'asset') { - succeeded = await downloadAsset(target.asset); + actionResult = actionResultFromSuccess( + await downloadAsset(target.asset), + ); } break; } @@ -329,14 +351,14 @@ export function useRightClickMenu({ if (current?.target !== target || current.pendingAction !== action) { return current; } - if (succeeded) { + if (actionResult.successed) { return null; } return { ...current, pendingAction: null, resultAction: action, - result: 'error', + result: actionResult, }; }); }, diff --git a/src/components/image-editor/ImageCanvasActionsContext.test.tsx b/src/components/image-editor/ImageCanvasActionsContext.test.tsx index a383b68dc..dacbf2968 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.test.tsx +++ b/src/components/image-editor/ImageCanvasActionsContext.test.tsx @@ -11,7 +11,7 @@ import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; describe('ImageCanvasActionsContext', () => { it('exposes the editor-scoped canvas actions', () => { - const focusResource = vi.fn(() => ({ found: true })); + const focusResource = vi.fn(() => ({ successed: true })); const refreshCanvas = vi.fn(); const { result } = renderHook(useImageCanvasActions, { wrapper: ({ children }: { children: ReactNode }) => ( @@ -24,13 +24,13 @@ describe('ImageCanvasActionsContext', () => { ), }); - let focusResult: { found: boolean } | undefined; + let focusResult: { successed: boolean } | undefined; act(() => { focusResult = result.current.focusResource('resource-a'); result.current.refreshCanvas(); }); - expect(focusResult).toEqual({ found: true }); + expect(focusResult).toEqual({ successed: true }); expect(focusResource).toHaveBeenCalledWith('resource-a'); expect(refreshCanvas).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasActionsContext.ts b/src/components/image-editor/ImageCanvasActionsContext.ts index b989b66e9..6f7527288 100644 --- a/src/components/image-editor/ImageCanvasActionsContext.ts +++ b/src/components/image-editor/ImageCanvasActionsContext.ts @@ -1,7 +1,12 @@ import { createContext, useContext } from 'react'; +export interface ImageCanvasActionResult { + successed: boolean; + reason?: 'not-found-on-canva' | 'other'; +} + export type ImageCanvasActions = { - focusResource: (resourceId: string) => { found: boolean }; + focusResource: (resourceId: string) => ImageCanvasActionResult; refreshCanvas: () => void; }; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4f5f39d17..e6f8a7236 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -32,6 +32,7 @@ import { PlatformRechargePaymentResultDialog, } from '../platform-entry/PlatformRechargePaymentStatusDialogs'; import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController'; +import type { ImageCanvasActionResult } from './ImageCanvasActionsContext'; import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider'; import { canvasAssetKindOrNull, @@ -901,19 +902,20 @@ export function ImageCanvasEditorView({ [animateViewportTo, canvasSize, setViewport], ); const focusCanvasResource = useCallback( - (resourceId: string) => { + (resourceId: string): ImageCanvasActionResult => { const normalizedResourceId = resourceId.trim(); if (!normalizedResourceId) { - return { found: false }; + return { successed: false, reason: 'other' }; } + // 同一 resourceId 对应多个画布图层时,定位顺序中的首个图层,保持单目标聚焦行为。 const targetLayer = layersRef.current.find( (layer) => layer.resourceId === normalizedResourceId, ); if (!targetLayer) { - return { found: false }; + return { successed: false, reason: 'not-found-on-canva' }; } focusCanvasBounds(targetLayer, { animate: true }); - return { found: true }; + return { successed: true }; }, [focusCanvasBounds], );