ваше сообщение коммита
This commit is contained in:
50
backend/db/migrations/017_add_file_storage_columns.sql
Normal file
50
backend/db/migrations/017_add_file_storage_columns.sql
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
-- Миграция для добавления колонок хранения файлов и удаления старой колонки attachments
|
||||||
|
|
||||||
|
-- UP Migration
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Добавляем колонки для хранения файла и его метаданных в таблицу messages
|
||||||
|
ALTER TABLE messages
|
||||||
|
ADD COLUMN attachment_filename TEXT NULL,
|
||||||
|
ADD COLUMN attachment_mimetype TEXT NULL,
|
||||||
|
ADD COLUMN attachment_size BIGINT NULL,
|
||||||
|
ADD COLUMN attachment_data BYTEA NULL;
|
||||||
|
|
||||||
|
-- Добавляем колонки для хранения файла и его метаданных в таблицу guest_messages
|
||||||
|
ALTER TABLE guest_messages
|
||||||
|
ADD COLUMN attachment_filename TEXT NULL,
|
||||||
|
ADD COLUMN attachment_mimetype TEXT NULL,
|
||||||
|
ADD COLUMN attachment_size BIGINT NULL,
|
||||||
|
ADD COLUMN attachment_data BYTEA NULL;
|
||||||
|
|
||||||
|
-- Удаляем старую колонку attachments из таблицы messages, если она существует
|
||||||
|
ALTER TABLE messages DROP COLUMN IF EXISTS attachments;
|
||||||
|
|
||||||
|
-- Удаляем старую колонку attachments из таблицы guest_messages, если она существует
|
||||||
|
ALTER TABLE guest_messages DROP COLUMN IF EXISTS attachments;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- DOWN Migration
|
||||||
|
-- Откат изменений: удаляем новые колонки и пытаемся вернуть старую колонку (тип TEXT или JSONB? Используем TEXT как предположение)
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE messages
|
||||||
|
DROP COLUMN IF EXISTS attachment_filename,
|
||||||
|
DROP COLUMN IF EXISTS attachment_mimetype,
|
||||||
|
DROP COLUMN IF EXISTS attachment_size,
|
||||||
|
DROP COLUMN IF EXISTS attachment_data;
|
||||||
|
-- Пытаемся вернуть старую колонку (данные будут потеряны при откате)
|
||||||
|
-- Возможно, потребуется указать правильный тип (TEXT или JSONB), который был раньше
|
||||||
|
ALTER TABLE messages ADD COLUMN attachments TEXT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE guest_messages
|
||||||
|
DROP COLUMN IF EXISTS attachment_filename,
|
||||||
|
DROP COLUMN IF EXISTS attachment_mimetype,
|
||||||
|
DROP COLUMN IF EXISTS attachment_size,
|
||||||
|
DROP COLUMN IF EXISTS attachment_data;
|
||||||
|
-- Пытаемся вернуть старую колонку (данные будут потеряны при откате)
|
||||||
|
-- Возможно, потребуется указать правильный тип (TEXT или JSONB), который был раньше
|
||||||
|
ALTER TABLE guest_messages ADD COLUMN attachments TEXT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"langchain": "^0.3.19",
|
"langchain": "^0.3.19",
|
||||||
"mailparser": "^3.7.2",
|
"mailparser": "^3.7.2",
|
||||||
|
"multer": "^1.4.5-lts.2",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"node-telegram-bot-api": "^0.66.0",
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
"nodemailer": "^6.10.0",
|
"nodemailer": "^6.10.0",
|
||||||
@@ -87,5 +88,6 @@
|
|||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"**/utf7/semver": "^7.7.1"
|
"**/utf7/semver": "^7.7.1"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
const aiAssistant = require('../services/ai-assistant');
|
const aiAssistant = require('../services/ai-assistant');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Настройка multer для обработки файлов в памяти
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage: storage });
|
||||||
|
|
||||||
// Функция для обработки гостевых сообщений после аутентификации
|
// Функция для обработки гостевых сообщений после аутентификации
|
||||||
async function processGuestMessages(userId, guestId) {
|
async function processGuestMessages(userId, guestId) {
|
||||||
try {
|
try {
|
||||||
console.log(`Processing guest messages for user ${userId} with guest ID ${guestId}`);
|
logger.info(`Processing guest messages for user ${userId} with guest ID ${guestId}`);
|
||||||
|
|
||||||
// Проверяем, обрабатывались ли уже эти сообщения
|
// Проверяем, обрабатывались ли уже эти сообщения
|
||||||
const mappingCheck = await db.query(
|
const mappingCheck = await db.query(
|
||||||
@@ -18,7 +24,7 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
|
|
||||||
// Если сообщения уже обработаны, пропускаем
|
// Если сообщения уже обработаны, пропускаем
|
||||||
if (mappingCheck.rows.length > 0 && mappingCheck.rows[0].processed) {
|
if (mappingCheck.rows.length > 0 && mappingCheck.rows[0].processed) {
|
||||||
console.log(`Guest messages for guest ID ${guestId} were already processed.`);
|
logger.info(`Guest messages for guest ID ${guestId} were already processed.`);
|
||||||
return { success: true, message: 'Guest messages already processed' };
|
return { success: true, message: 'Guest messages already processed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,35 +34,38 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
'INSERT INTO guest_user_mapping (user_id, guest_id) VALUES ($1, $2) ON CONFLICT (guest_id) DO UPDATE SET user_id = $1',
|
'INSERT INTO guest_user_mapping (user_id, guest_id) VALUES ($1, $2) ON CONFLICT (guest_id) DO UPDATE SET user_id = $1',
|
||||||
[userId, guestId]
|
[userId, guestId]
|
||||||
);
|
);
|
||||||
console.log(`Created mapping for guest ID ${guestId} to user ${userId}`);
|
logger.info(`Created mapping for guest ID ${guestId} to user ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем все гостевые сообщения
|
// Получаем все гостевые сообщения со всеми новыми полями
|
||||||
const guestMessagesResult = await db.query(
|
const guestMessagesResult = await db.query(
|
||||||
'SELECT * FROM guest_messages WHERE guest_id = $1 ORDER BY created_at ASC',
|
`SELECT
|
||||||
|
id, guest_id, content, language, is_ai, created_at,
|
||||||
|
attachment_filename, attachment_mimetype, attachment_size, attachment_data
|
||||||
|
FROM guest_messages WHERE guest_id = $1 ORDER BY created_at ASC`,
|
||||||
[guestId]
|
[guestId]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (guestMessagesResult.rows.length === 0) {
|
if (guestMessagesResult.rows.length === 0) {
|
||||||
console.log('No guest messages found');
|
logger.info(`No guest messages found for guest ID ${guestId}`);
|
||||||
|
const checkResult = await db.query('SELECT 1 FROM guest_user_mapping WHERE guest_id = $1', [guestId]);
|
||||||
// Помечаем как обработанные, даже если сообщений нет
|
if (checkResult.rows.length > 0) {
|
||||||
await db.query('UPDATE guest_user_mapping SET processed = true WHERE guest_id = $1', [
|
await db.query('UPDATE guest_user_mapping SET processed = true WHERE guest_id = $1', [guestId]);
|
||||||
guestId,
|
logger.info(`Marked guest mapping as processed (no messages found) for guest ID ${guestId}`);
|
||||||
]);
|
} else {
|
||||||
|
logger.warn(`Attempted to mark non-existent guest mapping as processed for guest ID ${guestId}`);
|
||||||
|
}
|
||||||
return { success: true, message: 'No guest messages found' };
|
return { success: true, message: 'No guest messages found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const guestMessages = guestMessagesResult.rows;
|
const guestMessages = guestMessagesResult.rows;
|
||||||
console.log(`Found ${guestMessages.length} guest messages`);
|
logger.info(`Found ${guestMessages.length} guest messages for guest ID ${guestId}`);
|
||||||
|
|
||||||
// Создаем новый диалог для этих сообщений
|
// Создаем новый диалог для этих сообщений
|
||||||
const firstMessage = guestMessages[0];
|
const firstMessage = guestMessages[0];
|
||||||
const title =
|
const title = firstMessage.content
|
||||||
firstMessage.content.length > 30
|
? (firstMessage.content.length > 30 ? `${firstMessage.content.substring(0, 30)}...` : firstMessage.content)
|
||||||
? `${firstMessage.content.substring(0, 30)}...`
|
: (firstMessage.attachment_filename ? `Файл: ${firstMessage.attachment_filename}` : 'Новый диалог');
|
||||||
: firstMessage.content;
|
|
||||||
|
|
||||||
const newConversationResult = await db.query(
|
const newConversationResult = await db.query(
|
||||||
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
||||||
@@ -64,74 +73,79 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const conversation = newConversationResult.rows[0];
|
const conversation = newConversationResult.rows[0];
|
||||||
console.log('Created new conversation for guest messages:', conversation);
|
logger.info(`Created new conversation ${conversation.id} for guest messages`);
|
||||||
|
|
||||||
// Отслеживаем успешные сохранения сообщений
|
// Отслеживаем успешные сохранения сообщений
|
||||||
const savedMessageIds = [];
|
const savedMessageIds = [];
|
||||||
|
|
||||||
// Обрабатываем каждое гостевое сообщение
|
// Обрабатываем каждое гостевое сообщение
|
||||||
for (const guestMessage of guestMessages) {
|
for (const guestMessage of guestMessages) {
|
||||||
console.log(`Processing guest message ID ${guestMessage.id}: ${guestMessage.content}`);
|
logger.info(`Processing guest message ID ${guestMessage.id}: ${guestMessage.content || guestMessage.attachment_filename || '(empty)'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Сохраняем сообщение пользователя
|
// Сохраняем сообщение пользователя в таблицу messages, включая данные файла
|
||||||
const userMessageResult = await db.query(
|
const userMessageResult = await db.query(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(conversation_id, content, sender_type, role, channel, created_at, user_id)
|
(conversation_id, content, sender_type, role, channel, created_at, user_id,
|
||||||
|
attachment_filename, attachment_mimetype, attachment_size, attachment_data)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6, $7)
|
($1, $2, 'user', 'user', 'web', $3, $4,
|
||||||
|
$5, $6, $7, $8)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
conversation.id,
|
conversation.id,
|
||||||
guestMessage.content,
|
guestMessage.content, // Текст (может быть NULL)
|
||||||
'user',
|
|
||||||
'user',
|
|
||||||
'web',
|
|
||||||
guestMessage.created_at,
|
guestMessage.created_at,
|
||||||
userId, // Добавляем userId в сообщение для прямой связи
|
userId,
|
||||||
|
guestMessage.attachment_filename, // Метаданные и данные файла
|
||||||
|
guestMessage.attachment_mimetype,
|
||||||
|
guestMessage.attachment_size,
|
||||||
|
guestMessage.attachment_data // BYTEA
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Saved user message with ID ${userMessageResult.rows[0].id}`);
|
const savedUserMessage = userMessageResult.rows[0];
|
||||||
|
logger.info(`Saved user message with ID ${savedUserMessage.id}`);
|
||||||
savedMessageIds.push(guestMessage.id);
|
savedMessageIds.push(guestMessage.id);
|
||||||
|
|
||||||
// Получаем ответ от ИИ только для сообщений пользователя (не AI)
|
// Получаем ответ от ИИ только для текстовых сообщений
|
||||||
if (!guestMessage.is_ai) {
|
if (!guestMessage.is_ai && guestMessage.content) {
|
||||||
console.log('Getting AI response for:', guestMessage.content);
|
logger.info('Getting AI response for:', guestMessage.content);
|
||||||
const language = guestMessage.language || 'auto';
|
const language = guestMessage.language || 'auto';
|
||||||
const aiResponse = await aiAssistant.getResponse(guestMessage.content, language);
|
// Предполагаем, что aiAssistant.getResponse принимает только текст
|
||||||
console.log('AI response received:', aiResponse);
|
const aiResponseContent = await aiAssistant.getResponse(guestMessage.content, language);
|
||||||
|
logger.info('AI response received' + (aiResponseContent ? '' : ' (empty)'), 'for conversation', conversation.id);
|
||||||
|
|
||||||
// Сохраняем ответ от ИИ
|
if (aiResponseContent) {
|
||||||
|
// Сохраняем ответ от ИИ (у него нет вложений)
|
||||||
const aiMessageResult = await db.query(
|
const aiMessageResult = await db.query(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(conversation_id, content, sender_type, role, channel, created_at, user_id)
|
(conversation_id, content, sender_type, role, channel, created_at, user_id)
|
||||||
VALUES
|
VALUES
|
||||||
($1, $2, $3, $4, $5, $6, $7)
|
($1, $2, 'assistant', 'assistant', 'web', $3, $4)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
conversation.id,
|
conversation.id,
|
||||||
aiResponse,
|
aiResponseContent,
|
||||||
'assistant',
|
|
||||||
'assistant',
|
|
||||||
'web',
|
|
||||||
new Date(),
|
new Date(),
|
||||||
userId, // Добавляем userId в сообщение для прямой связи
|
userId
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
logger.info(`Saved AI response with ID ${aiMessageResult.rows[0].id}`);
|
||||||
console.log(`Saved AI response with ID ${aiMessageResult.rows[0].id}`);
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(`Skipping AI response for guest message ID ${guestMessage.id} (is_ai: ${guestMessage.is_ai}, hasContent: ${!!guestMessage.content})`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing guest message ${guestMessage.id}:`, error);
|
logger.error(`Error processing guest message ${guestMessage.id}: ${error.message}`, { stack: error.stack });
|
||||||
// Продолжаем с другими сообщениями в случае ошибки
|
// Продолжаем с другими сообщениями в случае ошибки
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Удаляем только успешно обработанные гостевые сообщения
|
// Удаляем только успешно обработанные гостевые сообщения
|
||||||
if (savedMessageIds.length > 0) {
|
if (savedMessageIds.length > 0) {
|
||||||
await db.query('DELETE FROM guest_messages WHERE id = ANY($1)', [savedMessageIds]);
|
await db.query('DELETE FROM guest_messages WHERE id = ANY($1::int[])', [savedMessageIds]);
|
||||||
console.log(
|
logger.info(
|
||||||
`Deleted ${savedMessageIds.length} processed guest messages for guest ID ${guestId}`
|
`Deleted ${savedMessageIds.length} processed guest messages for guest ID ${guestId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -139,8 +153,12 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
await db.query('UPDATE guest_user_mapping SET processed = true WHERE guest_id = $1', [
|
await db.query('UPDATE guest_user_mapping SET processed = true WHERE guest_id = $1', [
|
||||||
guestId,
|
guestId,
|
||||||
]);
|
]);
|
||||||
|
logger.info(`Marked guest mapping as processed for guest ID ${guestId}`);
|
||||||
} else {
|
} else {
|
||||||
console.log('No guest messages were successfully processed, skipping deletion');
|
logger.warn(`No guest messages were successfully processed, skipping deletion for guest ID ${guestId}`);
|
||||||
|
// Если не было успешных, все равно пометим как обработанные, чтобы не пытаться снова
|
||||||
|
await db.query('UPDATE guest_user_mapping SET processed = true WHERE guest_id = $1', [guestId]);
|
||||||
|
logger.info(`Marked guest mapping as processed (no successful messages) for guest ID ${guestId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -149,130 +167,244 @@ async function processGuestMessages(userId, guestId) {
|
|||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing guest messages:', error);
|
logger.error(`Error in processGuestMessages for guest ID ${guestId}: ${error.message}`, { stack: error.stack });
|
||||||
throw error;
|
// Не пробрасываем ошибку дальше, чтобы не прерывать основной поток, но логируем ее
|
||||||
|
return { success: false, error: 'Internal error during guest message processing' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обработчик для гостевых сообщений
|
// Обработчик для гостевых сообщений
|
||||||
router.post('/guest-message', async (req, res) => {
|
router.post('/guest-message', upload.array('attachments'), async (req, res) => {
|
||||||
try {
|
// Логируем полученные данные
|
||||||
const { content, language, guestId: requestGuestId } = req.body;
|
logger.info('Received /guest-message request');
|
||||||
|
logger.debug('Request Body:', req.body);
|
||||||
|
logger.debug('Request Files:', req.files); // Файлы будут здесь
|
||||||
|
|
||||||
if (!content) {
|
try {
|
||||||
return res.status(400).json({ success: false, error: 'Content is required' });
|
// Извлекаем данные из req.body (текстовые поля)
|
||||||
|
const { message, language, guestId: requestGuestId } = req.body;
|
||||||
|
const files = req.files; // Файлы извлекаем из req.files
|
||||||
|
const file = files && files.length > 0 ? files[0] : null; // Берем только первый файл
|
||||||
|
|
||||||
|
// Валидация: должно быть либо сообщение, либо файл
|
||||||
|
if (!message && !file) {
|
||||||
|
logger.warn('Guest message attempt without content or file.', { guestId: requestGuestId });
|
||||||
|
return res.status(400).json({ success: false, error: 'Требуется текст сообщения или файл.' });
|
||||||
|
}
|
||||||
|
// Запрещаем и текст, и файл одновременно (согласно новым требованиям)
|
||||||
|
if (message && file) {
|
||||||
|
logger.warn('Guest message attempt with both text and file.', { guestId: requestGuestId });
|
||||||
|
return res.status(400).json({ success: false, error: 'Нельзя отправить текст и файл одновременно.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем гостевой ID из запроса или из сессии, или генерируем новый
|
// Используем гостевой ID из запроса или из сессии, или генерируем новый
|
||||||
const guestId = requestGuestId || req.session.guestId || crypto.randomBytes(16).toString('hex');
|
const guestId = requestGuestId || req.session.guestId || crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
// Сохраняем ID гостя в сессии
|
// Сохраняем/обновляем ID гостя в сессии
|
||||||
|
if (req.session.guestId !== guestId) {
|
||||||
req.session.guestId = guestId;
|
req.session.guestId = guestId;
|
||||||
await req.session.save();
|
}
|
||||||
|
|
||||||
console.log('Saving guest message:', { guestId, content });
|
// Подготавливаем данные для вставки
|
||||||
|
const messageContent = message || ''; // Текст или ПУСТАЯ СТРОКА, если есть файл
|
||||||
|
const attachmentFilename = file ? file.originalname : null;
|
||||||
|
const attachmentMimetype = file ? file.mimetype : null;
|
||||||
|
const attachmentSize = file ? file.size : null;
|
||||||
|
const attachmentData = file ? file.buffer : null; // Сам буфер файла
|
||||||
|
|
||||||
// Сохраняем сообщение пользователя
|
logger.info('Saving guest message:', {
|
||||||
|
guestId,
|
||||||
|
message: messageContent,
|
||||||
|
file: attachmentFilename,
|
||||||
|
mimetype: attachmentMimetype,
|
||||||
|
size: attachmentSize
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сохраняем сообщение пользователя с текстом или файлом
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
'INSERT INTO guest_messages (guest_id, content, language, is_ai) VALUES ($1, $2, $3, false) RETURNING id',
|
`INSERT INTO guest_messages
|
||||||
[guestId, content, language || 'auto']
|
(guest_id, content, language, is_ai,
|
||||||
|
attachment_filename, attachment_mimetype, attachment_size, attachment_data)
|
||||||
|
VALUES ($1, $2, $3, false, $4, $5, $6, $7) RETURNING id`,
|
||||||
|
[
|
||||||
|
guestId,
|
||||||
|
messageContent, // Текст сообщения или NULL
|
||||||
|
language || 'auto',
|
||||||
|
attachmentFilename,
|
||||||
|
attachmentMimetype,
|
||||||
|
attachmentSize,
|
||||||
|
attachmentData // BYTEA данные файла или NULL
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Guest message saved:', result.rows[0]);
|
const savedMessageId = result.rows[0].id;
|
||||||
|
logger.info('Guest message saved with ID:', savedMessageId);
|
||||||
|
|
||||||
|
// Сохраняем сессию после успешной операции с БД
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info('Session saved after guest message');
|
||||||
|
} catch (sessionError) {
|
||||||
|
logger.error('Error saving session after guest message:', sessionError);
|
||||||
|
// Не прерываем ответ пользователю из-за ошибки сессии
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
messageId: result.rows[0].id,
|
messageId: savedMessageId, // Возвращаем ID сохраненного сообщения
|
||||||
|
guestId: guestId // Возвращаем использованный guestId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving guest message:', error);
|
logger.error('Error saving guest message:', error);
|
||||||
res.status(500).json({ success: false, error: 'Internal server error' });
|
res.status(500).json({ success: false, error: 'Ошибка сохранения гостевого сообщения' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Маршрут для обычных сообщений (для аутентифицированных пользователей)
|
// Обработчик для сообщений аутентифицированных пользователей
|
||||||
router.post('/message', requireAuth, async (req, res) => {
|
router.post('/message', requireAuth, upload.array('attachments'), async (req, res) => {
|
||||||
const { message, conversationId, language = 'auto' } = req.body;
|
logger.info('Received /message request');
|
||||||
|
logger.debug('Request Body:', req.body);
|
||||||
|
logger.debug('Request Files:', req.files);
|
||||||
|
|
||||||
if (!message) {
|
const userId = req.session.userId;
|
||||||
return res.status(400).json({ error: 'Message is required' });
|
const { message, language, conversationId: convIdFromRequest } = req.body;
|
||||||
|
const files = req.files;
|
||||||
|
const file = files && files.length > 0 ? files[0] : null;
|
||||||
|
|
||||||
|
// Валидация: должно быть либо сообщение, либо файл
|
||||||
|
if (!message && !file) {
|
||||||
|
logger.warn('Authenticated message attempt without content or file.', { userId });
|
||||||
|
return res.status(400).json({ success: false, error: 'Требуется текст сообщения или файл.' });
|
||||||
}
|
}
|
||||||
|
// Запрещаем и текст, и файл одновременно
|
||||||
|
if (message && file) {
|
||||||
|
logger.warn('Authenticated message attempt with both text and file.', { userId });
|
||||||
|
return res.status(400).json({ success: false, error: 'Нельзя отправить текст и файл одновременно.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationId = convIdFromRequest;
|
||||||
|
let conversation = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Processing message:', {
|
// Найти или создать диалог
|
||||||
message,
|
|
||||||
conversationId,
|
|
||||||
language,
|
|
||||||
userId: req.session.userId,
|
|
||||||
});
|
|
||||||
const userId = req.session.userId;
|
|
||||||
|
|
||||||
let conversation;
|
|
||||||
|
|
||||||
// Если указан ID диалога, проверяем его существование и принадлежность пользователю
|
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
const conversationResult = await db.query(
|
const convResult = await db.query(
|
||||||
'SELECT * FROM conversations WHERE id = $1 AND user_id = $2',
|
'SELECT * FROM conversations WHERE id = $1 AND user_id = $2',
|
||||||
[conversationId, userId]
|
[conversationId, userId]
|
||||||
);
|
);
|
||||||
|
if (convResult.rows.length === 0) {
|
||||||
if (conversationResult.rows.length === 0) {
|
logger.warn('Conversation not found or access denied', { conversationId, userId });
|
||||||
return res.status(404).json({ error: 'Conversation not found or access denied' });
|
return res.status(404).json({ success: false, error: 'Диалог не найден или доступ запрещен' });
|
||||||
}
|
}
|
||||||
|
conversation = convResult.rows[0];
|
||||||
conversation = conversationResult.rows[0];
|
|
||||||
console.log('Using existing conversation:', conversation);
|
|
||||||
} else {
|
} else {
|
||||||
// Создаем новый диалог
|
// Создаем новый диалог, если ID не предоставлен
|
||||||
const title = message.length > 30 ? `${message.substring(0, 30)}...` : message;
|
const title = message
|
||||||
|
? (message.length > 50 ? `${message.substring(0, 50)}...` : message)
|
||||||
|
: (file ? `Файл: ${file.originalname}` : 'Новый диалог');
|
||||||
|
|
||||||
const newConversationResult = await db.query(
|
const newConvResult = await db.query(
|
||||||
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
'INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *',
|
||||||
[userId, title]
|
[userId, title]
|
||||||
);
|
);
|
||||||
|
conversation = newConvResult.rows[0];
|
||||||
conversation = newConversationResult.rows[0];
|
conversationId = conversation.id;
|
||||||
console.log('Created new conversation:', conversation);
|
logger.info('Created new conversation', { conversationId, userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подготавливаем данные для вставки сообщения пользователя
|
||||||
|
const messageContent = message || ''; // Текст или ПУСТАЯ СТРОКА, если есть файл
|
||||||
|
const attachmentFilename = file ? file.originalname : null;
|
||||||
|
const attachmentMimetype = file ? file.mimetype : null;
|
||||||
|
const attachmentSize = file ? file.size : null;
|
||||||
|
const attachmentData = file ? file.buffer : null;
|
||||||
|
|
||||||
// Сохраняем сообщение пользователя
|
// Сохраняем сообщение пользователя
|
||||||
console.log('Saving user message');
|
|
||||||
const userMessageResult = await db.query(
|
const userMessageResult = await db.query(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(conversation_id, content, sender_type, role, tokens_used, channel, created_at)
|
(conversation_id, user_id, content, sender_type, role, channel,
|
||||||
VALUES
|
attachment_filename, attachment_mimetype, attachment_size, attachment_data)
|
||||||
($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, 'user', 'user', 'web', $4, $5, $6, $7)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[conversation.id, message, 'user', 'user', 0, 'web', new Date()]
|
[
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
messageContent,
|
||||||
|
attachmentFilename,
|
||||||
|
attachmentMimetype,
|
||||||
|
attachmentSize,
|
||||||
|
attachmentData
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
const userMessage = userMessageResult.rows[0];
|
||||||
|
logger.info('User message saved', { messageId: userMessage.id, conversationId });
|
||||||
|
|
||||||
// Получаем ответ от ИИ
|
// Получаем ответ от ИИ, только если это было текстовое сообщение
|
||||||
console.log('Getting AI response');
|
let aiMessage = null;
|
||||||
const aiResponse = await aiAssistant.getResponse(message, language);
|
if (messageContent) { // Только для текстовых сообщений
|
||||||
console.log('AI response received:', aiResponse);
|
try {
|
||||||
|
const detectedLanguage = language === 'auto' ? aiAssistant.detectLanguage(messageContent) : language;
|
||||||
|
logger.info('Getting AI response for:', messageContent);
|
||||||
|
const aiResponseContent = await aiAssistant.getResponse(messageContent, detectedLanguage);
|
||||||
|
logger.info('AI response received' + (aiResponseContent ? '' : ' (empty)'), { conversationId });
|
||||||
|
|
||||||
// Сохраняем ответ от ИИ
|
if (aiResponseContent) {
|
||||||
console.log('Saving AI response');
|
|
||||||
const aiMessageResult = await db.query(
|
const aiMessageResult = await db.query(
|
||||||
`INSERT INTO messages
|
`INSERT INTO messages
|
||||||
(conversation_id, content, sender_type, role, tokens_used, channel, created_at)
|
(conversation_id, user_id, content, sender_type, role, channel)
|
||||||
VALUES
|
VALUES ($1, $2, $3, 'assistant', 'assistant', 'web')
|
||||||
($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[conversation.id, aiResponse, 'assistant', 'assistant', 0, 'web', new Date()]
|
[conversationId, userId, aiResponseContent]
|
||||||
);
|
);
|
||||||
|
aiMessage = aiMessageResult.rows[0];
|
||||||
|
logger.info('AI response saved', { messageId: aiMessage.id, conversationId });
|
||||||
|
}
|
||||||
|
} catch (aiError) {
|
||||||
|
logger.error('Error getting or saving AI response:', aiError);
|
||||||
|
// Не прерываем основной ответ, но логируем ошибку
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = {
|
// Форматируем ответ для фронтенда
|
||||||
success: true,
|
const formatMessageForFrontend = (msg) => {
|
||||||
userMessage: userMessageResult.rows[0],
|
if (!msg) return null;
|
||||||
aiMessage: aiMessageResult.rows[0],
|
const formatted = {
|
||||||
conversation,
|
id: msg.id,
|
||||||
|
conversation_id: msg.conversation_id,
|
||||||
|
user_id: msg.user_id,
|
||||||
|
content: msg.content,
|
||||||
|
sender_type: msg.sender_type,
|
||||||
|
role: msg.role,
|
||||||
|
channel: msg.channel,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
attachments: null // Инициализируем как null
|
||||||
|
};
|
||||||
|
// Добавляем информацию о файле, если она есть
|
||||||
|
if (msg.attachment_filename) {
|
||||||
|
formatted.attachments = [{
|
||||||
|
originalname: msg.attachment_filename,
|
||||||
|
mimetype: msg.attachment_mimetype,
|
||||||
|
size: msg.attachment_size,
|
||||||
|
// НЕ передаем attachment_data обратно в ответе на POST
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(response);
|
res.json({
|
||||||
|
success: true,
|
||||||
|
conversationId: conversationId,
|
||||||
|
userMessage: formatMessageForFrontend(userMessage),
|
||||||
|
aiMessage: formatMessageForFrontend(aiMessage),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error processing message:', error);
|
logger.error('Error processing authenticated message:', error);
|
||||||
res.status(500).json({ error: 'Error processing message' });
|
res.status(500).json({ success: false, error: 'Ошибка обработки сообщения' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,79 +424,120 @@ router.get('/models', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Получение истории сообщений
|
// Получение истории сообщений
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', requireAuth, async (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
// Параметры пагинации
|
||||||
|
const limit = parseInt(req.query.limit, 10) || 30;
|
||||||
|
const offset = parseInt(req.query.offset, 10) || 0;
|
||||||
|
// Флаг для запроса только количества
|
||||||
|
const countOnly = req.query.count_only === 'true';
|
||||||
|
// Опциональный ID диалога
|
||||||
|
const conversationId = req.query.conversation_id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Session in history route:', {
|
// Если нужен только подсчет
|
||||||
id: req.sessionID,
|
if (countOnly) {
|
||||||
userId: req.session.userId,
|
let countQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1';
|
||||||
address: req.session.address,
|
let countParams = [userId];
|
||||||
authenticated: req.session.authenticated,
|
if (conversationId) {
|
||||||
guestId: req.session.guestId,
|
countQuery += ' AND conversation_id = $2';
|
||||||
|
countParams.push(conversationId);
|
||||||
|
}
|
||||||
|
const countResult = await db.query(countQuery, countParams);
|
||||||
|
const totalCount = parseInt(countResult.rows[0].count, 10);
|
||||||
|
return res.json({ success: true, count: totalCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем основной запрос
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
conversation_id,
|
||||||
|
user_id,
|
||||||
|
content,
|
||||||
|
sender_type,
|
||||||
|
role,
|
||||||
|
channel,
|
||||||
|
created_at,
|
||||||
|
attachment_filename,
|
||||||
|
attachment_mimetype,
|
||||||
|
attachment_size,
|
||||||
|
attachment_data -- Выбираем и данные файла
|
||||||
|
FROM messages
|
||||||
|
WHERE user_id = $1
|
||||||
|
`;
|
||||||
|
const params = [userId];
|
||||||
|
|
||||||
|
// Добавляем фильтр по диалогу, если нужно
|
||||||
|
if (conversationId) {
|
||||||
|
query += ' AND conversation_id = $2';
|
||||||
|
params.push(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем сортировку и пагинацию
|
||||||
|
query += ' ORDER BY created_at ASC LIMIT $' + (params.length + 1) + ' OFFSET $' + (params.length + 2);
|
||||||
|
params.push(limit);
|
||||||
|
params.push(offset);
|
||||||
|
|
||||||
|
logger.debug('Executing history query:', { query, params });
|
||||||
|
|
||||||
|
const result = await db.query(query, params);
|
||||||
|
|
||||||
|
// Обрабатываем результаты для фронтенда
|
||||||
|
const messages = result.rows.map(msg => {
|
||||||
|
const formatted = {
|
||||||
|
id: msg.id,
|
||||||
|
conversation_id: msg.conversation_id,
|
||||||
|
user_id: msg.user_id,
|
||||||
|
content: msg.content,
|
||||||
|
sender_type: msg.sender_type,
|
||||||
|
role: msg.role,
|
||||||
|
channel: msg.channel,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
attachments: null // Инициализируем
|
||||||
|
};
|
||||||
|
|
||||||
|
// Если есть данные файла, добавляем их в attachments
|
||||||
|
if (msg.attachment_data) {
|
||||||
|
formatted.attachments = [{
|
||||||
|
originalname: msg.attachment_filename,
|
||||||
|
mimetype: msg.attachment_mimetype,
|
||||||
|
size: msg.attachment_size,
|
||||||
|
// Кодируем Buffer в Base64 для передачи на фронтенд
|
||||||
|
data_base64: msg.attachment_data.toString('base64')
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
// Не забываем удалить поле attachment_data из итогового объекта,
|
||||||
|
// так как оно уже обработано и не нужно в сыром виде на фронте
|
||||||
|
// (хотя map и так создает новый объект, это для ясности)
|
||||||
|
delete formatted.attachment_data;
|
||||||
|
|
||||||
|
return formatted;
|
||||||
});
|
});
|
||||||
|
|
||||||
const limit = parseInt(req.query.limit) || 50;
|
// Получаем общее количество сообщений для пагинации (если не запрашивали только количество)
|
||||||
const offset = parseInt(req.query.offset) || 0;
|
let totalCountQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1';
|
||||||
|
let totalCountParams = [userId];
|
||||||
// Если пользователь аутентифицирован и у него есть гостевые сообщения,
|
if (conversationId) {
|
||||||
// автоматически связываем их перед получением истории
|
totalCountQuery += ' AND conversation_id = $2';
|
||||||
if (req.session.authenticated && req.session.userId && req.session.guestId) {
|
totalCountParams.push(conversationId);
|
||||||
try {
|
|
||||||
console.log('Automatically linking guest messages before fetching history');
|
|
||||||
await processGuestMessages(req.session.userId, req.session.guestId);
|
|
||||||
|
|
||||||
// Очищаем guestId из сессии после связывания
|
|
||||||
req.session.guestId = null;
|
|
||||||
await req.session.save();
|
|
||||||
|
|
||||||
console.log('Guest messages automatically linked');
|
|
||||||
} catch (linkError) {
|
|
||||||
console.error('Error auto-linking guest messages:', linkError);
|
|
||||||
// Продолжаем выполнение, даже если связывание не удалось
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const totalCountResult = await db.query(totalCountQuery, totalCountParams);
|
||||||
|
const totalMessages = parseInt(totalCountResult.rows[0].count, 10);
|
||||||
|
|
||||||
let messages = [];
|
logger.info(`Returning message history for user ${userId}`, { count: messages.length, offset, limit, total: totalMessages });
|
||||||
let total = 0;
|
|
||||||
|
|
||||||
// Если пользователь аутентифицирован, получаем его сообщения
|
res.json({
|
||||||
if (req.session.authenticated && req.session.userId) {
|
|
||||||
const countResult = await db.query(
|
|
||||||
`SELECT COUNT(*) as total FROM messages m
|
|
||||||
JOIN conversations c ON m.conversation_id = c.id
|
|
||||||
WHERE c.user_id = $1`,
|
|
||||||
[req.session.userId]
|
|
||||||
);
|
|
||||||
total = parseInt(countResult.rows[0].total) || 0;
|
|
||||||
|
|
||||||
const result = await db.query(
|
|
||||||
`SELECT
|
|
||||||
m.id,
|
|
||||||
m.content,
|
|
||||||
m.sender_type,
|
|
||||||
m.role,
|
|
||||||
m.created_at,
|
|
||||||
c.user_id,
|
|
||||||
c.id as conversation_id
|
|
||||||
FROM messages m
|
|
||||||
JOIN conversations c ON m.conversation_id = c.id
|
|
||||||
WHERE c.user_id = $1
|
|
||||||
ORDER BY m.created_at ASC
|
|
||||||
LIMIT $2 OFFSET $3`,
|
|
||||||
[req.session.userId, limit, offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
messages = result.rows;
|
|
||||||
console.log(`Found ${messages.length} messages for authenticated user`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
success: true,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
total: total,
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
|
total: totalMessages
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting chat history:', error);
|
logger.error(`Error fetching message history for user ${userId}: ${error.message}`, { stack: error.stack });
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ success: false, error: 'Ошибка получения истории сообщений' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,16 +38,48 @@ async function runMigrations() {
|
|||||||
for (const file of migrationFiles) {
|
for (const file of migrationFiles) {
|
||||||
if (!executedMigrations.has(file)) {
|
if (!executedMigrations.has(file)) {
|
||||||
const filePath = path.join(migrationsDir, file);
|
const filePath = path.join(migrationsDir, file);
|
||||||
const sql = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
|
|
||||||
|
// Ищем начало UP секции (или начало файла)
|
||||||
|
const upMarker = '-- UP Migration';
|
||||||
|
const downMarker = '-- DOWN Migration';
|
||||||
|
let upSqlStartIndex = fileContent.indexOf(upMarker);
|
||||||
|
if (upSqlStartIndex !== -1) {
|
||||||
|
// Ищем перевод строки после маркера
|
||||||
|
let newlineIndex = fileContent.indexOf('\n', upSqlStartIndex);
|
||||||
|
if (newlineIndex === -1) { // Если маркер в последней строке
|
||||||
|
newlineIndex = fileContent.length;
|
||||||
|
}
|
||||||
|
upSqlStartIndex = newlineIndex + 1; // Начинаем со следующей строки
|
||||||
|
} else {
|
||||||
|
upSqlStartIndex = 0; // Если маркера нет, берем все с начала
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем конец UP секции (начало DOWN секции)
|
||||||
|
let upSqlEndIndex = fileContent.indexOf(downMarker);
|
||||||
|
if (upSqlEndIndex === -1) {
|
||||||
|
upSqlEndIndex = fileContent.length; // Если маркера DOWN нет, берем все до конца
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем только UP SQL
|
||||||
|
const sqlToExecute = fileContent.substring(upSqlStartIndex, upSqlEndIndex).trim();
|
||||||
|
|
||||||
|
if (!sqlToExecute) {
|
||||||
|
logger.warn(`Migration file ${file} has no executable UP SQL content. Skipping.`);
|
||||||
|
continue; // Пропускаем пустые миграции
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Executing UP migration from ${file}...`);
|
||||||
await pool.query('BEGIN');
|
await pool.query('BEGIN');
|
||||||
try {
|
try {
|
||||||
await pool.query(sql);
|
// Выполняем только извлеченный UP SQL
|
||||||
|
await pool.query(sqlToExecute);
|
||||||
await pool.query('INSERT INTO migrations (name) VALUES ($1)', [file]);
|
await pool.query('INSERT INTO migrations (name) VALUES ($1)', [file]);
|
||||||
await pool.query('COMMIT');
|
await pool.query('COMMIT');
|
||||||
logger.info(`Migration ${file} executed successfully`);
|
logger.info(`Migration ${file} executed successfully`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await pool.query('ROLLBACK');
|
await pool.query('ROLLBACK');
|
||||||
|
logger.error(`Error executing migration ${file}:`, error); // Логируем ошибку перед пробросом
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1793
backend/yarn.lock
1793
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -316,24 +316,9 @@ input, textarea {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 205px;
|
|
||||||
transition: bottom var(--transition-normal);
|
transition: bottom var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Адаптация позиции при активации фокуса */
|
|
||||||
.chat-container:has(.chat-input.focused) .chat-messages {
|
|
||||||
bottom: 305px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Реализуем программное изменение позиции через JS для браузеров без поддержки :has */
|
|
||||||
@media (max-width: 100vw) {
|
|
||||||
/* Примечание: Этот блок @media может потребовать обновления в JS коде, */
|
|
||||||
/* если он используется для имитации :has. Сейчас изменяем только CSS. */
|
|
||||||
.chat-input.focused ~ .messages-container {
|
|
||||||
bottom: 305px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Стили для сообщений */
|
/* Стили для сообщений */
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
@@ -421,8 +406,7 @@ input, textarea {
|
|||||||
.chat-input {
|
.chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-sm);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
padding: var(--spacing-md) var(--spacing-lg);
|
|
||||||
background: var(--color-white);
|
background: var(--color-white);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-grey-light);
|
border: 1px solid var(--color-grey-light);
|
||||||
@@ -430,73 +414,193 @@ input, textarea {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
height: auto;
|
transition: all var(--transition-normal);
|
||||||
min-height: var(--chat-input-min-height);
|
|
||||||
max-height: var(--chat-input-max-height);
|
|
||||||
transition: min-height var(--transition-normal), padding var(--transition-normal);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-shadow: none;
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Стиль для input при фокусе */
|
.input-area {
|
||||||
.chat-input.focused {
|
display: flex;
|
||||||
min-height: var(--chat-input-focus-min-height);
|
align-items: flex-end;
|
||||||
max-height: var(--chat-input-focus-max-height);
|
gap: var(--spacing-sm);
|
||||||
padding: var(--spacing-lg);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input textarea {
|
.chat-input textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: var(--spacing-sm);
|
border: none;
|
||||||
border: 1px solid var(--color-grey-light);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
resize: none;
|
resize: none;
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 40px;
|
min-height: 24px;
|
||||||
transition: min-height var(--transition-normal), border-color var(--transition-normal);
|
max-height: 120px; /* Ограничение высоты textarea */
|
||||||
|
padding: 8px 0; /* Уменьшаем вертикальные отступы */
|
||||||
|
outline: none;
|
||||||
|
overflow-y: hidden; /* Убираем скролл, так как высота меняется динамически */
|
||||||
|
height: auto; /* Позволяем высоте изменяться */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input textarea:focus {
|
.chat-input textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input.focused textarea {
|
/* Контейнер для иконок */
|
||||||
min-height: 120px;
|
.chat-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input button {
|
/* Стили для кнопок-иконок */
|
||||||
padding: 0 var(--spacing-lg);
|
.chat-icon-btn {
|
||||||
background: var(--color-primary);
|
width: 36px;
|
||||||
color: var(--color-white);
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--font-size-md);
|
transition: all var(--transition-fast);
|
||||||
transition: background-color var(--transition-normal);
|
color: var(--color-grey);
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input button:hover:not(:disabled) {
|
.chat-icon-btn:hover {
|
||||||
background: var(--color-primary-dark);
|
color: var(--color-primary);
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input button:disabled {
|
.chat-icon-btn:disabled {
|
||||||
background: #ccc;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Новый контейнер для действий чата */
|
||||||
|
.chat-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Стили для кнопок в чате */
|
/* Стили для кнопок в чате */
|
||||||
.chat-buttons {
|
.chat-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-buttons button {
|
.chat-buttons button {
|
||||||
@@ -513,15 +617,6 @@ input, textarea {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-buttons button:first-child {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-buttons button:first-child:hover:not(:disabled) {
|
|
||||||
background-color: var(--color-primary-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-buttons .clear-btn {
|
.chat-buttons .clear-btn {
|
||||||
background-color: var(--color-danger);
|
background-color: var(--color-danger);
|
||||||
color: var(--color-white);
|
color: var(--color-white);
|
||||||
@@ -1095,7 +1190,6 @@ input, textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-input textarea {
|
.chat-input textarea {
|
||||||
padding: var(--spacing-sm);
|
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1148,7 +1242,7 @@ input, textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.disconnect-block {
|
.disconnect-block {
|
||||||
padding: var(--spacing-sm) 0;
|
margin-bottom: var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wallet-header {
|
.wallet-header {
|
||||||
|
|||||||
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>
|
|
||||||
@@ -21,335 +21,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-container">
|
<ChatInterface
|
||||||
<div ref="messagesContainer" class="chat-messages">
|
:messages="messages"
|
||||||
<div
|
:is-loading="isLoading"
|
||||||
v-for="message in messages"
|
:has-more-messages="messageLoading.hasMoreMessages"
|
||||||
:key="message.id"
|
v-model:newMessage="newMessage"
|
||||||
:class="[
|
v-model:attachments="attachments"
|
||||||
'message',
|
@send-message="handleSendMessage"
|
||||||
message.sender_type === 'assistant' || message.role === 'assistant'
|
@load-more="loadMessages"
|
||||||
? '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 class="message-content" v-html="formatMessage(message.content)" />
|
|
||||||
<div class="message-meta">
|
|
||||||
<div class="message-time">
|
|
||||||
{{ formatTime(message.timestamp || message.created_at) }}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chat-input">
|
|
||||||
<textarea
|
|
||||||
ref="messageInput"
|
|
||||||
v-model="newMessage"
|
|
||||||
placeholder="Введите сообщение..."
|
|
||||||
:disabled="isLoading"
|
|
||||||
rows="3"
|
|
||||||
autofocus
|
|
||||||
@keydown.enter.prevent="handleMessage(newMessage)"
|
|
||||||
@focus="handleFocus"
|
|
||||||
@blur="handleBlur"
|
|
||||||
/>
|
/>
|
||||||
<div class="chat-buttons">
|
|
||||||
<button :disabled="isLoading || !newMessage.trim()" @click="handleMessage(newMessage)">
|
|
||||||
{{ isLoading ? 'Отправка...' : 'Отправить' }}
|
|
||||||
</button>
|
|
||||||
<button class="clear-btn" :disabled="isLoading" @click="clearGuestMessages">
|
|
||||||
Очистить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Правая панель с информацией о кошельке -->
|
<!-- Правая панель с информацией о кошельке -->
|
||||||
<transition name="sidebar-slide">
|
<Sidebar
|
||||||
<div v-if="showWalletSidebar" class="wallet-sidebar">
|
v-model="showWalletSidebar"
|
||||||
<div class="wallet-sidebar-content">
|
:is-authenticated="isAuthenticated"
|
||||||
<!-- Блок для неавторизованных пользователей -->
|
:telegram-auth="telegramAuth"
|
||||||
<div v-if="!isAuthenticated" class="auth-container">
|
:email-auth="emailAuth"
|
||||||
<div class="wallet-header">
|
:token-balances="tokenBalances"
|
||||||
<div class="wallet-header-buttons">
|
:identities="auth.identities?.value"
|
||||||
<button class="close-wallet-sidebar" @click="toggleWalletSidebar">×</button>
|
@wallet-auth="handleWalletAuth"
|
||||||
</div>
|
@disconnect-wallet="disconnectWallet"
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-buttons-wrapper">
|
|
||||||
<button
|
|
||||||
v-if="
|
|
||||||
!telegramAuth.showVerification &&
|
|
||||||
!emailAuth.showForm &&
|
|
||||||
!emailAuth.showVerification
|
|
||||||
"
|
|
||||||
class="auth-btn connect-wallet-btn"
|
|
||||||
@click="handleWalletAuth"
|
|
||||||
>
|
|
||||||
Подключить кошелек
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="
|
|
||||||
!telegramAuth.showVerification &&
|
|
||||||
!emailAuth.showForm &&
|
|
||||||
!emailAuth.showVerification
|
|
||||||
"
|
|
||||||
class="auth-btn telegram-btn"
|
|
||||||
@click="handleTelegramAuth"
|
|
||||||
>
|
|
||||||
Подключить Telegram
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="
|
|
||||||
!telegramAuth.showVerification &&
|
|
||||||
!emailAuth.showForm &&
|
|
||||||
!emailAuth.showVerification
|
|
||||||
"
|
|
||||||
class="auth-btn email-btn"
|
|
||||||
@click="handleEmailAuth"
|
|
||||||
>
|
|
||||||
Подключить Email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="telegramAuth.showVerification" class="verification-block">
|
|
||||||
<div class="verification-code">
|
|
||||||
<span>Код верификации:</span>
|
|
||||||
<code @click="copyCode(telegramAuth.verificationCode)">{{
|
|
||||||
telegramAuth.verificationCode
|
|
||||||
}}</code>
|
|
||||||
<span v-if="codeCopied" class="copied-message">Скопировано!</span>
|
|
||||||
</div>
|
|
||||||
<a :href="telegramAuth.botLink" target="_blank" class="bot-link"
|
|
||||||
>Открыть бота Telegram</a
|
|
||||||
>
|
|
||||||
<button class="cancel-btn" @click="cancelTelegramAuth">Отмена</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Сообщение об ошибке в Telegram -->
|
|
||||||
<div v-if="telegramAuth.error" class="error-message">
|
|
||||||
{{ telegramAuth.error }}
|
|
||||||
<button class="close-error" @click="telegramAuth.error = ''">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для Email верификации -->
|
|
||||||
<div v-if="emailAuth.showForm" class="email-form">
|
|
||||||
<p>Введите ваш email для получения кода подтверждения:</p>
|
|
||||||
<div class="email-form-container">
|
|
||||||
<input
|
|
||||||
v-model="emailAuth.email"
|
|
||||||
type="email"
|
|
||||||
placeholder="Ваш email"
|
|
||||||
class="email-input"
|
|
||||||
:class="{ 'email-input-error': emailAuth.formatError }"
|
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
class="send-email-btn"
|
|
||||||
:disabled="emailAuth.isLoading"
|
|
||||||
@click="sendEmailVerification"
|
|
||||||
>
|
|
||||||
{{ emailAuth.isLoading ? 'Отправка...' : 'Отправить код' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="cancel-btn" @click="cancelEmailAuth">Отмена</button>
|
|
||||||
<p v-if="emailAuth.formatError" class="email-format-error">
|
|
||||||
Пожалуйста, введите корректный email
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для ввода кода верификации Email -->
|
|
||||||
<div v-if="emailAuth.showVerification" class="email-verification-form">
|
|
||||||
<p>
|
|
||||||
На ваш email <strong>{{ emailAuth.verificationEmail }}</strong> отправлен код
|
|
||||||
подтверждения.
|
|
||||||
</p>
|
|
||||||
<div class="email-form-container">
|
|
||||||
<input
|
|
||||||
v-model="emailAuth.verificationCode"
|
|
||||||
type="text"
|
|
||||||
placeholder="Введите код верификации"
|
|
||||||
maxlength="6"
|
|
||||||
class="email-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="send-email-btn"
|
|
||||||
:disabled="emailAuth.isVerifying"
|
|
||||||
@click="verifyEmailCode"
|
|
||||||
>
|
|
||||||
{{ emailAuth.isVerifying ? 'Проверка...' : 'Подтвердить' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="cancel-btn" @click="cancelEmailAuth">Отмена</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Сообщение об ошибке в Email -->
|
|
||||||
<div v-if="emailAuth.error" class="error-message">
|
|
||||||
{{ emailAuth.error }}
|
|
||||||
<button class="close-error" @click="emailAuth.error = ''">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Блок для авторизованных пользователей -->
|
|
||||||
<div v-if="isAuthenticated">
|
|
||||||
<!-- Контейнер только для кнопок -->
|
|
||||||
<div class="auth-buttons-container">
|
|
||||||
<div class="wallet-header">
|
|
||||||
<div class="wallet-header-buttons">
|
|
||||||
<button class="auth-btn disconnect-wallet-btn" @click="disconnectWallet">
|
|
||||||
Отключить
|
|
||||||
</button>
|
|
||||||
<button class="close-wallet-sidebar" @click="toggleWalletSidebar">×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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 class="user-info-item">
|
|
||||||
<span class="user-info-label">Telegram:</span>
|
|
||||||
<span v-if="hasIdentityType('telegram')" class="user-info-value">
|
|
||||||
{{ getIdentityValue('telegram') }}
|
|
||||||
</span>
|
|
||||||
<button v-else class="connect-btn" @click="handleTelegramAuth">
|
|
||||||
Подключить Telegram
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="user-info-item">
|
|
||||||
<span class="user-info-label">Email:</span>
|
|
||||||
<span v-if="hasIdentityType('email')" class="user-info-value">
|
|
||||||
{{ getIdentityValue('email') }}
|
|
||||||
</span>
|
|
||||||
<button v-else class="connect-btn" @click="handleEmailAuth">
|
|
||||||
Подключить Email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для Email верификации (отображается вместо user-info) -->
|
|
||||||
<div v-if="emailAuth.showForm" class="email-form">
|
|
||||||
<p>Введите ваш email для получения кода подтверждения:</p>
|
|
||||||
<div class="email-form-container">
|
|
||||||
<input
|
|
||||||
v-model="emailAuth.email"
|
|
||||||
type="email"
|
|
||||||
placeholder="Ваш email"
|
|
||||||
class="email-input"
|
|
||||||
:class="{ 'email-input-error': emailAuth.formatError }"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="send-email-btn"
|
|
||||||
:disabled="emailAuth.isLoading"
|
|
||||||
@click="sendEmailVerification"
|
|
||||||
>
|
|
||||||
{{ emailAuth.isLoading ? 'Отправка...' : 'Отправить код' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="cancel-btn" @click="cancelEmailAuth">Отмена</button>
|
|
||||||
<p v-if="emailAuth.formatError" class="email-format-error">
|
|
||||||
Пожалуйста, введите корректный email
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для ввода кода верификации Email (отображается вместо user-info) -->
|
|
||||||
<div v-if="emailAuth.showVerification" class="email-verification-form">
|
|
||||||
<p>
|
|
||||||
На ваш email <strong>{{ emailAuth.verificationEmail }}</strong> отправлен код
|
|
||||||
подтверждения.
|
|
||||||
</p>
|
|
||||||
<div class="email-form-container">
|
|
||||||
<input
|
|
||||||
v-model="emailAuth.verificationCode"
|
|
||||||
type="text"
|
|
||||||
placeholder="Введите код верификации"
|
|
||||||
maxlength="6"
|
|
||||||
class="email-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="send-email-btn"
|
|
||||||
:disabled="emailAuth.isVerifying"
|
|
||||||
@click="verifyEmailCode"
|
|
||||||
>
|
|
||||||
{{ emailAuth.isVerifying ? 'Проверка...' : 'Подтвердить' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button class="cancel-btn" @click="cancelEmailAuth">Отмена</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для Telegram верификации (отображается вместо user-info) -->
|
|
||||||
<div v-if="telegramAuth.showVerification" class="verification-block">
|
|
||||||
<div class="verification-code">
|
|
||||||
<span>Код верификации:</span>
|
|
||||||
<code @click="copyCode(telegramAuth.verificationCode)">{{ telegramAuth.verificationCode }}</code>
|
|
||||||
<span v-if="codeCopied" class="copied-message">Скопировано!</span>
|
|
||||||
</div>
|
|
||||||
<a :href="telegramAuth.botLink" target="_blank" class="bot-link">Открыть бота Telegram</a>
|
|
||||||
<button class="cancel-btn" @click="cancelTelegramAuth">Отмена</button>
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -359,10 +53,11 @@
|
|||||||
import { connectWithWallet } from '../services/wallet';
|
import { connectWithWallet } from '../services/wallet';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import api from '../api/axios';
|
import api from '../api/axios';
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import { marked } from 'marked';
|
|
||||||
import '../assets/styles/home.css';
|
import '../assets/styles/home.css';
|
||||||
import { fetchTokenBalances, TOKEN_CONTRACTS } from '../services/tokens';
|
import { fetchTokenBalances, TOKEN_CONTRACTS } from '../services/tokens';
|
||||||
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
|
import Message from '../components/Message.vue'; // Импортируем новый компонент
|
||||||
|
import ChatInterface from '../components/ChatInterface.vue'; // Импортируем интерфейс чата
|
||||||
|
|
||||||
console.log('HomeView.vue: Оптимизированная версия с чатом');
|
console.log('HomeView.vue: Оптимизированная версия с чатом');
|
||||||
|
|
||||||
@@ -500,8 +195,7 @@
|
|||||||
// Основные состояния
|
// Основные состояния
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const messages = ref([]);
|
const messages = ref([]);
|
||||||
const newMessage = ref('');
|
const newMessage = ref(''); // Управляется через v-model с ChatInterface
|
||||||
const messagesContainer = ref(null);
|
|
||||||
const userLanguage = ref('ru');
|
const userLanguage = ref('ru');
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isConnecting = ref(false);
|
const isConnecting = ref(false);
|
||||||
@@ -535,6 +229,8 @@
|
|||||||
const notifications = ref({
|
const notifications = ref({
|
||||||
successMessage: '',
|
successMessage: '',
|
||||||
showSuccess: false,
|
showSuccess: false,
|
||||||
|
errorMessage: '', // Добавлено для ошибок
|
||||||
|
showError: false // Добавлено для ошибок
|
||||||
});
|
});
|
||||||
|
|
||||||
// Состояния для пагинации и загрузки сообщений
|
// Состояния для пагинации и загрузки сообщений
|
||||||
@@ -547,6 +243,9 @@
|
|||||||
isLinkingGuest: false,
|
isLinkingGuest: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Состояние для прикрепленных файлов (управляется через v-model с ChatInterface)
|
||||||
|
const attachments = ref([]);
|
||||||
|
|
||||||
// Состояние для балансов токенов
|
// Состояние для балансов токенов
|
||||||
const tokenBalances = ref({
|
const tokenBalances = ref({
|
||||||
eth: '0',
|
eth: '0',
|
||||||
@@ -759,10 +458,6 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Прокручиваем к последнему сообщению
|
|
||||||
await nextTick();
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки истории сообщений:', error);
|
console.error('Ошибка загрузки истории сообщений:', error);
|
||||||
@@ -775,55 +470,83 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Обрабатывает отправку сообщения
|
* Обрабатывает отправку сообщения
|
||||||
* @param {string} text - Текст сообщения
|
* @param {object} payload - Данные из ChatInterface { message: string, attachments: File[] }
|
||||||
*/
|
*/
|
||||||
const handleMessage = async (text) => {
|
const handleSendMessage = async (payload) => {
|
||||||
if (!text.trim()) return;
|
const { message: text, attachments: files } = payload;
|
||||||
|
|
||||||
try {
|
|
||||||
// Создаем сообщение пользователя
|
|
||||||
const userMessageContent = text.trim();
|
const userMessageContent = text.trim();
|
||||||
const tempId = generateUniqueId();
|
|
||||||
|
|
||||||
|
// Определяем контент для локального отображения
|
||||||
|
let displayContent = userMessageContent;
|
||||||
|
if (!displayContent && files && files.length > 0) {
|
||||||
|
displayContent = `[Файл: ${files[0].name}]`; // Используем имя первого файла для отображения
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempId = generateUniqueId();
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
content: userMessageContent,
|
content: displayContent, // Используем displayContent
|
||||||
sender_type: 'user',
|
sender_type: 'user',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
isGuest: !auth.isAuthenticated.value,
|
isGuest: !auth.isAuthenticated.value,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
// Передаем attachments в формате, который понимает Message.vue
|
||||||
|
attachments: files ? files.map(f => ({
|
||||||
|
originalname: f.name, // Используем name из File объекта
|
||||||
|
size: f.size,
|
||||||
|
mimetype: f.type
|
||||||
|
})) : []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавляем сообщение в чат
|
|
||||||
messages.value.push(userMessage);
|
messages.value.push(userMessage);
|
||||||
|
// newMessage и attachments очищаются внутри ChatInterface после emit('send-message')
|
||||||
|
|
||||||
// Очищаем поле ввода
|
// TODO: Реализовать прокрутку к последнему сообщению, возможно через emit из ChatInterface
|
||||||
newMessage.value = '';
|
// scrollToBottom();
|
||||||
|
|
||||||
// Прокручиваем к последнему сообщению
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
// Устанавливаем состояние загрузки
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (auth.isAuthenticated.value) {
|
const formData = new FormData();
|
||||||
// Отправляем сообщение как авторизованный пользователь
|
formData.append('message', userMessageContent);
|
||||||
const response = await axios.post('/api/chat/message', {
|
formData.append('language', userLanguage.value);
|
||||||
message: userMessageContent,
|
|
||||||
language: userLanguage.value,
|
// Добавляем файлы в FormData
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append('attachments', file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiUrl = '/api/chat/message';
|
||||||
|
if (!auth.isAuthenticated.value) {
|
||||||
|
let guestId = getFromStorage('guestId');
|
||||||
|
if (!guestId) {
|
||||||
|
guestId = generateUniqueId();
|
||||||
|
setToStorage('guestId', guestId);
|
||||||
|
}
|
||||||
|
formData.append('guestId', guestId);
|
||||||
|
apiUrl = '/api/chat/guest-message';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post(apiUrl, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data' // Важно для отправки файлов
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
// Обновляем ID сообщения пользователя
|
// Обновляем ID сообщения пользователя
|
||||||
const userMsgIndex = messages.value.findIndex((m) => m.id === tempId);
|
const userMsgIndex = messages.value.findIndex((m) => m.id === tempId);
|
||||||
if (userMsgIndex !== -1) {
|
if (userMsgIndex !== -1) {
|
||||||
messages.value[userMsgIndex].id = response.data.userMessage.id;
|
messages.value[userMsgIndex].id = response.data.userMessage?.id || response.data.messageId;
|
||||||
messages.value[userMsgIndex].isLocal = false;
|
messages.value[userMsgIndex].isLocal = false;
|
||||||
|
messages.value[userMsgIndex].timestamp = response.data.userMessage?.created_at || new Date().toISOString(); // Обновляем время
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем ответ ИИ
|
// Добавляем ответ ИИ, если есть
|
||||||
|
if (response.data.aiMessage) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: response.data.aiMessage.id,
|
id: response.data.aiMessage.id,
|
||||||
content: response.data.aiMessage.content,
|
content: response.data.aiMessage.content,
|
||||||
@@ -831,72 +554,49 @@
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
timestamp: response.data.aiMessage.created_at,
|
timestamp: response.data.aiMessage.created_at,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Прокручиваем к последнему сообщению
|
|
||||||
scrollToBottom();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Отправляем сообщение как гость
|
|
||||||
console.log('Отправка гостевого сообщения:', userMessageContent);
|
|
||||||
|
|
||||||
// Получаем или создаем идентификатор гостя
|
|
||||||
let guestId = getFromStorage('guestId');
|
|
||||||
if (!guestId) {
|
|
||||||
guestId = generateUniqueId();
|
|
||||||
setToStorage('guestId', guestId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post('/api/chat/guest-message', {
|
// Если было гостевое сообщение, сохраняем
|
||||||
content: userMessageContent,
|
if (!auth.isAuthenticated.value) {
|
||||||
guestId,
|
|
||||||
language: userLanguage.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
console.log('Гостевое сообщение отправлено:', response.data);
|
|
||||||
|
|
||||||
// Обновляем ID сообщения пользователя
|
|
||||||
const userMsgIndex = messages.value.findIndex((m) => m.id === tempId);
|
|
||||||
if (userMsgIndex !== -1) {
|
|
||||||
messages.value[userMsgIndex].id = response.data.messageId;
|
|
||||||
messages.value[userMsgIndex].isLocal = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем сообщение в localStorage
|
|
||||||
try {
|
try {
|
||||||
const storedMessages = JSON.parse(getFromStorage('guestMessages', '[]'));
|
const storedMessages = JSON.parse(getFromStorage('guestMessages', '[]'));
|
||||||
|
// Добавляем только сообщение пользователя, так как ИИ ответ не приходит для гостя сразу
|
||||||
storedMessages.push({
|
storedMessages.push({
|
||||||
id: response.data.messageId,
|
id: response.data.messageId, // Используем ID, полученный от сервера
|
||||||
content: userMessageContent,
|
content: userMessageContent,
|
||||||
sender_type: 'user',
|
sender_type: 'user',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
isGuest: true,
|
isGuest: true,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: messages.value[userMsgIndex]?.timestamp || new Date().toISOString(),
|
||||||
|
attachmentsInfo: userMessage.attachmentsInfo // Сохраняем инфо о файлах
|
||||||
});
|
});
|
||||||
setToStorage('guestMessages', JSON.stringify(storedMessages));
|
setToStorage('guestMessages', JSON.stringify(storedMessages));
|
||||||
setToStorage('hasUserSentMessage', 'true');
|
setToStorage('hasUserSentMessage', 'true');
|
||||||
hasUserSentMessage.value = true;
|
hasUserSentMessage.value = true;
|
||||||
} catch (storageError) {
|
} catch (storageError) {
|
||||||
console.error('Ошибка сохранения сообщения в localStorage:', storageError);
|
console.error('Ошибка сохранения гостевого сообщения в localStorage:', storageError);
|
||||||
}
|
}
|
||||||
|
// Показываем правую панель для гостя
|
||||||
// Показываем правую панель, если она скрыта
|
|
||||||
if (!showWalletSidebar.value) {
|
if (!showWalletSidebar.value) {
|
||||||
showWalletSidebar.value = true;
|
showWalletSidebar.value = true;
|
||||||
setToStorage('showWalletSidebar', 'true');
|
setToStorage('showWalletSidebar', 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Обработка ошибки от API
|
||||||
|
throw new Error(response.data.error || 'Ошибка отправки сообщения от API');
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка отправки сообщения:', error);
|
console.error('Ошибка отправки сообщения:', error);
|
||||||
|
|
||||||
// Помечаем сообщение как ошибочное
|
// Помечаем сообщение как ошибочное
|
||||||
const userMsgIndex = messages.value.findIndex((m) => m.id === tempId);
|
const userMsgIndex = messages.value.findIndex((m) => m.id === tempId);
|
||||||
if (userMsgIndex !== -1) {
|
if (userMsgIndex !== -1) {
|
||||||
messages.value[userMsgIndex].hasError = true;
|
messages.value[userMsgIndex].hasError = true;
|
||||||
|
messages.value[userMsgIndex].isLocal = false; // Перестаем показывать "Отправка..."
|
||||||
}
|
}
|
||||||
|
// Добавляем системное сообщение об ошибке
|
||||||
// Добавляем сообщение об ошибке в чат
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
content: 'Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте еще раз.',
|
content: 'Произошла ошибка при отправке сообщения. Пожалуйста, попробуйте еще раз.',
|
||||||
@@ -904,59 +604,23 @@
|
|||||||
role: 'system',
|
role: 'system',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
scrollToBottom();
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
// TODO: Прокрутка к последнему сообщению после получения ответа или ошибки
|
||||||
|
// scrollToBottom();
|
||||||
// После отправки сообщения возвращаем нормальный размер
|
|
||||||
setTimeout(() => {
|
|
||||||
const chatInput = document.querySelector('.chat-input');
|
|
||||||
const chatMessages = document.querySelector('.chat-messages');
|
|
||||||
if (chatInput) {
|
|
||||||
chatInput.classList.remove('focused');
|
|
||||||
if (!CSS.supports('selector(:has(div))') && chatMessages) {
|
|
||||||
chatMessages.style.bottom = '135px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Непредвиденная ошибка в handleMessage:', error);
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Прокручивает контейнер с сообщениями к последнему сообщению
|
* Очищает гостевые сообщения (только из localStorage)
|
||||||
*/
|
*/
|
||||||
const scrollToBottom = () => {
|
const clearGuestLocalMessages = () => {
|
||||||
if (messagesContainer.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Очищает гостевые сообщения
|
|
||||||
*/
|
|
||||||
const clearGuestMessages = () => {
|
|
||||||
removeFromStorage('guestMessages');
|
removeFromStorage('guestMessages');
|
||||||
console.log('Гостевые сообщения очищены');
|
console.log('Гостевые сообщения из localStorage очищены');
|
||||||
messages.value = messages.value.filter((m) => !m.isGuest);
|
// Фильтруем текущие сообщения, если они были загружены из localStorage
|
||||||
};
|
// Осторожно: не удалять сообщения, которые пришли с сервера после аутентификации
|
||||||
|
if (!auth.isAuthenticated.value) {
|
||||||
/**
|
messages.value = messages.value.filter(m => !m.isGuest);
|
||||||
* Обрабатывает прокрутку контейнера с сообщениями
|
|
||||||
*/
|
|
||||||
const handleScroll = async () => {
|
|
||||||
const element = messagesContainer.value;
|
|
||||||
if (
|
|
||||||
!messageLoading.value.isLoadingMore &&
|
|
||||||
messageLoading.value.hasMoreMessages &&
|
|
||||||
element.scrollTop === 0
|
|
||||||
) {
|
|
||||||
await loadMessages();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1621,37 +1285,6 @@
|
|||||||
setToStorage('showWalletSidebar', showWalletSidebar.value.toString());
|
setToStorage('showWalletSidebar', showWalletSidebar.value.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Обрабатывает получение фокуса полем ввода
|
|
||||||
*/
|
|
||||||
const handleFocus = () => {
|
|
||||||
const chatInput = document.querySelector('.chat-input');
|
|
||||||
const chatMessages = document.querySelector('.chat-messages');
|
|
||||||
if (chatInput) {
|
|
||||||
chatInput.classList.add('focused');
|
|
||||||
if (!CSS.supports('selector(:has(div))') && chatMessages) {
|
|
||||||
chatMessages.style.bottom = '235px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обрабатывает потерю фокуса полем ввода
|
|
||||||
*/
|
|
||||||
const handleBlur = () => {
|
|
||||||
// Если сообщение непустое, оставляем расширенный вид
|
|
||||||
if (!newMessage.value.trim()) {
|
|
||||||
const chatInput = document.querySelector('.chat-input');
|
|
||||||
const chatMessages = document.querySelector('.chat-messages');
|
|
||||||
if (chatInput) {
|
|
||||||
chatInput.classList.remove('focused');
|
|
||||||
if (!CSS.supports('selector(:has(div))') && chatMessages) {
|
|
||||||
chatMessages.style.bottom = '135px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// 7. НАБЛЮДАТЕЛИ (WATCHERS)
|
// 7. НАБЛЮДАТЕЛИ (WATCHERS)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -1679,12 +1312,13 @@
|
|||||||
return dateA - dateB;
|
return dateA - dateB;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Прокручиваем к последнему сообщению
|
// Прокрутка теперь обрабатывается в ChatInterface
|
||||||
nextTick(() => {
|
// nextTick(() => {
|
||||||
scrollToBottom();
|
// scrollToBottom();
|
||||||
});
|
// });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ deep: true } // Добавим deep: true на случай сложных изменений в массиве
|
||||||
);
|
);
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -1715,11 +1349,6 @@
|
|||||||
// Запускаем отслеживание изменений аутентификации
|
// Запускаем отслеживание изменений аутентификации
|
||||||
watchAuthChanges();
|
watchAuthChanges();
|
||||||
|
|
||||||
// Устанавливаем обработчик скролла
|
|
||||||
if (messagesContainer.value) {
|
|
||||||
messagesContainer.value.addEventListener('scroll', handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем историю сообщений
|
// Загружаем историю сообщений
|
||||||
if (shouldLoadHistory.value) {
|
if (shouldLoadHistory.value) {
|
||||||
// Проверяем сессию пользователя
|
// Проверяем сессию пользователя
|
||||||
@@ -1740,19 +1369,14 @@
|
|||||||
messages.value = [...messages.value, ...parsedMessages];
|
messages.value = [...messages.value, ...parsedMessages];
|
||||||
hasUserSentMessage.value = true;
|
hasUserSentMessage.value = true;
|
||||||
setToStorage('hasUserSentMessage', 'true');
|
setToStorage('hasUserSentMessage', 'true');
|
||||||
} else {
|
|
||||||
// Если пользователь аутентифицирован, удаляем гостевые сообщения
|
|
||||||
removeFromStorage('guestMessages');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ошибка загрузки сообщений из localStorage:', e);
|
console.error('Ошибка загрузки сообщений из localStorage:', e);
|
||||||
}
|
}
|
||||||
}
|
} else if (isAuthenticated.value) {
|
||||||
|
// Если пользователь аутентифицирован, загружаем с сервера
|
||||||
// Загружаем историю сообщений, если пользователь аутентифицирован
|
|
||||||
if (isAuthenticated.value) {
|
|
||||||
await loadMessages({ initial: true });
|
await loadMessages({ initial: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1782,17 +1406,16 @@
|
|||||||
startBalanceUpdates();
|
startBalanceUpdates();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Прокручиваем к последнему сообщению
|
|
||||||
scrollToBottom();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// При размонтировании компонента
|
// При размонтировании компонента
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
/* Удалено, т.к. скролл теперь в ChatInterface
|
||||||
// Очищаем обработчик скролла
|
// Очищаем обработчик скролла
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
messagesContainer.value.removeEventListener('scroll', handleScroll);
|
messagesContainer.value.removeEventListener('scroll', handleScroll);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// Удаляем слушатель события загрузки истории чата
|
// Удаляем слушатель события загрузки истории чата
|
||||||
window.removeEventListener('load-chat-history', () => loadMessages({ initial: true }));
|
window.removeEventListener('load-chat-history', () => loadMessages({ initial: true }));
|
||||||
|
|||||||
Reference in New Issue
Block a user