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
- 后端资源投影按 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 样例模型
427 lines
13 KiB
TypeScript
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,
|
|
};
|
|
}
|