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],
);