@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
@@ -11,6 +11,13 @@
11
11
  class="w-full"
12
12
  :loading="isLoading"
13
13
  >
14
+ <!-- PrimeVue 4.5 renders the chip-mode placeholder twice while loading with no options yet. -->
15
+ <template
16
+ v-if="selectedDatabases.length === 0"
17
+ #value="{ placeholder: emptyLabel }"
18
+ >
19
+ {{ emptyLabel }}
20
+ </template>
14
21
  <template #option="{ option }">
15
22
  <div class="flex items-center gap-2">
16
23
  <Icon
@@ -78,6 +78,7 @@ interface LocaleInputProps {
78
78
  inputType?: 'text' | 'textarea'
79
79
  rows?: number
80
80
  placeholder?: LocaleStringDto
81
+ allowTranslation?: boolean
81
82
  }
82
83
  }
83
84
 
@@ -140,6 +141,7 @@ function hasValue(lang: Locale): boolean {
140
141
 
141
142
  // Check if translation is possible (current locale has text and at least one other locale is empty)
142
143
  const canTranslate = computed(() => {
144
+ if (props.context.allowTranslation === false) return false
143
145
  const currentText = localeValue.value[activeLocale.value]
144
146
  if (!currentText) return false
145
147
 
@@ -0,0 +1,181 @@
1
+ <template>
2
+ <div class="flex flex-col gap-2">
3
+ <input
4
+ ref="fileInput"
5
+ type="file"
6
+ class="hidden"
7
+ :aria-label="context.label"
8
+ :accept="accept"
9
+ @change="onFileChosen"
10
+ >
11
+
12
+ <button
13
+ v-if="state === 'empty'"
14
+ :id="context.id"
15
+ type="button"
16
+ class="flex w-full cursor-pointer items-center gap-3 rounded-lg border-2 border-dashed border-surface-300 p-4 text-left transition-colors hover:border-primary-500 hover:bg-surface-50 dark:border-surface-600 dark:hover:bg-surface-800"
17
+ :class="{ 'border-primary-500 bg-surface-50 dark:bg-surface-800': isDragOver }"
18
+ @click="chooseFile"
19
+ @dragover.prevent="isDragOver = true"
20
+ @dragleave.prevent="isDragOver = false"
21
+ @drop.prevent="onFileDropped"
22
+ >
23
+ <i class="pi pi-upload text-surface-400" />
24
+ <span class="flex flex-col">
25
+ <span class="text-sm font-semibold text-surface-700 dark:text-surface-200">
26
+ {{ context.placeholder ?? t('form.secret_file_input.drop_hint') }}
27
+ </span>
28
+ <span class="text-xs text-surface-500 dark:text-surface-400">
29
+ {{ t('form.secret_file_input.limits', { accept: accept ?? '*', size: maxSizeLabel }) }}
30
+ </span>
31
+ </span>
32
+ </button>
33
+
34
+ <div
35
+ v-else
36
+ class="flex items-center justify-between gap-3 rounded-lg border border-surface-200 bg-surface-50 p-3 dark:border-surface-700 dark:bg-surface-800"
37
+ >
38
+ <div class="flex min-w-0 items-center gap-3">
39
+ <i
40
+ class="pi text-surface-400"
41
+ :class="state === 'stored' ? 'pi-lock' : 'pi-file'"
42
+ />
43
+ <div class="flex min-w-0 flex-col">
44
+ <span class="truncate text-sm font-medium text-surface-800 dark:text-surface-100">
45
+ {{ state === 'stored' ? t('form.secret_file_input.stored') : (fileName ?? t('form.secret_file_input.selected')) }}
46
+ </span>
47
+ <span
48
+ v-if="state === 'selected' && clientEmail"
49
+ class="truncate text-xs text-surface-500 dark:text-surface-400"
50
+ >
51
+ {{ t('form.secret_file_input.client_email', { email: clientEmail }) }}
52
+ </span>
53
+ </div>
54
+ </div>
55
+ <div class="flex shrink-0 items-center gap-1">
56
+ <Button
57
+ :id="context.id"
58
+ :label="t('form.secret_file_input.replace')"
59
+ icon="pi pi-refresh"
60
+ size="small"
61
+ severity="secondary"
62
+ text
63
+ @click="chooseFile"
64
+ />
65
+ <Button
66
+ v-tooltip.top="t('form.secret_file_input.remove')"
67
+ :aria-label="t('form.secret_file_input.remove')"
68
+ icon="pi pi-trash"
69
+ size="small"
70
+ severity="secondary"
71
+ text
72
+ rounded
73
+ @click="removeFile"
74
+ />
75
+ </div>
76
+ </div>
77
+
78
+ <Message
79
+ v-if="errorMessage"
80
+ severity="error"
81
+ size="small"
82
+ variant="simple"
83
+ >
84
+ {{ errorMessage }}
85
+ </Message>
86
+ </div>
87
+ </template>
88
+
89
+ <script setup lang="ts">
90
+ interface SecretFileInputProps {
91
+ context: {
92
+ id: string
93
+ label?: string
94
+ node: { input: (value: string) => void }
95
+ value?: string | null
96
+ accept?: string
97
+ maxSizeBytes?: number
98
+ placeholder?: string
99
+ }
100
+ }
101
+
102
+ // The API returns a stored secret as this mask followed by a handle, never the secret itself
103
+ // (SecretEncryptionService in packages/core); resubmitting it untouched keeps the stored value.
104
+ const STORED_SECRET_MASK = '••••••••'
105
+ const DEFAULT_MAX_SIZE_BYTES = 65536
106
+
107
+ const props = defineProps<SecretFileInputProps>()
108
+ const { t } = useI18n()
109
+
110
+ const fileInput = ref<HTMLInputElement>()
111
+ const isDragOver = ref(false)
112
+ const fileName = ref<string | null>(null)
113
+ const clientEmail = ref<string | null>(null)
114
+ const errorMessage = ref<string | null>(null)
115
+
116
+ const accept = computed(() => props.context.accept)
117
+ const maxSizeBytes = computed(() => props.context.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES)
118
+ const maxSizeLabel = computed(() => `${Math.round(maxSizeBytes.value / 1024)} KB`)
119
+ const expectsJson = computed(() => accept.value?.includes('.json') ?? false)
120
+
121
+ const state = computed<'empty' | 'stored' | 'selected'>(() => {
122
+ const value = props.context.value
123
+ if (!value) return 'empty'
124
+ return value.startsWith(STORED_SECRET_MASK) ? 'stored' : 'selected'
125
+ })
126
+
127
+ function chooseFile() {
128
+ fileInput.value?.click()
129
+ }
130
+
131
+ async function onFileChosen(event: Event) {
132
+ const input = event.target as HTMLInputElement
133
+ const file = input.files?.[0]
134
+ // Cleared so picking the same file again after a failed attempt still fires `change`.
135
+ input.value = ''
136
+ if (file) await acceptFile(file)
137
+ }
138
+
139
+ async function onFileDropped(event: DragEvent) {
140
+ isDragOver.value = false
141
+ const file = event.dataTransfer?.files?.[0]
142
+ if (file) await acceptFile(file)
143
+ }
144
+
145
+ async function acceptFile(file: File) {
146
+ errorMessage.value = null
147
+ if (file.size > maxSizeBytes.value) {
148
+ errorMessage.value = t('form.secret_file_input.too_large', { size: maxSizeLabel.value })
149
+ return
150
+ }
151
+ const contents = await file.text()
152
+ let parsed: unknown = null
153
+ if (expectsJson.value) {
154
+ try {
155
+ parsed = JSON.parse(contents)
156
+ }
157
+ catch {
158
+ errorMessage.value = t('form.secret_file_input.invalid_json')
159
+ return
160
+ }
161
+ }
162
+ fileName.value = file.name
163
+ clientEmail.value = readClientEmail(parsed)
164
+ props.context.node.input(contents)
165
+ }
166
+
167
+ // A Google service-account key names the account the Drive folder has to be shared with; showing it
168
+ // lets the admin confirm the right key was picked without ever rendering the private key.
169
+ function readClientEmail(parsed: unknown): string | null {
170
+ if (!parsed || typeof parsed !== 'object') return null
171
+ const email = (parsed as Record<string, unknown>).client_email
172
+ return typeof email === 'string' ? email : null
173
+ }
174
+
175
+ function removeFile() {
176
+ fileName.value = null
177
+ clientEmail.value = null
178
+ errorMessage.value = null
179
+ props.context.node.input('')
180
+ }
181
+ </script>
@@ -80,6 +80,9 @@
80
80
  option-value="name"
81
81
  :placeholder="namespacePlaceholder ?? t('lib.vectorStore.namespaces.placeholder')"
82
82
  :filter="filter"
83
+ :invalid="namespaceScopeIsEmpty"
84
+ :aria-invalid="namespaceScopeIsEmpty"
85
+ aria-describedby="vector-store-namespaces-hint"
83
86
  display="chip"
84
87
  class="w-full"
85
88
  >
@@ -93,7 +96,18 @@
93
96
  </div>
94
97
  </template>
95
98
  </MultiSelect>
96
- <small class="mt-1 text-surface-500">
99
+ <small
100
+ v-if="namespaceScopeIsEmpty"
101
+ id="vector-store-namespaces-hint"
102
+ class="mt-1 block text-red-500"
103
+ >
104
+ {{ t('lib.vectorStore.namespaces.required') }}
105
+ </small>
106
+ <small
107
+ v-else
108
+ id="vector-store-namespaces-hint"
109
+ class="mt-1 text-surface-500"
110
+ >
97
111
  {{ t('lib.vectorStore.namespaces.help') }}
98
112
  </small>
99
113
  </div>
@@ -203,6 +217,11 @@ const selectedNamespaces = computed({
203
217
  },
204
218
  })
205
219
 
220
+ // The API refuses this scope on save and a run would abort on it, so it is flagged while the admin edits
221
+ const namespaceScopeIsEmpty = computed(() =>
222
+ Boolean(selectedDatabase.value) && !allNamespaces.value && selectedNamespaces.value.length === 0,
223
+ )
224
+
206
225
  // Allowed metadata filter fields (delegated to ChipsInput child component)
207
226
  const allowedFilterFields = computed(() => currentValue.value?.allowed_metadata_filter_fields ?? [])
208
227
 
@@ -70,6 +70,28 @@
70
70
  </small>
71
71
  </div>
72
72
 
73
+ <div class="flex flex-col gap-2">
74
+ <label
75
+ for="database-source-select"
76
+ class="text-sm font-medium"
77
+ >
78
+ {{ t('knowledge.form.source.label') }}
79
+ </label>
80
+ <Select
81
+ id="database-source-select"
82
+ v-model="selectedSource"
83
+ :options="sourceOptions"
84
+ option-label="display_name"
85
+ option-value="name"
86
+ :placeholder="t('knowledge.form.source.placeholder')"
87
+ :disabled="isSubmitting"
88
+ class="w-full"
89
+ />
90
+ <small class="text-gray-500">
91
+ {{ selectedSourceData?.description || t('knowledge.form.source.help') }}
92
+ </small>
93
+ </div>
94
+
73
95
  <div
74
96
  v-if="selectedClassData && configForm.length > 0 && formReady"
75
97
  class="content flex flex-col gap-2"
@@ -140,6 +162,20 @@
140
162
  </div>
141
163
  </StepPanel>
142
164
  </StepItem>
165
+ <StepItem
166
+ v-if="selectedSourceData && sourceSchema.length > 0"
167
+ :value="sourceStepIndex"
168
+ >
169
+ <Step>{{ t('knowledge.form.create_database.steps.source') }}</Step>
170
+ <StepPanel>
171
+ <div class="content flex flex-col gap-6 py-4">
172
+ <FormKitSchema
173
+ :schema="sourceSchema"
174
+ :data="formData"
175
+ />
176
+ </div>
177
+ </StepPanel>
178
+ </StepItem>
143
179
  </Stepper>
144
180
  </FormKit>
145
181
  </div>
@@ -164,9 +200,20 @@
164
200
  </template>
165
201
 
166
202
  <script setup lang="ts">
167
- import { type FormElement, serializeFormData } from '@core/composables/form/useFormKitTransform'
203
+ import {
204
+ buildFormKitSchema,
205
+ type FormElement,
206
+ hydrateFormData,
207
+ serializeFormData,
208
+ } from '@core/composables/form/useFormKitTransform'
168
209
  import { getNode } from '@formkit/core'
169
210
 
211
+ import type { SourcePipelineDto } from '@core/sdk/client'
212
+ import type { FormKitSchemaNode } from '@formkit/core'
213
+
214
+ // Manual upload is the absence of a source; it is offered as a first, form-less entry of the source list.
215
+ const MANUAL_UPLOAD = '__manual_upload__'
216
+
170
217
  const props = defineProps<{
171
218
  modelValue: boolean
172
219
  }>()
@@ -180,6 +227,7 @@ const { t, locale } = useI18n()
180
227
  const toast = useToast()
181
228
  const { mutateAsync: createDatabase } = useCreateDatabase()
182
229
  const { ingestors, ingestorsAreLoading } = useIngestors()
230
+ const { sourcePipelines } = useSourcePipelines()
183
231
  const { tenantId } = useTenant()
184
232
 
185
233
  // The ingestor list is the "class" list: each ingestor announces the form its databases are configured
@@ -206,6 +254,41 @@ const {
206
254
  locale,
207
255
  })
208
256
 
257
+ // The source is a second axis of the database: its form is announced by the source pipeline exactly like the
258
+ // ingestor's, and rendered as one more step of the same form under the `source_configuration` group, so a single
259
+ // submit carries both.
260
+ const selectedSource = ref<string>(MANUAL_UPLOAD)
261
+ const sourceOptions = computed<Array<Pick<SourcePipelineDto, 'name' | 'display_name' | 'description'>>>(() => [
262
+ {
263
+ name: MANUAL_UPLOAD,
264
+ display_name: t('knowledge.form.source.manual_upload.label'),
265
+ description: t('knowledge.form.source.manual_upload.description'),
266
+ },
267
+ ...(sourcePipelines.value ?? []),
268
+ ])
269
+ const selectedSourceData = computed<SourcePipelineDto | undefined>(() =>
270
+ sourcePipelines.value?.find(source => source.name === selectedSource.value),
271
+ )
272
+ const sourceForm = computed<FormElement[]>(() => (selectedSourceData.value?.form ?? []) as FormElement[])
273
+ const sourceSchema = computed<FormKitSchemaNode[]>(() => {
274
+ if (sourceForm.value.length === 0) return []
275
+ return [{
276
+ $formkit: 'group',
277
+ name: 'source_configuration',
278
+ children: buildFormKitSchema(sourceForm.value, { locale: locale.value }),
279
+ }]
280
+ })
281
+ const sourceStepIndex = computed(
282
+ () => (simpleElementsSchema.value.length > 0 ? 1 : 0) + groupConfigs.value.length + repeaterConfigs.value.length,
283
+ )
284
+
285
+ watch(selectedSource, () => {
286
+ formData.value = {
287
+ ...formData.value,
288
+ source_configuration: sourceForm.value.length > 0 ? hydrateFormData({}, sourceForm.value) : undefined,
289
+ }
290
+ })
291
+
209
292
  const databaseName = ref('')
210
293
  const isSubmitting = ref(false)
211
294
  const formReady = ref(false)
@@ -219,6 +302,7 @@ watch(visible, async (isVisible) => {
219
302
  if (!isVisible) {
220
303
  formReady.value = false
221
304
  databaseName.value = ''
305
+ selectedSource.value = MANUAL_UPLOAD
222
306
  resetForm()
223
307
  return
224
308
  }
@@ -258,13 +342,18 @@ function triggerFormSubmit() {
258
342
 
259
343
  async function handleFormSubmit() {
260
344
  const database = databaseName.value
345
+ const { source_configuration, ...ingestorData } = formData.value
261
346
  try {
262
347
  await createDatabase({
263
348
  database,
264
349
  tenantId: tenantId.value!,
265
350
  request: {
266
351
  ingestor: selectedClass.value,
267
- configuration: serializeFormData(formData.value, configForm.value as FormElement[]),
352
+ configuration: serializeFormData(ingestorData, configForm.value as FormElement[]),
353
+ source: selectedSourceData.value?.name ?? null,
354
+ source_configuration: selectedSourceData.value
355
+ ? serializeFormData((source_configuration ?? {}) as Record<string, unknown>, sourceForm.value)
356
+ : {},
268
357
  },
269
358
  })
270
359
 
@@ -0,0 +1,165 @@
1
+ <template>
2
+ <Dialog
3
+ v-model:visible="visible"
4
+ modal
5
+ :header="t('knowledge.form.edit_source.title', { name: database?.display_name || database?.name })"
6
+ :style="{ width: '50rem' }"
7
+ :closable="!isSubmitting"
8
+ >
9
+ <div class="flex flex-col gap-6">
10
+ <div class="flex flex-col gap-2">
11
+ <label
12
+ for="database-source-edit-select"
13
+ class="text-sm font-medium"
14
+ >
15
+ {{ t('knowledge.form.source.label') }}
16
+ </label>
17
+ <Select
18
+ id="database-source-edit-select"
19
+ v-model="selectedSource"
20
+ :options="sourceOptions"
21
+ option-label="display_name"
22
+ option-value="name"
23
+ :disabled="isSubmitting"
24
+ class="w-full"
25
+ />
26
+ <small class="text-gray-500">
27
+ {{ selectedSourceData?.description || t('knowledge.form.source.manual_upload.description') }}
28
+ </small>
29
+ </div>
30
+
31
+ <!-- Keyed on the source so switching re-seeds the form: the stored configuration only applies to the
32
+ source it was written for, and its secrets arrive masked and stay masked unless replaced. -->
33
+ <FormKitDynamicConfiguration
34
+ v-if="selectedSourceData"
35
+ :key="selectedSourceData.name"
36
+ :form="selectedSourceData.form"
37
+ :initial-data="initialConfiguration"
38
+ @submit="confirmHandoverThenSubmit"
39
+ />
40
+ <Button
41
+ v-else
42
+ :label="t('knowledge.form.edit_source.switch_to_manual')"
43
+ :loading="isSubmitting"
44
+ :disabled="database?.source === null"
45
+ class="w-full"
46
+ @click="submitSource(null)"
47
+ />
48
+ </div>
49
+ </Dialog>
50
+ </template>
51
+
52
+ <script setup lang="ts">
53
+ import type { DatabaseDto, SourcePipelineDto } from '@core/sdk/client'
54
+
55
+ const MANUAL_UPLOAD = '__manual_upload__'
56
+
57
+ const props = defineProps<{
58
+ modelValue: boolean
59
+ database: DatabaseDto | null
60
+ }>()
61
+
62
+ const emit = defineEmits<{
63
+ 'update:modelValue': [value: boolean]
64
+ }>()
65
+
66
+ const { t } = useI18n()
67
+ const toast = useToast()
68
+ const confirm = useConfirm()
69
+ const { tenantId } = useTenant()
70
+ const { sourcePipelines } = useSourcePipelines()
71
+ const { mutateAsync: updateDatabaseSource } = useUpdateDatabaseSource()
72
+
73
+ const isSubmitting = ref(false)
74
+ const selectedSource = ref<string>(MANUAL_UPLOAD)
75
+
76
+ const visible = computed({
77
+ get: () => props.modelValue,
78
+ set: (value: boolean) => emit('update:modelValue', value),
79
+ })
80
+
81
+ const sourceOptions = computed<Array<Pick<SourcePipelineDto, 'name' | 'display_name' | 'description'>>>(() => [
82
+ {
83
+ name: MANUAL_UPLOAD,
84
+ display_name: t('knowledge.form.source.manual_upload.label'),
85
+ description: t('knowledge.form.source.manual_upload.description'),
86
+ },
87
+ ...(sourcePipelines.value ?? []),
88
+ ])
89
+ const selectedSourceData = computed<SourcePipelineDto | undefined>(() =>
90
+ sourcePipelines.value?.find(source => source.name === selectedSource.value),
91
+ )
92
+
93
+ // The stored configuration is only meaningful for the source it belongs to; another source starts blank.
94
+ const initialConfiguration = computed<Record<string, unknown>>(() =>
95
+ props.database?.source === selectedSource.value
96
+ ? (props.database?.source_configuration ?? {}) as Record<string, unknown>
97
+ : {},
98
+ )
99
+
100
+ watch(visible, (isVisible) => {
101
+ if (!isVisible) return
102
+ selectedSource.value = props.database?.source ?? MANUAL_UPLOAD
103
+ })
104
+
105
+ // A source owns its database's content: on the next sync it removes every file it does not have. Handing a
106
+ // manually filled database over is therefore confirmed first, and the API refuses it without the acknowledgement.
107
+ const existingDocuments = computed(() =>
108
+ (props.database?.namespaces ?? []).reduce((total, namespace) => total + namespace.number_of_documents, 0),
109
+ )
110
+ const isHandoverFromManualUpload = computed(() => props.database?.source === null && existingDocuments.value > 0)
111
+
112
+ function confirmHandoverThenSubmit(configuration: Record<string, unknown>) {
113
+ if (!isHandoverFromManualUpload.value) {
114
+ submitSource(configuration)
115
+ return
116
+ }
117
+ confirm.require({
118
+ header: t('knowledge.form.edit_source.replace_documents.header'),
119
+ message: t('knowledge.form.edit_source.replace_documents.message', {
120
+ name: props.database?.display_name || props.database?.name,
121
+ count: existingDocuments.value,
122
+ }),
123
+ icon: 'pi pi-exclamation-triangle',
124
+ rejectLabel: t('common.actions.cancel'),
125
+ acceptLabel: t('knowledge.form.edit_source.replace_documents.accept'),
126
+ acceptClass: 'p-button-danger',
127
+ accept: () => submitSource(configuration, { replaceExistingDocuments: true }),
128
+ })
129
+ }
130
+
131
+ async function submitSource(
132
+ configuration: Record<string, unknown> | null,
133
+ options: { replaceExistingDocuments?: boolean } = {},
134
+ ) {
135
+ if (!props.database) return
136
+ isSubmitting.value = true
137
+ try {
138
+ await updateDatabaseSource({
139
+ database: props.database.name,
140
+ tenantId: tenantId.value!,
141
+ request: configuration === null
142
+ ? { source: null, source_configuration: {} }
143
+ : {
144
+ source: selectedSourceData.value?.name ?? null,
145
+ source_configuration: configuration,
146
+ replace_existing_documents: options.replaceExistingDocuments ?? false,
147
+ },
148
+ })
149
+ toast.add({ severity: 'success', summary: t('knowledge.form.edit_source.success'), life: 3000 })
150
+ visible.value = false
151
+ }
152
+ catch (error) {
153
+ console.error('Failed to update the knowledge database source:', error)
154
+ toast.add({
155
+ severity: 'error',
156
+ summary: t('knowledge.form.edit_source.error'),
157
+ detail: error instanceof Error ? error.message : String(error),
158
+ life: 5000,
159
+ })
160
+ }
161
+ finally {
162
+ isSubmitting.value = false
163
+ }
164
+ }
165
+ </script>
@@ -164,6 +164,7 @@ const emit = defineEmits<{
164
164
  const { uploadFile } = useFileUpload()
165
165
  const { supportedFileTypes } = useSupportedFileTypes()
166
166
  const { tenantId } = useTenant()
167
+ const queryCache = useQueryCache()
167
168
 
168
169
  const acceptedFileTypesString = computed(() => {
169
170
  return (supportedFileTypes.value ?? []).join(', ')
@@ -276,6 +277,9 @@ const handleUpload = async () => {
276
277
  })
277
278
  }
278
279
 
280
+ // Invalidated here rather than by the caller: uploading from the knowledge overview into the namespace that is
281
+ // already open navigates nowhere, so no page would otherwise refetch its list.
282
+ queryCache.invalidateQueries({ key: ['tenant', tenantId.value, 'knowledge', 'databases', database, 'namespaces', namespace, 'documents'] })
279
283
  emit('success', { files: selectedFiles.value, namespace, database })
280
284
  closeModal()
281
285
  }
@@ -36,7 +36,7 @@
36
36
  @click.stop="handleEditClick"
37
37
  />
38
38
  <Button
39
- v-if="!autoSync"
39
+ v-if="!sourced"
40
40
  v-tooltip.top="t('knowledge.upload_documents')"
41
41
  icon="pi pi-upload"
42
42
  rounded
@@ -46,7 +46,7 @@
46
46
  @click.stop="handleUploadClick"
47
47
  />
48
48
  <Button
49
- v-if="!autoSync"
49
+ v-if="!sourced"
50
50
  v-tooltip.top="t('knowledge.delete_namespace')"
51
51
  icon="pi pi-trash"
52
52
  rounded
@@ -78,10 +78,10 @@ import { capitalCase } from 'change-case'
78
78
  import type { NamespaceDto } from '@core/sdk/client'
79
79
 
80
80
  // Not gated on the database's `deletable`: a legacy database cannot be removed as a whole, but its
81
- // namespaces can. Only an auto-synced source, which would just re-sync them, blocks both affordances.
81
+ // namespaces can. Only a sourced database, whose source would just re-sync them, blocks both affordances.
82
82
  const props = defineProps<{
83
83
  namespace: NamespaceDto
84
- autoSync?: boolean
84
+ sourced?: boolean
85
85
  }>()
86
86
 
87
87
  const emit = defineEmits<{
@@ -143,7 +143,7 @@ const isCreating = ref(false)
143
143
 
144
144
  const databaseOptions = computed(() =>
145
145
  (props.databases || [])
146
- .filter(db => !db.auto_sync)
146
+ .filter(db => !db.source)
147
147
  .map(db => ({
148
148
  name: db.name,
149
149
  displayName: capitalCase(db.name),
@@ -81,6 +81,7 @@ import { useI18n } from '#i18n'
81
81
 
82
82
  const props = defineProps<{
83
83
  modelValue: boolean
84
+ database: string
84
85
  namespace: NamespaceDto | null
85
86
  }>()
86
87
 
@@ -106,16 +107,14 @@ const handleSave = async () => {
106
107
 
107
108
  error.value = ''
108
109
 
109
- const namespaceId = props.namespace.id
110
- const databaseId = props.namespace.database_id
111
110
  const updatePayload: UpdateNamespaceRequest = {
112
111
  display_name: displayName.value || props.namespace.name,
113
112
  description: description.value || null,
114
113
  }
115
114
 
116
115
  await updateNamespace({
117
- namespace: namespaceId,
118
- database: databaseId,
116
+ namespace: props.namespace.name,
117
+ database: props.database,
119
118
  payload: ref(updatePayload),
120
119
  tenantId: tenantId.value!,
121
120
  })
@@ -96,7 +96,7 @@
96
96
  :placeholder="t('role.add_access_role')"
97
97
  size="small"
98
98
  class="flex-1"
99
- @keyup.enter="add"
99
+ @keydown.enter.prevent="add"
100
100
  />
101
101
  <Button
102
102
  type="button"
@@ -52,6 +52,12 @@
52
52
  height="5rem"
53
53
  />
54
54
  </template>
55
+ <div
56
+ v-else-if="appsError"
57
+ class="col-span-4 py-4 text-center text-sm text-surface-500 dark:text-surface-400"
58
+ >
59
+ {{ t('http_error.code.503') }}
60
+ </div>
55
61
  <template
56
62
  v-else
57
63
  >
@@ -104,7 +110,7 @@ const router = useRouter()
104
110
  const route = useRoute()
105
111
  const tenantPath = useTenantPath()
106
112
 
107
- const { apps, appsLoading } = useApps()
113
+ const { apps, appsLoading, appsError } = useApps()
108
114
  const { t } = useI18n()
109
115
 
110
116
  const shownApps = computed(() => {