@cat-factory/app 0.256.3 → 0.258.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/app/components/binaryCandidates/BinaryCandidatesWindow.vue +284 -0
- package/app/components/binaryOutput/BinaryOutputReport.vue +75 -0
- package/app/components/board/AddTaskModal.vue +38 -12
- package/app/components/board/RecurringPipelineModal.vue +24 -12
- package/app/components/board/nodes/TaskCard.vue +34 -7
- package/app/components/forkDecision/ForkDecisionWindow.vue +3 -1
- package/app/components/panels/AgentStepDetail.vue +42 -20
- package/app/components/panels/InspectorPanel.vue +39 -0
- package/app/components/panels/ResultWindowShell.logic.spec.ts +4 -0
- package/app/components/panels/inspector/TaskExecution.vue +34 -34
- package/app/components/pipeline/BinaryOutputStepPicker.logic.spec.ts +119 -1
- package/app/components/pipeline/BinaryOutputStepPicker.logic.ts +115 -1
- package/app/components/pipeline/BinaryOutputStepPicker.vue +463 -1
- package/app/components/pipeline/PipelineBuilder.vue +44 -0
- package/app/components/pipeline/PipelinePreview.vue +20 -1
- package/app/components/pipeline/PipelineProgress.vue +50 -0
- package/app/composables/api/binaryCandidates.ts +36 -0
- package/app/composables/api/execution.ts +19 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineHealth.spec.ts +42 -5
- package/app/composables/usePipelineHealth.ts +109 -48
- package/app/modular/agent-kinds.ts +5 -0
- package/app/modular/result-views.ts +4 -0
- package/app/stores/binaryCandidates.ts +89 -0
- package/app/stores/environmentWizard/context.ts +0 -2
- package/app/stores/environmentWizard/flow.ts +11 -6
- package/app/stores/environmentWizard.ts +12 -11
- package/app/stores/execution/commands.ts +26 -1
- package/app/stores/pipelines/draftActions.ts +2 -0
- package/app/stores/pipelines/draftStepConfig.ts +4 -161
- package/app/stores/pipelines/draftStepOptions.ts +204 -0
- package/app/stores/ui/resultViews.ts +8 -6
- package/app/stores/ui/runStepOpeners.ts +23 -1
- package/app/types/domain.ts +6 -0
- package/app/types/execution.ts +5 -0
- package/app/utils/agentPalette.spec.ts +26 -0
- package/app/utils/agentPalette.ts +9 -4
- package/app/utils/binaryCandidates.spec.ts +110 -0
- package/app/utils/binaryCandidates.ts +126 -0
- package/app/utils/binaryOutput.spec.ts +122 -1
- package/app/utils/binaryOutput.ts +149 -2
- package/app/utils/catalog.spec.ts +24 -0
- package/app/utils/catalog.ts +21 -4
- package/app/utils/pipeline.spec.ts +35 -3
- package/app/utils/pipeline.ts +54 -3
- package/app/utils/pipelineRender.spec.ts +122 -1
- package/app/utils/pipelineRender.ts +113 -2
- package/i18n/locales/de.json +107 -3
- package/i18n/locales/en.json +107 -3
- package/i18n/locales/es.json +107 -3
- package/i18n/locales/fr.json +107 -3
- package/i18n/locales/he.json +107 -3
- package/i18n/locales/it.json +107 -3
- package/i18n/locales/ja.json +107 -3
- package/i18n/locales/pl.json +107 -3
- package/i18n/locales/tr.json +107 -3
- package/i18n/locales/uk.json +107 -3
- package/i18n/plural-forms.spec.ts +15 -0
- package/package.json +2 -2
|
@@ -21,7 +21,11 @@ import StepExecutionHistory from '~/components/board/StepExecutionHistory.vue'
|
|
|
21
21
|
import { useStepTimer } from '~/composables/useStepTimer'
|
|
22
22
|
import { useStepProse } from '~/composables/useStepProse'
|
|
23
23
|
import { useStepApproval } from '~/composables/useStepApproval'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
REDIRECT_PARK_PRESENTATION,
|
|
26
|
+
type RedirectParkView,
|
|
27
|
+
dedicatedParkView,
|
|
28
|
+
} from '~/utils/pipelineRender'
|
|
25
29
|
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
26
30
|
|
|
27
31
|
// Detail overlay for a single pipeline step. Opened by clicking an agent in the
|
|
@@ -191,14 +195,37 @@ const genericApprovalPending = computed(
|
|
|
191
195
|
() => approvalPending.value && !companionExceeded.value && !dedicatedPark.value,
|
|
192
196
|
)
|
|
193
197
|
|
|
194
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* How the park that holds this step presents itself (prose, icon, action label), or null when no
|
|
200
|
+
* window owns it. Read from the shared table rather than branched on here, so this overlay cannot
|
|
201
|
+
* go on naming the fork decision for a park that is not one.
|
|
202
|
+
*/
|
|
203
|
+
const parkPresentation = computed(() =>
|
|
204
|
+
dedicatedPark.value && dedicatedPark.value !== 'input-gate'
|
|
205
|
+
? REDIRECT_PARK_PRESENTATION[dedicatedPark.value]
|
|
206
|
+
: null,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Jump from this overlay to the window that can actually resolve the dedicated park.
|
|
211
|
+
*
|
|
212
|
+
* A `Record` over the vocabulary rather than an `if` chain, for the reason the presentation table
|
|
213
|
+
* gives: an unhandled member falls out of a chain as a button that closes this overlay and opens
|
|
214
|
+
* NOTHING, which is indistinguishable from a misclick. Here a new park fails to compile until it
|
|
215
|
+
* names its opener.
|
|
216
|
+
*/
|
|
217
|
+
const PARK_OPENERS: Record<RedirectParkView, (instanceId: string, stepIndex: number) => void> = {
|
|
218
|
+
'follow-ups': (id, idx) => ui.openFollowUps(id, idx),
|
|
219
|
+
'fork-decision': (id, idx) => ui.openForkDecision(id, idx),
|
|
220
|
+
'binary-candidates': (id, idx) => ui.openBinaryCandidates(id, idx),
|
|
221
|
+
}
|
|
222
|
+
|
|
195
223
|
function openDedicatedWindow() {
|
|
196
224
|
const c = ctx.value
|
|
197
225
|
const park = dedicatedPark.value
|
|
198
|
-
if (!c || !park) return
|
|
226
|
+
if (!c || !park || park === 'input-gate') return
|
|
199
227
|
close()
|
|
200
|
-
|
|
201
|
-
else if (park === 'fork-decision') ui.openForkDecision(c.instanceId, c.stepIndex)
|
|
228
|
+
PARK_OPENERS[park](c.instanceId, c.stepIndex)
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
// The GitHub-style approval/review state machine for a pending gate step. A park a
|
|
@@ -476,9 +503,11 @@ async function copyOutput() {
|
|
|
476
503
|
@resolve="resolveCompanionCap"
|
|
477
504
|
/>
|
|
478
505
|
|
|
479
|
-
<!-- a park a dedicated window owns (fork choice / follow-up triage
|
|
480
|
-
generic approval rail can't resolve it (the server refuses),
|
|
481
|
-
the human at the window that can
|
|
506
|
+
<!-- a park a dedicated window owns (fork choice / follow-up triage / candidate
|
|
507
|
+
comparison): the generic approval rail can't resolve it (the server refuses),
|
|
508
|
+
so point the human at the window that can. Copy and icon come from the shared
|
|
509
|
+
per-park table, so a park added to the vocabulary can never inherit another
|
|
510
|
+
one's wording here. -->
|
|
482
511
|
<!-- the pre-dispatch input gate holds this step: answered here, in place -->
|
|
483
512
|
<InputGateNotice
|
|
484
513
|
v-if="inputGateVerdict && instance"
|
|
@@ -489,30 +518,23 @@ async function copyOutput() {
|
|
|
489
518
|
/>
|
|
490
519
|
|
|
491
520
|
<div
|
|
492
|
-
v-if="
|
|
521
|
+
v-if="parkPresentation"
|
|
493
522
|
class="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4"
|
|
494
523
|
data-testid="dedicated-park-redirect"
|
|
524
|
+
:data-park="dedicatedPark"
|
|
495
525
|
>
|
|
496
526
|
<p class="text-[13px] leading-relaxed text-amber-200/90">
|
|
497
|
-
{{
|
|
498
|
-
dedicatedPark === 'follow-ups'
|
|
499
|
-
? t('panels.stepDetail.followUpsParked')
|
|
500
|
-
: t('panels.stepDetail.forkParked')
|
|
501
|
-
}}
|
|
527
|
+
{{ t(parkPresentation.noticeKey) }}
|
|
502
528
|
</p>
|
|
503
529
|
<UButton
|
|
504
530
|
class="mt-3"
|
|
505
531
|
color="primary"
|
|
506
532
|
size="sm"
|
|
507
|
-
:icon="
|
|
533
|
+
:icon="parkPresentation.icon"
|
|
508
534
|
data-testid="dedicated-park-open"
|
|
509
535
|
@click="openDedicatedWindow"
|
|
510
536
|
>
|
|
511
|
-
{{
|
|
512
|
-
dedicatedPark === 'follow-ups'
|
|
513
|
-
? t('panels.stepDetail.openFollowUps')
|
|
514
|
-
: t('panels.stepDetail.chooseApproach')
|
|
515
|
-
}}
|
|
537
|
+
{{ t(parkPresentation.actionKey) }}
|
|
516
538
|
</UButton>
|
|
517
539
|
</div>
|
|
518
540
|
|
|
@@ -7,6 +7,7 @@ import { inspectorPanels } from '~/modular/panels/inspector.logic'
|
|
|
7
7
|
import IconButton from '~/components/common/IconButton.vue'
|
|
8
8
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
9
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
|
+
import { BLUEPRINT_AGENT_KIND } from '@cat-factory/contracts'
|
|
10
11
|
import { VCS_PROVIDER_ICONS } from '~/utils/vcs'
|
|
11
12
|
|
|
12
13
|
const board = useBoardStore()
|
|
@@ -244,6 +245,22 @@ const runMenu = computed(() => {
|
|
|
244
245
|
]
|
|
245
246
|
})
|
|
246
247
|
|
|
248
|
+
// Mapping a service: one run of the mapping agent against this frame, started by KIND (no
|
|
249
|
+
// pipeline). The button owns only its in-flight state — a refusal is already surfaced as a toast
|
|
250
|
+
// by the command, and the run itself then reports through the ordinary board/run projection, so
|
|
251
|
+
// there is nothing for this component to remember about it afterwards.
|
|
252
|
+
const mappingService = ref(false)
|
|
253
|
+
async function mapService() {
|
|
254
|
+
const id = block.value?.id
|
|
255
|
+
if (!id) return
|
|
256
|
+
mappingService.value = true
|
|
257
|
+
try {
|
|
258
|
+
await execution.startAgentKind(id, BLUEPRINT_AGENT_KIND)
|
|
259
|
+
} finally {
|
|
260
|
+
mappingService.value = false
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
// Delegate to the shared confirm-gated deletion so the button and the keyboard shortcut
|
|
248
265
|
// (Delete/Backspace) follow the exact same prompt + optimistic-delete + rollback path.
|
|
249
266
|
const { deleteBlock, archiveBlock } = useBlockDeletion()
|
|
@@ -514,6 +531,28 @@ const showOriginalDescription = ref(false)
|
|
|
514
531
|
{{ t('panels.inspector.viewRequirements') }}
|
|
515
532
|
</UButton>
|
|
516
533
|
|
|
534
|
+
<!-- service (frame): (re)map the repository into the service → modules blueprint and
|
|
535
|
+
populate the board. A SINGLE-KIND run of the mapping agent, not a pipeline — the
|
|
536
|
+
preset that used to wrap this one step is retired. Needs a linked repo to read, so it
|
|
537
|
+
is disabled (with the reason) until the frame has one. -->
|
|
538
|
+
<UButton
|
|
539
|
+
v-if="isFrame"
|
|
540
|
+
block
|
|
541
|
+
color="neutral"
|
|
542
|
+
variant="soft"
|
|
543
|
+
size="sm"
|
|
544
|
+
icon="i-lucide-map"
|
|
545
|
+
:loading="mappingService"
|
|
546
|
+
:disabled="!serviceRepo || mappingService"
|
|
547
|
+
:title="serviceRepo ? undefined : t('panels.inspector.mapServiceNoRepo')"
|
|
548
|
+
@click="mapService"
|
|
549
|
+
>
|
|
550
|
+
{{ t('panels.inspector.mapService') }}
|
|
551
|
+
</UButton>
|
|
552
|
+
<p v-if="isFrame && !serviceRepo" class="text-[11px] text-slate-500">
|
|
553
|
+
{{ t('panels.inspector.mapServiceNoRepo') }}
|
|
554
|
+
</p>
|
|
555
|
+
|
|
517
556
|
<!-- The level/type-keyed inspector body: the `inspectorPanels` panel group
|
|
518
557
|
(slice 4 of the modular-vue adoption). `<PanelsOutlet>` renders every
|
|
519
558
|
panel whose `when(block)` matches, ordered, with the selected block
|
|
@@ -27,6 +27,10 @@ const SHELL_DEFAULT_WIDTH: ResultWindowWidth = '3xl'
|
|
|
27
27
|
* not made the decision.
|
|
28
28
|
*/
|
|
29
29
|
const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
|
|
30
|
+
'binaryCandidates/BinaryCandidatesWindow.vue': {
|
|
31
|
+
width: '5xl',
|
|
32
|
+
why: 'a preview grid of generated candidates grouped by subject, read side by side to be compared, with no rail beside it',
|
|
33
|
+
},
|
|
30
34
|
'brainstorm/BrainstormWindow.vue': {
|
|
31
35
|
width: 'full',
|
|
32
36
|
why: 'options column + the choose/dismiss action rail',
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
isCompanionKind,
|
|
9
9
|
containerPhaseLabel,
|
|
10
10
|
dedicatedParkView,
|
|
11
|
+
REDIRECT_PARK_PRESENTATION,
|
|
12
|
+
type RedirectParkView,
|
|
11
13
|
} from '~/utils/pipelineRender'
|
|
12
14
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
13
15
|
import AgentFailureHistory from '~/components/board/AgentFailureHistory.vue'
|
|
@@ -184,15 +186,28 @@ function openStep(i: number) {
|
|
|
184
186
|
if (instance.value) ui.openStepDetail(instance.value.id, i)
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
/**
|
|
190
|
+
* The window-owned park holding a step, or null. `input-gate` is filtered out because it has no
|
|
191
|
+
* window: it is answered by the inline notice this list renders above itself, so offering a
|
|
192
|
+
* button here would send a human to an overlay that does not exist.
|
|
193
|
+
*/
|
|
194
|
+
function redirectPark(step: PipelineStep): RedirectParkView | null {
|
|
195
|
+
const park = dedicatedParkView(step, instance.value)
|
|
196
|
+
return park && park !== 'input-gate' ? park : null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Open the window that resolves a park. A `Record` over the vocabulary, so a park added to it
|
|
201
|
+
* fails to compile until it names its opener rather than rendering a button that does nothing.
|
|
202
|
+
*/
|
|
203
|
+
const PARK_OPENERS: Record<RedirectParkView, (instanceId: string, stepIndex: number) => void> = {
|
|
204
|
+
'follow-ups': (id, idx) => ui.openFollowUps(id, idx),
|
|
205
|
+
'fork-decision': (id, idx) => ui.openForkDecision(id, idx),
|
|
206
|
+
'binary-candidates': (id, idx) => ui.openBinaryCandidates(id, idx),
|
|
190
207
|
}
|
|
191
208
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
function openFollowUpsFor(i: number) {
|
|
195
|
-
if (instance.value) ui.openFollowUps(instance.value.id, i)
|
|
209
|
+
function openParkFor(park: RedirectParkView, i: number) {
|
|
210
|
+
if (instance.value) PARK_OPENERS[park](instance.value.id, i)
|
|
196
211
|
}
|
|
197
212
|
|
|
198
213
|
// Open the PR deep-review findings-selection window for a pr-reviewer step parked awaiting
|
|
@@ -471,38 +486,23 @@ async function mergePr() {
|
|
|
471
486
|
>
|
|
472
487
|
{{ t('inspector.execution.decide') }}
|
|
473
488
|
</UButton>
|
|
474
|
-
<!-- A
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
dedicatedParkView(s, instance) === 'fork-decision'
|
|
481
|
-
"
|
|
482
|
-
color="primary"
|
|
483
|
-
variant="soft"
|
|
484
|
-
size="xs"
|
|
485
|
-
icon="i-lucide-git-fork"
|
|
486
|
-
@click="openForkFor(i)"
|
|
487
|
-
>
|
|
488
|
-
{{ t('inspector.execution.chooseApproach') }}
|
|
489
|
-
</UButton>
|
|
490
|
-
<!-- A coder step parked on undecided follow-up items: triage them (file /
|
|
491
|
-
send back / answer / dismiss) in the dedicated window, not a plain
|
|
492
|
-
approval — the generic approve resolver refuses this park. -->
|
|
489
|
+
<!-- A step parked on something a dedicated WINDOW answers: the implementation-fork
|
|
490
|
+
choice, undecided follow-up items, or a candidate comparison. None of them is a
|
|
491
|
+
plain approval (the generic resolver refuses all three server-side), and all
|
|
492
|
+
three present the same way here: one button into the window that can resolve
|
|
493
|
+
it. Driven by the shared per-park table rather than a branch each, because a
|
|
494
|
+
branch each is how the candidate park shipped with no button at all. -->
|
|
493
495
|
<UButton
|
|
494
|
-
v-else-if="
|
|
495
|
-
s.approval &&
|
|
496
|
-
s.approval.status === 'pending' &&
|
|
497
|
-
dedicatedParkView(s, instance) === 'follow-ups'
|
|
498
|
-
"
|
|
496
|
+
v-else-if="s.approval && s.approval.status === 'pending' && redirectPark(s)"
|
|
499
497
|
color="primary"
|
|
500
498
|
variant="soft"
|
|
501
499
|
size="xs"
|
|
502
|
-
icon="
|
|
503
|
-
|
|
500
|
+
:icon="REDIRECT_PARK_PRESENTATION[redirectPark(s)!].icon"
|
|
501
|
+
:data-park="redirectPark(s)"
|
|
502
|
+
data-testid="dedicated-park-open"
|
|
503
|
+
@click="openParkFor(redirectPark(s)!, i)"
|
|
504
504
|
>
|
|
505
|
-
{{ t(
|
|
505
|
+
{{ t(REDIRECT_PARK_PRESENTATION[redirectPark(s)!].railActionKey) }}
|
|
506
506
|
</UButton>
|
|
507
507
|
<!-- A pr-reviewer step parked awaiting a finding selection: open the dedicated
|
|
508
508
|
findings-selection window, not the generic approval gate. -->
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import { MAX_BINARY_PIXEL_EXTENT } from '@cat-factory/contracts'
|
|
3
|
+
import {
|
|
4
|
+
formatExtent,
|
|
5
|
+
formatReferenceImages,
|
|
6
|
+
generationControlOffer,
|
|
7
|
+
parseExtent,
|
|
8
|
+
parseMediaTypeRequirement,
|
|
9
|
+
parseReferenceImages,
|
|
10
|
+
sameFormats,
|
|
11
|
+
} from './BinaryOutputStepPicker.logic'
|
|
3
12
|
|
|
4
13
|
describe('parseMediaTypeRequirement', () => {
|
|
5
14
|
it('stores the reduction the backend compares against, not what was typed', () => {
|
|
@@ -56,3 +65,112 @@ describe('sameFormats', () => {
|
|
|
56
65
|
expect(sameFormats(['a/b', 'c/d'], ['a/b', 'c/d'])).toBe(true)
|
|
57
66
|
})
|
|
58
67
|
})
|
|
68
|
+
|
|
69
|
+
describe('parseReferenceImages', () => {
|
|
70
|
+
it('reads the role, the location and the optional service off each line', () => {
|
|
71
|
+
const { usable, unusable } = parseReferenceImages(
|
|
72
|
+
['subject|assets/hero.png|asset-store', 'style|https://cdn.example/palette.png'].join('\n'),
|
|
73
|
+
)
|
|
74
|
+
expect(usable).toEqual([
|
|
75
|
+
{ role: 'subject', location: 'assets/hero.png', service: 'asset-store' },
|
|
76
|
+
{ role: 'style', location: 'https://cdn.example/palette.png' },
|
|
77
|
+
])
|
|
78
|
+
expect(unusable).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// A reference someone typed and the step does not carry is a generation that silently ignores
|
|
82
|
+
// it, which is the "absent reads as fine" failure the rest of this surface exists to avoid.
|
|
83
|
+
it('reports a refused line rather than dropping it', () => {
|
|
84
|
+
const { usable, unusable } = parseReferenceImages(
|
|
85
|
+
['mood|assets/hero.png', 'subject|', 'subject|assets/ok.png'].join('\n'),
|
|
86
|
+
)
|
|
87
|
+
expect(usable.map((ref) => ref.location)).toEqual(['assets/ok.png'])
|
|
88
|
+
expect(unusable).toEqual(['mood|assets/hero.png', 'subject|'])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('round-trips through the text the field shows', () => {
|
|
92
|
+
const text = 'base|assets/hero.png|asset-store'
|
|
93
|
+
expect(formatReferenceImages(parseReferenceImages(text).usable)).toBe(text)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('parseExtent', () => {
|
|
98
|
+
it('accepts exactly what the save boundary accepts', () => {
|
|
99
|
+
expect(parseExtent(' 96 ')).toBe(96)
|
|
100
|
+
expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT))).toBe(MAX_BINARY_PIXEL_EXTENT)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('refuses everything the schema would refuse, so a typed size is a saveable one', () => {
|
|
104
|
+
// Including ZERO, which is the one that mattered: an unset half written as 0 stored a config
|
|
105
|
+
// the schema rejects, so the step became unsaveable behind an opaque validation error and the
|
|
106
|
+
// untouched field rendered "0" back at whoever had not touched it.
|
|
107
|
+
expect(parseExtent('0')).toBeNull()
|
|
108
|
+
expect(parseExtent('-8')).toBeNull()
|
|
109
|
+
expect(parseExtent('96.5')).toBeNull()
|
|
110
|
+
expect(parseExtent('wide')).toBeNull()
|
|
111
|
+
expect(parseExtent(String(MAX_BINARY_PIXEL_EXTENT + 1))).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reads a blank field as nothing typed', () => {
|
|
115
|
+
expect(parseExtent('')).toBeNull()
|
|
116
|
+
expect(parseExtent(' ')).toBeNull()
|
|
117
|
+
// And the round trip holds: what a stored size renders as is what parses back to it.
|
|
118
|
+
expect(parseExtent(formatExtent(96))).toBe(96)
|
|
119
|
+
expect(formatExtent(undefined)).toBe('')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('generationControlOffer', () => {
|
|
124
|
+
const declaring = (...capabilities: string[]) => ({ capabilities }) as never
|
|
125
|
+
|
|
126
|
+
it('offers everything while a selected integration has declared nothing', () => {
|
|
127
|
+
// An integration that pinned nothing down is not a denial: hiding a control would be a claim
|
|
128
|
+
// about a vendor's API that nobody established. The advisory line says it is unconfirmed.
|
|
129
|
+
const offers = generationControlOffer([declaring('seed'), declaring()], undefined)
|
|
130
|
+
expect(offers('seed')).toBe(true)
|
|
131
|
+
expect(offers('tileable')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('offers everything when nothing is selected yet', () => {
|
|
135
|
+
expect(generationControlOffer([], undefined)('upscale')).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('hides a control once every selection has declared and none has the capability', () => {
|
|
139
|
+
const offers = generationControlOffer([declaring('seed'), declaring('aspect-ratio')], undefined)
|
|
140
|
+
expect(offers('seed')).toBe(true)
|
|
141
|
+
expect(offers('aspect-ratio')).toBe(true)
|
|
142
|
+
expect(offers('tileable')).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// The regression this pins. Changing the selection does not clear options authored against the
|
|
146
|
+
// old one, so a stored option whose capability nothing declares still REFUSES the run at
|
|
147
|
+
// admission. Hiding its control leaves the reader an error saying to remove an option and no
|
|
148
|
+
// control that removes it: a step that cannot be run and cannot be fixed from the surface that
|
|
149
|
+
// configures it.
|
|
150
|
+
it('keeps offering a control whose option is already SET, whatever the selection declares', () => {
|
|
151
|
+
const selection = [declaring('seed')]
|
|
152
|
+
expect(generationControlOffer(selection, {})('tileable')).toBe(false)
|
|
153
|
+
expect(generationControlOffer(selection, { tileable: true })('tileable')).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps the control for every option shape that carries a requirement', () => {
|
|
157
|
+
const selection = [declaring('seed')]
|
|
158
|
+
// Each of these is stored differently (a flag, a number that may be zero, a list, a mode), and
|
|
159
|
+
// the requirement is derived from the same helper admission uses rather than re-read here.
|
|
160
|
+
const offers = generationControlOffer(selection, {
|
|
161
|
+
seed: 0,
|
|
162
|
+
aspectRatio: '16:9',
|
|
163
|
+
negativePrompt: 'blurry',
|
|
164
|
+
edit: { mode: 'mask' },
|
|
165
|
+
referenceImages: [{ role: 'subject', location: 'assets/hero.png' }],
|
|
166
|
+
} as never)
|
|
167
|
+
for (const capability of [
|
|
168
|
+
'aspect-ratio',
|
|
169
|
+
'negative-prompt',
|
|
170
|
+
'mask-edit',
|
|
171
|
+
'reference-image',
|
|
172
|
+
] as const) {
|
|
173
|
+
expect(offers(capability)).toBe(true)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
binaryReferenceImageSchema,
|
|
3
|
+
MAX_BINARY_PIXEL_EXTENT,
|
|
4
|
+
mediaTypeSchema,
|
|
5
|
+
normalizeMediaType,
|
|
6
|
+
requiredBinaryCapabilities,
|
|
7
|
+
type BinaryGenerationOptions,
|
|
8
|
+
type BinaryGeneratorCapability,
|
|
9
|
+
type BinaryReferenceImage,
|
|
10
|
+
} from '@cat-factory/contracts'
|
|
2
11
|
import * as v from 'valibot'
|
|
3
12
|
|
|
4
13
|
// The pure half of BinaryOutputStepPicker: reading a free-text FORMAT requirement, and telling
|
|
@@ -53,3 +62,108 @@ export function sameFormats(
|
|
|
53
62
|
): boolean {
|
|
54
63
|
return (a ?? []).join(',') === (b ?? []).join(',')
|
|
55
64
|
}
|
|
65
|
+
|
|
66
|
+
/** A parsed reference-image list: what the step will carry, and what was refused on the way in. */
|
|
67
|
+
export interface ParsedReferenceImages {
|
|
68
|
+
/** Well-formed entries, in the order typed: exactly what gets stored. */
|
|
69
|
+
usable: BinaryReferenceImage[]
|
|
70
|
+
/** Lines that are not `role|location[|service]`, kept VERBATIM so the warning can quote them. */
|
|
71
|
+
unusable: string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a reference-image list from the one-per-line `role|location[|service]` text the builder
|
|
76
|
+
* accepts.
|
|
77
|
+
*
|
|
78
|
+
* Free TEXT rather than a picker, for the reason the format requirement beside it is: what a
|
|
79
|
+
* reference points at is an object in the org's own storage or a URL, and neither is a set the
|
|
80
|
+
* platform can enumerate. The three fields are positional because the shape is small and a
|
|
81
|
+
* three-input row per reference would dominate a step row that is already dense; the ROLE comes
|
|
82
|
+
* first because it is the constrained field, so a typo lands on the half the parser can name.
|
|
83
|
+
*
|
|
84
|
+
* A refused line is REPORTED, never quietly dropped, exactly as a refused format is: a reference
|
|
85
|
+
* someone typed and the step does not carry is a generation that silently ignores it.
|
|
86
|
+
*/
|
|
87
|
+
export function parseReferenceImages(text: string): ParsedReferenceImages {
|
|
88
|
+
const usable: BinaryReferenceImage[] = []
|
|
89
|
+
const unusable: string[] = []
|
|
90
|
+
for (const line of text
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((part) => part.trim())
|
|
93
|
+
.filter(Boolean)) {
|
|
94
|
+
const [role, location, service] = line.split('|').map((part) => part.trim())
|
|
95
|
+
const parsed = v.safeParse(binaryReferenceImageSchema, {
|
|
96
|
+
role,
|
|
97
|
+
location,
|
|
98
|
+
...(service ? { service } : {}),
|
|
99
|
+
})
|
|
100
|
+
if (parsed.success) usable.push(parsed.output)
|
|
101
|
+
else unusable.push(line)
|
|
102
|
+
}
|
|
103
|
+
return { usable, unusable }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Render a stored reference list back into the text the field shows. */
|
|
107
|
+
export function formatReferenceImages(
|
|
108
|
+
references: readonly BinaryReferenceImage[] | undefined,
|
|
109
|
+
): string {
|
|
110
|
+
return (references ?? [])
|
|
111
|
+
.map((ref) => [ref.role, ref.location, ref.service].filter(Boolean).join('|'))
|
|
112
|
+
.join('\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read one half of an exact output size out of the field, or null when what is typed is not a
|
|
117
|
+
* pixel extent the step could carry.
|
|
118
|
+
*
|
|
119
|
+
* Held to the SAME bounds the schema holds it to, {@link MAX_BINARY_PIXEL_EXTENT} imported rather
|
|
120
|
+
* than repeated, so a number this accepts is one the save accepts. Blank is null like anything
|
|
121
|
+
* else here: the caller's job is to tell "nothing typed yet" from "typed and unusable", and it has
|
|
122
|
+
* the raw text to do it with.
|
|
123
|
+
*/
|
|
124
|
+
export function parseExtent(raw: string): number | null {
|
|
125
|
+
const text = raw.trim()
|
|
126
|
+
if (!text) return null
|
|
127
|
+
const value = Number(text)
|
|
128
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_BINARY_PIXEL_EXTENT) return null
|
|
129
|
+
return value
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Render a stored extent back into the text its field shows; absent is an empty field. */
|
|
133
|
+
export function formatExtent(value: number | undefined): string {
|
|
134
|
+
return value === undefined ? '' : String(value)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the predicate that decides whether the control for a generation option is OFFERED, given
|
|
139
|
+
* what the step's selected integrations declare and what its stored options already require.
|
|
140
|
+
*
|
|
141
|
+
* Three rules, and the third is the one that is easy to miss:
|
|
142
|
+
*
|
|
143
|
+
* - An integration that declares NOTHING pins nothing down, so while one is selected (or nothing
|
|
144
|
+
* is) every control stays offered and the advisory line says the support is unconfirmed.
|
|
145
|
+
* Hiding one would be a claim about a vendor's API that nobody established.
|
|
146
|
+
* - Otherwise a control is offered exactly when something selected declares its capability.
|
|
147
|
+
* - And a control whose option is ALREADY SET stays offered whatever the selection says. Changing
|
|
148
|
+
* the selection does not clear options authored against the old one, so hiding the control on
|
|
149
|
+
* the way past strands a stored requirement that refuses the run at admission, under an error
|
|
150
|
+
* telling the reader to remove an option whose only control has just disappeared. The platform
|
|
151
|
+
* will not silently drop an authored requirement, so the person who stated it needs the control
|
|
152
|
+
* that withdraws it. This is the SPA's standing rule that a hidden field must leave behind
|
|
153
|
+
* exactly the default it would have shown.
|
|
154
|
+
*
|
|
155
|
+
* Returned as a closure over ONE pass across the selection rather than recomputed per capability:
|
|
156
|
+
* the template asks it once per control, and the sets are the same for all of them.
|
|
157
|
+
*/
|
|
158
|
+
export function generationControlOffer(
|
|
159
|
+
selected: readonly { capabilities?: readonly BinaryGeneratorCapability[] }[],
|
|
160
|
+
options: BinaryGenerationOptions | undefined,
|
|
161
|
+
): (capability: BinaryGeneratorCapability) => boolean {
|
|
162
|
+
const declared = new Set(selected.flatMap((generator) => generator.capabilities ?? []))
|
|
163
|
+
const undeclared =
|
|
164
|
+
selected.length === 0 || selected.some((g) => (g.capabilities ?? []).length === 0)
|
|
165
|
+
// The same derivation admission judges the step by, imported rather than re-implemented, so the
|
|
166
|
+
// control a person is offered and the requirement the run is refused for cannot disagree.
|
|
167
|
+
const required = new Set(requiredBinaryCapabilities(options))
|
|
168
|
+
return (capability) => undeclared || declared.has(capability) || required.has(capability)
|
|
169
|
+
}
|