right click copy/dowload option
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 画布Agent对话面板
|
||||
|
||||
日期:`2026-07-16`
|
||||
日期:`2026-07-20`
|
||||
|
||||
## 定位与边界
|
||||
|
||||
@@ -114,11 +114,12 @@
|
||||
5. 生成中的进行中动画;
|
||||
6. 错误气泡(失败/余额不足,带原因);
|
||||
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
|
||||
8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体。右键动作由消息气泡的 `useRightClickMenu` 内部执行,不向上层暴露消息文本、素材地址或私有对象键。
|
||||
|
||||
不做(明确排除,防止后人补齐):
|
||||
|
||||
- 点赞/点踩反馈按钮;
|
||||
- 消息复制、分享/导出对话;
|
||||
- 分享/导出整段对话;
|
||||
- Agent 模式切换下拉(固定单一 Agent);
|
||||
- 语音输入、@引用、多 Agent 协作;
|
||||
- Lovart 的积分/加速档位显示(泥点扣费只在生成动作上体现)。
|
||||
@@ -156,3 +157,4 @@
|
||||
- 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。
|
||||
- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;轮询到任务终态并完成会话懒回填后统一刷新工程快照和素材库。
|
||||
- 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。
|
||||
- 对话正文右键菜单只复制当前气泡展示的完整文本,隐藏的内部 system 文本不得进入菜单;素材右键菜单优先于正文菜单,私有素材继续通过既有读取链路换签或代理下载,不复制会过期的临时链接。
|
||||
|
||||
@@ -3,16 +3,33 @@ import { Image as ImageIcon, X } from 'lucide-react';
|
||||
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
|
||||
import type { RightClickMenuHandler } from './common.ts';
|
||||
|
||||
function AttachmentChip({
|
||||
attachment,
|
||||
onRemove,
|
||||
onRightClickMenu,
|
||||
}: {
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
onRemove?: () => void;
|
||||
onRightClickMenu?: RightClickMenuHandler;
|
||||
}) {
|
||||
const label = attachment.label?.trim() || attachment.referenceId;
|
||||
return (
|
||||
<span className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm">
|
||||
<span
|
||||
className="group relative inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 shadow-sm"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
mediaType: 'image',
|
||||
source: attachment.imageSrc,
|
||||
objectKey: attachment.objectKey,
|
||||
suggestedFileName: label,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
{onRemove ? (
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
|
||||
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
|
||||
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
|
||||
|
||||
import { MessageBubble } from './MessageBubble.tsx';
|
||||
|
||||
vi.mock('@/src/services/assetReadUrlService.ts', () => ({
|
||||
getSignedAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
|
||||
readAssetBytes: vi.fn(),
|
||||
resolveAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'),
|
||||
shouldResolveAssetReadUrl: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('@/src/services/clipboard.ts', () => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderMessage(message: EditorAgentMessage) {
|
||||
return render(
|
||||
<MessageBubble
|
||||
@@ -19,6 +32,11 @@ function renderMessage(message: EditorAgentMessage) {
|
||||
}
|
||||
|
||||
describe('MessageBubble', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(copyTextToClipboard).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
|
||||
renderMessage({
|
||||
id: 2,
|
||||
@@ -50,4 +68,161 @@ describe('MessageBubble', () => {
|
||||
|
||||
expect(container.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('copies the complete visible message and only exposes the enum action', async () => {
|
||||
renderMessage({
|
||||
id: 4,
|
||||
role: 'assistant',
|
||||
text: '第一行\n第二行',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-20T00:00:00Z',
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(screen.getByLabelText('Agent消息'), {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(copyTextToClipboard).toHaveBeenCalledWith('第一行\n第二行'),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('menuitem', { name: '已复制' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('copies an attached WebP as PNG without opening the message text menu', async () => {
|
||||
const clipboardWrite = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { write: clipboardWrite },
|
||||
});
|
||||
const ClipboardItemMock = vi.fn(function (
|
||||
this: { items: Record<string, Blob> },
|
||||
items: Record<string, Blob>,
|
||||
) {
|
||||
this.items = items;
|
||||
});
|
||||
Object.defineProperty(globalThis, 'ClipboardItem', {
|
||||
configurable: true,
|
||||
value: ClipboardItemMock,
|
||||
});
|
||||
const bitmapClose = vi.fn();
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockResolvedValue({
|
||||
width: 16,
|
||||
height: 9,
|
||||
close: bitmapClose,
|
||||
}),
|
||||
});
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
|
||||
(callback) => callback(new Blob(['png'], { type: 'image/png' })),
|
||||
);
|
||||
vi.mocked(readAssetBytes).mockResolvedValue({
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['webp'], { type: 'image/webp' })),
|
||||
} as unknown as Response);
|
||||
const { container } = renderMessage({
|
||||
id: 5,
|
||||
role: 'user',
|
||||
text: '参考这张图',
|
||||
attachments: [
|
||||
{
|
||||
source: 'canvas_resource',
|
||||
referenceId: 'resource-1',
|
||||
objectKey: 'editor/private.webp',
|
||||
imageSrc: '/generated-editor-images/private.webp',
|
||||
label: '参考图.webp',
|
||||
},
|
||||
],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-20T00:00:00Z',
|
||||
});
|
||||
|
||||
fireEvent.contextMenu(container.querySelector('.group')!, {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('menu', { name: '消息素材右键菜单' })).toBeTruthy();
|
||||
expect(screen.queryByRole('menuitem', { name: '复制文本' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '复制图片' }));
|
||||
|
||||
await waitFor(() => expect(clipboardWrite).toHaveBeenCalledTimes(1));
|
||||
expect(readAssetBytes).toHaveBeenCalledWith(
|
||||
'/generated-editor-images/private.webp',
|
||||
{ objectKey: 'editor/private.webp' },
|
||||
);
|
||||
expect(ClipboardItemMock).toHaveBeenCalledWith({
|
||||
'image/png': expect.any(Blob),
|
||||
});
|
||||
expect(bitmapClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('downloads generated video with the inferred extension', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:agent-video');
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectURL,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectURL,
|
||||
});
|
||||
const anchorClick = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
vi.mocked(readAssetBytes).mockResolvedValue({
|
||||
blob: vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })),
|
||||
} as unknown as Response);
|
||||
const { container } = renderMessage({
|
||||
id: 6,
|
||||
role: 'system',
|
||||
text: 'internal tool result',
|
||||
attachments: [],
|
||||
toolCall: {
|
||||
toolName: 'generate_video',
|
||||
status: 'completed',
|
||||
args: {},
|
||||
displayArgs: {
|
||||
stringArgs: [],
|
||||
imageArgs: [],
|
||||
extras: { priceMudPoints: 1 },
|
||||
},
|
||||
images: [],
|
||||
videos: [
|
||||
{
|
||||
resourceId: 'video-1',
|
||||
objectKey: 'editor/video.mp4',
|
||||
videoSrc: '/generated-editor-videos/video.mp4',
|
||||
},
|
||||
],
|
||||
audios: [],
|
||||
},
|
||||
createdAt: '2026-07-20T00:00:00Z',
|
||||
});
|
||||
await waitFor(() => expect(container.querySelector('video')).toBeTruthy());
|
||||
const video = container.querySelector('video');
|
||||
|
||||
fireEvent.contextMenu(video!.parentElement!, {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
expect(screen.queryByRole('menuitem', { name: '复制图片' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '下载视频' }));
|
||||
|
||||
await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
|
||||
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:agent-video');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversa
|
||||
import { PendingToolCall } from '@/src/components/image-editor/EditorAgentConversation/PendingToolCall.tsx';
|
||||
import ToolCallView from '@/src/components/image-editor/EditorAgentConversation/ToolCallView.tsx';
|
||||
|
||||
import { MessageBubbleRightClickMenu } from './MessageBubbleRightClickMenu.tsx';
|
||||
import { useRightClickMenu } from './useRightClickMenu.ts';
|
||||
|
||||
function messageRoleLabel(role: EditorAgentMessage['role']) {
|
||||
if (role === 'user') {
|
||||
return '你';
|
||||
@@ -54,6 +57,12 @@ export function MessageBubble({
|
||||
onCancelToolCall,
|
||||
onJobCompleted,
|
||||
}: MessageBubbleProps) {
|
||||
const {
|
||||
rightClickMenu,
|
||||
openRightClickMenu,
|
||||
closeRightClickMenu,
|
||||
runRightClickAction,
|
||||
} = useRightClickMenu();
|
||||
const systemErrorText =
|
||||
message.role === 'system' &&
|
||||
!message.toolCall &&
|
||||
@@ -61,7 +70,16 @@ export function MessageBubble({
|
||||
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
|
||||
: null;
|
||||
|
||||
if (message.role === 'system' && !message.toolCall && systemErrorText === null) {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const isSystemError = systemErrorText !== null;
|
||||
const visibleText = systemErrorText ?? (!isSystem ? message.text : '');
|
||||
|
||||
if (
|
||||
message.role === 'system' &&
|
||||
!message.toolCall &&
|
||||
systemErrorText === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
@@ -81,56 +99,78 @@ 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={
|
||||
isSystemError
|
||||
? 'Agent错误'
|
||||
: isSystem
|
||||
? 'Agent操作'
|
||||
: `${messageRoleLabel(message.role)}消息`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
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'
|
||||
: isSystemError || message.toolCall?.status === 'failed'
|
||||
? 'border border-red-200 bg-red-50 text-red-700'
|
||||
: 'border border-slate-200 bg-white text-slate-700'
|
||||
}`
|
||||
<>
|
||||
<article
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
aria-label={
|
||||
isSystemError
|
||||
? 'Agent错误'
|
||||
: isSystem
|
||||
? 'Agent操作'
|
||||
: `${messageRoleLabel(message.role)}消息`
|
||||
}
|
||||
onContextMenu={
|
||||
visibleText.trim()
|
||||
? (event) =>
|
||||
openRightClickMenu(event, { kind: 'text', text: visibleText })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(!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">
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentChip
|
||||
key={attachmentKey(attachment)}
|
||||
attachment={attachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.toolCall ? (
|
||||
<ToolCallView
|
||||
toolCall={message.toolCall}
|
||||
onJobCompleted={onJobCompleted}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
<div
|
||||
className={
|
||||
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'
|
||||
: 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 || 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">
|
||||
{message.attachments.map((attachment) => (
|
||||
<AttachmentChip
|
||||
key={attachmentKey(attachment)}
|
||||
attachment={attachment}
|
||||
onRightClickMenu={(event, asset) =>
|
||||
openRightClickMenu(event, { kind: 'asset', asset })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.toolCall ? (
|
||||
<ToolCallView
|
||||
toolCall={message.toolCall}
|
||||
onJobCompleted={onJobCompleted}
|
||||
onRightClickMenu={(event, asset) =>
|
||||
openRightClickMenu(event, { kind: 'asset', asset })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
{rightClickMenu ? (
|
||||
<MessageBubbleRightClickMenu
|
||||
x={rightClickMenu.x}
|
||||
y={rightClickMenu.y}
|
||||
target={rightClickMenu.target}
|
||||
pendingAction={rightClickMenu.pendingAction}
|
||||
resultAction={rightClickMenu.resultAction}
|
||||
result={rightClickMenu.result}
|
||||
onAction={(action) => void runRightClickAction(action)}
|
||||
onClose={closeRightClickMenu}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
type EditorAgentContextAsset,
|
||||
EditorAgentRightClickAction,
|
||||
type RightClickMenuTarget,
|
||||
} from './common.ts';
|
||||
|
||||
type MessageBubbleRightClickMenuProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
target: RightClickMenuTarget;
|
||||
pendingAction: EditorAgentRightClickAction | null;
|
||||
resultAction: EditorAgentRightClickAction | null;
|
||||
result: 'success' | 'error' | null;
|
||||
onAction: (action: EditorAgentRightClickAction) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
function actionLabel({
|
||||
action,
|
||||
idleLabel,
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
}: {
|
||||
action: EditorAgentRightClickAction;
|
||||
idleLabel: string;
|
||||
pendingAction: EditorAgentRightClickAction | null;
|
||||
resultAction: EditorAgentRightClickAction | null;
|
||||
result: 'success' | 'error' | null;
|
||||
}) {
|
||||
if (pendingAction === action) {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '下载中'
|
||||
: '复制中';
|
||||
}
|
||||
if (resultAction !== action) {
|
||||
return idleLabel;
|
||||
}
|
||||
if (result === 'success') {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '已下载'
|
||||
: '已复制';
|
||||
}
|
||||
if (result === 'error') {
|
||||
return action === EditorAgentRightClickAction.DownloadAsset
|
||||
? '下载失败'
|
||||
: '复制失败';
|
||||
}
|
||||
return idleLabel;
|
||||
}
|
||||
|
||||
function assetDownloadLabel(asset: EditorAgentContextAsset) {
|
||||
return `下载${
|
||||
asset.mediaType === 'image'
|
||||
? '图片'
|
||||
: asset.mediaType === 'video'
|
||||
? '视频'
|
||||
: '音频'
|
||||
}`;
|
||||
}
|
||||
|
||||
export function MessageBubbleRightClickMenu({
|
||||
x,
|
||||
y,
|
||||
target,
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
onAction,
|
||||
onClose,
|
||||
}: MessageBubbleRightClickMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const menu = menuRef.current;
|
||||
if (!menu || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const rect = menu.getBoundingClientRect();
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
Math.max(x, VIEWPORT_MARGIN),
|
||||
Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerWidth - rect.width - VIEWPORT_MARGIN,
|
||||
),
|
||||
),
|
||||
y: Math.min(
|
||||
Math.max(y, VIEWPORT_MARGIN),
|
||||
Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerHeight - rect.height - VIEWPORT_MARGIN,
|
||||
),
|
||||
),
|
||||
});
|
||||
}, [target.kind, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointerdown', handlePointerDown);
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('scroll', onClose, true);
|
||||
window.addEventListener('resize', onClose);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', handlePointerDown);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('scroll', onClose, true);
|
||||
window.removeEventListener('resize', onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="image-canvas-editor__context-menu"
|
||||
role="menu"
|
||||
aria-label={target.kind === 'text' ? '消息右键菜单' : '消息素材右键菜单'}
|
||||
style={{ left: position?.x ?? x, top: position?.y ?? y }}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{target.kind === 'text' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.CopyText)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.CopyText,
|
||||
idleLabel: '复制文本',
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{target.asset.mediaType === 'image' ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.CopyImage)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.CopyImage,
|
||||
idleLabel: '复制图片',
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => onAction(EditorAgentRightClickAction.DownloadAsset)}
|
||||
>
|
||||
{actionLabel({
|
||||
action: EditorAgentRightClickAction.DownloadAsset,
|
||||
idleLabel: assetDownloadLabel(target.asset),
|
||||
pendingAction,
|
||||
resultAction,
|
||||
result,
|
||||
})}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,16 @@ import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { ResolvedAssetVideo } from '@/src/components/ResolvedAssetVideo.tsx';
|
||||
import { getExternalGenerationJobStatus } from '@/src/services/external-generation';
|
||||
|
||||
import type { RightClickMenuHandler } from './common.ts';
|
||||
|
||||
function ToolCallView({
|
||||
toolCall,
|
||||
onJobCompleted,
|
||||
onRightClickMenu,
|
||||
}: {
|
||||
toolCall: EditorAgentToolCall;
|
||||
onJobCompleted?: () => void;
|
||||
onRightClickMenu?: RightClickMenuHandler;
|
||||
}) {
|
||||
const videos = toolCall.videos ?? [];
|
||||
const audios = toolCall.audios ?? [];
|
||||
@@ -69,6 +73,8 @@ function ToolCallView({
|
||||
disposed = true;
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
|
||||
const isCancelled = displayStatus === 'cancelled';
|
||||
const isCompleted = displayStatus === 'completed';
|
||||
@@ -107,6 +113,17 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
mediaType: 'image',
|
||||
source: image.imageSrc,
|
||||
objectKey: image.objectKey,
|
||||
suggestedFileName: `Agent生成图片-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={image.thumbnailSrc ?? image.imageSrc}
|
||||
@@ -125,6 +142,17 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${video.resourceId ?? video.objectKey ?? index}`}
|
||||
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
mediaType: 'video',
|
||||
source: video.videoSrc,
|
||||
objectKey: video.objectKey,
|
||||
suggestedFileName: `Agent生成视频-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ResolvedAssetVideo
|
||||
src={video.videoSrc}
|
||||
@@ -148,6 +176,17 @@ function ToolCallView({
|
||||
<div
|
||||
key={`${toolCall.toolName}-${audio.resourceId ?? audio.objectKey ?? index}`}
|
||||
className="flex items-center gap-2 rounded-xl border border-slate-200 bg-slate-50 p-2"
|
||||
onContextMenu={
|
||||
onRightClickMenu
|
||||
? (event) =>
|
||||
onRightClickMenu(event, {
|
||||
mediaType: 'audio',
|
||||
source: audio.audioSrc,
|
||||
objectKey: audio.objectKey,
|
||||
suggestedFileName: `Agent生成音频-${index + 1}`,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Volume2
|
||||
className="h-4 w-4 shrink-0 text-slate-500"
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
|
||||
import type { EditorAgentAttachmentRef } from '@/packages/shared/src/contracts';
|
||||
|
||||
export enum EditorAgentRightClickAction {
|
||||
CopyText = 'copy_text',
|
||||
CopyImage = 'copy_image',
|
||||
DownloadAsset = 'download_asset',
|
||||
}
|
||||
|
||||
export type EditorAgentContextAsset = {
|
||||
mediaType: 'image' | 'video' | 'audio';
|
||||
source: string;
|
||||
objectKey?: string | null;
|
||||
suggestedFileName: string;
|
||||
};
|
||||
|
||||
export type RightClickMenuTarget =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'asset'; asset: EditorAgentContextAsset };
|
||||
|
||||
export type RightClickMenuHandler = (
|
||||
event: ReactMouseEvent<HTMLElement>,
|
||||
asset: EditorAgentContextAsset,
|
||||
) => void;
|
||||
|
||||
export function attachmentKey(attachment: EditorAgentAttachmentRef) {
|
||||
return `${attachment.source}:${attachment.referenceId}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
|
||||
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
|
||||
|
||||
import {
|
||||
type EditorAgentContextAsset,
|
||||
EditorAgentRightClickAction,
|
||||
type RightClickMenuTarget,
|
||||
} from './common.ts';
|
||||
|
||||
type RightClickMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
target: RightClickMenuTarget;
|
||||
pendingAction: EditorAgentRightClickAction | null;
|
||||
resultAction: EditorAgentRightClickAction | null;
|
||||
result: 'success' | 'error' | null;
|
||||
};
|
||||
|
||||
const MIME_FILE_EXTENSIONS: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/wav': 'wav',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(value: string, mimeType: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[<>:"/\\|?*]/gu, '-')
|
||||
.replace(/\p{Cc}/gu, '-')
|
||||
.replace(/[. ]+$/u, '')
|
||||
.slice(0, 100);
|
||||
const baseName = normalized || 'Agent素材';
|
||||
if (/\.[a-z0-9]{2,5}$/iu.test(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
const extension = MIME_FILE_EXTENSIONS[mimeType.toLowerCase()] ?? '';
|
||||
return extension ? `${baseName}.${extension}` : baseName;
|
||||
}
|
||||
|
||||
async function convertImageBlobToPng(blob: Blob) {
|
||||
if (blob.type.toLowerCase() === 'image/png') {
|
||||
return blob;
|
||||
}
|
||||
if (
|
||||
typeof createImageBitmap !== 'function' ||
|
||||
typeof document === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持复制此图片格式');
|
||||
}
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('图片转换失败');
|
||||
}
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((pngBlob) => {
|
||||
if (pngBlob) {
|
||||
resolve(pngBlob);
|
||||
} else {
|
||||
reject(new Error('图片转换失败'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAssetImage(asset: EditorAgentContextAsset) {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
typeof navigator.clipboard?.write !== 'function' ||
|
||||
typeof ClipboardItem === 'undefined'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await readAssetBytes(asset.source, {
|
||||
objectKey: asset.objectKey,
|
||||
});
|
||||
const pngBlob = await convertImageBlobToPng(await response.blob());
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob }),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAsset(asset: EditorAgentContextAsset) {
|
||||
if (
|
||||
typeof document === 'undefined' ||
|
||||
typeof URL.createObjectURL !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await readAssetBytes(asset.source, {
|
||||
objectKey: asset.objectKey,
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = sanitizeDownloadName(asset.suggestedFileName, blob.type);
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRightClickMenu() {
|
||||
const [rightClickMenu, setRightClickMenu] =
|
||||
useState<RightClickMenuState | null>(null);
|
||||
|
||||
const closeRightClickMenu = useCallback(() => {
|
||||
setRightClickMenu(null);
|
||||
}, []);
|
||||
|
||||
const openRightClickMenu = useCallback(
|
||||
(event: ReactMouseEvent<HTMLElement>, target: RightClickMenuTarget) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setRightClickMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
target,
|
||||
pendingAction: null,
|
||||
resultAction: null,
|
||||
result: null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const runRightClickAction = useCallback(
|
||||
async (action: EditorAgentRightClickAction) => {
|
||||
if (!rightClickMenu || rightClickMenu.pendingAction) {
|
||||
return;
|
||||
}
|
||||
const target = rightClickMenu.target;
|
||||
setRightClickMenu((current) =>
|
||||
current?.target === target
|
||||
? {
|
||||
...current,
|
||||
pendingAction: action,
|
||||
resultAction: null,
|
||||
result: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
const succeeded =
|
||||
action === EditorAgentRightClickAction.CopyText &&
|
||||
target.kind === 'text'
|
||||
? await copyTextToClipboard(target.text)
|
||||
: action === EditorAgentRightClickAction.CopyImage &&
|
||||
target.kind === 'asset' &&
|
||||
target.asset.mediaType === 'image'
|
||||
? await copyAssetImage(target.asset)
|
||||
: action === EditorAgentRightClickAction.DownloadAsset &&
|
||||
target.kind === 'asset'
|
||||
? await downloadAsset(target.asset)
|
||||
: false;
|
||||
setRightClickMenu((current) =>
|
||||
current?.target === target && current.pendingAction === action
|
||||
? {
|
||||
...current,
|
||||
pendingAction: null,
|
||||
resultAction: action,
|
||||
result: succeeded ? 'success' : 'error',
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[rightClickMenu],
|
||||
);
|
||||
|
||||
return {
|
||||
rightClickMenu,
|
||||
openRightClickMenu,
|
||||
closeRightClickMenu,
|
||||
runRightClickAction,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user