@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.
- package/{pages/auth/login.vue → components/Auth/LoginPanel.vue} +3 -33
- package/components/FormKit/AgentSelector.vue +20 -7
- package/components/FormKit/CronInput.vue +208 -0
- package/components/FormKit/Repeater.vue +1 -4
- package/components/FormKit/VectorStoreInput.vue +43 -6
- package/components/Knowledge/Database/CreateModal.vue +315 -0
- package/components/Knowledge/Database/EmptyCard.vue +34 -0
- package/components/Knowledge/DeleteConfirmModal.vue +87 -0
- package/components/Knowledge/Document/List.vue +20 -9
- package/components/Knowledge/Namespace/Card.vue +18 -0
- package/components/Knowledge/Namespace/CreateModal.vue +11 -3
- package/components/Role/AccessCapabilities.vue +4 -4
- package/components/Role/AccessCapabilityGroup.vue +22 -6
- package/components/Role/AccessRulesEditor.vue +15 -12
- package/composables/app/useAppVersion.ts +37 -20
- package/composables/auth/useAuth.ts +3 -11
- package/composables/document/useCreateDatabase.ts +21 -0
- package/composables/document/useDeleteDatabase.ts +29 -0
- package/composables/document/useDeleteDocument.ts +1 -1
- package/composables/document/useDeleteDocuments.ts +1 -1
- package/composables/document/useDeleteNamespace.ts +30 -0
- package/composables/document/useIngestors.ts +23 -0
- package/composables/document/useScheduledDeletions.ts +16 -8
- package/composables/form/useCreateInstanceForm.ts +2 -2
- package/composables/form/useFormKitTransform.ts +44 -15
- package/formkit.config.ts +57 -3
- package/i18n/locales/de.yaml +84 -5
- package/i18n/locales/en.yaml +80 -3
- package/i18n/locales/fr.yaml +82 -3
- package/i18n/locales/it.yaml +84 -3
- package/middleware/agent-admin.ts +31 -0
- package/middleware/auth.global.ts +34 -16
- package/package.json +1 -1
- package/pages/[tenant]/service/agents.vue +3 -0
- package/pages/[tenant]/service/knowledge/[db]/[namespace]/[document_id].vue +12 -5
- package/pages/[tenant]/service/knowledge.vue +138 -0
- package/pages/auth/login/[idp].vue +57 -0
- package/pages/auth/login/index.vue +46 -0
- package/plugins/0.runtime-config.client.ts +5 -0
- package/plugins/oidc-client.ts +1 -1
- package/sdk/client/client/client.gen.ts +1 -3
- package/sdk/client/client/types.gen.ts +7 -13
- package/sdk/client/client/utils.gen.ts +1 -2
- package/sdk/client/core/queryKeySerializer.gen.ts +1 -6
- package/sdk/client/core/types.gen.ts +5 -3
- package/sdk/client/index.ts +47 -4
- package/sdk/client/schemas.gen.ts +1787 -780
- package/sdk/client/sdk.gen.ts +231 -1
- package/sdk/client/types.gen.ts +1103 -226
- package/plugins/keycloak-client.ts +0 -41
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
variant="text"
|
|
71
71
|
rounded
|
|
72
72
|
size="small"
|
|
73
|
-
@click="
|
|
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="
|
|
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="
|
|
151
|
-
@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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
207
|
-
|
|
207
|
+
const removeRules = (removed: string[]) => {
|
|
208
|
+
rules.value = rules.value.filter(rule => !removed.includes(rule))
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
const
|
|
211
|
-
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
11
|
-
// build that was tested, so
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
38
|
-
|
|
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({
|
|
5
|
+
$auth.signinRedirect({ extraQueryParams })
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const logout = async () => {
|
|
9
|
-
const { $auth
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* the
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
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)
|
|
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, {
|
|
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
|
}),
|