@nodaro/prompts 1.8.1 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +371 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +538 -64
- package/dist/index.d.ts +538 -64
- package/dist/index.js +353 -106
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/character-fx-timing-catalogs.test.ts +240 -0
- package/src/__tests__/factory-presets.test.ts +62 -12
- package/src/__tests__/node-prompt-fields.test.ts +81 -0
- package/src/__tests__/parameter-registry-sync.test.ts +4 -1
- package/src/__tests__/prompt-affixes.test.ts +83 -0
- package/src/__tests__/transition-timing-catalogs.test.ts +153 -0
- package/src/__tests__/video-reference-ref-id-tokens.test.ts +301 -0
- package/src/character-fx.ts +102 -19
- package/src/composition-effects.ts +6 -1
- package/src/factory-presets/generate-image.ts +37 -24
- package/src/factory-presets/shared-image.ts +10 -10
- package/src/factory-presets/switchx.ts +6 -6
- package/src/factory-presets/video-edit.ts +6 -6
- package/src/index.ts +1 -0
- package/src/node-prompt-fields.ts +206 -0
- package/src/picker-catalogs.ts +66 -17
- package/src/picker-wiring.ts +2 -1
- package/src/provider-prompt-doctrine.ts +2 -2
- package/src/ref-binding.ts +45 -0
- package/src/ref-id-tokens.ts +112 -0
- package/src/resolve-prompt.ts +60 -5
- package/src/transitions.ts +79 -19
- package/src/video-reference-resolver.ts +78 -39
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth: which data field(s) hold a node's user-editable
|
|
3
|
+
* prompt text. Drives the quick-edit Prompt modal so it can work generically
|
|
4
|
+
* across every AI node without each call site hardcoding a field name.
|
|
5
|
+
*
|
|
6
|
+
* The registry LIVES HERE (`@nodaro/prompts`) so the backend orchestrator, the
|
|
7
|
+
* `/v1/nodes` registry, the prompt-affix totality tests and the docs tooling
|
|
8
|
+
* read the SAME list the editor does. `frontend/src/lib/prompt-fields.ts` is a
|
|
9
|
+
* pure re-export of this module — frontend code keeps importing
|
|
10
|
+
* `@/lib/prompt-fields` unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Most nodes store the prompt in `data.prompt`, but several don't
|
|
13
|
+
* (`text-prompt` → `text`, `image-to-text` → `customPrompt`, …) — this map is
|
|
14
|
+
* where that knowledge lives, once.
|
|
15
|
+
*
|
|
16
|
+
* INVARIANT: every node that exposes a user-editable prompt MUST have an entry
|
|
17
|
+
* here, or the quick-edit modal silently does nothing for it. A guard test
|
|
18
|
+
* (`prompt-fields.test.ts`) keeps this in sync with the node set.
|
|
19
|
+
*
|
|
20
|
+
* This module is intentionally pure data (no React/lucide imports) so it can be
|
|
21
|
+
* pulled into the app-runtime bundle and any test without dragging in the icon
|
|
22
|
+
* library. The string `icon` kind is mapped to a concrete lucide component in
|
|
23
|
+
* `prompt-edit-button.tsx` (`getPromptIcon`), the only place that renders it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { SnippetMedia } from "./factory-snippets/types.js"
|
|
27
|
+
import { NODE_PROMPT_CANDIDATE_FIELDS } from "./resolve-prompt.js"
|
|
28
|
+
|
|
29
|
+
/** Which lucide glyph a node's prompt affordance uses. Kept as a string so this
|
|
30
|
+
* module stays icon-library-free; mapped to a component in the strip button. */
|
|
31
|
+
export type PromptIconKind = "pencil" | "paintbrush"
|
|
32
|
+
|
|
33
|
+
export interface PromptFieldSpec {
|
|
34
|
+
/** Data key holding the primary prompt (e.g. "prompt", "text", "customPrompt"). */
|
|
35
|
+
readonly prompt: string
|
|
36
|
+
/** Data key holding the negative prompt, when the node supports one. */
|
|
37
|
+
readonly negative?: string
|
|
38
|
+
/** Label override for the primary field (defaults to "Prompt"). */
|
|
39
|
+
readonly promptLabel?: string
|
|
40
|
+
/** Icon for the prompt affordance (strip button + modal title). Defaults to a
|
|
41
|
+
* pencil; image-editing nodes use a paintbrush to read as "edit". */
|
|
42
|
+
readonly icon?: PromptIconKind
|
|
43
|
+
/** Node modality for prompt-snippet scoping — drives which snippet pool the
|
|
44
|
+
* "/" menu and Snippets button show for this node's prompt fields. REQUIRED
|
|
45
|
+
* so a new node cannot forget to declare it (compile error). */
|
|
46
|
+
readonly media: SnippetMedia
|
|
47
|
+
/** True when this node renders a media-result preview body and therefore
|
|
48
|
+
* participates in inline-prompt mode (the `InlineNodePrompt` editor on the
|
|
49
|
+
* node face, centralized in `BaseNode`). REQUIRED (like `media`) so a new
|
|
50
|
+
* prompt node must consciously choose — compile error if omitted. The set of
|
|
51
|
+
* `inline: true` types is guarded in `prompt-fields.test.ts`. */
|
|
52
|
+
readonly inline: boolean
|
|
53
|
+
/** Some nodes only READ their prompt field when a sibling discriminator
|
|
54
|
+
* selects it — TTS keeps `directText` but resolves it only while
|
|
55
|
+
* `textSource === "direct"`, defaulting to `"connected"`. Writing the text
|
|
56
|
+
* without flipping the discriminator saved the value and then failed with
|
|
57
|
+
* "no text found" (founder hit it live, 2026-08-14). Declare the pair here
|
|
58
|
+
* and every writer flips it automatically. */
|
|
59
|
+
readonly promptGate?: { readonly field: string; readonly value: string }
|
|
60
|
+
/** Prompt pre/post text (`promptPrefix` / `promptSuffix`) support. Defaults to
|
|
61
|
+
* ON for every prompt node; set `false` ONLY for a node whose "prompt" is not
|
|
62
|
+
* a model prompt (the plain Text input node). Read via `nodeSupportsPromptAffixes`. */
|
|
63
|
+
readonly affixes?: false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const NODE_PROMPT_FIELDS: Readonly<Record<string, PromptFieldSpec>> = {
|
|
67
|
+
// ── Image ──
|
|
68
|
+
// (`edit-image` / `image-to-image` are legacy types consolidated into
|
|
69
|
+
// `modify-image`; they're not in the creatable node set, so a node of that
|
|
70
|
+
// type never mounts and needs no entry here. The guard test enforces that.)
|
|
71
|
+
"generate-image": { prompt: "prompt", negative: "negativePrompt", media: "image", inline: true },
|
|
72
|
+
"modify-image": { prompt: "prompt", negative: "negativePrompt", icon: "paintbrush", media: "image", inline: true },
|
|
73
|
+
"generate-mask": { prompt: "prompt", promptLabel: "What to mask", media: "image", inline: true },
|
|
74
|
+
// ── Video ──
|
|
75
|
+
"generate-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
76
|
+
// Trimmed multi-segment stitch sibling of generate-video — no negativePrompt field.
|
|
77
|
+
"generate-video-pro": { prompt: "prompt", media: "video", inline: true },
|
|
78
|
+
// Span-replace sibling of generate-video-pro — no negativePrompt field.
|
|
79
|
+
"edit-video-pro": { prompt: "prompt", media: "video", inline: true },
|
|
80
|
+
"text-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
81
|
+
"image-to-video": { prompt: "prompt", negative: "negativePrompt", promptLabel: "Motion prompt", media: "video", inline: true },
|
|
82
|
+
"video-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
83
|
+
"switchx": { prompt: "prompt", promptLabel: "Look prompt", media: "video", inline: true },
|
|
84
|
+
"extend-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
85
|
+
"speech-to-video": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
86
|
+
"motion-transfer": { prompt: "prompt", negative: "negativePrompt", media: "video", inline: true },
|
|
87
|
+
// Cinematic Avatar (HeyGen) — generative prompt (NOT a verbatim script),
|
|
88
|
+
// so it participates in the quick-edit Prompt modal like other AI video nodes.
|
|
89
|
+
"cinematic-avatar": { prompt: "prompt", media: "video", inline: true },
|
|
90
|
+
"video-sfx": { prompt: "prompt", negative: "negativePrompt", promptLabel: "Sound prompt", media: "audio", inline: true },
|
|
91
|
+
"video-retake": { prompt: "prompt", promptLabel: "Retake prompt", media: "video", inline: true },
|
|
92
|
+
// ── Audio / music ──
|
|
93
|
+
"generate-music": { prompt: "prompt", media: "audio", inline: true },
|
|
94
|
+
"suno-generate": { prompt: "prompt", media: "audio", inline: true },
|
|
95
|
+
"text-to-audio": { prompt: "prompt", media: "audio", inline: true },
|
|
96
|
+
// ── Text / LLM (no media-result preview body → no inline editor) ──
|
|
97
|
+
"text-prompt": { prompt: "text", promptLabel: "Text", media: "text", inline: false, affixes: false },
|
|
98
|
+
"image-to-text": { prompt: "customPrompt", promptLabel: "Question", media: "text", inline: false },
|
|
99
|
+
"llm-chat": { prompt: "userInput", promptLabel: "Prompt", media: "text", inline: false },
|
|
100
|
+
// ── Speech / voice ──
|
|
101
|
+
"text-to-speech": { prompt: "directText", promptLabel: "Text", media: "audio", inline: true, promptGate: { field: "textSource", value: "direct" } },
|
|
102
|
+
"voice-design": { prompt: "voiceDescription", promptLabel: "Voice description", media: "audio", inline: true },
|
|
103
|
+
"voice-remix": { prompt: "voiceDescription", promptLabel: "Voice description", media: "audio", inline: true },
|
|
104
|
+
"lip-sync": { prompt: "prompt", media: "audio", inline: true },
|
|
105
|
+
// ── Suno (music) ──
|
|
106
|
+
"suno-cover": { prompt: "prompt", media: "audio", inline: true },
|
|
107
|
+
"suno-extend": { prompt: "prompt", media: "audio", inline: true },
|
|
108
|
+
"suno-replace-section": { prompt: "prompt", media: "audio", inline: true },
|
|
109
|
+
"suno-upload-extend": { prompt: "prompt", media: "audio", inline: true },
|
|
110
|
+
// NOTE: `suno-add-vocals` is intentionally absent — its node has no
|
|
111
|
+
// user-editable prompt (SunoAddVocalsData declares only `model`; the config
|
|
112
|
+
// panel + backend route take taskId/audioId/model, never a prompt). A stale
|
|
113
|
+
// entry here rendered a phantom Prompt editor in the quick-edit modal that
|
|
114
|
+
// wrote to a `data.prompt` key nothing ever reads (the dead-field class the
|
|
115
|
+
// guard test now catches via defaultData ownership).
|
|
116
|
+
"suno-lyrics": { prompt: "prompt", media: "audio", inline: false },
|
|
117
|
+
"suno-style-boost": { prompt: "content", promptLabel: "Style", media: "audio", inline: false },
|
|
118
|
+
// ── Composition / FX (compact, no media-result preview → no inline editor) ──
|
|
119
|
+
"image-critic": { prompt: "prompt", promptLabel: "Criteria", media: "image", inline: false },
|
|
120
|
+
"motion-graphics": { prompt: "motionPrompt", promptLabel: "Motion prompt", media: "video", inline: false },
|
|
121
|
+
"3d-title": { prompt: "titlePrompt", promptLabel: "Title", media: "text", inline: false },
|
|
122
|
+
// ── Script / alignment (their primary text field) ──
|
|
123
|
+
"generate-script": { prompt: "styleGuide", promptLabel: "Style guide", media: "text", inline: false },
|
|
124
|
+
"forced-alignment": { prompt: "transcript", promptLabel: "Transcript", media: "audio", inline: false },
|
|
125
|
+
// Video Analysis — its focus hint is the editable prompt (renders a JSON scene
|
|
126
|
+
// table, not a media preview → no inline editor).
|
|
127
|
+
"video-analysis": { prompt: "analysisFocus", promptLabel: "Analysis focus", media: "video", inline: false },
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The prompt-field spec for a node type, or undefined if it has none. */
|
|
131
|
+
export function getPromptFields(nodeType: string | undefined): PromptFieldSpec | undefined {
|
|
132
|
+
return nodeType ? NODE_PROMPT_FIELDS[nodeType] : undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True when this node type has a registered, quick-editable prompt field. */
|
|
136
|
+
export function nodeHasPromptField(nodeType: string | undefined): boolean {
|
|
137
|
+
return getPromptFields(nodeType) !== undefined
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Snippet modality for a node type (drives the snippet pool), or undefined
|
|
141
|
+
* when the node has no prompt field. */
|
|
142
|
+
export function getSnippetMedia(nodeType: string | undefined): SnippetMedia | undefined {
|
|
143
|
+
return getPromptFields(nodeType)?.media
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True when this node type renders the inline on-node prompt editor — the
|
|
147
|
+
* media-preview nodes (image/video/audio result body). Single source for
|
|
148
|
+
* BaseNode's centralized `InlineNodePrompt` rendering and the gold nodes'
|
|
149
|
+
* `showInline` derivation (`useInlinePromptActive`). */
|
|
150
|
+
export function nodeHasInlinePrompt(nodeType: string | undefined): boolean {
|
|
151
|
+
return getPromptFields(nodeType)?.inline === true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when the node type carries `promptPrefix` / `promptSuffix` — every
|
|
155
|
+
* registered prompt node except explicit opt-outs (`affixes: false`). The single
|
|
156
|
+
* predicate the config panel, Final view, `/v1/nodes` registry, totality tests
|
|
157
|
+
* and docs tooling all gate on. */
|
|
158
|
+
export function nodeSupportsPromptAffixes(nodeType: string | undefined): boolean {
|
|
159
|
+
const spec = getPromptFields(nodeType)
|
|
160
|
+
return spec !== undefined && spec.affixes !== false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where the RUN-TIME prompt lives when it differs from the editor's prompt
|
|
165
|
+
* field (spec §7). `generate-script`'s editable prompt is its `styleGuide`, but
|
|
166
|
+
* the run wraps the topic `prompt` with the affixes — so previewing the style
|
|
167
|
+
* guide must NOT show them.
|
|
168
|
+
*/
|
|
169
|
+
export const PROMPT_AFFIX_CORE_FIELD_OVERRIDES: Readonly<Record<string, string>> = { "generate-script": "prompt" }
|
|
170
|
+
|
|
171
|
+
/** The data key whose value the run wraps with promptPrefix/promptSuffix, or
|
|
172
|
+
* undefined when the node has no affixes. */
|
|
173
|
+
export function promptAffixCoreField(nodeType: string | undefined): string | undefined {
|
|
174
|
+
if (!nodeSupportsPromptAffixes(nodeType)) return undefined
|
|
175
|
+
return PROMPT_AFFIX_CORE_FIELD_OVERRIDES[nodeType!] ?? getPromptFields(nodeType)!.prompt
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when a preview of `promptField` on this node must show the affixes —
|
|
180
|
+
* i.e. that key is what the run actually wraps. THE gate for the editor's Final
|
|
181
|
+
* view, so a sibling field (llm-chat's `systemPrompt`, generate-script's
|
|
182
|
+
* `styleGuide`) never renders a wrap the run doesn't perform.
|
|
183
|
+
*
|
|
184
|
+
* Two ways a key qualifies:
|
|
185
|
+
* - it IS the core field ({@link promptAffixCoreField}), or
|
|
186
|
+
* - it is a run-time FALLBACK candidate for the type
|
|
187
|
+
* ({@link NODE_PROMPT_CANDIDATE_FIELDS}) — `computeNodePrompt` picks the
|
|
188
|
+
* first present candidate and wraps THAT, so i2v's legacy `motionPrompt`
|
|
189
|
+
* genuinely receives the affixes when `data.prompt` is empty.
|
|
190
|
+
*
|
|
191
|
+
* The two rules never collide: no override type declares candidates (guarded in
|
|
192
|
+
* `node-prompt-fields.test.ts`).
|
|
193
|
+
*
|
|
194
|
+
* `undefined` means "the node's primary prompt field" — the common call.
|
|
195
|
+
*/
|
|
196
|
+
export function promptFieldCarriesAffixes(nodeType: string | undefined, promptField: string | undefined): boolean {
|
|
197
|
+
const core = promptAffixCoreField(nodeType)
|
|
198
|
+
if (core === undefined) return false
|
|
199
|
+
if (promptField === undefined) return getPromptFields(nodeType)!.prompt === core
|
|
200
|
+
return promptField === core || (NODE_PROMPT_CANDIDATE_FIELDS[nodeType!] ?? []).includes(promptField)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The affix-capable node types, derived from the registry (never hand-listed). */
|
|
204
|
+
export const PROMPT_AFFIX_NODE_TYPES: ReadonlySet<string> = new Set(
|
|
205
|
+
Object.entries(NODE_PROMPT_FIELDS).filter(([, s]) => s.affixes !== false).map(([t]) => t),
|
|
206
|
+
)
|
package/src/picker-catalogs.ts
CHANGED
|
@@ -51,8 +51,22 @@ import { POST_PROCESS_EFFECTS } from "./post-process-effects.js"
|
|
|
51
51
|
import { CAMERA_MOTIONS, CAMERA_MOTION_CATEGORY_LABELS, CAMERA_MOTION_CATEGORY_ORDER } from "./camera-motions.js"
|
|
52
52
|
import { LENSES } from "./lens.js"
|
|
53
53
|
import { CAMERA_FORMATS } from "./camera-format.js"
|
|
54
|
-
import {
|
|
55
|
-
|
|
54
|
+
import {
|
|
55
|
+
TRANSITIONS,
|
|
56
|
+
TRANSITION_CATEGORY_LABELS,
|
|
57
|
+
TRANSITION_CATEGORY_ORDER,
|
|
58
|
+
TRANSITION_POSITIONS,
|
|
59
|
+
TRANSITION_DURATIONS,
|
|
60
|
+
TRANSITION_INTENSITIES,
|
|
61
|
+
} from "./transitions.js"
|
|
62
|
+
import {
|
|
63
|
+
CHARACTER_FX,
|
|
64
|
+
CHARACTER_FX_CATEGORY_LABELS,
|
|
65
|
+
CHARACTER_FX_CATEGORY_ORDER,
|
|
66
|
+
CHARACTER_FX_POSITIONS,
|
|
67
|
+
CHARACTER_FX_DURATIONS,
|
|
68
|
+
CHARACTER_FX_INTENSITIES,
|
|
69
|
+
} from "./character-fx.js"
|
|
56
70
|
import { POSES, POSE_CATEGORY_LABELS, POSE_CATEGORY_ORDER } from "./pose.js"
|
|
57
71
|
import { MATERIALS, MATERIAL_CATEGORY_LABELS, MATERIAL_CATEGORY_ORDER } from "./materials.js"
|
|
58
72
|
import { ANIMALS, ANIMAL_SUBCATEGORY_LABELS, ANIMAL_SUBCATEGORY_ORDER } from "@nodaro/shared"
|
|
@@ -127,7 +141,10 @@ export interface PickerCatalog {
|
|
|
127
141
|
readonly options?: readonly PickerOption[]
|
|
128
142
|
/** multi-dim: the dimension keys (no single catalog to flatten). */
|
|
129
143
|
readonly fields?: readonly string[]
|
|
130
|
-
/**
|
|
144
|
+
/** Per-field option lists. Multi-dim catalogs always carry these, one per
|
|
145
|
+
* `fields` entry in order; a single-dim catalog carries them when it has
|
|
146
|
+
* secondary parameter fields beside its main picker (transition
|
|
147
|
+
* position/duration/intensity). */
|
|
131
148
|
readonly dimensions?: readonly PickerDimension[]
|
|
132
149
|
}
|
|
133
150
|
|
|
@@ -447,7 +464,10 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
|
|
|
447
464
|
catalogId: "composition-effects",
|
|
448
465
|
kind: "single",
|
|
449
466
|
valueField: "compositionEffect",
|
|
450
|
-
|
|
467
|
+
// No default effect — composition effects are dramatic subject transforms,
|
|
468
|
+
// so an unset node injects nothing until the user picks one (matches the
|
|
469
|
+
// "unset folds nothing" behaviour every consumer already honours).
|
|
470
|
+
defaultValue: "none",
|
|
451
471
|
options: toOptions(COMPOSITION_EFFECTS),
|
|
452
472
|
},
|
|
453
473
|
{
|
|
@@ -522,6 +542,17 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
|
|
|
522
542
|
categoryOrder: TRANSITION_CATEGORY_ORDER,
|
|
523
543
|
categoryLabels: TRANSITION_CATEGORY_LABELS,
|
|
524
544
|
options: toOptions(TRANSITIONS, "category"),
|
|
545
|
+
// The node's three timing parameters, alongside the transition itself.
|
|
546
|
+
// `dimensions` on a single-dim catalog carries exactly what it says on a
|
|
547
|
+
// multi-dim one — extra value fields with their own option lists — so a
|
|
548
|
+
// consumer that only sends ids can offer Position/Duration/Intensity
|
|
549
|
+
// without composing the clauses itself. The rich picker UI still renders
|
|
550
|
+
// only `options`; these fields are secondary controls beside it.
|
|
551
|
+
dimensions: perFieldDims([
|
|
552
|
+
["position", TRANSITION_POSITIONS],
|
|
553
|
+
["duration", TRANSITION_DURATIONS],
|
|
554
|
+
["intensity", TRANSITION_INTENSITIES],
|
|
555
|
+
]),
|
|
525
556
|
},
|
|
526
557
|
{
|
|
527
558
|
nodeType: "character-fx",
|
|
@@ -533,6 +564,16 @@ const SINGLE_CATALOGS: readonly PickerCatalog[] = [
|
|
|
533
564
|
categoryOrder: CHARACTER_FX_CATEGORY_ORDER,
|
|
534
565
|
categoryLabels: CHARACTER_FX_CATEGORY_LABELS,
|
|
535
566
|
options: toOptions(CHARACTER_FX, "category"),
|
|
567
|
+
// The node's three timing parameters, alongside the effect itself — the
|
|
568
|
+
// same shape `transition` carries above, but the character-fx scales, not
|
|
569
|
+
// the transition ones: the wording is deliberately different (an effect
|
|
570
|
+
// manifests and persists; a transition occurs and spans), so an id-only
|
|
571
|
+
// consumer must read these rows, never reuse the transition rows.
|
|
572
|
+
dimensions: perFieldDims([
|
|
573
|
+
["position", CHARACTER_FX_POSITIONS],
|
|
574
|
+
["duration", CHARACTER_FX_DURATIONS],
|
|
575
|
+
["intensity", CHARACTER_FX_INTENSITIES],
|
|
576
|
+
]),
|
|
536
577
|
},
|
|
537
578
|
|
|
538
579
|
// -------- "Subject / Object" family --------
|
|
@@ -816,10 +857,11 @@ export function summarizePickerCatalogs(): readonly PickerCatalogSummary[] {
|
|
|
816
857
|
kind: c.kind,
|
|
817
858
|
valueField: c.valueField,
|
|
818
859
|
fields: c.fields,
|
|
860
|
+
// Every option the detail call will return, whichever kind — a single-dim
|
|
861
|
+
// catalog with secondary dimensions carries both.
|
|
819
862
|
optionCount:
|
|
820
|
-
c.
|
|
821
|
-
|
|
822
|
-
: (c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
|
|
863
|
+
(c.options?.length ?? 0) +
|
|
864
|
+
(c.dimensions?.reduce((n, d) => n + d.options.length, 0) ?? 0),
|
|
823
865
|
}))
|
|
824
866
|
}
|
|
825
867
|
|
|
@@ -876,22 +918,29 @@ export function projectPickerCatalog(
|
|
|
876
918
|
categoryLabels: c.categoryLabels,
|
|
877
919
|
detail,
|
|
878
920
|
}
|
|
921
|
+
const projectDims = (dims: readonly PickerDimension[]) =>
|
|
922
|
+
dims.map((d) => ({
|
|
923
|
+
field: d.field,
|
|
924
|
+
label: d.label,
|
|
925
|
+
options: d.options.map((o) => projectOption(o, detail)),
|
|
926
|
+
}))
|
|
927
|
+
|
|
879
928
|
if (c.kind === "single") {
|
|
880
929
|
let options = c.options ?? []
|
|
881
930
|
if (opts.category) options = options.filter((o) => o.category === opts.category)
|
|
882
|
-
|
|
931
|
+
const projected = { ...base, options: options.map((o) => projectOption(o, detail)) }
|
|
932
|
+
// A single-dim catalog may still carry secondary parameter dimensions
|
|
933
|
+
// (transition's position/duration/intensity). They are additive — a
|
|
934
|
+
// consumer reading only `options` is unaffected — but dropping them here
|
|
935
|
+
// would hide the one thing an id-only client needs to offer those controls.
|
|
936
|
+
if (!c.dimensions) return projected
|
|
937
|
+
let dims = c.dimensions
|
|
938
|
+
if (opts.field) dims = dims.filter((d) => d.field === opts.field)
|
|
939
|
+
return { ...projected, dimensions: projectDims(dims) }
|
|
883
940
|
}
|
|
884
941
|
let dims = c.dimensions ?? []
|
|
885
942
|
if (opts.field) dims = dims.filter((d) => d.field === opts.field)
|
|
886
|
-
return {
|
|
887
|
-
...base,
|
|
888
|
-
fields: c.fields,
|
|
889
|
-
dimensions: dims.map((d) => ({
|
|
890
|
-
field: d.field,
|
|
891
|
-
label: d.label,
|
|
892
|
-
options: d.options.map((o) => projectOption(o, detail)),
|
|
893
|
-
})),
|
|
894
|
-
}
|
|
943
|
+
return { ...base, fields: c.fields, dimensions: projectDims(dims) }
|
|
895
944
|
}
|
|
896
945
|
|
|
897
946
|
/**
|
package/src/picker-wiring.ts
CHANGED
|
@@ -147,7 +147,8 @@ export const SINGLE_PICKER_WIRING: ReadonlyArray<SingleDimPickerWiring> = [
|
|
|
147
147
|
{ kind: "single", nodeType: "photo-genre", label: "Photo Genre", valueField: "photoGenre", defaultValue: "fashion-editorial", catalogId: "photo-genre", entries: mapCat(PHOTO_GENRES, "category"), groupOrder: PHOTO_GENRE_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: PHOTO_GENRE_CATEGORY_LABELS },
|
|
148
148
|
{ kind: "single", nodeType: "backdrop", label: "Backdrop", valueField: "backdrop", defaultValue: "white-seamless", catalogId: "backdrop", entries: mapCat(BACKDROPS, "category"), groupOrder: BACKDROP_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: BACKDROP_CATEGORY_LABELS },
|
|
149
149
|
{ kind: "single", nodeType: "render-quality", label: "Render Quality", valueField: "renderQuality", defaultValue: "raytracing", catalogId: "render-quality", entries: mapCat(RENDER_QUALITIES) },
|
|
150
|
-
|
|
150
|
+
// No default effect — see the note in picker-catalogs.ts.
|
|
151
|
+
{ kind: "single", nodeType: "composition-effects", label: "Composition Effect", valueField: "compositionEffect", defaultValue: "none", catalogId: "composition-effects", entries: mapCat(COMPOSITION_EFFECTS) },
|
|
151
152
|
{ kind: "single", nodeType: "action-fx", label: "Action FX", valueField: "actionFx", defaultValue: "earthquake-tremor", catalogId: "action-fx", entries: mapCat(ACTION_FX, "category"), groupOrder: ACTION_FX_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: ACTION_FX_CATEGORY_LABELS as Record<string, string> },
|
|
152
153
|
{ kind: "single", nodeType: "loop-subject", label: "Loop Subject", valueField: "loopSubject", defaultValue: "tunnel", catalogId: "loop-subject", entries: mapCat(LOOP_SUBJECTS, "category"), groupOrder: LOOP_SUBJECT_CATEGORY_ORDER as ReadonlyArray<string>, groupLabels: LOOP_SUBJECT_CATEGORY_LABELS as Record<string, string> },
|
|
153
154
|
{ kind: "single", nodeType: "post-process-effects", label: "Post-Process Effect", valueField: "postProcess", defaultValue: "vignette-soft", catalogId: "post-process-effects", entries: mapCat(POST_PROCESS_EFFECTS) },
|
|
@@ -73,7 +73,7 @@ precise subject → action details → scene/environment → lighting & color to
|
|
|
73
73
|
- Transitions and camera terms on 2.5: state a transition's trigger point AND method in one sentence — "At the 5-second mark, the camera quickly transitions leftward using a left wipe combined with a natural dissolve." Basic shot and camera terms are written directly (push in / pull out / pan / track / orbit / dolly zoom / whip pan / hard cut / dissolve / one-shot / speed ramp); only niche terms need [term + descriptive explanation] — which is exactly what the pickers' compact hint mode emits versus their long hints.
|
|
74
74
|
|
|
75
75
|
**References (when reference media is attached)**
|
|
76
|
-
- Refer to assets by ordinal in attachment order: "@Image 1", "Video 2", "Audio 1". Asset ORDER is priority — put the most identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding — \`{image:1:person}\` resolves to "the person from @image_1" — so a wired reference and its mention stay in sync.)
|
|
76
|
+
- Refer to assets by ordinal in attachment order: "@Image 1", "Video 2", "Audio 1". Asset ORDER is priority — put the most identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding — \`{image:1:person}\` resolves to "the person from @image_1" — so a wired reference and its mention stay in sync. An API caller that passes \`connectedReferences\` can instead name a reference by its own id — \`{ref:<id>}\` / \`{ref:<id>:label}\` — and the platform substitutes the \`@image_N\` seat after it has numbered the references, so the client never computes N; a token whose reference was not attached drops to its label or name.)
|
|
77
77
|
- Define each subject once, then reuse the label consistently: 'Define the woman in the red dress in Image 1 as the courier' … 'the courier opens the door'. In multi-character scenes bind every character to its image ("the man from Image 1 hands the box to the woman from Image 2") and append: "do not generate duplicate copies of the same character".
|
|
78
78
|
- Character identity: ONE close-up headshot + ONE full-body image is ideal. On the 2.0 SKUs do NOT attach multi-view/three-view character sheets — the model reads the views as separate people, causing identity drift and twin duplicates; 2.5 accepts multi-view images (see "Generation differences").
|
|
79
79
|
- 4-5 assets total works best (1-2 character images + 1 scene image + 1 camera-movement video + 1 audio clip). Maxing out the 9-image/3-video/3-audio limits degrades feature priority and adherence.
|
|
@@ -177,7 +177,7 @@ precise subject → action details → scene/environment → lighting & color to
|
|
|
177
177
|
- Nothing visual connected → text-to-video. A concrete aspect ratio is required (21:9 / 16:9 / 4:3 / 1:1 / 3:4 / 9:16 — no adaptive); Nodaro renders 16:9 unless one is picked.
|
|
178
178
|
|
|
179
179
|
**References (when reference media is attached)**
|
|
180
|
-
- Refer to assets by ordinal in attachment order: "@Image 1", "Video 1", "Audio 1". Put the identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding, so a wired reference and its mention stay in sync.)
|
|
180
|
+
- Refer to assets by ordinal in attachment order: "@Image 1", "Video 1", "Audio 1". Put the identity-critical asset first. (In the editor, the \`{image:N:label}\` / \`{video:N}\` / \`{audio:N}\` prompt tokens auto-emit this binding, so a wired reference and its mention stay in sync. An API caller that passes \`connectedReferences\` can instead write \`{ref:<id>}\` / \`{ref:<id>:label}\` with the reference's own id — the platform substitutes the \`@image_N\` seat after numbering.)
|
|
181
181
|
- Caps: 9 reference images; 3 reference videos, each 2-15s and ≤15s combined; 3 reference audio clips, ≤15s combined. Reference audio cannot be used alone — it must accompany an image or video reference.
|
|
182
182
|
- Define each subject once, then reuse the label consistently ("the woman from @Image 1 … the woman opens the door"). A focused set of 4-5 assets beats maxing every cap.
|
|
183
183
|
- Billing note: generated seconds AND reference-video input seconds bill at the same per-second rate; the first 5 input images are free and each extra image adds a small surcharge; audio input is free.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference-binding surface string — `@image_N` / `@video_N` / `@audio_N`
|
|
3
|
+
* — and the one identity sentence built on it. Split out of
|
|
4
|
+
* `video-reference-resolver.ts` so the id-addressed token resolver
|
|
5
|
+
* (`ref-id-tokens.ts`) can bind through the same arrows without a module
|
|
6
|
+
* cycle; the resolver re-exports both, so importers are unaffected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The SINGLE swap-point for the reference-binding surface-string (design D1/D7).
|
|
11
|
+
*
|
|
12
|
+
* Every place that renders an `@image_N`-style binding into a video prompt — the
|
|
13
|
+
* per-image subject phrasing, the bare ordinal in a "Use these characters" /
|
|
14
|
+
* pair-back bullet, and the opening/closing frame directive — MUST go through
|
|
15
|
+
* these five arrows. The default form is `@image_N`; if the D7 probe shows a
|
|
16
|
+
* provider prefers the legacy `Image N` form, flipping is editing ONLY these five
|
|
17
|
+
* arrows (`@image_${n}` → `Image ${n}`), nothing downstream.
|
|
18
|
+
*
|
|
19
|
+
* This IS the live swap-point: `resolveVideoReferenceCore` routes the per-image
|
|
20
|
+
* subject phrasing, the "Use these characters" / pair-back bullet ordinals, and
|
|
21
|
+
* the frame directive through these arrows, and `resolveReferenceTokens` resolves
|
|
22
|
+
* the body `{image:N}` tokens through `REF_BINDING[kind]` — so the five arrows
|
|
23
|
+
* are the ONLY emission sites for the binding surface string.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The identity-reference binding sentence shared by the flat-image-list
|
|
27
|
+
* resolvers (gemini-omni, veo i2v): names the ordinal span as identities and
|
|
28
|
+
* says the two things a multimodal model needs to hear — match exactly, and
|
|
29
|
+
* these are not frames. One spelling; both resolvers ride it.
|
|
30
|
+
*/
|
|
31
|
+
export function identityRefsSentence(firstOrdinal: number, lastOrdinal: number): string {
|
|
32
|
+
return firstOrdinal === lastOrdinal
|
|
33
|
+
? `${REF_BINDING.ordinal(firstOrdinal)} is an identity reference for this shot's subjects — match its subject's exact appearance; it is not a frame.`
|
|
34
|
+
: `${REF_BINDING.ordinal(firstOrdinal)} through ${REF_BINDING.ordinal(lastOrdinal)} are identity references for this shot's subjects — match each subject's exact appearance; they are not frames.`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const REF_BINDING = {
|
|
38
|
+
image: (label: string, n: number) => `the ${label} from @image_${n}`,
|
|
39
|
+
video: (label: string, n: number) => `the ${label} from @video_${n}`,
|
|
40
|
+
audio: (label: string, n: number) => `the ${label} from @audio_${n}`,
|
|
41
|
+
/** ordinal as it appears in a "Use these characters" bullet / pair-back */
|
|
42
|
+
ordinal: (n: number) => `@image_${n}`,
|
|
43
|
+
frame: (n: number, role: "opening" | "closing") =>
|
|
44
|
+
`Use @image_${n} as the ${role} (${role === "opening" ? "first" : "last"}) frame of the video.`,
|
|
45
|
+
} as const
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `{ref:<id>}` / `{ref:<id>:<label>}` — id-addressed reference tokens.
|
|
3
|
+
*
|
|
4
|
+
* The API/Studio form of the positional `{image:N}` token: the client names a
|
|
5
|
+
* reference by its OWN `connectedReferences[].id` and the platform substitutes
|
|
6
|
+
* the `@image_N` seat after IT has done the numbering. Without it a client that
|
|
7
|
+
* wanted the binding inline had to mirror the numbering walk client-side — a
|
|
8
|
+
* duplicated rule that misbinds pictures the moment the walk changes.
|
|
9
|
+
*
|
|
10
|
+
* `resolveVideoReferenceCore` builds the `RefIdTokenContext` DURING its walk
|
|
11
|
+
* and calls `resolveRefIdTokens` before the `referenceOrder` reorder (so the
|
|
12
|
+
* binding follows the reference to its final seat); the video routes call it
|
|
13
|
+
* standalone on their no-image-reference early return (nothing seated, so
|
|
14
|
+
* every token degrades).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { REF_BINDING } from "./ref-binding.js"
|
|
18
|
+
|
|
19
|
+
/** The label class of `REFERENCE_TOKEN_RE` (`{image:N:label}`), shared. */
|
|
20
|
+
const REF_TOKEN_LABEL_RE = /^[a-zA-Z0-9_ -]+$/
|
|
21
|
+
/** Cheap gate for the whole pass — a prompt without it is untouched. */
|
|
22
|
+
const HAS_REF_ID_TOKEN_RE = /\{ref:/i
|
|
23
|
+
/**
|
|
24
|
+
* One well-formed `{ref:…}` token: everything between `{ref:` and the next
|
|
25
|
+
* `}` that contains no brace. Greedy over a brace-free class, so the scan is
|
|
26
|
+
* LINEAR in the prompt length whatever the content — `prompt` is up to
|
|
27
|
+
* `PROMPT_HARD_CEILING` (30k) chars of caller-controlled text, so a lazy
|
|
28
|
+
* quantifier with a nested optional label group here would be a quadratic-time
|
|
29
|
+
* ReDoS surface. The id / label split happens in code (`splitLabel`), not in
|
|
30
|
+
* the regex. `ref` is case-insensitive; ids are not.
|
|
31
|
+
*/
|
|
32
|
+
const REF_ID_TOKEN_RE = /\{[rR][eE][fF]:([^{}]*)\}/g
|
|
33
|
+
/**
|
|
34
|
+
* Last-resort net for a MALFORMED `{ref:` (a brace inside the id, or no
|
|
35
|
+
* closing `}`): drop the `{ref:` run up to the next whitespace or brace, so
|
|
36
|
+
* the prefix can never reach a model, without eating prose past the token.
|
|
37
|
+
*/
|
|
38
|
+
const MALFORMED_REF_ID_TOKEN_RE = /\{[rR][eE][fF]:[^\s{}]*\}?/g
|
|
39
|
+
|
|
40
|
+
/** What `resolveRefIdTokens` resolves against — the numbering walk's output. */
|
|
41
|
+
export interface RefIdTokenContext {
|
|
42
|
+
/** Reference id → the 1-based `@image_N` seat the walk gave it. */
|
|
43
|
+
readonly slotById: ReadonlyMap<string, number>
|
|
44
|
+
/** Reference id → display name, the degrade target of a token that cannot bind. */
|
|
45
|
+
readonly nameById: ReadonlyMap<string, string>
|
|
46
|
+
/**
|
|
47
|
+
* How many image references actually ship — the same range gate `{image:N}`
|
|
48
|
+
* uses. A seat past it (a capped-out or duplicate-URL ref) must not bind.
|
|
49
|
+
*/
|
|
50
|
+
readonly imageCount: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split a token's content into `<id>` and an optional `<label>` at the LAST
|
|
55
|
+
* colon — only when the tail is a well-formed label. Ids are opaque and may
|
|
56
|
+
* themselves contain `:` (`slug:variant`) or `/` (a URL), so nothing before
|
|
57
|
+
* the last colon is ever interpreted.
|
|
58
|
+
*/
|
|
59
|
+
function splitLabel(content: string): { id: string; label?: string } {
|
|
60
|
+
const at = content.lastIndexOf(":")
|
|
61
|
+
if (at === -1) return { id: content }
|
|
62
|
+
const tail = content.slice(at + 1)
|
|
63
|
+
if (!REF_TOKEN_LABEL_RE.test(tail)) return { id: content }
|
|
64
|
+
return { id: content.slice(0, at), label: tail }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Rewrite id-addressed reference tokens into the `@image_N` binding of the
|
|
69
|
+
* reference the caller sent under that id.
|
|
70
|
+
*
|
|
71
|
+
* Ids are matched by IDENTITY against the known ids (seated or named), never
|
|
72
|
+
* parsed by character class: the whole content is tried as an id first (the
|
|
73
|
+
* longest reading — an id may itself end in something label-shaped), then
|
|
74
|
+
* `<id>:<label>` split at the last colon, then the token is unknown. The label
|
|
75
|
+
* class is the one `REFERENCE_TOKEN_RE` uses. An id containing `{`, `}`, an
|
|
76
|
+
* `@name:N` mention or a `{image:N}` token is unsupported (the mention pass
|
|
77
|
+
* runs first and would rewrite it; a brace ends the token).
|
|
78
|
+
*
|
|
79
|
+
* Per token:
|
|
80
|
+
* - id seated in range → `REF_BINDING.image(label, N)` when labeled, else the
|
|
81
|
+
* bare `REF_BINDING.ordinal(N)` — exactly what `{image:N[:label]}` emits.
|
|
82
|
+
* - otherwise (unknown id, ref skipped by the walk, capped out, or no image
|
|
83
|
+
* references at all) → the label if given, else the ref's display name if
|
|
84
|
+
* the id is known, else "". A token never ships raw — a malformed one is
|
|
85
|
+
* dropped by the last-resort net.
|
|
86
|
+
*
|
|
87
|
+
* No whitespace tidy here: every caller runs `resolveReferenceTokens` after
|
|
88
|
+
* this (the core does at every return), and that collapses the gap a dropped
|
|
89
|
+
* token leaves. Returns the input untouched when it carries no `{ref:` at all.
|
|
90
|
+
*/
|
|
91
|
+
export function resolveRefIdTokens(
|
|
92
|
+
prompt: string | undefined,
|
|
93
|
+
ctx: RefIdTokenContext,
|
|
94
|
+
): string | undefined {
|
|
95
|
+
if (!prompt || !HAS_REF_ID_TOKEN_RE.test(prompt)) return prompt
|
|
96
|
+
const known = (id: string): boolean => id.length > 0 && (ctx.slotById.has(id) || ctx.nameById.has(id))
|
|
97
|
+
const bind = (id: string, label: string | undefined): string => {
|
|
98
|
+
const slot = ctx.slotById.get(id)
|
|
99
|
+
if (slot !== undefined && slot >= 1 && slot <= ctx.imageCount) {
|
|
100
|
+
return label ? REF_BINDING.image(label, slot) : REF_BINDING.ordinal(slot)
|
|
101
|
+
}
|
|
102
|
+
return label ?? ctx.nameById.get(id) ?? ""
|
|
103
|
+
}
|
|
104
|
+
return prompt
|
|
105
|
+
.replace(REF_ID_TOKEN_RE, (_match, content: string) => {
|
|
106
|
+
if (known(content)) return bind(content, undefined)
|
|
107
|
+
const { id, label } = splitLabel(content)
|
|
108
|
+
if (known(id)) return bind(id, label)
|
|
109
|
+
return label ?? ""
|
|
110
|
+
})
|
|
111
|
+
.replace(MALFORMED_REF_ID_TOKEN_RE, "")
|
|
112
|
+
}
|
package/src/resolve-prompt.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveNodeRefs } from "@nodaro/shared"
|
|
1
|
+
import { resolveNodeRefs, readPromptAffixes, type PromptAffixes } from "@nodaro/shared"
|
|
2
2
|
import { SOCIAL_POST_NODE_TYPES } from "@nodaro/shared"
|
|
3
3
|
|
|
4
4
|
export interface ResolvePromptArgs {
|
|
@@ -13,16 +13,71 @@ export interface ResolvePromptArgs {
|
|
|
13
13
|
* Off/undefined = exact legacy precedence (override > typed > wired) for every
|
|
14
14
|
* other node type. */
|
|
15
15
|
appendWired?: boolean
|
|
16
|
+
/** Pre/post text wrapped around WHICHEVER core precedence produced (override,
|
|
17
|
+
* typed, wired, or the appendWired combination). Applied last. */
|
|
18
|
+
affixes?: PromptAffixes
|
|
16
19
|
}
|
|
17
20
|
const present = (s?: string): s is string => typeof s === "string" && s.trim().length > 0
|
|
18
21
|
const rr = (s: string, m: ReadonlyMap<string, string>) => (m.size > 0 ? resolveNodeRefs(s, m) : s)
|
|
19
22
|
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Prompt affixes (pre/post text) — see docs/prompt-pre-post-text.md (join rule, empty-core, no-op guarantee)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const GLUE_PUNCTUATION = new Set([",", ".", ";", ":", "!", "?", ")"])
|
|
28
|
+
|
|
29
|
+
/** The ONE join rule between two non-blank prompt parts: a single space unless
|
|
30
|
+
* the boundary already has whitespace on either side or the right part opens
|
|
31
|
+
* with sentence punctuation. Shared by `joinPromptParts` and the editor's
|
|
32
|
+
* Final-view segment builder so preview and run agree byte-for-byte. */
|
|
33
|
+
export function promptPartSeparator(left: string, right: string): "" | " " {
|
|
34
|
+
if (left.length === 0 || right.length === 0) return ""
|
|
35
|
+
if (/\s$/.test(left) || /^\s/.test(right)) return ""
|
|
36
|
+
if (GLUE_PUNCTUATION.has(right[0])) return ""
|
|
37
|
+
return " "
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Join prompt parts with `promptPartSeparator`; blank parts are dropped,
|
|
41
|
+
* non-blank parts are used verbatim (no trimming). */
|
|
42
|
+
export function joinPromptParts(parts: ReadonlyArray<string | undefined>): string {
|
|
43
|
+
let out = ""
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
if (!present(part)) continue
|
|
46
|
+
out = out.length === 0 ? part : out + promptPartSeparator(out, part) + part
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wrap `core` with the node's pre/post text. `{Label}` refs are resolved in the
|
|
53
|
+
* AFFIXES only — the core arrives exactly as the caller's precedence produced it.
|
|
54
|
+
* NO-OP GUARANTEE: with no non-blank affix the core is returned unchanged (same
|
|
55
|
+
* reference) — every existing workflow stays byte-identical.
|
|
56
|
+
* EMPTY CORE: a blank core with affixes yields the joined affixes alone.
|
|
57
|
+
*/
|
|
58
|
+
export function applyPromptAffixes(core: string, affixes: PromptAffixes | undefined, refMap: ReadonlyMap<string, string>): string
|
|
59
|
+
export function applyPromptAffixes(core: string | undefined, affixes: PromptAffixes | undefined, refMap: ReadonlyMap<string, string>): string | undefined
|
|
60
|
+
export function applyPromptAffixes(
|
|
61
|
+
core: string | undefined,
|
|
62
|
+
affixes: PromptAffixes | undefined,
|
|
63
|
+
refMap: ReadonlyMap<string, string>,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const prefix = present(affixes?.prefix) ? rr(affixes!.prefix!, refMap) : undefined
|
|
66
|
+
const suffix = present(affixes?.suffix) ? rr(affixes!.suffix!, refMap) : undefined
|
|
67
|
+
if (prefix === undefined && suffix === undefined) return core
|
|
68
|
+
return joinPromptParts([prefix, core, suffix])
|
|
69
|
+
}
|
|
70
|
+
|
|
20
71
|
/** SINGLE SOURCE OF TRUTH for prompt precedence across both DAG engines:
|
|
21
72
|
* override (list fan-out) > first present typed candidate > wired > "".
|
|
22
73
|
* "present" = non-empty after trim. {Label} refs are resolved on the chosen
|
|
23
74
|
* branch via the shared resolveNodeRefs. With `appendWired`, the chosen base
|
|
24
|
-
* AND the wired value are both emitted (joined ". "). */
|
|
25
|
-
export function resolvePrompt({ override, typed = [], wired, refMap, appendWired }: ResolvePromptArgs): string {
|
|
75
|
+
* AND the wired value are both emitted (joined ". "). Affixes wrap the result. */
|
|
76
|
+
export function resolvePrompt({ override, typed = [], wired, refMap, appendWired, affixes }: ResolvePromptArgs): string {
|
|
77
|
+
return applyPromptAffixes(resolveCore({ override, typed, wired, refMap, appendWired }), affixes, refMap)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveCore({ override, typed = [], wired, refMap, appendWired }: Omit<ResolvePromptArgs, "affixes">): string {
|
|
26
81
|
// appendWired: a connected prompt APPENDS to the TYPED base. An `override`
|
|
27
82
|
// (list fan-out item) still fully REPLACES — it never receives a wired append,
|
|
28
83
|
// so per-item fan-out prompts are unchanged.
|
|
@@ -110,7 +165,7 @@ export function computeNodePrompt(
|
|
|
110
165
|
const fields = NODE_PROMPT_CANDIDATE_FIELDS[nodeType] ?? ["prompt"]
|
|
111
166
|
typed = fields.map((f) => data[f] as string | undefined)
|
|
112
167
|
}
|
|
113
|
-
return resolvePrompt({ override, typed, wired, refMap, appendWired })
|
|
168
|
+
return resolvePrompt({ override, typed, wired, refMap, appendWired, affixes: readPromptAffixes(data) })
|
|
114
169
|
}
|
|
115
170
|
|
|
116
171
|
export interface LlmChatFieldArgs {
|
|
@@ -125,7 +180,7 @@ export function computeLlmChatFields(
|
|
|
125
180
|
{ override, wiredUserInput, wiredSystemPrompt, refMap }: LlmChatFieldArgs,
|
|
126
181
|
): { userInput: string; systemPrompt: string } {
|
|
127
182
|
return {
|
|
128
|
-
userInput: resolvePrompt({ override, typed: [data.userInput as string | undefined], wired: wiredUserInput, refMap }),
|
|
183
|
+
userInput: resolvePrompt({ override, typed: [data.userInput as string | undefined], wired: wiredUserInput, refMap, affixes: readPromptAffixes(data) }),
|
|
129
184
|
systemPrompt: resolvePrompt({ typed: [data.systemPrompt as string | undefined], wired: wiredSystemPrompt, refMap }),
|
|
130
185
|
}
|
|
131
186
|
}
|