合并 AI 设计图生图客户端交互

This commit is contained in:
2026-08-13 14:20:29 +08:00
3 changed files with 220 additions and 57 deletions

View File

@@ -5,7 +5,7 @@
Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口: Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版本为 `2.0.0`,包含两个已开通产品模块,并保留一个学习模块入口:
- `Makelore CodeAI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时并为服务端可运行版本提供真机扫码预览。 - `Makelore CodeAI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时并为服务端可运行版本提供真机扫码预览。
- `Makelore CanvasAI 绘画`以设计项目Workspace组织 Agent 对话、方向确认、图片/视频生成任务和私有结果 - `Makelore CanvasAI 绘画`以设计项目Workspace组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传
- `Makelore LearningAI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。 - `Makelore LearningAI 学习`:数学与知识宇宙主题的学习入口,当前暂未开通;开屏页和模块切换菜单保留置灰入口,不能进入。
作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Electron Main 对源码快照运行安装包内固定 npm 和项目 `package-lock.json` 锁定的 Vite使用内置浏览器预检同一份输出字节再将源码归档、构建归档与校验清单一并提交。平台独立重算摘要、固化不可变 Release 并进入运营审核;客户端预检不构成可信审核凭据。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”小游戏和小程序会创建完整的平台发布模板项目配置底部提供“一键提交审核”Electron Main 对源码快照运行安装包内固定 npm 和项目 `package-lock.json` 锁定的 Vite使用内置浏览器预检同一份输出字节再将源码归档、构建归档与校验清单一并提交。平台独立重算摘要、固化不可变 Release 并进入运营审核;客户端预检不构成可信审核凭据。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。
@@ -17,7 +17,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版
- 共享开发浏览器AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network支持本地与公网开发地址。 - 共享开发浏览器AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network支持本地与公网开发地址。
- 真机预览AI 编程项目空间可读取当前静态 Release 状态;待审版本使用短时预览地址,已发布版本使用同源 HTTPS `play_url`,供手机直接测试触控、屏幕和移动网络环境。 - 真机预览AI 编程项目空间可读取当前静态 Release 状态;待审版本使用短时预览地址,已发布版本使用同源 HTTPS `play_url`,供手机直接测试触控、屏幕和移动网络环境。
- 后端边界Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - 后端边界Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。
- AI 绘画:每个设计项目固定一个设计 Agent并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation图片/视频生成任务与资产属于 Workspace切换或新建会话不会创建新项目也不会隐藏项目任务。每条 Conversation 复用服务端持久 Agent Gateway Session任务进度通过实时流推送并在统一列表中展示断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - AI 绘画:每个设计项目固定一个设计 Agent并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation图片/视频生成任务与资产属于 Workspace切换或新建会话不会创建新项目也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session任务进度通过实时流推送并在统一列表中展示断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。
- 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。
- 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/` - 品牌资产:生产 SVG、PNG、应用图标、托盘图标与安装器视觉位于 [`resources/brand/`](resources/brand/README.md) 和 `resources/icons/`

View File

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

View File

@@ -711,11 +711,152 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled(); expect(sendImageWorkspaceMessageMock).not.toHaveBeenCalled();
}); });
it('keeps the historical video picker behavior when the brief medium is not decided yet', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: null,
summary: '',
ready: false,
missingDecision: '请选择首帧来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-undecided-source',
role: 'assistant',
kind: 'choice',
text: '请选择视频首帧来源。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择视频首帧' });
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为视频首帧',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择视频首帧' }))
.not.toBeInTheDocument();
});
it('opens the image reference picker and submits a successful work Asset id', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
phase: 'shaping',
brief: {
version: 2,
status: 'draft',
medium: 'image',
summary: '参考海洋公益海报生成新图',
ready: false,
missingDecision: '请选择参考图来源',
},
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
expect(within(picker).getByText(/作为图生图参考/)).toBeInTheDocument();
const selectButton = within(picker).getByRole('button', { name: '选择此图片' });
await waitFor(() => expect(selectButton).toBeEnabled());
fireEvent.click(selectButton);
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用作品图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-one'],
));
expect(screen.queryByRole('dialog', { name: '选择图生图参考图' }))
.not.toBeInTheDocument();
});
it('uploads a local image reference and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(),
messages: [
...conversationFixture().messages,
{
id: 'message-image-source',
role: 'assistant',
kind: 'choice',
text: '请选择图生图参考图。',
quickReplies: ['从作品列表选择图片'],
generationQuote: null,
turnRevision: 2,
createdAt: '2026-08-12T09:00:00Z',
},
],
turnRevision: 2,
} satisfies DesignConversation);
render(<MemoryRouter><ImageCanvas /></MemoryRouter>);
fireEvent.click(await screen.findByRole('button', { name: '从作品列表选择图片' }));
const picker = screen.getByRole('dialog', { name: '选择图生图参考图' });
const file = new File(['image-bytes'], 'reference.png', { type: 'image/png' });
fireEvent.change(within(picker).getByLabelText('上传本地图片'), {
target: { files: [file] },
});
await waitFor(() => expect(uploadImageWorkspaceAssetMock)
.toHaveBeenCalledWith('workspace-cloud', file));
await waitFor(() => expect(sendImageWorkspaceMessageMock).toHaveBeenCalledWith(
'workspace-cloud',
'conversation-cloud',
2,
'使用上传图片作为图生图参考图',
expect.stringMatching(/^turn-/),
['asset-uploaded'],
));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择图生图参考图' }),
).not.toBeInTheDocument());
});
it('submits a successful work Asset id when it is selected as the video first frame', async () => { it('submits a successful work Asset id when it is selected as the video first frame', async () => {
const pendingSelection = deferred<DesignConversation>(); const pendingSelection = deferred<DesignConversation>();
sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise); sendImageWorkspaceMessageMock.mockReturnValueOnce(pendingSelection.promise);
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(), ...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
turnRevision: 2, turnRevision: 2,
messages: [ messages: [
...conversationFixture().messages, ...conversationFixture().messages,
@@ -759,6 +900,10 @@ describe('ImageCanvas Workspace-first design experience', () => {
it('uploads a local first-frame image and submits its Asset id to the Agent', async () => { it('uploads a local first-frame image and submits its Asset id to the Agent', async () => {
fetchImageWorkspaceConversationMock.mockResolvedValueOnce({ fetchImageWorkspaceConversationMock.mockResolvedValueOnce({
...conversationFixture(), ...conversationFixture(),
brief: {
...conversationFixture().brief,
medium: 'video',
},
messages: [ messages: [
...conversationFixture().messages, ...conversationFixture().messages,
{ {
@@ -793,6 +938,9 @@ describe('ImageCanvas Workspace-first design experience', () => {
expect.stringMatching(/^turn-/), expect.stringMatching(/^turn-/),
['asset-uploaded'], ['asset-uploaded'],
)); ));
await waitFor(() => expect(
screen.queryByRole('dialog', { name: '选择视频首帧' }),
).not.toBeInTheDocument());
}); });
it('renders policy-blocked generation tasks with a user-facing design message', async () => { it('renders policy-blocked generation tasks with a user-facing design message', async () => {