feat: update image and video sending
This commit is contained in:
parent
8d0c187bbf
commit
eb32e31cc1
@ -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">
|
||||
|
||||
@ -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) : [];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user