解读 Civitai Training Studio:基于 SvelteKit Spoke 架构的 LoRA 训练 UI 设计规范与工程实践
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
Training Studio 是 Civitai 仓库中新一代 LoRA 训练界面(apps/training-studio),它以独立 SvelteKit 应用的形式替代应用内旧训练器,并保留了日后可整体抽离为独立服务的能力。本文以 apps/training-studio/CLAUDE.md 为骨架,结合该应用的源码、环境配置与 Dockerfile,完整拆解它的 Spoke 架构、四步训练流程、模型目录机制、无 OAuth 预览方案以及一套适用于所有 SvelteKit 子应用的硬性代码规范,帮助读者理解并复现这套工程实践。
应用定位:一个可抽离的 Spoke
Training Studio 是整个 Civitai 仓库中一个特殊的 SvelteKit 应用。与仓库主应用(Next.js + Mantine + tRPC + Prisma)不同,它属于apps/*下的 SvelteKit 5 体系(rune 语法 + Kysely + shadcn-svelte + Tailwind v4),其定位在 CLAUDE.md 中写得很明确:
The Training Studio is the LoRA training UI — a slicker replacement for the in-app trainer (
src/components/Training/**), built as aseparate appthat can be extracted later.
即它是应用内训练器(主应用src/components/Training/**)的现代化替代品,但以独立应用形态存在,训练数据不来自仓库数据库,而是直接来自 orchestrator(训练编排服务)。这种"轮辐(spoke)"形态与仓库中apps/moderator、apps/auth、apps/creator-studio保持一致,共享同一套会话体系与组件规范。
从 package.json 可以看到它的依赖面非常克制:仅引入@civitai/auth(会话门禁)、@civitai/brand(favicon),外加@civitai/db与@civitai/redis——而这两个数据库相关依赖只用于一个目的:在应用内为用户铸造一个短期有效的 orchestrator token。仓库根 package.json 提供了pnpm dev:training-studio(pnpm --filter @civitai/training-studio-app dev)作为开发入口,发布则通过scripts/release-app.mjs apps/training-studio training-studio-v patch|minor|major完成版本化发布。
Spoke 架构:会话门禁与最小依赖原则
鉴权 Delta:任意登录用户即可使用
训练是普通用户功能,而非后台管理功能,因此 spoke 门禁与 moderator 应用不同。在 apps/training-studio/src/lib/server/auth.ts 中,门禁谓词只断言"已解析出会话":
import { createSpokeGuard } from '@civitai/auth'; // Training is a normal user feature, so the gate allows ANY authenticated Civitai user — the predicate // only asserts a session resolved. No redis client here, so token revocation is NOT checked: a // signature-only gate. OK because the token is short-lived and mutations re-check server-side. export const guard = createSpokeGuard({ require: (user) => !!user });该文件中的注释还揭示了一个重要的权衡:由于本应用没有 redis 客户端做令牌吊销检查,这是一个仅验签的门禁。之所以可接受,是因为 mint 出的 orchestrator token 是短期的,且所有变更类操作都会在服务端重新校验。若未来需要实时吊销能力,文档给出的路径是给该应用补一个@civitai/redis客户端并传入isRevoked。
hooks.server.ts:门禁决策的 SvelteKit 适配层
门禁决策逻辑本身与框架无关(来自@civitai/auth的createSpokeGuard),只有 apps/training-studio/src/hooks.server.ts 是 SvelteKit 特有的。它读取 Cookie 头,交给 guard 判断后分三种情况处理:
login:无有效会话 → 302 重定向到 hub 登录页,登录后返回本应用;forbidden:已登录但不满足require→ 303 重定向到 civitai.com(刻意不用 403,因为重新登录无济于事);ok:已认证用户 → 填充locals.user并继续渲染。
此外,/favicon.svg被列入PUBLIC_PATHS,是唯一无需会话即可解析的公开路径(该文件在构建期预渲染,构建时不存在 Cookie)。在门禁之后,还有一个基于 Flipt 的封闭测试(closed-beta)门:未进入 beta 段的已登录用户会收到一个内联 HTML 的 403 页面("Training Studio isn't open yet"),而不是重定向——因为重新登录同样无法解决。
铸造 orchestrator token:数据库与 Redis 的唯一用途
这是整个应用中@civitai/db与@civitai/redis唯一的使用场景,实现在 apps/training-studio/src/lib/server/orchestrator-token.ts。orchestrator 通过用户 API key 认证调用方(它会将 bearer 解析到 Civitai 的/api/v1/me并缓存),因此该应用需要"get-or-mint"(先取缓存、取不到再铸造)这样一个用户 token,与主应用的getOrchestratorToken逻辑一致。其三级策略为:
- 共享缓存:复用主应用在所有 pod 间共用的
generation:tokenssys-redis hash(键REDIS_SYS_KEYS.GENERATION.TOKENS)。该 hash 通常已被所有 generation/training 调用预热过,命中即可省去一次铸造。注意它由主应用以 Lua HSET 写入,因此这里必须用顶层hGetRAW 读取,不能用.packed; - 自有缓存:本应用自己铸造过的 token(键
REDIS_SYS_KEYS.GENERATION.ORCHESTRATOR_TOKENS),用 msgpackr 打包存储; - 现场铸造:写入一条
type: 'System'、name: 'generation-token'的ApiKey行,TTL 为 3600 秒(TTL_SECONDS = 3600),并顺带清理该用户 30 秒内过期的旧 System key。
源码中有两个值得复用的细节:
- 时区安全:
expiresAt在数据库侧计算(now() + make_interval(secs => ${TTL_SECONDS + 5})),而不是用 JSDate。原因注释得很清楚:ApiKey.expiresAt是timestamp without time zone,node-postgres 会把 JS Date 的本地墙钟时间写进去,在 UTC 时区之外的机器上会得到"已过期"的行,导致/api/v1/me(按expiresAt >= now()过滤)永远找不到该 key,最终 orchestrator 返回 401。 - 只写自己的键:应用只向自有 hash 写入,绝不写共享 hash,以保证隔离——本应用的 bug 不会污染主应用的 token 缓存。
配套的 db.ts 刻意只建单一 Kysely 客户端(singleClient: true,无副本),因为唯一的 DB 用途是写一条ApiKey行加一次清理读;sslNoVerify: true用于兼容 cnpg 连接池的自签名证书。而 redis.ts 采用惰性构造:createSysRedis()会立即建立连接,所以把它放进函数内、首次使用时才创建,缺REDIS_SYS_URL时只在首次调用失败而非应用启动即崩。
四步训练流程:从 Select 到 Live Results
CLAUDE.md 强调:可点击的设计原型已入库,写 UI 之前必须先打开它——即 docs/prototype/training-flow.html 与同目录下的screen-*.html。整个流程共四步,之后进入决策:
- Select(选择模型):选择类型(Character/Style/Concept/Effect)→ 自动推荐基础模型。卡片按媒体(media)分组为生态(ecosystem),图片类型展示图片模型、Effect 展示视频模型。每个生态带版本(最新版默认选中 +
Custom…选项)、起价(from-price)以及标签类型锁定(label-type lock)——数据集要么是 tags 要么是 captions,禁止混用,悬停时会解释原因。多跑(multi-run)是次要的"Add another model"入口。该步已构建于 apps/training-studio/src/routes/SelectStep.svelte; - Data & labels(数据与标签):上传 / 从我的生成(from-my-generations)/ 复用数据集;自动打标免费,且格式由模型推导(tags 还是 captions 取决于模型,绝不询问用户);支持逐图编辑与重新打标;可选触发词(trigger word,依赖模型)。该步标记为"下一步"(next);
- Review & start(复核与开始):steps 作为主字段,以"每张图约被看到 N 次"(each image seen ~N×)呈现并带偏低警告;类型作为预设;高级参数折叠;示例提示词(sample prompts);whatif 估价;Start 按钮;
- Results(实时结果):进度头(step/checkpoint,不显示 loss/LR),epoch 卡片流式进入;随后可 Publish / Generate / Save+Download / Train further / Remix。
落地页(Landing)是My trainings(apps/training-studio/src/routes/MyTrainings.svelte),作为"断线重连"的面:运行记录保留 30 天,发布后模型永久留在 Civitai,但运行本身仍会从列表离开;"New training" 进入上述流程。
源码印证:步骤间的数据所有权与复核逻辑
SelectStep.svelte通过$props()接收onContinue、prices与可恢复的initialselection——从 Data 步返回时流程拥有该状态,因此组件重挂载不会丢失已选模型。它用untrack标记一次性的状态播种(media、loraType、runs、sweepOpen),并用$derived推导types、recommendedCard等。其核心约束包括:
- 标签类型锁:多跑(
multi)时若卡片不支持当前 labelMode 则禁止切换(if (multi && !labelOptions(card).includes(labelMode)) return;); - Custom 版本:
Custom…是第 169 行构造出的版本条目({ key: CUSTOM_VERSION_KEY, label: 'Custom', surcharge: CUSTOM_MODEL_SURCHARGE }),用户粘贴一个 Civitai 模型 AIR(urn:air:…)即可基于自定义模型训练;customIncomplete会在 Custom 跑缺少合法 AIR 时阻止 Continue; - 精选与长尾:图片媒体将
zimage/anima/krea2、视频媒体将minimaxh3/wan/ltx列为 featured,长尾模型藏在 "show more" 之后,避免 20 个模型糊成一面墙。
ReviewStep.svelte则体现了"steps 作为主字段"的设计:它按所选模型的默认值(paramsForVersion)逐跑播种steps/epochs/unetLr/textEncoderLr/networkDim/networkAlpha/lrScheduler/optimizer/resolution/batchSize,多跑 sweep 可混用模型所以逐跑播种而非使用扁平常量;"每张图看到次数"由Math.round(steps / imageCount)计算(seen),低于阈值时给出警告(low(i));示例提示词从数据集本身随机抽取 3 条标签(seedPrompts)生成,这样训练期间生成的测试图能反映模型真正学到的东西,数据集无可用标签时才回退到'a photo'。总价(runTotal)逐跑累加,任一跑无法估价则整体显示 "—",而不是悄悄漏掉一跑。
架构决策(2026-08-27 团队评审定稿,勿再重开)
CLAUDE.md 明确列出了四项已经定论、不应再反复讨论的架构决策:
- 训练数据不落库,orchestrator 是唯一事实源。草稿工作流(draft workflows)有 30 天 TTL、按用户门控,用于暂存进行中的数据集/标签/设置;点击 Start 后草稿变成真正的工作流。
@civitai/db依赖仅用于铸造 orchestrator token(一条ApiKey行),绝不用于训练状态。 - 逐 blob 上传,不打 zip 包。上传时即扫描(与 generation 相同的策略),自动打标基于 signals 完成。源码 apps/training-studio/src/lib/upload.ts 印证了这一路径:预签名 → POST → 返回已扫描 blob,两跳、限并发、无 zip;
url缺失即表示 blob 不可用或被拦截,422(内容策略拒绝)/415(类型不支持)/413(过大)属于不可重试的永久性错误。 - 基于 signals 的实时训练(step/checkpoint,接近实时)。Generate/Publish 基于工作流 ID / AIR操作,而非
ModelVersion。服务端 signals.ts 展示了具体机制:workflowSignalCallbacks(userId)为 orchestrator 注册一个workflow-update回调(type: ['step:*']),训练 step 推进时经 SignalR 推送给该用户,让详情页无需 5 秒轮询即可刷新;getSignalsAccessToken在服务端为每个用户 mint 一个短期的 SignalR 访问 token(避免浏览器 CORS),未配置时优雅降级为纯轮询。 - 仅支持 AI-Toolkit(Kohya 留在应用内训练器)。触发词依赖模型(大模型/视频模型无法训练触发词——需要逐模型与 Atif 确认后再强制)。
模型目录:一份手工同步的 vendored 镜像
apps/training-studio/src/lib/data/trainingModels.ts 是主应用 src/utils/training.ts 中trainingModelInfo的vendored 快照——因为apps/*包无法从主应用的src/导入。文件头注释(镜像日期 2026-08-27)说明了同步规则:训练器列表变更时手工重新镜像;若trainingModelInfo未来迁入packages/civitai-*共享包,则改为直接导入。
该镜像做了两处结构调整:一是把上游扁平的trainingModelInfo条目按家族分组成卡片(一个卡片 = 一个 family,扁平条目变成该卡片的versions),这正是 Select 步的呈现方式(SD 1.5 → Standard/…,SDXL → Standard/Pony/Illustrious,Wan → 2.2/2.1,LTX → 2.5/2.3/2.0);二是当上游把同一家族拆到多个type时(LTX、ZImage、ACE-Step),保留每版本的ecosystem(orchestrator 需要它)并给卡片一个合成 id。
关键类型设计(可在 trainingModels.ts 中查看完整定义):
LabelType = 'tag' | 'caption':标签格式是推导出来的,而非存储在源数据中。booru 标签家族(SD 1.5、SDXL 及其 Pony/Illustrious 变体)用 tags 打标,更新的模型用自然语言 captions;Data 步据此自动选择打标器,从不询问用户;Media = 'image' | 'video' | 'audio';ModelVersionInfo:含key(上游trainingModelInfo键)、air、baseModel、ecosystem(AI-Toolkit 生态 + 可选的判别变体)、engine(当训练引擎不是默认ai-toolkit时,如 Flux.2 走imageResourceTraining路径)、version(如 qwen 生态固定2509,因为默认与latest都指向当前不可解析的2512资源)、isNew;ModelCard:type(基础模型类型或合成家族 id)、media、label(默认标签格式)、bothLabels(可同时训练 tags 和 captions 的模型,Data 步才提供选择)、flag('recommended'/'anime'/'latest'等自由文本徽标,与TYPES[].recommended无关)、released(最新可选版本的公开发布日期)、versions(最新/优先的排在最前,versions[0]即默认选中项)。
覆盖范围上,镜像包含截至镜像日期所有未注释、未禁用的trainingModelInfo条目:上游被注释掉的sd3_medium/large与disabled: true的wan_2_1_i2v_14b_720p被有意省略,Flux.2 Edit 条目上游本身也处于注释状态;AI-Toolkit 专用、上游标记为占位符的生态(boogu、ideogram4、ltx25、minimaxh3)的 AIR 原样复制。
无 OAuth 预览:TRAINING_STUDIO_DEV_LOGIN旁路
CLAUDE.md 给出了一个非常实用的开发技巧:由于这是 *.civitai.com 的 spoke,共享 hub 的.civitai.com会话 Cookie,所以不需要登录 UI、也不需要 OAuth 桥接端点——它只需验证会话并门控"是否已登录"。而在本地预览时,连会话验证都可以跳过:
hooks.server.tshas adev-onlybypass: invite devwithTRAINING_STUDIO_DEV_LOGIN=1(set in.env) it injects a stub user and skips the guard (dead code in the built server).pnpm dev:training-studio→ http://localhost:5173. Screenshot with a headless browser to iterate.
源码实现于 hooks.server.ts:
const DEV_LOGIN = dev && process.env.TRAINING_STUDIO_DEV_LOGIN !== '0'; const DEV_USER = { id: 0, username: 'dev-preview' } as unknown as SessionUser;要点:dev在构建产物中恒为false,所以该旁路在生产服务器中是死代码;它在vite dev下默认开启(!== '0'),想针对本地 hub 练习真实登录则显式设TRAINING_STUDIO_DEV_LOGIN=0。开发模式下+page.server.ts检测到locals.devPreview时会返回SAMPLE_ROWS示例列表(见 apps/training-studio/src/routes/+page.server.ts),让 UI 在无真实数据时也可预览。文档还建议用无头浏览器截图来迭代 UI。
环境变量全景(源自 .env.example)
完整模板见 apps/training-studio/.env.example,按功能分组如下:
| 分组 | 变量 | 说明 |
|---|---|---|
| Auth(spoke) | AUTH_JWT_ISSUER | hub 源(token 签发者),用于校验 JWTiss与构建登录重定向,默认https://auth.civitai.com |
AUTH_JWKS_URI | hub 公钥地址,用于本地 ES256 验签(免去每次请求的签名检查网络跳转) | |
AUTH_INTERNAL_TOKEN | 共享服务密钥,让会话客户端可对 hub 做 internal 认证的 read-through | |
AUTH_HUB_INTERNAL_URL | 可选的服务端覆盖:集群内直连 hub 服务地址,不绕公网边缘 | |
NEXTAUTH_SECRET | 必填:ApiKey 哈希盐。本应用用generateSecretHash铸造 orchestrator token,必须与主应用/数据库使用同一个NEXTAUTH_SECRET,否则 orchestrator 无法校验 key | |
| Dev | TRAINING_STUDIO_DEV_LOGIN | vite dev下默认 1(跳过 OAuth 门禁),设 0 走真实登录;对构建产物无影响 |
| Database | DATABASE_URL | 指向主应用/orchestrator 使用的同一数据库;代码内已强制sslmode=no-verify(cnpg 池自签名证书),URL 无需带该参数 |
| Redis | REDIS_URL/REDIS_SYS_URL | 铸造 token 的跨 pod 缓存(get-or-mint),使每用户约每小时铸造一次而非每请求一次;loadRedisEnv要求两者都配,未配则退化为每请求铸造(fail-open) |
| Orchestrator | ORCHESTRATOR_ENDPOINT | 训练后端地址,注意保留末尾斜杠,与主应用保持一致 |
ORCHESTRATOR_MODE | prod或dev | |
ORCHESTRATOR_ACCESS_TOKEN | 仅开发用:本地库里 mint 的 key 无法被共享/生产 orchestrator 校验(本地会 401),所以设MODE=dev并粘贴你自己的个人 Civitai API key(需 generation/full 权限),即可看到自己的真实工作流 | |
TRAINING_TRACE_MODE | 实时逐 epoch 训练追踪:events(NDJSON:进度+日志)或logs(纯文本);默认none——在 orchestrator 侧 trace 特性上线前发送该字段可能导致训练提交被拒绝 | |
| 主应用交接 | CIVITAI_URL | 主应用源;Publish/Generate 会导航到<CIVITAI_URL>/models/train/...由主应用接管发布向导与生成器 |
| 资源 | PUBLIC_IMAGE_LOCATION | 用户头像 CDN 基址(SessionUser.image是裸 Cloudflare Images key) |
| Buzz | BUZZ_ENDPOINT | 服务端读取用户余额(无 CORS 的服务直调) |
| Signals | SIGNALS_ENDPOINT/PUBLIC_SIGNALS_ENDPOINT | 服务端 mint 用户级 SignalR token / 客户端 hub 地址;两者都留空则禁用推送、回退轮询 |
| Feature flags | FLIPT_URL/FLIPT_FETCHER_SECRET | Flipt 封闭测试门禁;FLIPT_LOCAL_OVERRIDES(如trainingStudio=on)仅开发用,可不动共享 Flipt 状态强制开旗 |
封闭测试门禁(Flipt)
apps/training-studio/src/lib/server/flipt.ts 定义了trainingStudio标志(TRAINING_STUDIO_FLAG)。规则是默认关闭:标志不存在(或 Flipt 故障)时仅 moderator 可用,封闭测试绝不会意外向所有人开放。每次求值都必须携带由buildFliptContext(user)构建的上下文——注释特别警告:段约束匹配的是上下文属性而非实体 id,漏传上下文会匹配不到任何段、返回标志的基础enabled(false),等于默默把 beta 关给所有人。客户端惰性构造并缓存于globalThis(兼容 dev HMR),未配置的部署降级为所有标志关闭而不是启动失败。
构建与运行:Dockerfile 的工程细节
apps/training-studio/Dockerfile 展示了 spoke 应用在 pnpm monorepo 中的构建范式,有几点值得注意:
- 必须以仓库根为构建上下文:
docker build -f apps/training-studio/Dockerfile -t civitai-training-studio .,因为要 COPYpnpm-lock.yaml、pnpm-workspace.yaml、根package.json与patches/(根 package.json 声明了pnpm.patchedDependencies,pnpm 在安装时会对补丁文件做哈希,即使--ignore-scripts也需要它在上下文中); - 过滤安装:
pnpm install --frozen-lockfile --filter @civitai/training-studio-app... --ignore-scripts,--ignore-scripts跳过根部的db:generate(Prisma)——spoke 通过 Kysely(@civitai/db/kysely+ 手写的@civitai/db-schema类型)读库,从不 import Prisma client; - 构建期占位符:
src/lib/server/db.ts在模块加载时即调用required('DATABASE_URL'),SvelteKit 的analyse后处理步骤会 import 服务端模块,因此 build 阶段设置ENV DATABASE_URL=postgres://build:build@localhost:5432/build占位。该阶段不携带到 runtime(独立的 FROM),真实值来自 k8s secret。由于本应用是单客户端,故意不设DATABASE_REPLICA_URL;Redis、Buzz、orchestrator client 全部惰性构造,无需构建期占位; - 运行时:
pnpm --filter @civitai/training-studio-app deploy --prod --legacy产出无工作区符号链接的自包含目录,以非 root 用户sveltekit运行,adapter-node 输出监听PORT=3000,ORIGIN(及代理场景的PROTOCOL_HEADER/HOST_HEADER)用于保证url.origin正确。生产发布由 Tektontag-webhook在training-studio-vX.Y.Ztag 推送时触发。
另一个工程细节在 vite.config.ts:SvelteKit/Vite 把.env载入$env/dynamic/private而不是process.env,但@civitai/*包直接读process.env(如loadAuthEnv),所以该配置用loadEnv(mode, process.cwd(), '')把.env桥接进process.env(只填空缺,真实环境变量仍优先);同时把工作区@civitai/*包(原生 TS 源码)与@civitai/client(裸 re-export 目录、无exportsmap、Node SSR 会报ERR_UNSUPPORTED_DIR_IMPORT)列入ssr.noExternal,让 Vite 打包它们而非交给 Node。
全体 SvelteKit 应用的硬性规范(Non-negotiables)
CLAUDE.md 中有一节"Non-negotiables",与 docs/svelte-app-standard.md 保持一致(后者是仓库所有 SvelteKit 应用——moderator/auth/creator-studio/training-studio——共享的规范,各应用的 CLAUDE.md 只记录自身差异)。这些规范直接决定代码正确性,值得逐条展开:
1. 派生 Promise,绝不在$effect中 fetch 后赋给$state
这是这些应用中最常见的 bug 来源。在$effect中 fetch 并把结果赋给$state会导致:卡死的 spinner、重复运行的循环、或旧响应落到新查询上。正确写法是:
const signals = $derived( browser ? fetch(`/api/user-signals/${userId}`).then((r): Promise<Signals> => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) : null ); {#await signals} <p class="text-sm text-dark-2">Checking…</p> {:then result} … {:catch} <p class="text-sm text-red-300">Could not load security signals.</p> {/await}新的userId会产出新的 promise、模板重新 await,因此不存在过期状态;browser保证 SSR 不发请求。每个{#await}都必须有{:catch},否则拒绝会被静默吞掉、面板永远不填充。写入后要重取时,用计数器(?v=${version})参与派生表达式以重建 promise,而不要对非load来源的数据调用invalidateAll()。$effect只用于同步 Svelte 之外的资源(订阅、命令式 API、prop 变化时重置本地镜像),它不是数据获取钩子也不是计算值;用untrack()处理由 prop 播种的$state初始化器。
2.{#each}的 key 是正确性问题,不是 lint 规则
{#each rows as row (row.id)}——无 key 或重复 key 的循环会复用错误的 DOM 节点,导致某行的操作按钮接到另一行上。自然键不唯一时,用多个列组合出唯一键:
{#each accounts as acct (`${acct.userId}:${acct.ip}:${acct.type}`)}优先在查询中选出真实主键,而不是在模板里拼键。Training Studio 的trainingFlow.ts中nextRunId正是为此设计的:sweep 允许重复跑,除身份外没有唯一的东西,所以用稳定的客户端 id 作为{#each}的 key。
3. 单向传入的$bindableprop 会闩锁(latch)
shadcn 包装组件把checked/indeterminate/value/open声明为$bindable,底层原语在交互时会写回它。作为普通 prop 传入时,这个写回变成子组件局部覆盖,且 Svelte 只有在父表达式产出与上次推送不同的值时才丢弃它——于是任何交互后状态保持不变的控件会一直渲染与你的数据相反的样子(穿越重渲染与重置按钮)。三态复选框是经典案例:点击未选中框到达mixed期间checked始终为 false,复选框在本地闩锁为true,与缓冲区、变更集和服务器全部不一致。父组件拥有状态时,用函数绑定:
<Checkbox bind:checked={() => state === 'on', () => toggle(row)} bind:indeterminate={() => state === 'mixed', () => {}} />setter 可以忽略其参数——而且常常必须忽略:原语会把点击 indeterminate 框解析为true,那将总是授予而非切换。标准文档特别警告:svelte-check看不到这个问题,只读 diff 的评审也看不到——单向版本类型检查通过、读起来也正确,它是 2026-08-14 在apps/moderator/admin页面上靠点击页面才发现的。所以凡是三态或原语拥有状态的控件,完工前必须实际交互一遍。
4. 表单:form actions +use:enhance,自定义回调必须调applyAction
服务端变更走表单 action,用use:enhance渐进增强,而不是fetch+ JSON。自定义 enhance 回调替换默认处理(包括applyAction)——不调用它,每个fail()都会被丢弃,被拒绝的 action 看起来和成功一模一样:
const afterAction = () => async ({ result }: { result: ActionResult }) => { await applyAction(result); if (result.type === 'success') { … } };同一页面多个面板提交到同一路由时会共享一个form对象,所以每个失败都要打上 scope 标签、各面板只渲染自己的;每个 action 失败都必须在页面某处可见。乐观 UI 失败时必须回滚——如果点击在服务器应答前先置灰了某行或标记为已处理,非成功结果要撤销它,否则操作者的记录就错了,而他们跳过的那一项恰恰是失败的。
5. 其余硬性规则
{#key}包裹任何持有本地状态、且主体变化时必须重置的东西(打开的确认框不能跨搜索残留到另一个主体);- Snippets(
{#snippet})替代重复标记,children 优于 slots;onclick而非on:click; - 组件一律用
@civitai/ui(shadcn-svelte)原语,动手写之前先查packages/civitai-ui/src/lib/components/;缺失的加进该包而非应用内。Select而非NativeSelect;裸<button>/<input>只用于真正无样式的小交互;不用 Mantine、不用clsx,用cnfrom@civitai/ui/utils.js; - 样式:Tailwind v4,纯暗色。正文与次要文本用
text-dark-2(#8c8fa3),text-dark-3只用于边框与禁用态(在bg-dark-6上对比度不足),text-dark-0用于主要数值、text-white用于标题;面板形状复用rounded-xl border border-dark-4 bg-dark-6 p-5;不要给 button 加cursor-pointer——Tailwind v4 preflight 去掉了按钮指针光标,@civitai/ui的theme.css已统一加回; - 组件放置:页面级组件是路由目录中
+page.svelte的兄弟;$lib/components/只放被多个路由使用的组件(出现第二个消费者时才迁移,而非提前预留);+page.svelte超过约 150 行或承载多个面板就要拆分; - 服务端:
+page.server.ts的load负责读、formactions负责写,服务逻辑放$lib/server/;Kysely builder 优先,rawsql仅在 builder 力不能及时使用(位掩码索引匹配、PG 函数、jsonb/LATERAL、Prisma schema 未建模的表);外部 HTTP 与未缓存的慢读走/api/*由面板拉取以避免阻塞首屏,廉价读取放load(ClickHouse 汇总读是常例外的例外);每个 action 输入用 zod 校验,变更按 owner + id 双重限定(WHERE id = ? AND userId = ?),0 行受影响视为失败而非成功——对零变更报成功会为根本没发生的事写审计行;action 的门禁路径必须与页面一致(组节点的授权是其子节点的并集,在父节点门禁会悄悄扩大可操作范围); - 注释:只写"防破坏护栏"式注释(不变量、类型转换、顺序要求、未来编辑会踩的坑),不写叙述、来源、移植说明。
验证闭环:typecheck、build 与三评审 Agent
CLAUDE.md 与 svelte-app-standard 对验证环节有非常具体的要求:
- 用
typecheck,绝不用check;build不算检查。两者都会跑svelte-kit sync,与 dev server 的文件监听冲突。同时要读svelte-check的WARNING行:state_referenced_locally是真实 bug,且不会出现在其他地方; - 绝不在
.svelte文件的函数签名中写可选参数(n?: number)。Svelte 5 的 TS 剥离会擦除类型注解但保留?,rollup 因此收到非法 JS,只有build会失败——typecheck 干净、dev 正常出页、所有评审通过。要用默认值(n = 0)或显式联合(e: SubmitEvent | null = null)。类型内部({ reset: (id?: string) => void })的?没问题,整个注解会被整体擦除。这个不对称正是"交付前至少跑一次build"的原因(一次,而非诊断循环)——曾有两次这类问题溜进生产,因为能抓到它们的循环恰恰是我们被禁止运行的; - 测试:每个应用拥有自己的
vitest.config.ts,声明name: 'app:<slug>',根配置按配置文件做 glob——没有该文件的应用会静默不被选中。单应用pnpm --filter @civitai/<app> test,全量pnpm run test:apps:run(CI 的 "App unit tests" job)。app:前缀是承重的:每个应用同时以@civitai/*发布,去掉name会把套件挪进 packages job。这些是node 环境下对纯模块的测试——无 SvelteKit 管线,$lib与$env虚拟模块在各应用配置中别名化,路由逻辑可通过从+page.server.tsimportload/actions并传入其读取的事件切片来测试,组件行为则不可测(没有浏览器测试项目),由评审与打开页面验证。套件绝不能连接DATABASE_URL指向的任何库——mock 应用的 db 模块;确需真实 schema 时用 Kysely 的DummyDriver编译语句并发送不带ANALYZE的EXPLAIN(校验列、连接与类型而不执行),用describe.skipIf(!hasDb)门控让无库的检出仍能跑其余测试,参考示例 apps/moderator/src/test/explain-harness.ts; - 评审:一个段落在"完成"前必须过三个 Agent——
svelte-correctness-review(逻辑、数据形状、auth 范围、失败路径)、svelte-idiom-review(Svelte 5 惯用法 + UI/样式约定)、svelte-abstraction-review(重复、缺失组件、放置),每个 Agent 以应用目录为范围并读取该应用的 CLAUDE.md 了解本地差异。修复非平凡 bug 或抽取/变更共享组件后还要跑svelte-recurrence-sweep——拿一个已知缺陷找出所有存在该形态的地方(三个应用间),因为三个评审都局限于眼前的段,一个页面修掉的 bug 会在兄弟页面存续。这三个评审是拿代码与自身比较,看不到你没写的东西,所以从别处移植(Retool、主应用)时要做第四遍:把构建产物与源比较——那才是唯一能抓到功能缺失的一遍。
提交门禁:两道都必须通过
最后,CLAUDE.md 开头用醒目的 🔴 声明了提交纪律,适用于任何分支的任何提交:
- 对抗性评审(Adversarial review):对该段代码运行评审 Agent(
/svelte-review:correctness + idiom + abstraction)并解决全部发现; - 维护者的个人评审 + 明确 OK:人类阅读 diff 并说出 "commit"。这是独立且必需的步骤——测试通过、构建变绿、或"go on"/"continue"都不算数。
两道都完成之前,变更留在工作树中并提问。这套纪律与 svelte-app-standard 的"段落在未解决发现时不算完成,仅通过 typecheck 也不算——打开页面看看"原则一脉相承:typecheck 和 build 能放过大量渲染为空白页的代码。
小结
Training Studio 展示了在大型 monorepo 中构建"可独立演进、可随时抽离"的 SvelteKit 子应用的一整套工程范式:Spoke 形态的会话门禁与最小化依赖(数据库 + Redis 只服务一个 token 铸造目标)、以 orchestrator 为唯一事实源的四步训练流程、由模型推导标签格式的模型目录镜像、TRAINING_STUDIO_DEV_LOGIN旁路带来的零 OAuth 本地预览,以及贯穿所有 SvelteKit 应用的硬性正确性规范。对于想要理解或复刻这套架构的读者,建议按以下顺序阅读仓库内资料:先读 apps/training-studio/CLAUDE.md 与 docs/svelte-app-standard.md 建立全局认知,再对照 apps/training-studio/.env.example 配置本地环境,打开 apps/training-studio/docs/prototype/training-flow.html 对照设计,最后深入 orchestrator-token.ts、trainingModels.ts 与各步骤组件源码,即可完整还原这套训练的端到端实现。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考