Files
Genarrative/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

411 lines
12 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from 'react';
import {
createEditorAgentAttachmentRef,
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,
contextAssetMediaSrc,
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 referenceId = layer.resourceId || layer.id;
const attachment = createEditorAgentAttachmentRef({
source: 'canvas_resource',
referenceId,
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 = createEditorAgentAttachmentRef({
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 createEditorAgentAttachmentRef({
source: 'canvas_resource',
referenceId: resource.resourceId,
objectKey: resource.objectKey ?? upload.objectKey,
imageSrc: resource.imageSrc,
thumbnailSrc: null,
// pasted file.name does not give more information
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) => {
if (asset.kind === 'attachment') {
const directAttachment = createEditorAgentAttachmentRef({
source: asset.source,
referenceId: asset.referenceId,
objectKey: asset.objectKey,
imageSrc: asset.imageSrc,
thumbnailSrc: asset.thumbnailSrc,
label: asset.label,
width: asset.width,
height: asset.height,
});
return directAttachment.referenceId.trim() &&
directAttachment.imageSrc.trim()
? appendAttachments([directAttachment])
: false;
}
const objectKey = asset.objectKey?.trim();
const mediaSrc = contextAssetMediaSrc(asset).trim();
const objectKeyOption = objectKey
? attachmentOptions.find(
({ attachment }) => attachment.objectKey?.trim() === objectKey,
)
: undefined;
const option =
objectKeyOption ||
attachmentOptions.find(({ attachment }) => {
return (
attachment.imageSrc.trim() === mediaSrc ||
attachment.thumbnailSrc?.trim() === mediaSrc
);
});
const matchedAttachment = option?.attachment;
return matchedAttachment?.referenceId.trim() &&
matchedAttachment.imageSrc.trim()
? appendAttachments([matchedAttachment])
: false;
},
[appendAttachments, attachmentOptions],
);
const handleInputPaste = useCallback(
(event: ClipboardEvent) => {
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;
const refreshedAttachments = currentAttachments.map(
(attachment) =>
attachmentOptionsByKey.get(attachmentKey(attachment))?.attachment ??
attachment,
);
updateAttachments([]);
return refreshedAttachments;
}, [attachmentOptionsByKey, updateAttachments]);
const restoreAttachments = useCallback(
(failedAttachments: EditorAgentAttachmentRef[]) => {
updateAttachments((currentAttachments) => {
// respect current (version) when duplicated with old ones
const restoredAttachments = mergeAttachments(
currentAttachments,
failedAttachments,
);
if (restoredAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) {
setAttachmentError(
`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张,发送失败的附件未恢复`,
);
return currentAttachments;
}
setAttachmentError(null);
return restoredAttachments;
});
},
[updateAttachments],
);
return {
attachments,
attachmentError,
isPastingAttachment,
attachmentPickerOpen,
attachmentPickerTab,
draftAttachmentKeys,
canvasAttachmentOptions,
libraryAttachmentOptions,
setAttachmentPickerTab,
openAttachmentPicker,
closeAttachmentPicker,
toggleAttachmentKey,
applyAttachmentSelection,
referenceContextAsset,
handleInputPaste,
removeAttachment,
consumeAttachments,
restoreAttachments,
};
}