mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
updated prompt-snippets docs
This commit is contained in:
@@ -13,7 +13,7 @@ A walkthrough doc to make nested-wildcard resolution concrete. Uses a small inve
|
||||
|
||||
## Syntax — one symbol everywhere
|
||||
|
||||
Our system uses **`#category`** as the only reference syntax, both in user-typed prompts and inside category values. Real wildcard model files (e.g., fullFeatureFantasy) use the older Dynamic Prompts convention `__name__` for nested references — we transform `__name__` → `#name` at import time. The stored JSONB values, the resolver, and the user-facing UI all see `#` only.
|
||||
Our system uses **`#category`** as the only reference syntax, both in user-typed prompts and inside category values. Real wildcard model files (e.g., fullFeatureFantasy) use the older Dynamic Prompts convention `__name__` for nested references — we transform `__name__` → `#name` at import time. The stored `text[]` values, the resolver, and the user-facing UI all see `#` only.
|
||||
|
||||
What's preserved literally on import:
|
||||
|
||||
@@ -32,7 +32,7 @@ The semantics are unchanged; only the reference syntax is normalized.
|
||||
|
||||
## The example wildcard pack
|
||||
|
||||
Imagine a System-kind WildcardSet called **"MyFantasyPack v1.0"** with 7 categories. Their `values` JSONB arrays after import (already normalized to `#`):
|
||||
Imagine a System-kind WildcardSet called **"MyFantasyPack v1.0"** with 7 categories. Their `values` arrays after import (already normalized to `#`):
|
||||
|
||||
| Category name | values |
|
||||
|----|----|
|
||||
@@ -208,56 +208,57 @@ The resolver returns this composed prompt for the workflow step. (The awkward "A
|
||||
|
||||
**Workflow step 2** would do the same with `#villain` → `#sorcerer_type` and `#weapon`, producing a different prompt.
|
||||
|
||||
### Step metadata records the path
|
||||
### Where the data lives — workflow vs step
|
||||
|
||||
Each workflow step's metadata captures the resolution chain so we can reconstruct what happened:
|
||||
Snippet metadata lives on the **workflow** (the parent of all steps in the submission), recorded once. Steps themselves are vanilla — each step's `params.prompt` and `params.negativePrompt` already contain the fully substituted text after server-side resolution. The orchestrator processes them identically to no-snippet steps.
|
||||
|
||||
**Workflow metadata** captures the user's selections so the picker can reload state on a re-edit and result cards can show what fed the batch. Suppose for this run the user explicitly picked only `#hero` for `#character` (skipping `#villain`), and left `#setting` at the full-pool default:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"snippetReferences": [
|
||||
{
|
||||
"category": "character",
|
||||
"referencePosition": 0,
|
||||
"resolvedValues": [
|
||||
// workflow.metadata
|
||||
"snippets": {
|
||||
"wildcardSetIds": [201], // MyFantasyPack v1.0 was the only active set
|
||||
"mode": "batch",
|
||||
"batchCount": 2,
|
||||
"targets": {
|
||||
"prompt": [
|
||||
{
|
||||
"wildcardSetId": 21,
|
||||
"categoryId": 401, // 'character'
|
||||
"valueIndex": 0, // picked #hero
|
||||
"value": "#hero",
|
||||
"nestedExpansion": {
|
||||
"category": "hero",
|
||||
"categoryId": 402,
|
||||
"valueIndex": 1, // picked the paladin one
|
||||
"value": "a noble paladin with a #weapon",
|
||||
"nested": [
|
||||
{ "category": "weapon", "categoryId": 405, "valueIndex": 0, "value": "{cursed|bloodied} sword", "alternationPick": "bloodied" }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "setting",
|
||||
"referencePosition": 1,
|
||||
"resolvedValues": [
|
||||
{
|
||||
"wildcardSetId": 21, "categoryId": 406, "valueIndex": 0,
|
||||
"value": "a {misty|dusty} #location",
|
||||
"alternationPick": "misty",
|
||||
"nested": [
|
||||
{ "category": "location", "categoryId": 407, "valueIndex": 1, "value": "ruined temple" }
|
||||
"category": "character",
|
||||
"selections": [
|
||||
{ "categoryId": 401, "values": ["#hero"] } // parent value; nested expansion not stored
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "setting",
|
||||
"selections": [] // empty = full-pool default
|
||||
}
|
||||
]
|
||||
],
|
||||
"negativePrompt": []
|
||||
}
|
||||
],
|
||||
"samplingSeed": 42,
|
||||
"cartesianTotal": 2,
|
||||
"sampledTo": 2
|
||||
},
|
||||
"tags": [..., "wildcards"]
|
||||
}
|
||||
```
|
||||
|
||||
Captures everything needed to re-run this exact step (same seed → same result tree).
|
||||
`categoryId` is the canonical pointer to the source `WildcardSetCategory` row — `wildcardSetId` is reachable through that row's FK, so we don't duplicate it. `values` is the array of picked value strings for that source category; for re-edit and display without a lookup. Nested expansion (`#hero` → paladin → `#weapon` → bloodied sword) is recoverable from the seed; not duplicated here.
|
||||
|
||||
`mode` is a per-submission choice (set by the form's mode toggle) — `"batch"` runs unique combinations, `"random"` runs independent random samples. `batchCount` is the number of workflow steps to fan out into. References are organized by target (here just `prompt`; `negativePrompt` is empty `[]` in this example, future editors would add their own keys). Cartesian totals and sample stats aren't stored — they're computable from `snippets.targets[*]` + the corresponding template strings at display time.
|
||||
|
||||
On the client, the entire `snippets` payload lives on a dedicated node in the existing generation graph used by `GenerationForm`. Each editor node (prompt, negativePrompt, future targets) reads its slice via `snippets.targets[<editorNodeName>]`. The node updates as the user adds/removes references and adjusts mode/count, then serializes into the workflow metadata at submit time.
|
||||
|
||||
**Step metadata (per image)** — vanilla, no snippet content:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"params": {
|
||||
"prompt": "A a noble paladin with a bloodied sword emerges from the shadows of a misty ruined temple",
|
||||
"negativePrompt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The full nested expansion tree we just walked through is *not* stored anywhere. It's reproducible on demand from `(seed, prompt template, snippetSelections)` — the seed deterministically drives both the cap sampling and the per-step picks.
|
||||
|
||||
---
|
||||
|
||||
@@ -376,7 +377,7 @@ function expandValue(
|
||||
|
||||
The seed-driven `pickWeighted` and `expandAlternation` keep determinism intact — same submission seed produces byte-identical expansions.
|
||||
|
||||
> **Parser note:** `#?category` (random-pick mode) is only valid at the top level of the user's prompt, not inside a value. Nested refs are always batch-style `#name`. If a wildcard model's source file used `#?` inside a value, our import would either reject it or treat it as `#` — to confirm during implementation.
|
||||
The mode (`batch` vs `random`) governs only the *top-level* expansion of the user's prompt — that is, how `batchCount` workflow steps are produced from the references. Nested `#name` resolution inside values is always a single random pick per occurrence, regardless of mode.
|
||||
|
||||
---
|
||||
|
||||
@@ -407,12 +408,12 @@ The seed-driven `pickWeighted` and `expandAlternation` keep determinism intact
|
||||
Recommendation: **port to TS ourselves**. The grammar is simple enough (alternation, weights, multi-pick, nested refs) and we want the resolver in our generation pipeline without subprocess hops.
|
||||
|
||||
5. **Nested-ref parsing for the audit dependency graph.** At audit time, we need to extract `#refs` from each category's values to build the dependency graph. Two approaches:
|
||||
- Regex over the JSONB strings at audit time — straightforward, parse-on-demand.
|
||||
- Regex over the array entries at audit time — straightforward, parse-on-demand.
|
||||
- Cache parsed refs as a separate column (e.g., `parsedRefs Json`) populated at import — faster audit, more storage.
|
||||
|
||||
Recommendation: **regex at audit time** for v1. Audit isn't latency-sensitive; categories are small. Add cached-refs column only if we measure a problem.
|
||||
|
||||
6. **Step metadata depth.** The example in Diagram 3 shows full nested expansion captured in step metadata. Useful for reproduction and debugging, but JSONB grows with depth. Cap the recorded depth (e.g., only 2–3 levels of nesting in metadata, summarized after that)?
|
||||
6. **Step metadata depth.** The example in Diagram 3 shows full nested expansion captured in step metadata. Useful for reproduction and debugging, but the metadata blob grows with depth. Cap the recorded depth (e.g., only 2–3 levels of nesting in metadata, summarized after that)?
|
||||
|
||||
Recommendation: **record full depth for now**. Expansion trees are bounded by `MAX_DEPTH = 10`, so worst case is 10 levels — bounded and small. Revisit if real wildcard packs produce huge expansion trees.
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# WildcardSet Provisioning Job
|
||||
|
||||
**Status:** ready for implementation in a dedicated session
|
||||
**Owner:** TBD
|
||||
**Companion docs:**
|
||||
|
||||
- [prompt-snippets.md](./prompt-snippets.md) — feature overview
|
||||
- [prompt-snippets-schema.md](./prompt-snippets-schema.md) — schema spec (authoritative for table definitions)
|
||||
- [prompt-snippets-schema-examples.md](./prompt-snippets-schema-examples.md) — populated table walkthrough
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Pre-create `WildcardSet` + `WildcardSetCategory` rows for every published wildcard-type `ModelVersion`, so that:
|
||||
|
||||
1. **User imports become pure pointer creation** (instantaneous; no extraction or audit on the user-facing path).
|
||||
2. **The wildcard catalog is browsable** — category counts, value counts, and previews are available before any user has imported.
|
||||
3. **Audit runs once per model version** (centrally, off the user path) rather than per-first-importer.
|
||||
4. **Concurrency is trivial** — the `WildcardSet` always exists by the time anyone tries to import; no first-importer race condition.
|
||||
|
||||
This document covers the provisioning job only. The user-facing import flow, the picker UI, the resolver, and other phases are described in the product doc.
|
||||
|
||||
## 2. Scope
|
||||
|
||||
**In scope:**
|
||||
|
||||
- A reusable `importWildcardModelVersion(modelVersionId)` service function that:
|
||||
- Locates the wildcard model's source zip
|
||||
- Extracts text files
|
||||
- Normalizes nested-reference syntax (`__name__` → `#name`)
|
||||
- Creates `WildcardSet` + `WildcardSetCategory` rows
|
||||
- Enqueues the audit job for the new set
|
||||
- A publish-time hook that calls this function when a wildcard-type `ModelVersion` is published.
|
||||
- A periodic reconciliation cron job that catches any `Published` wildcard model versions without a corresponding `WildcardSet`.
|
||||
- A one-time backfill script for the initial deploy (basically: run reconciliation against all existing published wildcard models).
|
||||
- Idempotency, error handling, and observability.
|
||||
|
||||
**Not in scope (separate work):**
|
||||
|
||||
- The audit pipeline itself (`auditPromptEnriched` → `WildcardSetCategory.auditStatus` flips). This job *enqueues* audit but doesn't implement it. The audit-consumer side is described in [prompt-snippets-schema.md](./prompt-snippets-schema.md) §6.3.
|
||||
- The user-import flow / `UserWildcardSet` pointer creation (described in [prompt-snippets-schema.md](./prompt-snippets-schema.md) §6.1, simplified).
|
||||
- Picker UI, resolver, generation form integration.
|
||||
- Schema migrations — the `WildcardSet`, `WildcardSetCategory`, and `UserWildcardSet` tables must already exist (see schema doc §8 for the migration). This job assumes the schema is in place.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Two complementary paths feed the same core import function:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ importWildcardModelVersion() │
|
||||
│ (shared core; idempotent) │
|
||||
└────────────────┬─────────────────┘
|
||||
│
|
||||
┌───────────────────┴────────────────────┐
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌──────────────▼─────────────┐
|
||||
│ Publish-time hook │ │ Reconciliation cron │
|
||||
│ (event-driven, │ │ (every hour or daily; │
|
||||
│ primary path) │ │ safety net + backfill) │
|
||||
└──────────┬──────────┘ └──────────────┬──────────────┘
|
||||
│ │
|
||||
Fires when a Periodically scans for ModelVersions
|
||||
wildcard ModelVersion with status='Published', type='Wildcard',
|
||||
is published and no matching WildcardSet row.
|
||||
```
|
||||
|
||||
Both paths converge on the same `importWildcardModelVersion(modelVersionId)` function. Idempotency is enforced by the `(modelVersionId)` unique constraint on `WildcardSet`.
|
||||
|
||||
## 4. Implementation detail
|
||||
|
||||
### 4.1 Shared core: `importWildcardModelVersion`
|
||||
|
||||
Location: probably `src/server/services/wildcardSetProvisioning.service.ts` (or wherever sibling import services live — investigate during implementation).
|
||||
|
||||
```ts
|
||||
async function importWildcardModelVersion(modelVersionId: number): Promise<{
|
||||
status: 'created' | 'already_exists' | 'failed';
|
||||
wildcardSetId?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// 1. Check if already imported (fast path for reconciliation re-runs)
|
||||
const existing = await prisma.wildcardSet.findUnique({
|
||||
where: { modelVersionId },
|
||||
select: { id: true }
|
||||
});
|
||||
if (existing) return { status: 'already_exists', wildcardSetId: existing.id };
|
||||
|
||||
// 2. Load the model version + verify it's a Wildcard type
|
||||
const modelVersion = await prisma.modelVersion.findUnique({
|
||||
where: { id: modelVersionId },
|
||||
include: { model: { select: { type: true, name: true } }, files: true }
|
||||
});
|
||||
if (!modelVersion || modelVersion.model.type !== 'Wildcard') {
|
||||
return { status: 'failed', error: 'not a wildcard model version' };
|
||||
}
|
||||
|
||||
// 3. Locate the source zip file URL (usually one .zip per ModelVersion)
|
||||
const zipFile = modelVersion.files.find(f => f.name.endsWith('.zip'));
|
||||
if (!zipFile) return { status: 'failed', error: 'no zip file' };
|
||||
|
||||
// 4. Download + extract zip → in-memory list of { filename, lines[] }
|
||||
let files;
|
||||
try {
|
||||
files = await extractWildcardZip(zipFile.url);
|
||||
} catch (err) {
|
||||
return { status: 'failed', error: `extraction failed: ${err.message}` };
|
||||
}
|
||||
|
||||
// 5. Normalize: rewrite __name__ → #name in every line
|
||||
for (const f of files) {
|
||||
f.lines = f.lines.map(normalizeNestedRefs);
|
||||
}
|
||||
|
||||
// 6. Create WildcardSet + categories in a single transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const set = await tx.wildcardSet.create({
|
||||
data: {
|
||||
kind: 'System',
|
||||
modelVersionId,
|
||||
modelName: modelVersion.model.name,
|
||||
versionName: modelVersion.name,
|
||||
sourceFileCount: files.length,
|
||||
totalValueCount: files.reduce((n, f) => n + f.lines.length, 0),
|
||||
auditStatus: 'Pending',
|
||||
}
|
||||
});
|
||||
|
||||
for (const [i, f] of files.entries()) {
|
||||
await tx.wildcardSetCategory.create({
|
||||
data: {
|
||||
wildcardSetId: set.id,
|
||||
name: f.filename.replace(/\.txt$/, ''),
|
||||
values: f.lines,
|
||||
valueCount: f.lines.length,
|
||||
displayOrder: i,
|
||||
auditStatus: 'Pending',
|
||||
nsfwLevel: 0,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return set;
|
||||
});
|
||||
|
||||
// 7. Enqueue audit job for the new set (post-commit)
|
||||
await enqueueAuditJob({ wildcardSetId: result.id });
|
||||
|
||||
return { status: 'created', wildcardSetId: result.id };
|
||||
}
|
||||
|
||||
function normalizeNestedRefs(line: string): string {
|
||||
// Rewrite __name__ → #name. Single-pass regex; no nested escaping needed
|
||||
// since the source uses a flat token format.
|
||||
return line.replace(/__([a-zA-Z][a-zA-Z0-9_]*)__/g, '#$1');
|
||||
}
|
||||
```
|
||||
|
||||
**Concurrency:** if two callers race to import the same `modelVersionId`, one wins via the `@unique` constraint; the loser catches the unique-violation error and re-runs the find-existing path. Wrap the create call in a try/catch:
|
||||
|
||||
```ts
|
||||
try {
|
||||
const set = await tx.wildcardSet.create({ ... });
|
||||
// ... categories
|
||||
} catch (err) {
|
||||
if (err.code === 'P2002' /* Prisma unique violation */) {
|
||||
return { status: 'already_exists', wildcardSetId: (await tx.wildcardSet.findUnique({ where: { modelVersionId } }))!.id };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Publish-time hook
|
||||
|
||||
When a wildcard-type `ModelVersion` transitions to `Published`, call `importWildcardModelVersion(modelVersionId)`. Asynchronous (don't block the publish response). Fire-and-forget into the existing job queue is fine — reconciliation will catch any failures.
|
||||
|
||||
**Where to hook:**
|
||||
|
||||
- The model publish path is somewhere around `src/server/services/model.service.ts` or `src/server/controllers/model.controller.ts`. Investigate during implementation — look for where `ModelVersion.status` is set to `Published`, or where publish-time side effects (notifications, metrics) already fire.
|
||||
- The job queue framework currently in use should be the implementation target. Look for existing patterns like `enqueue*` calls.
|
||||
|
||||
**Suggested implementation:**
|
||||
|
||||
```ts
|
||||
// In the publish flow, after the publish transaction commits:
|
||||
if (modelVersion.model.type === 'Wildcard') {
|
||||
enqueueWildcardSetImport({ modelVersionId: modelVersion.id });
|
||||
}
|
||||
```
|
||||
|
||||
Where `enqueueWildcardSetImport` queues a job that calls `importWildcardModelVersion(modelVersionId)` with the existing retry/backoff machinery.
|
||||
|
||||
### 4.3 Reconciliation cron
|
||||
|
||||
Runs periodically (suggested: every hour). Scans for missed/failed imports.
|
||||
|
||||
```ts
|
||||
async function reconcileWildcardSets() {
|
||||
const unimported = await prisma.modelVersion.findMany({
|
||||
where: {
|
||||
model: { type: 'Wildcard' },
|
||||
status: 'Published',
|
||||
WildcardSet: null, // no matching set
|
||||
},
|
||||
select: { id: true },
|
||||
take: 100, // batch size; tune as needed
|
||||
});
|
||||
|
||||
for (const mv of unimported) {
|
||||
const result = await importWildcardModelVersion(mv.id);
|
||||
if (result.status === 'failed') {
|
||||
logger.warn('wildcard set provisioning failed', { modelVersionId: mv.id, error: result.error });
|
||||
// Optional: increment a metric, alert mods after N failures
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Scheduling:** use the existing cron framework (probably the same one that runs other reconciliation jobs — look for sibling `*Job.ts` files). Hourly is a reasonable default; daily is fine if event-driven publishing is reliable.
|
||||
|
||||
**Backoff for repeated failures:** if a `modelVersionId` fails N times in a row (e.g. the zip is corrupt and extraction will never succeed), stop retrying and flag for manual moderator review. Could be tracked via a separate `WildcardSetImportFailure` table or just a counter on a job-state record; implementer's choice.
|
||||
|
||||
### 4.4 One-time backfill
|
||||
|
||||
For the initial deploy: pre-existing wildcard models need their `WildcardSet`s created. The reconciliation job naturally handles this — it just needs to run repeatedly until all unimported models are processed.
|
||||
|
||||
**Recommended approach:**
|
||||
|
||||
1. Deploy the migration + provisioning code in a "schema-only" state (no UI yet).
|
||||
2. Run a manual one-shot script that calls `reconcileWildcardSets()` in a loop until it finds nothing to process. This may take a while if there are many wildcard models on the platform — log progress.
|
||||
3. Verify counts: `SELECT COUNT(*) FROM "ModelVersion" mv JOIN "Model" m ... WHERE m.type = 'Wildcard' AND mv.status = 'Published'` should equal `SELECT COUNT(*) FROM "WildcardSet" WHERE kind = 'System'`.
|
||||
4. Audit job picks up the new sets and processes them in the background.
|
||||
|
||||
A standalone script at `scripts/backfill-wildcard-sets.ts` (or wherever Civitai keeps one-off scripts) is fine. Keep it idempotent — re-running should be safe.
|
||||
|
||||
## 5. Audit pipeline integration
|
||||
|
||||
This job's responsibility ends at "row created + audit enqueued." The audit pipeline runs separately and updates `WildcardSetCategory.auditStatus` + `nsfwLevel` per-category, then rolls up to `WildcardSet.auditStatus`.
|
||||
|
||||
Until audit completes, the `WildcardSet` exists but its categories are `auditStatus: Pending`. The resolver excludes Pending categories from generation pools (only `Clean` ones contribute), so the set effectively can't be used yet.
|
||||
|
||||
If the audit pipeline isn't yet built when this job ships, the rows will sit at Pending until it does. That's acceptable — the user-import flow can still create `UserWildcardSet` pointers, but the picker will show "this set is still being processed" until the audit lands.
|
||||
|
||||
**Contract from this job to the audit job:**
|
||||
- This job calls `enqueueAuditJob({ wildcardSetId })` after committing.
|
||||
- The audit job is responsible for processing all `Pending` categories belonging to that set, updating per-category fields, and rolling up the set-level `auditStatus`.
|
||||
- Audit failures don't roll back this job — a `WildcardSet` with all-Dirty categories is valid (just unusable).
|
||||
|
||||
## 6. Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Same `modelVersionId` imported twice (race) | Unique constraint enforces single row; second caller returns `status: 'already_exists'`. |
|
||||
| Zip file missing or corrupt | Job returns `status: 'failed'`. Reconciliation retries; after N failures flag for mod review. |
|
||||
| Wildcard model unpublished after import | Don't auto-delete the `WildcardSet`. A separate moderation flow (out of this job's scope) sets `WildcardSet.isInvalidated = true`. The set still exists in the DB; user pointers work but the resolver excludes invalidated sets from pools. |
|
||||
| Wildcard model deleted (hard) | The schema's `onDelete: Restrict` on `WildcardSet.modelVersion` blocks the delete. Resolution: invalidate the wildcard set first, then delete (admin action). |
|
||||
| Republish of same `ModelVersion` (uploaded a new zip for the same version) | Out of this job's scope — model versions are immutable post-publish per Civitai's general convention. If this convention ever changes, this job needs to handle re-extraction and audit. Flag the assumption in code comments. |
|
||||
| Job runs while audit is still processing the previous run's results | Idempotent — `importWildcardModelVersion` returns `'already_exists'` and skips. Audit job runs to completion independently. |
|
||||
| Empty zip / no `.txt` files | Return `status: 'failed'` with a helpful error. Treat as a publishing problem; flag for mod review. Don't create an empty `WildcardSet`. |
|
||||
| `.txt` file with all empty/whitespace lines | Skip silently — `lines` for that file is `[]`, no `WildcardSetCategory` is created for it. The `WildcardSet` exists with the other non-empty categories. |
|
||||
| Source files use unusual character encoding | Default to UTF-8; fail explicitly if decoding errors occur (don't silently mangle text). |
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
**Unit tests:**
|
||||
|
||||
- `normalizeNestedRefs`: a handful of cases (single ref, multiple refs, no refs, nested in alternation `{__a__|__b__}`)
|
||||
- `importWildcardModelVersion`: idempotency (call twice, second returns `already_exists`); failure paths (no zip, corrupt zip, not-a-wildcard-model)
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
- Full end-to-end against a known wildcard model version in a test DB — verify row counts, normalized values in the `text[]` column, audit job enqueued.
|
||||
- Reconciliation cron: seed N unimported wildcard model versions, run reconciliation, assert all are imported.
|
||||
|
||||
**Manual verification before backfill:**
|
||||
|
||||
- Run the import against a single small wildcard model in staging
|
||||
- Verify the `WildcardSet`, `WildcardSetCategory` rows look correct
|
||||
- Spot-check a few `values` arrays for proper normalization (`__name__` rewritten to `#name`)
|
||||
- Verify the audit job is enqueued
|
||||
|
||||
## 8. Implementation checklist
|
||||
|
||||
In order:
|
||||
|
||||
- [ ] **Pre-req:** confirm the schema migration ([prompt-snippets-schema.md](./prompt-snippets-schema.md) §8) has shipped — `WildcardSet`, `WildcardSetCategory`, `UserWildcardSet` tables + enums + CHECK constraint exist.
|
||||
- [ ] **Pre-req:** confirm an audit-job target exists (or stub one that no-ops; the audit pipeline itself can ship after this job).
|
||||
- [ ] Implement `extractWildcardZip(url)` helper — downloads zip from S3/CloudFront, extracts in memory, returns `{ filename, lines: string[] }[]`. Reuse existing zip-extraction utilities if any exist.
|
||||
- [ ] Implement `normalizeNestedRefs(line)` — single regex rewrite.
|
||||
- [ ] Implement `importWildcardModelVersion(modelVersionId)` — the shared core function described in §4.1.
|
||||
- [ ] Wire the publish-time hook (§4.2) into the existing model publish flow.
|
||||
- [ ] Implement and schedule the reconciliation cron (§4.3).
|
||||
- [ ] Write the backfill script (§4.4).
|
||||
- [ ] Tests: unit + integration per §7.
|
||||
- [ ] Run staging backfill and verify.
|
||||
- [ ] Run prod backfill (likely a few minutes to an hour depending on wildcard model count).
|
||||
- [ ] Add observability: count of imports succeeded/failed per run, alert on N consecutive failures.
|
||||
|
||||
## 9. Open questions to resolve in the implementation session
|
||||
|
||||
These are codebase-specific and need investigation:
|
||||
|
||||
1. **Job queue framework.** What does Civitai use for background jobs (Bull? Custom? Tekton tasks)? The publish hook and reconciliation cron should plug into whatever already exists. Look at sibling `*Job.ts` or `src/server/jobs/` for patterns.
|
||||
2. **Cron scheduler.** Where are scheduled jobs registered? How are they triggered (Vercel cron? GitHub Actions? Internal scheduler)?
|
||||
3. **Model publish hook point.** Where in `src/server/services/model.service.ts` or controller does `ModelVersion.status` flip to `Published`? Need to add the wildcard-import enqueue here.
|
||||
4. **Zip extraction utilities.** Does Civitai already have helpers for downloading + extracting zip files from S3/CloudFront? Check `src/utils/` or `src/server/utils/`. If yes, reuse.
|
||||
5. **`enqueueAuditJob` signature.** Need to coordinate with whoever's building the audit pipeline. The job should accept `{ wildcardSetId }` and process all Pending categories belonging to that set.
|
||||
6. **Model version files structure.** What's the exact `ModelVersion.files` shape for wildcard models? Likely a single `.zip` entry but worth confirming. Check existing wildcard-model records on the platform.
|
||||
7. **Failure tracking.** Does the existing job framework provide retry counts and dead-letter queues, or do we need a separate `WildcardSetImportFailure` table?
|
||||
8. **Where to log + observe.** Probably the existing `logger` + Axiom/Datadog. Add metrics for job success/failure counts + duration.
|
||||
|
||||
## 10. Code locations to check
|
||||
|
||||
For grounding the implementation work — these are likely paths based on a typical Next.js/Prisma codebase like Civitai's. Confirm during the session:
|
||||
|
||||
- **Service layer:** `src/server/services/` — likely the new `wildcardSetProvisioning.service.ts` should live here next to `model.service.ts`
|
||||
- **Job runners:** look for `src/server/jobs/` or similar — pattern for the reconciliation cron
|
||||
- **Model controllers / publish flow:** `src/server/controllers/model.controller.ts` or `src/server/services/model.service.ts` — for the publish hook
|
||||
- **Existing zip extraction:** search the codebase for `JSZip`, `unzipper`, or `extract-zip` to find existing patterns
|
||||
- **Backfill scripts:** check `scripts/` or wherever one-off operational scripts live
|
||||
|
||||
## 11. Definition of done
|
||||
|
||||
- `WildcardSet` rows exist for every published wildcard `ModelVersion` on the platform.
|
||||
- New wildcard model publishes automatically create `WildcardSet` + categories within seconds.
|
||||
- Reconciliation cron runs on schedule and is observable (success counts, failure alerts).
|
||||
- Backfill script ran successfully against prod; counts verified.
|
||||
- Tests passing; failure paths validated.
|
||||
- The user-import flow (separate work) can rely on `WildcardSet` rows existing for any published wildcard model.
|
||||
@@ -45,15 +45,15 @@ Three System-kind sets imported by other users earlier. No User-kind sets shown
|
||||
|
||||
`nsfwLevel` values follow the existing Civitai bitwise convention. `1` is a placeholder for "SFW only" — actual bit values are defined elsewhere.
|
||||
|
||||
### `UserWildcardSet` (Bob's existing pointers)
|
||||
### `UserWildcardSet` (Bob's library)
|
||||
|
||||
| id | userId | wildcardSetId | nickname | isActive | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|----|
|
||||
| 88 | 2042 | 3 | null | true | 0 | 2026-02-14 09:12:00 |
|
||||
| 89 | 2042 | 12 | "Medieval env" | false | 1 | 2026-02-20 14:08:22 |
|
||||
| 104 | 2042 | 16 | null | true | 2 | 2026-03-10 19:44:01 |
|
||||
| id | userId | wildcardSetId | nickname | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|
|
||||
| 88 | 2042 | 3 | null | 0 | 2026-02-14 09:12:00 |
|
||||
| 89 | 2042 | 12 | "Medieval env" | 1 | 2026-02-20 14:08:22 |
|
||||
| 104 | 2042 | 16 | null | 2 | 2026-03-10 19:44:01 |
|
||||
|
||||
Alice has no rows yet.
|
||||
Library pointers only — there's no `isActive` column. Whether a set contributes to a given submission is determined per-form-state on the client (localStorage) and recorded per-submission in workflow metadata's `wildcardSetIds`. Alice has no library yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,11 +79,14 @@ await prisma.$transaction(async (tx) => {
|
||||
}
|
||||
});
|
||||
await tx.userWildcardSet.create({
|
||||
data: { userId: 1001, wildcardSetId: userSet.id, isActive: true }
|
||||
data: { userId: 1001, wildcardSetId: userSet.id }
|
||||
});
|
||||
// Note: the form's snippet-selection node also adds userSet.id to its
|
||||
// localStorage wildcardSetIds list, so the new set is immediately active
|
||||
// for this generation context.
|
||||
}
|
||||
|
||||
// 2. Create a new category with the saved value (categories are immutable; this is always a new category)
|
||||
// 2. Create a new category with the saved value (or append to an existing category — User-kind values are mutable)
|
||||
await tx.wildcardSetCategory.create({
|
||||
data: {
|
||||
wildcardSetId: userSet.id,
|
||||
@@ -114,13 +117,15 @@ await prisma.$transaction(async (tx) => {
|
||||
|----|----|----|----|----|----|----|
|
||||
| 700 | 30 | character | `["blonde hair, green tunic, pointed ears, pointed cap, determined expression"]` | 1 | Pending | 0 |
|
||||
|
||||
### `UserWildcardSet` — new row 490 (Alice's auto-pointer at her own set)
|
||||
### `UserWildcardSet` — new row 490 (Alice's library pointer at her own set)
|
||||
|
||||
| id | userId | wildcardSetId | nickname | isActive | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|----|
|
||||
| 490 | 1001 | 30 | null | true | 0 | 2026-04-24 12:00:00 |
|
||||
| id | userId | wildcardSetId | nickname | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|
|
||||
| 490 | 1001 | 30 | null | 0 | 2026-04-24 12:00:00 |
|
||||
|
||||
If Alice later saves more `character` values, the service creates new categories (e.g. `character-2`) rather than mutating row 700 — categories are immutable.
|
||||
The form's localStorage now reads `wildcardSetIds: [490]`. The set is immediately active for this generation context — no DB-level activation flag involved.
|
||||
|
||||
If Alice later saves more `character` values, the service appends to row 700's `values` array (User-kind categories are mutable). Each mutation triggers a per-category re-audit.
|
||||
|
||||
After audit (next stage of background work), category 700 transitions to `Clean` with an `nsfwLevel` set, and `WildcardSet 30` rolls up to `Clean`.
|
||||
|
||||
@@ -138,8 +143,9 @@ await prisma.$transaction(async (tx) => {
|
||||
|
||||
if (existing) {
|
||||
await tx.userWildcardSet.create({
|
||||
data: { userId: 1001, wildcardSetId: existing.id, isActive: true }
|
||||
data: { userId: 1001, wildcardSetId: existing.id }
|
||||
});
|
||||
// Form's localStorage adds existing.id to its wildcardSetIds list.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,7 +169,7 @@ await prisma.$transaction(async (tx) => {
|
||||
data: {
|
||||
wildcardSetId: set.id,
|
||||
name: f.name.replace(/\.txt$/, ''),
|
||||
values: f.lines, // JSONB string[]
|
||||
values: f.lines, // text[]
|
||||
valueCount: f.lines.length,
|
||||
displayOrder: i,
|
||||
auditStatus: 'Pending',
|
||||
@@ -173,8 +179,9 @@ await prisma.$transaction(async (tx) => {
|
||||
}
|
||||
|
||||
await tx.userWildcardSet.create({
|
||||
data: { userId: 1001, wildcardSetId: set.id, isActive: true }
|
||||
data: { userId: 1001, wildcardSetId: set.id }
|
||||
});
|
||||
// Form's localStorage adds set.id to its wildcardSetIds list.
|
||||
});
|
||||
// Post-commit: enqueue audit job for set.id
|
||||
```
|
||||
@@ -187,7 +194,7 @@ await prisma.$transaction(async (tx) => {
|
||||
|
||||
### `WildcardSetCategory` — 59 new rows (representative sample)
|
||||
|
||||
Each row's `values` is a JSONB array of strings (one per non-empty line in the source `.txt`). `auditStatus` is `Pending` until the audit job runs.
|
||||
Each row's `values` is a Postgres `text[]` (one entry per non-empty line in the source `.txt`). `auditStatus` is `Pending` until the audit job runs.
|
||||
|
||||
| id | wildcardSetId | name | values (preview) | valueCount | auditStatus | nsfwLevel |
|
||||
|----|----|----|----|----|----|----|
|
||||
@@ -202,18 +209,18 @@ Each row's `values` is a JSONB array of strings (one per non-empty line in the s
|
||||
|
||||
Observations:
|
||||
|
||||
- Most categories contain a single line with internal Dynamic Prompts syntax (alternation/weights) → 1-element JSONB array. Resolver expands the syntax at gen time.
|
||||
- Most categories contain a single line with internal Dynamic Prompts syntax (alternation/weights) → 1-element `text[]`. Resolver expands the syntax at gen time.
|
||||
- `elemental_types.txt` is the simple-list outlier — 16 distinct values.
|
||||
- Source-file `__character_f__` style refs are normalized to `#character_f` at import. The stored values shown above already reflect this. Resolution at generation time stays within set 17's scope.
|
||||
- `color.txt` is malformed at source (no delimiters); audit won't reject this since it's not a policy violation, but it will produce a bad single value.
|
||||
|
||||
### `UserWildcardSet` — Alice's new pointer at fullFeatureFantasy
|
||||
|
||||
| id | userId | wildcardSetId | nickname | isActive | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|----|
|
||||
| 491 | 1001 | 17 | null | true | 1 | 2026-04-24 12:33:08 |
|
||||
| id | userId | wildcardSetId | nickname | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|
|
||||
| 491 | 1001 | 17 | null | 1 | 2026-04-24 12:33:08 |
|
||||
|
||||
Alice now has two active sets: her own User-kind set (id 30) and the new System-kind set (id 17).
|
||||
Alice's library now has two pointers; the form's localStorage `wildcardSetIds: [490, 491]` reflects that both are active for the current generation context.
|
||||
|
||||
---
|
||||
|
||||
@@ -280,7 +287,7 @@ FROM "UserWildcardSet" uws
|
||||
JOIN "WildcardSet" ws ON uws."wildcardSetId" = ws.id
|
||||
JOIN "WildcardSetCategory" wsc ON wsc."wildcardSetId" = ws.id
|
||||
WHERE uws."userId" = 1001
|
||||
AND uws."isActive" = true
|
||||
AND uws."wildcardSetId" = ANY(ARRAY[490, 491]) -- the wildcardSetIds from submission
|
||||
AND ws."isInvalidated" = false
|
||||
AND wsc.name = 'character'
|
||||
AND wsc."auditStatus" = 'Clean'
|
||||
@@ -308,73 +315,80 @@ Cartesian: `3 × 1 × 1 × 1 × 16 = 48 combinations` → over the 10-cap → se
|
||||
|
||||
### Submission payload (client → server)
|
||||
|
||||
Alice has nothing in her negative prompt for this submission, so `negativePrompt` is an empty array:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"promptDoc": { /* Tiptap doc JSON */ },
|
||||
"promptTemplate": "A #character wearing armor, wielding #weapons_melee, with #expressions expression in #weather_time weather, featuring #elemental_types magic — dramatic composition, 8k",
|
||||
"negativePromptTemplate": "low quality, blurry",
|
||||
"snippets": {
|
||||
"references": [
|
||||
{ "category": "character", "kind": "batch", "selections": [] },
|
||||
{ "category": "weapons_melee", "kind": "batch", "selections": [] },
|
||||
{ "category": "expressions", "kind": "batch", "selections": [] },
|
||||
{ "category": "weather_time", "kind": "batch", "selections": [] },
|
||||
{ "category": "elemental_types", "kind": "batch", "selections": [] }
|
||||
]
|
||||
"wildcardSetIds": [490, 491],
|
||||
"mode": "batch",
|
||||
"batchCount": 10,
|
||||
"targets": {
|
||||
"prompt": [
|
||||
{ "category": "character", "selections": [] },
|
||||
{ "category": "weapons_melee", "selections": [] },
|
||||
{ "category": "expressions", "selections": [] },
|
||||
{ "category": "weather_time", "selections": [] },
|
||||
{ "category": "elemental_types", "selections": [] }
|
||||
],
|
||||
"negativePrompt": []
|
||||
}
|
||||
},
|
||||
"input": { "seed": 847291, "quantity": 4 /* ... other graph params */ }
|
||||
}
|
||||
```
|
||||
|
||||
`selections: []` means "use full pool" — the default.
|
||||
`selections: []` means "use full pool" — the default. On the client, this `snippets` object is the serialized form of a dedicated node in the generation graph; each editor node (prompt, negativePrompt) has a dependency on the snippets node and re-renders its chips by reading `snippets.targets[<ownNodeName>]`.
|
||||
|
||||
### Workflow step metadata (one of the 10 sampled steps)
|
||||
### Workflow metadata (one record for the whole batch)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"snippetReferences": [
|
||||
{
|
||||
"category": "character",
|
||||
"referencePosition": 0,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 30, "categoryId": 700, "valueIndex": 0, "value": "blonde hair, green tunic, pointed ears..." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "weapons_melee",
|
||||
"referencePosition": 1,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 17, "categoryId": 654, "valueIndex": 0, "value": "{3.0::sword|3.0::dagger|..." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "expressions",
|
||||
"referencePosition": 2,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 17, "categoryId": 635, "valueIndex": 0, "value": "{3.0::serious|3.0::determined|..." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "weather_time",
|
||||
"referencePosition": 3,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 17, "categoryId": 656, "valueIndex": 0, "value": "{1-2$$3.0::day|3.0::night|..." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "elemental_types",
|
||||
"referencePosition": 4,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 17, "categoryId": 631, "valueIndex": 5, "value": "lightning" }
|
||||
]
|
||||
// workflow.metadata
|
||||
"snippets": {
|
||||
"wildcardSetIds": [490, 491], // Alice's User-kind set + fullFeatureFantasy subscription
|
||||
"mode": "batch",
|
||||
"batchCount": 10,
|
||||
"targets": {
|
||||
"prompt": [
|
||||
{ "category": "character", "selections": [] }, // [] = full pool default
|
||||
{ "category": "weapons_melee", "selections": [] },
|
||||
{ "category": "expressions", "selections": [] },
|
||||
{ "category": "weather_time", "selections": [] },
|
||||
{ "category": "elemental_types", "selections": [] }
|
||||
],
|
||||
"negativePrompt": []
|
||||
}
|
||||
],
|
||||
"samplingSeed": 847291,
|
||||
"cartesianTotal": 48,
|
||||
"sampledTo": 10
|
||||
},
|
||||
"tags": [..., "wildcards"] // workflow.tags gets the 'wildcards' marker
|
||||
}
|
||||
```
|
||||
|
||||
Source identifier `(wildcardSetId, categoryId, valueIndex)` is uniform across User-kind and System-kind. The Dynamic Prompts expansion (`{3.0::sword|...}` → `sword`) happens later in the generation pipeline, downstream of this metadata.
|
||||
`wildcardSetIds` snapshots which sets contributed to the default pools. Without it, re-resolving this submission later would consult Alice's *current* active sets, which may have changed.
|
||||
|
||||
In batch mode, the cartesian total (3 × 1 × 1 × 1 × 16 = 48) is computed at display time from `snippets.targets.prompt` + the prompt template + the active sets' content. With `batchCount: 10`, the resolver samples 10 of the 48 combinations using the form's seed.
|
||||
|
||||
Alice didn't make explicit picks (defaults applied) so every `selections` array is empty. Had she selected, say, just `["Zelda"]` for `#character`, that entry's `selections` would read `[{ "categoryId": 700, "values": ["blonde hair, green tunic, ..."] }]` — `categoryId` is the canonical source pointer (the parent `wildcardSetId` is reachable through the FK on `WildcardSetCategory`), `values` is the array of value strings for re-edit/display without lookups.
|
||||
|
||||
The `wildcards` tag on `workflow.tags` lets analytics/admin queries filter for snippet-using submissions without parsing the metadata blob.
|
||||
|
||||
### Step metadata (per image, one of the 10 sampled steps)
|
||||
|
||||
Vanilla — looks identical to a no-snippet step. The snippet substitution has already happened server-side.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"params": {
|
||||
"prompt": "A blonde hair, green tunic, pointed ears, pointed cap, determined expression wearing armor, wielding sword, with serious expression in day weather, featuring lightning magic — dramatic composition, 8k",
|
||||
"negativePrompt": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The orchestrator processes this step as it would any other — no awareness of where the prompt came from.
|
||||
|
||||
---
|
||||
|
||||
@@ -387,15 +401,17 @@ Source identifier `(wildcardSetId, categoryId, valueIndex)` is uniform across Us
|
||||
"prompt": "A #character wearing armor, ...",
|
||||
"seed": -1,
|
||||
"quantity": 4,
|
||||
"activeWildcardSetIds": [490, 491]
|
||||
"wildcardSetIds": [490, 491]
|
||||
}
|
||||
```
|
||||
|
||||
Loading the preset:
|
||||
|
||||
1. Set all `UserWildcardSet WHERE userId = 1001` to `isActive = false`.
|
||||
2. Set rows with `id IN (490, 491)` to `isActive = true`.
|
||||
3. If any IDs no longer exist, surface a warning + "re-add fullFeatureFantasy v3.0" shortcut.
|
||||
1. Form's snippet-selection node hydrates `wildcardSetIds: [490, 491]` from the preset values into its localStorage state.
|
||||
2. Form fetches `getOwnedWildcardSets({ ids: [490, 491] })` to validate ownership and get set details for the picker.
|
||||
3. Any IDs not owned (e.g., set was removed since the preset was saved) are silently dropped from the form state and surfaced as a warning chip in the picker.
|
||||
|
||||
Crucially, no DB rows are mutated by preset load — only form state changes. Alice's library is untouched.
|
||||
|
||||
---
|
||||
|
||||
@@ -403,9 +419,9 @@ Loading the preset:
|
||||
|
||||
Bob clicks "Add set" → fullFeatureFantasy v3.0. The transaction finds existing `WildcardSet 17` and just adds a pointer:
|
||||
|
||||
| id | userId | wildcardSetId | nickname | isActive | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|----|
|
||||
| 492 | 2042 | 17 | "FFv3" | true | 3 | 2026-04-25 08:17:33 |
|
||||
| id | userId | wildcardSetId | nickname | sortOrder | addedAt |
|
||||
|----|----|----|----|----|----|
|
||||
| 492 | 2042 | 17 | "FFv3" | 3 | 2026-04-25 08:17:33 |
|
||||
|
||||
Zero re-extraction, zero re-audit. Content sharing pays off.
|
||||
|
||||
@@ -451,7 +467,7 @@ Cascades through:
|
||||
- `WildcardSetCategory` (Alice's `character` category, id 700) — deleted
|
||||
- `UserWildcardSet` (Alice's pointer, id 490) — deleted
|
||||
|
||||
Other users are unaffected. Generation history still references the deleted IDs in step metadata; consumers should handle missing references gracefully ("snippet no longer available").
|
||||
Other users are unaffected. Workflow metadata for past submissions still references the deleted set ID; consumers should handle missing references gracefully ("snippet source no longer available"). Step prompts already contain the substituted text, so they continue to render fine.
|
||||
|
||||
---
|
||||
|
||||
@@ -470,8 +486,8 @@ At projected year-one scale (§7 of schema spec): ~5k System sets, ~100k–500k
|
||||
## Takeaways for DB review
|
||||
|
||||
1. **Single content table for both kinds.** System-kind and User-kind sets share `WildcardSet` and `WildcardSetCategory` schemas. The `kind` discriminator + nullable `(modelVersionId, ownerUserId)` distinguishes them. CHECK constraint enforces the invariant.
|
||||
2. **Values inline as JSONB string arrays.** `WildcardSetCategory.values` is a JSONB array of plain strings. No separate value table. Per-category audit; the category is the atomic unit of allow/deny.
|
||||
2. **Values inline as Postgres `text[]`.** `WildcardSetCategory.values` is a `text[]` column. No separate value table, no JSONB. Per-category audit; the category is the atomic unit of allow/deny.
|
||||
3. **Resolver is a single query** across `UserWildcardSet → WildcardSet → WildcardSetCategory`. No app-side merging of separate sources.
|
||||
4. **Most write pressure is at System-kind first-import** (one bulk transaction per imported model version). User-kind writes are infrequent (one row per user save). Steady-state writes negligible.
|
||||
5. **Step metadata uses uniform source identifier** `(wildcardSetId, categoryId, valueIndex)` for both kinds, making historical generation reproducibility traceable without JOINs.
|
||||
6. **Categories are immutable post-create** — both kinds. User snippet workflow is "save creates a new category"; iterative growth means new categories (e.g. `character-2`) rather than mutating existing ones.
|
||||
5. **Snippet metadata lives on the workflow, not the step.** One `snippetSelections` record per submission captures the user's picks. Each step's metadata is vanilla — just the substituted prompt — so the orchestrator processes snippet-driven steps identically to ordinary steps. Reproduction is anchored on `(seed, prompt template, snippetSelections)`.
|
||||
6. **System-kind categories are immutable; User-kind categories are mutable.** Source-zip-derived content never changes; user-owned categories support full CRUD on values with each mutation triggering a per-category re-audit. Selections are identified by `value` text (stable under reorder, breaks only on edit/delete — handled gracefully via picker orphan state).
|
||||
|
||||
@@ -46,14 +46,14 @@ Wildcard-set content is **cached globally** — one extracted copy per model ver
|
||||
┌─────────────────────────────────┐
|
||||
│ WildcardSetCategory │
|
||||
│ (new) │
|
||||
│ values: JSONB string[] │
|
||||
│ values: text[] │
|
||||
│ audit + nsfwLevel here │
|
||||
└─────────────────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ User (existing) │◀──────│ UserWildcardSet │ ──→ WildcardSet
|
||||
│ │ 1:N │ (new) │ (per-user pointer
|
||||
│ │ │ isActive flag for picker scope │ for both kinds)
|
||||
│ │ │ (library pointer, no activation │ for both kinds)
|
||||
└─────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -63,8 +63,8 @@ Wildcard-set content is **cached globally** — one extracted copy per model ver
|
||||
**Key shape decisions:**
|
||||
|
||||
- **One unified content table.** `WildcardSet` covers both globally-shared content imported from wildcard-type models (`kind = System`) and user-owned personal collections (`kind = User`). The discriminator + nullable owner/model FKs differentiate them; the resolver and picker treat them uniformly.
|
||||
- **Values are inline JSONB string arrays.** No separate value table. Audit and site-availability flags live on the category. Categories are the atomic unit of audit + visibility — if a category fails audit it disappears from generation pools entirely; if it passes, its `nsfwLevel` controls whether it shows on .com (SFW) vs .red (NSFW) vs both.
|
||||
- **`UserWildcardSet` is the activation/scoping mechanism for both kinds.** Owners of User-kind sets get a `UserWildcardSet` row pointing at their own set; subscribers to System-kind sets get a row pointing at the system set. `isActive` controls whether the set contributes to picker results regardless of kind.
|
||||
- **Values are an inline Postgres `text[]` column.** No separate value table, no JSONB. Audit and site-availability flags live on the category. Categories are the atomic unit of audit + visibility — if a category fails audit it disappears from generation pools entirely; if it passes, its `nsfwLevel` controls whether it shows on .com (SFW) vs .red (NSFW) vs both.
|
||||
- **`UserWildcardSet` is a pure library pointer for both kinds.** Owners of User-kind sets get a `UserWildcardSet` row pointing at their own set; subscribers to System-kind sets get a row pointing at the system set. There is no `isActive` column — which sets are active for a given submission is form state (localStorage), captured per-submission as `wildcardSetIds` in workflow metadata. This lets remix work cleanly: loading an old workflow restores its specific active sets without disturbing the user's current library state.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,7 +147,7 @@ enum WildcardSetAuditStatus {
|
||||
|
||||
### 4.2 `WildcardSetCategory` — categories within a wildcard set, values inline
|
||||
|
||||
One row per `.txt` file in the source zip (e.g. `character.txt` → one category). The category's values are stored directly on this row as a `JSONB` array — no separate value table. Audit and site-availability flags live here so that a category is the atomic unit of "is this content allowed to be used."
|
||||
One row per `.txt` file in the source zip (e.g. `character.txt` → one category). The category's values are stored directly on this row as a Postgres `text[]` column — no separate value table, no JSONB structure. Audit and site-availability flags live here so that a category is the atomic unit of "is this content allowed to be used."
|
||||
|
||||
```prisma
|
||||
model WildcardSetCategory {
|
||||
@@ -162,7 +162,10 @@ model WildcardSetCategory {
|
||||
// (`{a|b|c}`, `{1-2$$a|b}`, `N.0::name`); the resolver expands those at generation time.
|
||||
// Nested references are normalized at import: source-file `__name__` is rewritten to `#name`
|
||||
// so the stored values use a single reference syntax everywhere in our system.
|
||||
values Json @db.JsonB
|
||||
// For User-kind sets, this column is mutable (users add, remove, edit, reorder values);
|
||||
// each mutation triggers an audit re-run for the category. For System-kind sets, the column
|
||||
// is set at import and never modified (the source model version is immutable).
|
||||
values String[] @db.Text
|
||||
|
||||
// Denormalized count for fast displays ("24 values") without parsing the JSON.
|
||||
valueCount Int
|
||||
@@ -200,15 +203,15 @@ enum CategoryAuditStatus {
|
||||
**Field notes:**
|
||||
|
||||
- `name` uses the PostgreSQL `citext` type — case-insensitive comparisons and unique constraint automatically. Stores the source filename's casing as-is; the picker can render it directly, and prompts match it regardless of how the user types `#Character` vs `#character`. Removes the need for a separate `displayName` column.
|
||||
- `values` is a JSONB array of strings, e.g. `["fire", "water", "earth", ...]` for `elemental_types`, or `["{3.0::serious|3.0::determined|...}"]` for a single-line weighted-alternation file. Empty source lines are dropped at import. Order is preserved via array position.
|
||||
- `values` is a Postgres `text[]`, e.g. `{"fire","water","earth", ...}` for `elemental_types`, or `{"{3.0::serious|3.0::determined|...}"}` for a single-line weighted-alternation file. Empty source lines are dropped at import. Order is preserved via array position. Identifier for re-edit / metadata uses the literal value string, not the array index — index isn't stable under reorder.
|
||||
- **Audit is one verdict per category, not per value.** If any line in the category fails audit, the whole category becomes `Dirty` and is excluded from resolution. Authors curate categories as cohesive lists; partial use after a partial-audit-fail isn't a workflow we want to support, and per-line audit columns aren't needed.
|
||||
- `nsfwLevel` follows the existing Civitai bitwise NSFW convention so the site router can filter categories using the same logic it already uses for images, models, etc. A category with `nsfwLevel = 0` (unrated) is treated as not-yet-available pending classification.
|
||||
- `valueCount` is denormalized for picker headers — derivable from `jsonb_array_length(values)` but cached to avoid the function call on hot reads.
|
||||
- `valueCount` is denormalized for picker headers — derivable from `array_length(values, 1)` but cached to avoid the function call on hot reads.
|
||||
- Cascades from `WildcardSet` — deleting a set deletes its categories.
|
||||
|
||||
### 4.3 `UserWildcardSet` — per-user activation pointer
|
||||
### 4.3 `UserWildcardSet` — per-user library pointer
|
||||
|
||||
Each row = "this user has this wildcard set active in their picker." Used for both `kind = System` (subscribed to a shared set) and `kind = User` (using their own owned set). When a user creates a User-kind set, a `UserWildcardSet` row is auto-created so the resolver doesn't need a special-case query path.
|
||||
Each row = "this user has this wildcard set in their library." Used for both `kind = System` (subscribed to a shared set) and `kind = User` (their own owned set). When a user creates a User-kind set, a `UserWildcardSet` row is auto-created.
|
||||
|
||||
```prisma
|
||||
model UserWildcardSet {
|
||||
@@ -219,51 +222,96 @@ model UserWildcardSet {
|
||||
wildcardSetId Int
|
||||
wildcardSet WildcardSet @relation(fields: [wildcardSetId], references: [id], onDelete: Cascade)
|
||||
|
||||
nickname String? // optional user rename for display (overrides set's own name in their picker)
|
||||
isActive Boolean @default(true)
|
||||
nickname String? // optional user rename for library display
|
||||
sortOrder Int @default(0)
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, wildcardSetId])
|
||||
@@index([userId, isActive]) // primary resolver query: "what sets does this user have active?"
|
||||
@@index([wildcardSetId]) // occasional: "who has this set active?" for invalidation fan-out
|
||||
@@index([userId]) // library list / ownership verification at resolve time
|
||||
@@index([wildcardSetId]) // occasional: "who has this set?" for invalidation fan-out
|
||||
}
|
||||
```
|
||||
|
||||
**Field notes:**
|
||||
|
||||
- **Pointer for both kinds.** For System-kind, the user explicitly added the set (subscription). For User-kind, the row is auto-created when the user creates the set; deactivating it hides the set from the picker without deleting it. Deleting a User-kind set cascades through this row.
|
||||
- Cascades on both sides. Deleting a user drops their pointers; deleting a `WildcardSet` drops all dependent pointers (including the owner's pointer for User-kind).
|
||||
- `(userId, isActive)` is the hottest index — every prompt's autocomplete fetch uses it.
|
||||
- **Pure library pointer, no activation state.** Whether a set contributes to a given submission is *not* stored in the DB — it lives in the form's generation-graph state (localStorage on the client) and is captured per-submission in `workflow.metadata`'s `wildcardSetIds`. This matters for re-edit / remix: loading an old workflow restores its specific active sets without touching the user's current library state.
|
||||
- **Cascades on both sides.** Deleting a user drops their pointers; deleting a `WildcardSet` drops all dependent pointers (including the owner's pointer for User-kind).
|
||||
- **Resolve-time ownership check.** When a submission arrives carrying `wildcardSetIds`, the resolver verifies each ID is owned by the submitter via this table. IDs without a matching pointer are silently dropped (the user revoked access since the form state was saved).
|
||||
- No audit fields here; the authoritative audit lives on the `WildcardSet` and its categories.
|
||||
|
||||
**Why no `isActive` column:** active sets are a per-form-state concept — they change as the user remixes old workflows or edits new ones, and shouldn't be shared across generation contexts. Keeping activation in localStorage (and per-submission metadata) lets `Form A` and `Form B` use different active sets without one disturbing the other.
|
||||
|
||||
### 4.4 Metadata conventions (no schema change)
|
||||
|
||||
Two existing JSON blobs gain new conventional keys.
|
||||
Existing JSON blobs gain new conventional keys. **No per-step snippet metadata** — steps remain ignorant of snippets and look identical to no-snippet steps once expansion is done.
|
||||
|
||||
**`GenerationPreset.values`** — gains `activeWildcardSetIds: number[]`. When a preset is saved, we snapshot which `UserWildcardSet.id`s are active. On load, those get reactivated (with a warning if any have since been removed from the library). No DB change; just a new key convention.
|
||||
**`GenerationPreset.values`** — gains `wildcardSetIds: number[]`. When a preset is saved, we snapshot which `UserWildcardSet.id`s are active. On load, those get re-applied to the form state (with a warning if any have since been removed from the library). No DB change; just a new key convention.
|
||||
|
||||
**Workflow step metadata** (`GenerationStep.metadata` or equivalent — wherever step metadata JSON lives today) gains a `snippetReferences` array per step, recording exactly which values were used:
|
||||
**`Workflow.tags`** — when a submission uses snippets, the `wildcards` tag is added to the workflow's existing tags array. Serves as an analytics filter (`workflow.tags @> '{wildcards}'`) and as a quick test for "did this generation use snippets?" without parsing the metadata blob.
|
||||
|
||||
**Workflow metadata** — gains a single `snippets` object holding everything. One record per workflow, not per step. The shape uses a generic `targets` map keyed by target ID (e.g. `prompt`, `negativePrompt`) rather than hard-coded keys, so new target types (e.g. a future `musicDescription` editor node) can be added without schema changes.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"snippetReferences": [
|
||||
{
|
||||
"category": "character",
|
||||
"referencePosition": 0,
|
||||
"resolvedValues": [
|
||||
{ "wildcardSetId": 42, "categoryId": 991, "valueIndex": 2, "value": "blonde hair, green tunic, pointed ears..." },
|
||||
{ "wildcardSetId": 17, "categoryId": 631, "valueIndex": 5, "value": "lightning" }
|
||||
// workflow.metadata
|
||||
"snippets": {
|
||||
"wildcardSetIds": [490, 491], // UserWildcardSet pointer IDs at submit time
|
||||
"mode": "batch", // "batch" | "random" — submission-level toggle
|
||||
"batchCount": 10, // how many workflow steps to fan out into
|
||||
"targets": {
|
||||
"prompt": [
|
||||
{
|
||||
"category": "character",
|
||||
"selections": [
|
||||
{ "categoryId": 700, "values": ["blonde hair, green tunic, pointed ears...", "young man, green hat..."] },
|
||||
{ "categoryId": 401, "values": ["#hero"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "setting",
|
||||
"selections": [] // empty array = "default to full pool"
|
||||
}
|
||||
],
|
||||
"negativePrompt": [
|
||||
{ "category": "bad_anatomy", "selections": [] }
|
||||
]
|
||||
// future: "musicDescription": [...] — no schema change required
|
||||
}
|
||||
],
|
||||
"samplingSeed": 847291,
|
||||
"cartesianTotal": 3648,
|
||||
"sampledTo": 10
|
||||
},
|
||||
"tags": [..., "wildcards"]
|
||||
}
|
||||
```
|
||||
|
||||
The source identifier is always `(wildcardSetId, categoryId, valueIndex)` — pointing into the JSONB `values` array on `WildcardSetCategory`. Both User-kind and System-kind sets share this shape; consumers can look up `WildcardSet.kind` if they need to distinguish (e.g., to label "from your library" vs "from a model"). Categories are immutable post-create, so the index is stable. The literal `value` text is also recorded for human-readable history.
|
||||
Top-level fields under `snippets`:
|
||||
|
||||
- `wildcardSetIds` — snapshot of the user's active `UserWildcardSet.id`s at submit time. Required for reproducibility of default-pool resolutions; the user's library could change between submission and re-resolution. Same convention as `GenerationPreset.values.wildcardSetIds`.
|
||||
- `mode` — `"batch"` runs unique cartesian-product combinations across the user's selections; `"random"` runs independent random samples per step. Single value applies across all targets.
|
||||
- `batchCount` — number of workflow steps to fan out into. In batch mode, this caps the cartesian product (sample with seeded PRNG if more combinations are available than `batchCount`). In random mode, this is the number of independent random draws.
|
||||
- `targets` — keyed map of resolution contexts. Key is an arbitrary string identifier (e.g. `prompt`, `negativePrompt`); value is an array of references. Each target maintains its own state — a `#character` reference in `prompt` is independent of a `#character` reference in `negativePrompt`. Cartesian math at resolve time multiplies across **all targets simultaneously**: a step gets one substituted output per target, drawn together from the combined cartesian space.
|
||||
|
||||
**Conventional target keys for v1:** `prompt` and `negativePrompt`. Future targets are additive — implementers iterate `Object.keys(snippets.targets)` and process each one's reference array.
|
||||
|
||||
Per-reference shape (entries in each target array):
|
||||
|
||||
- `category` — the prompt-side reference name (e.g., `#character` → `"character"`).
|
||||
- `selections` — the user's explicit picks, grouped by source category. Empty array = default-to-full-pool was used (the pool is computed from `wildcardSetIds`). Concrete entries record:
|
||||
- `categoryId` — the canonical source category. The `wildcardSetId` is reachable via the FK on `WildcardSetCategory`, so we don't store it twice.
|
||||
- `values` — the array of value strings the user picked from this source. Strings within the array are unique (app-level enforcement).
|
||||
|
||||
Anything derivable is intentionally not stored:
|
||||
|
||||
- The `cartesianTotal` ("48 possible combinations") is a one-line computation from the union of references across all `snippets.targets[*]` + the corresponding template strings at display time.
|
||||
- `sampledTo` is just `batchCount`.
|
||||
|
||||
**Identifier choice — value text, not index.** Selections record the literal `values` strings rather than array indices because User-kind values can be reordered, edited, added, and removed by their owner; the index is unstable, the text is mostly stable (explicit edit or removal still orphans the reference, which we handle gracefully). System-kind values never change, but using the same identifier convention keeps the resolver simple.
|
||||
|
||||
**Implementation note:** on the client, the entire `snippets` payload lives as a dedicated node in the existing generation graph used by `GenerationForm`. Each editor node (prompt, negativePrompt, and any future targets) has a dependency on the snippets node and reads from `snippets.targets[<editorNodeName>]` to render chips with their current selection state. The snippets node auto-prunes references whose `wildcardSetIds` the user no longer has access to (server returns the validated subset on form mount). Tiptap chips referencing pruned/invalidated sets render in a **red badge state** in the editor to flag "no corresponding snippet to use" — the user can either re-add the source set or delete the reference from the editor.
|
||||
|
||||
What's intentionally not stored anywhere on the workflow or step:
|
||||
|
||||
- Per-step picks — fully reproducible from `(seed, prompt templates, snippets)`. Re-running the resolver gives identical expansions.
|
||||
- Nested expansion trees — same reasoning. Recoverable on demand.
|
||||
- `samplingSeed` — the form's existing `seed` field is the single source of truth for randomness.
|
||||
|
||||
---
|
||||
|
||||
@@ -280,8 +328,8 @@ The source identifier is always `(wildcardSetId, categoryId, valueIndex)` — po
|
||||
| `WildcardSetCategory` | `(wildcardSetId)` | List all categories in a set |
|
||||
| `WildcardSetCategory` | `(wildcardSetId, auditStatus)` | Resolver: clean categories per set |
|
||||
| `WildcardSetCategory` | `(auditStatus)` | Background audit / re-audit job |
|
||||
| `UserWildcardSet` | `(userId, wildcardSetId)` unique | Enforce one pointer per user/set |
|
||||
| `UserWildcardSet` | `(userId, isActive)` | Primary resolver query per user |
|
||||
| `UserWildcardSet` | `(userId, wildcardSetId)` unique | Enforce one pointer per user/set + ownership check at resolve time |
|
||||
| `UserWildcardSet` | `(userId)` | Library list ("show me all my sets") |
|
||||
| `UserWildcardSet` | `(wildcardSetId)` | Fan-out when invalidating a set |
|
||||
|
||||
---
|
||||
@@ -295,7 +343,8 @@ Atomic transaction. Fewer rows now that values live inline on categories — one
|
||||
```
|
||||
BEGIN
|
||||
SELECT id FROM WildcardSet WHERE modelVersionId = ? AND kind = 'System'
|
||||
IF found: create UserWildcardSet (userId, wildcardSetId=found.id, isActive=true)
|
||||
IF found: create UserWildcardSet (userId, wildcardSetId=found.id)
|
||||
-- form's localStorage adds found.id to its wildcardSetIds list
|
||||
ELSE:
|
||||
INSERT WildcardSet (
|
||||
kind = 'System',
|
||||
@@ -309,13 +358,14 @@ BEGIN
|
||||
INSERT WildcardSetCategory (
|
||||
wildcardSetId,
|
||||
name, -- citext, preserves source filename casing
|
||||
values = jsonb(lines), -- JSONB array of strings, normalized to `#name`
|
||||
values = lines, -- text[], normalized to `#name`
|
||||
valueCount = length(lines),
|
||||
displayOrder,
|
||||
auditStatus = 'Pending',
|
||||
nsfwLevel = 0
|
||||
)
|
||||
INSERT UserWildcardSet (userId, wildcardSetId, isActive=true)
|
||||
INSERT UserWildcardSet (userId, wildcardSetId)
|
||||
-- form's localStorage adds the new set.id to its wildcardSetIds list
|
||||
COMMIT
|
||||
-- Then: enqueue audit job for the new WildcardSet
|
||||
```
|
||||
@@ -324,7 +374,7 @@ Concurrency: two users hitting first-import for the same model version at once
|
||||
|
||||
### 6.1a User-kind set creation and snippet save
|
||||
|
||||
User-kind sets are created lazily. The first time a user clicks "Save to my snippets" (from a wildcard picker row, or via a "create snippet" form), the service ensures a User-kind set exists for them and adds a category to it.
|
||||
User-kind sets and their categories are mutable: users can add values, edit them, reorder them, and remove them at any time. The first time a user clicks "Save to my snippets" (from a wildcard picker row, or via a "create snippet" form), the service ensures a User-kind set exists for them; subsequent saves either append to an existing category or create a new one.
|
||||
|
||||
```
|
||||
BEGIN
|
||||
@@ -336,38 +386,41 @@ BEGIN
|
||||
ownerUserId, name = 'My snippets',
|
||||
totalValueCount = 0, auditStatus = 'Pending'
|
||||
)
|
||||
INSERT UserWildcardSet (userId = ownerUserId, wildcardSetId = new.id, isActive = true)
|
||||
INSERT UserWildcardSet (userId = ownerUserId, wildcardSetId = new.id)
|
||||
-- form's localStorage adds new.id to its wildcardSetIds list
|
||||
|
||||
-- Find or create the category
|
||||
-- Find or create the category, then append the value
|
||||
SELECT id, values FROM WildcardSetCategory WHERE wildcardSetId = ? AND name = ?
|
||||
IF not found:
|
||||
INSERT WildcardSetCategory (
|
||||
wildcardSetId,
|
||||
name = '<chosen category, e.g. "character">',
|
||||
values = jsonb([newValue]), -- single-element array on creation
|
||||
values = ARRAY[newValue]::text[],
|
||||
valueCount = 1,
|
||||
auditStatus = 'Pending',
|
||||
nsfwLevel = 0
|
||||
)
|
||||
ELSE:
|
||||
-- Categories are immutable post-create per the agreed model.
|
||||
-- Adding a new value to an existing category creates a NEW category
|
||||
-- (e.g. "character" → "character-2") OR the user picks a different name.
|
||||
-- We surface this to the user at save time rather than mutating in place.
|
||||
REJECT or PROMPT for new category name
|
||||
-- Append to existing values array; enforce uniqueness within category at the app level
|
||||
-- (block exact duplicates with a friendly error).
|
||||
UPDATE WildcardSetCategory
|
||||
SET values = array_append(values, newValue),
|
||||
valueCount = valueCount + 1,
|
||||
auditStatus = 'Pending' -- re-audit on any mutation
|
||||
WHERE id = ?
|
||||
|
||||
UPDATE WildcardSet.totalValueCount += new values added
|
||||
COMMIT
|
||||
-- Enqueue audit for the new WildcardSetCategory
|
||||
-- Enqueue audit for the affected WildcardSetCategory
|
||||
```
|
||||
|
||||
This preserves the immutability invariant: existing categories never change. If a user wants to grow their character collection, they're either creating a new category (e.g. `characters_v2`) or starting fresh. UX-side, we'll need to make this clear in the "save to my snippets" flow — either auto-name new categories as `<base>-N`, or prompt the user.
|
||||
Other mutations follow the same pattern — `array_remove(values, target)`, in-place reorder via `UPDATE ... SET values = ARRAY[...]`, etc. Every mutation flips `auditStatus` back to `Pending` and enqueues a re-audit; until the audit completes the category temporarily isn't selectable, but its existing references in past workflows continue to work because past prompts already have substituted text in their step metadata.
|
||||
|
||||
> **Open product question:** is per-category immutability the right semantic for User-kind sets, or do we want categories to grow over time as users save more values? Strict immutability matches System-kind (which is desirable for uniformity) but creates UX friction for the iterative-saving workflow. See §9 open question 5.
|
||||
### 6.2 Resolver: get content for a `#category` reference
|
||||
|
||||
### 6.2 Resolver: get active content for a `#category` reference
|
||||
The resolver receives `wildcardSetIds` from the submission payload (sourced from the form's localStorage state, snapshotted into workflow metadata). It validates ownership, then fetches the matching categories.
|
||||
|
||||
Given `userId`, `category='character'`, and the request's site context (SFW vs NSFW expressed as a `requiredNsfwMask` int), fetch everything selectable. With the unified design, this is a **single query** — no separate path for personal snippets:
|
||||
Given `userId`, `wildcardSetIds`, `category='character'`, and the request's site context (SFW vs NSFW expressed as a `requiredNsfwMask` int):
|
||||
|
||||
```sql
|
||||
SELECT wsc.id AS "categoryId",
|
||||
@@ -385,27 +438,31 @@ FROM "UserWildcardSet" uws
|
||||
JOIN "WildcardSet" ws ON uws."wildcardSetId" = ws.id
|
||||
JOIN "WildcardSetCategory" wsc ON wsc."wildcardSetId" = ws.id
|
||||
WHERE uws."userId" = ?
|
||||
AND uws."isActive" = true
|
||||
AND uws."wildcardSetId" = ANY(?) -- the wildcardSetIds from submission
|
||||
AND ws."isInvalidated" = false
|
||||
AND wsc.name = 'character'
|
||||
AND wsc."auditStatus" = 'Clean'
|
||||
AND (wsc."nsfwLevel" & ?) <> 0; -- bitwise filter: category overlaps with required site rating
|
||||
AND (wsc."nsfwLevel" & ?) <> 0; -- bitwise filter: category overlaps with required site rating
|
||||
```
|
||||
|
||||
The `(uws.userId, uws.wildcardSetId)` join condition does double duty — it enforces ownership (any submitted ID without a matching pointer is silently dropped) and narrows to active sets in one pass.
|
||||
|
||||
The picker UI groups results by `setKind` for display ("From My Snippets" for User-kind, "From fullFeatureFantasy v3.0" for System-kind), but storage and querying are uniform.
|
||||
|
||||
**Indexes carrying this query:** `(userId, isActive)` on `UserWildcardSet`, `(wildcardSetId, name)` + `(wildcardSetId, auditStatus)` on `WildcardSetCategory`. Two-table-FK-walk; well-indexed three-table joins at this scale are sub-millisecond.
|
||||
**Indexes carrying this query:** `(userId, wildcardSetId)` unique on `UserWildcardSet` (covers both ownership and active-set filtering); `(wildcardSetId, name)` + `(wildcardSetId, auditStatus)` on `WildcardSetCategory`. Three-table-FK-walk, sub-millisecond at this scale.
|
||||
|
||||
**Expected result size:** ~3–20 category rows (one per active set that has the category). The app unpacks `values` arrays in code to produce the picker's flat list.
|
||||
|
||||
**Form mount behavior** (related, client-side): when `GenerationForm` mounts, it reads `wildcardSetIds` from localStorage (form state managed by the existing generation-graph), then fetches the corresponding `WildcardSet` rows via a `getOwnedWildcardSets(ids: number[])` tRPC query. The server returns only sets the user owns; missing IDs are silently stripped. If localStorage is empty (fresh session), the form initializes with the platform's system default wildcard set (TODO — see §9 open question 5b).
|
||||
|
||||
### 6.3 Audit job — category-level
|
||||
|
||||
Triggered on WildcardSet creation and when audit rules version bumps. Audit is per-category: read all values from the JSONB array, run audit rules across them, produce one verdict for the whole category. If any line fails, the category is `Dirty`.
|
||||
Triggered on WildcardSet creation, when audit rules version bumps, and (for User-kind) on every category mutation. Audit is per-category: read all values from the array, run audit rules across them, produce one verdict for the whole category. If any line fails, the category is `Dirty`.
|
||||
|
||||
```
|
||||
FOR each WildcardSetCategory WHERE wildcardSetId = ?
|
||||
AND (auditStatus = 'Pending' OR auditRuleVersion != currentRuleVersion):
|
||||
lines = parse JSONB values array
|
||||
lines = values -- text[]
|
||||
verdict, nsfwLevel, note = runAudit(lines)
|
||||
UPDATE WildcardSetCategory
|
||||
SET auditStatus = verdict,
|
||||
@@ -441,28 +498,24 @@ Downstream: resolver filters `isInvalidated = false`, so content is immediately
|
||||
|
||||
### 6.5 Preset save / load
|
||||
|
||||
**Save:**
|
||||
Active sets are part of the form's generation-graph state, so they snapshot/restore through the same path as the rest of the preset's `values` JSON. No DB writes flip activation state.
|
||||
|
||||
**Save:** the form serializes its current state (including the snippet-selection node's `wildcardSetIds` from localStorage) into `preset.values`. No special preset-save code path for snippets:
|
||||
|
||||
```
|
||||
const activeSetIds = await prisma.userWildcardSet.findMany({
|
||||
where: { userId, isActive: true }, select: { id: true }
|
||||
});
|
||||
preset.values = { ...otherValues, activeWildcardSetIds: activeSetIds.map(s => s.id) };
|
||||
preset.values = serializeFormState(); // includes wildcardSetIds, snippetSelections, snippetMode, batchCount
|
||||
```
|
||||
|
||||
**Load:**
|
||||
**Load:** the form reads `preset.values` and applies it to its generation-graph state. The snippet-selection node's `wildcardSetIds` is hydrated, then a follow-up `getOwnedWildcardSets(ids)` fetch validates ownership and returns the set details for the picker. IDs the user no longer owns are silently dropped from the form state and surfaced as a warning chip in the picker.
|
||||
|
||||
```
|
||||
const targetIds = preset.values.activeWildcardSetIds ?? [];
|
||||
await prisma.userWildcardSet.updateMany({
|
||||
where: { userId },
|
||||
data: { isActive: false }
|
||||
});
|
||||
await prisma.userWildcardSet.updateMany({
|
||||
where: { userId, id: { in: targetIds } },
|
||||
data: { isActive: true }
|
||||
});
|
||||
// Report any targetIds that no longer exist to the client for warning UI
|
||||
applyFormState(preset.values);
|
||||
const setDetails = await trpc.wildcardSet.getOwnedSets({ ids: form.wildcardSetIds });
|
||||
form.wildcardSetIds = setDetails.map(s => s.userWildcardSetId); // dropped any not owned
|
||||
```
|
||||
|
||||
Same flow for **remix**: clicking remix on an old workflow loads `workflow.metadata.wildcardSetIds` (and the rest of the snippet metadata) into the form. The user's current library state isn't touched; the form's local state simply reflects what was active for that workflow.
|
||||
|
||||
---
|
||||
|
||||
## 7. Estimated data volumes
|
||||
@@ -473,10 +526,10 @@ Educated guesses based on current Civitai scale; DB reviewer should sanity-check
|
||||
|---|---|---|
|
||||
| `WildcardSet` (System-kind) | ~1 per imported model version | ~5k rows |
|
||||
| `WildcardSet` (User-kind) | ~1–3 per active snippet user | ~100k–500k rows |
|
||||
| `WildcardSetCategory` | System: ~50 per set, ~6KB JSONB. User: ~5 per set, smaller JSONB | ~500k–1M rows |
|
||||
| `WildcardSetCategory` | System: ~50 per set, ~6KB text[]. User: ~5 per set, smaller text[] | ~500k–1M rows |
|
||||
| `UserWildcardSet` | ~3–10 per active user (subscriptions + own User-kind sets) | ~500k–2M rows |
|
||||
|
||||
`WildcardSetCategory` total storage is dominated by System-kind sets (~1.5GB across 250k rows from imported wildcard models). User-kind categories are typically smaller — fewer values per category, shorter values — and add negligible storage compared to System-kind. Postgres TOAST handles the larger JSONB blobs automatically.
|
||||
`WildcardSetCategory` total storage is dominated by System-kind sets (~1.5GB across 250k rows from imported wildcard models). User-kind categories are typically smaller — fewer values per category, shorter values — and add negligible storage compared to System-kind. Postgres TOAST handles longer array entries automatically; `text[]` storage is more compact than the equivalent JSONB shape would have been.
|
||||
|
||||
Write pressure is at import time (one bulk transaction per System-kind set; one row at a time for User-kind set creation/category-add). Steady-state writes are negligible.
|
||||
|
||||
@@ -494,7 +547,7 @@ CREATE TYPE "WildcardSetAuditStatus" AS ENUM ('Pending', 'Clean', 'Mixed', 'Dirt
|
||||
CREATE TYPE "CategoryAuditStatus" AS ENUM ('Pending', 'Clean', 'Dirty');
|
||||
|
||||
CREATE TABLE "WildcardSet" (...); -- has `kind`, nullable model FKs, nullable owner FK, `name CITEXT`
|
||||
CREATE TABLE "WildcardSetCategory" (...); -- has `values JSONB`, `auditStatus`, `nsfwLevel`, `name CITEXT`
|
||||
CREATE TABLE "WildcardSetCategory" (...); -- has `values text[]`, `auditStatus`, `nsfwLevel`, `name CITEXT`
|
||||
CREATE TABLE "UserWildcardSet" (...); -- per-user activation pointer for both kinds
|
||||
|
||||
ALTER TABLE "WildcardSet" ADD CONSTRAINT wildcard_set_kind_owner_check CHECK (
|
||||
@@ -514,26 +567,29 @@ No data backfill. No existing columns modified. `CREATE EXTENSION IF NOT EXISTS`
|
||||
|
||||
## 9. Open questions for DB review
|
||||
|
||||
1. **`WildcardSetCategory.values` as JSONB vs `text[]`.** Postgres `text[]` would also work and is slightly more constrained (always array-of-string). JSONB is more flexible if we later want to attach per-value metadata. Preference?
|
||||
2. **JSONB read patterns.** Resolver fetches the whole `values` array per category and unpacks in app code. Alternative: server-side `jsonb_array_elements_text(values)` to unnest at query time. Either is fine at this scale; flagging in case there's a house preference.
|
||||
3. **Denormalization of `valueCount` / `totalValueCount`.** Kept for read-path performance. `valueCount` is derivable from `jsonb_array_length(values)` — could be a generated column. Worth doing, or overkill?
|
||||
4. **`nsfwLevel` set by audit pipeline vs explicit moderator action.** Current plan: audit produces a verdict + an inferred `nsfwLevel` based on content rules. Mods can override later. Is there a more rigorous classification process the team would want here (e.g., human-in-the-loop required before any non-zero rating)?
|
||||
5. **User-kind category immutability vs. growth.** §6.1a's flow says categories are immutable post-create — adding a value means creating a new category (e.g. `character-2`). Strict but matches System-kind. Alternative: allow appending to a User-kind category's `values` JSONB. Less strict; complicates audit (re-audit on every append) and step-metadata stability (`valueIndex` shifts if we ever reorder). Preference?
|
||||
6. **Global set deletion.** Current plan: `WildcardSet` rows are never hard-deleted; `isInvalidated` handles policy-driven removals. Do we want a separate `deletedAt` for a softer concept, or is hard-delete-with-cascade acceptable for User-kind sets specifically (since we won't have step-history risk for personal content)?
|
||||
7. **Audit rule version as a string.** Letting the audit service own the versioning scheme. Alternative: a dedicated `AuditRuleset` table and FK to it. Simpler-as-string for v1?
|
||||
8. **CHECK constraint enforcement.** The `(kind, modelVersionId, ownerUserId)` invariant is enforced via a single CHECK constraint at migration time. Worth reviewing whether this is the right level of enforcement, or whether we'd prefer a partial unique index approach or trigger-based.
|
||||
9. **Default User-kind set name.** New users get a User-kind set called "My snippets" lazily created on first save. Hardcoded? Localizable? Prompted? Probably hardcoded for v1 with a per-user rename allowed via `name`.
|
||||
1. **Denormalization of `valueCount` / `totalValueCount`.** Kept for read-path performance. `valueCount` is derivable from `array_length(values, 1)` — could be a generated column. Worth doing, or overkill?
|
||||
2. **`nsfwLevel` set by audit pipeline vs explicit moderator action.** Current plan: audit produces a verdict + an inferred `nsfwLevel` based on content rules. Mods can override later. Is there a more rigorous classification process the team would want here (e.g., human-in-the-loop required before any non-zero rating)?
|
||||
3. **Global set deletion.** Current plan: `WildcardSet` rows are never hard-deleted; `isInvalidated` handles policy-driven removals. Do we want a separate `deletedAt` for a softer concept, or is hard-delete-with-cascade acceptable for User-kind sets specifically (since we won't have step-history risk for personal content)?
|
||||
4. **Audit rule version as a string.** Letting the audit service own the versioning scheme. Alternative: a dedicated `AuditRuleset` table and FK to it. Simpler-as-string for v1?
|
||||
5. **CHECK constraint enforcement.** The `(kind, modelVersionId, ownerUserId)` invariant is enforced via a single CHECK constraint at migration time. Worth reviewing whether this is the right level of enforcement, or whether we'd prefer a partial unique index approach or trigger-based.
|
||||
6. **Default User-kind set name.** New users get a User-kind set called "My snippets" lazily created on first save. Hardcoded? Localizable? Prompted? Probably hardcoded for v1 with a per-user rename allowed via `name`.
|
||||
7. **Re-audit cadence on User-kind mutations.** Every value add/edit/remove flips the category to `Pending` and enqueues a re-audit. For an active user editing rapidly, this could mean many re-audit jobs queued in seconds. Coalesce with debounce? Or just let the queue handle it (audit is fast)?
|
||||
|
||||
### TODO items
|
||||
|
||||
- **5b. System default wildcard set.** When `GenerationForm` mounts and no `wildcardSetIds` exists in localStorage (fresh session, cleared cache), the form should fall back to a Civitai-curated default set so the snippet picker isn't empty for first-time users. Mechanism TBD: a designated `WildcardSet` row with a special flag (`isSystemDefault Boolean`?) or hardcoded ID. Scope: identify or create the default content; add the boolean column or config; surface in the form's initial state.
|
||||
- **5c. `getResourceData` integration for Wildcard models.** When a user adds a Wildcard-type model via the resource picker in `GenerationForm`, the existing `getResourceData` helper needs to return the corresponding `WildcardSet.id` so the form can add it to its `wildcardSetIds`. Today `getResourceData` returns model/lora info; extending it to recognize `Wildcard` model type and return the resolved set ID is a focused client+server change.
|
||||
- **5d. Migration ordering.** Schema changes during the design phase (dropping `isActive`, etc.) should each ship as a separate Prisma migration if any of them lands in production before the next change. Worth flagging up front so we don't end up with a single mega-migration that's hard to roll back.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope for v1
|
||||
|
||||
- Search indexes over snippet/wildcard content (we defer to straightforward WHERE clauses until scale warrants — Postgres GIN on the JSONB `values` column is an option later).
|
||||
- Search indexes over snippet/wildcard content (we defer to straightforward WHERE clauses until scale warrants — Postgres GIN on `text[]` columns is an option later).
|
||||
- Cross-user sharing of User-kind sets (a "Shared" or "Public" `kind` value would be additive when we want it).
|
||||
- Wildcard set version-diff storage (immutable JSONB makes diffing a future concern).
|
||||
- Wildcard set version-diff storage (System-kind sets are immutable; diffing User-kind history is future concern).
|
||||
- Per-line audit results within a category (audit is atomic at the category level).
|
||||
- Set favoriting, tagging, or grouping beyond `sortOrder`.
|
||||
- Editing existing categories' values (categories are immutable post-create).
|
||||
- A dedicated favorites feature/table — favoriting is implemented by copying values into a User-kind set named however the user wants ("Favorites", "My picks", etc). No separate UI layer required.
|
||||
- Per-snippet labels for User-kind sets (values are plain strings; users find content by reading + searching).
|
||||
|
||||
These are deliberately punted — the schema above accommodates them as additive changes later.
|
||||
|
||||
@@ -13,11 +13,9 @@ Planning. Not yet implemented.
|
||||
- **WildcardSet** — the unified content table. Every set has a `kind`:
|
||||
- **System-kind:** content imported from a wildcard-type model. Globally cached, shared across all users who subscribe.
|
||||
- **User-kind:** owned by one user. Their personal collection, e.g. the default "My snippets" set.
|
||||
- **WildcardSetCategory** — categories within a set (e.g. `character`, `setting`). Each category holds a JSONB array of plain string values. Values can be plain text, use Dynamic Prompts alternation/weight syntax (`{a|b}`, `{1-2$$a|b}`, `N.0::name`), or contain nested `#name` references to other categories within the same set.
|
||||
- **UserWildcardSet** — per-user activation pointer. Decides which sets contribute to the user's current picker. Used for both kinds: subscription pointer for System-kind, auto-created owner pointer for User-kind. `isActive` flag governs picker visibility.
|
||||
- **Reference syntax:**
|
||||
- `#category` — batch mode. Selecting multiple values fans out into combinations.
|
||||
- `#?category` — random-pick mode. One value picked per workflow step.
|
||||
- **WildcardSetCategory** — categories within a set (e.g. `character`, `setting`). Each category holds a Postgres `text[]` of plain string values. Values can be plain text, use Dynamic Prompts alternation/weight syntax (`{a|b}`, `{1-2$$a|b}`, `N.0::name`), or contain nested `#name` references to other categories within the same set. For User-kind sets the array is mutable (add, edit, reorder, remove); for System-kind sets it's immutable from the source zip.
|
||||
- **UserWildcardSet** — per-user library pointer. Records that a user has access to a wildcard set. Used for both kinds: subscription pointer for System-kind, auto-created owner pointer for User-kind. There is no DB-level "active" flag — which sets are active for a given submission is captured in the form state (localStorage on the client) and snapshotted as `wildcardSetIds` in workflow metadata.
|
||||
- **Reference syntax:** `#category` is the only reference syntax. Whether the submission expands as a batch (cartesian fan-out) or random sampling (independent picks per step) is a form-level mode toggle, not a per-reference syntax.
|
||||
|
||||
For Prisma definitions, queries, indexes, and migration plan, see [prompt-snippets-schema.md](./prompt-snippets-schema.md). For a populated walkthrough using real wildcard-model content, see [prompt-snippets-schema-examples.md](./prompt-snippets-schema-examples.md).
|
||||
|
||||
@@ -26,13 +24,15 @@ For Prisma definitions, queries, indexes, and migration plan, see [prompt-snippe
|
||||
## Resolved design decisions
|
||||
|
||||
- **Default selection = full pool.** When a user references `#category` without explicitly selecting values, the resolver uses every clean value across the user's active sets. Users opt into narrower selections deliberately.
|
||||
- **Categories are immutable post-create.** Both kinds. Editing a category means creating a new one (with appended suffix, or a fresh name). System-kind reflects the source model's content; User-kind enforces the same rule for consistency and audit simplicity.
|
||||
- **System-kind categories are immutable; User-kind categories are mutable.** The source zip behind a System-kind set never changes (model versions are themselves immutable on Civitai), so its categories are read-only after import. User-kind categories support full CRUD on their values — add, edit, reorder, remove — with each mutation triggering a per-category re-audit.
|
||||
- **One audit verdict per category.** Audit runs across all values in a category and produces a single `Clean | Dirty` outcome. Dirty categories are excluded from generation pools entirely. No per-value audit.
|
||||
- **NSFW classification is per-category.** Audit also produces an `nsfwLevel` (bitwise, following Civitai convention). The site router uses this to decide whether the category appears on `.com` (SFW) vs `.red` (NSFW) vs both.
|
||||
- **Combination cap:** 10 per submission. Over-cap fan-out is randomly sampled with a seeded PRNG; user can reroll.
|
||||
- **No-repeat rule.** When a category appears multiple times in one prompt, no single combination reuses the same value across slots.
|
||||
- **Syntax:** `#category` (batch) and `#?category` (random-pick). Server resolves these against the user's active sets; unmatched `#tokens` pass through to the existing textual-inversion parser.
|
||||
- **Determinism:** the existing generation-form `seed` drives all snippet randomness (cap sampling + `#?` picks). Same seed + same payload = byte-identical expanded prompts.
|
||||
- **Syntax:** `#category` is the single reference syntax. Server resolves these against the user's active sets; unmatched `#tokens` pass through to the existing textual-inversion parser.
|
||||
- **Mode is per-submission, not per-reference.** The form has a `snippetMode` toggle (`batch` | `random`) that governs how the submission expands. Batch mode runs unique cartesian-product combinations across selections; random mode runs independent random samples per step.
|
||||
- **`batchCount` is user-configurable.** Number of workflow steps to fan out into. In batch mode it caps the cartesian product (sample with seeded PRNG when over-available); in random mode it's the number of independent draws.
|
||||
- **Determinism:** the existing generation-form `seed` drives all snippet randomness (cap sampling and random-mode picks). Same seed + same payload = byte-identical expanded prompts.
|
||||
- **Submission audit:** snippet content is pre-audited at category creation. The user's literal template text (outside any `#reference`) is audited at submission. Composed prompt goes to external moderation as part of the normal submission flow.
|
||||
|
||||
---
|
||||
@@ -42,8 +42,8 @@ For Prisma definitions, queries, indexes, and migration plan, see [prompt-snippe
|
||||
See [prompt-snippets-schema.md](./prompt-snippets-schema.md) for full Prisma definitions, indexes, and CHECK constraints. Quick summary:
|
||||
|
||||
- `WildcardSet` — `kind: System | User` discriminator. System-kind has `modelVersionId`, `modelName`, `versionName`. User-kind has `ownerUserId`, `name`. Audit aggregate, invalidation flags, denormalized `totalValueCount`.
|
||||
- `WildcardSetCategory` — `name CITEXT`, `values JSONB string[]`, per-category `auditStatus`, `nsfwLevel` (bitwise int), `valueCount`.
|
||||
- `UserWildcardSet` — `(userId, wildcardSetId, isActive)`. Activation pointer for both kinds.
|
||||
- `WildcardSetCategory` — `name CITEXT`, `values text[]`, per-category `auditStatus`, `nsfwLevel` (bitwise int), `valueCount`.
|
||||
- `UserWildcardSet` — `(userId, wildcardSetId)`. Library pointer for both kinds. No activation flag — see [prompt-snippets-schema.md](./prompt-snippets-schema.md) §4.3.
|
||||
|
||||
There is no separate `PromptSnippet` table. User personal content is a User-kind `WildcardSet` whose categories live in the same table as System-kind imported content.
|
||||
|
||||
@@ -51,20 +51,17 @@ There is no separate `PromptSnippet` table. User personal content is a User-kind
|
||||
|
||||
## Syntax and parsing
|
||||
|
||||
**Trigger characters:**
|
||||
**Trigger character:** `#category`. The behavior of the submission (cartesian fan-out vs random sampling) is governed by the `snippetMode` form toggle, not by the prompt syntax.
|
||||
|
||||
- `#` — batch mode. `#category` references the category for cartesian-product fan-out.
|
||||
- `#?` — random-pick mode. `#?category` picks one value per workflow step.
|
||||
|
||||
**Grammar:** trigger + identifier matching `[A-Za-z][A-Za-z0-9_]*`. Categories are matched case-insensitively (citext storage preserves original casing for display).
|
||||
**Grammar:** `#` + identifier matching `[A-Za-z][A-Za-z0-9_]*`. Categories are matched case-insensitively (citext storage preserves original casing for display).
|
||||
|
||||
**Collision with existing `#textualInversion` syntax** is resolved at the server. Snippet expansion runs first and replaces any `#token` matching one of the user's accessible category names. Unmatched `#tokens` pass through to the textual-inversion parser unchanged. Edge case: a user with both a wildcard category named `foo` and a textual-inversion resource named `foo` will see the wildcard win — rare conflict, surface a warning if it ever happens.
|
||||
|
||||
**Parser:** new helper in [src/utils/prompt-helpers.ts](../../src/utils/prompt-helpers.ts), co-located with the existing `parsePromptResources`.
|
||||
|
||||
```ts
|
||||
const snippetReferencePattern = /(#\??)([a-zA-Z][a-zA-Z0-9_]*)/g;
|
||||
// Returns ordered list of references: [{ kind: 'batch' | 'random', category, position }]
|
||||
const snippetReferencePattern = /#([a-zA-Z][a-zA-Z0-9_]*)/g;
|
||||
// Returns ordered list of references: [{ category, position }]
|
||||
```
|
||||
|
||||
**Slot-counting:** `"#character fights #character"` contains two batch slots for `character`. The no-repeat rule ensures the two slots within a single combination hold different values.
|
||||
@@ -98,7 +95,7 @@ A new "Wildcards" tab in the resources picker (alongside LoRAs, embeddings):
|
||||
|
||||
Wrap the existing [InputPrompt.tsx](../../src/components/Generate/Input/InputPrompt.tsx) with an autocomplete-aware shell:
|
||||
|
||||
1. Watches the textarea for `#` or `#?` trigger characters.
|
||||
1. Watches the textarea for the `#` trigger character.
|
||||
2. Opens a positioned popover (Mantine `Popover` + `ScrollArea`) showing matching categories from the user's active sets, with source labels (e.g., "from My snippets" vs "from fullFeatureFantasy v3.0").
|
||||
3. Arrow keys navigate, Enter inserts. Inserted text is plain `#category` (no rich tokenization) — survives copy/paste, preset save/load, server round-tripping.
|
||||
4. Existing `#references` in the textarea are highlighted via lightweight overlay.
|
||||
@@ -107,7 +104,8 @@ Wrap the existing [InputPrompt.tsx](../../src/components/Generate/Input/InputPro
|
||||
|
||||
Below the prompt input, a `SnippetReferencePanel` component:
|
||||
|
||||
- Lists each unique `#category` / `#?category` reference found in the prompt.
|
||||
- Lists each unique `#category` reference found in the prompt.
|
||||
- A mode toggle (Batch | Random) sets `snippetMode` for the submission. A number input sets `batchCount`.
|
||||
- For each reference: shows the merged pool of values across active sets, grouped by source (e.g. "From My Snippets," "From fullFeatureFantasy v3.0"). **No values selected = full pool used by default.** Users opt into narrower selections explicitly via per-source filter pills + per-row checkboxes.
|
||||
- Search box per reference (filter values across sources within one category — handles large libraries).
|
||||
- Per-row `⋯` menu for affordances like "Save to my snippets" (copies a value from a System-kind set into the user's User-kind set as a new category).
|
||||
@@ -125,6 +123,16 @@ Submit is enabled (with an info alert) when:
|
||||
|
||||
- Total cartesian combinations > 10 — alert reads *"N combinations — randomly running 10. [Reroll]"*.
|
||||
|
||||
### Mobile
|
||||
|
||||
Same components, same data, same Tiptap-based prompt editor — adapted for touch and a small screen by:
|
||||
|
||||
- **Progressive chip disclosure.** In the prompt input, references default to the minimal form (`#character`). After the user taps a chip for the first time, it expands to the verbose form (`#character · 6 selected`). This keeps the prompt clean by default and only adds visual weight to references the user has explicitly curated.
|
||||
- **Slim bottom drawer for the picker.** Tapping a chip slides up a focused picker drawer covering the bottom ~65% of the screen. There is no scrim — the prompt remains fully visible above the drawer so the user keeps full context while editing selections. The drawer holds the same source-grouped list, source-filter pills, search input, and per-row "Save to My Snippets" overflow action as the desktop popover.
|
||||
- **Stripped chrome.** No screen header, sources strip, or always-on reference panel — the chips in the prompt are the only entry point to the picker.
|
||||
|
||||
Mockup: [docs/working/mockups/prompt-snippets-mobile/r2-slim-bottom-drawer.html](../working/mockups/prompt-snippets-mobile/r2-slim-bottom-drawer.html). Two phone frames showing the before-tap (minimal chips, no drawer) and after-tap (verbose chip + drawer open) states.
|
||||
|
||||
---
|
||||
|
||||
## Submission payload
|
||||
@@ -132,18 +140,35 @@ Submit is enabled (with an info alert) when:
|
||||
Extend the `generateFromGraph` call ([generationRequestHooks.ts:216-237](../../src/components/ImageGeneration/utils/generationRequestHooks.ts#L216-L237)) to include snippet context:
|
||||
|
||||
```ts
|
||||
type SnippetReference = {
|
||||
category: string;
|
||||
// empty selections array = "use full pool" (default behavior, scoped to wildcardSetIds)
|
||||
// value text is the stable identifier — survives reorder, breaks only on edit/delete
|
||||
selections: { categoryId: number; values: string[] }[];
|
||||
};
|
||||
|
||||
type GenerateFromGraphInput = {
|
||||
input: GraphInput; // existing (already contains the form `seed`)
|
||||
civitaiTip, creatorTip, tags, remixOfId, buzzType; // existing
|
||||
snippets?: { // new
|
||||
references: {
|
||||
category: string;
|
||||
kind: 'batch' | 'random';
|
||||
// empty selections array = "use full pool" (default behavior)
|
||||
selections: { wildcardSetId: number; categoryId: number; valueIndex: number }[];
|
||||
}[];
|
||||
wildcardSetIds: number[]; // UserWildcardSet pointer IDs active at submit time
|
||||
mode: 'batch' | 'random'; // submission-level toggle
|
||||
batchCount: number; // workflow steps to fan out into
|
||||
targets: Record<string, SnippetReference[]>;
|
||||
// Conventional target keys for v1: 'prompt', 'negativePrompt'.
|
||||
// Extensible: future editor nodes (e.g. 'musicDescription') just add their own key.
|
||||
// Empty target = []. No wrapper object.
|
||||
};
|
||||
};
|
||||
|
||||
// On the client, this `snippets` payload is the serialized form of a dedicated node in the
|
||||
// existing generation graph used by GenerationForm. Each editor node has a dependency on the
|
||||
// snippets node and reads its target slice (snippets.targets[editorNodeName]) to render chips.
|
||||
// Mode and batchCount are submission-level; per-reference kind doesn't exist.
|
||||
//
|
||||
// On submission, the server adds the `wildcards` tag to workflow.tags so the workflow is
|
||||
// queryable as a snippet-using submission ("did this generation use snippets?") without
|
||||
// parsing the metadata blob.
|
||||
```
|
||||
|
||||
- Client does **not** expand. Server is the sole source of truth for permutation enumeration, cap enforcement, and seed-based sampling — keeps the cap un-spoofable.
|
||||
@@ -158,29 +183,52 @@ type GenerateFromGraphInput = {
|
||||
Snippet expansion slots into [orchestration-new.service.ts](../../src/server/services/orchestrator/orchestration-new.service.ts) at `createStepInputs`, before per-step build:
|
||||
|
||||
```ts
|
||||
async function expandSnippetsToPrompts(
|
||||
template: string,
|
||||
references: SnippetReference[],
|
||||
userId: number,
|
||||
seed: number,
|
||||
): Promise<{ prompt: string; assignment: ResolvedAssignment }[]> {
|
||||
// 1. For each reference, fetch the merged pool (all active sets × matching category) — clean only,
|
||||
// nsfwLevel-filtered for the request's site context.
|
||||
async function expandSnippetsToTargets(input: {
|
||||
templates: Record<string, string>; // keyed by target ID (e.g. { prompt, negativePrompt })
|
||||
wildcardSetIds: number[];
|
||||
targets: Record<string, SnippetReference[]>; // same keys as templates
|
||||
mode: 'batch' | 'random';
|
||||
batchCount: number;
|
||||
seed: number;
|
||||
}): Promise<Array<Record<string, string>>> { // each combination = a record { targetId → substituted text }
|
||||
// 1. For each reference across all targets, fetch the merged pool from
|
||||
// wildcardSetIds × matching category — clean only, nsfwLevel-filtered for the site context.
|
||||
// 2. If reference.selections is empty, use the full pool. Otherwise restrict to selections.
|
||||
// 3. For batch refs (#): enumerate k-permutations per category, then cartesian-product across categories.
|
||||
// If total > 10: Fisher-Yates shuffle keyed by seed, take first 10.
|
||||
// 4. For random-pick refs (#?): pick one value per workflow step using PRNG keyed by
|
||||
// (seed, stepIndex, refPosition).
|
||||
// 5. Substitute values into template; recursively resolve any nested #name refs within source set scope.
|
||||
// 6. Return one expanded prompt + assignment per combination.
|
||||
// 3. Resolve mode (single mode applies across all targets):
|
||||
// - "batch": enumerate k-permutations per category across ALL target references,
|
||||
// then cartesian-product. If total > batchCount, Fisher-Yates shuffle keyed by seed,
|
||||
// take first batchCount.
|
||||
// - "random": for each of batchCount steps, pick one value per reference (across all targets)
|
||||
// using PRNG keyed by (seed, stepIndex, targetId, refPosition).
|
||||
// 4. For each resulting combination, substitute values into each target's template;
|
||||
// recursively resolve any nested #name refs within source set scope.
|
||||
// 5. Return one record per combination — keys are target IDs, values are the substituted text.
|
||||
}
|
||||
```
|
||||
|
||||
**Determinism contract:** sampling and random-pick are pure functions of `(seed, references, selections, template)`. Implementation uses a seeded PRNG (e.g. `mulberry32`); no wall-clock or process-level randomness.
|
||||
The cartesian space is unified across **all targets**. A single combination produces substituted text for every target simultaneously (e.g., one `prompt` AND one `negativePrompt`). References on any target contribute to the total combination count multiplicatively. Adding a new target (e.g. `musicDescription`) automatically participates in the cartesian without resolver code changes.
|
||||
|
||||
**Determinism contract:** all randomness (over-cap sampling, random-mode picks, nested-ref alternation) is a pure function of `(seed, wildcardSetIds, references, selections, template)`. Implementation uses a seeded PRNG (e.g. `mulberry32`); no wall-clock or process-level randomness.
|
||||
|
||||
**Workflow shape:** single `submitWorkflow()` call with all N steps. Same submission boundary, same buzz-accounting path.
|
||||
|
||||
**Step metadata:** each step records its resolved values (with `wildcardSetId`, `categoryId`, `valueIndex`, and the literal value text) for reproducibility and result-card display. Schema doc §4.4 has the JSON shape.
|
||||
**Where the data lives:**
|
||||
|
||||
- **Workflow metadata** gets a single `snippets` object (per submission) containing `wildcardSetIds`, `mode`, `batchCount`, and a keyed `targets` map (with conventional keys `prompt` and `negativePrompt` in v1). Used to reload picker state on re-edit and to show "this batch ran with character: Zelda, Link" in run summaries. See schema doc §4.4 for the full shape.
|
||||
- **Workflow.tags** gains a `wildcards` entry whenever snippets were used in the submission. Cheap analytics signal + queryable filter ("did this generation use snippets?") without parsing the metadata blob.
|
||||
- **Step metadata stays vanilla.** Each step's `params.prompt` and `params.negativePrompt` already contain the fully substituted text. The orchestrator processes snippet-driven steps identically to ordinary steps — no new step-level fields, no awareness of where the prompt came from.
|
||||
|
||||
Reproduction of any specific step's expansion is recoverable on demand from `(seed, target templates, snippets)` — re-running the resolver gives byte-identical results. We don't duplicate the per-step expansion tree on every step.
|
||||
|
||||
### Generation-graph node behavior (client)
|
||||
|
||||
The `snippets` object lives as a dedicated node in the generation graph that `GenerationForm` builds. Three behaviors worth calling out:
|
||||
|
||||
1. **Editor nodes have a dependency on the snippets node.** Each editor node (prompt, negativePrompt, and any future targets) reads `snippets.targets[<ownNodeName>]` to render its Tiptap chips with their current selection state. When the snippets node updates (a chip is tapped, mode flips, a set is added), the dependent editor nodes re-render.
|
||||
|
||||
2. **Auto-prune on access loss.** When the form mounts (or after a preset/remix load), the form fetches `getOwnedWildcardSets({ ids: snippets.wildcardSetIds })`. The server returns only the IDs the user still owns. Any IDs missing from the response are silently pruned from the snippets node's `wildcardSetIds`, plus any selections referencing categories from those sets (across every target). The user starts with a clean, valid state; no errors at submit time from stale references.
|
||||
|
||||
3. **Red-badge state for orphaned references.** A `#category` chip in any editor is "orphaned" when it references a category that no longer resolves against any active set (the user removed the source set, the set was invalidated, or the category itself is Dirty). The Tiptap chip renders with a red badge in this case to flag "no corresponding snippet to use." The user can either re-add the source set (if they removed it) or delete the reference from the editor. Submit is blocked while any orphaned chips exist in any target.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,7 +238,7 @@ async function expandSnippetsToPrompts(
|
||||
|
||||
Audit runs per-category when a `WildcardSetCategory` is created:
|
||||
|
||||
1. Read all values in the JSONB array.
|
||||
1. Read all values in the `text[]` array.
|
||||
2. Run audit rules across the values.
|
||||
3. Produce one verdict (`Clean` or `Dirty`) plus an `nsfwLevel` classification.
|
||||
4. Update the category row.
|
||||
@@ -214,12 +262,12 @@ Background cron job re-audits affected categories when audit rule version bumps.
|
||||
|
||||
Presets snapshot the user's active sets at save time. `GenerationPreset.values` gains:
|
||||
|
||||
- `activeWildcardSetIds: number[]` — list of `UserWildcardSet.id`s that were active when the preset was saved.
|
||||
- `wildcardSetIds: number[]` — list of `UserWildcardSet.id`s that were active when the preset was saved.
|
||||
- `prompt` continues to save with `#references` as literal text.
|
||||
|
||||
On load:
|
||||
|
||||
- The user's `UserWildcardSet.isActive` is updated to match the preset's snapshot (deactivate everything, then activate the listed IDs).
|
||||
- The form's `wildcardSetIds` (in localStorage) is hydrated from the preset's snapshot. No DB rows are touched.
|
||||
- If any IDs in the snapshot no longer exist, the preset load surfaces a warning and offers a "re-add these sets" shortcut for missing System-kind sets.
|
||||
- For each `#reference` in the loaded prompt, the picker panel populates per-reference selection state. Defaults to "all selected" (full pool); user adjusts.
|
||||
|
||||
@@ -241,10 +289,11 @@ No user-visible features. Validates the data model end-to-end via API testing.
|
||||
|
||||
### Phase 2 — System-kind import flow
|
||||
|
||||
- Wildcard-type model browsing (resources picker tab)
|
||||
- "Add to library" creates `WildcardSet` (with first-import extraction + audit) + a `UserWildcardSet` pointer
|
||||
- Wildcard-type model browsing (resources picker tab in `GenerationForm`)
|
||||
- "Add to library" creates `WildcardSet` (with first-import extraction + audit) + a `UserWildcardSet` library pointer
|
||||
- Extend `getResourceData` to recognize `Wildcard` model type and return the corresponding `WildcardSet.id` so the form's snippet-selection node can add it to its `wildcardSetIds` immediately (auto-active in the current generation context)
|
||||
- User can browse their imported sets in a library page
|
||||
- Resolver query (read-only, returns merged-pool data for a `#category` lookup)
|
||||
- `getOwnedWildcardSets({ ids })` tRPC query for hydrating form state on mount/preset-load/remix
|
||||
|
||||
Users can subscribe to wildcard models but don't yet see them in the prompt UI.
|
||||
|
||||
@@ -272,54 +321,54 @@ No submission changes yet — references behave as literal text server-side.
|
||||
|
||||
Pickers show in UI; selections aren't sent yet.
|
||||
|
||||
### Phase 6 — server expansion + step fan-out
|
||||
### Phase 6 — server expansion + step fan-out (both modes)
|
||||
|
||||
- Augment `generateFromGraph` payload
|
||||
- `snippetExpansion.ts` module in `server/services/orchestrator/`
|
||||
- Augment `generateFromGraph` payload (snippet selections, mode, batchCount, wildcardSetIds)
|
||||
- `snippetExpansion.ts` module in `server/services/orchestrator/` — handles both batch and random modes uniformly
|
||||
- Hook into `createStepInputs`
|
||||
- Step metadata records resolved values
|
||||
- Workflow metadata records the snippet inputs; step metadata stays vanilla (substituted prompt only)
|
||||
|
||||
First end-to-end working slice.
|
||||
|
||||
### Phase 7 — random-pick mode (`#?category`)
|
||||
|
||||
- Extend parser to recognize `#?` as a distinct kind
|
||||
- Per-step seeded PRNG resolution in `expandSnippetsToPrompts`
|
||||
- Picker panel adds "random pool" affordances (bulk select)
|
||||
|
||||
### Phase 8 — nested wildcard resolution
|
||||
### Phase 7 — nested wildcard resolution
|
||||
|
||||
- Recursive `#name` expansion within source-set scope (max depth + cycle detection)
|
||||
- Transitive `Dirty` propagation: if a category references another `Dirty` category, mark this one `Dirty` too
|
||||
|
||||
### Phase 8 — system default wildcard set
|
||||
|
||||
- Identify or create a Civitai-curated default `WildcardSet` (System-kind) for first-time users
|
||||
- Mechanism: `isSystemDefault Boolean` flag on `WildcardSet` (or hardcoded ID — TBD)
|
||||
- Form mount: when localStorage has no `wildcardSetIds`, initialize with the system default ID so the picker isn't empty for new users
|
||||
- Schema impact is minor (one boolean column or none); product impact is curating the default content
|
||||
|
||||
---
|
||||
|
||||
## Random-pick mode
|
||||
## Submission modes
|
||||
|
||||
Two reference kinds share the same selection pool but differ in expansion semantics:
|
||||
Mode is a per-submission toggle (`snippetMode`) on the form, not a per-reference syntax. The same selections produce different output behavior depending on mode:
|
||||
|
||||
- **`#category` — batch mode.** Each reference slot uses one selected value; selections cartesian-product across categories into multiple workflow steps. *Fans out a batch.*
|
||||
- **`#?category` — random-pick mode.** One value is randomly picked from the pool per workflow step and inserted into every `#?category` occurrence in that step. *Does not fan out.*
|
||||
- **`batch` mode.** Enumerate the cartesian product of selected values across references (with no-repeat for repeated category slots), then run `batchCount` of them. If the cartesian total exceeds `batchCount`, sample using the seeded PRNG; if fewer combinations are available, run all of them.
|
||||
- **`random` mode.** Run `batchCount` independent steps. Each step picks one value per reference using a PRNG keyed by `(seed, stepIndex, refPosition)`. No cartesian enumeration; each step is an independent draw.
|
||||
|
||||
**Combined usage:** A prompt may mix both modes. Example: `"#character walking through #?setting"` with 3 characters and 5 settings → 3 workflow steps (one per character), each step's `#?setting` independently picks one of the 5 settings via seeded PRNG. Total images = 3 steps × `quantity` per step.
|
||||
**Total images output:** `batchCount × quantity` (where `quantity` is the existing per-step images-per-workflow setting).
|
||||
|
||||
**Per-step, not per-image.** All images in a single workflow step share the same `#?` resolution. For per-image variance, set `quantity = 1` and rely on batch fan-out.
|
||||
**Per-step, not per-image.** All images within a single workflow step share the same prompt. For per-image variance in random mode, set `quantity = 1` and increase `batchCount`.
|
||||
|
||||
**Validation:** `#?category` requires at least one available value (after default = full pool rule applies). Same-category occurrences within a prompt share one pick per step.
|
||||
**Same-category repeated slots in a single prompt** still follow the no-repeat rule in batch mode (a single combination uses different values for each slot of the same category). In random mode, all slots of the same category in one step share the same random pick (consistent with how a single random draw populates the prompt).
|
||||
|
||||
**Shared selection pool with batch.** A prompt like `"#character fights #?character"` uses one selection set for `character` — batch slots iterate, random-pick slots draw from the same set. Separate per-reference pools is a v2 enhancement.
|
||||
**Selection pool semantics are uniform across modes.** Empty `selections` for a reference means default-to-full-pool, computed from `wildcardSetIds`. Explicit `selections` restrict the pool. The mode just determines how the resolver enumerates and samples from the resulting pools.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Cross-user sharing of User-kind sets (a `Shared` or `Public` `kind` value would be additive when we want it)
|
||||
- Editing existing categories' values (categories are immutable post-create)
|
||||
- A dedicated favorites feature/table — users implement "favorites" by saving values into a User-kind set named however they want ("Favorites", "My picks", etc.); no separate system needed
|
||||
- Per-snippet labels for User-kind sets (values are plain strings; users find content by reading + searching)
|
||||
- Per-reference "shared pick" toggle (all `#character` occurrences get the same value within a combination)
|
||||
- Per-reference separate selection pools (batch and random-pick drawing from different sets within the same category)
|
||||
- Per-image random-pick resolution (currently per-step)
|
||||
- Per-reference mode override (whole submission is one mode; mixing batch and random within a single prompt is not in v1)
|
||||
- Per-image random-mode resolution (currently per-step — set `quantity = 1` and increase `batchCount` for per-image variance)
|
||||
- Weight syntax for snippets (e.g., `#character:1.2`)
|
||||
- Search indexes over wildcard content (Postgres GIN on JSONB `values` is an option later)
|
||||
- Search indexes over wildcard content (Postgres GIN on the `text[]` `values` column is an option later)
|
||||
|
||||
These are deliberately deferred. The schema accommodates them as additive changes later.
|
||||
|
||||
Reference in New Issue
Block a user