合并 AI 设计图生图客户端交互
This commit is contained in:
@@ -78,7 +78,7 @@ const GENERATION_CONFIRMATION_INTENTS = new Set([
|
||||
'确认开始制作',
|
||||
'确认并开始制作',
|
||||
]);
|
||||
const VIDEO_FIRST_FRAME_PICKER_REPLY = '从作品列表选择图片';
|
||||
const IMAGE_SOURCE_PICKER_REPLY = '从作品列表选择图片';
|
||||
|
||||
function isGenerationConfirmationIntent(message: string): boolean {
|
||||
const normalized = message.trim().replace(/[\s,,。.!!??、]/g, '');
|
||||
@@ -259,7 +259,7 @@ function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FirstFrameAssetOption({
|
||||
function ImageSourceAssetOption({
|
||||
asset,
|
||||
busy,
|
||||
onSelect,
|
||||
@@ -424,10 +424,10 @@ export function ImageCanvas() {
|
||||
const [submittingConversationId, setSubmittingConversationId] = useState<string | null>(null);
|
||||
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [firstFramePickerOpen, setFirstFramePickerOpen] = useState(false);
|
||||
const [firstFrameBusyKey, setFirstFrameBusyKey] = useState<string | null>(null);
|
||||
const [firstFrameError, setFirstFrameError] = useState<string | null>(null);
|
||||
const [uploadedFirstFrameAsset, setUploadedFirstFrameAsset] = useState<DesignAsset | null>(null);
|
||||
const [imageSourcePickerOpen, setImageSourcePickerOpen] = useState(false);
|
||||
const [imageSourceBusyKey, setImageSourceBusyKey] = useState<string | null>(null);
|
||||
const [imageSourceError, setImageSourceError] = useState<string | null>(null);
|
||||
const [uploadedImageSourceAsset, setUploadedImageSourceAsset] = useState<DesignAsset | null>(null);
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const quote = useMemo(
|
||||
@@ -435,20 +435,21 @@ export function ImageCanvas() {
|
||||
[conversation],
|
||||
);
|
||||
const generationAvailable = bootstrap?.capabilities.generation ?? false;
|
||||
const firstFrameAssets = useMemo(
|
||||
const imageSourceAssets = useMemo(
|
||||
() => {
|
||||
const assets = tasks
|
||||
.filter((task) => task.status === 'succeeded')
|
||||
.flatMap((task) => task.resultAssets)
|
||||
.filter((asset) => asset.mediaType === 'image' && asset.mimeType === 'image/png');
|
||||
if (uploadedFirstFrameAsset
|
||||
&& uploadedFirstFrameAsset.workspaceId === workspace?.workspaceId) {
|
||||
assets.unshift(uploadedFirstFrameAsset);
|
||||
if (uploadedImageSourceAsset
|
||||
&& uploadedImageSourceAsset.workspaceId === workspace?.workspaceId) {
|
||||
assets.unshift(uploadedImageSourceAsset);
|
||||
}
|
||||
return [...new Map(assets.map((asset) => [asset.assetId, asset])).values()];
|
||||
},
|
||||
[tasks, uploadedFirstFrameAsset, workspace?.workspaceId],
|
||||
[tasks, uploadedImageSourceAsset, workspace?.workspaceId],
|
||||
);
|
||||
const selectingImageReference = conversation?.brief.medium === 'image';
|
||||
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
|
||||
if (!conversation) return [];
|
||||
const messages: RenderedDesignMessage[] = [...conversation.messages];
|
||||
@@ -499,8 +500,8 @@ export function ImageCanvas() {
|
||||
setPrompt('');
|
||||
setActionError(null);
|
||||
setConfirmingQuoteId(null);
|
||||
setFirstFramePickerOpen(false);
|
||||
setFirstFrameError(null);
|
||||
setImageSourcePickerOpen(false);
|
||||
setImageSourceError(null);
|
||||
}, [taskConversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -569,17 +570,22 @@ export function ImageCanvas() {
|
||||
void handleSend();
|
||||
};
|
||||
|
||||
const handleFirstFrameSelect = async (asset: DesignAsset) => {
|
||||
if (!workspace || !conversation || submitting || firstFrameBusyKey) return;
|
||||
const handleImageSourceSelect = async (asset: DesignAsset) => {
|
||||
if (!workspace || !conversation || submitting || imageSourceBusyKey) return;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
const requestedConversationId = conversation.conversationId;
|
||||
setFirstFrameBusyKey(asset.assetId);
|
||||
setFirstFrameError(null);
|
||||
setImageSourceBusyKey(asset.assetId);
|
||||
setImageSourceError(null);
|
||||
setActionError(null);
|
||||
setSubmittingConversationId(requestedConversationId);
|
||||
setFirstFramePickerOpen(false);
|
||||
setImageSourcePickerOpen(false);
|
||||
try {
|
||||
await sendMessage('使用作品图片作为视频首帧', [asset.assetId]);
|
||||
await sendMessage(
|
||||
selectingImageReference
|
||||
? '使用作品图片作为图生图参考图'
|
||||
: '使用作品图片作为视频首帧',
|
||||
[asset.assetId],
|
||||
);
|
||||
} catch (error) {
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId === requestedWorkspaceId
|
||||
@@ -587,39 +593,44 @@ export function ImageCanvas() {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setFirstFrameBusyKey(null);
|
||||
setImageSourceBusyKey(null);
|
||||
setSubmittingConversationId((current) => (
|
||||
current === requestedConversationId ? null : current
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstFrameUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleImageSourceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!workspace || !conversation || !file || submitting || firstFrameBusyKey) return;
|
||||
if (!workspace || !conversation || !file || submitting || imageSourceBusyKey) return;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
const requestedConversationId = conversation.conversationId;
|
||||
setFirstFrameBusyKey('upload');
|
||||
setFirstFrameError(null);
|
||||
setImageSourceBusyKey('upload');
|
||||
setImageSourceError(null);
|
||||
setSubmittingConversationId(requestedConversationId);
|
||||
try {
|
||||
const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file);
|
||||
setUploadedFirstFrameAsset(asset);
|
||||
await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]);
|
||||
setUploadedImageSourceAsset(asset);
|
||||
await sendMessage(
|
||||
selectingImageReference
|
||||
? '使用上传图片作为图生图参考图'
|
||||
: '使用上传的图片作为视频首帧',
|
||||
[asset.assetId],
|
||||
);
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId === requestedWorkspaceId
|
||||
&& current.activeConversationId === requestedConversationId) {
|
||||
setFirstFramePickerOpen(false);
|
||||
setImageSourcePickerOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId === requestedWorkspaceId
|
||||
&& current.activeConversationId === requestedConversationId) {
|
||||
setFirstFrameError(error instanceof Error ? error.message : String(error));
|
||||
setImageSourceError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setFirstFrameBusyKey(null);
|
||||
setImageSourceBusyKey(null);
|
||||
setSubmittingConversationId((current) => (
|
||||
current === requestedConversationId ? null : current
|
||||
));
|
||||
@@ -837,9 +848,9 @@ export function ImageCanvas() {
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3 text-xs font-semibold"
|
||||
onClick={() => {
|
||||
if (reply === VIDEO_FIRST_FRAME_PICKER_REPLY) {
|
||||
setFirstFrameError(null);
|
||||
setFirstFramePickerOpen(true);
|
||||
if (reply === IMAGE_SOURCE_PICKER_REPLY) {
|
||||
setImageSourceError(null);
|
||||
setImageSourcePickerOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!isGenerationConfirmationIntent(reply)) {
|
||||
@@ -935,19 +946,23 @@ export function ImageCanvas() {
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={firstFramePickerOpen}
|
||||
open={imageSourcePickerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && firstFrameBusyKey) return;
|
||||
setFirstFramePickerOpen(open);
|
||||
if (!open) setFirstFrameError(null);
|
||||
if (!open && imageSourceBusyKey) return;
|
||||
setImageSourcePickerOpen(open);
|
||||
if (!open) setImageSourceError(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[88vh] max-w-4xl grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="flex-row items-start justify-between space-y-0 px-5 py-4 sm:px-6">
|
||||
<div className="min-w-0 text-left">
|
||||
<DialogTitle className="text-balance text-base">选择视频首帧</DialogTitle>
|
||||
<DialogTitle className="text-balance text-base">
|
||||
{selectingImageReference ? '选择图生图参考图' : '选择视频首帧'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-pretty text-xs leading-5">
|
||||
从本项目作品中选择一张已完成图片,或上传本地图片作为视频起始画面。
|
||||
{selectingImageReference
|
||||
? '从本项目作品中选择一张已完成图片,或上传本地图片作为图生图参考。'
|
||||
: '从本项目作品中选择一张已完成图片,或上传本地图片作为视频起始画面。'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
@@ -955,9 +970,9 @@ export function ImageCanvas() {
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="关闭首帧选择"
|
||||
aria-label={selectingImageReference ? '关闭参考图选择' : '关闭首帧选择'}
|
||||
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
|
||||
disabled={Boolean(firstFrameBusyKey)}
|
||||
disabled={Boolean(imageSourceBusyKey)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -973,43 +988,43 @@ export function ImageCanvas() {
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="design-first-frame-upload"
|
||||
aria-busy={firstFrameBusyKey === 'upload'}
|
||||
htmlFor="design-image-source-upload"
|
||||
aria-busy={imageSourceBusyKey === 'upload'}
|
||||
className={cn(
|
||||
'inline-flex h-10 cursor-pointer items-center justify-center rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground shadow-sm transition-transform active:scale-[0.96]',
|
||||
firstFrameBusyKey && 'pointer-events-none opacity-50',
|
||||
imageSourceBusyKey && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
>
|
||||
{firstFrameBusyKey === 'upload'
|
||||
{imageSourceBusyKey === 'upload'
|
||||
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
: <Upload className="mr-1.5 h-4 w-4" />}
|
||||
{firstFrameBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
|
||||
{imageSourceBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
|
||||
<input
|
||||
id="design-first-frame-upload"
|
||||
id="design-image-source-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||||
aria-label="上传本地图片"
|
||||
className="sr-only"
|
||||
disabled={Boolean(firstFrameBusyKey)}
|
||||
onChange={(event) => void handleFirstFrameUpload(event)}
|
||||
disabled={Boolean(imageSourceBusyKey)}
|
||||
onChange={(event) => void handleImageSourceUpload(event)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{firstFrameError ? (
|
||||
{imageSourceError ? (
|
||||
<p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">
|
||||
{firstFrameError}
|
||||
{imageSourceError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{firstFrameAssets.length > 0 ? (
|
||||
{imageSourceAssets.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{firstFrameAssets.map((asset) => (
|
||||
<FirstFrameAssetOption
|
||||
{imageSourceAssets.map((asset) => (
|
||||
<ImageSourceAssetOption
|
||||
key={asset.assetId}
|
||||
asset={asset}
|
||||
busy={Boolean(firstFrameBusyKey)}
|
||||
onSelect={() => void handleFirstFrameSelect(asset)}
|
||||
busy={Boolean(imageSourceBusyKey)}
|
||||
onSelect={() => void handleImageSourceSelect(asset)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user