From eb32e31cc1dab64a1be8de87de76ec8f12049f51 Mon Sep 17 00:00:00 2001
From: JOJO <1498581755@qq.com>
Date: Wed, 25 Feb 2026 13:20:27 +0800
Subject: [PATCH] feat: update image and video sending
---
static/src/App.vue | 5 +
static/src/app.ts | 173 +++++++++++++++++-
static/src/components/input/InputComposer.vue | 2 +
static/src/components/overlay/ImagePicker.vue | 75 +++++++-
static/src/components/overlay/VideoPicker.vue | 74 +++++++-
static/src/stores/upload.ts | 98 +++++++++-
6 files changed, 399 insertions(+), 28 deletions(-)
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 @@
-