Files
Genarrative/src/components/image-editor/EditorAgentConversation/useConversationAttachments.ts
T
k88936 61fe4c48af
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
图片画布接入 3D 资源:新增 model3d 类别、3D 预览与 3D 生成入口
- 后端资源投影按 asset_kind 分叉,新增 project_editor_client_media_src 与 EDITOR_MODEL3D_ASSET_KIND:model3d 只投影预览图,模型本体只留 objectKey,预览缺失才回落历史口径
- tripo3d 存储新增按字节魔数识别模型 content_type 与扩展名映射,不采信 provider 声明与下载响应头
- editor_project_storage 把 model3d 加入 EDITOR_CANVAS_ASSET_KINDS 白名单
- 前端 CanvasAssetKind 与 image-canvas-core 类型新增 model3d:画布卡片渲染预览图或 3D 占位,类型角标新增「3D模型」,覆盖资格只在它与空类别之间成立
- 新增 model3d-preview 子目录(Model3dViewerModal / Model3dViewerModel),Tailwind 内联、不新增 CSS,失败原因分超限 / 格式不可识别 / 无 WebGL2 三类展示
- 选中工具条新增只读 preview-model3d 能力位,仅 objectKey 为空时置灰;打开预览同时选中图层并暂停画布快捷键与舞台交互
- 查看器包源对象 format 改为可选,新增「声明 Content-Type → 字节魔数 → 地址扩展名」三级格式判定并导出支持格式清单
- 导出工作流新增 3d_models/ 目录,模型下载按真实响应头 MIME 定扩展名、对象键扩展名兜底
- 新增 3D 生成入口:底部工具栏浮动子选项与文生 / 图生 3D 面板,落 projectResource + canvasCompletion,复用占位框与任务侧栏
- 3D 定价只读实时定价查询,段缺失即入口不可提交;幂等键由前端铸造,用户重试必须换键
- 新增 ADR 0003 / 0004 与实施计划、里程碑文档;更新 Tripo 技术方案、后端架构、CONTEXT 术语、decision-log 与 pitfalls
- .gitignore 忽略仓库根目录手工下载的 3D 样例模型
2026-09-22 10:10:00 +08:00

427 lines
13 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[]);
/**
* 3D 模型资源不能当参考图。
*
* 它的 `mediaType` 就是 `image`(画布上用预览图渲染),但资源身份是模型本体,
* `objectKey` 指向 `.glb`。参考图管线按 objectKey 取对象、按 `image/*` 校验,
* 放进来只会得到一个打不开的附件;"把 3D 结果的渲染图当参考图"是另一个产品决策。
*/
function isModel3dSource(assetKind: string | null | undefined) {
return assetKind === 'model3d';
}
function isImageLayer(layer: CanvasLayer) {
return (
!isModel3dSource(layer.assetKind) &&
(layer.mediaType ?? 'image') === 'image' &&
Boolean(layer.resourceId?.trim()) &&
layer.src.trim()
);
}
function isImageAsset(asset: EditorAsset) {
return (
!isModel3dSource(asset.assetKind) &&
(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,
};
}