ваше сообщение коммита

This commit is contained in:
2025-06-20 15:50:13 +03:00
parent c8ce916dab
commit 5111b584e5
8 changed files with 7297 additions and 24 deletions

View File

@@ -1,11 +1,12 @@
<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 v-for="message in messages" :key="message.id" :class="['message-wrapper', { 'selected-message': selectedMessageIds.includes(message.id) }]">
<template v-if="isAdmin">
<input type="checkbox" class="admin-select-checkbox" :checked="selectedMessageIds.includes(message.id)" @change="() => toggleSelectMessage(message.id)" />
</template>
<Message :message="message" />
</div>
</div>
<div ref="chatInputRef" class="chat-input">
@@ -68,6 +69,14 @@
<path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z" fill="currentColor"/>
</svg>
</button>
<button v-if="props.isAdmin" class="chat-icon-btn ai-reply-btn" title="Сгенерировать ответ ИИ" @click="handleAiReply" :disabled="isAiLoading">
<template v-if="isAiLoading">
<svg class="ai-spinner" width="22" height="22" viewBox="0 0 50 50"><circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle></svg>
</template>
<template v-else>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="8" r="4"/><path d="M8 16v2M16 16v2"/></svg>
</template>
</button>
</div>
</div>
<div class="attachment-preview" v-if="localAttachments.length > 0">
@@ -92,6 +101,7 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import Message from './Message.vue';
import messagesService from '../services/messagesService.js';
const props = defineProps({
messages: {
@@ -103,6 +113,7 @@ const props = defineProps({
attachments: Array, // Для v-model
// Добавляем пропс для проверки, есть ли еще сообщения для загрузки
hasMoreMessages: Boolean,
isAdmin: { type: Boolean, default: false }
});
const emit = defineEmits([
@@ -110,6 +121,7 @@ const emit = defineEmits([
'update:attachments',
'send-message',
'load-more', // Событие для загрузки старых сообщений
'ai-reply',
]);
const messagesContainer = ref(null);
@@ -434,6 +446,45 @@ onUnmounted(() => {
}
});
const isAiLoading = ref(false);
const selectedMessageIds = ref([]);
function toggleSelectMessage(id) {
if (selectedMessageIds.value.includes(id)) {
selectedMessageIds.value = selectedMessageIds.value.filter(mid => mid !== id);
} else {
selectedMessageIds.value.push(id);
}
}
async function handleAiReply() {
if (isAiLoading.value) return;
// Если выбраны сообщения — отправляем их, иначе старое поведение
if (emit) {
const selectedMessages = props.messages.filter(m => selectedMessageIds.value.includes(m.id));
emit('ai-reply', selectedMessages);
return;
}
isAiLoading.value = true;
try {
const response = await messagesService.sendMessage({
message: props.newMessage,
attachments: []
});
if (response && response.aiMessage && response.aiMessage.content) {
emit('update:newMessage', response.aiMessage.content);
} else {
emit('update:newMessage', '');
}
} catch (e) {
console.error('Ошибка генерации ответа ИИ:', e);
alert('Ошибка генерации ответа ИИ');
} finally {
isAiLoading.value = false;
}
}
</script>
<style scoped>
@@ -689,4 +740,19 @@ onUnmounted(() => {
font-size: var(--font-size-xs);
}
}
.ai-spinner {
animation: ai-spin 1s linear infinite;
}
@keyframes ai-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.selected-message {
background: #e6f7ff;
}
.admin-select-checkbox {
margin-right: 8px;
}
</style>

View File

@@ -6,6 +6,7 @@ const SettingsBlockchainView = () => import('../views/settings/BlockchainSetting
const SettingsSecurityView = () => import('../views/settings/SecuritySettingsView.vue');
const SettingsInterfaceView = () => import('../views/settings/InterfaceSettingsView.vue');
import axios from 'axios';
import { setToStorage } from '../utils/storage.js';
console.log('router/index.js: Script loaded');
@@ -152,4 +153,9 @@ router.beforeEach(async (to, from, next) => {
}
});
router.afterEach(() => {
// Всегда закрываем сайдбар при переходе на любую страницу
setToStorage('showWalletSidebar', false);
});
export default router;

View File

@@ -5,8 +5,36 @@ export default {
if (!userId) return [];
const { data } = await axios.get(`/api/messages?userId=${userId}`);
return data;
},
async sendMessage({ conversationId, message, attachments = [], toUserId }) {
const formData = new FormData();
if (conversationId) formData.append('conversationId', conversationId);
if (message) formData.append('message', message);
if (toUserId) formData.append('toUserId', toUserId);
attachments.forEach(file => {
formData.append('attachments', file);
});
const { data } = await axios.post('/api/chat/message', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
withCredentials: true
});
return data;
},
async getMessagesByConversationId(conversationId) {
if (!conversationId) return [];
const { data } = await axios.get(`/api/messages?conversationId=${conversationId}`);
return data;
},
async getConversationByUserId(userId) {
if (!userId) return null;
const { data } = await axios.get(`/api/messages/conversations?userId=${userId}`);
return data;
},
async generateAiDraft(conversationId, messages, language = 'auto') {
const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language });
return data;
}
};
};
export async function getAllMessages() {
const { data } = await axios.get('/api/messages');

View File

@@ -60,12 +60,18 @@
<button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div>
<div class="messages-block">
<h3>История сообщений</h3>
<div v-if="isLoadingMessages" class="loading">Загрузка...</div>
<div v-else-if="messages.length === 0" class="empty">Нет сообщений</div>
<div v-else class="messages-list">
<Message v-for="msg in messages" :key="msg.id" :message="msg" />
</div>
<h3>Чат с пользователем</h3>
<ChatInterface
:messages="messages"
:isLoading="isLoadingMessages"
:attachments="chatAttachments"
:newMessage="chatNewMessage"
:isAdmin="isAdmin"
@send-message="handleSendMessage"
@update:newMessage="val => chatNewMessage = val"
@update:attachments="val => chatAttachments = val"
@ai-reply="handleAiReply"
/>
</div>
<el-dialog v-model="showTagModal" title="Добавить тег пользователю">
<div v-if="allTags.length">
@@ -106,8 +112,10 @@ import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue';
import Message from '../../components/Message.vue';
import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js';
import { useAuth } from '../../composables/useAuth';
const route = useRoute();
const router = useRouter();
@@ -126,6 +134,11 @@ const showTagModal = ref(false);
const newTagName = ref('');
const newTagDescription = ref('');
const messages = ref([]);
const chatAttachments = ref([]);
const chatNewMessage = ref('');
const { isAdmin } = useAuth();
const isAiLoading = ref(false);
const conversationId = ref(null);
function toggleSidebar() {
isSidebarOpen.value = !isSidebarOpen.value;
@@ -207,10 +220,18 @@ async function loadMessages() {
if (!contact.value || !contact.value.id) return;
isLoadingMessages.value = true;
try {
messages.value = await messagesService.getMessagesByUserId(contact.value.id);
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
// Получаем conversationId для контакта
const conv = await messagesService.getConversationByUserId(contact.value.id);
conversationId.value = conv?.id || null;
if (conversationId.value) {
messages.value = await messagesService.getMessagesByConversationId(conversationId.value);
if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else {
lastMessageDate.value = null;
}
} else {
messages.value = [];
lastMessageDate.value = null;
}
} catch (e) {
@@ -296,6 +317,63 @@ function goBack() {
}
}
async function handleSendMessage({ message, attachments }) {
console.log('handleSendMessage', message, attachments);
if (!contact.value || !contact.value.id || !conversationId.value) return;
const tempId = 'local-' + Date.now();
const optimisticMsg = {
id: tempId,
conversation_id: conversationId.value,
user_id: null,
content: message,
sender_type: 'user',
role: 'user',
channel: 'web',
created_at: new Date().toISOString(),
attachments: [],
isLocal: true
};
messages.value.push(optimisticMsg);
try {
await messagesService.sendMessage({
message,
conversationId: conversationId.value,
attachments,
toUserId: contact.value.id
});
} finally {
await loadMessages();
}
}
async function handleAiReply(selectedMessages = []) {
console.log('[AI-ASSISTANT] Кнопка нажата, messages:', messages.value);
if (isAiLoading.value) {
console.log('[AI-ASSISTANT] Уже идёт генерация, выход');
return;
}
if (!Array.isArray(selectedMessages) || selectedMessages.length === 0) {
alert('Выберите хотя бы одно сообщение пользователя для генерации ответа.');
return;
}
isAiLoading.value = true;
try {
// Генерируем черновик ответа через новый endpoint
const draftResp = await messagesService.generateAiDraft(conversationId.value, selectedMessages);
if (draftResp && draftResp.success && draftResp.aiMessage) {
chatNewMessage.value = draftResp.aiMessage;
console.log('[AI-ASSISTANT] Черновик сгенерирован:', draftResp.aiMessage);
} else {
alert('Не удалось сгенерировать ответ ИИ.');
}
} catch (e) {
alert('Ошибка генерации ответа ИИ: ' + (e?.message || e));
} finally {
isAiLoading.value = false;
console.log('[AI-ASSISTANT] Генерация завершена');
}
}
onMounted(async () => {
await reloadContact();
await loadMessages();