@swiss-ai-hub/web 0.321.1 → 0.323.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/Event/Display/AddMemoryToChatHistoryEvent.vue +3 -3
- package/components/Event/Display/AttachedFileEvent.vue +42 -0
- package/components/Event/Display/AttachedFilesReadEvent.vue +22 -0
- package/components/Event/Display/BaseRetrieveMemoryEvent.vue +2 -2
- package/components/Event/Display/BaseStoreMemoryEvent.vue +2 -2
- package/components/Event/Display/CompleteConversationEvent.vue +35 -0
- package/components/Event/Display/ComposeContextEvent.vue +38 -0
- package/components/Event/Display/ContextComposedEvent.vue +60 -0
- package/components/Event/Display/ContextualizeConversationEvent.vue +26 -0
- package/components/Event/Display/ConversationContextualizedEvent.vue +34 -0
- package/components/Event/Display/HumanInTheLoopResponseEvent.vue +11 -1
- package/components/Event/Display/KnowledgeSearchedEvent.vue +33 -0
- package/components/Event/Display/MemoryRecalledEvent.vue +33 -0
- package/components/Event/Display/MemoryStorageRequestedEvent.vue +29 -0
- package/components/Event/Display/NotAMetaQuestionEvent.vue +20 -0
- package/components/Event/Display/Part/Facts.vue +39 -0
- package/components/Event/Display/Part/MessageList.vue +50 -0
- package/components/Event/Display/Part/ToolCalls.vue +58 -0
- package/components/Event/Display/Part/ToolLoopState.vue +36 -0
- package/components/Event/Display/ReadAttachedFilesEvent.vue +28 -0
- package/components/Event/Display/RecallMemoryEvent.vue +27 -0
- package/components/Event/Display/RunToolLoopEvent.vue +34 -0
- package/components/Event/Display/SandboxFileDisplayedEvent.vue +46 -0
- package/components/Event/Display/SearchKnowledgeEvent.vue +31 -0
- package/components/Event/Display/ToolCallApprovedEvent.vue +27 -0
- package/components/Event/Display/ToolCallsDecidedEvent.vue +24 -0
- package/components/Event/Display/ToolLoopCondensedEvent.vue +31 -0
- package/components/Event/Display/ToolLoopFinishedEvent.vue +49 -0
- package/components/Event/Display/ToolLoopIterationEvent.vue +18 -0
- package/components/Event/Display/ToolLoopStatusEvent.vue +27 -0
- package/components/Event/Display/ToolResultEvent.vue +38 -0
- package/components/Event/List/EventList.vue +1 -1
- package/components/FormKit/DynamicConfiguration.vue +6 -1
- package/components/FormKit/KnowledgeCollectionSelector.vue +281 -0
- package/components/FormKit/KnowledgeDatabaseSelector.vue +7 -0
- package/components/FormKit/LocaleInput.vue +2 -0
- package/components/FormKit/SecretFileInput.vue +181 -0
- package/components/FormKit/VectorStoreInput.vue +20 -1
- package/components/Knowledge/Database/CreateModal.vue +91 -2
- package/components/Knowledge/Database/SourceModal.vue +165 -0
- package/components/Knowledge/Document/UploadModal.vue +4 -0
- package/components/Knowledge/Namespace/Card.vue +4 -4
- package/components/Knowledge/Namespace/CreateModal.vue +1 -1
- package/components/Knowledge/Namespace/EditModal.vue +3 -4
- package/components/Role/AccessRulesEditor.vue +1 -1
- package/components/Service/Selection.vue +7 -1
- package/components/Support/IncidentButton.vue +37 -0
- package/components/Support/IncidentDialog.vue +119 -0
- package/components/User/Settings.vue +9 -8
- package/components/UserFiles/Browser.vue +361 -0
- package/components/UserFiles/MoveDialog.vue +77 -0
- package/components/UserFiles/NameDialog.vue +59 -0
- package/components/UserFiles/Preview.vue +110 -0
- package/composables/auth/useAuth.ts +19 -3
- package/composables/document/useDocumentIngestionPolling.ts +64 -0
- package/composables/document/useDocuments.ts +3 -0
- package/composables/document/useSourcePipelines.ts +23 -0
- package/composables/document/useUpdateDatabaseSource.ts +21 -0
- package/composables/event/useEventComponent.ts +46 -0
- package/composables/form/useFormKitTransform.ts +68 -17
- package/composables/openwebui/useOpenWebuiDisclaimer.ts +22 -0
- package/composables/suite/useApps.ts +12 -3
- package/composables/suite/useSuite.ts +2 -0
- package/composables/support/useIncidentAvailability.ts +17 -0
- package/composables/support/useIncidentContext.ts +56 -0
- package/composables/support/useIncidentForm.ts +45 -0
- package/composables/support/useIncidentReport.ts +16 -0
- package/composables/support/useOpenWebUIContext.ts +38 -0
- package/composables/tenant/useChatDisclaimer.ts +24 -0
- package/composables/user/useApplyLocale.ts +23 -0
- package/composables/user/useMyLocale.ts +20 -0
- package/composables/user/useRestorePreferredLocale.ts +38 -0
- package/composables/userFiles/useCreateUserFolder.ts +15 -0
- package/composables/userFiles/useDeleteUserFile.ts +15 -0
- package/composables/userFiles/useMoveUserFile.ts +15 -0
- package/composables/userFiles/useUploadUserFiles.ts +19 -0
- package/composables/userFiles/useUserFileContent.ts +24 -0
- package/composables/userFiles/useUserFiles.ts +25 -0
- package/formkit.config.ts +10 -2
- package/i18n/locales/de.yaml +171 -3
- package/i18n/locales/en.yaml +172 -3
- package/i18n/locales/fr.yaml +173 -3
- package/i18n/locales/it.yaml +173 -3
- package/layouts/default.vue +7 -1
- package/middleware/auth.global.ts +6 -3
- package/middleware/home-redirect.ts +15 -4
- package/package.json +4 -3
- package/pages/[tenant]/service/knowledge/[db]/[namespace].vue +6 -8
- package/pages/[tenant]/service/knowledge.vue +33 -7
- package/pages/[tenant]/service/openai/[thread_id]/[display_id]/files.vue +51 -0
- package/pages/[tenant]/service/openai.vue +61 -17
- package/pages/[tenant]/service/user-knowledge.vue +19 -0
- package/plugins/api-client.client.ts +5 -0
- package/plugins/oidc-client.ts +14 -7
- package/sdk/client/index.ts +171 -1
- package/sdk/client/schemas.gen.ts +18602 -8560
- package/sdk/client/sdk.gen.ts +482 -0
- package/sdk/client/types.gen.ts +13616 -7044
- package/types/EventFact.ts +4 -0
- 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
|
|
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 {
|
|
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(
|
|
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="!
|
|
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="!
|
|
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
|
|
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
|
-
|
|
84
|
+
sourced?: boolean
|
|
85
85
|
}>()
|
|
86
86
|
|
|
87
87
|
const emit = defineEmits<{
|
|
@@ -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:
|
|
118
|
-
database:
|
|
116
|
+
namespace: props.namespace.name,
|
|
117
|
+
database: props.database,
|
|
119
118
|
payload: ref(updatePayload),
|
|
120
119
|
tenantId: tenantId.value!,
|
|
121
120
|
})
|
|
@@ -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(() => {
|