| <template> |
| <div class="input-area fade-in" @dragover.prevent="handleDragOver" @dragleave.prevent="handleDragLeave" |
| @drop.prevent="handleDrop"> |
| <div class="input-container" :style="{ |
| width: '85%', |
| maxWidth: '900px', |
| margin: '0 auto', |
| border: isDark ? 'none' : '1px solid #e0e0e0', |
| borderRadius: '24px', |
| boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)', |
| backgroundColor: isDark ? '#2d2d2d' : 'transparent', |
| position: 'relative' |
| }"> |
| |
| <transition name="fade"> |
| <div v-if="isDragging" class="drop-overlay"> |
| <div class="drop-overlay-content"> |
| <v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon> |
| <span class="drop-text">{{ tm('input.dropToUpload') }}</span> |
| </div> |
| </div> |
| </transition> |
| |
| <transition name="slideReply" @after-leave="handleReplyAfterLeave"> |
| <div class="reply-preview" v-if="props.replyTo && !isReplyClosing"> |
| <div class="reply-content"> |
| <v-icon size="small" class="reply-icon">mdi-reply</v-icon> |
| "<span class="reply-text">{{ props.replyTo.selectedText }}</span>" |
| </div> |
| <v-btn @click="handleClearReply" class="remove-reply-btn" icon="mdi-close" size="x-small" |
| color="grey" variant="text" /> |
| </div> |
| </transition> |
| <textarea ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" :disabled="disabled" |
| placeholder="Ask AstrBot..." class="chat-textarea" |
| autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false" |
| style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 16px 20px; min-height: 40px; max-height: 200px; overflow-y: auto; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea> |
| <div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;"> |
| <div |
| style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden;"> |
| |
| <StyledMenu offset="8" location="top start" :close-on-content-click="false"> |
| <template v-slot:activator="{ props: activatorProps }"> |
| <v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" /> |
| </template> |
| |
| |
| <v-list-item class="styled-menu-item" rounded="md" @click="triggerImageInput"> |
| <template v-slot:prepend> |
| <v-icon icon="mdi-file-upload-outline" size="small"></v-icon> |
| </template> |
| <v-list-item-title> |
| {{ tm('input.upload') }} |
| </v-list-item-title> |
| </v-list-item> |
| |
| |
| <ConfigSelector :session-id="sessionId || null" :platform-id="sessionPlatformId" |
| :is-group="sessionIsGroup" :initial-config-id="props.configId" |
| @config-changed="handleConfigChange" /> |
| |
| |
| <v-list-item class="styled-menu-item" rounded="md" @click="$emit('toggleStreaming')"> |
| <template v-slot:prepend> |
| <v-icon :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon> |
| </template> |
| <v-list-item-title> |
| {{ enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled') }} |
| </v-list-item-title> |
| </v-list-item> |
| </StyledMenu> |
|
|
| <!-- Provider/Model Selector Menu --> |
| <ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" /> |
| </div> |
| <div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center; flex-shrink: 0;"> |
| <input type="file" ref="imageInputRef" @change="handleFileSelect" style="display: none" multiple /> |
| <v-progress-circular v-if="disabled && !mobile" indeterminate size="16" class="mr-1" width="1.5" /> |
| |
| |
| |
| |
| |
| |
| |
| |
| {{ tm('voice.liveMode') } |
| |
| </v-btn> --> |
| <v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'" |
| class="record-btn"> |
| <v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text" |
| plain></v-icon> |
| |
| {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') } |
| |
| </v-btn> |
| <v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn"> |
| <v-icon icon="mdi-stop" variant="text" plain></v-icon> |
| |
| {{ tm('input.stopGenerating') } |
| |
| </v-btn> |
| <v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple" |
| :disabled="!canSend" class="send-btn" /> |
| </div> |
| </div> |
| </div> |
|
|
| <!-- 附件预览区 --> |
| <div class="attachments-preview" |
| v-if="stagedImagesUrl.length > 0 || stagedAudioUrl || (stagedFiles && stagedFiles.length > 0)"> |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| {{ tm('voice.recording') } |
| |
| <v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small" |
| color="error" variant="text" /> |
| </div> |
|
|
| |
| |
| |
| {{ file.original_name } |
| |
| <v-btn @click="$emit('removeFile', index)" class="remove-attachment-btn" icon="mdi-close" size="small" |
| color="error" variant="text" /> |
| </div> |
| </div> |
| </div> |
| </template> |
|
|
| <script setup lang="ts"> |
| import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'; |
| import { useDisplay } from 'vuetify'; |
| import { useModuleI18n } from '@/i18n/composables'; |
| import { useCustomizerStore } from '@/stores/customizer'; |
| import ConfigSelector from './ConfigSelector.vue'; |
| import ProviderModelMenu from './ProviderModelMenu.vue'; |
| import StyledMenu from '@/components/shared/StyledMenu.vue'; |
| import type { Session } from '@/composables/useSessions'; |
|
|
| interface StagedFileInfo { |
| attachment_id: string; |
| filename: string; |
| original_name: string; |
| url: string; |
| type: string; |
| } |
|
|
| interface ReplyInfo { |
| messageId: number; |
| selectedText?: string; |
| } |
|
|
| interface Props { |
| prompt: string; |
| stagedImagesUrl: string[]; |
| stagedAudioUrl: string; |
| stagedFiles?: StagedFileInfo[]; |
| disabled: boolean; |
| enableStreaming: boolean; |
| isRecording: boolean; |
| isRunning: boolean; |
| sessionId?: string | null; |
| currentSession?: Session | null; |
| configId?: string | null; |
| replyTo?: ReplyInfo | null; |
| } |
|
|
| const props = withDefaults(defineProps{ |
| sessionId: null, |
| currentSession: null, |
| configId: null, |
| stagedFiles: () => [], |
| replyTo: null |
| } |
| |
| { |
| 'update:prompt': [value: string]; |
| send: []; |
| stop: []; |
| toggleStreaming: []; |
| removeImage: [index: number]; |
| removeAudio: []; |
| removeFile: [index: number]; |
| startRecording: []; |
| stopRecording: []; |
| pasteImage: [event: ClipboardEvent]; |
| fileSelect: [files: FileList]; |
| clearReply: []; |
| openLiveMode: []; |
| } |
| |
| { tm } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| { |
| get: () => props.prompt, |
| set: (value) => emit('update:prompt', value) |
| } |
| |
| |
| |
| |
| { |
| return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl || (props.stagedFiles && props.stagedFiles.length > 0); |
| } |
| |
| |
| |
| |
| |
| |
| |
| { |
| isReplyClosing.value = true; |
| } |
| |
| |
| { |
| emit('clearReply'); |
| isReplyClosing.value = false; |
| } |
| |
| { mobile } |
| |
| |
| { |
| const el = inputField.value; |
| if (!el) return; |
| el.style.height = 'auto'; |
| el.style.height = Math.min(el.scrollHeight, 200) + 'px'; |
| } |
| |
| { |
| nextTick(autoResize); |
| } |
| |
| { |
| // Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕) |
| // Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留) |
| if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) { |
| e.preventDefault(); |
| if (localPrompt.value.trim() === '/astr_live_dev') { |
| emit('openLiveMode'); |
| localPrompt.value = ''; |
| return; |
| } |
| { |
| emit('send'); |
| } |
| |
| |
| |
| |
| { |
| e.preventDefault(); |
| if (ctrlKeyDown.value) return; |
| |
| ctrlKeyDown.value = true; |
| ctrlKeyTimer.value = window.setTimeout(() => { |
| if (ctrlKeyDown.value && !props.isRecording) { |
| emit('startRecording'); |
| } |
| |
| |
| |
| |
| { |
| if (e.keyCode === 66) { |
| ctrlKeyDown.value = false; |
| |
| if (ctrlKeyTimer.value) { |
| clearTimeout(ctrlKeyTimer.value); |
| ctrlKeyTimer.value = null; |
| } |
| |
| { |
| emit('stopRecording'); |
| } |
| |
| |
| |
| { |
| emit('pasteImage', e); |
| } |
| |
| { |
| // 清除之前的 leave timeout |
| if (dragLeaveTimeout) { |
| clearTimeout(dragLeaveTimeout); |
| dragLeaveTimeout = null; |
| } |
| |
| |
| { |
| isDragging.value = true; |
| } |
| |
| |
| { |
| // 使用 timeout 避免在子元素间移动时闪烁 |
| dragLeaveTimeout = window.setTimeout(() => { |
| isDragging.value = false; |
| } |
| |
| |
| { |
| isDragging.value = false; |
| |
| const files = e.dataTransfer?.files; |
| if (files && files.length > 0) { |
| emit('fileSelect', files); |
| } |
| |
| |
| { |
| imageInputRef.value?.click(); |
| } |
| |
| { |
| const target = event.target as HTMLInputElement; |
| const files = target.files; |
| if (files) { |
| emit('fileSelect', files); |
| } |
| |
| |
| |
| { |
| if (props.isRecording) { |
| emit('stopRecording'); |
| }{ |
| emit('startRecording'); |
| } |
| |
| |
| { configId: string; agentRunnerType: string }{ |
| const runnerType = (payload.agentRunnerType || '').toLowerCase(); |
| const isInternal = runnerType === 'internal' || runnerType === 'local'; |
| showProviderSelector.value = isInternal; |
| } |
| |
| { |
| if (!showProviderSelector.value) { |
| return null; |
| } |
| |
| |
| |
| { |
| if (inputField.value) { |
| inputField.value.addEventListener('paste', handlePaste); |
| } |
| |
| |
| |
| { |
| if (inputField.value) { |
| inputField.value.removeEventListener('paste', handlePaste); |
| } |
| |
| |
| |
| { |
| getCurrentSelection |
| } |
| |
|
|
| <style scoped> |
| .input-area { |
| padding: 16px; |
| background-color: transparent; |
| position: relative; |
| border-top: 1px solid var(--v-theme-border); |
| flex-shrink: 0; |
| } |
|
|
| /* 拖拽上传遮罩 */ |
| .drop-overlay { |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background-color: rgba(103, 58, 183, 0.15); |
| border: 2px dashed rgba(103, 58, 183, 0.5); |
| border-radius: 24px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: 100; |
| pointer-events: none; |
| } |
|
|
| .drop-overlay-content { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 8px; |
| } |
|
|
| .drop-text { |
| font-size: 16px; |
| font-weight: 500; |
| color: #673ab7; |
| } |
|
|
| /* Fade transition for drop overlay */ |
| .fade-enter-active, |
| .fade-leave-active { |
| transition: opacity 0.2s ease; |
| } |
|
|
| .fade-enter-from, |
| .fade-leave-to { |
| opacity: 0; |
| } |
|
|
| .reply-preview { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 8px 16px; |
| margin: 8px 8px 0 8px; |
| background-color: rgba(103, 58, 183, 0.06); |
| border-radius: 12px; |
| gap: 8px; |
| max-height: 500px; |
| overflow: hidden; |
| } |
|
|
| /* Transition animations for reply preview */ |
| .slideReply-enter-active { |
| animation: slideDown 0.2s ease-out; |
| } |
|
|
| .slideReply-leave-active { |
| animation: slideUp 0.2s ease-out; |
| } |
|
|
| @keyframes slideDown { |
| from { |
| max-height: 0; |
| opacity: 0; |
| margin-top: 0; |
| padding-top: 0; |
| padding-bottom: 0; |
| } |
|
|
| to { |
| max-height: 500px; |
| opacity: 1; |
| margin-top: 8px; |
| padding-top: 8px; |
| padding-bottom: 8px; |
| } |
| } |
|
|
| @keyframes slideUp { |
| from { |
| max-height: 500px; |
| opacity: 1; |
| margin-top: 8px; |
| padding-top: 8px; |
| padding-bottom: 8px; |
| } |
|
|
| to { |
| max-height: 0; |
| opacity: 0; |
| margin-top: 0; |
| padding-top: 0; |
| padding-bottom: 0; |
| } |
| } |
|
|
| .reply-content { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| flex: 1; |
| min-width: 0; |
| overflow: hidden; |
| } |
|
|
| .reply-icon { |
| color: var(--v-theme-secondary); |
| flex-shrink: 0; |
| } |
|
|
| .reply-text { |
| font-size: 13px; |
| color: var(--v-theme-secondaryText); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| flex: 1; |
| min-width: 0; |
| } |
|
|
| .remove-reply-btn { |
| flex-shrink: 0; |
| opacity: 0.6; |
| } |
|
|
| .attachments-preview { |
| display: flex; |
| gap: 8px; |
| margin-top: 8px; |
| max-width: 900px; |
| margin: 8px auto 0; |
| flex-wrap: wrap; |
| } |
|
|
| .image-preview, |
| .audio-preview, |
| .file-preview { |
| position: relative; |
| display: inline-flex; |
| } |
|
|
| .preview-image { |
| width: 60px; |
| height: 60px; |
| object-fit: cover; |
| border-radius: 8px; |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
| } |
|
|
| .audio-chip, |
| .file-chip { |
| height: 36px; |
| border-radius: 18px; |
| } |
|
|
| .file-name-preview { |
| max-width: 120px; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
|
|
| .remove-attachment-btn { |
| position: absolute; |
| top: -8px; |
| right: -8px; |
| opacity: 0.8; |
| transition: opacity 0.2s; |
| } |
|
|
| .remove-attachment-btn:hover { |
| opacity: 1; |
| } |
|
|
| .fade-in { |
| animation: fadeIn 0.3s ease-in-out; |
| } |
|
|
| @keyframes fadeIn { |
| from { |
| opacity: 0; |
| transform: translateY(10px); |
| } |
|
|
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
|
|
| @media (max-width: 768px) { |
| .input-area { |
| padding: 0 !important; |
| padding-bottom: 10px !important; |
| } |
|
|
| .input-container { |
| width: 100% !important; |
| max-width: 100% !important; |
| } |
|
|
| .input-area textarea, |
| .chat-textarea { |
| min-height: 32px !important; |
| max-height: 160px !important; |
| font-size: 16px !important; |
| padding: 16px 16px 12px 16px !important; |
| } |
| } |
| </style> |
|
|