@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.
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,37 @@
1
+ <template>
2
+ <!-- Nothing at all on a deployment without an incident repository: a button that leads to
3
+ "not configured" is the affordance the ADR says such a deployment must not grow. -->
4
+ <template v-if="incidentReportingIsAvailable">
5
+ <Button
6
+ v-if="props.labelled"
7
+ class="w-full"
8
+ :label="t('support.report_bug')"
9
+ variant="text"
10
+ icon="pi pi-exclamation-circle"
11
+ icon-pos="right"
12
+ @click="open"
13
+ />
14
+ <Button
15
+ v-else
16
+ v-tooltip="{ value: t('support.report_bug'), showDelay: 0 }"
17
+ :aria-label="t('support.report_bug')"
18
+ icon="pi pi-exclamation-circle"
19
+ variant="text"
20
+ size="large"
21
+ @click="open"
22
+ />
23
+ </template>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ const props = withDefaults(defineProps<{
28
+ // Icon-only in the app rail, labelled inside the settings popover.
29
+ labelled?: boolean
30
+ }>(), {
31
+ labelled: false,
32
+ })
33
+
34
+ const { t } = useI18n()
35
+ const { open } = useIncidentReport()
36
+ const { incidentReportingIsAvailable } = useIncidentAvailability()
37
+ </script>
@@ -0,0 +1,119 @@
1
+ <template>
2
+ <Dialog
3
+ v-model:visible="isOpen"
4
+ :header="t('support.dialog_title')"
5
+ modal
6
+ :style="{ width: '44rem' }"
7
+ :breakpoints="{ '1199px': '80vw', '767px': '95vw' }"
8
+ >
9
+ <div
10
+ v-if="incidentFormIsLoading"
11
+ class="flex justify-center py-10"
12
+ >
13
+ <ProgressSpinner style="width: 2rem; height: 2rem" />
14
+ </div>
15
+
16
+ <Message
17
+ v-else-if="incidentFormError"
18
+ severity="warn"
19
+ :closable="false"
20
+ >
21
+ {{ t('support.unavailable') }}
22
+ </Message>
23
+
24
+ <template v-else-if="incidentForm">
25
+ <FormKitDynamicConfiguration
26
+ :form="incidentForm.elements"
27
+ :initial-data="initialData"
28
+ :submit-label="incidentIsSubmitting ? t('support.submitting') : t('support.submit')"
29
+ @submit="onSubmit"
30
+ >
31
+ <template #before-submit>
32
+ <div
33
+ v-if="incidentForm.attachments"
34
+ class="flex flex-col gap-2 border-b border-surface-200 pb-4 dark:border-surface-700"
35
+ >
36
+ <!-- A heading, not a <label>: it names the whole picker block, and the picker button carries its own
37
+ accessible name. -->
38
+ <p class="font-medium">
39
+ {{ incidentForm.attachments.label }}
40
+ </p>
41
+ <p
42
+ v-if="incidentForm.attachments.description"
43
+ class="text-xs font-light text-surface-500 dark:text-surface-400"
44
+ >
45
+ {{ incidentForm.attachments.description }}
46
+ </p>
47
+ <FileUpload
48
+ mode="basic"
49
+ multiple
50
+ custom-upload
51
+ :auto="false"
52
+ :accept="incidentForm.attachments.accept.join(',')"
53
+ :file-limit="incidentForm.attachments.max_files"
54
+ :max-file-size="incidentForm.attachments.max_bytes"
55
+ :choose-label="t('support.choose_files')"
56
+ @select="onSelect"
57
+ />
58
+ <ul
59
+ v-if="attachments.length"
60
+ class="flex flex-col gap-1 text-xs text-surface-600 dark:text-surface-300"
61
+ >
62
+ <li
63
+ v-for="file in attachments"
64
+ :key="file.name"
65
+ class="flex items-center gap-2"
66
+ >
67
+ <i class="pi pi-paperclip" />
68
+ <span>{{ file.name }}</span>
69
+ <span class="text-surface-400">{{ Math.ceil(file.size / 1024) }} KB</span>
70
+ </li>
71
+ </ul>
72
+ </div>
73
+ </template>
74
+ </FormKitDynamicConfiguration>
75
+ </template>
76
+ </Dialog>
77
+ </template>
78
+
79
+ <script setup lang="ts">
80
+ const { t } = useI18n()
81
+ const toast = useToast()
82
+ const { isOpen, close } = useIncidentReport()
83
+ const { incidentForm, incidentFormIsLoading, incidentFormError } = useIncidentForm()
84
+ const { submitIncident, incidentIsSubmitting } = useSubmitIncident()
85
+ const { buildIncidentContext } = useIncidentContext()
86
+
87
+ // Snapshotted when the dialog opens rather than computed: the reported time has to be when the
88
+ // reporter raised this, and DynamicConfiguration seeds its model from `initialData` only once.
89
+ const initialData = ref<Record<string, string>>({})
90
+ const attachments = ref<File[]>([])
91
+
92
+ watch(isOpen, (opened) => {
93
+ if (!opened) return
94
+ initialData.value = buildIncidentContext()
95
+ attachments.value = []
96
+ })
97
+
98
+ function onSelect(event: { files: File[] }): void {
99
+ attachments.value = event.files
100
+ }
101
+
102
+ async function onSubmit(submission: Record<string, unknown>): Promise<void> {
103
+ try {
104
+ const created = await submitIncident({ submission, attachments: attachments.value })
105
+ toast.add({
106
+ severity: 'success',
107
+ summary: t('support.sent'),
108
+ detail: t('support.sent_detail', { reference: created.reference, number: created.number }),
109
+ life: 8000,
110
+ })
111
+ close()
112
+ }
113
+ catch {
114
+ // Left open on purpose: the dialog still holds everything the reporter typed, so closing it
115
+ // would make them write the report twice.
116
+ toast.add({ severity: 'error', summary: t('support.send_failed'), detail: t('support.send_failed_detail') })
117
+ }
118
+ }
119
+ </script>
@@ -25,6 +25,7 @@
25
25
  class="w-full"
