完善 AI 设计资产预览与视频首帧选择

需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。

实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。

验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
2026-08-06 11:31:47 +08:00
parent 2a3c9850d1
commit 15b17775cb
11 changed files with 1149 additions and 31 deletions

View File

@@ -9,6 +9,9 @@ import {
IMAGE_WORKSPACE_API_PATH,
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
designAssetDownloadPath,
type DesignAsset,
type DesignAssetSaveResult,
type DesignGenerationTask,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
@@ -125,6 +128,7 @@ export function sendImageWorkspaceMessage(
expectedTurnRevision: number,
message: string,
clientTurnId = createImageWorkspaceTurnId(),
attachmentAssetIds: string[] = [],
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
@@ -134,7 +138,7 @@ export function sendImageWorkspaceMessage(
clientTurnId,
expectedTurnRevision,
message: message.trim(),
attachmentAssetIds: [],
attachmentAssetIds,
}),
},
);
@@ -178,3 +182,82 @@ export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promis
const separator = contentPath.includes('?') ? '&' : '?';
return `${getHostApiBase()}${contentPath}${separator}token=${encodeURIComponent(token)}`;
}
const IMAGE_FILE_EXTENSIONS: Record<string, string> = {
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
};
function assetDownloadFileName(asset: DesignAsset): string {
const safeAssetId = asset.assetId
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32) || 'image';
const extension = IMAGE_FILE_EXTENSIONS[asset.mimeType.toLowerCase()] ?? 'png';
return `Makelore-AI-Design-${safeAssetId}.${extension}`;
}
export function saveImageWorkspaceAsset(asset: DesignAsset): Promise<DesignAssetSaveResult> {
return requestData(
designAssetDownloadPath(asset.workspaceId, asset.assetId),
{
method: 'POST',
body: JSON.stringify({ defaultFileName: assetDownloadFileName(asset) }),
},
);
}
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
async function fileToBase64(file: File): Promise<string> {
const bytes = new Uint8Array(await file.arrayBuffer());
let encoded = '';
for (let offset = 0; offset < bytes.length; offset += 48 * 1024) {
const chunk = bytes.subarray(offset, offset + 48 * 1024);
let binary = '';
for (let index = 0; index < chunk.length; index += 0x8000) {
binary += String.fromCharCode(...chunk.subarray(index, index + 0x8000));
}
encoded += btoa(binary);
}
return encoded;
}
export async function uploadImageWorkspaceAsset(
workspaceId: string,
file: File,
): Promise<DesignAsset> {
if (!DESIGN_IMAGE_UPLOAD_MIME_TYPES.has(file.type)) {
throw new ImageWorkspaceApiError(
400,
'design_asset_upload_type_invalid',
'请选择 JPEG、PNG 或 WebP 图片',
);
}
if (file.size <= 0 || file.size > MAX_DESIGN_IMAGE_UPLOAD_BYTES) {
throw new ImageWorkspaceApiError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets`,
{
method: 'POST',
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
dataBase64: await fileToBase64(file),
}),
},
);
}

View File

@@ -3,6 +3,7 @@ import {
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import {
@@ -10,20 +11,35 @@ import {
Check,
Clock3,
Cloud,
Download,
Film,
ImageIcon,
Loader2,
Maximize2,
Plus,
RefreshCw,
Send,
Sparkles,
Upload,
WandSparkles,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
IMAGE_WORKSPACE_CREATE_PROJECT_EVENT,
resolveImageWorkspaceAssetUrl,
saveImageWorkspaceAsset,
uploadImageWorkspaceAsset,
} from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
@@ -60,6 +76,7 @@ const GENERATION_CONFIRMATION_INTENTS = new Set([
'确认开始制作',
'确认并开始制作',
]);
const VIDEO_FIRST_FRAME_PICKER_REPLY = '从作品列表选择图片';
function isGenerationConfirmationIntent(message: string): boolean {
const normalized = message.trim().replace(/[\s,。.!??、]/g, '');
@@ -94,6 +111,8 @@ type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
function AssetPreview({ asset }: { asset: DesignAsset }) {
const [url, setUrl] = useState<string | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -124,12 +143,172 @@ function AssetPreview({ asset }: { asset: DesignAsset }) {
);
}
const download = async () => {
if (downloading) return;
setDownloading(true);
try {
const result = await saveImageWorkspaceAsset(asset);
if (result.status === 'saved') {
toast.success('图片已保存到本地');
}
} catch (error) {
toast.error('图片下载失败', {
description: error instanceof Error ? error.message : String(error),
});
} finally {
setDownloading(false);
}
};
return (
<img
src={url}
alt="AI 设计生成结果"
className="aspect-video w-full bg-surface-subtle object-contain"
/>
<>
<div className="bg-surface-subtle">
<button
type="button"
aria-label="放大查看生成图片"
className="group relative block w-full cursor-zoom-in overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-inset"
onClick={() => setPreviewOpen(true)}
>
<img
src={url}
alt="AI 设计生成结果"
className="aspect-video w-full bg-surface-subtle object-contain outline outline-1 -outline-offset-1 outline-black/10 transition-transform duration-200 group-hover:scale-[1.015] group-focus-visible:scale-[1.015]"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-center gap-1.5 bg-gradient-to-t from-black/55 to-transparent px-3 pb-2.5 pt-8 text-[11px] font-semibold text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
<Maximize2 className="h-3.5 w-3.5" />
</span>
</button>
<div className="grid grid-cols-2 gap-2 border-t border-border/60 bg-background/95 p-2">
<Button
type="button"
size="sm"
variant="ghost"
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
onClick={() => setPreviewOpen(true)}
>
<Maximize2 className="mr-1.5 h-3.5 w-3.5" />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={downloading ? '正在保存生成图片' : '下载生成图片'}
aria-busy={downloading}
className="h-10 rounded-xl text-xs font-semibold transition-transform active:scale-[0.96]"
disabled={downloading}
onClick={() => void download()}
>
{downloading
? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
: <Download className="mr-1.5 h-3.5 w-3.5" />}
{downloading ? '保存中' : '下载图片'}
</Button>
</div>
</div>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-h-[92vh] max-w-6xl gap-0 overflow-hidden p-0">
<DialogHeader className="flex-row items-center justify-between space-y-0 border-b border-border/70 px-4 py-3 sm:px-5">
<div className="min-w-0 text-left">
<DialogTitle className="text-base"></DialogTitle>
<DialogDescription className="mt-0.5 text-xs tabular-nums">
{asset.width} × {asset.height} ·
</DialogDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
aria-busy={downloading}
className="h-10 rounded-xl bg-brand px-3 text-xs font-semibold text-primary-foreground transition-transform active:scale-[0.96]"
disabled={downloading}
onClick={() => void download()}
>
{downloading
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <Download className="mr-1.5 h-4 w-4" />}
{downloading ? '正在保存' : '下载原图'}
</Button>
<DialogClose asChild>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="关闭图片预览"
className="h-10 w-10 rounded-xl transition-transform active:scale-[0.96]"
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</div>
</DialogHeader>
<div className="flex min-h-0 items-center justify-center bg-black/90 p-3 sm:p-5">
<img
src={url}
alt="AI 设计大图预览"
className="max-h-[calc(92vh-76px)] max-w-full rounded-lg object-contain outline outline-1 outline-white/10"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
function FirstFrameAssetOption({
asset,
busy,
onSelect,
}: {
asset: DesignAsset;
busy: boolean;
onSelect(): void;
}) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void resolveImageWorkspaceAssetUrl(asset.contentPath)
.then((nextUrl) => {
if (!cancelled) setUrl(nextUrl);
})
.catch(() => {
if (!cancelled) setUrl(null);
});
return () => {
cancelled = true;
};
}, [asset.contentPath]);
return (
<article className="overflow-hidden rounded-2xl bg-background shadow-soft ring-1 ring-black/10 dark:ring-white/10">
<div className="flex aspect-video items-center justify-center bg-surface-subtle">
{url ? (
<img
src={url}
alt="可选作品图片"
className="h-full w-full object-contain outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
) : <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
</div>
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-xs font-medium tabular-nums text-muted-foreground">
{asset.width} × {asset.height}
</span>
<Button
type="button"
size="sm"
className="h-10 rounded-xl px-3 text-xs font-semibold transition-transform active:scale-[0.96]"
disabled={busy || !url}
onClick={onSelect}
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
</Button>
</div>
</article>
);
}
@@ -237,6 +416,10 @@ export function ImageCanvas() {
const [submitting, setSubmitting] = useState(false);
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 conversationEndRef = useRef<HTMLDivElement | null>(null);
const quote = useMemo(
@@ -244,6 +427,20 @@ export function ImageCanvas() {
[workspace],
);
const generationAvailable = bootstrap?.capabilities.generation ?? false;
const firstFrameAssets = 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);
}
return [...new Map(assets.map((asset) => [asset.assetId, asset])).values()];
},
[tasks, uploadedFirstFrameAsset, workspace?.workspaceId],
);
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
if (!workspace) return [];
const messages: RenderedDesignMessage[] = [...workspace.messages];
@@ -345,6 +542,52 @@ export function ImageCanvas() {
}
};
const handleFirstFrameSelect = async (asset: DesignAsset) => {
if (!workspace || submitting || firstFrameBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
setFirstFrameBusyKey(asset.assetId);
setFirstFrameError(null);
setSubmitting(true);
try {
await sendMessage('使用作品图片作为视频首帧', [asset.assetId]);
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFramePickerOpen(false);
}
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFrameError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setSubmitting(false);
}
};
const handleFirstFrameUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = '';
if (!workspace || !file || submitting || firstFrameBusyKey) return;
const requestedWorkspaceId = workspace.workspaceId;
setFirstFrameBusyKey('upload');
setFirstFrameError(null);
setSubmitting(true);
try {
const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file);
setUploadedFirstFrameAsset(asset);
await sendMessage('使用上传的图片作为视频首帧', [asset.assetId]);
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFramePickerOpen(false);
}
} catch (error) {
if (useImageWorkspaceStore.getState().activeWorkspaceId === requestedWorkspaceId) {
setFirstFrameError(error instanceof Error ? error.message : String(error));
}
} finally {
setFirstFrameBusyKey(null);
setSubmitting(false);
}
};
const openCreateProject = () => {
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
};
@@ -513,9 +756,14 @@ export function ImageCanvas() {
type="button"
variant="outline"
size="sm"
className="h-8 rounded-full px-3 text-xs font-semibold"
onClick={() => {
if (!isGenerationConfirmationIntent(reply)) {
className="h-8 rounded-full px-3 text-xs font-semibold"
onClick={() => {
if (reply === VIDEO_FIRST_FRAME_PICKER_REPLY) {
setFirstFrameError(null);
setFirstFramePickerOpen(true);
return;
}
if (!isGenerationConfirmationIntent(reply)) {
setPrompt(reply);
return;
}
@@ -606,6 +854,98 @@ export function ImageCanvas() {
</div>
</aside>
</div>
<Dialog
open={firstFramePickerOpen}
onOpenChange={(open) => {
if (!open && firstFrameBusyKey) return;
setFirstFramePickerOpen(open);
if (!open) setFirstFrameError(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>
<DialogDescription className="mt-1 text-pretty text-xs leading-5">
</DialogDescription>
</div>
<DialogClose asChild>
<Button
type="button"
size="icon"
variant="ghost"
aria-label="关闭首帧选择"
className="h-10 w-10 shrink-0 rounded-xl transition-transform active:scale-[0.96]"
disabled={Boolean(firstFrameBusyKey)}
>
<X className="h-4 w-4" />
</Button>
</DialogClose>
</DialogHeader>
<div className="min-h-0 overflow-y-auto bg-surface-subtle/55 px-5 py-4 sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold"></h3>
<p className="mt-0.5 text-xs text-muted-foreground">
PNG
</p>
</div>
<label
htmlFor="design-first-frame-upload"
aria-busy={firstFrameBusyKey === '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',
)}
>
{firstFrameBusyKey === 'upload'
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
: <Upload className="mr-1.5 h-4 w-4" />}
{firstFrameBusyKey === 'upload' ? '上传并提交中' : '上传本地图片'}
<input
id="design-first-frame-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)}
/>
</label>
</div>
{firstFrameError ? (
<p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">
{firstFrameError}
</p>
) : null}
{firstFrameAssets.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{firstFrameAssets.map((asset) => (
<FirstFrameAssetOption
key={asset.assetId}
asset={asset}
busy={Boolean(firstFrameBusyKey)}
onSelect={() => void handleFirstFrameSelect(asset)}
/>
))}
</div>
) : (
<div className="mt-4 rounded-2xl bg-background px-5 py-10 text-center shadow-soft ring-1 ring-black/5 dark:ring-white/10">
<ImageIcon className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 text-sm font-semibold"></p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -60,7 +60,7 @@ type ImageWorkspaceState = {
refreshTasks: () => Promise<DesignGenerationTask[]>;
connectTaskStream: () => void;
disconnectTaskStream: () => void;
sendMessage: (message: string) => Promise<DesignWorkspace>;
sendMessage: (message: string, attachmentAssetIds?: string[]) => Promise<DesignWorkspace>;
confirmGeneration: (quoteId: string) => Promise<DesignWorkspace>;
reset: () => void;
};
@@ -635,19 +635,27 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
disconnectTaskStream: stopTaskStream,
sendMessage: async (message) => {
sendMessage: async (message, attachmentAssetIds = []) => {
const workspace = get().workspace;
const userText = message.trim();
const clientTurnId = createImageWorkspaceTurnId();
if (!workspace) throw new Error('请先选择设计项目');
try {
set({ pendingTurn: createPendingTurn(workspace, clientTurnId, userText), error: null });
const updated = await sendImageWorkspaceMessage(
workspace.workspaceId,
workspace.turnRevision,
userText,
clientTurnId,
);
const updated = attachmentAssetIds.length > 0
? await sendImageWorkspaceMessage(
workspace.workspaceId,
workspace.turnRevision,
userText,
clientTurnId,
attachmentAssetIds,
)
: await sendImageWorkspaceMessage(
workspace.workspaceId,
workspace.turnRevision,
userText,
clientTurnId,
);
if (get().activeWorkspaceId === workspace.workspaceId) {
applyWorkspace(updated);
await get().refreshTasks().catch(() => []);