@swiss-ai-hub/web 0.321.1 → 0.323.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/components/Event/Display/AddMemoryToChatHistoryEvent.vue +3 -3
  2. package/components/Event/Display/AttachedFileEvent.vue +42 -0
  3. package/components/Event/Display/AttachedFilesReadEvent.vue +22 -0
  4. package/components/Event/Display/BaseRetrieveMemoryEvent.vue +2 -2
  5. package/components/Event/Display/BaseStoreMemoryEvent.vue +2 -2
  6. package/components/Event/Display/CompleteConversationEvent.vue +35 -0
  7. package/components/Event/Display/ComposeContextEvent.vue +38 -0
  8. package/components/Event/Display/ContextComposedEvent.vue +60 -0
  9. package/components/Event/Display/ContextualizeConversationEvent.vue +26 -0
  10. package/components/Event/Display/ConversationContextualizedEvent.vue +34 -0
  11. package/components/Event/Display/HumanInTheLoopResponseEvent.vue +11 -1
  12. package/components/Event/Display/KnowledgeSearchedEvent.vue +33 -0
  13. package/components/Event/Display/MemoryRecalledEvent.vue +33 -0
  14. package/components/Event/Display/MemoryStorageRequestedEvent.vue +29 -0
  15. package/components/Event/Display/NotAMetaQuestionEvent.vue +20 -0
  16. package/components/Event/Display/Part/Facts.vue +39 -0
  17. package/components/Event/Display/Part/MessageList.vue +50 -0
  18. package/components/Event/Display/Part/ToolCalls.vue +58 -0
  19. package/components/Event/Display/Part/ToolLoopState.vue +36 -0
  20. package/components/Event/Display/ReadAttachedFilesEvent.vue +28 -0
  21. package/components/Event/Display/RecallMemoryEvent.vue +27 -0
  22. package/components/Event/Display/RunToolLoopEvent.vue +34 -0
  23. package/components/Event/Display/SandboxFileDisplayedEvent.vue +46 -0
  24. package/components/Event/Display/SearchKnowledgeEvent.vue +31 -0
  25. package/components/Event/Display/ToolCallApprovedEvent.vue +27 -0
  26. package/components/Event/Display/ToolCallsDecidedEvent.vue +24 -0
  27. package/components/Event/Display/ToolLoopCondensedEvent.vue +31 -0
  28. package/components/Event/Display/ToolLoopFinishedEvent.vue +49 -0
  29. package/components/Event/Display/ToolLoopIterationEvent.vue +18 -0
  30. package/components/Event/Display/ToolLoopStatusEvent.vue +27 -0
  31. package/components/Event/Display/ToolResultEvent.vue +38 -0
  32. package/components/Event/List/EventList.vue +1 -1
  33. package/components/FormKit/DynamicConfiguration.vue +6 -1
  34. package/components/FormKit/KnowledgeCollectionSelector.vue +281 -0
  35. package/components/FormKit/KnowledgeDatabaseSelector.vue +7 -0
  36. package/components/FormKit/LocaleInput.vue +2 -0
  37. package/components/FormKit/SecretFileInput.vue +181 -0
  38. package/components/FormKit/VectorStoreInput.vue +20 -1
  39. package/components/Knowledge/Database/CreateModal.vue +91 -2
  40. package/components/Knowledge/Database/SourceModal.vue +165 -0
  41. package/components/Knowledge/Document/UploadModal.vue +4 -0
  42. package/components/Knowledge/Namespace/Card.vue +4 -4
  43. package/components/Knowledge/Namespace/CreateModal.vue +1 -1
  44. package/components/Knowledge/Namespace/EditModal.vue +3 -4
  45. package/components/Role/AccessRulesEditor.vue +1 -1
  46. package/components/Service/Selection.vue +7 -1
  47. package/components/Support/IncidentButton.vue +37 -0
  48. package/components/Support/IncidentDialog.vue +119 -0
  49. package/components/User/Settings.vue +9 -8
  50. package/components/UserFiles/Browser.vue +361 -0
  51. package/components/UserFiles/MoveDialog.vue +77 -0
  52. package/components/UserFiles/NameDialog.vue +59 -0
  53. package/components/UserFiles/Preview.vue +110 -0
  54. package/composables/auth/useAuth.ts +19 -3
  55. package/composables/document/useDocumentIngestionPolling.ts +64 -0
  56. package/composables/document/useDocuments.ts +3 -0
  57. package/composables/document/useSourcePipelines.ts +23 -0
  58. package/composables/document/useUpdateDatabaseSource.ts +21 -0
  59. package/composables/event/useEventComponent.ts +46 -0
  60. package/composables/form/useFormKitTransform.ts +68 -17
  61. package/composables/openwebui/useOpenWebuiDisclaimer.ts +22 -0
  62. package/composables/suite/useApps.ts +12 -3
  63. package/composables/suite/useSuite.ts +2 -0
  64. package/composables/support/useIncidentAvailability.ts +17 -0
  65. package/composables/support/useIncidentContext.ts +56 -0
  66. package/composables/support/useIncidentForm.ts +45 -0
  67. package/composables/support/useIncidentReport.ts +16 -0
  68. package/composables/support/useOpenWebUIContext.ts +38 -0
  69. package/composables/tenant/useChatDisclaimer.ts +24 -0
  70. package/composables/user/useApplyLocale.ts +23 -0
  71. package/composables/user/useMyLocale.ts +20 -0
  72. package/composables/user/useRestorePreferredLocale.ts +38 -0
  73. package/composables/userFiles/useCreateUserFolder.ts +15 -0
  74. package/composables/userFiles/useDeleteUserFile.ts +15 -0
  75. package/composables/userFiles/useMoveUserFile.ts +15 -0
  76. package/composables/userFiles/useUploadUserFiles.ts +19 -0
  77. package/composables/userFiles/useUserFileContent.ts +24 -0
  78. package/composables/userFiles/useUserFiles.ts +25 -0
  79. package/formkit.config.ts +10 -2
  80. package/i18n/locales/de.yaml +171 -3
  81. package/i18n/locales/en.yaml +172 -3
  82. package/i18n/locales/fr.yaml +173 -3
  83. package/i18n/locales/it.yaml +173 -3
  84. package/layouts/default.vue +7 -1
  85. package/middleware/auth.global.ts +6 -3
  86. package/middleware/home-redirect.ts +15 -4
  87. package/package.json +4 -3
  88. package/pages/[tenant]/service/knowledge/[db]/[namespace].vue +6 -8
  89. package/pages/[tenant]/service/knowledge.vue +33 -7
  90. package/pages/[tenant]/service/openai/[thread_id]/[display_id]/files.vue +51 -0
  91. package/pages/[tenant]/service/openai.vue +61 -17
  92. package/pages/[tenant]/service/user-knowledge.vue +19 -0
  93. package/plugins/api-client.client.ts +5 -0
  94. package/plugins/oidc-client.ts +14 -7
  95. package/sdk/client/index.ts +171 -1
  96. package/sdk/client/schemas.gen.ts +18602 -8560
  97. package/sdk/client/sdk.gen.ts +482 -0
  98. package/sdk/client/types.gen.ts +13616 -7044
  99. package/types/EventFact.ts +4 -0
  100. package/utils/apiResponseGuard.ts +97 -0
