civitai 生成图选择器(Generated Image Picker)设计与 BlobData 状态重构指南
2026/9/18 3:15:30 网站建设 项目流程

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)填充公开字段(urlidavailablensfwLevelblockedReason等)。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负责注入maxSelectableonConfirm回调;confirmPicker/cancelPicker是终结动作。

2.4 与仓库现状的对照

仓库当前实现 generationImage.select.ts 已经采用createSelectionStore<BlobData>({ getKey, name: 'generated-image-select' }),并导出SelectionProvideruseActionsuseSelectionuseIsSelecteduseIsSelectinguseSelectedCountuseRegisterOrder——即文档规划的 "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 need

useSelection()直接返回BlobData[]getSelectedImages()整个删除。仓库中的实际消费者 GeneratedImageActions.tsx 已经按此形态工作:selected直接用于批量删除(按workflowId/stepName/image.id聚合元数据更新)、发布(读取image.urlimage.workflowIdimage.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,即表单允许的总数减去已上传数,保证挑选结果不会撑爆上限;
  • onConfirmBlobData映射为表单值的形状{ 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'两种布局,并具备maxslotsaspectenableDrawingwarnOnMissingAiMetadata等 props,生成图选择器按钮按计划通过新增的enableGeneratedImagePickerprop 接入默认布局。

4.3 吸底操作条:ImagePickerFooter

ImagePickerFooter渲染在ScrollableQueue/ScrollableFeed内部(Queue/Feed 之后、ScrollArea 之内),复用FormFootershadow-topper sticky bottom-0 z-10吸底模式,展示:

  • "{n} of {max} selected"计数;
  • CancelConfirm两个操作按钮。

吸底设计保证用户在长列表中滚动挑选时,确认/取消动作始终可见可点。

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 文件修改清单

#文件变更内容
1src/components/ImageGeneration/utils/generationImage.select.ts重写:纯 zustand、BlobData 存储、picker 状态
2src/components/ImageGeneration/GeneratedImage.tsx简化 toggle/isSelected 调用(直接传 BlobData),增加 picker 感知的复选框/点击
3src/components/ImageGeneration/GeneratedImageActions.tsx删除getSelectedImages()二次查询,直接使用 BlobData;picker 期间隐藏
4新建src/components/ImageGeneration/ImagePickerFooter.tsx吸底操作条:计数 + Cancel + Confirm
5src/components/ImageGeneration/GenerationTabs.tsx在 ScrollableQueue/ScrollableFeed 中挂载ImagePickerFooter,加 Tab 守卫
6src/components/generation_v2/inputs/ImageUploadMultipleInput.tsx新增enableGeneratedImagePickerprop + 触发按钮
7src/components/generation_v2/GenerationForm.tsxenableGeneratedImagePicker透传给ImagesInput

5.2 推荐实施顺序

按依赖关系从底层向上推进,每步都可独立编译验证:

  1. 重写generationImage.select.ts—— BlobData Store + picker 状态(地基,决定上层所有 API 形态);
  2. 更新GeneratedImage.tsx—— 传 BlobData 给 toggle/isSelected,加 picker UI(复选框始终显示、上限禁用态);
  3. 更新GeneratedImageActions.tsx—— 直接用 BlobData,删掉查询,picker 期间隐藏批量操作;
  4. 创建ImagePickerFooter.tsx—— 吸底计数与确认/取消;
  5. 更新GenerationTabs.tsx—— 挂载 footer + Tab 切换守卫;
  6. 更新ImageUploadMultipleInput.tsx—— 加 prop + 触发按钮;
  7. 更新GenerationForm.tsx—— 透传 prop,接通端到端链路。

六、验证清单

文档给出的验证步骤覆盖"回归"与"新功能"两条线:

  1. pnpm run typecheck—— 无类型错误;
  2. 回归测试:既有批量选择 → 下载/删除/发布仍然正常(Store 重构不能破坏旧能力);
  3. picker 主链路:点击 dropzone 上的按钮 → 在 queue 中选择图片 → confirm → 图片出现在表单中;
  4. 上限强制:已上传 3/7 张时,picker 允许最多再选 4 张(maxSelectable = max - currentCount生效,超出即 no-op + 禁用态);
  5. 取消语义:Cancel 返回 generate 视图,且表单无任何改动(确认cancelPicker清空了选中与 picker 状态)。

七、总结:一个 Store,两种模式

这套方案的核心价值在于复用

  • 普通模式:批量选择 → 下载/删除/发布,使用同一份selected状态与既有toggle/setSelectedAPI;
  • Picker 模式:在同一 Store 上叠加picker: { active, maxSelectable, onConfirm },通过startPicker/confirmPicker/cancelPicker三个动作把"挑选 → 回填"变成任何表单输入都能调用的通用能力。

状态层从字符串 ID 升级为BlobData后,所有消费者都直接拿到urlwidthheightworkflowIdstep等完整数据,消除了二次查询,也为未来挑战投稿、训练选图等更多"从生成结果中选择"的场景铺平了道路。实现时务必牢记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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询