fix reference & paste concurrence, extract picker modal and hook
This commit is contained in:
@@ -73,6 +73,7 @@
|
||||
- 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。
|
||||
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
|
||||
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。
|
||||
- 输入区附件临时状态统一收口到 `useConversationAttachments`,选择弹窗由独立的 `AttachmentPicker` 负责纯展示;选择、引用、粘贴上传完成、移除、发送清空和失败恢复都必须经同一最新状态更新入口。异步粘贴完成时基于当时的最新附件去重并重新校验 9 张上限,不能用上传开始时捕获的旧列表覆盖期间新增的引用。
|
||||
|
||||
## 工具调用确认展示契约
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
|
||||
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
|
||||
import type {
|
||||
AttachmentPickerTab,
|
||||
EditorAgentAttachmentOption,
|
||||
} from './useConversationAttachments.ts';
|
||||
|
||||
export function AttachmentPicker({
|
||||
open,
|
||||
tab,
|
||||
canvasOptions,
|
||||
libraryOptions,
|
||||
selectedKeys,
|
||||
attachmentError,
|
||||
onTabChange,
|
||||
onToggleKey,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
tab: AttachmentPickerTab;
|
||||
canvasOptions: EditorAgentAttachmentOption[];
|
||||
libraryOptions: EditorAgentAttachmentOption[];
|
||||
selectedKeys: Set<string>;
|
||||
attachmentError: string | null;
|
||||
onTabChange: (tab: AttachmentPickerTab) => void;
|
||||
onToggleKey: (key: string) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="选择图片附件"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
|
||||
应用
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-[18rem] flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'canvas'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('canvas')}
|
||||
>
|
||||
画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'library'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('library')}
|
||||
>
|
||||
素材库
|
||||
</button>
|
||||
</div>
|
||||
{attachmentError ? (
|
||||
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
|
||||
{visibleOptions.map((option) => {
|
||||
const label =
|
||||
option.attachment.label?.trim() || option.attachment.referenceId;
|
||||
return (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
|
||||
}
|
||||
objectKey={option.attachment.objectKey}
|
||||
refreshKey={option.attachment.referenceId}
|
||||
alt=""
|
||||
className="aspect-square rounded-xl bg-slate-100 object-cover"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择${option.sourceLabel}图片 ${label}`}
|
||||
checked={selectedKeys.has(option.key)}
|
||||
onChange={() => onToggleKey(option.key)}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
+277
-9
@@ -524,9 +524,277 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'conversation-1' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a referenced image when a pending paste upload finishes', async () => {
|
||||
const client = createClient();
|
||||
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-referenced',
|
||||
objectKey: 'editor/referenced.png',
|
||||
imageSrc: '/generated-editor-images/referenced.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',
|
||||
});
|
||||
let resolveUpload!: (upload: {
|
||||
src: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
legacyPublicPath: string;
|
||||
}) => void;
|
||||
uploadEditorMediaAssetFileMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={[
|
||||
{
|
||||
id: 'layer-referenced',
|
||||
resourceId: 'resource-referenced',
|
||||
title: '并发引用图',
|
||||
src: '/generated-editor-images/referenced.png',
|
||||
objectKey: 'editor/referenced.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const messageLog = await screen.findByRole('log', {
|
||||
name: '画布 Agent 消息流',
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(messageLog.querySelector('.grid.grid-cols-3 > div')).toBeTruthy(),
|
||||
);
|
||||
const input = screen.getByLabelText('发送给画布 Agent');
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [
|
||||
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(
|
||||
messageLog.querySelector('.grid.grid-cols-3 > div')!,
|
||||
{ clientX: 30, clientY: 40 },
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '引用' }));
|
||||
expect(await screen.findByText('并发引用图')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveUpload({
|
||||
src: '/generated/pasted.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
||||
assetObjectId: 'asset-object-pasted',
|
||||
legacyPublicPath: '/generated/pasted.png',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('粘贴图片')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
referenceId: 'resource-referenced',
|
||||
}),
|
||||
expect.objectContaining({ referenceId: 'resource-pasted' }),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('evaluates the nine-image limit against references added during paste', async () => {
|
||||
const client = createClient();
|
||||
const layers = Array.from({ length: 9 }, (_, index) => ({
|
||||
id: `layer-${index + 1}`,
|
||||
resourceId: `resource-${index + 1}`,
|
||||
title: `附件-${index + 1}`,
|
||||
src: `/generated/attachment-${index + 1}.png`,
|
||||
objectKey: `editor/attachment-${index + 1}.png`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 512,
|
||||
originalHeight: 512,
|
||||
zIndex: index + 1,
|
||||
sourceType: 'generated' as const,
|
||||
}));
|
||||
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-9',
|
||||
objectKey: 'editor/attachment-9.png',
|
||||
imageSrc: '/generated/attachment-9.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',
|
||||
});
|
||||
let resolveUpload!: (upload: {
|
||||
src: string;
|
||||
objectKey: string;
|
||||
assetObjectId: string;
|
||||
legacyPublicPath: string;
|
||||
}) => void;
|
||||
uploadEditorMediaAssetFileMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<EditorAgentConversationPanelView
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
client={client}
|
||||
layers={layers}
|
||||
/>,
|
||||
);
|
||||
|
||||
const messageLog = await screen.findByRole('log', {
|
||||
name: '画布 Agent 消息流',
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
|
||||
const attachmentDialog = screen.getByRole('dialog', {
|
||||
name: '选择图片附件',
|
||||
});
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('checkbox', {
|
||||
name: `选择画布图片 附件-${index}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
fireEvent.click(
|
||||
within(attachmentDialog).getByRole('button', { name: '应用' }),
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('发送给画布 Agent');
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: {
|
||||
files: [
|
||||
new File(['pending-paste'], 'pending.png', { type: 'image/png' }),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
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('附件-9')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveUpload({
|
||||
src: '/generated/pasted.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/agent-paste/image/pasted.png',
|
||||
assetObjectId: 'asset-object-pasted',
|
||||
legacyPublicPath: '/generated/pasted.png',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText('最多 9 张')).toBeTruthy();
|
||||
expect(screen.queryByText('粘贴图片')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
|
||||
expect(request?.attachments).toHaveLength(9);
|
||||
expect(request?.attachments).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ referenceId: 'resource-9' }),
|
||||
]),
|
||||
);
|
||||
expect(request?.attachments).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ referenceId: 'resource-pasted' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('sends selected attachments even when the text input is empty', async () => {
|
||||
@@ -648,9 +916,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
).toBe('失败后恢复这条草稿');
|
||||
expect(screen.getByText('角色图层')).toBeTruthy();
|
||||
expect(
|
||||
within(screen.getByRole('log', { name: '画布 Agent 消息流' })).queryByText(
|
||||
'失败后恢复这条草稿',
|
||||
),
|
||||
within(
|
||||
screen.getByRole('log', { name: '画布 Agent 消息流' }),
|
||||
).queryByText('失败后恢复这条草稿'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -785,9 +1053,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '取消' }).hasAttribute('disabled')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveConfirmation();
|
||||
|
||||
+27
-401
@@ -9,26 +9,16 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import { PlatformActionButton } from '@/src/components/common/PlatformActionButton.tsx';
|
||||
import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDangerConfirmDialog.tsx';
|
||||
import { UnifiedModal } from '@/src/components/common/UnifiedModal.tsx';
|
||||
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
|
||||
import {
|
||||
attachmentKey,
|
||||
type EditorAgentContextAsset,
|
||||
} from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
|
||||
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
|
||||
import {
|
||||
MessageBubble,
|
||||
ThinkingBubble,
|
||||
@@ -37,25 +27,14 @@ import type {
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
|
||||
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
|
||||
import { ResolvedAssetImage } from '@/src/components/ResolvedAssetImage.tsx';
|
||||
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
|
||||
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
|
||||
|
||||
import { useImageCanvasContextStore } from '../useImageCanvasContextStore.ts';
|
||||
import { useConversationAttachments } from './useConversationAttachments.ts';
|
||||
import {
|
||||
type EditorAgentConversationClient,
|
||||
useEditorAgentConversation,
|
||||
} from './useEditorAgentConversation';
|
||||
|
||||
type AttachmentPickerTab = 'canvas' | 'library';
|
||||
|
||||
type EditorAgentAttachmentOption = {
|
||||
key: string;
|
||||
sourceLabel: string;
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
};
|
||||
|
||||
type EditorAgentConversationPanelViewProps = {
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
@@ -71,170 +50,6 @@ function stopAgentPanelWheel(event: ReactWheelEvent<HTMLElement>) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function isImageLayer(layer: CanvasLayer) {
|
||||
return (
|
||||
(layer.mediaType ?? 'image') === 'image' &&
|
||||
Boolean(layer.resourceId?.trim()) &&
|
||||
layer.src.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAsset(asset: EditorAsset) {
|
||||
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
|
||||
}
|
||||
|
||||
function createCanvasAttachmentOptions(
|
||||
layers: CanvasLayer[] = [],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return layers.filter(isImageLayer).map((layer) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'canvas_resource',
|
||||
referenceId: layer.resourceId || layer.id,
|
||||
objectKey: layer.objectKey ?? null,
|
||||
imageSrc: layer.src,
|
||||
thumbnailSrc: layer.thumbnailSrc ?? null,
|
||||
label: layer.title,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '画布',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createLibraryAttachmentOptions(
|
||||
assets: EditorAsset[] = [],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return assets.filter(isImageAsset).map((asset) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'library_asset',
|
||||
referenceId: asset.id,
|
||||
objectKey: asset.objectKey ?? null,
|
||||
imageSrc: asset.src,
|
||||
thumbnailSrc: asset.thumbnailSrc ?? null,
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '素材库',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function AttachmentPickerModal({
|
||||
open,
|
||||
tab,
|
||||
canvasOptions,
|
||||
libraryOptions,
|
||||
selectedKeys,
|
||||
attachmentError,
|
||||
onTabChange,
|
||||
onToggleKey,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
tab: AttachmentPickerTab;
|
||||
canvasOptions: EditorAgentAttachmentOption[];
|
||||
libraryOptions: EditorAgentAttachmentOption[];
|
||||
selectedKeys: Set<string>;
|
||||
attachmentError: string | null;
|
||||
onTabChange: (tab: AttachmentPickerTab) => void;
|
||||
onToggleKey: (key: string) => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions;
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title="选择图片附件"
|
||||
size="md"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<PlatformActionButton tone="ghost" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton tone="primary" size="sm" onClick={onApply}>
|
||||
应用
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex min-h-[18rem] flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'canvas'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('canvas')}
|
||||
>
|
||||
画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1.5 text-sm ${
|
||||
tab === 'library'
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
onClick={() => onTabChange('library')}
|
||||
>
|
||||
素材库
|
||||
</button>
|
||||
</div>
|
||||
{attachmentError ? (
|
||||
<div className="rounded-2xl bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
{attachmentError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
|
||||
{visibleOptions.map((option) => {
|
||||
const label =
|
||||
option.attachment.label?.trim() || option.attachment.referenceId;
|
||||
return (
|
||||
<label
|
||||
key={option.key}
|
||||
className="flex cursor-pointer flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-2 text-sm text-slate-700 shadow-sm"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={
|
||||
option.attachment.thumbnailSrc ?? option.attachment.imageSrc
|
||||
}
|
||||
objectKey={option.attachment.objectKey}
|
||||
refreshKey={option.attachment.referenceId}
|
||||
alt=""
|
||||
className="aspect-square rounded-xl bg-slate-100 object-cover"
|
||||
/>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择${option.sourceLabel}图片 ${label}`}
|
||||
checked={selectedKeys.has(option.key)}
|
||||
onChange={() => onToggleKey(option.key)}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorAgentConversationPanelView({
|
||||
open,
|
||||
onToggleOpen,
|
||||
@@ -278,70 +93,31 @@ export function EditorAgentConversationPanelView({
|
||||
onConfirmSent,
|
||||
});
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
|
||||
[],
|
||||
);
|
||||
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
|
||||
const [attachmentPickerTab, setAttachmentPickerTab] =
|
||||
useState<AttachmentPickerTab>('canvas');
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
|
||||
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const {
|
||||
attachments,
|
||||
attachmentError,
|
||||
isPastingAttachment,
|
||||
attachmentPickerOpen,
|
||||
attachmentPickerTab,
|
||||
draftAttachmentKeys,
|
||||
canvasAttachmentOptions,
|
||||
libraryAttachmentOptions,
|
||||
setAttachmentPickerTab,
|
||||
openAttachmentPicker,
|
||||
closeAttachmentPicker,
|
||||
toggleAttachmentKey,
|
||||
applyAttachmentSelection,
|
||||
referenceContextAsset,
|
||||
handleInputPaste,
|
||||
removeAttachment,
|
||||
consumeAttachments,
|
||||
restoreAttachmentsIfEmpty,
|
||||
} = useConversationAttachments({ projectId, layers, assets });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
const canvasAttachmentOptions = useMemo(
|
||||
() => createCanvasAttachmentOptions(layers),
|
||||
[layers],
|
||||
);
|
||||
const libraryAttachmentOptions = useMemo(
|
||||
() => createLibraryAttachmentOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const attachmentOptionsByKey = useMemo(() => {
|
||||
const optionMap = new Map<string, EditorAgentAttachmentOption>();
|
||||
[...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach(
|
||||
(option) => optionMap.set(option.key, option),
|
||||
);
|
||||
return optionMap;
|
||||
}, [canvasAttachmentOptions, libraryAttachmentOptions]);
|
||||
|
||||
const hasProject = Boolean(projectId?.trim());
|
||||
const isConversationBusy = isWaiting || isToolCallActionPending;
|
||||
|
||||
const openAttachmentPicker = () => {
|
||||
setAttachmentError(null);
|
||||
setDraftAttachmentKeys(new Set(attachments.map(attachmentKey)));
|
||||
setAttachmentPickerOpen(true);
|
||||
};
|
||||
|
||||
const toggleAttachmentKey = (key: string) => {
|
||||
setDraftAttachmentKeys((currentKeys) => {
|
||||
const nextKeys = new Set(currentKeys);
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key);
|
||||
} else {
|
||||
nextKeys.add(key);
|
||||
}
|
||||
return nextKeys;
|
||||
});
|
||||
};
|
||||
|
||||
const applyAttachmentSelection = () => {
|
||||
const nextAttachments = Array.from(draftAttachmentKeys)
|
||||
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
|
||||
.filter((attachment): attachment is EditorAgentAttachmentRef =>
|
||||
Boolean(attachment),
|
||||
);
|
||||
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
setAttachments(nextAttachments);
|
||||
setAttachmentPickerOpen(false);
|
||||
};
|
||||
|
||||
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (isWaiting) {
|
||||
@@ -355,162 +131,12 @@ export function EditorAgentConversationPanelView({
|
||||
return;
|
||||
}
|
||||
setDraftText('');
|
||||
const nextAttachments = attachments;
|
||||
setAttachments([]);
|
||||
const nextAttachments = consumeAttachments();
|
||||
void sendMessage(text, nextAttachments).catch(() => {
|
||||
setDraftText((currentText) => (currentText ? currentText : text));
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.length ? currentAttachments : nextAttachments,
|
||||
);
|
||||
restoreAttachmentsIfEmpty(nextAttachments);
|
||||
});
|
||||
};
|
||||
const appendAttachments = (nextAttachments: EditorAgentAttachmentRef[]) => {
|
||||
function mergeAttachments(
|
||||
currentAttachments: EditorAgentAttachmentRef[],
|
||||
nextAttachments: EditorAgentAttachmentRef[],
|
||||
) {
|
||||
const merged = [...currentAttachments];
|
||||
const existingKeys = new Set(currentAttachments.map(attachmentKey));
|
||||
nextAttachments.forEach((attachment) => {
|
||||
const key = attachmentKey(attachment);
|
||||
if (!existingKeys.has(key)) {
|
||||
existingKeys.add(key);
|
||||
merged.push(attachment);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
const mergedAttachments = mergeAttachments(attachments, nextAttachments);
|
||||
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return false;
|
||||
}
|
||||
setAttachments(mergedAttachments);
|
||||
setAttachmentError(null);
|
||||
return true;
|
||||
};
|
||||
|
||||
const referenceContextAsset = (asset: EditorAgentContextAsset) => {
|
||||
const objectKey = asset.objectKey?.trim();
|
||||
const source = asset.source.trim();
|
||||
if (!objectKey && !source) {
|
||||
return false;
|
||||
}
|
||||
const options = [...canvasAttachmentOptions, ...libraryAttachmentOptions];
|
||||
const objectKeyOption = objectKey
|
||||
? options.find(
|
||||
({ attachment }) => attachment.objectKey?.trim() === objectKey,
|
||||
)
|
||||
: undefined;
|
||||
const option =
|
||||
objectKeyOption ||
|
||||
options.find(({ attachment }) => {
|
||||
return (
|
||||
attachment.imageSrc.trim() === source ||
|
||||
attachment.thumbnailSrc?.trim() === source
|
||||
);
|
||||
});
|
||||
return option ? appendAttachments([option.attachment]) : false;
|
||||
};
|
||||
|
||||
const createPastedAgentImageAttachment = async (
|
||||
file: File,
|
||||
): Promise<EditorAgentAttachmentRef> => {
|
||||
if (!projectId?.trim()) {
|
||||
throw new Error('缺少画布项目');
|
||||
}
|
||||
const [upload, dimensions] = await Promise.all([
|
||||
uploadEditorMediaAssetFile(file, 'image', {
|
||||
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
|
||||
entityId: projectId,
|
||||
metadata: {
|
||||
source: 'agent-input-paste',
|
||||
},
|
||||
}),
|
||||
probeImageFileDimensions(file),
|
||||
]);
|
||||
const width = dimensions?.width ?? 1;
|
||||
const height = dimensions?.height ?? 1;
|
||||
const resource = await createEditorProjectResource(projectId, {
|
||||
imageSrc: upload.src,
|
||||
objectKey: upload.objectKey,
|
||||
assetObjectId: upload.assetObjectId,
|
||||
width,
|
||||
height,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
return {
|
||||
source: 'canvas_resource',
|
||||
referenceId: resource.resourceId,
|
||||
objectKey: resource.objectKey ?? upload.objectKey,
|
||||
imageSrc: resource.imageSrc,
|
||||
thumbnailSrc: null,
|
||||
label: resource.label ?? '粘贴图片',
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
};
|
||||
};
|
||||
function extractClipboardImageFiles(
|
||||
clipboardData: DataTransfer | null,
|
||||
): File[] {
|
||||
if (!clipboardData) {
|
||||
return [];
|
||||
}
|
||||
const fileItems = Array.from(clipboardData.files ?? []).filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
return [...fileItems];
|
||||
}
|
||||
|
||||
const handleInputPaste = (
|
||||
event: ReactClipboardEvent<HTMLTextAreaElement>,
|
||||
) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPastingAttachment) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (attachments.length >= EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingAttachmentSlots =
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS - attachments.length;
|
||||
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
|
||||
const hasOverflow = uploadFiles.length < imageFiles.length;
|
||||
// TODO: deduplicate those existing assets
|
||||
setIsPastingAttachment(true);
|
||||
setAttachmentError('图片上传中');
|
||||
void Promise.all(
|
||||
uploadFiles.map((file) => createPastedAgentImageAttachment(file)),
|
||||
)
|
||||
.then((pastedAttachments) => {
|
||||
if (appendAttachments(pastedAttachments) && hasOverflow) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAttachmentError('图片粘贴失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPastingAttachment(false);
|
||||
});
|
||||
};
|
||||
|
||||
const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => {
|
||||
const key = attachmentKey(targetAttachment);
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.filter(
|
||||
(attachment) => attachmentKey(attachment) !== key,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
@@ -713,7 +339,7 @@ export function EditorAgentConversationPanelView({
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
<AttachmentPickerModal
|
||||
<AttachmentPicker
|
||||
open={attachmentPickerOpen}
|
||||
tab={attachmentPickerTab}
|
||||
canvasOptions={canvasAttachmentOptions}
|
||||
@@ -723,7 +349,7 @@ export function EditorAgentConversationPanelView({
|
||||
onTabChange={setAttachmentPickerTab}
|
||||
onToggleKey={toggleAttachmentKey}
|
||||
onApply={applyAttachmentSelection}
|
||||
onClose={() => setAttachmentPickerOpen(false)}
|
||||
onClose={closeAttachmentPicker}
|
||||
/>
|
||||
<PlatformDangerConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS,
|
||||
type EditorAgentAttachmentRef,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
} from '@/src/components/image-editor/ImageCanvasEditorTypes.ts';
|
||||
import { probeImageFileDimensions } from '@/src/components/image-editor/ImageCanvasFileModel.ts';
|
||||
import { uploadEditorMediaAssetFile } from '@/src/services/image-editor/editorMediaAssetUploadClient.ts';
|
||||
import { createEditorProjectResource } from '@/src/services/image-editor/editorProjectClient.ts';
|
||||
|
||||
import { attachmentKey, type EditorAgentContextAsset } from './common.ts';
|
||||
|
||||
export type AttachmentPickerTab = 'canvas' | 'library';
|
||||
|
||||
export type EditorAgentAttachmentOption = {
|
||||
key: string;
|
||||
sourceLabel: string;
|
||||
attachment: EditorAgentAttachmentRef;
|
||||
};
|
||||
|
||||
type AttachmentUpdater =
|
||||
| EditorAgentAttachmentRef[]
|
||||
| ((
|
||||
currentAttachments: EditorAgentAttachmentRef[],
|
||||
) => EditorAgentAttachmentRef[]);
|
||||
|
||||
function isImageLayer(layer: CanvasLayer) {
|
||||
return (
|
||||
(layer.mediaType ?? 'image') === 'image' &&
|
||||
Boolean(layer.resourceId?.trim()) &&
|
||||
layer.src.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAsset(asset: EditorAsset) {
|
||||
return (asset.mediaType ?? 'image') === 'image' && asset.src.trim();
|
||||
}
|
||||
|
||||
function createCanvasAttachmentOptions(
|
||||
layers: CanvasLayer[],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return layers.filter(isImageLayer).map((layer) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'canvas_resource',
|
||||
referenceId: layer.resourceId || layer.id,
|
||||
objectKey: layer.objectKey ?? null,
|
||||
imageSrc: layer.src,
|
||||
thumbnailSrc: layer.thumbnailSrc ?? null,
|
||||
label: layer.title,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '画布',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createLibraryAttachmentOptions(
|
||||
assets: EditorAsset[],
|
||||
): EditorAgentAttachmentOption[] {
|
||||
return assets.filter(isImageAsset).map((asset) => {
|
||||
const attachment: EditorAgentAttachmentRef = {
|
||||
source: 'library_asset',
|
||||
referenceId: asset.id,
|
||||
objectKey: asset.objectKey ?? null,
|
||||
imageSrc: asset.src,
|
||||
thumbnailSrc: asset.thumbnailSrc ?? null,
|
||||
label: asset.label,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
};
|
||||
return {
|
||||
key: attachmentKey(attachment),
|
||||
sourceLabel: '素材库',
|
||||
attachment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAttachments(
|
||||
currentAttachments: EditorAgentAttachmentRef[],
|
||||
nextAttachments: EditorAgentAttachmentRef[],
|
||||
) {
|
||||
const merged = [...currentAttachments];
|
||||
const existingKeys = new Set(currentAttachments.map(attachmentKey));
|
||||
nextAttachments.forEach((attachment) => {
|
||||
const key = attachmentKey(attachment);
|
||||
if (!existingKeys.has(key)) {
|
||||
existingKeys.add(key);
|
||||
merged.push(attachment);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractClipboardImageFiles(clipboardData: DataTransfer | null) {
|
||||
if (!clipboardData) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(clipboardData.files ?? []).filter((file) =>
|
||||
file.type.startsWith('image/'),
|
||||
);
|
||||
}
|
||||
|
||||
async function createPastedAgentImageAttachment(
|
||||
projectId: string,
|
||||
file: File,
|
||||
): Promise<EditorAgentAttachmentRef> {
|
||||
const [upload, dimensions] = await Promise.all([
|
||||
uploadEditorMediaAssetFile(file, 'image', {
|
||||
pathSegments: ['editor', 'agent-paste', 'image', `${Date.now()}`],
|
||||
entityId: projectId,
|
||||
metadata: {
|
||||
source: 'agent-input-paste',
|
||||
},
|
||||
}),
|
||||
probeImageFileDimensions(file),
|
||||
]);
|
||||
const width = dimensions?.width ?? 1;
|
||||
const height = dimensions?.height ?? 1;
|
||||
const resource = await createEditorProjectResource(projectId, {
|
||||
imageSrc: upload.src,
|
||||
objectKey: upload.objectKey,
|
||||
assetObjectId: upload.assetObjectId,
|
||||
width,
|
||||
height,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
return {
|
||||
source: 'canvas_resource',
|
||||
referenceId: resource.resourceId,
|
||||
objectKey: resource.objectKey ?? upload.objectKey,
|
||||
imageSrc: resource.imageSrc,
|
||||
thumbnailSrc: null,
|
||||
label: resource.label ?? '粘贴图片',
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function useConversationAttachments({
|
||||
projectId,
|
||||
layers,
|
||||
assets,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
layers: CanvasLayer[];
|
||||
assets: EditorAsset[];
|
||||
}) {
|
||||
const [attachments, setAttachments] = useState<EditorAgentAttachmentRef[]>(
|
||||
[],
|
||||
);
|
||||
const attachmentsRef = useRef<EditorAgentAttachmentRef[]>([]);
|
||||
const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false);
|
||||
const [attachmentPickerTab, setAttachmentPickerTab] =
|
||||
useState<AttachmentPickerTab>('canvas');
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [isPastingAttachment, setIsPastingAttachment] = useState(false);
|
||||
const isPastingAttachmentRef = useRef(false);
|
||||
const [draftAttachmentKeys, setDraftAttachmentKeys] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const canvasAttachmentOptions = useMemo(
|
||||
() => createCanvasAttachmentOptions(layers),
|
||||
[layers],
|
||||
);
|
||||
const libraryAttachmentOptions = useMemo(
|
||||
() => createLibraryAttachmentOptions(assets),
|
||||
[assets],
|
||||
);
|
||||
const attachmentOptions = useMemo(
|
||||
() => [...canvasAttachmentOptions, ...libraryAttachmentOptions],
|
||||
[canvasAttachmentOptions, libraryAttachmentOptions],
|
||||
);
|
||||
const attachmentOptionsByKey = useMemo(() => {
|
||||
const optionMap = new Map<string, EditorAgentAttachmentOption>();
|
||||
attachmentOptions.forEach((option) => optionMap.set(option.key, option));
|
||||
return optionMap;
|
||||
}, [attachmentOptions]);
|
||||
|
||||
const updateAttachments = useCallback((updater: AttachmentUpdater) => {
|
||||
const nextAttachments =
|
||||
typeof updater === 'function' ? updater(attachmentsRef.current) : updater;
|
||||
attachmentsRef.current = nextAttachments;
|
||||
setAttachments(nextAttachments);
|
||||
return nextAttachments;
|
||||
}, []);
|
||||
|
||||
const appendAttachments = useCallback(
|
||||
(nextAttachments: EditorAgentAttachmentRef[]) => {
|
||||
const mergedAttachments = mergeAttachments(
|
||||
attachmentsRef.current,
|
||||
nextAttachments,
|
||||
);
|
||||
if (mergedAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return false;
|
||||
}
|
||||
updateAttachments(mergedAttachments);
|
||||
setAttachmentError(null);
|
||||
return true;
|
||||
},
|
||||
[updateAttachments],
|
||||
);
|
||||
|
||||
const openAttachmentPicker = useCallback(() => {
|
||||
setAttachmentError(null);
|
||||
setDraftAttachmentKeys(new Set(attachmentsRef.current.map(attachmentKey)));
|
||||
setAttachmentPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAttachmentPicker = useCallback(() => {
|
||||
setAttachmentPickerOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleAttachmentKey = useCallback((key: string) => {
|
||||
setDraftAttachmentKeys((currentKeys) => {
|
||||
const nextKeys = new Set(currentKeys);
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key);
|
||||
} else {
|
||||
nextKeys.add(key);
|
||||
}
|
||||
return nextKeys;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyAttachmentSelection = useCallback(() => {
|
||||
const nextAttachments = Array.from(draftAttachmentKeys)
|
||||
.map((key) => attachmentOptionsByKey.get(key)?.attachment)
|
||||
.filter((attachment): attachment is EditorAgentAttachmentRef =>
|
||||
Boolean(attachment),
|
||||
);
|
||||
if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
updateAttachments(nextAttachments);
|
||||
setAttachmentPickerOpen(false);
|
||||
}, [attachmentOptionsByKey, draftAttachmentKeys, updateAttachments]);
|
||||
|
||||
const referenceContextAsset = useCallback(
|
||||
(asset: EditorAgentContextAsset) => {
|
||||
const objectKey = asset.objectKey?.trim();
|
||||
const source = asset.source.trim();
|
||||
if (!objectKey && !source) {
|
||||
return false;
|
||||
}
|
||||
const objectKeyOption = objectKey
|
||||
? attachmentOptions.find(
|
||||
({ attachment }) => attachment.objectKey?.trim() === objectKey,
|
||||
)
|
||||
: undefined;
|
||||
const option =
|
||||
objectKeyOption ||
|
||||
attachmentOptions.find(({ attachment }) => {
|
||||
return (
|
||||
attachment.imageSrc.trim() === source ||
|
||||
attachment.thumbnailSrc?.trim() === source
|
||||
);
|
||||
});
|
||||
return option ? appendAttachments([option.attachment]) : false;
|
||||
},
|
||||
[appendAttachments, attachmentOptions],
|
||||
);
|
||||
|
||||
const handleInputPaste = useCallback(
|
||||
(event: ReactClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const imageFiles = extractClipboardImageFiles(event.clipboardData);
|
||||
if (!imageFiles.length || isPastingAttachmentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const remainingAttachmentSlots =
|
||||
EDITOR_AGENT_MAX_ATTACHMENTS - attachmentsRef.current.length;
|
||||
if (remainingAttachmentSlots <= 0) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadFiles = imageFiles.slice(0, remainingAttachmentSlots);
|
||||
const hasOverflow = uploadFiles.length < imageFiles.length;
|
||||
const currentProjectId = projectId?.trim();
|
||||
if (!currentProjectId) {
|
||||
setAttachmentError('缺少画布项目');
|
||||
return;
|
||||
}
|
||||
|
||||
isPastingAttachmentRef.current = true;
|
||||
setIsPastingAttachment(true);
|
||||
setAttachmentError('图片上传中');
|
||||
void Promise.all(
|
||||
uploadFiles.map((file) =>
|
||||
createPastedAgentImageAttachment(currentProjectId, file),
|
||||
),
|
||||
)
|
||||
.then((pastedAttachments) => {
|
||||
if (appendAttachments(pastedAttachments) && hasOverflow) {
|
||||
setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setAttachmentError('图片粘贴失败,请重试');
|
||||
})
|
||||
.finally(() => {
|
||||
isPastingAttachmentRef.current = false;
|
||||
setIsPastingAttachment(false);
|
||||
});
|
||||
},
|
||||
[appendAttachments, projectId],
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback(
|
||||
(targetAttachment: EditorAgentAttachmentRef) => {
|
||||
const key = attachmentKey(targetAttachment);
|
||||
updateAttachments((currentAttachments) =>
|
||||
currentAttachments.filter(
|
||||
(attachment) => attachmentKey(attachment) !== key,
|
||||
),
|
||||
);
|
||||
},
|
||||
[updateAttachments],
|
||||
);
|
||||
|
||||
const consumeAttachments = useCallback(() => {
|
||||
const currentAttachments = attachmentsRef.current;
|
||||
updateAttachments([]);
|
||||
return currentAttachments;
|
||||
}, [updateAttachments]);
|
||||
|
||||
const restoreAttachmentsIfEmpty = useCallback(
|
||||
(failedAttachments: EditorAgentAttachmentRef[]) => {
|
||||
updateAttachments((currentAttachments) =>
|
||||
currentAttachments.length ? currentAttachments : failedAttachments,
|
||||
);
|
||||
},
|
||||
[updateAttachments],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
attachmentError,
|
||||
isPastingAttachment,
|
||||
attachmentPickerOpen,
|
||||
attachmentPickerTab,
|
||||
draftAttachmentKeys,
|
||||
canvasAttachmentOptions,
|
||||
libraryAttachmentOptions,
|
||||
setAttachmentPickerTab,
|
||||
openAttachmentPicker,
|
||||
closeAttachmentPicker,
|
||||
toggleAttachmentKey,
|
||||
applyAttachmentSelection,
|
||||
referenceContextAsset,
|
||||
handleInputPaste,
|
||||
removeAttachment,
|
||||
consumeAttachments,
|
||||
restoreAttachmentsIfEmpty,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user