26
26
  />
27
27
  </div>
28
+ <SupportIncidentButton labelled />
28
29
  <Button
29
30
  class="w-full"
30
31
  :label="t('user.logout')"
@@ -46,9 +47,8 @@ import { changeLocale } from '@formkit/vue'
46
47
  const auth = useAuth()
47
48
  const { versionDisplay } = useAppVersion()
48
49
  const { t, locale, locales } = useI18n()
49
- const queryCache = useQueryCache()
50
- const switchLocalePath = useSwitchLocalePath()
51
- const router = useRouter()
50
+ const { applyLocale } = useApplyLocale()
51
+ const { updateMyLocale } = useUpdateMyLocale()
52
52
 
53
53
  const op = ref()
54
54
  const toggle = (event: Event) => {
@@ -73,11 +73,12 @@ const selectedLocale = computed({
73
73
  set: (newValue) => {
74
74
  if (newValue?.code && newValue.code !== locale.value) {
75
75
  op.value.hide()
76
- changeLocale(newValue.code)
77
- router.push(switchLocalePath(newValue.code))
78
- .then(() => {
79
- queryCache.invalidateQueries()
80
- })
76
+ applyLocale(newValue.code)
77
+ // Fire-and-forget: the switch itself must not wait on the network, and a
78
+ // failed write only costs the user the cross-device preference.
79
+ updateMyLocale({ locale: newValue.code }).catch((error) => {
80
+ console.error('Failed to persist preferred locale', error)
81
+ })
81
82
  }
82
83
  },
83
84
  })
@@ -0,0 +1,361 @@
1
+ <template>
2
+ <div class="flex w-full flex-col gap-4">
3
+ <div class="flex flex-wrap items-center justify-between gap-2">
4
+ <Breadcrumb
5
+ :model="crumbs"
6
+ class="!bg-transparent !p-0"
7
+ >
8
+ <template #item="{ item }">
9
+ <button
10
+ type="button"
11
+ class="cursor-pointer hover:underline"
12
+ @click="openFolder(item.folder)"
13
+ >
14
+ {{ item.label }}
15
+ </button>
16
+ </template>
17
+ </Breadcrumb>
18
+ <div class="flex gap-2">
19
+ <Button
20
+ :label="t('userFiles.actions.upload')"
21
+ icon="pi pi-upload"
22
+ size="small"
23
+ :loading="isUploading"
24
+ @click="fileInput?.click()"
25
+ />
26
+ <Button
27
+ :label="t('userFiles.actions.newFolder')"
28
+ icon="pi pi-folder-plus"
29
+ size="small"
30
+ severity="secondary"
31
+ @click="openNameDialog('folder')"
32
+ />
33
+ <Button
34
+ v-tooltip.bottom="t('userFiles.actions.refresh')"
35
+ icon="pi pi-refresh"
36
+ size="small"
37
+ severity="secondary"
38
+ text
39
+ @click="refresh()"
40
+ />
41
+ <label
42
+ :for="fileInputId"
43
+ class="sr-only"
44
+ >{{ t('userFiles.actions.upload') }}</label>
45
+ <input
46
+ :id="fileInputId"
47
+ ref="fileInput"
48
+ type="file"
49
+ multiple
50
+ class="hidden"
51
+ @change="onFilesPicked"
52
+ >
53
+ </div>
54
+ </div>
55
+
56
+ <Message
57
+ v-if="noFileSpace"
58
+ severity="info"
59
+ :closable="false"
60
+ >
61
+ {{ t('userFiles.empty.noSpace') }}
62
+ </Message>
63
+
64
+ <Message
65
+ v-else-if="loadFailed"
66
+ severity="error"
67
+ :closable="false"
68
+ >
69
+ {{ t('userFiles.error.load') }}
70
+ </Message>
71
+
72
+ <div
73
+ v-else
74
+ class="flex flex-col gap-4"
75
+ :class="{ 'xl:flex-row': !stacked }"
76
+ >
77
+ <div
78
+ class="min-w-0 flex-1 rounded-lg border-2 border-dashed p-1 transition-colors"
79
+ :class="isDragging ? 'border-primary bg-primary-50 dark:bg-primary-950' : 'border-transparent'"
80
+ @dragover.prevent="isDragging = true"
81
+ @dragleave.prevent="isDragging = false"
82
+ @drop.prevent="onFilesDropped"
83
+ >
84
+ <DataTable
85
+ :value="entries"
86
+ :loading="isLoading"
87
+ data-key="path"
88
+ size="small"
89
+ row-hover
90
+ :row-class="(entry: FileEntryDto) => entry.path === selected?.path ? '!bg-primary-50 dark:!bg-primary-950' : ''"
91
+ class="cursor-pointer"
92
+ @row-click="onRowClick($event.data)"
93
+ >
94
+ <template #empty>
95
+ <p class="py-6 text-center text-sm text-surface-500">
96
+ {{ t('userFiles.empty.folder') }}
97
+ </p>
98
+ </template>
99
+ <Column :header="t('userFiles.columns.name')">
100
+ <template #body="{ data }">
101
+ <button
102
+ type="button"
103
+ class="flex max-w-full items-center gap-2 rounded text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-primary"
104
+ :aria-label="t(data.kind === 'folder' ? 'userFiles.actions.openFolder' : 'userFiles.actions.preview', { name: data.conversation_title ?? data.name })"
105
+ @click.stop="onRowClick(data)"
106
+ >
107
+ <Icon
108
+ :name="data.kind === 'folder' ? 'mage:folder' : 'mage:file'"
109
+ class="shrink-0 text-lg text-surface-500"
110
+ aria-hidden="true"
111
+ />
112
+ <span class="truncate font-medium">{{ data.conversation_title ?? data.name }}</span>
113
+ <span
114
+ v-if="data.conversation_title"
115
+ class="truncate text-xs text-surface-400"
116
+ >{{ t('userFiles.conversation') }}</span>
117
+ </button>
118
+ </template>
119
+ </Column>
120
+ <Column
121
+ :header="t('userFiles.columns.size')"
122
+ class="w-28 whitespace-nowrap text-sm text-surface-500"
123
+ >
124
+ <template #body="{ data }">
125
+ {{ data.size == null ? '' : formatSize(data.size) }}
126
+ </template>
127
+ </Column>
128
+ <Column
129
+ :header="t('userFiles.columns.modified')"
130
+ class="w-44 whitespace-nowrap text-sm text-surface-500"
131
+ >
132
+ <template #body="{ data }">
133
+ {{ useDateFormat(new Date(data.modified * 1000), 'DD.MM.YYYY HH:mm').value }}
134
+ </template>
135
+ </Column>
136
+ <Column class="w-40">
137
+ <template #body="{ data }">
138
+ <div
139
+ class="flex justify-end"
140
+ @click.stop
141
+ @keydown.stop
142
+ >
143
+ <Button
144
+ v-if="data.kind === 'file'"
145
+ v-tooltip.bottom="t('userFiles.actions.download')"
146
+ icon="pi pi-download"
147
+ size="small"
148
+ text
149
+ @click="download(data.path, data.name)"
150
+ />
151
+ <Button
152
+ v-if="!data.conversation_title"
153
+ v-tooltip.bottom="t('userFiles.actions.move')"
154
+ icon="pi pi-folder-open"
155
+ size="small"
156
+ text
157
+ @click="openMoveDialog(data)"
158
+ />
159
+ <Button
160
+ v-if="!data.conversation_title"
161
+ v-tooltip.bottom="t('userFiles.actions.rename')"
162
+ icon="pi pi-pencil"
163
+ size="small"
164
+ text
165
+ @click="openNameDialog('rename', data)"
166
+ />
167
+ <Button
168
+ v-tooltip.bottom="t('userFiles.actions.delete')"
169
+ icon="pi pi-trash"
170
+ size="small"
171
+ severity="danger"
172
+ text
173
+ @click="confirmDelete(data)"
174
+ />
175
+ </div>
176
+ </template>
177
+ </Column>
178
+ </DataTable>
179
+ </div>
180
+ <div
181
+ v-if="selected"
182
+ class="w-full"
183
+ :class="{ 'xl:w-[45%]': !stacked }"
184
+ >
185
+ <UserFilesPreview
186
+ :entry="selected"
187
+ @close="selected = null"
188
+ />
189
+ </div>
190
+ </div>
191
+
192
+ <UserFilesMoveDialog
193
+ v-model:visible="moveDialog.visible"
194
+ :entry="moveDialog.entry"
195
+ :folder="folder"
196
+ :folders="entries.filter(entry => entry.kind === 'folder')"
197
+ :parent-label="crumbs.length > 1 ? crumbs[crumbs.length - 2].label : t('userFiles.title')"
198
+ @submit="onMoveSubmitted"
199
+ />
200
+ <UserFilesNameDialog
201
+ v-model:visible="nameDialog.visible"
202
+ :header="nameDialog.mode === 'folder' ? t('userFiles.actions.newFolder') : t('userFiles.actions.rename')"
203
+ :initial-name="nameDialog.entry?.name"
204
+ @submit="onNameSubmitted"
205
+ />
206
+ </div>
207
+ </template>
208
+
209
+ <script setup lang="ts">
210
+ import type { FileEntryDto } from '@core/sdk/client'
211
+
212
+ const props = withDefaults(defineProps<{ initialFolder?: string, stacked?: boolean }>(), {
213
+ initialFolder: '.',
214
+ stacked: false,
215
+ })
216
+ const { t } = useI18n()
217
+ const toast = useToast()
218
+ const confirm = useConfirm()
219
+
220
+ const { folder, entries, folderTitle, isLoading, error, refresh } = useUserFiles()
221
+ watch(() => props.initialFolder, value => (folder.value = value), { immediate: true })
222
+ const { uploadUserFiles, isUploading } = useUploadUserFiles()
223
+ const { createUserFolder } = useCreateUserFolder()
224
+ const { moveUserFile } = useMoveUserFile()
225
+ const { deleteUserFile } = useDeleteUserFile()
226
+ const { download } = useUserFileContent()
227
+
228
+ const selected = ref<FileEntryDto | null>(null)
229
+ const fileInput = ref<HTMLInputElement | null>(null)
230
+ const fileInputId = useId()
231
+ const isDragging = ref(false)
232
+ const nameDialog = reactive<{ visible: boolean, mode: 'folder' | 'rename', entry?: FileEntryDto }>({
233
+ visible: false,
234
+ mode: 'folder',
235
+ })
236
+ const moveDialog = reactive<{ visible: boolean, entry?: FileEntryDto }>({ visible: false })
237
+ const titles = reactive<Record<string, string>>({})
238
+
239
+ const errorStatus = computed(() => (error.value as { statusCode?: number } | null)?.statusCode)
240
+ const noFileSpace = computed(() => errorStatus.value === 404 && folder.value === '.')
241
+ // A conversation's folder only exists once a file lands in it, so a missing folder below the top is shown empty.
242
+ const loadFailed = computed(() => error.value != null && errorStatus.value !== 404)
243
+
244
+ watch(entries, (list) => {
245
+ for (const entry of list) if (entry.conversation_title) titles[entry.path] = entry.conversation_title
246
+ }, { immediate: true })
247
+ watch(folderTitle, (title) => {
248
+ if (title) titles[folder.value] = title
249
+ }, { immediate: true })
250
+
251
+ const crumbs = computed(() => {
252
+ const parts = folder.value === '.' ? [] : folder.value.split('/')
253
+ return [
254
+ { label: t('userFiles.title'), folder: '.' },
255
+ ...parts.map((part, index) => {
256
+ const path = parts.slice(0, index + 1).join('/')
257
+ return { label: titles[path] ?? part, folder: path }
258
+ }),
259
+ ]
260
+ })
261
+
262
+ const join = (name: string) => (folder.value === '.' ? name : `${folder.value}/${name}`)
263
+ const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
264
+
265
+ const openFolder = (path: string) => {
266
+ folder.value = path
267
+ selected.value = null
268
+ }
269
+
270
+ const onRowClick = (entry: FileEntryDto) => {
271
+ if (entry.kind === 'folder') openFolder(entry.path)
272
+ else selected.value = entry
273
+ }
274
+
275
+ const formatSize = (bytes: number) => {
276
+ if (bytes < 1024) return `${bytes} B`
277
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
278
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`
279
+ }
280
+
281
+ const notifyFailure = (summary: string, failure: unknown) => {
282
+ const detail = (failure as { data?: { detail?: string } })?.data?.detail
283
+ toast.add({ severity: 'error', summary, detail, life: 5000 })
284
+ }
285
+
286
+ const uploadFiles = async (files: File[]) => {
287
+ if (files.length === 0) return
288
+ try {
289
+ await uploadUserFiles({ folder: folder.value, files })
290
+ toast.add({ severity: 'success', summary: t('userFiles.toast.uploaded', { count: files.length }), life: 3000 })
291
+ }
292
+ catch (error_) {
293
+ notifyFailure(t('userFiles.toast.uploadFailed'), error_)
294
+ }
295
+ }
296
+
297
+ const onFilesPicked = async (event: Event) => {
298
+ const input = event.target as HTMLInputElement
299
+ await uploadFiles([...(input.files ?? [])])
300
+ input.value = ''
301
+ }
302
+
303
+ const onFilesDropped = async (event: DragEvent) => {
304
+ isDragging.value = false
305
+ await uploadFiles([...(event.dataTransfer?.files ?? [])])
306
+ }
307
+
308
+ const openNameDialog = (mode: 'folder' | 'rename', entry?: FileEntryDto) => {
309
+ Object.assign(nameDialog, { visible: true, mode, entry })
310
+ }
311
+
312
+ const onNameSubmitted = async (name: string) => {
313
+ try {
314
+ if (nameDialog.mode === 'folder') {
315
+ await createUserFolder(join(name))
316
+ }
317
+ else if (nameDialog.entry) {
318
+ const parent = parentOf(nameDialog.entry.path)
319
+ await moveUserFile({ source: nameDialog.entry.path, destination: parent === '.' ? name : `${parent}/${name}` })
320
+ if (selected.value?.path === nameDialog.entry.path) selected.value = null
321
+ }
322
+ }
323
+ catch (error_) {
324
+ notifyFailure(t('userFiles.toast.changeFailed'), error_)
325
+ }
326
+ }
327
+
328
+ const openMoveDialog = (entry: FileEntryDto) => {
329
+ Object.assign(moveDialog, { visible: true, entry })
330
+ }
331
+
332
+ const onMoveSubmitted = async (targetFolder: string) => {
333
+ const entry = moveDialog.entry
334
+ if (!entry) return
335
+ try {
336
+ await moveUserFile({ source: entry.path, destination: targetFolder === '.' ? entry.name : `${targetFolder}/${entry.name}` })
337
+ if (selected.value?.path === entry.path) selected.value = null
338
+ }
339
+ catch (error_) {
340
+ notifyFailure(t('userFiles.toast.changeFailed'), error_)
341
+ }
342
+ }
343
+
344
+ const confirmDelete = (entry: FileEntryDto) => {
345
+ confirm.require({
346
+ header: t('userFiles.delete.header'),
347
+ message: t(entry.kind === 'folder' ? 'userFiles.delete.folder' : 'userFiles.delete.file', { name: entry.conversation_title ?? entry.name }),
348
+ icon: 'pi pi-exclamation-triangle',
349
+ acceptClass: 'p-button-danger',
350
+ accept: async () => {
351
+ try {
352
+ await deleteUserFile(entry.path)
353
+ if (selected.value?.path === entry.path) selected.value = null
354
+ }
355
+ catch (error_) {
356
+ notifyFailure(t('userFiles.toast.changeFailed'), error_)
357
+ }
358
+ },
359
+ })
360
+ }
361
+ </script>
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <Dialog
3
+ :visible="visible"
4
+ modal
5
+ :header="t('userFiles.move.header', { name: entry?.name ?? '' })"
6
+ class="w-[28rem]"
7
+ @update:visible="emit('update:visible', $event)"
8
+ >
9
+ <div class="flex flex-col gap-3">
10
+ <Listbox
11
+ v-model="target"
12
+ :options="targets"
13
+ option-label="label"
14
+ option-value="folder"
15
+ class="w-full"
16
+ :empty-message="t('userFiles.move.noTargets')"
17
+ >
18
+ <template #option="{ option }">
19
+ <div class="flex items-center gap-2">
20
+ <Icon
21
+ :name="option.up ? 'mage:arrow-up' : 'mage:folder'"
22
+ class="text-surface-500"
23
+ />
24
+ <span>{{ option.label }}</span>
25
+ </div>
26
+ </template>
27
+ </Listbox>
28
+ <div class="flex justify-end gap-2">
29
+ <Button
30
+ :label="t('userFiles.actions.cancel')"
31
+ severity="secondary"
32
+ text
33
+ @click="emit('update:visible', false)"
34
+ />
35
+ <Button
36
+ :label="t('userFiles.actions.move')"
37
+ :disabled="target == null"
38
+ @click="submit"
39
+ />
40
+ </div>
41
+ </div>
42
+ </Dialog>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import type { FileEntryDto } from '@core/sdk/client'
47
+
48
+ const props = defineProps<{
49
+ visible: boolean
50
+ entry?: FileEntryDto
51
+ folder: string
52
+ folders: FileEntryDto[]
53
+ parentLabel: string
54
+ }>()
55
+ const emit = defineEmits<{ 'update:visible': [boolean], 'submit': [string] }>()
56
+ const { t } = useI18n()
57
+
58
+ const target = ref<string | null>(null)
59
+ watch(() => props.visible, (visible) => {
60
+ if (visible) target.value = null
61
+ })
62
+
63
+ const parentOf = (path: string) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
64
+
65
+ const targets = computed(() => [
66
+ ...(props.folder === '.' ? [] : [{ label: props.parentLabel, folder: parentOf(props.folder), up: true }]),
67
+ ...props.folders
68
+ .filter(folder => folder.path !== props.entry?.path)
69
+ .map(folder => ({ label: folder.conversation_title ?? folder.name, folder: folder.path, up: false })),
70
+ ])
71
+
72
+ const submit = () => {
73
+ if (target.value == null) return
74
+ emit('submit', target.value)
75
+ emit('update:visible', false)
76
+ }
77
+ </script>
@@ -0,0 +1,59 @@
1
+ <template>
2
+ <Dialog
3
+ :visible="visible"
4
+ modal
5
+ :header="header"
6
+ class="w-[28rem]"
7
+ @update:visible="emit('update:visible', $event)"
8
+ >
9
+ <form
10
+ class="flex flex-col gap-3"
11
+ @submit.prevent="submit"
12
+ >
13
+ <InputText
14
+ v-model="name"
15
+ autofocus
16
+ :invalid="!isValid && name.length > 0"
17
+ />
18
+ <small
19
+ v-if="!isValid && name.length > 0"
20
+ class="text-red-500"
21
+ >{{ t('userFiles.name.invalid') }}</small>
22
+ <div class="flex justify-end gap-2">
23
+ <Button
24
+ :label="t('userFiles.actions.cancel')"
25
+ severity="secondary"
26
+ text
27
+ @click="emit('update:visible', false)"
28
+ />
29
+ <Button
30
+ type="submit"
31
+ :label="t('userFiles.actions.save')"
32
+ :disabled="!isValid"
33
+ />
34
+ </div>
35
+ </form>
36
+ </Dialog>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ const props = defineProps<{ visible: boolean, header: string, initialName?: string }>()
41
+ const emit = defineEmits<{ 'update:visible': [boolean], 'submit': [string] }>()
42
+ const { t } = useI18n()
43
+
44
+ const name = ref('')
45
+ watch(() => props.visible, (visible) => {
46
+ if (visible) name.value = props.initialName ?? ''
47
+ })
48
+
49
+ const isValid = computed(() => {
50
+ const trimmed = name.value.trim()
51
+ return trimmed.length > 0 && trimmed !== '.' && trimmed !== '..' && !trimmed.includes('/')
52
+ })
53
+
54
+ const submit = () => {
55
+ if (!isValid.value) return
56
+ emit('submit', name.value.trim())
57
+ emit('update:visible', false)
58
+ }
59
+ </script>