| <template> |
| <v-card class="standalone-chat-card" elevation="0" rounded="0"> |
| <v-card-text class="standalone-chat-container"> |
| <div class="chat-layout"> |
| |
| <div class="chat-content-panel"> |
| <MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark" |
| :isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview" |
| ref="messageList" /> |
| <div class="welcome-container fade-in" v-else> |
| <div class="welcome-title"> |
| <span>Hello, I'm</span> |
| <span class="bot-name">AstrBot ⭐</span> |
| </div> |
| <p class="text-caption text-medium-emphasis mt-2"> |
| 测试配置: {{ configId || 'default' }} |
| </p> |
| </div> |
| |
| |
| <ChatInput |
| v-model:prompt="prompt" |
| :stagedImagesUrl="stagedImagesUrl" |
| :stagedAudioUrl="stagedAudioUrl" |
| :disabled="isStreaming" |
| :is-running="isStreaming || isConvRunning" |
| :enableStreaming="enableStreaming" |
| :isRecording="isRecording" |
| :session-id="currSessionId || null" |
| :current-session="getCurrentSession" |
| :config-id="configId" |
| @send="handleSendMessage" |
| @stop="handleStopMessage" |
| @toggleStreaming="toggleStreaming" |
| @removeImage="removeImage" |
| @removeAudio="removeAudio" |
| @startRecording="handleStartRecording" |
| @stopRecording="handleStopRecording" |
| @pasteImage="handlePaste" |
| @fileSelect="handleFileSelect" |
| @openLiveMode="" |
| ref="chatInputRef" |
| /> |
| </div> |
| </div> |
| </v-card-text> |
| </v-card> |
| |
| |
| <v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh"> |
| <v-card class="image-preview-card" elevation="8"> |
| <v-card-title class="d-flex justify-space-between align-center pa-4"> |
| <span>{{ t('core.common.imagePreview') }}</span> |
| <v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" /> |
| </v-card-title> |
| <v-card-text class="text-center pa-4"> |
| <img :src="previewImageUrl" class="preview-image-large" /> |
| </v-card-text> |
| </v-card> |
| </v-dialog> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'; |
| import axios from 'axios'; |
| import { useCustomizerStore } from '@/stores/customizer'; |
| import { useI18n, useModuleI18n } from '@/i18n/composables'; |
| import { useTheme } from 'vuetify'; |
| import MessageList from '@/components/chat/MessageList.vue'; |
| import ChatInput from '@/components/chat/ChatInput.vue'; |
| import { useMessages } from '@/composables/useMessages'; |
| import { useMediaHandling } from '@/composables/useMediaHandling'; |
| import { useRecording } from '@/composables/useRecording'; |
| import { useToast } from '@/utils/toast'; |
| import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding'; |
| |
| interface Props { |
| configId?: string | null; |
| } |
| |
| const props = withDefaults(defineProps<Props>(), { |
| configId: null |
| }); |
| |
| const { t } = useI18n(); |
| const { error: showError } = useToast(); |
| |
| |
| |
| const imagePreviewDialog = ref(false); |
| const previewImageUrl = ref(''); |
| |
| |
| const currSessionId = ref(''); |
| const getCurrentSession = computed(() => null); |
| |
| async function bindConfigToSession(sessionId: string) { |
| const confId = (props.configId || '').trim(); |
| if (!confId || confId === 'default') { |
| return; |
| } |
| |
| const umoDetails = buildWebchatUmoDetails(sessionId, false); |
| |
| await axios.post('/api/config/umo_abconf_route/update', { |
| umo: umoDetails.umo, |
| conf_id: confId |
| }); |
| } |
| |
| async function newSession() { |
| try { |
| const response = await axios.get('/api/chat/new_session'); |
| const sessionId = response.data.data.session_id; |
| |
| try { |
| await bindConfigToSession(sessionId); |
| } catch (err) { |
| console.error('Failed to bind config to session', err); |
| } |
| |
| currSessionId.value = sessionId; |
| |
| return sessionId; |
| } catch (err) { |
| console.error(err); |
| throw err; |
| } |
| } |
| |
| function updateSessionTitle(sessionId: string, title: string) { |
| |
| } |
| |
| function getSessions() { |
| |
| } |
| |
| const { |
| stagedImagesUrl, |
| stagedAudioUrl, |
| stagedFiles, |
| getMediaFile, |
| processAndUploadImage, |
| handlePaste, |
| removeImage, |
| removeAudio, |
| clearStaged, |
| cleanupMediaCache |
| } = useMediaHandling(); |
| |
| const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording(); |
| |
| const { |
| messages, |
| isStreaming, |
| isConvRunning, |
| enableStreaming, |
| getSessionMessages: getSessionMsg, |
| sendMessage: sendMsg, |
| stopMessage: stopMsg, |
| toggleStreaming |
| } = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions); |
| |
| |
| const messageList = ref<InstanceType<typeof MessageList> | null>(null); |
| const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null); |
| |
| |
| const prompt = ref(''); |
| |
| const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark'); |
| |
| function openImagePreview(imageUrl: string) { |
| previewImageUrl.value = imageUrl; |
| imagePreviewDialog.value = true; |
| } |
| |
| async function handleStartRecording() { |
| await startRec(); |
| } |
| |
| async function handleStopRecording() { |
| const audioFilename = await stopRec(); |
| stagedAudioUrl.value = audioFilename; |
| } |
| |
| async function handleFileSelect(files: FileList) { |
| for (const file of files) { |
| await processAndUploadImage(file); |
| } |
| } |
| |
| async function handleSendMessage() { |
| if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) { |
| return; |
| } |
| |
| try { |
| if (!currSessionId.value) { |
| await newSession(); |
| } |
| |
| const promptToSend = prompt.value.trim(); |
| const audioNameToSend = stagedAudioUrl.value; |
| const filesToSend = stagedFiles.value.map(f => ({ |
| attachment_id: f.attachment_id, |
| url: f.url, |
| original_name: f.original_name, |
| type: f.type |
| })); |
| |
| |
| prompt.value = ''; |
| clearStaged(); |
| |
| |
| const selection = chatInputRef.value?.getCurrentSelection(); |
| const selectedProviderId = selection?.providerId || ''; |
| const selectedModelName = selection?.modelName || ''; |
| |
| await sendMsg( |
| promptToSend, |
| filesToSend, |
| audioNameToSend, |
| selectedProviderId, |
| selectedModelName |
| ); |
| |
| |
| nextTick(() => { |
| messageList.value?.scrollToBottom(); |
| }); |
| } catch (err) { |
| console.error('Failed to send message:', err); |
| showError(t('features.chat.errors.sendMessageFailed')); |
| |
| |
| } |
| } |
| |
| async function handleStopMessage() { |
| await stopMsg(); |
| } |
| |
| onMounted(async () => { |
| |
| try { |
| await newSession(); |
| } catch (err) { |
| console.error('Failed to create initial session:', err); |
| showError(t('features.chat.errors.createSessionFailed')); |
| } |
| }); |
| |
| onBeforeUnmount(() => { |
| cleanupMediaCache(); |
| }); |
| </script> |
| |
| <style scoped> |
| |
| @keyframes fadeIn { |
| from { |
| opacity: 0; |
| transform: translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| |
| .standalone-chat-card { |
| width: 100%; |
| height: 100%; |
| max-height: 100%; |
| overflow: hidden; |
| } |
| |
| .standalone-chat-container { |
| width: 100%; |
| height: 100%; |
| max-height: 100%; |
| padding: 0; |
| overflow: hidden; |
| } |
| |
| .chat-layout { |
| height: 100%; |
| max-height: 100%; |
| display: flex; |
| overflow: hidden; |
| } |
| |
| .chat-content-panel { |
| height: 100%; |
| max-height: 100%; |
| width: 100%; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| .conversation-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 8px; |
| padding-left: 16px; |
| border-bottom: 1px solid var(--v-theme-border); |
| width: 100%; |
| padding-right: 32px; |
| flex-shrink: 0; |
| } |
| |
| .conversation-header-info h4 { |
| margin: 0; |
| font-weight: 500; |
| } |
| |
| .conversation-header-actions { |
| display: flex; |
| gap: 8px; |
| align-items: center; |
| } |
| |
| .welcome-container { |
| height: 100%; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| flex-direction: column; |
| } |
| |
| .welcome-title { |
| font-size: 28px; |
| margin-bottom: 8px; |
| } |
| |
| .bot-name { |
| font-weight: 700; |
| margin-left: 8px; |
| color: var(--v-theme-secondary); |
| } |
| |
| .fade-in { |
| animation: fadeIn 0.3s ease-in-out; |
| } |
| |
| .preview-image-large { |
| max-width: 100%; |
| max-height: 70vh; |
| object-fit: contain; |
| } |
| </style> |
| |