@@ -0,0 +1,110 @@
1
+ <template>
2
+ <div class="flex h-full flex-col gap-3">
3
+ <div class="flex items-center justify-between gap-2">
4
+ <p class="truncate font-semibold">
5
+ {{ entry.name }}
6
+ </p>
7
+ <div class="flex gap-1">
8
+ <Button
9
+ v-tooltip.bottom="t('userFiles.actions.download')"
10
+ icon="pi pi-download"
11
+ size="small"
12
+ text
13
+ @click="download(entry.path, entry.name)"
14
+ />
15
+ <Button
16
+ v-tooltip.bottom="t('userFiles.actions.close')"
17
+ icon="pi pi-times"
18
+ size="small"
19
+ text
20
+ @click="emit('close')"
21
+ />
22
+ </div>
23
+ </div>
24
+ <ProgressBar
25
+ v-if="isLoading"
26
+ mode="indeterminate"
27
+ style="height: 2px"
28
+ />
29
+ <img
30
+ v-else-if="kind === 'image' && objectUrl"
31
+ :src="objectUrl"
32
+ :alt="entry.name"
33
+ class="max-h-[70vh] self-start rounded border border-surface-200 object-contain dark:border-surface-700"
34
+ >
35
+ <iframe
36
+ v-else-if="kind === 'pdf' && objectUrl"
37
+ :src="objectUrl"
38
+ :title="entry.name"
39
+ class="h-[70vh] w-full rounded border border-surface-200 dark:border-surface-700"
40
+ />
41
+ <pre
42
+ v-else-if="kind === 'text'"
43
+ class="max-h-[70vh] overflow-auto whitespace-pre-wrap rounded border border-surface-200 bg-surface-0 p-3 text-xs dark:border-surface-700 dark:bg-surface-900"
44
+ >{{ text }}</pre>
45
+ <p
46
+ v-else
47
+ class="text-sm text-surface-500"
48
+ >
49
+ {{ t('userFiles.preview.unavailable') }}
50
+ </p>
51
+ </div>
52
+ </template>
53
+
54
+ <script setup lang="ts">
55
+ import type { FileEntryDto } from '@core/sdk/client'
56
+
57
+ const props = defineProps<{ entry: FileEntryDto }>()
58
+ const emit = defineEmits<{ close: [] }>()
59
+ const { t } = useI18n()
60
+ const { fetchBlob, download } = useUserFileContent()
61
+
62
+ const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
63
+ const TEXT_EXTENSIONS = new Set(['txt', 'md', 'csv', 'json', 'log', 'py', 'yaml', 'yml', 'xml', 'html', 'js', 'ts', 'sql'])
64
+ const MAX_TEXT_BYTES = 2 * 1024 * 1024
65
+
66
+ const objectUrl = ref<string | null>(null)
67
+ const text = ref('')
68
+ const isLoading = ref(false)
69
+
70
+ const kind = computed(() => {
71
+ const extension = props.entry.name.split('.').pop()?.toLowerCase() ?? ''
72
+ if (IMAGE_EXTENSIONS.has(extension)) return 'image'
73
+ if (extension === 'pdf') return 'pdf'
74
+ if (TEXT_EXTENSIONS.has(extension) && (props.entry.size ?? 0) <= MAX_TEXT_BYTES) return 'text'
75
+ return 'none'
76
+ })
77
+
78
+ // Only the latest load may touch the preview: one overtaken by another selection or by unmounting is dropped.
79
+ let loadGeneration = 0
80
+
81
+ const revokeObjectUrl = () => {
82
+ if (objectUrl.value) URL.revokeObjectURL(objectUrl.value)
83
+ objectUrl.value = null
84
+ }
85
+
86
+ const load = async () => {
87
+ const generation = ++loadGeneration
88
+ const previewKind = kind.value
89
+ revokeObjectUrl()
90
+ text.value = ''
91
+ isLoading.value = previewKind !== 'none'
92
+ if (previewKind === 'none') return
93
+ try {
94
+ const blob = await fetchBlob(props.entry.path)
95
+ const content = previewKind === 'text' ? await blob.text() : null
96
+ if (generation !== loadGeneration) return
97
+ if (content === null) objectUrl.value = URL.createObjectURL(blob)
98
+ else text.value = content
99
+ }
100
+ finally {
101
+ if (generation === loadGeneration) isLoading.value = false
102
+ }
103
+ }
104
+
105
+ watch(() => props.entry.path, load, { immediate: true })
106
+ onBeforeUnmount(() => {
107
+ loadGeneration++
108
+ revokeObjectUrl()
109
+ })
110
+ </script>
@@ -1,13 +1,29 @@
1
1
  export const useAuth = () => {
2
+ // The UserManager in plugins/oidc-client.ts is built once at bootstrap, so the
3
+ // locale baked into its redirect URIs is whatever the app started in. Switching
4
+ // language is a client-side route push, so those URIs go stale and a round trip
5
+ // through Keycloak lands back on the bootstrap locale -- which @nuxtjs/i18n then
6
+ // writes into the `i18n_redirected` cookie, destroying the user's choice.
7
+ // Passing the URIs per call keeps them tied to the locale that is live *now*.
8
+ const localeRedirectUri = (path: string) => {
9
+ const { $i18n } = useNuxtApp()
10
+ return `${globalThis.location.origin}/${$i18n.locale.value}/auth/${path}`
11
+ }
12
+
2
13
  const login = (idpHint?: string) => {
3
- const { $auth } = useNuxtApp()
14
+ const { $auth, $i18n } = useNuxtApp()
4
15
  const extraQueryParams = idpHint ? { kc_idp_hint: idpHint } : {}
5
- $auth.signinRedirect({ extraQueryParams })
16
+ $auth.signinRedirect({
17
+ extraQueryParams,
18
+ redirect_uri: localeRedirectUri('callback'),
19
+ // Renders Keycloak's own login page in the user's language.
20
+ ui_locales: $i18n.locale.value,
21
+ })
6
22
  }
7
23
 
8
24
  const logout = async () => {
9
25
  const { $auth } = useNuxtApp()
10
- await $auth.signoutRedirect()
26
+ await $auth.signoutRedirect({ post_logout_redirect_uri: localeRedirectUri('login') })
11
27
  }
12
28
 
13
29
  const getUser = async () => {
@@ -0,0 +1,64 @@
1
+ import { useDocumentVisibility, useIntervalFn } from '@vueuse/core'
2
+
3
+ import type { DocumentDto } from '@core/sdk/client'
4
+ import type { MaybeRefOrGetter } from 'vue'
5
+
6
+ // Ingestion runs asynchronously in Dagster and nothing pushes its completion to the UI, so a freshly uploaded
7
+ // document would stay "Processing" until the next focus or remount. Polling runs only while a row on the page is
8
+ // still pending and the tab is visible, so a fully ingested page costs nothing.
9
+ const INGESTION_POLL_INTERVAL_MS = 5_000
10
+
11
+ export function useDocumentIngestionPolling(
12
+ documents: MaybeRefOrGetter<DocumentDto[]>,
13
+ refetch: () => Promise<unknown>,
14
+ isFetching: MaybeRefOrGetter<boolean>,
15
+ ) {
16
+ const route = useRoute()
17
+ const { tenantId } = useTenant()
18
+ const queryCache = useQueryCache()
19
+ const visibility = useDocumentVisibility()
20
+ const { isScheduled } = useScheduledDeletions(
21
+ () => route.params.db as string,
22
+ () => route.params.namespace as string,
23
+ )
24
+
25
+ // A row being deleted never becomes ingested and its cleanup can take hours, so it must not keep the page polling.
26
+ const pendingIds = computed(() => new Set(
27
+ toValue(documents)
28
+ .filter(document => !document.is_ingested && !isScheduled(document.id))
29
+ .map(document => document.id),
30
+ ))
31
+
32
+ // refetch() aborts the request still in flight, so a list slower than the interval would never land. Skipping the
33
+ // tick lets it finish instead.
34
+ const { pause, resume } = useIntervalFn(() => {
35
+ if (!toValue(isFetching)) {
36
+ refetch()
37
+ }
38
+ }, INGESTION_POLL_INTERVAL_MS, { immediate: false })
39
+
40
+ watch(
41
+ [() => pendingIds.value.size > 0, visibility],
42
+ ([hasPendingDocuments, visibilityState]) => {
43
+ if (hasPendingDocuments && visibilityState === 'visible') {
44
+ resume()
45
+ }
46
+ else {
47
+ pause()
48
+ }
49
+ },
50
+ { immediate: true },
51
+ )
52
+
53
+ // The detail, nodes and summary queries of a document share its key prefix and are cached for minutes, so an open
54
+ // detail view would keep showing the pre-ingestion state without this. A document that left the page before we saw
55
+ // it finish is refreshed too; invalidating one that is gone is harmless.
56
+ watch(pendingIds, (currentPendingIds, previousPendingIds) => {
57
+ const settledIds = [...(previousPendingIds ?? [])].filter(documentId => !currentPendingIds.has(documentId))
58
+ for (const documentId of settledIds) {
59
+ queryCache.invalidateQueries({
60
+ key: ['tenant', tenantId.value, 'knowledge', 'databases', route.params.db as string, 'namespaces', route.params.namespace as string, 'documents', documentId],
61
+ })
62
+ }
63
+ })
64
+ }
@@ -88,11 +88,14 @@ export const useDocuments = defineQuery(() => {
88
88
  // Only show loading on initial load, not during refetch/search when we have data
89
89
  const isLoading = computed(() => documentsQuery.asyncStatus.value === 'loading' && !documentsQuery.state.value?.data)
90
90
  const isFetching = computed(() => documentsQuery.asyncStatus.value === 'loading')
91
+ // Only a changed search, sort or page shows the previous results as placeholder; a background refetch does not.
92
+ const isSearching = computed(() => isFetching.value && documentsQuery.isPlaceholderData.value)
91
93
 
92
94
  return {
93
95
  documents,
94
96
  isLoading,
95
97
  isFetching,
98
+ isSearching,
96
99
  pagination: paginationMeta,
97
100
  currentPage,
98
101
  pageSize,
@@ -0,0 +1,23 @@
1
+ import { getSourcePipelines, type SourcePipelineDto } from '@core/sdk/client'
2
+ import { minutesToMilliseconds } from 'date-fns'
3
+
4
+ export const useSourcePipelines = defineQuery(() => {
5
+ const { tenantId } = useTenant()
6
+
7
+ const { data: sourcePipelines, isPending: sourcePipelinesAreLoading } = useQuery<SourcePipelineDto[]>({
8
+ key: () => ['tenant', tenantId.value, 'source-pipelines'],
9
+ staleTime: minutesToMilliseconds(5),
10
+ enabled: useTenantReady(),
11
+ query: async () => {
12
+ return await getSourcePipelines({
13
+ composable: '$fetch',
14
+ path: { tenant_id: tenantId.value! },
15
+ })
16
+ },
17
+ })
18
+
19
+ return {
20
+ sourcePipelines,
21
+ sourcePipelinesAreLoading,
22
+ }
23
+ })
@@ -0,0 +1,21 @@
1
+ import { updateDatabaseSource, type UpdateDatabaseSourceRequest } from '@core/sdk/client'
2
+
3
+ export const useUpdateDatabaseSource = defineMutation(() => {
4
+ const queryCache = useQueryCache()
5
+ const { tenantId } = useTenant()
6
+
7
+ return useMutation({
8
+ mutation: (params: { database: string, tenantId: string, request: UpdateDatabaseSourceRequest }) =>
9
+ updateDatabaseSource({
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
+ })
@@ -31,7 +31,30 @@ import {
31
31
  EventDisplayBaseRetrieveMemoryEvent,
32
32
  EventDisplayBaseStoreMemoryEvent,
33
33
  EventDisplayAddMemoryToChatHistoryEvent,
34
+ EventDisplayContextComposedEvent,
34
35
  EventDisplayMetaQuestionDetectedEvent,
36
+ EventDisplayContextualizeConversationEvent,
37
+ EventDisplayConversationContextualizedEvent,
38
+ EventDisplayComposeContextEvent,
39
+ EventDisplayCompleteConversationEvent,
40
+ EventDisplayNotAMetaQuestionEvent,
41
+ EventDisplayRecallMemoryEvent,
42
+ EventDisplayMemoryRecalledEvent,
43
+ EventDisplayMemoryStorageRequestedEvent,
44
+ EventDisplaySearchKnowledgeEvent,
45
+ EventDisplayKnowledgeSearchedEvent,
46
+ EventDisplayReadAttachedFilesEvent,
47
+ EventDisplayAttachedFilesReadEvent,
48
+ EventDisplayAttachedFileEvent,
49
+ EventDisplayRunToolLoopEvent,
50
+ EventDisplayToolLoopIterationEvent,
51
+ EventDisplayToolCallsDecidedEvent,
52
+ EventDisplayToolCallApprovedEvent,
53
+ EventDisplayToolResultEvent,
54
+ EventDisplayToolLoopStatusEvent,
55
+ EventDisplayToolLoopCondensedEvent,
56
+ EventDisplayToolLoopFinishedEvent,
57
+ EventDisplaySandboxFileDisplayedEvent,
35
58
  } from '#components'
36
59
 
37
60
  export const useEventComponent = () => {
@@ -74,7 +97,30 @@ export const useEventComponent = () => {
74
97
  BaseRetrieveMemoryEvent: EventDisplayBaseRetrieveMemoryEvent,
75
98
  BaseStoreMemoryEvent: EventDisplayBaseStoreMemoryEvent,
76
99
  AddMemoryToChatHistoryEvent: EventDisplayAddMemoryToChatHistoryEvent,
100
+ ContextComposedEvent: EventDisplayContextComposedEvent,
77
101
  MetaQuestionDetectedEvent: EventDisplayMetaQuestionDetectedEvent,
102
+ ContextualizeConversationEvent: EventDisplayContextualizeConversationEvent,
103
+ ConversationContextualizedEvent: EventDisplayConversationContextualizedEvent,
104
+ ComposeContextEvent: EventDisplayComposeContextEvent,
105
+ CompleteConversationEvent: EventDisplayCompleteConversationEvent,
106
+ NotAMetaQuestionEvent: EventDisplayNotAMetaQuestionEvent,
107
+ RecallMemoryEvent: EventDisplayRecallMemoryEvent,
108
+ MemoryRecalledEvent: EventDisplayMemoryRecalledEvent,
109
+ MemoryStorageRequestedEvent: EventDisplayMemoryStorageRequestedEvent,
110
+ SearchKnowledgeEvent: EventDisplaySearchKnowledgeEvent,
111
+ KnowledgeSearchedEvent: EventDisplayKnowledgeSearchedEvent,
112
+ ReadAttachedFilesEvent: EventDisplayReadAttachedFilesEvent,
113
+ AttachedFilesReadEvent: EventDisplayAttachedFilesReadEvent,
114
+ AttachedFileEvent: EventDisplayAttachedFileEvent,
115
+ RunToolLoopEvent: EventDisplayRunToolLoopEvent,
116
+ ToolLoopIterationEvent: EventDisplayToolLoopIterationEvent,
117
+ ToolCallsDecidedEvent: EventDisplayToolCallsDecidedEvent,
118
+ ToolCallApprovedEvent: EventDisplayToolCallApprovedEvent,
119
+ ToolResultEvent: EventDisplayToolResultEvent,
120
+ ToolLoopStatusEvent: EventDisplayToolLoopStatusEvent,
121
+ ToolLoopCondensedEvent: EventDisplayToolLoopCondensedEvent,
122
+ ToolLoopFinishedEvent: EventDisplayToolLoopFinishedEvent,
123
+ SandboxFileDisplayedEvent: EventDisplaySandboxFileDisplayedEvent,
78
124
  }
79
125
  const exact_match = mapping[event.event._event_name]
80
126
  if (exact_match) {
@@ -268,6 +268,8 @@ const EXCLUDED_FIELDS = new Set([
268
268
  'nullable', // Wrapper-level signal for the transform; never a FormKit/PrimeVue prop
269
269
  'defaultEnabled', // Wrapper-level signal (initial nullable-toggle state); never a FormKit prop
270
270
  'default_enabled', // snake_case form of the above
271
+ 'toggleLabel', // Rendered on the synthetic nullable toggle, never on the element itself
272
+ 'toggleHelp', // Rendered on the synthetic nullable toggle, never on the element itself
271
273
  // Backend serialises the Pydantic default into element.value (form duality). FormKit pushes
272
274
  // schema `value` up to the parent v-model on input registration, which would clobber the
273
275
  // loaded data with the backend default. Defaults belong in data, seeded via seedFormDefaults.
@@ -286,6 +288,15 @@ function buildNodeProperties(
286
288
  return buildLocaleInputProperties(element, label, locale)
287
289
  }
288
290
 
291
+ // Markup, not an input: an `$el` element carries no formkit type, and giving it one anyway
292
+ // emitted `$formkit: undefined` alongside the tag, which FormKit rendered as an empty box.
293
+ const tag = element.$el ?? element.el
294
+ if (!formkitType && tag) {
295
+ const markup: Record<string, unknown> = { $el: tag as string }
296
+ if (element.attrs) markup.attrs = element.attrs
297
+ return markup
298
+ }
299
+
289
300
  // `preserve: true` keeps the input's value in the form context when an `if:`
290
301
  // condition (nullable toggle or backend-supplied `condition_if`) unmounts it.
291
302
  const cleanNode: Record<string, unknown> = { $formkit: formkitType, preserve: true }
@@ -362,10 +373,13 @@ function buildNullableToggleNode(
362
373
  element: FormElement,
363
374
  label: string | undefined,
364
375
  help: string | undefined,
376
+ toggleId: string | undefined,
377
+ locale: string,
365
378
  ): Record<string, unknown> {
366
379
  const fieldName = element.name as string
367
- const toggleId = nullableToggleId(element)
368
380
  const gatingCondition = element.if as string | undefined
381
+ const toggleLabel = getLocalizedString(element.toggleLabel, locale) ?? (label ? `Enable ${label}` : 'Enable')
382
+ const toggleHelp = getLocalizedString(element.toggleHelp, locale) ?? help
369
383
  return {
370
384
  $formkit: 'primeCheckbox',
371
385
  // `preserve: true` for the same reason the gated input itself carries it: when this toggle's own
@@ -373,10 +387,13 @@ function buildNullableToggleNode(
373
387
  // state seeded from the saved value is lost — an already-configured field then remounts reading "off".
374
388
  preserve: true,
375
389
  name: nullableToggleName(fieldName),
376
- id: toggleId,
377
- key: toggleId,
378
- label: label ? `Enable ${label}` : 'Enable',
379
- ...(help ? { help } : {}),
390
+ // Repeater rows share one schema object, so a fixed id would register every row's toggle under the
391
+ // same key in FormKit's single global node registry. Those rows pass no id and let FormKit assign a
392
+ // unique one per instance; they gate on row data instead of `$get`, so nothing looks the id up.
393
+ ...(toggleId ? { id: toggleId } : {}),
394
+ key: toggleId ?? nullableToggleName(fieldName),
395
+ label: toggleLabel,
396
+ ...(toggleHelp ? { help: toggleHelp } : {}),
380
397
  ...(gatingCondition ? { if: gatingCondition } : {}),
381
398
  binary: true,
382
399
  }
@@ -385,16 +402,19 @@ function buildNullableToggleNode(
385
402
  /**
386
403
  * `help` is only supplied for groups: a group's own node renders no help, so the toggle is the
387
404
  * only place to hang it. Nullable leaves already render their help on the input itself — passing
388
- * it here too would print the same sentence twice.
405
+ * it here too would print the same sentence twice. An element's own `toggleHelp` is meant for the
406
+ * toggle alone, so it is rendered there for leaves and groups alike.
389
407
  */
390
408
  function applyNullableToggle(
391
409
  element: FormElement,
392
410
  baseNode: FormKitSchemaNode | FormKitSchemaNode[],
393
411
  label: string | undefined,
412
+ toggleId: string | undefined,
413
+ locale: string,
394
414
  help?: string,
395
415
  ): FormKitSchemaNode[] {
396
416
  const nodeArray = Array.isArray(baseNode) ? baseNode : [baseNode]
397
- return [buildNullableToggleNode(element, label, help) as FormKitSchemaNode, ...nodeArray]
417
+ return [buildNullableToggleNode(element, label, help, toggleId, locale) as FormKitSchemaNode, ...nodeArray]
398
418
  }
399
419
 
400
420
  function gateElement(element: FormElement, toggleCondition: string): FormElement {
@@ -406,6 +426,22 @@ function gateElement(element: FormElement, toggleCondition: string): FormElement
406
426
  * Handles groups specially by wrapping them in fieldsets when they have labels.
407
427
  * Skips repeater elements (they are handled separately).
408
428
  */
429
+ /**
430
+ * Transforms an element's children, letting text through untouched.
431
+ *
432
+ * A child may be prose rather than another element — `$el` nodes carry their content that way and
433
+ * the schema takes a bare string there (see `buildFieldWarningNode`). Recursing into a string
434
+ * yielded nothing, so an element declaring text rendered as an empty tag.
435
+ */
436
+ function transformChildren(
437
+ children: unknown,
438
+ transform: (child: FormElement) => FormKitSchemaNode | FormKitSchemaNode[],
439
+ ): FormKitSchemaNode[] {
440
+ if (!children) return []
441
+ const list = Array.isArray(children) ? children : [children]
442
+ return list.flatMap(child => (typeof child === 'string' ? child : transform(child as FormElement))) as FormKitSchemaNode[]
443
+ }
444
+
409
445
  export function transformElementToSchema(
410
446
  element: FormElement,
411
447
  options: TransformOptions = {},
@@ -417,9 +453,7 @@ export function transformElementToSchema(
417
453
 
418
454
  const { locale = 'en', labelTransform, optionsResolver, fieldWarning } = options
419
455
 
420
- const children = (element.children as FormElement[] || []).flatMap(
421
- child => transformElementToSchema(child, options),
422
- ) as FormKitSchemaNode[]
456
+ const children = transformChildren(element.children, child => transformElementToSchema(child, options))
423
457
 
424
458
  let label = getLocalizedString(element.label, locale)
425
459
  if (label && labelTransform) {
@@ -427,7 +461,8 @@ export function transformElementToSchema(
427
461
  }
428
462
 
429
463
  const isNullable = element.nullable === true
430
- const toggleCondition = isNullable ? `$get(${nullableToggleId(element)}).value` : undefined
464
+ const toggleId = isNullable ? nullableToggleId(element) : undefined
465
+ const toggleCondition = isNullable ? `$get(${toggleId}).value` : undefined
431
466
 
432
467
  const warningText = fieldWarning?.(element)
433
468
  const warningNode = warningText ? buildFieldWarningNode(element, warningText) : undefined
@@ -436,7 +471,9 @@ export function transformElementToSchema(
436
471
  const gatedElement = isNullable ? gateElement(element, toggleCondition!) : element
437
472
  const groupNode = createGroupNode(gatedElement, children, label)
438
473
  if (!isNullable) return withFieldWarning(groupNode, warningNode, false)
439
- const toggledNodes = applyNullableToggle(element, groupNode, label, getLocalizedString(element.help, locale))
474
+ const toggledNodes = applyNullableToggle(
475
+ element, groupNode, label, toggleId, locale, getLocalizedString(element.help, locale),
476
+ )
440
477
  return withFieldWarning(toggledNodes, warningNode, true)
441
478
  }
442
479
 
@@ -444,7 +481,7 @@ export function transformElementToSchema(
444
481
  if (children.length > 0) cleanNode.children = children
445
482
  if (isNullable) {
446
483
  cleanNode.if = combineConditions(toggleCondition!, element.if as string | undefined)
447
- const toggledNodes = applyNullableToggle(element, cleanNode as FormKitSchemaNode, label)
484
+ const toggledNodes = applyNullableToggle(element, cleanNode as FormKitSchemaNode, label, toggleId, locale)
448
485
  return withFieldWarning(toggledNodes, warningNode, true)
449
486
  }
450
487
 
@@ -478,33 +515,47 @@ function buildLeafNodeForRepeater(
478
515
 
479
516
  /**
480
517
  * Transforms a form element for use inside a repeater's children schema.
518
+ *
519
+ * A nullable element here gates on its own row's data (`$__<field>__enabled`) rather than on
520
+ * `$get(<id>).value` as it does outside a repeater. Every row renders the one shared
521
+ * `childrenSchema` object, so a schema-level id is the same string in all of them — and FormKit
522
+ * resolves `$get` through a single global registry keyed by that id, which holds one node for the
523
+ * whole repeater (the last row to mount; a row unmounting deletes the key outright). The gate then
524
+ * answered for an arbitrary row instead of the one whose checkbox was clicked, so ticking "Enable X"
525
+ * mounted nothing. A data reference resolves against the `:data="modelValue[index]"` each row is
526
+ * rendered with, which is per row by construction. `dataPath` is the dotted prefix of the enclosing
527
+ * groups, since a nested group's toggle is seeded inside that group's object, not at the row root.
481
528
  */
482
529
  export function transformElementForRepeater(
483
530
  element: FormElement,
484
531
  locale = 'en',
532
+ dataPath = '',
485
533
  ): FormKitSchemaNode | FormKitSchemaNode[] {
486
534
  if (!element) return []
487
535
 
488
536
  const formkitType = getFormkitType(element)
537
+ const childPath = formkitType === 'group' ? `${dataPath}${element.name as string}.` : dataPath
489
538
  const children = (element.children as FormElement[] || []).flatMap(
490
- child => transformElementForRepeater(child, locale),
539
+ child => transformElementForRepeater(child, locale, childPath),
491
540
  ) as FormKitSchemaNode[]
492
541
 
493
542
  const label = getLocalizedString(element.label, locale)
494
543
  const isNullable = element.nullable === true
495
- const toggleCondition = isNullable ? `$get(${nullableToggleId(element)}).value` : undefined
544
+ const toggleCondition = isNullable
545
+ ? `$${dataPath}${nullableToggleName(element.name as string)}`
546
+ : undefined
496
547
 
497
548
  if (formkitType === 'group') {
498
549
  const gatedElement = isNullable ? gateElement(element, toggleCondition!) : element
499
550
  const groupNode = createGroupNode(gatedElement, children, label)
500
551
  if (!isNullable) return groupNode
501
- return applyNullableToggle(element, groupNode, label, getLocalizedString(element.help, locale))
552
+ return applyNullableToggle(element, groupNode, label, undefined, locale, getLocalizedString(element.help, locale))
502
553
  }
503
554
 
504
555
  const cleanNode = buildLeafNodeForRepeater(element, formkitType, label, locale, children)
505
556
  if (isNullable) {
506
557
  cleanNode.if = combineConditions(toggleCondition!, element.if as string | undefined)
507
- return applyNullableToggle(element, cleanNode as FormKitSchemaNode, label)
558
+ return applyNullableToggle(element, cleanNode as FormKitSchemaNode, label, undefined, locale)
508
559
  }
509
560
 
510
561
  return cleanNode as FormKitSchemaNode
@@ -0,0 +1,22 @@
1
+ import type { Ref } from 'vue'
2
+
3
+ export function useOpenWebuiDisclaimer(iframe: Ref<HTMLIFrameElement | null>, text: Ref<string>) {
4
+ const config = useRuntimeConfig()
5
+ const origin = new URL(config.public.webui.url).origin
6
+ const type = 'aihub:chat-disclaimer'
7
+
8
+ function sendDisclaimer() {
9
+ iframe.value?.contentWindow?.postMessage({ type, version: 1, text: text.value }, origin)
10
+ }
11
+
12
+ function onMessage(event: MessageEvent) {
13
+ if (event.origin !== origin || event.source !== iframe.value?.contentWindow) return
14
+ if (event.data?.type === `${type}:ready` && event.data.version === 1) sendDisclaimer()
15
+ }
16
+
17
+ watch(text, sendDisclaimer)
18
+ onMounted(() => window.addEventListener('message', onMessage))
19
+ onBeforeUnmount(() => window.removeEventListener('message', onMessage))
20
+
21
+ return { sendDisclaimer }
22
+ }
@@ -2,19 +2,27 @@ import type { ServiceDto } from '@core/sdk/client'
2
2
  import type { MenuItem } from 'primevue/menuitem'
3
3
 
4
4
  export const useApps = () => {
5
- const { suite, suiteIsLoading } = useSuite()
5
+ const { suite, suiteError, suiteIsLoading } = useSuite()
6
6
  const router = useRouter()
7
7
  const tenantPath = useTenantPath()
8
8
 
9
9
  const apps = computed<MenuItem>(() => {
10
- const suiteApps = suite.value?.services.map((service: ServiceDto) => ({
10
+ // `services` is non-optional on SuiteDto, so the type system cannot catch a
11
+ // cached value that is not actually a SuiteDto. It used to be reachable: a
12
+ // proxy answering 200 text/html for a downed API landed here as a raw HTML
13
+ // string and `.services.map` threw inside this computed, taking down every
14
+ // page on the default layout. The SDK guard now rejects that response; this
15
+ // stays as the last line of defence.
16
+ const services = Array.isArray(suite.value?.services) ? suite.value.services : []
17
+
18
+ const suiteApps = services.map((service: ServiceDto) => ({
11
19
  label: service.name,
12
20
  description: service.description,
13
21
  icon: service.icon,
14
22
  path: service.path,
15
23
  isAdmin: service.user_is_admin ?? false,
16
24
  } satisfies MenuItem
17
- )) ?? []
25
+ ))
18
26
  return [
19
27
  { icon: 'material-symbols:home', label: 'Home', path: '/' },
20
28
  ...suiteApps,
@@ -27,5 +35,6 @@ export const useApps = () => {
27
35
  return {
28
36
  apps,
29
37
  appsLoading: suiteIsLoading,
38
+ appsError: suiteError,
30
39
  }
31
40
  }
@@ -6,6 +6,7 @@ export const useSuite = defineQuery(() => {
6
6
 
7
7
  const {
8
8
  data: suite,
9
+ error: suiteError,
9
10
  isPending: suiteIsLoading,
10
11
  } = useQuery<SuiteDto>({
11
12
  key: () => ['tenant', tenantId.value, 'suite'],
@@ -21,6 +22,7 @@ export const useSuite = defineQuery(() => {
21
22
 
22
23
  return {
23
24
  suite,
25
+ suiteError,
24
26
  suiteIsLoading,
25
27
  }
26
28
  })
@@ -0,0 +1,17 @@
1
+ import { getIncidentAvailability } from '@core/sdk/client'
2
+ import { hoursToMilliseconds } from 'date-fns'
3
+
4
+ // Asked once per app load, and answered 200 on every deployment: the form endpoint itself answers
5
+ // 404 where reporting is not configured, and the shell's global error handler would toast that at
6
+ // a user who has not clicked anything. The button is drawn only where it leads somewhere.
7
+ export const useIncidentAvailability = defineQuery(() => {
8
+ const { data } = useQuery({
9
+ key: () => ['incident-availability'],
10
+ staleTime: hoursToMilliseconds(1),
11
+ query: async () => await getIncidentAvailability({ composable: '$fetch' }),
12
+ })
13
+
14
+ const incidentReportingIsAvailable = computed(() => data.value?.enabled === true)
15
+
16
+ return { incidentReportingIsAvailable }
17
+ })