
civitai 生成器资源选择器 ResourceSelectModal 重构方案解析拆解一个 InstantSearch 管三件事的架构债【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai生成器Generator的资源选择弹窗ResourceSelectModal是 civitai 生成工作流中最关键的入口之一创作者需要在这里为 checkpoint 叠加 LoRA、embedding、VAE 等资源并完成搜索、筛选、排序、版本资格判定等一系列操作。本文基于仓库中的重构提案文档系统拆解该组件一个 InstantSearch 实例服务三个无关关注点导致的架构债逐条还原提案识别的具体坏味道与证据并结合当前仓库源码验证 Phase 1 快速制胜项的落地情况、Phase 2/3 的进展与设计方向。读完本文你将掌握如何识别搜索引擎被当作分页器/状态容器的典型信号如何用类型化 filter 构建器替代手写字符串拼接以及如何通过分阶段、先低风险后结构性的节奏治理复杂前端状态。组件定位ResourceSelectModal 在 civitai 中扮演什么角色在深入重构方案之前先明确这个组件的边界。从源码结构看它位于src/components/ImageGeneration/GenerationForm/ResourceSelectModal/由三部分组成ResourceSelectModal/index.tsx模态框外壳宽度固定为CATALOG_WIDTH 1276源码注释说明该宽度按四列网格清空所需尺寸计算与ResourceHitList中的MIN_COLUMN_WIDTH呼应移动端切换为全屏内部用ResourceSelectProvider包裹ResourceSelectModalContentResourceSelectModalContent.tsx内容主体按设计稿分为三段式布局——顶部标题栏、中部标签页导航rail 资源目录catalog、底部多选暂存条StagedTrayResourceHitList.tsx结果网格与卡片渲染负责 featured 领奖台podium、版本过滤、隐藏偏好应用等客户端逻辑。模态框通过role与selectSource区分使用场景资源选择role resource时标题为 Add resources副标题明确说明用途——LoRAs, embeddings and VAEs layered on your checkpoint。selectSource则包含generation、training、addResource、modelVersion、auction五种取值见 resource-select.types.ts同一套 UI 需要同时服务生成、训练、手动添加、组件链接、拍卖等多个入口这也是后文一个组件背上三件事问题被放大的重要背景。核心问题一个 InstantSearch 实例服务三个无关关注点重构提案将问题的根因概括为一句话一个 InstantSearch 实例被用来同时服务三个彼此无关的关注点。理解这三件事的划分是读懂全部坏味道的前提。关注点 1全文 / 分面搜索。all和official两个标签页、搜索框、类型与 base-model 分面筛选。这才是 InstantSearch 的本职工作——全文检索 分面聚合 相关性排序。关注点 2策展 / 个性化 ID 列表。recent、liked、featured、recommended、auction这些标签页并非搜索引擎的产物而是预先通过 tRPC 拉取一组模型 ID再把它们以id IN [...]的形式注入回 Meili 查询让 InstantSearch 只充当一个对预计算集合做分页的分页器。关注点 3生成资格 / 版本解析。客户端对模型版本按canGenerate是否可生成、base-model 匹配做过滤叠加隐藏偏好hidden preferences过滤以及对 featured 标签页的领奖台podium重排序。这一层是业务规则本不该由搜索管道承担。这三件事挤在同一条管道里接缝处就渗出了一堆 workaround。提案接着用六个具体坏味道作为证据下面逐一拆解并对照当前源码验证。具体坏味道六条架构债的实证坏味道 1Sort 用索引名魔法字符串表达官方优先official-first的排序在 Meili 侧是一条副本索引replicamodels_v9:isOfficial:desc客户端通过在一个 effect 里修改indexUiState.sortBy来选中它涉及 ResourceSelectModalContent.tsx。同时resourceSort常量里存在两个Relevance键——其中一个从 ResourceSelectFilters.tsx 的排序下拉中隐藏而 label 查找逻辑又必须同时覆盖这两个键。这条链路的脆弱性直接体现为三类已知故障index not initemsofsortBy 警告——sortBy的值与 InstantSearch 配置的索引列表不一致Attribute isOfficial is not sortable硬失败——isOfficial字段在 Meili 侧未被配置为可排序属性排序请求直接报错标签页切换竞态tab-switch race——effect 驱动的sortBy修改与标签页切换并发发生时排序状态可能滞后或互相覆盖。提案对此的定性是条件式排序conditional sorting本身就在和 InstantSearch 一个索引对应一种排序的模型对抗。坏味道 2Meili filter 是手工拼接的字符串useResourceSelectMeiliFilters约 150 行负责把 AND/OR 子句、id IN [...]、手工引号转义、base-model 松弛逻辑拼成一个 filter 字符串。问题在于没有转义保证——值中的引号、反斜杠可能破坏整个 filter 表达式优先级靠手工摆放括号——子句组合的正确性依赖人肉维护括号位置事实上不可测试——一个 150 行的字符串拼接函数几乎无法写单元测试覆盖所有组合。作为对比当前仓库中 Meili 的转义坑有明确注释佐证。src/components/Search/meili-filter.ts顶部的注释写道未加引号的值只有在匹配[A-Za-z0-9_.-]或非 ASCII 字母时才能被解析且搜索客户端会把由此产生的 400 错误吞掉、伪装成空结果集——这正是手写拼接最容易踩中的隐性失败模式。坏味道 3一个 UI 背后是两种数据范式all/official/mine是真实的 Meili 查询recent/liked/featured/recommended/auction则先通过 tRPCuseResourceSelectQueries预取 ID 列表再回填成id IN [...]。于是InstantSearch 对一部分标签页是搜索引擎对另一部分标签页只是笨拙的分页器。从当前代码看这个问题的服务端化痕迹已经很明显src/server/services/resource-select.service.ts中的resolveTabIds函数resource-select.service.ts在服务端按标签页解析 ID 集合liked→ 调用getUserBookmarkedModels取用户书签模型recent再按selectSource细分generation走客户端传入的restrictToIds来自生成历史addResource走getRecentlyManuallyAddedmodelVersion走getRecentlyRecommendedauction走getRecentlyBidtraining则直接查询数据库中的训练模型并解析其 base-model其余标签页返回null不施加 ID 限制且空数组仍会施加限制匹配不到任何结果以保持与旧客户端行为一致。坏味道 4Meili 输出在客户端被覆盖ResourceHitListResourceHitList.tsx拿到 Meili 结果后做了一系列再加工featured 标签页按拍卖position重新排序并拆分出前三名的领奖台podiumEntries过滤position 1 position 3通过filterVersions过滤版本其中skipBaseModelForOwnTabs复刻了 Meili 侧的 base-model 逻辑——源码注释原话是keep them in sync保持它们同步这意味着同一份业务规则在客户端与服务端各有一份拷贝靠注释约定同步应用隐藏偏好useApplyHiddenPreferences。结果是页面展示的顺序与内容已经和 Meili 返回的不一致了。搜索引擎输出的排序被业务层二次篡改而搜索引擎本身对此毫不知情。坏味道 5featured 的分页被彻底放弃为了让客户端的 position 排序能作用于全部结果代码用了hitsPerPage{selectedTab featured ? 1000 : hitsPerPage}——把整个 featured 集合一次性加载进一页。这等于用内存换排序正确性featured 集合有多大这一页就有多大数据量上涨时首屏耗时与网络负担线性增长。当前服务端实现保留了同样的权衡resource-select.service.ts中FEATURED_LIMIT 1000featured 标签页offset固定为 0、take固定为 1000其余标签页才走 Meili 的 offset 游标分页resource-select.service.ts。坏味道 6状态分散 用 key 强拆重挂载标签页状态住在localStorage排序住在 InstantSearch 的 UI state列表 ID 住在 tRPC 缓存分面住在Configure组件里。状态被切成四份彼此没有单一来源。更直接的问题是Configure上挂key{totalFilters}ResourceHitList上挂key{selectedTab}。用key变化强制组件重挂载来重置状态本质上是用销毁重建代替状态管理。当前代码中key{tab}依然存在ResourceSelectModalContent.tsx说明这一条在现网尚未拆除——它与 Phase 2 的待办项直接对应。重构方案分阶段落地而不是一次性重写提案明确的态度是不要一把梭重写。先落地低风险收益重新度量再决定是否走结构性改造。三个阶段的目标、风险与当前进度如下。Phase 1 — 快速制胜低风险、无行为变化已完成Phase 1 的两个改动都不改变任何用户可见行为纯属内部重构且都已标记完成[x]。1a. 类型化 Meili filter 构建器。将useResourceSelectMeiliFilters中的字符串拼接替换为一个可组合的小模块and() / or() / eq() / ne() / inArray() / not()统一处理引号与转义。当前仓库中该模块位于 src/shared/utils/meili-filter.ts提案当时写的是src/components/Search/utils/meili-filter.ts最终落位更靠近共享层完整 API 如下export type FilterValue string | number | boolean; export type FilterClause string; type MaybeClause FilterClause | null | undefined | false; function quote(value: FilterValue): string { if (typeof value string) return ${value.replace(/\\/g, \\\\).replace(//g, \\)}; return ${value}; } export const eq (field: string, value: FilterValue): FilterClause ${field} ${quote(value)}; export const ne (field: string, value: FilterValue): FilterClause ${field} ! ${quote(value)}; export const inArray (field: string, values: ReadonlyArrayFilterValue): FilterClause ${field} IN [${values.map(quote).join(, )}]; export const not (clause: FilterClause): FilterClause NOT ${clause}; function combine(op: AND | OR, clauses: MaybeClause[]): FilterClause | null { const valid clauses.filter((c): c is FilterClause typeof c string c.length 0); if (valid.length 0) return null; if (valid.length 1) return valid[0]; return (${valid.join( ${op} )}); } export const and (...clauses: MaybeClause[]): FilterClause | null combine(AND, clauses); export const or (...clauses: MaybeClause[]): FilterClause | null combine(OR, clauses);这个模块的四个关键设计决策直接回应了坏味道 2字符串双引号包裹并转义反斜杠与双引号数字 / 布尔值原样输出——不同数据类型走不同序列化路径杜绝给数字加引号这类错误and/or自动丢弃 falsy 子句null/undefined/false/——调用方可以内联条件表达式例如and(base, cond eq(x, 1))无需手写三元判断无有效子句时返回null——可继续被上游的and/or丢弃形成自然的上抛单子句时省略外层括号——生成最精简的表达式同时多子句时才用括号包裹并以AND/OR连接。配套测试位于 src/components/Search/tests/meili-filter.test.ts覆盖了单引号转义OBrien→O\\Brien、反斜杠转义a\b→a\\\\b、反斜杠与引号组合、保留字NOT、EXISTS必须加引号才会被当作值解析、表达式标点ab)cd、a,b:c;d以及非 ASCII 与 emoji 值。测试还断言了图片搜索页实际发送的表达式poi ! true OR user.username Rogue O\Light——把真实调用链上的产物固化成了回归测试。需要说明的是quoteMeiliValue单引号版本位于 src/components/Search/meili-filter.ts与共享构建器双引号版本并存服务端 resource-select.service.ts 通过import { and, eq, inArray, ne, not, or } from ~/shared/utils/meili-filter消费双引号版本。1b. 整合排序。把标签页 →indexUiState排序的推导逻辑收敛进一个与排序常量同址的useResourceSortForTab(tab)钩子替代原先散落在index.tsx、resource-select.types.ts、ResourceSelectFilters.tsx、ResourceSelectModalContent.tsx四个文件中的碎片逻辑。从当前代码树看排序推导已进一步收敛到服务端的单一函数meiliSortForresource-select.service.tsfunction meiliSortFor(sort: GetResourceSelectInput[sort]): string[] | undefined { switch (sort) { case popularity: return [metrics.thumbsUpCount:desc]; case newest: return [createdAt:desc]; case relevance: default: return undefined; // 交给 Meili 默认相关性排序 } }客户端侧resourceSort常量relevance/popularity/newest三键保留在 resource-select.types.ts排序下拉的 label 查找逻辑在 ResourceSelectFilters.tsx 中对应。Phase 2 — 分离关注点进行中2a. 把策展列表标签页从 InstantSearch 上拆下来。目标recent/liked/featured/recommended/auction直接基于 tRPC 数据渲染进共享卡片网格InstantSearch 只服务于真正的搜索标签页all/official 查询 / 分面。收益是消除id IN [...]注入、消除hitsPerPage1000的 hack并让 featured 领奖台成为一个诚实的策展列表。从当前代码结构看这个方向已经实质推进客户端已不再直接面向 InstantSearch而是统一走 useResourceSelectInfinite.ts 的trpc.model.getResourceSelect.useInfiniteQuery无限查询参数完整覆盖tab、selectSource、query、sort、limit每页 50 条、resources类型 base-model、filterTypes、filterBaseModels、tagName、canGenerate、excludedVersionIds、restrictToIds。其中recent generation场景会在查询前先拉取文本生成请求历史把其中的模型 ID 提取为restrictToIds并且等到 ID 就绪才启用查询——注释说明这是为了避免先发一次无限制请求、再翻转到受限集合的抖动。此外还配置了keepPreviousData保持翻页流畅、abortOnUnmountskipBatch批量请求无法单独中止因此必须跳过批处理以及SERVICE_UNAVAILABLE不重试的策略——因为该状态码是服务器侧 Meili 超时重试只会把第二个搜索排到第一个后面。2b. 版本资格去重。把filterVersions/ base-model 松弛逻辑收敛为一个共享工具或干脆上移到服务端让 Meili filter 与客户端 filter 无法再次漂移。当前的双端镜像事实清晰可见客户端 resource-select.types.ts 的skipBaseModelForOwnTabs(tab mine || tab official) selectSource modelVersion时跳过 base-model 匹配注释明确写着与服务端 picker 服务的同一谓词镜像服务端 resource-select.service.ts 存在同名同逻辑函数注释同样写着镜像客户端skipBaseModelForOwnTabs谓词。filterVersions本身的判定逻辑ResourceHitList.tsx包含三层canGenerate匹配未指定时放行、base-model 匹配skipBaseModel或资源未指定 base-model 时放行、excludedIds排除featured 标签页因为是跨生态领奖台服务器返回任意 base-model 的赢家不重新应用生态 base-model 过滤。同一份业务规则在两处各存一份、靠注释互相同步正是提案要消除的漂移风险。2c. 移除key{...}重挂载。等到数据源分离、不再需要强制重置后再拆除。当前代码中key{tab}仍在ResourceHitList上ResourceSelectModalContent.tsx说明这一条尚未完成而key{totalFilters}挂在Configure上的写法从当前代码树看已随 InstantSearch 客户端管线被 tRPC 无限查询取代。Phase 3 — 单一服务端契约更大、可选最终形态设想是一个服务端端点resource.pickerSearch({ query, tab, types, baseModels })返回已经排好序、已经做过资格过滤的一页结果搜索标签页走 Meili 服务端策展标签页走 Postgres / 缓存。客户端只负责渲染与分页。这样三个关注点全部折叠进服务端客户端侧可以删除 filter 字符串构建、ID-IN 注入、二次排序、双份排序键、版本去重。从当前代码结构可以推断现网实现已经相当接近这个方向getResourceSelectModelsresource-select.service.ts已把 filter 构建buildFilter、标签页 ID 解析resolveTabIds、排序推导meiliSortFor、featured 整集加载全部收进服务端。其中buildFilterresource-select.service.ts用类型化构建器组装出完整 filter逐段看就是一套可读性极佳的业务规则清单return and( // 可见性拍卖或匿名时排除 Private否则公开或属于自己 selectSource auction || !user?.id ? ne(availability, Availability.Private) : or(ne(availability, Availability.Private), eq(user.id, user.id)), canGenerate ! undefined eq(canGenerate, canGenerate), selectSource auction not(eq(cannotPromote, true)), or(...typeClauses), // 每个资源类型: type versions.baseModel 的 AND/OR 组合 featuredIds.length 0 inArray(id, featuredIds), filterTypes.length 0 inArray(type, filterTypes), filterBaseModels.length 0 inArray(versions.baseModel, filterBaseModels), tagName ? eq(tags.name, tagName) : null, tabIds inArray(id, tabIds), tab mine user ? eq(user.id, user.id) : null, tab official ? eq(user.id, constants.system.officialUserId) : null, excludeIds excludeIds.length 0 ? not(inArray(id, excludeIds)) : null, // 始终排除 celebrity 标签模型 not(eq(tags.name, celebrity)) );这里处处体现内联条件子句 falsy 丢弃的构建器设计canGenerate ! undefined eq(...)直接内联条件不满足时子句自动被丢弃无需逐条包三元判断。服务端还有一处值得单独展开的细节——官方模型钉选official pinresource-select.service.ts 与 #L263-L323getOfficialModelIds从 Postgres 查询isOfficial Published的模型并缓存进 RedisTTL 5 分钟注释解释了为何刻意设置retryCount: 0——缓存冷启动时默认重试会睡 5 秒 ×3 次而这个钉选只作用于选择器初始态一次失败降级为空数组的成本远低于让每次并发打开弹窗都卡住。钉选仅在tab all !query 无任何分面筛选时激活否则会把不符合筛选条件的官方模型强推到前排激活时官方 ID 从 Meili 流中排除避免自然排名的官方模型跨页重复出现再在首页把 Postgres 直出的官方模型前置拼接且只保留与所选类型 base-model 匹配的官方模型——Anima checkpoint 选择器钉选 Anima 官方模型而不是 Krea 2 官方 checkpoint。非目标与风险提案明确划定了重构的边界防止改造失控不改可见标签页、卡片 UI、生成流程——重构的是数据管道与状态管理不是产品形态Phase 2 会引入两条渲染路径搜索网格 vs. 策展网格——这是有意的取舍每条路径都更简单、更诚实于自己的本质而不是用一套伪装统一两种不同语义的 UIPhase 3 是一个真正的项目——只有当 Phase 1–2 落地后疼痛仍不足够缓解时才推进属于按需触发而非必做项。从该提案可复用的重构经验抛开 civitai 的具体业务这份提案在方法论层面提供了三条可迁移的经验第一先识别管道错配再谈重构。最核心的诊断不是代码丑而是InstantSearch 被当成分页器 / 状态容器使用——当一个组件让基础设施承载不属于它的职责时所有 workaround 都会沿着接缝生长。识别出三类数据流共用一条管道这个根因比逐条修坏味道重要得多。第二用类型化 DSL 消灭字符串拼接。and / or / eq / ne / inArray / not这套组合子的价值不仅在于转义正确性更在于子句可以内联条件表达式falsy 自动丢弃、单子句自动省略括号、无结果返回null可继续上抛。配合把真实调用链产物固化成断言如poi ! true OR user.username Rogue O\Light不可测试的字符串拼接变成了可回归验证的组合逻辑。第三分阶段落地、每步保持行为不变。Phase 1 全部是低风险、无行为变化的纯重构先让代码变得可测、可读再决定是否推进 Phase 2 的结构性拆分Phase 3 则在前面两步的收益被实际度量后再评估。重构的每一步都必须是可独立合入、可独立回滚的。进度与验证现状按重构提案文档的进度日志Phase 1filter 构建器 useResourceSortForTab已在提案形成的同时完成原型标注为[x]Phase 2、Phase 3 仍为待办。对照当前仓库代码类型化 filter 构建器已落地于 src/shared/utils/meili-filter.ts并有 完整单测 保障客户端已从直连 InstantSearch 客户端拼 filter演进为单条 tRPC 无限查询 服务端统一构建 filter / 解析 ID / 推导排序useResourceSelectInfinite.ts resource-select.service.tskey{tab}重挂载、featured 1000 条整页加载等 Phase 2 待办仍在现网代码中可见。也就是说文档描绘的治理方向与当前实现高度吻合且服务端契约化的程度已经超过了提案写作时的预期——对想要继续推进 Phase 2/3 的开发者而言现网代码本身就是最好的中途站参考客户端渲染层、服务端业务层与共享 filter DSL 的分层已经清晰成型剩下的工作更多是继续拆除遗留的客户端重排序与key重挂载 hack并向客户端只渲染 分页的最终契约收拢。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考