@swiss-ai-hub/web 0.319.0 → 0.321.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.
Files changed (50) hide show
  1. package/{pages/auth/login.vue → components/Auth/LoginPanel.vue} +3 -33
  2. package/components/FormKit/AgentSelector.vue +20 -7
  3. package/components/FormKit/CronInput.vue +208 -0
  4. package/components/FormKit/Repeater.vue +1 -4
  5. package/components/FormKit/VectorStoreInput.vue +43 -6
  6. package/components/Knowledge/Database/CreateModal.vue +315 -0
  7. package/components/Knowledge/Database/EmptyCard.vue +34 -0
  8. package/components/Knowledge/DeleteConfirmModal.vue +87 -0
  9. package/components/Knowledge/Document/List.vue +20 -9
  10. package/components/Knowledge/Namespace/Card.vue +18 -0
  11. package/components/Knowledge/Namespace/CreateModal.vue +11 -3
  12. package/components/Role/AccessCapabilities.vue +4 -4
  13. package/components/Role/AccessCapabilityGroup.vue +22 -6
  14. package/components/Role/AccessRulesEditor.vue +15 -12
  15. package/composables/app/useAppVersion.ts +37 -20
  16. package/composables/auth/useAuth.ts +3 -11
  17. package/composables/document/useCreateDatabase.ts +21 -0
  18. package/composables/document/useDeleteDatabase.ts +29 -0
  19. package/composables/document/useDeleteDocument.ts +1 -1
  20. package/composables/document/useDeleteDocuments.ts +1 -1
  21. package/composables/document/useDeleteNamespace.ts +30 -0
  22. package/composables/document/useIngestors.ts +23 -0
  23. package/composables/document/useScheduledDeletions.ts +16 -8
  24. package/composables/form/useCreateInstanceForm.ts +2 -2
  25. package/composables/form/useFormKitTransform.ts +44 -15
  26. package/formkit.config.ts +57 -3
  27. package/i18n/locales/de.yaml +84 -5
  28. package/i18n/locales/en.yaml +80 -3
  29. package/i18n/locales/fr.yaml +82 -3
  30. package/i18n/locales/it.yaml +84 -3
  31. package/middleware/agent-admin.ts +31 -0
  32. package/middleware/auth.global.ts +34 -16
  33. package/package.json +1 -1
  34. package/pages/[tenant]/service/agents.vue +3 -0
  35. package/pages/[tenant]/service/knowledge/[db]/[namespace]/[document_id].vue +12 -5
  36. package/pages/[tenant]/service/knowledge.vue +138 -0
  37. package/pages/auth/login/[idp].vue +57 -0
  38. package/pages/auth/login/index.vue +46 -0
  39. package/plugins/0.runtime-config.client.ts +5 -0
  40. package/plugins/oidc-client.ts +1 -1
  41. package/sdk/client/client/client.gen.ts +1 -3
  42. package/sdk/client/client/types.gen.ts +7 -13
  43. package/sdk/client/client/utils.gen.ts +1 -2
  44. package/sdk/client/core/queryKeySerializer.gen.ts +1 -6
  45. package/sdk/client/core/types.gen.ts +5 -3
  46. package/sdk/client/index.ts +47 -4
  47. package/sdk/client/schemas.gen.ts +1787 -780
  48. package/sdk/client/sdk.gen.ts +231 -1
  49. package/sdk/client/types.gen.ts +1103 -226
  50. package/plugins/keycloak-client.ts +0 -41
