@swiss-ai-hub/web 0.320.0 → 0.321.1

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.
Files changed (38) hide show
  1. package/components/FormKit/AgentSelector.vue +20 -7
  2. package/components/FormKit/Repeater.vue +1 -4
  3. package/components/FormKit/VectorStoreInput.vue +43 -6
  4. package/components/Knowledge/Database/CreateModal.vue +315 -0
  5. package/components/Knowledge/Database/EmptyCard.vue +34 -0
  6. package/components/Knowledge/DeleteConfirmModal.vue +87 -0
  7. package/components/Knowledge/Namespace/Card.vue +18 -0
  8. package/components/Knowledge/Namespace/CreateModal.vue +11 -3
  9. package/components/Role/AccessCapabilities.vue +4 -4
  10. package/components/Role/AccessCapabilityGroup.vue +22 -6
  11. package/components/Role/AccessRulesEditor.vue +15 -12
  12. package/composables/app/useAppVersion.ts +31 -33
  13. package/composables/auth/useAuth.ts +3 -11
  14. package/composables/document/useCreateDatabase.ts +21 -0
  15. package/composables/document/useDeleteDatabase.ts +29 -0
  16. package/composables/document/useDeleteDocument.ts +1 -1
  17. package/composables/document/useDeleteDocuments.ts +1 -1
  18. package/composables/document/useDeleteNamespace.ts +30 -0
  19. package/composables/document/useIngestors.ts +23 -0
  20. package/composables/form/useCreateInstanceForm.ts +2 -2
  21. package/composables/form/useFormKitTransform.ts +44 -15
  22. package/formkit.config.ts +24 -1
  23. package/i18n/locales/de.yaml +60 -5
  24. package/i18n/locales/en.yaml +57 -3
  25. package/i18n/locales/fr.yaml +58 -3
  26. package/i18n/locales/it.yaml +60 -3
  27. package/middleware/agent-admin.ts +31 -0
  28. package/package.json +1 -1
  29. package/pages/[tenant]/service/agents.vue +3 -0
  30. package/pages/[tenant]/service/knowledge/[db]/[namespace]/[document_id].vue +12 -5
  31. package/pages/[tenant]/service/knowledge.vue +138 -0
  32. package/plugins/0.runtime-config.client.ts +5 -0
  33. package/plugins/oidc-client.ts +1 -1
  34. package/sdk/client/index.ts +29 -2
  35. package/sdk/client/schemas.gen.ts +641 -77
  36. package/sdk/client/sdk.gen.ts +159 -1
  37. package/sdk/client/types.gen.ts +543 -75
  38. package/plugins/keycloak-client.ts +0 -41
@@ -21,8 +21,8 @@
21
21
  :group="group"
22
22
  :depth="0"
23
23
  :readonly="readonly"
24
- @add="(rule) => emit('add', rule)"
25
- @remove="(rule) => emit('remove', rule)"
24
+ @add="(rules) => emit('add', rules)"
25
+ @remove="(rules) => emit('remove', rules)"
26
26
  />
27
27
  </div>
28
28
  <p
@@ -55,8 +55,8 @@ const props = withDefaults(defineProps<{
55
55
  })
56
56
 
57
57
  const emit = defineEmits<{
58
- add: [rule: string]
59
- remove: [rule: string]
58
+ add: [rules: string[]]
59
+ remove: [rules: string[]]
60
60
  }>()
61
61
 
