ваше сообщение коммита
This commit is contained in:
622
frontend/src/components/ChatInterface.vue
Normal file
622
frontend/src/components/ChatInterface.vue
Normal file
@@ -0,0 +1,622 @@
|
||||
<template>
|
||||
<div class="chat-container" :style="{ '--chat-input-height': chatInputHeight + 'px' }">
|
||||
<div ref="messagesContainer" class="chat-messages" @scroll="handleScroll">
|
||||
<Message
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref="chatInputRef" class="chat-input">
|
||||
<div class="input-area">
|
||||
<textarea
|
||||
ref="messageInputRef"
|
||||
:value="newMessage"
|
||||
@input="handleInput"
|
||||
placeholder="Введите сообщение..."
|
||||
:disabled="isLoading"
|
||||
rows="1"
|
||||
autofocus
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
/>
|
||||
<div class="chat-icons">
|
||||
<button
|
||||
class="chat-icon-btn"
|
||||
title="Удерживайте для записи аудио"
|
||||
@mousedown="startAudioRecording"
|
||||
@mouseup="stopAudioRecording"
|
||||
@mouseleave="stopAudioRecording"
|
||||
:class="{ 'recording': isAudioRecording }"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" fill="currentColor"/>
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="chat-icon-btn"
|
||||
title="Удерживайте для записи видео"
|
||||
@mousedown="startVideoRecording"
|
||||
@mouseup="stopVideoRecording"
|
||||
@mouseleave="stopVideoRecording"
|
||||
:class="{ 'recording': isVideoRecording }"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="chat-icon-btn" title="Прикрепить файл" @click="handleFileUpload">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="chat-icon-btn" title="Очистить поле ввода" @click="clearInput">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="chat-icon-btn send-button"
|
||||
title="Отправить сообщение"
|
||||
:disabled="isSendDisabled"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24">
|
||||
<path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-preview" v-if="localAttachments.length > 0">
|
||||
<div v-for="(file, index) in localAttachments" :key="index" class="preview-item">
|
||||
<img v-if="file.type.startsWith('image/')" :src="file.previewUrl" class="image-preview"/>
|
||||
<div v-else-if="file.type.startsWith('audio/')" class="audio-preview">
|
||||
<span>🎵 {{ file.name }} ({{ formatFileSize(file.size) }})</span>
|
||||
</div>
|
||||
<div v-else-if="file.type.startsWith('video/')" class="video-preview">
|
||||
<span>🎬 {{ file.name }} ({{ formatFileSize(file.size) }})</span>
|
||||
</div>
|
||||
<div v-else class="file-preview">
|
||||
<span>📄 {{ file.name }} ({{ formatFileSize(file.size) }})</span>
|
||||
</div>
|
||||
<button @click="removeAttachment(index)" class="remove-attachment-btn">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: Boolean,
|
||||
newMessage: String, // Для v-model
|
||||
attachments: Array, // Для v-model
|
||||
// Добавляем пропс для проверки, есть ли еще сообщения для загрузки
|
||||
hasMoreMessages: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:newMessage',
|
||||
'update:attachments',
|
||||
'send-message',
|
||||
'load-more', // Событие для загрузки старых сообщений
|
||||
]);
|
||||
|
||||
const messagesContainer = ref(null);
|
||||
const messageInputRef = ref(null);
|
||||
const chatInputRef = ref(null); // Ref для chat-input
|
||||
const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее)
|
||||
|
||||
// Локальное состояние для предпросмотра, синхронизированное с props.attachments
|
||||
const localAttachments = ref([...props.attachments]);
|
||||
watch(() => props.attachments, (newVal) => {
|
||||
// Обновляем локальное состояние, только если внешнее изменилось
|
||||
if (JSON.stringify(newVal) !== JSON.stringify(localAttachments.value)) {
|
||||
// Очищаем старые URL превью перед обновлением
|
||||
localAttachments.value.forEach(att => {
|
||||
if (att.previewUrl) {
|
||||
URL.revokeObjectURL(att.previewUrl);
|
||||
}
|
||||
});
|
||||
localAttachments.value = [...newVal];
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Логика записи медиа (остается здесь, так как связана с UI компонента) ---
|
||||
const isAudioRecording = ref(false);
|
||||
const isVideoRecording = ref(false);
|
||||
const audioRecorder = ref(null);
|
||||
const videoRecorder = ref(null);
|
||||
const audioStream = ref(null);
|
||||
const videoStream = ref(null);
|
||||
const recordedAudioChunks = ref([]);
|
||||
const recordedVideoChunks = ref([]);
|
||||
|
||||
const startAudioRecording = async () => {
|
||||
console.log('[ChatInterface] startAudioRecording called');
|
||||
try {
|
||||
if (isAudioRecording.value) return;
|
||||
audioStream.value = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('[ChatInterface] Got audio stream:', audioStream.value);
|
||||
recordedAudioChunks.value = [];
|
||||
audioRecorder.value = new MediaRecorder(audioStream.value);
|
||||
audioRecorder.value.ondataavailable = (event) => {
|
||||
console.log('[ChatInterface] audioRecorder.ondataavailable fired');
|
||||
if (event.data.size > 0) recordedAudioChunks.value.push(event.data);
|
||||
};
|
||||
audioRecorder.value.onstop = () => {
|
||||
console.log('[ChatInterface] audioRecorder.onstop fired');
|
||||
setTimeout(() => {
|
||||
if (recordedAudioChunks.value.length === 0) {
|
||||
console.warn('[ChatInterface] No audio chunks recorded.');
|
||||
return;
|
||||
}
|
||||
console.log(`[ChatInterface] Creating audio Blob from ${recordedAudioChunks.value.length} chunks.`);
|
||||
const audioBlob = new Blob(recordedAudioChunks.value, { type: 'audio/webm' });
|
||||
const audioFile = new File([audioBlob], `audio-${Date.now()}.webm`, { type: 'audio/webm' });
|
||||
addAttachment(audioFile);
|
||||
recordedAudioChunks.value = [];
|
||||
}, 100);
|
||||
};
|
||||
audioRecorder.value.start();
|
||||
isAudioRecording.value = true;
|
||||
console.log('[ChatInterface] Audio recording started, recorder state:', audioRecorder.value.state);
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Error starting audio recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopAudioRecording = async () => {
|
||||
console.log('[ChatInterface] stopAudioRecording called');
|
||||
if (!isAudioRecording.value || !audioRecorder.value || audioRecorder.value.state === 'inactive') {
|
||||
console.log('[ChatInterface] stopAudioRecording: Not recording or recorder inactive, state:', audioRecorder.value?.state);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
audioRecorder.value.stop();
|
||||
console.log('[ChatInterface] audioRecorder.stop() called');
|
||||
isAudioRecording.value = false;
|
||||
if (audioStream.value) {
|
||||
audioStream.value.getTracks().forEach(track => track.stop());
|
||||
console.log('[ChatInterface] Audio stream tracks stopped.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Error stopping audio recording:', error);
|
||||
isAudioRecording.value = false;
|
||||
if (audioStream.value) audioStream.value.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
const startVideoRecording = async () => {
|
||||
console.log('[ChatInterface] startVideoRecording called');
|
||||
try {
|
||||
if (isVideoRecording.value) return;
|
||||
videoStream.value = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
console.log('[ChatInterface] Got video stream:', videoStream.value);
|
||||
recordedVideoChunks.value = [];
|
||||
let options = { mimeType: 'video/webm;codecs=vp9,opus' };
|
||||
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
||||
console.warn(`MIME type ${options.mimeType} not supported, trying video/webm...`);
|
||||
options = { mimeType: 'video/webm' };
|
||||
if (!MediaRecorder.isTypeSupported(options.mimeType)) {
|
||||
console.warn(`MIME type ${options.mimeType} not supported, using default.`);
|
||||
options = {};
|
||||
}
|
||||
}
|
||||
console.log('[ChatInterface] Using MediaRecorder options:', options);
|
||||
videoRecorder.value = new MediaRecorder(videoStream.value, options);
|
||||
|
||||
videoRecorder.value.ondataavailable = (event) => {
|
||||
console.log('[ChatInterface] videoRecorder.ondataavailable fired');
|
||||
if (event.data.size > 0) recordedVideoChunks.value.push(event.data);
|
||||
};
|
||||
videoRecorder.value.onstop = () => {
|
||||
console.log('[ChatInterface] videoRecorder.onstop fired');
|
||||
setTimeout(() => {
|
||||
if (recordedVideoChunks.value.length === 0) {
|
||||
console.warn('[ChatInterface] No video chunks recorded.');
|
||||
return;
|
||||
}
|
||||
console.log(`[ChatInterface] Creating video Blob from ${recordedVideoChunks.value.length} chunks.`);
|
||||
const videoBlob = new Blob(recordedVideoChunks.value, { type: videoRecorder.value.mimeType || 'video/webm' });
|
||||
const videoFile = new File([videoBlob], `video-${Date.now()}.webm`, { type: videoRecorder.value.mimeType || 'video/webm' });
|
||||
addAttachment(videoFile);
|
||||
recordedVideoChunks.value = [];
|
||||
}, 100);
|
||||
};
|
||||
videoRecorder.value.start();
|
||||
isVideoRecording.value = true;
|
||||
console.log('[ChatInterface] Video recording started, recorder state:', videoRecorder.value.state);
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Error starting video recording:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopVideoRecording = async () => {
|
||||
console.log('[ChatInterface] stopVideoRecording called');
|
||||
if (!isVideoRecording.value || !videoRecorder.value || videoRecorder.value.state === 'inactive') {
|
||||
console.log('[ChatInterface] stopVideoRecording: Not recording or recorder inactive, state:', videoRecorder.value?.state);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
videoRecorder.value.stop();
|
||||
console.log('[ChatInterface] videoRecorder.stop() called');
|
||||
isVideoRecording.value = false;
|
||||
if (videoStream.value) {
|
||||
videoStream.value.getTracks().forEach(track => track.stop());
|
||||
console.log('[ChatInterface] Video stream tracks stopped.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Error stopping video recording:', error);
|
||||
isVideoRecording.value = false;
|
||||
if (videoStream.value) videoStream.value.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
|
||||
// --- Логика загрузки файлов ---
|
||||
const handleFileUpload = () => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = '.txt,.pdf,.jpg,.jpeg,.png,.gif,.mp3,.wav,.mp4,.avi,.docx,.xlsx,.pptx,.odt,.ods,.odp,.zip,.rar,.7z';
|
||||
fileInput.onchange = (event) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
Array.from(files).forEach(file => addAttachment(file));
|
||||
}
|
||||
};
|
||||
fileInput.click();
|
||||
};
|
||||
|
||||
// --- Логика управления предпросмотром ---
|
||||
const addAttachment = (file) => {
|
||||
const attachment = {
|
||||
file: file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
previewUrl: null
|
||||
};
|
||||
if (file.type.startsWith('image/')) {
|
||||
attachment.previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
const updatedAttachments = [...localAttachments.value, attachment];
|
||||
localAttachments.value = updatedAttachments; // Обновляем локальное состояние
|
||||
emit('update:attachments', updatedAttachments); // Обновляем состояние в родителе
|
||||
nextTick(updateChatInputHeight); // Обновляем высоту после добавления превью
|
||||
};
|
||||
|
||||
const removeAttachment = (index) => {
|
||||
const attachment = localAttachments.value[index];
|
||||
if (attachment.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
const updatedAttachments = localAttachments.value.filter((_, i) => i !== index);
|
||||
localAttachments.value = updatedAttachments; // Обновляем локальное состояние
|
||||
emit('update:attachments', updatedAttachments); // Обновляем состояние в родителе
|
||||
nextTick(updateChatInputHeight); // Обновляем высоту после удаления превью
|
||||
};
|
||||
|
||||
// --- Очистка ввода ---
|
||||
const clearInput = () => {
|
||||
emit('update:newMessage', ''); // Очищаем текстовое поле через emit
|
||||
// Очищаем локальные превью и родительское состояние
|
||||
localAttachments.value.forEach(att => {
|
||||
if (att.previewUrl) {
|
||||
URL.revokeObjectURL(att.previewUrl);
|
||||
}
|
||||
});
|
||||
localAttachments.value = [];
|
||||
emit('update:attachments', []);
|
||||
nextTick(adjustTextareaHeight); // Сбросить высоту textarea
|
||||
};
|
||||
|
||||
// --- Отправка сообщения ---
|
||||
const isSendDisabled = computed(() => {
|
||||
return props.isLoading || (!props.newMessage.trim() && localAttachments.value.length === 0);
|
||||
});
|
||||
|
||||
const sendMessage = () => {
|
||||
if (isSendDisabled.value) return;
|
||||
// Отправляем событие с текстом и текущими прикрепленными файлами
|
||||
emit('send-message', {
|
||||
message: props.newMessage,
|
||||
attachments: localAttachments.value.map(att => att.file) // Отправляем только сами файлы
|
||||
});
|
||||
// Очищаем поле ввода и превью после отправки
|
||||
clearInput();
|
||||
nextTick(adjustTextareaHeight); // Сбросить высоту textarea после отправки
|
||||
};
|
||||
|
||||
// --- Прокрутка и UI ---
|
||||
const scrollToBottom = () => {
|
||||
if (messagesContainer.value) {
|
||||
// Используем nextTick для ожидания обновления DOM
|
||||
nextTick(() => {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Вызываем scrollToBottom при изменении количества сообщений
|
||||
watch(() => props.messages.length, () => {
|
||||
scrollToBottom();
|
||||
}, { flush: 'post' }); // flush: 'post' гарантирует выполнение после обновления DOM
|
||||
|
||||
// Обработчик скролла для подгрузки сообщений
|
||||
const handleScroll = () => {
|
||||
const element = messagesContainer.value;
|
||||
if (element && element.scrollTop === 0 && props.hasMoreMessages) {
|
||||
emit('load-more');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
// Логика добавления класса 'focused' удалена, т.к. высота управляется ResizeObserver
|
||||
// Можно добавить другую логику при фокусе, если нужно
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Логика удаления класса 'focused' удалена
|
||||
// Можно добавить другую логику при потере фокуса, если нужно
|
||||
};
|
||||
|
||||
// Форматирование размера файла
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Байт';
|
||||
const k = 1024;
|
||||
const sizes = ['Байт', 'КБ', 'МБ', 'ГБ'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// --- Автоматическое изменение высоты textarea ---
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = messageInputRef.value;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'; // Сброс высоты для пересчета
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
// Ограничиваем максимальную высоту (соответствует max-height в CSS)
|
||||
const newHeight = Math.min(scrollHeight, 120);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
// Высота родительского блока (.chat-input) обновится через ResizeObserver
|
||||
// nextTick(updateChatInputHeight); // Убрано отсюда
|
||||
}
|
||||
};
|
||||
|
||||
// Вызываем при изменении текста
|
||||
const handleInput = (event) => {
|
||||
emit('update:newMessage', event.target.value);
|
||||
adjustTextareaHeight();
|
||||
// Явно вызовем обновление высоты родителя после изменения textarea
|
||||
// Это может быть надежнее, чем полагаться только на ResizeObserver в некоторых случаях
|
||||
nextTick(updateChatInputHeight);
|
||||
};
|
||||
|
||||
// --- Динамическое изменение высоты ---
|
||||
let resizeObserver;
|
||||
|
||||
const updateChatInputHeight = () => {
|
||||
if (chatInputRef.value) {
|
||||
chatInputHeight.value = chatInputRef.value.offsetHeight;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Начальная установка высоты textarea и блока ввода
|
||||
adjustTextareaHeight();
|
||||
updateChatInputHeight();
|
||||
|
||||
if (chatInputRef.value) {
|
||||
resizeObserver = new ResizeObserver(updateChatInputHeight);
|
||||
resizeObserver.observe(chatInputRef.value);
|
||||
}
|
||||
// Убедимся, что высота input установлена после монтирования
|
||||
nextTick(updateChatInputHeight);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver && chatInputRef.value) {
|
||||
resizeObserver.unobserve(chatInputRef.value);
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Стили здесь для инкапсуляции, можно вынести в home.css */
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: var(--spacing-lg) 0 35px 0;
|
||||
min-height: 500px; /* Или другая подходящая высота */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-grey-light);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: var(--chat-input-height, 80px); /* 80px - запасной вариант */
|
||||
transition: bottom var(--transition-normal);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-white);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-grey-light);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transition: all var(--transition-normal);
|
||||
z-index: 10;
|
||||
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Стили для textarea, связанные с авто-ресайзом (дублируют home.css, но можно оставить для явности) */
|
||||
.chat-input textarea {
|
||||
overflow-y: hidden;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.chat-icons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
color: var(--color-grey);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-icon-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-icon-btn.send-button {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.chat-icon-btn.send-button:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
color: white;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.chat-icon-btn.send-button:disabled {
|
||||
background-color: #ccc;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-icon-btn.recording {
|
||||
color: var(--color-danger);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.chat-icon-btn.recording::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--color-danger);
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--color-grey-light);
|
||||
max-height: 100px; /* Можно увеличить, если нужно больше места */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--color-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.audio-preview,
|
||||
.video-preview,
|
||||
.file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.remove-attachment-btn {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
332
frontend/src/components/Message.vue
Normal file
332
frontend/src/components/Message.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'message',
|
||||
message.sender_type === 'assistant' || message.role === 'assistant'
|
||||
? 'ai-message'
|
||||
: message.sender_type === 'system' || message.role === 'system'
|
||||
? 'system-message'
|
||||
: 'user-message',
|
||||
message.isLocal ? 'is-local' : '',
|
||||
message.hasError ? 'has-error' : '',
|
||||
]"
|
||||
>
|
||||
<!-- Текстовый контент, если есть -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-if="message.content" class="message-content" v-html="formattedContent" />
|
||||
|
||||
<!-- Блок для отображения прикрепленного файла (теперь с плеерами/изображением/ссылкой) -->
|
||||
<div v-if="attachment" class="message-attachments">
|
||||
<div class="attachment-item">
|
||||
<!-- Изображение -->
|
||||
<img v-if="isImage" :src="objectUrl" :alt="attachment.originalname" class="attachment-preview image-preview"/>
|
||||
|
||||
<!-- Аудио -->
|
||||
<audio v-else-if="isAudio" :src="objectUrl" controls class="attachment-preview audio-preview" />
|
||||
|
||||
<!-- Видео -->
|
||||
<video v-else-if="isVideo" :src="objectUrl" controls class="attachment-preview video-preview" />
|
||||
|
||||
<!-- Другие типы файлов (ссылка на скачивание) -->
|
||||
<div v-else class="attachment-info file-preview">
|
||||
<span class="attachment-icon">📄</span>
|
||||
<a :href="objectUrl" :download="attachment.originalname" class="attachment-name">
|
||||
{{ attachment.originalname }}
|
||||
</a>
|
||||
<span class="attachment-size">({{ formatFileSize(attachment.size) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-meta">
|
||||
<div class="message-time">
|
||||
{{ formattedTime }}
|
||||
</div>
|
||||
<div v-if="message.isLocal" class="message-status">
|
||||
<span class="sending-indicator">Отправка...</span>
|
||||
</div>
|
||||
<div v-if="message.hasError" class="message-status">
|
||||
<span class="error-indicator">Ошибка отправки</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed, ref, watch, onUnmounted } from 'vue';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// --- Работа с вложениями ---
|
||||
const attachment = computed(() => {
|
||||
// Ожидаем массив attachments, даже если там только один элемент
|
||||
return props.message.attachments && props.message.attachments.length > 0
|
||||
? props.message.attachments[0]
|
||||
: null;
|
||||
});
|
||||
|
||||
const objectUrl = ref(null);
|
||||
const isImage = ref(false);
|
||||
const isAudio = ref(false);
|
||||
const isVideo = ref(false);
|
||||
|
||||
// Функция для преобразования Base64 в Blob
|
||||
const base64ToBlob = (base64, mimetype) => {
|
||||
try {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimetype });
|
||||
} catch (e) {
|
||||
console.error("Error decoding base64 string:", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Наблюдаем за изменением вложения в сообщении
|
||||
watch(attachment, (newAttachment) => {
|
||||
// Очищаем предыдущий URL, если он был
|
||||
if (objectUrl.value) {
|
||||
URL.revokeObjectURL(objectUrl.value);
|
||||
objectUrl.value = null;
|
||||
}
|
||||
// Сбрасываем типы
|
||||
isImage.value = false;
|
||||
isAudio.value = false;
|
||||
isVideo.value = false;
|
||||
|
||||
if (newAttachment && newAttachment.data_base64 && newAttachment.mimetype) {
|
||||
const blob = base64ToBlob(newAttachment.data_base64, newAttachment.mimetype);
|
||||
if (blob) {
|
||||
objectUrl.value = URL.createObjectURL(blob);
|
||||
|
||||
// Определяем тип для условного рендеринга
|
||||
const mimetype = newAttachment.mimetype.toLowerCase();
|
||||
if (mimetype.startsWith('image/')) {
|
||||
isImage.value = true;
|
||||
} else if (mimetype.startsWith('audio/')) {
|
||||
isAudio.value = true;
|
||||
} else if (mimetype.startsWith('video/')) {
|
||||
isVideo.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true }); // Выполняем сразу при монтировании
|
||||
|
||||
// Очистка при размонтировании
|
||||
onUnmounted(() => {
|
||||
if (objectUrl.value) {
|
||||
URL.revokeObjectURL(objectUrl.value);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Форматирование контента и времени (остается как было) ---
|
||||
const formattedContent = computed(() => {
|
||||
if (!props.message.content) return '';
|
||||
const rawHtml = marked.parse(props.message.content);
|
||||
return DOMPurify.sanitize(rawHtml);
|
||||
});
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const timestamp = props.message.timestamp || props.message.created_at;
|
||||
if (!timestamp) return '';
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('Invalid timestamp in Message.vue:', timestamp);
|
||||
return '';
|
||||
}
|
||||
return date.toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error formatting time in Message.vue:', error, timestamp);
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// Форматирование размера файла
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 Bytes'; // Добавлена проверка на undefined/null
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Стили можно скопировать из home.css или оставить глобальными */
|
||||
.message {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 75%;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.user-message {
|
||||
background-color: var(--color-user-message);
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
margin-right: var(--spacing-sm);
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
background-color: var(--color-ai-message);
|
||||
align-self: flex-start;
|
||||
margin-right: auto;
|
||||
margin-left: var(--spacing-sm);
|
||||
word-break: break-word;
|
||||
max-width: 70%;
|
||||
border-bottom-left-radius: 2px;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
background-color: var(--color-system-message);
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-style: italic;
|
||||
color: var(--color-system-text);
|
||||
text-align: center;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-content :deep(p) {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.message-content :deep(ul),
|
||||
.message-content :deep(ol) {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
.message-content :deep(pre) {
|
||||
background-color: #eee;
|
||||
padding: 0.5em;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.message-content :deep(code) {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-xs); /* Добавлен отступ сверху */
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-grey);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-status {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-grey);
|
||||
}
|
||||
|
||||
.sending-indicator {
|
||||
color: var(--color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error-indicator {
|
||||
color: var(--color-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.is-local {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.has-error {
|
||||
border: 1px solid var(--color-danger);
|
||||
}
|
||||
|
||||
/* --- НОВЫЕ СТИЛИ --- */
|
||||
.message-attachments {
|
||||
margin-top: var(--spacing-sm);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
flex-direction: column; /* Отображаем элементы в столбец */
|
||||
align-items: flex-start; /* Выравниваем по левому краю */
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
max-width: 100%;
|
||||
max-height: 300px; /* Ограничение высоты для превью */
|
||||
margin-bottom: var(--spacing-xs);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
object-fit: cover; /* Сохраняем пропорции */
|
||||
}
|
||||
|
||||
.audio-preview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
/* Стили для видео по умолчанию */
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-weight: 500;
|
||||
margin-right: var(--spacing-xs);
|
||||
color: var(--color-primary); /* Делаем имя файла похожим на ссылку */
|
||||
text-decoration: none;
|
||||
}
|
||||
.attachment-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.attachment-size {
|
||||
color: var(--color-grey);
|
||||
font-size: var(--font-size-xs); /* Уменьшим размер */
|
||||
}
|
||||
/* --- КОНЕЦ НОВЫХ СТИЛЕЙ --- */
|
||||
</style>
|
||||
179
frontend/src/components/Sidebar.vue
Normal file
179
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<transition name="sidebar-slide">
|
||||
<div v-if="modelValue" class="wallet-sidebar">
|
||||
<div class="wallet-sidebar-content">
|
||||
<!-- Блок для неавторизованных пользователей -->
|
||||
<div v-if="!isAuthenticated">
|
||||
<div class="button-with-close">
|
||||
<button
|
||||
v-if="
|
||||
!telegramAuth.showVerification &&
|
||||
!emailAuth.showForm &&
|
||||
!emailAuth.showVerification
|
||||
"
|
||||
class="auth-btn connect-wallet-btn"
|
||||
@click="handleWalletAuth"
|
||||
>
|
||||
Подключить кошелек
|
||||
</button>
|
||||
<button class="close-sidebar-btn" @click="closeSidebar">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Блок для авторизованных пользователей -->
|
||||
<div v-if="isAuthenticated">
|
||||
<div class="button-with-close">
|
||||
<button class="auth-btn disconnect-wallet-btn" @click="disconnectWallet">
|
||||
Отключить
|
||||
</button>
|
||||
<button class="close-sidebar-btn" @click="closeSidebar">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Блок информации о пользователе (отображается, если не активна ни одна форма) -->
|
||||
<div v-if="!emailAuth.showForm && !emailAuth.showVerification && !telegramAuth.showVerification" class="user-info">
|
||||
<h3>Идентификаторы:</h3>
|
||||
<div class="user-info-item">
|
||||
<span class="user-info-label">Кошелек:</span>
|
||||
<span v-if="hasIdentityType('wallet')" class="user-info-value">
|
||||
{{ truncateAddress(getIdentityValue('wallet')) }}
|
||||
</span>
|
||||
<button v-else class="connect-btn" @click="handleWalletAuth">
|
||||
Подключить кошелек
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Блок баланса токенов -->
|
||||
<div v-if="isAuthenticated && hasIdentityType('wallet')" class="token-balances">
|
||||
<h3>Баланс токенов:</h3>
|
||||
<div class="token-balance">
|
||||
<span class="token-name">ETH:</span>
|
||||
<span class="token-amount">{{ Number(tokenBalances.eth).toLocaleString() }}</span>
|
||||
<span class="token-symbol">{{ TOKEN_CONTRACTS.eth.symbol }}</span>
|
||||
</div>
|
||||
<div class="token-balance">
|
||||
<span class="token-name">BSC:</span>
|
||||
<span class="token-amount">{{ Number(tokenBalances.bsc).toLocaleString() }}</span>
|
||||
<span class="token-symbol">{{ TOKEN_CONTRACTS.bsc.symbol }}</span>
|
||||
</div>
|
||||
<div class="token-balance">
|
||||
<span class="token-name">ARB:</span>
|
||||
<span class="token-amount">{{
|
||||
Number(tokenBalances.arbitrum).toLocaleString()
|
||||
}}</span>
|
||||
<span class="token-symbol">{{ TOKEN_CONTRACTS.arbitrum.symbol }}</span>
|
||||
</div>
|
||||
<div class="token-balance">
|
||||
<span class="token-name">POL:</span>
|
||||
<span class="token-amount">{{ Number(tokenBalances.polygon).toLocaleString() }}</span>
|
||||
<span class="token-symbol">{{ TOKEN_CONTRACTS.polygon.symbol }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { TOKEN_CONTRACTS } from '../services/tokens';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
isAuthenticated: Boolean,
|
||||
telegramAuth: Object,
|
||||
emailAuth: Object,
|
||||
tokenBalances: Object,
|
||||
identities: Array
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'wallet-auth', 'disconnect-wallet']);
|
||||
|
||||
// Обработчики событий
|
||||
const handleWalletAuth = () => {
|
||||
emit('wallet-auth');
|
||||
};
|
||||
|
||||
const disconnectWallet = () => {
|
||||
emit('disconnect-wallet');
|
||||
};
|
||||
|
||||
// Функция закрытия сайдбара
|
||||
const closeSidebar = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
|
||||
// Вспомогательные функции
|
||||
const truncateAddress = (address) => {
|
||||
if (!address) return '';
|
||||
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
|
||||
};
|
||||
|
||||
const hasIdentityType = (type) => {
|
||||
if (!props.identities) return false;
|
||||
return props.identities.some((identity) => identity.provider === type);
|
||||
};
|
||||
|
||||
const getIdentityValue = (type) => {
|
||||
if (!props.identities) return null;
|
||||
const identity = props.identities.find((identity) => identity.provider === type);
|
||||
return identity ? identity.provider_id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.button-with-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.connect-wallet-btn,
|
||||
.disconnect-wallet-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close-sidebar-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-dark);
|
||||
border: 1px solid var(--color-grey);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.close-sidebar-btn:hover {
|
||||
background-color: var(--color-grey-light);
|
||||
border-color: var(--color-dark);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.close-sidebar-btn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
min-width: 42px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 360px) {
|
||||
.close-sidebar-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div v-if="isAuthenticated">
|
||||
<div class="conversation-list">
|
||||
<div class="list-header">
|
||||
<h3>Диалоги</h3>
|
||||
<button class="new-conversation-btn" @click="createNewConversation">
|
||||
<span>+</span> Новый диалог
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Загрузка диалогов...</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-list">
|
||||
<p>У вас пока нет диалогов.</p>
|
||||
<p>Создайте новый диалог, чтобы начать общение с ИИ-ассистентом.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations">
|
||||
<div
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.conversation_id"
|
||||
:class="[
|
||||
'conversation-item',
|
||||
{ active: selectedConversationId === conversation.conversation_id },
|
||||
]"
|
||||
@click="selectConversation(conversation.conversation_id)"
|
||||
>
|
||||
<div class="conversation-title">{{ conversation.title }}</div>
|
||||
<div class="conversation-meta">
|
||||
<span class="message-count">{{ conversation.message_count }} сообщений</span>
|
||||
<span class="time">{{ formatTime(conversation.last_activity) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="connect-wallet-prompt">
|
||||
<p>Подключите кошелек для просмотра бесед</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, defineEmits, watch, inject } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const emit = defineEmits(['select-conversation']);
|
||||
const auth = inject('auth');
|
||||
const isAuthenticated = computed(() => auth.isAuthenticated.value);
|
||||
|
||||
const conversations = ref([]);
|
||||
const loading = ref(true);
|
||||
const selectedConversationId = ref(null);
|
||||
|
||||
// Следим за изменением статуса аутентификации
|
||||
watch(
|
||||
() => isAuthenticated.value,
|
||||
(authenticated) => {
|
||||
if (!authenticated) {
|
||||
conversations.value = []; // Очищаем список бесед при отключении
|
||||
selectedConversationId.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Загрузка списка диалогов
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await axios.get('/api/messages/conversations');
|
||||
conversations.value = response.data;
|
||||
|
||||
// Если есть диалоги и не выбран ни один, выбираем первый
|
||||
if (conversations.value.length > 0 && !selectedConversationId.value) {
|
||||
selectConversation(conversations.value[0].conversation_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversations:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Выбор диалога
|
||||
const selectConversation = (conversationId) => {
|
||||
selectedConversationId.value = conversationId;
|
||||
emit('select-conversation', conversationId);
|
||||
};
|
||||
|
||||
// Создание нового диалога
|
||||
const createNewConversation = async () => {
|
||||
try {
|
||||
const response = await axios.post('/api/messages/conversations', {
|
||||
title: 'Новый диалог',
|
||||
});
|
||||
|
||||
// Добавляем новый диалог в список
|
||||
const newConversation = {
|
||||
conversation_id: response.data.id,
|
||||
title: response.data.title,
|
||||
username: authStore.username,
|
||||
address: authStore.address,
|
||||
message_count: 0,
|
||||
last_activity: response.data.created_at,
|
||||
created_at: response.data.created_at,
|
||||
};
|
||||
|
||||
conversations.value.unshift(newConversation);
|
||||
|
||||
// Выбираем новый диалог
|
||||
selectConversation(newConversation.conversation_id);
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
// Сегодня - показываем только время
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
// Вчера
|
||||
return 'Вчера';
|
||||
} else if (diffDays < 7) {
|
||||
// В течение недели - показываем день недели
|
||||
const days = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
return days[date.getDay()];
|
||||
} else {
|
||||
// Более недели назад - показываем дату
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка диалогов при монтировании компонента
|
||||
onMounted(() => {
|
||||
fetchConversations();
|
||||
});
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
fetchConversations,
|
||||
});
|
||||
</script>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<div class="message-input">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="message"
|
||||
placeholder="Введите сообщение..."
|
||||
:disabled="sending"
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
/>
|
||||
|
||||
<button class="send-button" :disabled="!message.trim() || sending" @click="sendMessage">
|
||||
<span v-if="sending">Отправка...</span>
|
||||
<span v-else>Отправить</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['message-sent']);
|
||||
const message = ref('');
|
||||
const sending = ref(false);
|
||||
const textareaRef = ref(null);
|
||||
|
||||
// Обработка нажатия Enter
|
||||
const handleEnter = (event) => {
|
||||
// Если нажат Shift+Enter, добавляем перенос строки
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе отправляем сообщение
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
// Отправка сообщения
|
||||
const sendMessage = async () => {
|
||||
const messageText = message.value.trim();
|
||||
if (!messageText) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: messageText,
|
||||
role: auth.isAuthenticated ? 'user' : 'guest',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
messages.value.push(userMessage);
|
||||
|
||||
try {
|
||||
// Логируем параметры запроса
|
||||
console.log('Sending message to Ollama:', {
|
||||
message: messageText,
|
||||
language: userLanguage.value,
|
||||
});
|
||||
|
||||
const response = await axios.post('/api/chat/message', {
|
||||
message: messageText,
|
||||
language: userLanguage.value,
|
||||
});
|
||||
|
||||
// Логируем ответ от Ollama
|
||||
console.log('Response from Ollama:', response.data);
|
||||
|
||||
// Обработка ответа
|
||||
messages.value.push({
|
||||
id: Date.now() + 1,
|
||||
content: response.data.message,
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Очищаем поле ввода
|
||||
message.value = '';
|
||||
|
||||
// Фокусируемся на поле ввода
|
||||
nextTick(() => {
|
||||
textareaRef.value.focus();
|
||||
});
|
||||
|
||||
// Уведомляем родительский компонент о новых сообщениях
|
||||
emit('message-sent', [response.data.userMessage, response.data.aiMessage]);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке сообщения:', error);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Сброс поля ввода
|
||||
const resetInput = () => {
|
||||
message.value = '';
|
||||
};
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
resetInput,
|
||||
focus: () => textareaRef.value?.focus(),
|
||||
});
|
||||
|
||||
const sendGuestMessage = async (messageText) => {
|
||||
if (!messageText.trim()) return;
|
||||
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: messageText,
|
||||
role: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
isGuest: true,
|
||||
};
|
||||
|
||||
// Добавляем сообщение пользователя в локальную историю
|
||||
messages.value.push(userMessage);
|
||||
|
||||
// Сохраняем сообщение в массиве гостевых сообщений
|
||||
guestMessages.value.push(userMessage);
|
||||
|
||||
// Сохраняем гостевые сообщения в localStorage
|
||||
localStorage.setItem('guestMessages', JSON.stringify(guestMessages.value));
|
||||
|
||||
// Очищаем поле ввода
|
||||
newMessage.value = '';
|
||||
|
||||
// Прокрутка вниз
|
||||
await nextTick();
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
|
||||
// Устанавливаем состояние загрузки
|
||||
isLoading.value = true;
|
||||
|
||||
// Вместо отправки запроса к Ollama, отправляем сообщение с кнопками для аутентификации
|
||||
const authMessage = {
|
||||
id: Date.now() + 1,
|
||||
content: 'Чтобы продолжить, пожалуйста, аутентифицируйтесь.',
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString(),
|
||||
isGuest: true,
|
||||
showAuthOptions: true, // Указываем, что нужно показать кнопки аутентификации
|
||||
};
|
||||
|
||||
messages.value.push(authMessage);
|
||||
guestMessages.value.push(authMessage);
|
||||
localStorage.setItem('guestMessages', JSON.stringify(guestMessages.value));
|
||||
|
||||
// Прокрутка вниз
|
||||
await nextTick();
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
};
|
||||
</script>
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div v-if="isAuthenticated">
|
||||
<div ref="threadContainer" class="message-thread">
|
||||
<div v-if="loading" class="loading">Загрузка сообщений...</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="empty-thread">
|
||||
<p>Нет сообщений. Начните диалог, отправив сообщение.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="messages">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:class="['message', message.sender_type]"
|
||||
>
|
||||
<div class="message-content">{{ message.content }}</div>
|
||||
<div class="message-meta">
|
||||
<span class="time">{{ formatTime(message.created_at) }}</span>
|
||||
<span v-if="message.channel" class="channel">
|
||||
{{ channelName(message.channel) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="connect-wallet-prompt">
|
||||
<p>Пожалуйста, подключите кошелек для просмотра сообщений</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick, defineExpose } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages = ref([]);
|
||||
const loading = ref(true);
|
||||
const threadContainer = ref(null);
|
||||
const isAuthenticated = ref(false);
|
||||
|
||||
// Загрузка сообщений диалога
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await axios.get(
|
||||
`/api/messages/conversations/${props.conversationId}/messages`
|
||||
);
|
||||
messages.value = response.data;
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (error) {
|
||||
console.error('Error fetching messages:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Добавление новых сообщений
|
||||
const addMessages = (newMessages) => {
|
||||
if (Array.isArray(newMessages)) {
|
||||
messages.value = [...messages.value, ...newMessages];
|
||||
} else {
|
||||
messages.value.push(newMessages);
|
||||
}
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
nextTick(() => {
|
||||
scrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
// Прокрутка к последнему сообщению
|
||||
const scrollToBottom = () => {
|
||||
if (threadContainer.value) {
|
||||
threadContainer.value.scrollTop = threadContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Получение названия канала
|
||||
const channelName = (channel) => {
|
||||
const channels = {
|
||||
web: 'Веб',
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
return channels[channel] || channel;
|
||||
};
|
||||
|
||||
// Наблюдение за изменением ID диалога
|
||||
watch(
|
||||
() => props.conversationId,
|
||||
(newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
fetchMessages();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Следим за изменением статуса аутентификации
|
||||
watch(
|
||||
() => isAuthenticated.value,
|
||||
(authenticated) => {
|
||||
if (!authenticated) {
|
||||
messages.value = []; // Очищаем сообщения при отключении
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Загрузка сообщений при монтировании компонента
|
||||
onMounted(() => {
|
||||
if (props.conversationId) {
|
||||
fetchMessages();
|
||||
}
|
||||
});
|
||||
|
||||
// Экспорт методов для использования в родительском компоненте
|
||||
defineExpose({
|
||||
fetchMessages,
|
||||
addMessages,
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user