civitai 生成图选择器(Generated Image Picker)设计与 BlobData 状态重构指南
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
导读
本文基于 docs/features/generation-image-picker.md 展开,系统讲解 civitai 前端生成面板中的**生成图选择器(Generated Image Picker)**设计:如何把orchestratorImageSelect选择状态从"字符串 ID 列表"重构为直接持有BlobData对象,并在此之上叠加一套可被任意功能激活的通用挑选流程(首个消费者是 v2 生成表单的ImageUploadMultipleInput)。读者将掌握:选择 Store 的两种实现形态(createSelectionStore+ 纯 zustand)及其取舍、picker 状态的 API 设计、消费者迁移要点,以及完整的落地顺序与验证清单。
一、背景:为什么需要一个通用的生成图选择器
在 civitai 的生成工作流中,用户经常需要从自己的生成队列(Queue)/信息流(Feed)中挑选图片,用于多种场景:
- 上传到 img2img / upscale 表单输入;
- 参与挑战(challenges)投稿;
- 作为训练(training)素材;
- 批量发布、下载、删除等已有操作。
在引入选择器之前,用户只能手动拖拽图片到目标位置,交互成本高且无法批量操作。设计目标是一个任何功能都能激活的通用选择器系统——同一个选择 Store,既能承载现有的"批量选择 → 下载/删除/发布"能力,又能扩展出"限选数量 → 确认回填"的 picker 模式,两者共用同一份选中状态。
现状对照:本文写作时,仓库中 src/components/ImageGeneration/utils/generationImage.select.ts 已落地了 "BlobData 值存储" 部分(
createSelectionStore<BlobData>+getKey),而 picker 状态扩展(startPicker/ImagePickerFooter等)仍属于文档规划中的后续步骤,读者可以按本文顺序继续推进。
二、Store 重构:从字符串 ID 到 BlobData + picker 状态
2.1 现状(字符串 ID +createSelectionStore)
旧实现将选中项以字符串 ID 记录:
// Stores: { "wfId:stepName:imgId": true } const selectStore = createSelectStore<string>('generated-image-select');由此带来两个问题:
useSelection()返回的是{ workflowId, stepName, imageId }[]元组数组,缺少图片的 URL、尺寸等实际数据;GeneratedImageActions.getSelectedImages()必须做二次查询,从请求数据里按 ID 反查BlobData,既多一层遍历,也让调用方依赖查询数据的形状。
2.2 新方案(纯 zustand,BlobData 值 + picker 状态)
import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import type { BlobData } from '~/shared/orchestrator/workflow-data'; const makeKey = (image: BlobData) => `${image.workflowId}:${image.stepName}:${image.id}`; interface OrchestratorImageSelectState { selected: Record<string, BlobData>; // Picker-specific state picker: { active: boolean; maxSelectable: number; onConfirm: ((images: BlobData[]) => void) | null; }; } const useStore = create<OrchestratorImageSelectState>()( devtools(() => ({ selected: {}, picker: { active: false, maxSelectable: 0, onConfirm: null }, }), { name: 'generated-image-select' }) );为什么不能用 immer?因为BlobData使用了私有字段(#step、#index),而 immer 基于 Proxy 进行结构共享与写时复制,Proxy 无法正确代理类实例的私有字段,会在运行期抛错。Store 形状本身很简单(selected是一个Record<string, BlobData>,picker 是三个标量字段),纯 zustand 配合浅拷贝即可,不需要 immer。
这一点可以从仓库源码得到印证:src/shared/orchestrator/workflow-data.ts 中BlobData抽象类确实声明了#step: StepData与#index: number两个私有字段,并在构造函数中通过Object.assign(this, data)填充公开字段(url、id、available、nsfwLevel、blockedReason等)。BlobData.from()工厂按data.type分发到ImageBlob/VideoBlob/AudioBlob/Model3DBlob子类。
2.3 导出 API:调用方签名不变,数据更丰富
export const orchestratorImageSelect = { // === Existing API (unchanged call sites) === useSelection: () => BlobData[], // was { workflowId, stepName, imageId }[] useIsSelected: (image: BlobData) => boolean, // was (args: { workflowId, stepName, imageId }) useIsSelecting: () => boolean, toggle: (image: BlobData, value?: boolean) => void, setSelected: (images: BlobData[]) => void, getSelected: () => BlobData[], // === Picker extensions === usePickerActive: () => boolean, usePickerMaxReached: () => boolean, startPicker: (opts: { maxSelectable: number; onConfirm: (images: BlobData[]) => void }) => void, confirmPicker: () => void, cancelPicker: () => void, };设计要点:
- 存量 API 签名形态不变,只是载荷从 ID 元组升级为完整的
BlobData,调用点的改动被压缩到最小; usePickerActive/usePickerMaxReached是只读 Hook,供各组件按 picker 状态渲染 UI;startPicker负责注入maxSelectable与onConfirm回调;confirmPicker/cancelPicker是终结动作。
2.4 与仓库现状的对照
仓库当前实现 generationImage.select.ts 已经采用createSelectionStore<BlobData>({ getKey, name: 'generated-image-select' }),并导出SelectionProvider、useActions、useSelection、useIsSelected、useIsSelecting、useSelectedCount、useRegisterOrder——即文档规划的 "BlobData 值存储" 部分已落地。底层通用选择 Store 位于 src/store/createSelectionStore.ts,它提供了:
toggle(普通点击切换并更新 shift 范围锚点);select(Gmail 风格 shift 连选范围切换,范围注册在registerOrder/useRegisterOrder的分组内,避免跨网格误选);selectMany/setSelected/clear/getSelected;- 细粒度的 selector Hook(每个 Hook 只订阅一个切片,单行切换只重渲染该行)。
三、消费者迁移:GeneratedImage 与 GeneratedImageActions
3.1GeneratedImage.tsx:直接传image
调用点从三字段元组改为直接传BlobData:
// Before: orchestratorImageSelect.useIsSelected({ workflowId: request.id, stepName: step.name, imageId: image.id }) orchestratorImageSelect.toggle({ workflowId: request.id, stepName: step.name, imageId: image.id }) // After: orchestratorImageSelect.useIsSelected(image) orchestratorImageSelect.toggle(image)当 picker 激活时,单张图片的交互行为如下:
- 复选框照常反映选中状态(同一 Store,无需额外状态);
- 点击照常切换选中(复用同一个
toggle()调用); toggle内部尊重picker.maxSelectable——已达上限且当前项未选中时直接 no-op;- 始终显示复选框(不再以
isSelecting为门槛); - 已达上限且当前项未选中时,呈现禁用态外观。
3.2GeneratedImageActions.tsx:删掉二次查询
// Before: const selected = orchestratorImageSelect.useSelection(); // { workflowId, stepName, imageId }[] function getSelectedImages() { const selectedIds = selected.map(x => x.imageId); return data.flatMap(wf => wf.succeededImages.filter(x => selectedIds.includes(x.id))); } // After: const selected = orchestratorImageSelect.useSelection(); // BlobData[] directly // getSelectedImages() is gone — `selected` is already what we needuseSelection()直接返回BlobData[],getSelectedImages()整个删除。仓库中的实际消费者 GeneratedImageActions.tsx 已经按此形态工作:selected直接用于批量删除(按workflowId/stepName/image.id聚合元数据更新)、发布(读取image.url、image.workflowId、image.step)、下载(downloadGeneratedImages(selected)),并过滤image.mediaType !== 'audio'。
picker 激活时,GeneratedImageActions的行为变化:
- 隐藏批量操作(下载/删除/发布/批量工作流菜单);
- 显示"Selecting images..."上下文标签,提示当前处于挑选模式而非批量管理模式。
四、Image picker 完整流程
4.1 激活:从ImageUploadMultipleInput发起
orchestratorImageSelect.startPicker({ maxSelectable: max - currentCount, onConfirm: (images) => { const newValues = images.map(img => ({ url: img.url, width: img.width, height: img.height })); onChange?.([...(value ?? []), ...newValues]); }, }); // startPicker also calls generationGraphPanel.setView('queue')关键细节:
maxSelectable是动态计算的剩余容量:max - currentCount,即表单允许的总数减去已上传数,保证挑选结果不会撑爆上限;onConfirm把BlobData映射为表单值的形状({ url, width, height })并追加到现有值数组尾部;startPicker内部还会调用generationGraphPanel.setView('queue'),自动把生成面板切到 Queue 视图,让用户立刻看到可挑选的图片列表。
4.2 触发按钮:放在 Dropzone 之外
在ImageUploadMultipleInput的默认布局中,把Dropzone包进一个relative容器,按钮作为兄弟节点(而非 Dropzone 的子节点),点击时执行e.stopPropagation():
- 按钮不是 Dropzone 的一部分,点击不会触发文件选择弹窗;
relative定位让按钮可以浮在拖放区之上。
仓库中的 ImageUploadMultipleInput.tsx 支持layout: 'default' | 'url-input'两种布局,并具备max、slots、aspect、enableDrawing、warnOnMissingAiMetadata等 props,生成图选择器按钮按计划通过新增的enableGeneratedImagePickerprop 接入默认布局。
4.3 吸底操作条:ImagePickerFooter
ImagePickerFooter渲染在ScrollableQueue/ScrollableFeed内部(Queue/Feed 之后、ScrollArea 之内),复用FormFooter的shadow-topper sticky bottom-0 z-10吸底模式,展示:
- "{n} of {max} selected"计数;
- Cancel与Confirm两个操作按钮。
吸底设计保证用户在长列表中滚动挑选时,确认/取消动作始终可见可点。
4.4 Tab 切换守卫
picker 模式期间:
- 切换到'generate' tab 被阻止(必须走 Cancel/Confirm 结束挑选);
- Queue / Feed 之间可以自由切换(挑选范围本身覆盖两个视图,滚动位置和筛选互不影响)。
这个守卫防止用户"半路退出"而丢失挑选上下文,同时保留在两类来源间补选图片的灵活性。
4.5 Confirm / Cancel 语义
confirmPicker(): // calls onConfirm(Object.values(selected)), resets state, setView('generate') cancelPicker(): // resets state (clears selection + picker), setView('generate')confirmPicker():把当前selected的对象值(BlobData[])交给onConfirm回调回填表单,然后重置 Store 状态并切回 generate 视图;cancelPicker():清空选中与 picker 状态(不留残余选中),同样切回 generate 视图——保证取消后表单和选择状态都回到干净起点。
五、涉及文件清单与落地顺序
5.1 文件修改清单
| # | 文件 | 变更内容 |
|---|---|---|
| 1 | src/components/ImageGeneration/utils/generationImage.select.ts | 重写:纯 zustand、BlobData 存储、picker 状态 |
| 2 | src/components/ImageGeneration/GeneratedImage.tsx | 简化 toggle/isSelected 调用(直接传 BlobData),增加 picker 感知的复选框/点击 |
| 3 | src/components/ImageGeneration/GeneratedImageActions.tsx | 删除getSelectedImages()二次查询,直接使用 BlobData;picker 期间隐藏 |
| 4 | 新建src/components/ImageGeneration/ImagePickerFooter.tsx | 吸底操作条:计数 + Cancel + Confirm |
| 5 | src/components/ImageGeneration/GenerationTabs.tsx | 在 ScrollableQueue/ScrollableFeed 中挂载ImagePickerFooter,加 Tab 守卫 |
| 6 | src/components/generation_v2/inputs/ImageUploadMultipleInput.tsx | 新增enableGeneratedImagePickerprop + 触发按钮 |
| 7 | src/components/generation_v2/GenerationForm.tsx | 把enableGeneratedImagePicker透传给ImagesInput |
5.2 推荐实施顺序
按依赖关系从底层向上推进,每步都可独立编译验证:
- 重写
generationImage.select.ts—— BlobData Store + picker 状态(地基,决定上层所有 API 形态); - 更新
GeneratedImage.tsx—— 传 BlobData 给 toggle/isSelected,加 picker UI(复选框始终显示、上限禁用态); - 更新
GeneratedImageActions.tsx—— 直接用 BlobData,删掉查询,picker 期间隐藏批量操作; - 创建
ImagePickerFooter.tsx—— 吸底计数与确认/取消; - 更新
GenerationTabs.tsx—— 挂载 footer + Tab 切换守卫; - 更新
ImageUploadMultipleInput.tsx—— 加 prop + 触发按钮; - 更新
GenerationForm.tsx—— 透传 prop,接通端到端链路。
六、验证清单
文档给出的验证步骤覆盖"回归"与"新功能"两条线:
pnpm run typecheck—— 无类型错误;- 回归测试:既有批量选择 → 下载/删除/发布仍然正常(Store 重构不能破坏旧能力);
- picker 主链路:点击 dropzone 上的按钮 → 在 queue 中选择图片 → confirm → 图片出现在表单中;
- 上限强制:已上传 3/7 张时,picker 允许最多再选 4 张(
maxSelectable = max - currentCount生效,超出即 no-op + 禁用态); - 取消语义:Cancel 返回 generate 视图,且表单无任何改动(确认
cancelPicker清空了选中与 picker 状态)。
七、总结:一个 Store,两种模式
这套方案的核心价值在于复用:
- 普通模式:批量选择 → 下载/删除/发布,使用同一份
selected状态与既有toggle/setSelectedAPI; - Picker 模式:在同一 Store 上叠加
picker: { active, maxSelectable, onConfirm },通过startPicker/confirmPicker/cancelPicker三个动作把"挑选 → 回填"变成任何表单输入都能调用的通用能力。
状态层从字符串 ID 升级为BlobData后,所有消费者都直接拿到url、width、height、workflowId、step等完整数据,消除了二次查询,也为未来挑战投稿、训练选图等更多"从生成结果中选择"的场景铺平了道路。实现时务必牢记BlobData的私有字段与 immer Proxy 的兼容性约束,并严格按"先底层 Store、后上层组件、最后接线"的顺序推进,配合文末的回归与新链路验证清单收尾。
延伸阅读
- 选择 Store 的完整实现(Gmail 风格 shift 范围选择、分组注册、selector Hook 优化):src/store/createSelectionStore.ts
BlobData抽象类及子类工厂(私有字段、NSFW 阻断、errored/displayable 语义):src/shared/orchestrator/workflow-data.ts- 当前已迁移的 Store 实例与导出 API:src/components/ImageGeneration/utils/generationImage.select.ts
- 批量操作的现实消费者(下载/删除/发布/批量工作流):src/components/ImageGeneration/GeneratedImageActions.tsx
- 首个 picker 消费者组件的现有 props 与布局:src/components/generation_v2/inputs/ImageUploadMultipleInput.tsx
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考