62
62
  const { capabilities, capabilitiesAreLoading } = useAccessCapabilities(
@@ -78,8 +78,9 @@
78
78
  </span>
79
79
  <code
80
80
  v-if="cap.rule"
81
+ v-tooltip.top="ruleTooltip(cap)"
81
82
  class="mt-0.5 shrink-0 font-mono text-[11px] text-surface-300 transition-colors group-hover/cap:text-surface-500 dark:text-surface-600 dark:group-hover/cap:text-surface-400"
82
- >{{ cap.rule }}</code>
83
+ >{{ cap.companion_rules?.length ? `${cap.rule} +${cap.companion_rules.length}` : cap.rule }}</code>
83
84
  </label>
84
85
  </div>
85
86
 
@@ -95,8 +96,8 @@
95
96
  :group="sub"
96
97
  :depth="depth + 1"
97
98
  :readonly="readonly"
98
- @add="(rule) => emit('add', rule)"
99
- @remove="(rule) => emit('remove', rule)"
99
+ @add="(rules) => emit('add', rules)"
100
+ @remove="(rules) => emit('remove', rules)"
100
101
  />
101
102
  </div>
102
103
  </div>
@@ -119,12 +120,27 @@ const props = withDefaults(defineProps<{
119
120
  })
120
121
 
121
122
  const emit = defineEmits<{
122
- add: [rule: string]
123
- remove: [rule: string]
123
+ add: [rules: string[]]
124
+ remove: [rules: string[]]
124
125
  }>()
125
126
 
127
+ // Two lists, two meanings. `companion_rules` is written and cleared with the row — a knowledge database
128
+ // owns its namespaces, and the grammar has no single form covering a node and its subtree, so that row
129
+ // needs both. `revoked_rules` is only ever cleared: an agent class does not own the profiles built from it,
130
+ // so ticking must never write `<class>.>`, while unticking still takes an old ceiling's copy of it. Each
131
+ // direction emits one payload because the parent writes them through a single `v-model`, which cannot
132
+ // absorb two writes in the same tick — the second would read a model value the first had not yet updated.
133
+ // The badge counts only what ticking grants; what unticking additionally takes is left to the tooltip.
134
+ const ruleTooltip = (cap: Capability) =>
135
+ [
136
+ cap.companion_rules?.length ? t('role.capability_also_granted', { rules: cap.companion_rules.join(', ') }) : null,
137
+ cap.revoked_rules?.length ? t('role.capability_also_cleared', { rules: cap.revoked_rules.join(', ') }) : null,
138
+ ].filter(Boolean).join('\n') || undefined
139
+
126
140
  const onToggle = (cap: Capability, value: boolean) => {
127
141
  if (props.readonly || !cap.rule) return
128
- emit(value ? 'add' : 'remove', cap.rule)
142
+ const granted = [cap.rule, ...(cap.companion_rules ?? [])]
143
+ if (value) emit('add', granted)
144
+ else emit('remove', [...granted, ...(cap.revoked_rules ?? [])])
129
145
  }
130
146
  </script>
@@ -70,7 +70,7 @@
70
70
  variant="text"
71
71
  rounded
72
72
  size="small"
73
- @click="remove(data.accessRule)"
73
+ @click="removeRules([data.accessRule])"
74
74
  />
75
75
  </template>
76
76
  </Column>
@@ -123,7 +123,7 @@
123
123
  type="button"
124
124
  class="flex flex-col items-start gap-1 rounded-md p-2 text-left hover:bg-surface-100 disabled:opacity-40 dark:hover:bg-surface-800"
125
125
  :disabled="rules.includes(preset.rule)"
126
- @click="addPreset(preset.rule)"
126
+ @click="addRules([preset.rule])"
127
127
  >
128
128
  <span class="flex items-center gap-2 text-sm font-medium">
129
129
  {{ preset.name }}
@@ -147,8 +147,8 @@
147
147
  <AccessCapabilities
148
148
  :rules="rules"
149
149
  :restrict-to-tenant="restrictToTenant"
150
- @add="addPreset"
151
- @remove="remove"
150
+ @add="addRules"
151
+ @remove="removeRules"
152
152
  />
153
153
  </div>
154
154
  </template>
@@ -197,17 +197,20 @@ const newRule = ref('')
197
197
 
198
198
  const isNew = (rule: string) => !props.initialRules.includes(rule)
199
199
 
200
- const add = () => {
201
- if (!newRule.value) return
202
- if (!rules.value.includes(newRule.value)) rules.value.push(newRule.value)
203
- newRule.value = ''
200
+ // Both write the model once per call, never once per rule: `rules` is a `defineModel`, so a second write in
201
+ // the same tick would still read the value the first one replaced. Assignment rather than in-place mutation
202
+ // for the same reason — a parent binding this through a computed never observes a mutated array.
203
+ const addRules = (added: string[]) => {
204
+ rules.value = [...rules.value, ...added.filter(rule => !rules.value.includes(rule))]
204
205
  }
205
206
 
206
- const addPreset = (rule: string) => {
207
- if (!rules.value.includes(rule)) rules.value.push(rule)
207
+ const removeRules = (removed: string[]) => {
208
+ rules.value = rules.value.filter(rule => !removed.includes(rule))
208
209
  }
209
210
 
210
- const remove = (rule: string) => {
211
- rules.value = rules.value.filter(r => r !== rule)
211
+ const add = () => {
212
+ if (!newRule.value) return
213
+ addRules([newRule.value])
214
+ newRule.value = ''
212
215
  }
213
216
  </script>
@@ -1,34 +1,32 @@
1
1
  import { getHealth } from '@core/sdk/client'
2
2
  import { minutesToMilliseconds } from 'date-fns'
3
3
 
4
- // Surfaces the running version of both services. The UI service version is baked
5
- // into the static bundle at build time (runtimeConfig.public.appVersion); the API
6
- // version is read from the health endpoint. When both agree a single number is
7
- // shown, otherwise both are shown as `UI / API`.
4
+ // Surfaces the running version of both services. When both name the same release
5
+ // a single version is shown, otherwise both are shown as `UI / API` so a genuine
6
+ // skew one service rolled, the other not stays visible.
8
7
  //
9
- // A release is promoted by retagging the exact `-rc.N` build that was tested, so
10
- // the baked UI version keeps its release-candidate suffix (e.g. `v0.317.0-rc.3`)
11
- // even though the artifact IS the final release. A container cannot read its own
12
- // image tag, so the release has to be inferred from what the API reports, and the
13
- // two deployment flavours report it differently: docker-compose pins the channel
14
- // tag (`latest`), while Helm pins an exact `vX.Y.Z` per instance and leaves the
15
- // API on its package metadata (`0.319.0`). Both are release signals; on either
16
- // one the suffix is dropped so a promoted build shows its release version.
8
+ // Neither container can know its own version at build time: a release is promoted
9
+ // by retagging the exact `-rc.N` build that was tested, so anything baked into an
10
+ // image keeps the candidate's name even though the artifact IS the final release.
11
+ // The deployment is the only party that knows the tag it pulled, so both sides
12
+ // take their version from it the UI through APP_VERSION in /config.js (see
13
+ // plugins/0.runtime-config.client.ts), the API through its AIHUB_VERSION env var.
14
+ // Each falls back to its build-time value when the deployment injects nothing,
15
+ // which is why a promoted build can still report `-rc.N` on one side only.
17
16
  //
18
- // Versions are compared with the leading `v` normalised away, because one side is
19
- // a git tag (`v0.319.0`) and the other Python package metadata (`0.319.0`) the
20
- // same version, and rendering it as `UI / API` would read as skew.
17
+ // docker-compose is the one flavour that pins a rolling channel tag instead of an
18
+ // exact version, so it reports the channel name (`latest`) rather than a number.
21
19
  //
22
- // Every other case is genuinely a candidate or a rolling build (another channel
23
- // such as `staging`, an API pinned to an explicit `-rc.N`, a nightly), so the
24
- // suffix stays to keep the build identifiable.
20
+ // Versions are compared with the leading `v` normalised away, because one side is
21
+ // a git tag (`v0.319.0`) and the other can be Python package metadata (`0.319.0`)
22
+ // the same version, and rendering it as `UI / API` would read as skew.
25
23
  const RELEASE_CHANNEL = 'latest'
26
24
  const RC_SUFFIX = /-rc\.\d+$/
27
25
  const toReleaseVersion = (version: string): string => version.replace(RC_SUFFIX, '')
28
- const toComparable = (version: string): string => version.replace(/^v/, '')
26
+ const toComparable = (version: string): string => toReleaseVersion(version.replace(/^v/, ''))
29
27
 
30
28
  export const useAppVersion = defineQuery(() => {
31
- const bakedUiVersion = useRuntimeConfig().public.appVersion as string
29
+ const declaredUiVersion = useRuntimeConfig().public.appVersion as string
32
30
 
33
31
  const { data: apiVersion } = useQuery<string>({
34
32
  key: () => ['app-version', 'api'],
@@ -39,23 +37,23 @@ export const useAppVersion = defineQuery(() => {
39
37
  },
40
38
  })
41
39
 
42
- // An API version that still carries `-rc.N` can never equal the stripped UI
43
- // version, so a candidate deployment fails this check without a separate guard.
44
- const isReleaseDeployment = computed(() => {
40
+ // The one version both services agree on, or null when they genuinely differ.
41
+ const agreedVersion = computed(() => {
45
42
  const api = apiVersion.value
46
- if (!api) return false
47
- return api === RELEASE_CHANNEL || toComparable(api) === toComparable(toReleaseVersion(bakedUiVersion))
43
+ if (!api) return declaredUiVersion
44
+ if (api === RELEASE_CHANNEL) return toReleaseVersion(declaredUiVersion)
45
+ if (toComparable(api) !== toComparable(declaredUiVersion)) return null
46
+ // Same release. If either side reports the promoted, suffix-free name then
47
+ // that is what this deployment is; if both still carry `-rc.N` it really is a
48
+ // candidate build and the suffix stays to keep it identifiable.
49
+ return RC_SUFFIX.test(declaredUiVersion) ? api : declaredUiVersion
48
50
  })
49
51
 
50
- const uiVersion = computed(() =>
51
- isReleaseDeployment.value ? toReleaseVersion(bakedUiVersion) : bakedUiVersion,
52
- )
52
+ const uiVersion = computed(() => agreedVersion.value ?? declaredUiVersion)
53
53
 
54
- const versionDisplay = computed(() => {
55
- const api = apiVersion.value
56
- if (!api || toComparable(api) === toComparable(uiVersion.value)) return uiVersion.value
57
- return `${uiVersion.value} / ${api}`
58
- })
54
+ const versionDisplay = computed(
55
+ () => agreedVersion.value ?? `${declaredUiVersion} / ${apiVersion.value}`,
56
+ )
59
57
 
60
58
  return { uiVersion, apiVersion, versionDisplay }
61
59
  })
@@ -2,20 +2,12 @@ export const useAuth = () => {
2
2
  const login = (idpHint?: string) => {
3
3
  const { $auth } = useNuxtApp()
4
4
  const extraQueryParams = idpHint ? { kc_idp_hint: idpHint } : {}
5
- $auth.signinRedirect({ prompt: 'login', extraQueryParams })
5
+ $auth.signinRedirect({ extraQueryParams })
6
6
  }
7
7
 
8
8
  const logout = async () => {
9
- const { $auth, $keycloakClient } = useNuxtApp()
10
- const user = await $auth.getUser()
11
-
12
- if (user?.refresh_token) {
13
- await $keycloakClient.logout(user.refresh_token)
14
- .catch((error: unknown) => console.error('Keycloak session revocation failed:', error))
15
- }
16
-
17
- await $auth.removeUser()
18
- navigateTo('/auth/login')
9
+ const { $auth } = useNuxtApp()
10
+ await $auth.signoutRedirect()
19
11
  }
20
12
 
21
13
  const getUser = async () => {
@@ -0,0 +1,21 @@
1
+ import { createDatabase, type CreateDatabaseRequest } from '@core/sdk/client'
2
+
3
+ export const useCreateDatabase = defineMutation(() => {
4
+ const queryCache = useQueryCache()
5
+ const { tenantId } = useTenant()
6
+
7
+ return useMutation({
8
+ mutation: (params: { database: string, tenantId: string, request: CreateDatabaseRequest }) =>
9
+ createDatabase({
10
+ composable: '$fetch',
11
+ body: params.request,
12
+ path: {
13
+ tenant_id: params.tenantId,
14
+ database: params.database,
15
+ },
16
+ }),
17
+ onSuccess: () => {
18
+ queryCache.invalidateQueries({ key: ['tenant', tenantId.value, 'knowledge'] })
19
+ },
20
+ })
21
+ })
@@ -0,0 +1,29 @@
1
+ import { deleteDatabase } from '@core/sdk/client'
2
+
3
+ export const useDeleteDatabase = defineMutation(() => {
4
+ const queryCache = useQueryCache()
5
+
6
+ const {
7
+ mutateAsync: deleteDatabaseMutation,
8
+ isLoading: isDeleting,
9
+ error: deleteError,
10
+ } = useMutation({
11
+ mutation: async ({ tenantId, database }: { tenantId: string, database: string }) => {
12
+ await deleteDatabase({
13
+ composable: '$fetch',
14
+ path: {
15
+ tenant_id: tenantId,
16
+ database,
17
+ },
18
+ })
19
+
20
+ queryCache.invalidateQueries({ key: ['tenant', tenantId, 'knowledge'] })
21
+ },
22
+ })
23
+
24
+ return {
25
+ deleteDatabase: deleteDatabaseMutation,
26
+ isDeleting,
27
+ deleteError,
28
+ }
29
+ })
@@ -5,7 +5,7 @@ export const useDeleteDocument = defineMutation(() => {
5
5
 
6
6
  const {
7
7
  mutateAsync: deleteDocumentMutation,
8
- isPending: isDeleting,
8
+ isLoading: isDeleting,
9
9
  error: deleteError,
10
10
  } = useMutation({
11
11
  mutation: async ({ tenantId, database, namespace, documentId }: { tenantId: string, database: string, namespace: string, documentId: string }) => {
@@ -7,7 +7,7 @@ export const useDeleteDocuments = defineMutation(() => {
7
7
 
8
8
  const {
9
9
  mutateAsync: deleteDocumentsMutation,
10
- isPending: isDeleting,
10
+ isLoading: isDeleting,
11
11
  error: deleteError,
12
12
  } = useMutation({
13
13
  mutation: async ({ tenantId, database, namespace, documentIds }: { tenantId: string, database: string, namespace: string, documentIds: string[] }): Promise<BatchDeleteDocumentsResponse> => {
@@ -0,0 +1,30 @@
1
+ import { deleteNamespace } from '@core/sdk/client'
2
+
3
+ export const useDeleteNamespace = defineMutation(() => {
4
+ const queryCache = useQueryCache()
5
+
6
+ const {
7
+ mutateAsync: deleteNamespaceMutation,
8
+ isLoading: isDeleting,
9
+ error: deleteError,
10
+ } = useMutation({
11
+ mutation: async ({ tenantId, database, namespace }: { tenantId: string, database: string, namespace: string }) => {
12
+ await deleteNamespace({
13
+ composable: '$fetch',
14
+ path: {
15
+ tenant_id: tenantId,
16
+ database,
17
+ namespace,
18
+ },
19
+ })
20
+
21
+ queryCache.invalidateQueries({ key: ['tenant', tenantId, 'knowledge'] })
22
+ },
23
+ })
24
+
25
+ return {
26
+ deleteNamespace: deleteNamespaceMutation,
27
+ isDeleting,
28
+ deleteError,
29
+ }
30
+ })
@@ -0,0 +1,23 @@
1
+ import { getIngestors, type IngestorDto } from '@core/sdk/client'
2
+ import { minutesToMilliseconds } from 'date-fns'
3
+
4
+ export const useIngestors = defineQuery(() => {
5
+ const { tenantId } = useTenant()
6
+
7
+ const { data: ingestors, isPending: ingestorsAreLoading } = useQuery<IngestorDto[]>({
8
+ key: () => ['tenant', tenantId.value, 'ingestors'],
9
+ staleTime: minutesToMilliseconds(5),
10
+ enabled: useTenantReady(),
11
+ query: async () => {
12
+ return await getIngestors({
13
+ composable: '$fetch',
14
+ path: { tenant_id: tenantId.value! },
15
+ })
16
+ },
17
+ })
18
+
19
+ return {
20
+ ingestors,
21
+ ingestorsAreLoading,
22
+ }
23
+ })
@@ -115,9 +115,9 @@ export function useCreateInstanceForm<T extends ClassDataLike>(options: CreateIn
115
115
 
116
116
  function resetForm() {
117
117
  selectedClass.value = initialClass()
118
- formData.value = {}
118
+ formData.value = hydrateFormData({}, configForm.value as FormElement[])
119
119
  activeStep.value = 0
120
- seededForClass = null
120
+ seededForClass = selectedClassData.value ? selectedClass.value : null
121
121
  }
122
122
 
123
123
  return {
@@ -339,15 +339,25 @@ function nullableToggleId(element: FormElement): string {
339
339
 
340
340
  /**
341
341
  * Combines a synthetic toggle condition with any existing condition_if.
342
+ *
343
+ * The `$` on every `$get(...)` must survive. `$:` marks the string as an expression; it does not put the
344
+ * references inside it into scope. Stripping their `$` made FormKit's compiler see a bare `get`, which it
345
+ * treats as a literal rather than a provided function — the expression then evaluated to the truthy string
346
+ * `"0{[nativecode]}.value"`, so a gated field rendered unconditionally and neither checkbox could hide it.
342
347
  */
343
348
  function combineConditions(toggleCondition: string, existing: string | undefined): string {
344
349
  if (!existing) return toggleCondition
345
- if (existing.startsWith('$:')) {
346
- return `$: ${toggleCondition.slice(1)} && (${existing.slice(2).trim()})`
347
- }
348
- return `$: ${toggleCondition.slice(1)} && (${existing.slice(1)})`
350
+ const existingExpression = existing.startsWith('$:') ? existing.slice(2).trim() : existing
351
+ return `$: ${toggleCondition} && (${existingExpression})`
349
352
  }
350
353
 
354
+ /**
355
+ * The toggle inherits the element's own `condition_if` so it disappears with the section it belongs to.
356
+ * Without this, a nullable field gated on a sibling checkbox (e.g. the memory model, which only applies
357
+ * while memory storage is on) would hide its input but leave a stray "Enable X" checkbox behind.
358
+ * The element's nullable-toggle condition is deliberately NOT inherited — that is the condition this very
359
+ * node controls.
360
+ */
351
361
  function buildNullableToggleNode(
352
362
  element: FormElement,
353
363
  label: string | undefined,
@@ -355,13 +365,19 @@ function buildNullableToggleNode(
355
365
  ): Record<string, unknown> {
356
366
  const fieldName = element.name as string
357
367
  const toggleId = nullableToggleId(element)
368
+ const gatingCondition = element.if as string | undefined
358
369
  return {
359
370
  $formkit: 'primeCheckbox',
371
+ // `preserve: true` for the same reason the gated input itself carries it: when this toggle's own
372
+ // condition unmounts it, FormKit would otherwise drop `__<field>__enabled` from the group data, and the
373
+ // state seeded from the saved value is lost — an already-configured field then remounts reading "off".
374
+ preserve: true,
360
375
  name: nullableToggleName(fieldName),
361
376
  id: toggleId,
362
377
  key: toggleId,
363
378
  label: label ? `Enable ${label}` : 'Enable',
364
379
  ...(help ? { help } : {}),
380
+ ...(gatingCondition ? { if: gatingCondition } : {}),
365
381
  binary: true,
366
382
  }
367
383
  }
@@ -682,17 +698,18 @@ export function coerceNullableToggles(
682
698
  }
683
699
 
684
700
  /**
685
- * Recursively fills missing leaf keys with the backend's serialised Pydantic defaults
686
- * (`element.value`). FormKit no longer receives `value` in the schema (it would clobber
687
- * the v-model on registration), so defaults must be merged into the form data instead.
688
- * Existing values including falsy ones like `false` or `""`are preserved.
689
- *
690
- * NOTE: This helper is load-bearing for edit/clone/template flows but has no direct
691
- * unit tests yet Vitest is not configured for packages/web (see packages/web/CLAUDE.md).
692
- * The Python-side `Form.to_formkit_form()` tests in packages/core lock in what
693
- * `element.value` looks like; behaviour here is exercised end-to-end on agent and
694
- * process edit forms.
701
+ * A nullable leaf stored as `null` is seeded with its default too, mirroring how `seedGroupDefault`
702
+ * materialises a null nullable group: `null` means "the toggle is off", not "the input holds nothing",
703
+ * so the field behind the toggle should still offer the default the backend ships (e.g. the memory
704
+ * model starting on the platform-wide one). Safe in both directions`seedNullableToggles` has
705
+ * already decided the toggle from the raw null-ness, so this cannot switch one on, and
706
+ * `coerceNullableToggles` re-nullifies a disabled field at submit time, so the seeded value is never
707
+ * persisted while the toggle is off.
695
708
  */
709
+ function isDisabledNullableLeaf(element: FormElement, value: unknown): boolean {
710
+ return element.nullable === true && value === null
711
+ }
712
+
696
713
  /**
697
714
  * Seed a group field's value: always materialise its children's defaults (starting from
698
715
  * the existing object when present, `{}` otherwise — including for a saved `null`). A null
@@ -726,6 +743,18 @@ function seedRepeaterDefault(value: unknown, children: FormElement[]): unknown {
726
743
  return value === undefined ? [] : value
727
744
  }
728
745
 
746
+ /**
747
+ * Recursively fills missing leaf keys with the backend's serialised Pydantic defaults
748
+ * (`element.value`). FormKit no longer receives `value` in the schema (it would clobber
749
+ * the v-model on registration), so defaults must be merged into the form data instead.
750
+ * Existing values — including falsy ones like `false` or `""` — are preserved.
751
+ *
752
+ * NOTE: This helper is load-bearing for edit/clone/template flows but has no direct
753
+ * unit tests yet — Vitest is not configured for packages/web (see packages/web/CLAUDE.md).
754
+ * The Python-side `Form.to_formkit_form()` tests in packages/core lock in what
755
+ * `element.value` looks like; behaviour here is exercised end-to-end on agent and
756
+ * process edit forms.
757
+ */
729
758
  export function seedFormDefaults(
730
759
  data: Record<string, unknown>,
731
760
  elements: FormElement[],
@@ -744,7 +773,7 @@ export function seedFormDefaults(
744
773
  else if (formkitType === 'repeater') {
745
774
  result[name] = seedRepeaterDefault(value, children)
746
775
  }
747
- else if (!(name in result) && element.value !== undefined) {
776
+ else if (element.value !== undefined && (!(name in result) || isDisabledNullableLeaf(element, value))) {
748
777
  result[name] = element.value
749
778
  }
750
779
  }
package/formkit.config.ts CHANGED
@@ -32,6 +32,21 @@ function localeRequired(node: FormKitNode): boolean {
32
32
  // create form: precisely the case this rule exists to catch.
33
33
  localeRequired.skipEmpty = false
34
34
 
35
+ // Same shape of problem for agentSelector: its value is always an `{agent_class, agent_id}` object,
36
+ // so picking a class alone yields a non-empty object with a blank `agent_id` that `required` accepts.
37
+ // A blank id then renders as a NATS wildcard at runtime and the delegation reaches no agent at all.
38
+ // Backend `AgentSelector` elements emit `agentRefRequired` instead
39
+ // (see packages/core/swiss_ai_hub/core/form/elements/agent_selector.py).
40
+ function agentRefRequired(node: FormKitNode): boolean {
41
+ const value = node.value as { agent_class?: string | null, agent_id?: string | null } | null | undefined
42
+ if (!value) return false
43
+ return !!value.agent_class?.trim() && !!value.agent_id?.trim()
44
+ }
45
+
46
+ // As with localeRequired: without this the rule never runs on a never-touched field, whose value is
47
+ // still `null`, so a fresh create form would submit with no agent selected at all.
48
+ agentRefRequired.skipEmpty = false
49
+
35
50
  /**
36
51
  * Passes when the value is listed in the sibling field named by `address`, or when that list is
37
52
  * empty — backend allow-lists treat empty as unrestricted. Reading the sibling through `node.at()`
@@ -54,6 +69,13 @@ const localeRequiredMessages = {
54
69
  it: 'Almeno una lingua deve essere compilata.',
55
70
  }
56
71
 
72
+ const agentRefRequiredMessages = {
73
+ de: 'Bitte wählen Sie einen Agententyp und ein Agentenprofil aus.',
74
+ en: 'Please select both an agent type and an agent profile.',
75
+ fr: 'Veuillez sélectionner un type d\'agent et un profil d\'agent.',
76
+ it: 'Seleziona sia un tipo di agente sia un profilo di agente.',
77
+ }
78
+
57
79
  const memberOfMessages = {
58
80
  de: 'Dieser Wert steht nicht in der Liste der erlaubten Werte.',
59
81
  en: 'This value is not in the allowed list.',
@@ -62,11 +84,12 @@ const memberOfMessages = {
62
84
  }
63
85
 
64
86
  const config: DefaultConfigOptions = {
65
- rules: { localeRequired, memberOf },
87
+ rules: { localeRequired, agentRefRequired, memberOf },
66
88
  messages: Object.fromEntries(
67
89
  LOCALES.map(locale => [locale, {
68
90
  validation: {
69
91
  localeRequired: localeRequiredMessages[locale],
92
+ agentRefRequired: agentRefRequiredMessages[locale],
70
93
  memberOf: memberOfMessages[locale],
71
94
  },
72
95
  }]),