mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(generation): add form-graph dep + Phase 0 spike
Proves the published package works in this repo's toolchain before any porting: both entry points resolve, zod v4 interop is clean (both this repo's graphs and form-graph import bare `zod`, so the subpath dual-instance hazard in vitest.config.mts does not apply), and the unit project runs it. The spike exercises what the generation port needs: conditional fields, the _ext bag, branchOn discrimination, server parse, and store effects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -245,6 +245,7 @@
|
||||
"exifreader": "^4.39.0",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"file-saver": "^2.0.5",
|
||||
"form-graph": "^0.2.0",
|
||||
"google-auth-library": "^9.15.0",
|
||||
"googleapis": "^144.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
Generated
+24
@@ -406,6 +406,9 @@ importers:
|
||||
file-saver:
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.5
|
||||
form-graph:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0(react@18.3.1)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(zod@4.0.17)
|
||||
google-auth-library:
|
||||
specifier: ^9.15.0
|
||||
version: 9.15.1
|
||||
@@ -7713,6 +7716,21 @@ packages:
|
||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
form-graph@0.2.0:
|
||||
resolution: {integrity: sha512-5SPFzM90QowQh4W8EuhorpK9hhb4mG40MtfnLzMcY0XPQaNbs+8whI3OOJxrGQKpKtIIGTcYWTsmp9bpjSNpJA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
svelte: ^5.0.0
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
formdata-node@4.4.1:
|
||||
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||
engines: {node: '>= 12.20'}
|
||||
@@ -19777,6 +19795,12 @@ snapshots:
|
||||
hasown: 2.0.4
|
||||
mime-types: 2.1.35
|
||||
|
||||
form-graph@0.2.0(react@18.3.1)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(zod@4.0.17):
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
svelte: 5.56.3(@typescript-eslint/types@8.60.1)
|
||||
zod: 4.0.17
|
||||
|
||||
formdata-node@4.4.1:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { branchOn, defineGraph } from 'form-graph';
|
||||
import { enumOf, slider, textOf } from 'form-graph/defs';
|
||||
|
||||
/**
|
||||
* Phase 0 spike: proves the published form-graph package works inside THIS
|
||||
* repo's toolchain before any porting starts — resolution of both entry
|
||||
* points, zod v4 interop (this repo is on zod ^4.0.17; form-graph declares it
|
||||
* as a peer), and that the vitest `unit` project runs it.
|
||||
*
|
||||
* It exercises the exact capabilities the generation port depends on:
|
||||
* conditional fields, the `_ext` bag, discriminated branching, server parse,
|
||||
* and store-level effects.
|
||||
*/
|
||||
|
||||
const WORKFLOW = enumOf({
|
||||
options: [
|
||||
{ value: 'txt2img', label: 'Text to image' },
|
||||
{ value: 'img2img', label: 'Image to image' },
|
||||
],
|
||||
default: 'txt2img',
|
||||
});
|
||||
|
||||
const img2img = defineGraph<{ maxStrength: number }>()
|
||||
.field('sourceImage', textOf({ output: z.string().url('A source image is required') }))
|
||||
.field('strength', ({ _ext }) =>
|
||||
slider({ min: 0, max: _ext.maxStrength, step: 0.05, default: 0.75 })
|
||||
);
|
||||
|
||||
const txt2img = defineGraph<{ maxStrength: number }>()
|
||||
.field('steps', slider({ min: 1, max: 50, default: 25 }))
|
||||
// conditional: absent unless the user opted into hires
|
||||
.field('hires', enumOf({ options: [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }], default: 'off' }))
|
||||
.field('hiresScale', ({ hires }) => (hires === 'on' ? slider({ min: 1.5, max: 4, default: 2 }) : null));
|
||||
|
||||
const spikeGraph = branchOn('workflow', WORKFLOW, { txt2img, img2img });
|
||||
|
||||
describe('form-graph package spike', () => {
|
||||
it('resolves both entry points and builds a store', () => {
|
||||
const store = spikeGraph.createStore({ ext: { maxStrength: 1 } });
|
||||
expect(store.getState()).toEqual({ workflow: 'txt2img', steps: 25, hires: 'off' });
|
||||
});
|
||||
|
||||
it('conditional fields come and go by prior-field value', () => {
|
||||
const store = spikeGraph.createStore({ ext: { maxStrength: 1 } });
|
||||
expect(store.getField('hiresScale')).toBeNull();
|
||||
store.set({ hires: 'on' });
|
||||
expect(store.getField('hiresScale')?.value).toBe(2);
|
||||
});
|
||||
|
||||
it('reads external context through the _ext bag', () => {
|
||||
const store = spikeGraph.createStore({ ext: { maxStrength: 0.6 } });
|
||||
store.set({ workflow: 'img2img' });
|
||||
expect(store.getField('strength')?.meta).toMatchObject({ max: 0.6 });
|
||||
});
|
||||
|
||||
it('parses raw input server-side with zod v4 schemas', () => {
|
||||
const ok = spikeGraph.parse(
|
||||
{ workflow: 'img2img', sourceImage: 'https://example.com/a.png', strength: 0.5 },
|
||||
{ maxStrength: 1 }
|
||||
);
|
||||
expect(ok.success).toBe(true);
|
||||
if (ok.success) expect(ok.data).toMatchObject({ workflow: 'img2img', strength: 0.5 });
|
||||
|
||||
const bad = spikeGraph.parse({ workflow: 'img2img', sourceImage: 'not-a-url' }, { maxStrength: 1 });
|
||||
expect(bad.success).toBe(false);
|
||||
if (!bad.success) expect(Object.keys(bad.errors)).toContain('sourceImage');
|
||||
});
|
||||
|
||||
it('discriminates the state union on the branch key', () => {
|
||||
type State = ReturnType<typeof spikeGraph.resolve>;
|
||||
type Img2Img = Extract<State, { workflow: 'img2img' }>;
|
||||
type Assert<T extends true> = T;
|
||||
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
type _strengthIsNumber = Assert<Equals<Img2Img['strength'], number>>;
|
||||
type _noStepsOnImg2Img = Assert<Equals<'steps' extends keyof Img2Img ? true : false, false>>;
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('runs effects on set(), rewriting the patch before intent', () => {
|
||||
const withRule = defineGraph()
|
||||
.field('steps', slider({ min: 1, max: 50, default: 25 }))
|
||||
.field('quality', enumOf({
|
||||
options: [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'final', label: 'Final' },
|
||||
],
|
||||
default: 'draft',
|
||||
}))
|
||||
// picking `final` implies a step floor — a coupling between two user choices
|
||||
.effect({
|
||||
quality: (quality, { next }) =>
|
||||
quality === 'final' && (next.steps ?? 0) < 20 ? { steps: 20 } : undefined,
|
||||
});
|
||||
|
||||
const store = withRule.createStore();
|
||||
store.set({ steps: 5 });
|
||||
expect(store.getState()).toEqual({ steps: 5, quality: 'draft' });
|
||||
store.set({ quality: 'final' });
|
||||
expect(store.getState()).toEqual({ steps: 20, quality: 'final' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user