@swiss-ai-hub/web 0.320.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/components/FormKit/AgentSelector.vue +20 -7
- 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/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 +31 -33
- 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/form/useCreateInstanceForm.ts +2 -2
- package/composables/form/useFormKitTransform.ts +44 -15
- package/formkit.config.ts +24 -1
- package/i18n/locales/de.yaml +60 -5
- package/i18n/locales/en.yaml +57 -3
- package/i18n/locales/fr.yaml +58 -3
- package/i18n/locales/it.yaml +60 -3
- package/middleware/agent-admin.ts +31 -0
- 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/plugins/0.runtime-config.client.ts +5 -0
- package/plugins/oidc-client.ts +1 -1
- package/sdk/client/index.ts +29 -2
- package/sdk/client/schemas.gen.ts +641 -77
- package/sdk/client/sdk.gen.ts +159 -1
- package/sdk/client/types.gen.ts +543 -75
- 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="(
|
|
25
|
-
@remove="(
|
|
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: [
|
|
59
|
-
remove: [
|
|
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="(
|
|
99
|
-
@remove="(
|
|
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: [
|
|
123
|
-
remove: [
|
|
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
|
-
|
|
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="
|
|
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,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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// the
|
|
11
|
-
// even though the artifact IS the final release.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
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
|
-
//
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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({
|
|
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
|
+
})
|
|
@@ -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
|
@@ -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
|
}]),
|