@@ -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,23 +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, which reports the channel tag (e.g.
7
- // `latest`) the running image was pulled as. When both match a single number is
8
- // 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.
9
7
  //
10
- // A release is promoted onto the `latest` channel by retagging the exact `-rc.N`
11
- // build that was tested, so the baked version keeps its release-candidate suffix
12
- // (e.g. `v0.317.0-rc.3`) even though the artifact IS the final release. Strip that
13
- // suffix only on the `latest` channel, so a promoted build shows its release
14
- // version (`v0.317.0`). On any other channel the build is genuinely a candidate or
15
- // rolling build, so keep the suffix to stay identifiable.
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.
16
+ //
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.
19
+ //
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.
16
23
  const RELEASE_CHANNEL = 'latest'
17
- const toReleaseVersion = (version: string): string => version.replace(/-rc\.\d+$/, '')
24
+ const RC_SUFFIX = /-rc\.\d+$/
25
+ const toReleaseVersion = (version: string): string => version.replace(RC_SUFFIX, '')
26
+ const toComparable = (version: string): string => toReleaseVersion(version.replace(/^v/, ''))
18
27
 
19
28
  export const useAppVersion = defineQuery(() => {
20
- const bakedUiVersion = useRuntimeConfig().public.appVersion as string
29
+ const declaredUiVersion = useRuntimeConfig().public.appVersion as string
21
30
 
22
31
  const { data: apiVersion } = useQuery<string>({
23
32
  key: () => ['app-version', 'api'],
@@ -28,15 +37,23 @@ export const useAppVersion = defineQuery(() => {
28
37
  },
29
38
  })
30
39
 
31
- const uiVersion = computed(() =>
32
- apiVersion.value === RELEASE_CHANNEL ? toReleaseVersion(bakedUiVersion) : bakedUiVersion,
33
- )
34
-
35
- const versionDisplay = computed(() => {
40
+ // The one version both services agree on, or null when they genuinely differ.
41
+ const agreedVersion = computed(() => {
36
42
  const api = apiVersion.value
37
- if (!api || api === uiVersion.value) return uiVersion.value
38
- return `${uiVersion.value} / ${api}`
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
39
50
  })
40
51
 
52
+ const uiVersion = computed(() => agreedVersion.value ?? declaredUiVersion)
53
+
54
+ const versionDisplay = computed(
55
+ () => agreedVersion.value ?? `${declaredUiVersion} / ${apiVersion.value}`,
56
+ )
57
+
41
58
  return { uiVersion, apiVersion, versionDisplay }
42
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
+ })
@@ -2,11 +2,15 @@ import { useStorage } from '@vueuse/core'
2
2
 
3
3
  import type { MaybeRefOrGetter } from 'vue'
4
4
 
5
- // Deletion is eventual (the pipeline cleans the stores minutes after the API returns 202), so the
6
- // list keeps returning a deleted document for a while. We persist scheduled ids in localStorage —
7
- // surviving page refreshes — to drive a "Deleting" badge, and expire them after a TTL so a failed
8
- // cleanup eventually re-surfaces the row instead of hiding it forever.
9
- const TTL_MS = 30 * 60 * 1000
5
+ // Deletion is eventual (the pipeline cleans the stores well after the API returns), so the list keeps
6
+ // returning a deleted document for a while. We persist scheduled ids in localStorage — surviving page
7
+ // refreshes — to drive a "Deleting" badge, and expire them after a TTL so a failed cleanup eventually
8
+ // re-surfaces the row instead of hiding it forever.
9
+ //
10
+ // The TTL has to cover a Dagster backlog, not just a slow run: the cleanup waits on a NATS sensor tick,
11
+ // then the observe job's turn in the queue, then a run-status sensor tick, then the remove job's turn.
12
+ // A busy instance can push that out for hours, so a day is the honest bound.
13
+ const TTL_MS = 24 * 60 * 60 * 1000
10
14
 
11
15
  const store = useStorage<Record<string, number>>('aihub:scheduled-deletions', {})
12
16
 
@@ -15,9 +19,13 @@ function entryKey(database: string, namespace: string, documentId: string): stri
15
19
  }
16
20
 
17
21
  export function useScheduledDeletions(database: MaybeRefOrGetter<string>, namespace: MaybeRefOrGetter<string>) {
22
+ function scheduledAt(documentId: string): number | undefined {
23
+ const at = store.value[entryKey(toValue(database), toValue(namespace), documentId)]
24
+ return at != null && Date.now() - at < TTL_MS ? at : undefined
25
+ }
26
+
18
27
  function isScheduled(documentId: string): boolean {
19
- const scheduledAt = store.value[entryKey(toValue(database), toValue(namespace), documentId)]
20
- return scheduledAt != null && Date.now() - scheduledAt < TTL_MS
28
+ return scheduledAt(documentId) != null
21
29
  }
22
30
 
23
31
  function schedule(documentIds: string[]): void {
@@ -44,5 +52,5 @@ export function useScheduledDeletions(database: MaybeRefOrGetter<string>, namesp
44
52
  }
45
53
  }
46
54
 
47
- return { isScheduled, schedule, unschedule }
55
+ return { isScheduled, scheduledAt, schedule, unschedule }
48
56
  }
@@ -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
@@ -1,5 +1,6 @@
1
1
  import AgentSelector from '@core/components/FormKit/AgentSelector.vue'
2
2
  import ChipsInput from '@core/components/FormKit/ChipsInput.vue'
3
+ import CronInput from '@core/components/FormKit/CronInput.vue'
3
4
  import IconSelector from '@core/components/FormKit/IconSelector.vue'
4
5
  import KnowledgeDatabaseSelector from '@core/components/FormKit/KnowledgeDatabaseSelector.vue'
5
6
  import LocaleInput from '@core/components/FormKit/LocaleInput.vue'
@@ -11,7 +12,7 @@ import { createInput } from '@formkit/vue'
11
12
  import { primeInputs } from '@sfxcode/formkit-primevue'
12
13
 
13
14
  import type { FormKitNode } from '@formkit/core'
14
- import type { DefaultConfigOptions } from '@formkit/vue'
15
+ import type { DefaultConfigOptions, PluginConfigs } from '@formkit/vue'
15
16
 
16
17
  const LOCALES = ['de', 'en', 'fr', 'it'] as const
17
18
 
@@ -31,6 +32,36 @@ function localeRequired(node: FormKitNode): boolean {
31
32
  // create form: precisely the case this rule exists to catch.
32
33
  localeRequired.skipEmpty = false
33
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
+
50
+ /**
51
+ * Passes when the value is listed in the sibling field named by `address`, or when that list is
52
+ * empty — backend allow-lists treat empty as unrestricted. Reading the sibling through `node.at()`
53
+ * registers it as a validation dependency, so FormKit re-runs this rule when the list itself
54
+ * changes, not only when this field does.
55
+ *
56
+ * Advisory only: it exists so an admin sees the conflict in the form. The backend never depends on
57
+ * it — a value outside the allow-list is rejected where it is actually used.
58
+ */
59
+ const memberOf: PluginConfigs['rules'][string] = (node, address: string) => {
60
+ const allowed = node.at(address)?.value
61
+ if (!Array.isArray(allowed) || allowed.length === 0) return true
62
+ return allowed.includes(node.value)
63
+ }
64
+
34
65
  const localeRequiredMessages = {
35
66
  de: 'Mindestens eine Sprache muss ausgefüllt sein.',
36
67
  en: 'At least one language must be filled in.',
@@ -38,10 +69,30 @@ const localeRequiredMessages = {
38
69
  it: 'Almeno una lingua deve essere compilata.',
39
70
  }
40
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
+
79
+ const memberOfMessages = {
80
+ de: 'Dieser Wert steht nicht in der Liste der erlaubten Werte.',
81
+ en: 'This value is not in the allowed list.',
82
+ fr: 'Cette valeur ne figure pas dans la liste des valeurs autorisées.',
83
+ it: 'Questo valore non è presente nell\'elenco dei valori consentiti.',
84
+ }
85
+
41
86
  const config: DefaultConfigOptions = {
42
- rules: { localeRequired },
87
+ rules: { localeRequired, agentRefRequired, memberOf },
43
88
  messages: Object.fromEntries(
44
- LOCALES.map(locale => [locale, { validation: { localeRequired: localeRequiredMessages[locale] } }]),
89
+ LOCALES.map(locale => [locale, {
90
+ validation: {
91
+ localeRequired: localeRequiredMessages[locale],
92
+ agentRefRequired: agentRefRequiredMessages[locale],
93
+ memberOf: memberOfMessages[locale],
94
+ },
95
+ }]),
45
96
  ),
46
97
  inputs: {
47
98
  ...primeInputs,
@@ -51,6 +102,9 @@ const config: DefaultConfigOptions = {
51
102
  chipsInput: createInput(ChipsInput, {
52
103
  props: ['placeholder'],
53
104
  }),
105
+ cronInput: createInput(CronInput, {
106
+ props: ['timezonePlaceholder', 'filter'],
107
+ }),
54
108
  knowledgeDatabaseSelector: createInput(KnowledgeDatabaseSelector, {
55
109
  props: ['placeholder', 'filter'],
56
110
  }),