feat: update image and video sending

This commit is contained in:
JOJO 2026-02-25 13:20:27 +08:00
parent 8d0c187bbf
commit eb32e31cc1
6 changed files with 399 additions and 28 deletions

View File

@ -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"
/>
</transition>
<transition name="overlay-fade">
@ -321,8 +324,10 @@
:entries="videoEntries"
:initial-selected="selectedVideos"
:loading="videoLoading"
:uploading="mediaUploading"
@close="closeVideoPicker"
@confirm="handleVideosConfirmed"
@local-files="handleLocalVideoFiles"
/>
</transition>
<transition name="overlay-fade">

View File

@ -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) : [];

View File

@ -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;

View File

@ -3,7 +3,25 @@
<div v-if="open" class="image-picker-backdrop" @click.self="close">
<div class="image-picker-panel">
<div class="header">
<div class="header-left">
<div class="title">选择图片最多9张</div>
<button
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
{{ uploading ? '上传中...' : '从本地发送' }}
</button>
<input
ref="localInput"
type="file"
class="file-input-hidden"
accept="image/*"
multiple
@change="onLocalChange"
/>
</div>
<button class="close-btn" @click="close">×</button>
</div>
<div class="body">
@ -36,26 +54,34 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
interface ImageEntry {
name: string;
path: string;
}
const props = defineProps<{
const props = withDefaults(
defineProps<{
open: boolean;
entries: ImageEntry[];
initialSelected: string[];
loading: boolean;
}>();
uploading?: boolean;
}>(),
{
uploading: false
}
);
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', list: string[]): void;
(e: 'local-files', files: FileList | null): void;
}>();
const selectedSet = ref<Set<string>>(new Set(props.initialSelected || []));
const localInput = ref<HTMLInputElement | null>(null);
watch(
() => props.initialSelected,
@ -82,6 +108,19 @@ const close = () => emit('close');
const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => {
if (props.uploading) return;
localInput.value?.click();
};
const onLocalChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('local-files', target?.files || null);
if (target) {
target.value = '';
}
};
const previewUrl = (path: string) => `/api/gui/files/download?path=${encodeURIComponent(path)}`;
onMounted(() => {
@ -117,9 +156,27 @@ onMounted(() => {
padding: 14px 16px;
border-bottom: 1px solid #1f2430;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.title {
font-weight: 600;
}
.local-btn {
border: 1px solid #2f3645;
padding: 4px 10px;
border-radius: 8px;
background: #1b202c;
color: #c5ccda;
font-size: 12px;
cursor: pointer;
}
.local-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.close-btn {
background: transparent;
color: #9aa3b5;

View File

@ -3,7 +3,24 @@
<div v-if="open" class="image-picker-backdrop" @click.self="close">
<div class="image-picker-panel">
<div class="header">
<div class="header-left">
<div class="title">选择视频一次最多 1 </div>
<button
type="button"
class="local-btn"
:disabled="uploading"
@click="triggerLocal"
>
{{ uploading ? '上传中...' : '从本地发送' }}
</button>
<input
ref="localInput"
type="file"
class="file-input-hidden"
accept="video/*"
@change="onLocalChange"
/>
</div>
<button class="close-btn" @click="close">×</button>
</div>
<div class="body">
@ -39,26 +56,34 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
interface VideoEntry {
name: string;
path: string;
}
const props = defineProps<{
const props = withDefaults(
defineProps<{
open: boolean;
entries: VideoEntry[];
initialSelected: string[];
loading: boolean;
}>();
uploading?: boolean;
}>(),
{
uploading: false
}
);
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', list: string[]): void;
(e: 'local-files', files: FileList | null): void;
}>();
const selectedSet = ref<Set<string>>(new Set(props.initialSelected || []));
const localInput = ref<HTMLInputElement | null>(null);
watch(
() => props.initialSelected,
@ -85,6 +110,19 @@ const close = () => emit('close');
const confirm = () => emit('confirm', Array.from(selectedSet.value));
const triggerLocal = () => {
if (props.uploading) return;
localInput.value?.click();
};
const onLocalChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit('local-files', target?.files || null);
if (target) {
target.value = '';
}
};
const fileExt = (name: string) => {
if (!name || !name.includes('.')) return '';
return name.split('.').pop()?.toLowerCase();
@ -123,9 +161,27 @@ onMounted(() => {
padding: 14px 16px;
border-bottom: 1px solid #1f2430;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.title {
font-weight: 600;
}
.local-btn {
border: 1px solid #2f3645;
padding: 4px 10px;
border-radius: 8px;
background: #1b202c;
color: #c5ccda;
font-size: 12px;
cursor: pointer;
}
.local-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.close-btn {
background: transparent;
color: #9aa3b5;

View File

@ -3,18 +3,28 @@ import { useUiStore } from './ui';
import { useFileStore } from './file';
import { usePolicyStore } from './policy';
interface UploadResult {
path: string;
filename: string;
}
interface UploadState {
uploading: boolean;
mediaUploading: boolean;
}
export const useUploadStore = defineStore('upload', {
state: (): UploadState => ({
uploading: false
uploading: false,
mediaUploading: false
}),
actions: {
setUploading(value: boolean) {
this.uploading = value;
},
setMediaUploading(value: boolean) {
this.mediaUploading = value;
},
async handleSelectedFiles(files: FileList | File[] | null) {
if (!files) {
return;
@ -26,10 +36,18 @@ export const useUploadStore = defineStore('upload', {
}
await this.uploadFile(file);
},
async uploadFile(file: File) {
if (!file || this.uploading) {
return;
async uploadFiles(
files: FileList | File[] | null,
options: { markUploading?: boolean; markMediaUploading?: boolean } = {}
): Promise<UploadResult[]> {
if (!files) {
return [];
}
const list = Array.isArray(files) ? files : Array.from(files);
if (!list.length) {
return [];
}
const { markUploading = true, markMediaUploading = false } = options;
const policyStore = usePolicyStore();
if (policyStore.uiBlocks?.block_upload) {
const uiStore = useUiStore();
@ -38,7 +56,57 @@ export const useUploadStore = defineStore('upload', {
message: '已被管理员禁用上传功能',
type: 'warning'
});
return;
return [];
}
const fileStore = useFileStore();
const results: UploadResult[] = [];
if (markUploading) {
this.setUploading(true);
}
if (markMediaUploading) {
this.setMediaUploading(true);
}
try {
for (const file of list) {
const uploaded = await this.uploadFile(file, {
manageUploading: false,
refreshFileTree: false,
skipPolicyCheck: true
});
if (uploaded?.path) {
results.push(uploaded);
}
}
if (results.length) {
await fileStore.fetchFileTree();
}
} finally {
if (markMediaUploading) {
this.setMediaUploading(false);
}
if (markUploading) {
this.setUploading(false);
}
}
return results;
},
async uploadFile(
file: File,
options: { manageUploading?: boolean; refreshFileTree?: boolean; skipPolicyCheck?: boolean } = {}
): Promise<UploadResult | null> {
const { manageUploading = true, refreshFileTree = true, skipPolicyCheck = false } = options;
if (!file || (manageUploading && this.uploading)) {
return null;
}
const policyStore = usePolicyStore();
if (!skipPolicyCheck && policyStore.uiBlocks?.block_upload) {
const uiStore = useUiStore();
uiStore.pushToast({
title: '上传被禁用',
message: '已被管理员禁用上传功能',
type: 'warning'
});
return null;
}
const uiStore = useUiStore();
const fileStore = useFileStore();
@ -53,7 +121,9 @@ export const useUploadStore = defineStore('upload', {
duration: 5000
});
}
if (manageUploading) {
this.setUploading(true);
}
const toastId = uiStore.pushToast({
title: '上传文件',
message: `正在上传 ${file.name}...`,
@ -84,7 +154,9 @@ export const useUploadStore = defineStore('upload', {
const message = result.error || result.message || '上传失败';
throw new Error(message);
}
if (refreshFileTree) {
await fileStore.fetchFileTree();
}
const successMessage = `上传成功:${result.path || file.name}`;
if (toastId) {
uiStore.updateToast(toastId, {
@ -100,6 +172,13 @@ export const useUploadStore = defineStore('upload', {
type: 'success'
});
}
if (result?.path) {
return {
path: result.path,
filename: result.filename || file.name || result.path
};
}
return null;
} catch (error: any) {
const originalMessage = error && error.message ? String(error.message) : '';
const fileLabel = file && file.name ? file.name : '文件';
@ -128,9 +207,12 @@ export const useUploadStore = defineStore('upload', {
type: 'error'
});
}
return null;
} finally {
if (manageUploading) {
this.setUploading(false);
}
}
}
}
});