@swiss-ai-hub/web 0.318.0 → 0.320.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/README.md CHANGED
@@ -155,8 +155,8 @@ The same single-instance requirement applies to **PrimeVue** (its theme system i
155
155
  > { "dependencies": { "vue-router": "4.6.4" } }
156
156
  > ```
157
157
  >
158
- > Verify with `pnpm why vue-router` (`@vueuse/router` must show `4.6.4`) and `pnpm build` (`nuxt generate` must succeed
159
- > forcing a single `vue-router` major instead breaks `@nuxtjs/i18n`, which needs 5.x).
158
+ > Verify with `pnpm why vue-router` (`@vueuse/router` must show `4.6.4`) and `pnpm build` (`nuxt generate` must succeed
159
+ > forcing a single `vue-router` major instead breaks `@nuxtjs/i18n`, which needs 5.x).
160
160
 
161
161
  ## Quick start
162
162
 
@@ -21,36 +21,14 @@
21
21
  </a>
22
22
  <div class="flex flex-col gap-2 text-center">
23
23
  <h2 class="text-xl text-white">
24
- {{ t('auth.login.welcome', { companyName }) }}
24
+ <slot name="heading" />
25
25
  </h2>
26
26
  <p class="text-surface-400">
27
- {{ t('auth.login.pleaseLogin') }}
27
+ <slot name="message" />
28
28
  </p>
29
29
  </div>
30
30
  <div class="flex flex-col gap-3">
31
- <ProgressSpinner
32
- v-if="isLoading"
33
- class="!h-8 !w-8"
34
- />
35
- <template v-else>
36
- <Button
37
- v-for="idp in authProviders ?? []"
38
- :key="idp.alias"
39
- :label="t('auth.login.loginWith', { provider: idp.display_name })"
40
- :icon="`pi ${idp.icon}`"
41
- icon-pos="right"
42
- class="!bg-white !text-black"
43
- @click="login(idp.alias || undefined)"
44
- />
45
- <Button
46
- v-if="(authProviders?.length ?? 0) === 0"
47
- :label="t('auth.login.title')"
48
- icon="pi pi-sign-in"
49
- icon-pos="right"
50
- class="!bg-white !text-black"
51
- @click="login()"
52
- />
53
- </template>
31
+ <slot />
54
32
  </div>
55
33
  </div>
56
34
  <a
@@ -68,13 +46,5 @@
68
46
  <script setup lang="ts">
69
47
  import logo from '@core/assets/images/logo.png'
70
48
 
71
- definePageMeta({
72
- layout: 'anonymous',
73
- })
74
-
75
49
  const { t } = useI18n()
76
- const { login } = useAuth()
77
- const { authProviders, isLoading } = useAuthProviders()
78
-
79
- const companyName = 'bbv Software Services AG'
80
50
  </script>
@@ -0,0 +1,208 @@
1
+ <template>
2
+ <div class="flex flex-col gap-4">
3
+ <div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
4
+ <div
5
+ v-for="position in POSITIONS"
6
+ :key="position.key"
7
+ >
8
+ <label
9
+ :for="`cron-${position.key}`"
10
+ class="mb-1 block text-sm font-medium"
11
+ >
12
+ {{ t(`lib.cron.${position.key}.label`) }}
13
+ </label>
14
+ <InputText
15
+ :id="`cron-${position.key}`"
16
+ :model-value="schedule[position.key]"
17
+ :aria-label="t(`lib.cron.${position.key}.label`)"
18
+ :invalid="Boolean(errors[position.key])"
19
+ :aria-invalid="Boolean(errors[position.key])"
20
+ class="w-full font-mono"
21
+ @update:model-value="(value) => updatePosition(position.key, value ?? '')"
22
+ />
23
+ <small
24
+ v-if="errors[position.key]"
25
+ class="mt-1 block text-red-500"
26
+ >
27
+ {{ errors[position.key] }}
28
+ </small>
29
+ <small
30
+ v-else
31
+ class="mt-1 block text-surface-500"
32
+ >{{ position.range }}</small>
33
+ </div>
34
+ </div>
35
+
36
+ <div>
37
+ <label
38
+ for="cron-timezone"
39
+ class="mb-1 block text-sm font-medium"
40
+ >
41
+ {{ t('lib.cron.timezone.label') }}
42
+ </label>
43
+ <Select
44
+ v-model="timezone"
45
+ :aria-label="t('lib.cron.timezone.label')"
46
+ input-id="cron-timezone"
47
+ :options="timezoneOptions"
48
+ :placeholder="timezonePlaceholder ?? t('lib.cron.timezone.placeholder')"
49
+ :filter="filter"
50
+ class="w-full"
51
+ />
52
+ <small class="mt-1 block text-surface-500">{{ t('lib.cron.timezone.help') }}</small>
53
+ </div>
54
+
55
+ <small class="text-surface-500">
56
+ {{ t('lib.cron.expression') }} <code class="font-mono">{{ expression }}</code>
57
+ </small>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ /**
63
+ * Editor for a backend `CronSchedule`: the five cron positions plus an IANA timezone.
64
+ *
65
+ * The emitted value is the schedule object in the backend's own field names, so it round-trips through
66
+ * `config_data` untouched — the scheduler reads those keys directly. Positions are kept separate rather than
67
+ * joined into one "0 12 * * *" string because that is how `CronSchedule` stores them, and flattening here would
68
+ * make every consumer parse it back apart.
69
+ *
70
+ * Deliberately has no presets and no plain-language summary — that is #1581, which extends this component rather
71
+ * than replacing it.
72
+ */
73
+
74
+ import { createMessage } from '@formkit/core'
75
+
76
+ import type { FormKitNode } from '@formkit/core'
77
+
78
+ interface CronSchedule {
79
+ minute: string
80
+ hour: string
81
+ day_of_month: string
82
+ month: string
83
+ day_of_week: string
84
+ timezone: string
85
+ }
86
+
87
+ type CronPosition = Exclude<keyof CronSchedule, 'timezone'>
88
+
89
+ interface CronInputProps {
90
+ context: {
91
+ node: FormKitNode
92
+ value?: CronSchedule | null
93
+ attrs: Record<string, unknown>
94
+ timezonePlaceholder?: string
95
+ filter?: boolean
96
+ }
97
+ }
98
+
99
+ const props = defineProps<CronInputProps>()
100
+ const { t } = useI18n()
101
+
102
+ const POSITIONS: { key: CronPosition, range: string, min: number, max: number }[] = [
103
+ { key: 'minute', range: '0-59', min: 0, max: 59 },
104
+ { key: 'hour', range: '0-23', min: 0, max: 23 },
105
+ { key: 'day_of_month', range: '1-31', min: 1, max: 31 },
106
+ { key: 'month', range: '1-12', min: 1, max: 12 },
107
+ { key: 'day_of_week', range: '0-6', min: 0, max: 6 },
108
+ ]
109
+
110
+ // Deliberately permissive: croniter accepts names (MON-FRI, JAN), steps, lists and the day-of-week specials, and a
111
+ // stricter pattern here would reject schedules the backend is happy to run. This catches the two things that are
112
+ // wrong in every cron dialect — a blank position and a stray character — and leaves grammar to the server, which
113
+ // now rejects a malformed cron on save rather than storing it.
114
+ const ALLOWED_CHARACTERS = /^[0-9A-Za-z*,\-/?#]+$/
115
+
116
+ // Blocking so the surrounding form cannot be submitted while a position is empty or malformed. Without it the only
117
+ // feedback is a 400 from the save endpoint, and before that endpoint validated, a blank position was stored and
118
+ // silently broke every run of the profile — not only its scheduled ones.
119
+ const ERROR_MESSAGE_KEY = 'cronPositions'
120
+
121
+ // Shown in the inputs the moment the field is enabled, never applied behind the user's back. `CronSchedule`
122
+ // requires all five positions and has no defaults, so leaving them blank would save a schedule the scheduler then
123
+ // skips with only a log line to say why. Hourly is the least surprising visible starting point — `* * * * *` would
124
+ // commit an admin to a run every minute for merely ticking the enable box.
125
+ const HOURLY: CronSchedule = {
126
+ minute: '0',
127
+ hour: '*',
128
+ day_of_month: '*',
129
+ month: '*',
130
+ day_of_week: '*',
131
+ timezone: resolveBrowserTimezone(),
132
+ }
133
+
134
+ const timezonePlaceholder = computed(() => props.context.timezonePlaceholder)
135
+ const filter = computed(() => props.context.filter ?? true)
136
+
137
+ const schedule = computed<CronSchedule>(() => props.context.value ?? HOURLY)
138
+
139
+ const errors = computed<Partial<Record<CronPosition, string>>>(() =>
140
+ Object.fromEntries(
141
+ POSITIONS.map(position => [position.key, positionError(position)]).filter(([, message]) => message),
142
+ ),
143
+ )
144
+
145
+ const expression = computed(() =>
146
+ POSITIONS.map(position => schedule.value[position.key] || '?').join(' '),
147
+ )
148
+
149
+ const timezone = computed({
150
+ get: () => schedule.value.timezone,
151
+ set: (value: string) => emitValue({ ...schedule.value, timezone: value }),
152
+ })
153
+
154
+ const timezoneOptions = computed<string[]>(() => {
155
+ // Not every engine implements supportedValuesOf; without it the admin can still keep the resolved zone.
156
+ const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
157
+ return supported ? supported('timeZone') : [schedule.value.timezone, 'UTC']
158
+ })
159
+
160
+ function resolveBrowserTimezone(): string {
161
+ try {
162
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
163
+ }
164
+ catch {
165
+ return 'UTC'
166
+ }
167
+ }
168
+
169
+ function positionError(position: { key: CronPosition, min: number, max: number }): string | undefined {
170
+ const value = schedule.value[position.key]
171
+ if (!value) return t('lib.cron.errors.required')
172
+ if (!ALLOWED_CHARACTERS.test(value)) return t('lib.cron.errors.invalid')
173
+
174
+ // Only plain integers are range-checked. Anything with a step, range or name is left to the server, whose
175
+ // croniter knows the dialect far better than a regex here could.
176
+ const outOfRange = value
177
+ .split(',')
178
+ .some(term => /^\d+$/.test(term) && (Number(term) < position.min || Number(term) > position.max))
179
+
180
+ return outOfRange ? t('lib.cron.errors.range', { min: position.min, max: position.max }) : undefined
181
+ }
182
+
183
+ function updatePosition(position: CronPosition, value: string) {
184
+ emitValue({ ...schedule.value, [position]: value.trim() })
185
+ }
186
+
187
+ // Always emits every key: a partial schedule passes the generated submission schema (which cannot carry
188
+ // CronSchedule's croniter and timezone validators) and is then rejected by the save endpoint.
189
+ function emitValue(value: CronSchedule) {
190
+ props.context.node.input({ ...value })
191
+ }
192
+
193
+ watch(errors, (current) => {
194
+ props.context.node.store.remove(ERROR_MESSAGE_KEY)
195
+ const first = Object.values(current)[0]
196
+ if (!first) return
197
+
198
+ props.context.node.store.set(
199
+ createMessage({ blocking: true, key: ERROR_MESSAGE_KEY, type: 'error', value: first, visible: true }),
200
+ )
201
+ }, { immediate: true })
202
+
203
+ // Seed the stored value so enabling the field yields a complete, valid schedule even if the admin saves without
204
+ // touching an input — what the inputs show and what gets saved must not diverge.
205
+ onMounted(() => {
206
+ if (!props.context.value) emitValue(HOURLY)
207
+ })
208
+ </script>
@@ -7,6 +7,7 @@
7
7
  :options="localeOptions"
8
8
  option-label="label"
9
9
  option-value="value"
10
+ :allow-empty="false"
10
11
  size="small"
11
12
  >
12
13
  <template #option="{ option }">
@@ -100,15 +101,24 @@ const localeOptions = locales.map(lang => ({
100
101
 
101
102
  const activeLocale = ref<Locale>('en')
102
103
 
104
+ function isLocale(value: unknown): value is Locale {
105
+ return locales.includes(value as Locale)
106
+ }
107
+
103
108
  // Get the current locale string value
104
109
  const localeValue = computed<LocaleStringDto>(() => {
105
110
  return props.context.value ?? { de: null, en: null, fr: null, it: null }
106
111
  })
107
112
 
108
113
  // Current input value for the active locale
114
+ // The guard is belt-and-braces next to `:allow-empty="false"`: with an unset `activeLocale`
115
+ // this setter used to write under the key `"null"`, producing a value whose four locales
116
+ // were all empty but which no longer looked like a locale object to the backend's
117
+ // normalization — so it slipped past validation and saved a blank name (issue #135).
109
118
  const currentValue = computed({
110
- get: () => localeValue.value[activeLocale.value] ?? '',
119
+ get: () => (isLocale(activeLocale.value) ? localeValue.value[activeLocale.value] ?? '' : ''),
111
120
  set: (newVal: string) => {
121
+ if (!isLocale(activeLocale.value)) return
112
122
  const updated: LocaleStringDto = {
113
123
  ...localeValue.value,
114
124
  [activeLocale.value]: newVal || null,
@@ -140,7 +140,7 @@ const { tenantId } = useTenant()
140
140
  const { getDocumentSourceUrl } = useDocumentUrl()
141
141
  const { deleteDocument, isDeleting } = useDeleteDocument()
142
142
  const { deleteDocuments, isDeleting: isBatchDeleting } = useDeleteDocuments()
143
- const { isScheduled, schedule, unschedule } = useScheduledDeletions(
143
+ const { isScheduled, scheduledAt, schedule, unschedule } = useScheduledDeletions(
144
144
  () => route.params.db as string,
145
145
  () => route.params.namespace as string,
146
146
  )
@@ -163,18 +163,24 @@ const checkedDocuments = ref<DocumentDto[]>([])
163
163
 
164
164
  const formatted = (datestr: string) => useDateFormat(new Date(datestr), 'DD.MM.YYYY')
165
165
 
166
- const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id) && document.is_ingested
166
+ const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id)
167
167
 
168
- // Processing documents are mid-ingestion; removing their source now would race the pipeline.
169
- const isDocumentDeletable = (document: DocumentDto) => document.is_ingested && !isDocumentDeleting(document)
168
+ // Deleting is safe at any stage of ingestion: the API only removes the source file, and the pipeline
169
+ // drops the document's partition before the remove job touches any store, which aborts an in-flight
170
+ // run before it writes. So the only reason to withhold the action is a deletion already in flight.
171
+ const isDocumentDeletable = (document: DocumentDto) => !isDocumentDeleting(document)
170
172
 
171
- // Ids derive from the source URI, so re-uploading a just-deleted file reuses its id. When that id
172
- // reappears as a placeholder, the scheduled entry is stale and must be cleared.
173
+ // Ids derive from the source URI, so re-uploading a just-deleted file reuses its id and would inherit
174
+ // its "Deleting" badge. An upload rewrites the document, so an updated_at newer than the moment we
175
+ // scheduled the deletion means this row is the replacement, not the one still on its way out.
173
176
  watch(
174
177
  () => props.documents,
175
178
  (documents) => {
176
179
  const reUploadedIds = (documents ?? [])
177
- .filter(document => !document.is_ingested && isScheduled(document.id))
180
+ .filter((document) => {
181
+ const requestedAt = scheduledAt(document.id)
182
+ return requestedAt != null && new Date(document.updated_at).getTime() > requestedAt
183
+ })
178
184
  .map(document => document.id)
179
185
  if (reUploadedIds.length > 0) {
180
186
  unschedule(reUploadedIds)
@@ -200,15 +206,20 @@ const onSelectAllChange = ({ checked }: { checked: boolean }) => {
200
206
 
201
207
  const handleRowClick = (event: DataTableRowClickEvent) => {
202
208
  const document = event.data as DocumentDto
203
- if (document.is_ingested && !isDocumentDeleting(document)) {
209
+ if (!isDocumentDeleting(document)) {
204
210
  emit('selected', document)
205
211
  }
206
212
  }
207
213
 
214
+ // Only a deletion in flight makes a row inert. A document still being ingested stays dimmed but usable —
215
+ // otherwise a failed ingestion leaves a row that can be neither opened nor removed.
208
216
  const getRowClass = (data: DocumentDto) => {
209
- if (!data.is_ingested || isDocumentDeleting(data)) {
217
+ if (isDocumentDeleting(data)) {
210
218
  return 'opacity-50 cursor-not-allowed pointer-events-none'
211
219
  }
220
+ if (!data.is_ingested) {
221
+ return 'opacity-50'
222
+ }
212
223
  return data.id === route.params.document_id ? 'bg-surface-100 dark:bg-surface-800' : ''
213
224
  }
214
225
 
@@ -3,18 +3,29 @@ import { minutesToMilliseconds } from 'date-fns'
3
3
 
4
4
  // Surfaces the running version of both services. The UI service version is baked
5
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
6
+ // version is read from the health endpoint. When both agree a single number is
8
7
  // shown, otherwise both are shown as `UI / API`.
9
8
  //
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.
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.
17
+ //
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.
21
+ //
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.
16
25
  const RELEASE_CHANNEL = 'latest'
17
- const toReleaseVersion = (version: string): string => version.replace(/-rc\.\d+$/, '')
26
+ const RC_SUFFIX = /-rc\.\d+$/
27
+ const toReleaseVersion = (version: string): string => version.replace(RC_SUFFIX, '')
28
+ const toComparable = (version: string): string => version.replace(/^v/, '')
18
29
 
19
30
  export const useAppVersion = defineQuery(() => {
20
31
  const bakedUiVersion = useRuntimeConfig().public.appVersion as string
@@ -28,13 +39,21 @@ export const useAppVersion = defineQuery(() => {
28
39
  },
29
40
  })
30
41
 
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(() => {
45
+ const api = apiVersion.value
46
+ if (!api) return false
47
+ return api === RELEASE_CHANNEL || toComparable(api) === toComparable(toReleaseVersion(bakedUiVersion))
48
+ })
49
+
31
50
  const uiVersion = computed(() =>
32
- apiVersion.value === RELEASE_CHANNEL ? toReleaseVersion(bakedUiVersion) : bakedUiVersion,
51
+ isReleaseDeployment.value ? toReleaseVersion(bakedUiVersion) : bakedUiVersion,
33
52
  )
34
53
 
35
54
  const versionDisplay = computed(() => {
36
55
  const api = apiVersion.value
37
- if (!api || api === uiVersion.value) return uiVersion.value
56
+ if (!api || toComparable(api) === toComparable(uiVersion.value)) return uiVersion.value
38
57
  return `${uiVersion.value} / ${api}`
39
58
  })
40
59
 
@@ -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
  }
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'
@@ -10,9 +11,66 @@ import { en, de, fr, it } from '@formkit/i18n'
10
11
  import { createInput } from '@formkit/vue'
11
12
  import { primeInputs } from '@sfxcode/formkit-primevue'
12
13
 
13
- import type { DefaultConfigOptions } from '@formkit/vue'
14
+ import type { FormKitNode } from '@formkit/core'
15
+ import type { DefaultConfigOptions, PluginConfigs } from '@formkit/vue'
16
+
17
+ const LOCALES = ['de', 'en', 'fr', 'it'] as const
18
+
19
+ // FormKit's built-in `required` rule only asks whether a value is present, and a localeInput's
20
+ // value is always a `{de, en, fr, it}` object — non-empty, so `required` passes even when every
21
+ // locale inside it is blank. Backend `LocaleInput` elements emit `localeRequired` instead
22
+ // (see packages/core/swiss_ai_hub/core/form/elements/locale_input.py).
23
+ function localeRequired(node: FormKitNode): boolean {
24
+ const value = node.value as Record<string, string | null> | null | undefined
25
+ if (!value) return false
26
+ return LOCALES.some(locale => !!value[locale]?.trim())
27
+ }
28
+
29
+ // FormKit skips a rule entirely when the node's value is empty, unless the rule opts out —
30
+ // which is why the built-in `required` sets this too. Without it the rule would never run on a
31
+ // never-touched field, whose value is still `null`, and a blank Name would pass on a fresh
32
+ // create form: precisely the case this rule exists to catch.
33
+ localeRequired.skipEmpty = false
34
+
35
+ /**
36
+ * Passes when the value is listed in the sibling field named by `address`, or when that list is
37
+ * empty — backend allow-lists treat empty as unrestricted. Reading the sibling through `node.at()`
38
+ * registers it as a validation dependency, so FormKit re-runs this rule when the list itself
39
+ * changes, not only when this field does.
40
+ *
41
+ * Advisory only: it exists so an admin sees the conflict in the form. The backend never depends on
42
+ * it — a value outside the allow-list is rejected where it is actually used.
43
+ */
44
+ const memberOf: PluginConfigs['rules'][string] = (node, address: string) => {
45
+ const allowed = node.at(address)?.value
46
+ if (!Array.isArray(allowed) || allowed.length === 0) return true
47
+ return allowed.includes(node.value)
48
+ }
49
+
50
+ const localeRequiredMessages = {
51
+ de: 'Mindestens eine Sprache muss ausgefüllt sein.',
52
+ en: 'At least one language must be filled in.',
53
+ fr: 'Au moins une langue doit être renseignée.',
54
+ it: 'Almeno una lingua deve essere compilata.',
55
+ }
56
+
57
+ const memberOfMessages = {
58
+ de: 'Dieser Wert steht nicht in der Liste der erlaubten Werte.',
59
+ en: 'This value is not in the allowed list.',
60
+ fr: 'Cette valeur ne figure pas dans la liste des valeurs autorisées.',
61
+ it: 'Questo valore non è presente nell\'elenco dei valori consentiti.',
62
+ }
14
63
 
15
64
  const config: DefaultConfigOptions = {
65
+ rules: { localeRequired, memberOf },
66
+ messages: Object.fromEntries(
67
+ LOCALES.map(locale => [locale, {
68
+ validation: {
69
+ localeRequired: localeRequiredMessages[locale],
70
+ memberOf: memberOfMessages[locale],
71
+ },
72
+ }]),
73
+ ),
16
74
  inputs: {
17
75
  ...primeInputs,
18
76
  agentSelector: createInput(AgentSelector, {
@@ -21,6 +79,9 @@ const config: DefaultConfigOptions = {
21
79
  chipsInput: createInput(ChipsInput, {
22
80
  props: ['placeholder'],
23
81
  }),
82
+ cronInput: createInput(CronInput, {
83
+ props: ['timezonePlaceholder', 'filter'],
84
+ }),
24
85
  knowledgeDatabaseSelector: createInput(KnowledgeDatabaseSelector, {
25
86
  props: ['placeholder', 'filter'],
26
87
  }),
@@ -557,7 +557,9 @@ auth:
557
557
  description: Bitte melden Sie sich an, um auf die Anwendung zuzugreifen
558
558
  tagline: Erleben Sie die Zukunft der künstlichen Intelligenz
559
559
  welcome: Willkommen im AI Hub von {companyName}
560
+ welcomeProvider: Willkommen im AI Hub — Anmeldung mit {provider}
560
561
  pleaseLogin: Bitte melden Sie sich an
562
+ pleaseLoginWith: Bitte melden Sie sich mit {provider} an
561
563
  loginWith: Anmelden mit {provider}
562
564
  customSolutions: Massgeschneiderte KI-Lösungen für Ihr Unternehmen
563
565
  logoAlt: Logo des Unternehmens
@@ -795,6 +797,28 @@ form:
795
797
  nicht wie erwartet. Um unerwartetes Verhalten zu vermeiden, deaktivieren Sie
796
798
  sie bitte vorübergehend, bis eine Korrektur veröffentlicht wird.
797
799
  lib:
800
+ cron:
801
+ errors:
802
+ required: Jede Position ist erforderlich — eine leere ergibt keinen gültigen
803
+ Zeitplan.
804
+ invalid: Hier sind nur Ziffern und die Zeichen * , - / ? erlaubt.
805
+ range: Werte müssen zwischen {min} und {max} liegen.
806
+ expression: 'Resultierender Cron-Ausdruck:'
807
+ minute:
808
+ label: Minute
809
+ hour:
810
+ label: Stunde
811
+ day_of_month:
812
+ label: Tag des Monats
813
+ month:
814
+ label: Monat
815
+ day_of_week:
816
+ label: Wochentag
817
+ timezone:
818
+ label: Zeitzone
819
+ placeholder: Zeitzone auswählen
820
+ help: Die Cron-Positionen werden in dieser Zeitzone interpretiert, damit ein
821
+ täglicher Zeitplan über Zeitumstellungen hinweg dieselbe Uhrzeit behält.
798
822
  vectorStore:
799
823
  label: Vektorspeicher
800
824
  help: Wissensdatenbank und Namensräume für die Suche auswählen
@@ -548,7 +548,9 @@ auth:
548
548
  description: Please log in to access the application
549
549
  tagline: Experience the future of artificial intelligence
550
550
  welcome: Welcome to the AI Hub of {companyName}
551
+ welcomeProvider: Welcome to the AI Hub — sign in with {provider}
551
552
  pleaseLogin: Please log in
553
+ pleaseLoginWith: Please log in with {provider}
552
554
  loginWith: Login with {provider}
553
555
  customSolutions: Custom AI solutions for your company
554
556
  logoAlt: Logo of company
@@ -782,6 +784,27 @@ form:
782
784
  org_memory_performance: This feature may not work as expected in some cases. To
783
785
  avoid unexpected behavior, please temporarily disable it until a fix is released.
784
786
  lib:
787
+ cron:
788
+ errors:
789
+ required: Every position is required — a blank one is not a valid schedule.
790
+ invalid: Only digits and the characters * , - / ? are allowed here.
791
+ range: Values must be between {min} and {max}.
792
+ expression: 'Resulting cron expression:'
793
+ minute:
794
+ label: Minute
795
+ hour:
796
+ label: Hour
797
+ day_of_month:
798
+ label: Day of month
799
+ month:
800
+ label: Month
801
+ day_of_week:
802
+ label: Day of week
803
+ timezone:
804
+ label: Timezone
805
+ placeholder: Select a timezone
806
+ help: The cron positions are interpreted in this timezone, so a daily schedule
807
+ keeps the same wall-clock time across daylight-saving changes.
785
808
  vectorStore:
786
809
  label: Vector Store
787
810
  help: Select the knowledge database and namespaces to search