diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index fa27785b..63192278 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -4,12 +4,14 @@ import { useChatStore } from '@/stores/hermes/chat' import { useAppStore } from '@/stores/hermes/app' import { useProfilesStore } from '@/stores/hermes/profiles' import { fetchContextLength } from '@/api/hermes/sessions' -import { NButton, NTooltip } from 'naive-ui' +import { NButton, NTooltip, useMessage } from 'naive-ui' import { computed, ref, onMounted, watch } from 'vue' import { useI18n } from 'vue-i18n' +import ImageCropDialog from './ImageCropDialog.vue' const chatStore = useChatStore() const { t } = useI18n() +const toast = useMessage() const inputText = ref('') const textareaRef = ref() const fileInputRef = ref() @@ -18,6 +20,78 @@ const isDragging = ref(false) const dragCounter = ref(0) const isComposing = ref(false) +// --- Avatar upload --- +const avatarFileInputRef = ref() +const showCropDialog = ref(false) +const cropImageSrc = ref('') +const cropFileName = ref('') +const pendingAvatarFile = ref(null) + +const ANIMATED_TYPES = ['image/gif', 'image/apng'] +function isAnimated(type: string): boolean { + return ANIMATED_TYPES.includes(type) +} + +function handleUserAvatarClick() { + avatarFileInputRef.value?.click() +} + +function handleAvatarFileChange(e: Event) { + const input = e.target as HTMLInputElement + if (!input.files?.[0]) return + const file = input.files[0] + input.value = '' + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + toast.error(t('avatar.fileTooLarge')) + return + } + // Validate file type + const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] + if (!validTypes.includes(file.type)) { + toast.error(t('avatar.invalidType')) + return + } + + // Animated images skip crop dialog + if (isAnimated(file.type)) { + void doUploadAvatar(file) + return + } + + // Show crop dialog for static images + pendingAvatarFile.value = file + cropImageSrc.value = URL.createObjectURL(file) + cropFileName.value = file.name + showCropDialog.value = true +} + +async function doUploadAvatar(file: File | Blob, fileName?: string) { + try { + await chatStore.uploadAvatar('user', file, fileName) + toast.success(t('avatar.uploadSuccess')) + } catch (err: any) { + toast.error(err.message || t('avatar.uploadFailed')) + } +} + +function handleCropDone(blob: Blob) { + showCropDialog.value = false + const name = pendingAvatarFile.value?.name || 'avatar.png' + URL.revokeObjectURL(cropImageSrc.value) + cropImageSrc.value = '' + pendingAvatarFile.value = null + void doUploadAvatar(blob, name) +} + +function handleCropCancel() { + showCropDialog.value = false + URL.revokeObjectURL(cropImageSrc.value) + cropImageSrc.value = '' + pendingAvatarFile.value = null +} + const canSend = computed(() => inputText.value.trim() || attachments.value.length > 0) // --- Context info --- @@ -261,6 +335,30 @@ function isImage(type: string): boolean { class="file-input-hidden" @change="handleFileChange" /> + + + + {{ t('avatar.changeUserAvatar') }} +