diff --git a/static/src/App.vue b/static/src/App.vue index 422a459..4d60a66 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -237,6 +237,7 @@ :streaming-message="composerBusy" :input-locked="displayLockEngaged" :uploading="uploading" + :media-uploading="mediaUploading" :thinking-mode="thinkingMode" :run-mode="resolvedRunMode" :model-menu-open="modelMenuOpen" @@ -310,8 +311,10 @@ :entries="imageEntries" :initial-selected="selectedImages" :loading="imageLoading" + :uploading="mediaUploading" @close="closeImagePicker" @confirm="handleImagesConfirmed" + @local-files="handleLocalImageFiles" /> @@ -321,8 +324,10 @@ :entries="videoEntries" :initial-selected="selectedVideos" :loading="videoLoading" + :uploading="mediaUploading" @close="closeVideoPicker" @confirm="handleVideosConfirmed" + @local-files="handleLocalVideoFiles" /> diff --git a/static/src/app.ts b/static/src/app.ts index 490450a..4b6baa2 100644 --- a/static/src/app.ts +++ b/static/src/app.ts @@ -510,7 +510,7 @@ const appOptions = { 'usageQuota' ]), ...mapWritableState(useFocusStore, ['focusedFiles']), - ...mapWritableState(useUploadStore, ['uploading']) + ...mapWritableState(useUploadStore, ['uploading', 'mediaUploading']) , ...mapState(useMonitorStore, { monitorIsLocked: (store) => store.isLocked @@ -838,10 +838,12 @@ const appOptions = { inputSetMultiline: 'setInputMultiline', inputSetImagePickerOpen: 'setImagePickerOpen', inputSetSelectedImages: 'setSelectedImages', + inputAddSelectedImage: 'addSelectedImage', inputClearSelectedImages: 'clearSelectedImages', inputRemoveSelectedImage: 'removeSelectedImage', inputSetVideoPickerOpen: 'setVideoPickerOpen', inputSetSelectedVideos: 'setSelectedVideos', + inputAddSelectedVideo: 'addSelectedVideo', inputClearSelectedVideos: 'clearSelectedVideos', inputRemoveSelectedVideo: 'removeSelectedVideo' }), @@ -877,7 +879,8 @@ const appOptions = { resourceSetUsageQuota: 'setUsageQuota' }), ...mapActions(useUploadStore, { - uploadHandleSelected: 'handleSelectedFiles' + uploadHandleSelected: 'handleSelectedFiles', + uploadBatchFiles: 'uploadFiles' }), ...mapActions(useFileStore, { fileFetchTree: 'fetchFileTree', @@ -2579,6 +2582,164 @@ const appOptions = { this.uploadHandleSelected(files); }, + normalizeLocalFiles(files) { + if (!files) return []; + const list = Array.isArray(files) ? files : Array.from(files); + return list.filter(Boolean); + }, + + isImageFile(file) { + const name = file?.name || ''; + const type = file?.type || ''; + return type.startsWith('image/') || /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(name); + }, + + isVideoFile(file) { + const name = file?.name || ''; + const type = file?.type || ''; + return type.startsWith('video/') || /\.(mp4|mov|m4v|webm|avi|mkv|flv|mpg|mpeg)$/i.test(name); + }, + + upsertImageEntry(path, filename) { + if (!path) return; + const name = filename || path.split('/').pop() || path; + const list = Array.isArray(this.imageEntries) ? this.imageEntries : []; + if (list.some((item) => item.path === path)) { + return; + } + this.imageEntries = [{ name, path }, ...list]; + }, + + upsertVideoEntry(path, filename) { + if (!path) return; + const name = filename || path.split('/').pop() || path; + const list = Array.isArray(this.videoEntries) ? this.videoEntries : []; + if (list.some((item) => item.path === path)) { + return; + } + this.videoEntries = [{ name, path }, ...list]; + }, + + async handleLocalImageFiles(files) { + if (!this.isConnected) { + return; + } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待当前图片上传完成', + type: 'info' + }); + return; + } + const list = this.normalizeLocalFiles(files); + if (!list.length) { + return; + } + const existingCount = Array.isArray(this.selectedImages) ? this.selectedImages.length : 0; + const remaining = Math.max(0, 9 - existingCount); + if (!remaining) { + this.uiPushToast({ + title: '已达上限', + message: '最多只能选择 9 张图片', + type: 'warning' + }); + return; + } + const valid = list.filter((file) => this.isImageFile(file)); + if (!valid.length) { + this.uiPushToast({ + title: '无法上传', + message: '仅支持图片文件', + type: 'warning' + }); + return; + } + if (valid.length < list.length) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非图片文件', + type: 'info' + }); + } + const limited = valid.slice(0, remaining); + if (valid.length > remaining) { + this.uiPushToast({ + title: '已超出数量', + message: `最多还能添加 ${remaining} 张图片,已自动截断`, + type: 'warning' + }); + } + const uploaded = await this.uploadBatchFiles(limited, { + markUploading: true, + markMediaUploading: true + }); + if (!uploaded.length) { + return; + } + uploaded.forEach((item) => { + if (!item?.path) return; + this.inputAddSelectedImage(item.path); + this.upsertImageEntry(item.path, item.filename); + }); + }, + + async handleLocalVideoFiles(files) { + if (!this.isConnected) { + return; + } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待当前视频上传完成', + type: 'info' + }); + return; + } + const list = this.normalizeLocalFiles(files); + if (!list.length) { + return; + } + const valid = list.filter((file) => this.isVideoFile(file)); + if (!valid.length) { + this.uiPushToast({ + title: '无法上传', + message: '仅支持视频文件', + type: 'warning' + }); + return; + } + if (valid.length < list.length) { + this.uiPushToast({ + title: '已忽略', + message: '已跳过非视频文件', + type: 'info' + }); + } + if (valid.length > 1) { + this.uiPushToast({ + title: '视频数量过多', + message: '一次只能选择 1 个视频,已使用第一个', + type: 'warning' + }); + } + const [file] = valid; + if (!file) { + return; + } + const uploaded = await this.uploadBatchFiles([file], { + markUploading: true, + markMediaUploading: true + }); + const [item] = uploaded; + if (!item?.path) { + return; + } + this.inputSetSelectedVideos([item.path]); + this.inputClearSelectedImages(); + this.upsertVideoEntry(item.path, item.filename); + }, + handleSendOrStop() { if (this.composerBusy) { this.stopTask(); @@ -2591,6 +2752,14 @@ const appOptions = { if (this.streamingUi || !this.isConnected) { return; } + if (this.mediaUploading) { + this.uiPushToast({ + title: '上传中', + message: '请等待图片/视频上传完成后再发送', + type: 'info' + }); + return; + } const text = (this.inputMessage || '').trim(); const images = Array.isArray(this.selectedImages) ? this.selectedImages.slice(0, 9) : []; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 3a1c4ec..d2a0981 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -52,6 +52,7 @@ :disabled=" !isConnected || (inputLocked && !streamingMessage) || + (mediaUploading && !streamingMessage) || ((!(inputMessage || '').trim() && (!selectedImages?.length && !selectedVideos?.length)) && !streamingMessage) " > @@ -167,6 +168,7 @@ const props = defineProps<{ currentModelKey: string; selectedImages?: string[]; selectedVideos?: string[]; + mediaUploading?: boolean; blockUpload?: boolean; blockToolToggle?: boolean; blockRealtimeTerminal?: boolean; diff --git a/static/src/components/overlay/ImagePicker.vue b/static/src/components/overlay/ImagePicker.vue index a6384de..cf63ab6 100644 --- a/static/src/components/overlay/ImagePicker.vue +++ b/static/src/components/overlay/ImagePicker.vue @@ -1,9 +1,27 @@