Загрузка данных
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" class="opacity-0" lang="ru">
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Tailwise admin is super flexible, powerful, clean & modern responsive tailwind admin template with unlimited possibilities.">
<meta name="keywords" content="admin template, Tailwise Admin Template, dashboard template, flat admin template, responsive admin template, web app">
<meta name="author" content="LEFT4CODE">
<title>Виртуальный бухгалтер - Чат</title>
<!-- BEGIN: CSS Assets-->
<link rel="stylesheet" href="dist/css/vendors/tippy.css">
<link rel="stylesheet" href="dist/css/vendors/zoom-vanilla.css">
<link rel="stylesheet" href="dist/css/vendors/simplebar.css">
<link rel="stylesheet" href="dist/css/themes/hurricane.css">
<link rel="stylesheet" href="dist/css/app.css">
<!-- END: CSS Assets-->
</head>
<body>
<div class="hurricane before:content-[''] before:z-[-1] before:w-screen before:bg-slate-50 before:top-0 before:h-screen before:fixed before:bg-texture-black before:bg-contain before:bg-fixed before:bg-[center_-20rem] before:bg-no-repeat">
<div class="relative loading-page loading-page--before-hide [&.loading-page--before-hide]:before:block [&.loading-page--hide]:before:opacity-0 before:content-[''] before:transition-opacity before:duration-300 before:hidden before:inset-0 before:h-screen before:w-screen before:fixed before:bg-gradient-to-b before:from-theme-1 before:to-theme-2 before:z-[60] [&.loading-page--before-hide]:after:block [&.loading-page--hide]:after:opacity-0 after:content-[''] after:transition-opacity after:duration-300 after:hidden after:h-16 after:w-16 after:animate-pulse after:fixed after:opacity-50 after:inset-0 after:m-auto after:bg-loading-puff after:bg-cover after:z-[61]">
<!-- TOP BAR -->
<div class="fixed top-0 left-0 z-50 side-menu group side-menu--collapsed">
<div class="fixed top-0 inset-x-0 mt-2.5 z-10 mx-2.5 h-[65px] bg-gradient-to-r from-theme-1 to-theme-2 rounded-[0.6rem] shadow-lg flex before:content-[''] before:absolute before:inset-x-0 before:-mt-2.5 before:h-2.5 before:backdrop-blur">
<div class="absolute inset-x-0 h-full transition-[padding] duration-100">
<div class="flex items-center w-full h-full px-5">
<!-- BEGIN: Breadcrumb -->
<nav aria-label="breadcrumb" class="flex flex-1 hidden xl:block">
<ol class="flex items-center text-theme-1 dark:text-slate-300 text-white/90">
<li class="">
<a href="">Виртуальный бухгалтер</a>
</li>
<li class="relative ml-5 pl-0.5 before:content-[''] before:w-[14px] before:h-[14px] before:bg-chevron-white before:transform before:rotate-[-90deg] before:bg-[length:100%] before:-ml-[1.125rem] before:absolute before:my-auto before:inset-y-0 dark:before:bg-chevron-white">
<a href="">Чат</a>
</li>
</ol>
</nav>
<!-- END: Breadcrumb -->
<!-- BEGIN: Notification & User Menu -->
<div class="flex items-center flex-1">
<div class="flex items-center gap-1 ml-auto">
<a class="p-2 rounded-full request-full-screen hover:bg-white/5" href="">
<i data-tw-merge="" data-lucide="expand" class="stroke-[1] h-[18px] w-[18px] text-white"></i>
</a>
</div>
<div data-tw-merge="" data-tw-placement="bottom-end" class="dropdown relative ml-5">
<button data-tw-toggle="dropdown" aria-expanded="false" class="cursor-pointer image-fit h-[36px] w-[36px] overflow-hidden rounded-full border-[3px] border-white/[0.15]">
</button>
<div data-transition="" data-selector=".show" data-enter="transition-all ease-linear duration-150" data-enter-from="absolute !mt-5 invisible opacity-0 translate-y-1" data-enter-to="!mt-1 visible opacity-100 translate-y-0" data-leave="transition-all ease-linear duration-150" data-leave-from="!mt-1 visible opacity-100 translate-y-0" data-leave-to="absolute !mt-5 invisible opacity-0 translate-y-1" class="dropdown-menu absolute z-[9999] hidden">
<div data-tw-merge="" class="dropdown-content rounded-md border-transparent bg-white p-2 shadow-[0px_3px_10px_#00000017] dark:border-transparent dark:bg-darkmode-600 w-56 mt-1">
<a href="hurricane-settings-email-settings.html" class="cursor-pointer flex items-center p-2 transition duration-300 ease-in-out rounded-md hover:bg-slate-200/60 dark:bg-darkmode-600 dark:hover:bg-darkmode-400 dropdown-item">
<i data-tw-merge="" data-lucide="inbox" class="stroke-[1] w-4 h-4 mr-2"></i>
Настройки почты
</a>
<a href="hurricane-settings-security.html" class="cursor-pointer flex items-center p-2 transition duration-300 ease-in-out rounded-md hover:bg-slate-200/60 dark:bg-darkmode-600 dark:hover:bg-darkmode-400 dropdown-item">
<i data-tw-merge="" data-lucide="lock" class="stroke-[1] w-4 h-4 mr-2"></i>
Восстановить пароль
</a>
<div class="h-px my-2 -mx-2 bg-slate-200/60 dark:bg-darkmode-400"></div>
<a href="/logout" class="cursor-pointer flex items-center p-2 transition duration-300 ease-in-out rounded-md hover:bg-slate-200/60 dark:bg-darkmode-600 dark:hover:bg-darkmode-400 dropdown-item">
<i data-tw-merge="" data-lucide="power" class="stroke-[1] w-4 h-4 mr-2"></i>
Выйти
</a>
</div>
</div>
</div>
</div>
<!-- END: Notification & User Menu -->
</div>
</div>
</div>
</div>
<!-- VUE APP -->
<div id="chat-app" class="content transition-[margin,width] duration-100 px-5 xl:mr-2.5 mt-[75px] pt-[31px] pb-16 content--compact xl:ml-[275px] [&.content--compact]:xl:ml-[100px]">
<div class="container">
<div class="grid grid-cols-12 gap-x-6 gap-y-10">
<div class="col-span-12">
<!-- HEADER -->
<div class="mt-4 flex flex-col gap-y-3 md:mt-0 md:h-10 md:flex-row md:items-center">
<div class="text-base font-medium group-[.mode--light]:text-white">
Чат бот
</div>
<div class="flex flex-col gap-x-3 gap-y-2 sm:flex-row md:ml-auto">
<button @click="createNewChat" data-tw-merge="" class="transition duration-200 border shadow-sm inline-flex items-center justify-center py-2 px-3 rounded-md font-medium cursor-pointer focus:ring-4 focus:ring-primary focus:ring-opacity-20 focus-visible:outline-none dark:focus:ring-slate-700 dark:focus:ring-opacity-50 [&:hover:not(:disabled)]:bg-opacity-90 [&:hover:not(:disabled)]:border-opacity-90 [&:not(button)]:text-center disabled:opacity-70 disabled:cursor-not-allowed bg-primary border-primary text-white dark:border-primary group-[.mode--light]:!border-transparent group-[.mode--light]:!bg-white/[0.12] group-[.mode--light]:!text-slate-200">
<i data-tw-merge="" data-lucide="messages-square" class="mr-2 h-4 w-4 stroke-[1.3]"></i>
Создать новый чат
</button>
</div>
</div>
<div class="mt-3.5 flex flex-col gap-x-6 gap-y-10 lg:flex-row">
<!-- SIDEBAR - ИСТОРИЯ -->
<div class="w-full flex-none lg:w-[23rem]">
<div class="flex flex-col gap-y-7">
<div class="box box--stacked flex flex-col p-2">
<ul data-tw-merge="" role="tablist" class="p-0.5 border rounded-lg dark:border-darkmode-400 w-full flex border-transparent bg-transparent">
<li id="example-1-tab" data-tw-merge="" role="presentation" class="focus-visible:outline-none flex-1 first:rounded-l-[0.6rem] last:rounded-r-[0.6rem] [&[aria-selected='true']_button]:border-primary/[0.15] [&[aria-selected='true']_button]:bg-primary/[0.04] [&[aria-selected='true']_button]:font-medium [&[aria-selected='true']_button]:text-primary [&[aria-selected='true']_button]:shadow-sm">
<button data-tw-merge="" data-tw-target="#example-1" role="tab" class="cursor-pointer appearance-none px-3 border border-transparent transition-colors dark:text-slate-400 [&.active]:text-slate-700 dark:border-transparent [&.active]:border [&.active]:shadow-sm [&.active]:font-medium [&.active]:border-slate-200 [&.active]:bg-white [&.active]:dark:text-slate-300 [&.active]:dark:bg-darkmode-400 [&.active]:dark:border-darkmode-400 active flex w-full items-center justify-center gap-2 whitespace-nowrap rounded-[0.6rem] py-3 text-slate-500">
<i data-tw-merge="" data-lucide="messages-square" class="h-4 w-4 stroke-[1.4]"></i>
История
<span class="flex min-w-[1.15rem] items-center justify-center rounded-full bg-white text-xs">
<span class="block h-full w-full rounded-full bg-theme-1/[0.75] px-1.5 py-0.5 leading-none text-white">
@{{ chats.length }}
</span>
</span>
</button>
</li>
</ul>
</div>
<div class="box box--stacked flex flex-col p-5">
<div class="tab-content">
<div data-transition="" data-selector=".active" id="example-1" role="tabpanel" aria-labelledby="example-1-tab" class="tab-pane active">
<div class="">
<div class="relative">
<i data-tw-merge="" data-lucide="search" class="absolute inset-y-0 left-0 z-10 my-auto ml-4 h-4 w-4 stroke-[1.3] text-slate-500/90"></i>
<input v-model="searchQuery" data-tw-merge="" type="text" placeholder="Поиск в истории..." class="disabled:bg-slate-100 disabled:cursor-not-allowed dark:disabled:bg-darkmode-800/50 dark:disabled:border-transparent [&[readonly]]:bg-slate-100 [&[readonly]]:cursor-not-allowed [&[readonly]]:dark:bg-darkmode-800/50 [&[readonly]]:dark:border-transparent transition duration-200 ease-in-out w-full text-sm border-slate-200 shadow-sm placeholder:text-slate-400/90 focus:ring-4 focus:ring-primary focus:ring-opacity-20 focus:border-primary focus:border-opacity-40 dark:bg-darkmode-800 dark:border-transparent dark:focus:ring-slate-700 dark:focus:ring-opacity-50 dark:placeholder:text-slate-500/80 [&[type='file']]:border file:mr-4 file:py-2 file:px-4 file:rounded-l-md file:border-0 file:border-r-[1px] file:border-slate-100/10 file:text-sm file:font-semibold file:bg-slate-100 file:text-slate-500/70 hover:file:bg-200 group-[.form-inline]:flex-1 group-[.input-group]:rounded-none group-[.input-group]:[&:not(:first-child)]:border-l-transparent group-[.input-group]:first:rounded-l group-[.input-group]:last:rounded-r group-[.input-group]:z-10 rounded-full py-2.5 pl-10">
</div>
<div class="mt-4 flex flex-col gap-1">
<!-- История чатов -->
<div v-for="chat in filteredChats" :key="chat.id"
@click="selectChat(chat.id)"
:class="['cursor-pointer items-center gap-4 rounded-lg px-2 py-2.5 hover:bg-slate-50', { 'bg-slate-100': currentChatId === chat.id }]"
class="-mx-2 flex">
<div class="w-full">
<div class="flex w-full items-center">
<div class="max-w-[7rem] truncate font-medium md:max-w-[8rem]">
@{{ chat.title }}
</div>
<div class="ml-auto flex items-center gap-2">
<div class="text-xs text-slate-500/90">
@{{ formatTime(chat.updated_at) }}
</div>
</div>
</div>
<div v-if="chat.last_message" class="mt-1.5 flex items-center">
<div class="max-w-[7rem] truncate text-slate-500/90 md:max-w-[10rem]">
@{{ chat.last_message }}
</div>
</div>
</div>
</div>
<!-- Пустое состояние -->
<div v-if="chats.length === 0" class="text-center py-8 text-slate-500">
Нет истории чатов
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- CHAT WINDOW -->
<div class="flex w-full flex-col gap-y-7">
<div class="box box--stacked flex flex-col p-5">
<!-- HEADER -->
<div class="flex items-center gap-3.5 border-b border-dashed pb-5">
<div>
<div class="image-fit h-12 w-12 overflow-hidden rounded-full border-[3px] border-slate-200/70">
<img src="dist/images/users/user6-50x50.jpg" alt="Виртуальный бухгалтер">
</div>
</div>
<div>
<div class="max-w-[9rem] truncate font-medium md:max-w-none">
Виртуальный бухгалтер
</div>
<div class="mt-0.5 max-w-[9rem] truncate text-slate-500 md:max-w-none">
Ваш персональный помощник
</div>
</div>
</div>
<!-- MESSAGES -->
<div ref="messagesContainer" class="scrollable-ref h-96 -mx-3 overflow-y-auto [&:-webkit-scrollbar]:w-0 [&:-webkit-scrollbar]:bg-transparent [&_.simplebar-content]:p-0 [&_.simplebar-track.simplebar-vertical]:w-[10px] [&_.simplebar-track.simplebar-vertical]:mr-0.5 [&_.simplebar-track.simplebar-vertical_.simplebar-scrollbar]:before:bg-slate-400/20">
<!-- Message item -->
<div v-for="message in messages" :key="message.id"
:class="['max-w-[85%] sm:max-w-none relative group flex items-end gap-3 pt-4', message.is_bot ? 'mr-auto' : 'ml-auto flex-row-reverse right']">
<div v-if="message.is_bot" class="hidden sm:block">
<!-- Avatar placeholder for bot -->
</div>
<div :class="['rounded-r-xl rounded-tl-xl border border-slate-200/80 px-4 pb-4 pt-3',
message.is_bot ? 'bg-slate-50/80' : 'bg-primary/10 rounded-l-xl rounded-br-none text-right']">
<div>@{{ message.text }}</div>
<!-- Buttons for bot messages -->
<div v-if="message.is_bot && message.buttons && message.buttons.length > 0"
class="max-w-[85%] sm:max-w-none relative mr-auto group pt-4 flex flex-wrap items-end gap-3">
<button v-for="(button, index) in message.buttons"
:key="index"
@click="handleButtonClick(button, message.id)"
class="transition duration-200 border shadow-sm inline-flex items-center justify-center py-2 px-3 rounded-md font-medium cursor-pointer focus:ring-4 focus:ring-primary focus:ring-opacity-20 focus-visible:outline-none dark:focus:ring-slate-700 dark:focus:ring-opacity-50 [&:hover:not(:disabled)]:bg-opacity-90 [&:hover:not(:disabled)]:border-opacity-90 [&:not(button)]:text-center disabled:opacity-70 disabled:cursor-not-allowed bg-primary border-primary text-white dark:border-primary group-[.mode--light]:!border-transparent group-[.mode--light]:!bg-white/[0.12] group-[.mode--light]:!text-slate-200">
<div>@{{ button.text }}</div>
</button>
</div>
</div>
</div>
<!-- Loading indicator -->
<div v-if="isLoading" class="flex justify-center py-4">
<div class="animate-pulse text-slate-500">Печатает...</div>
</div>
</div>
<!-- INPUT -->
<div class="relative">
<textarea v-model="newMessage"
@keyup.enter.exact="sendMessage"
data-tw-merge=""
placeholder="Напишите сообщение..."
class="disabled:bg-slate-100 disabled:cursor-not-allowed dark:disabled:bg-darkmode-800/50 dark:disabled:border-transparent [&[readonly]]:bg-slate-100 [&[readonly]]:cursor-not-allowed [&[readonly]]:dark:bg-darkmode-800/50 [&[readonly]]:dark:border-transparent transition duration-200 ease-in-out w-full text-sm border-slate-200 shadow-sm placeholder:text-slate-400/90 focus:ring-4 focus:ring-primary focus:ring-opacity-20 focus:border-primary focus:border-opacity-40 dark:bg-darkmode-800 dark:border-transparent dark:focus:ring-slate-700 dark:focus:ring-opacity-50 dark:placeholder:text-slate-500/80 group-[.form-inline]:flex-1 group-[.input-group]:rounded-none group-[.input-group]:[&:not(:first-child)]:border-l-transparent group-[.input-group]:first:rounded-l group-[.input-group]:last:rounded-r group-[.input-group]:z-10 -mb-1.5 resize-none rounded-xl pr-16"></textarea>
<div class="absolute inset-y-0 right-0 flex w-[3.8rem] items-center justify-center">
<a @click.prevent="sendMessage" class="box flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-transparent bg-gradient-to-b from-theme-1/90 to-theme-2/90" href="">
<i data-tw-merge="" data-lucide="send" class="-ml-0.5 h-4 w-4 stroke-[1.3] text-white/70"></i>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- BEGIN: Vendor JS Assets-->
<script src="dist/js/vendors/dom.js"></script>
<script src="dist/js/vendors/tailwind-merge.js"></script>
<script src="dist/js/vendors/lucide.js"></script>
<script src="dist/js/vendors/tab.js"></script>
<script src="dist/js/vendors/tippy.js"></script>
<script src="dist/js/vendors/popper.js"></script>
<script src="dist/js/vendors/dropdown.js"></script>
<script src="dist/js/vendors/image-zoom.js"></script>
<script src="dist/js/vendors/simplebar.js"></script>
<script src="dist/js/vendors/transition.js"></script>
<script src="dist/js/vendors/modal.js"></script>
<script src="dist/js/components/base/theme-color.js"></script>
<script src="dist/js/components/base/lucide.js"></script>
<script src="dist/js/components/base/tippy.js"></script>
<script src="dist/js/themes/hurricane.js"></script>
<!-- END: Vendor JS Assets-->
<!-- VUE.JS -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
chats: [],
currentChatId: null,
messages: [],
newMessage: '',
isLoading: false,
searchQuery: ''
}
},
computed: {
filteredChats() {
if (!this.searchQuery) return this.chats;
return this.chats.filter(chat =>
chat.title.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
},
mounted() {
this.loadChats();
// Инициализация Lucide иконок после монтирования
this.$nextTick(() => {
if (window.lucide) {
lucide.createIcons();
}
});
},
updated() {
// Переинициализация иконок после обновления
this.$nextTick(() => {
if (window.lucide) {
lucide.createIcons();
}
});
},
methods: {
// Загрузка списка чатов
async loadChats() {
try {
const response = await fetch('/api/chats', {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
if (response.ok) {
this.chats = await response.json();
// Автоматически выбрать первый чат
if (this.chats.length > 0 && !this.currentChatId) {
this.selectChat(this.chats[0].id);
}
}
} catch (error) {
console.error('Ошибка загрузки чатов:', error);
}
},
// Создание нового чата
async createNewChat() {
try {
const response = await fetch('/api/chats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
title: 'Новый чат ' + new Date().toLocaleTimeString()
})
});
if (response.ok) {
const newChat = await response.json();
this.chats.unshift(newChat);
this.selectChat(newChat.id);
} else {
alert('Не удалось создать чат');
}
} catch (error) {
console.error('Ошибка создания чата:', error);
alert('Не удалось создать чат');
}
},
// Выбор чата
async selectChat(chatId) {
this.currentChatId = chatId;
await this.loadMessages(chatId);
},
// Загрузка сообщений
async loadMessages(chatId) {
try {
this.isLoading = true;
const response = await fetch(`/api/chats/${chatId}/messages`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
});
if (response.ok) {
this.messages = await response.json();
this.scrollToBottom();
}
} catch (error) {
console.error('Ошибка загрузки сообщений:', error);
} finally {
this.isLoading = false;
}
},
// Отправка сообщения
async sendMessage() {
if (!this.newMessage.trim() || !this.currentChatId) return;
const messageText = this.newMessage;
this.newMessage = '';
// Добавляем сообщение пользователя локально
this.messages.push({
id: Date.now(),
text: messageText,
is_bot: false,
buttons: []
});
this.scrollToBottom();
try {
this.isLoading = true;
const response = await fetch(`/api/chats/${this.currentChatId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
message: messageText
})
});
if (response.ok) {
const data = await response.json();
// Добавляем ответ бота
if (data.bot_response) {
this.messages.push({
id: data.bot_response.id,
text: data.bot_response.text,
is_bot: true,
buttons: data.bot_response.buttons || []
});
this.scrollToBottom();
}
// Обновляем список чатов
await this.loadChats();
} else {
alert('Не удалось отправить сообщение');
}
} catch (error) {
console.error('Ошибка отправки сообщения:', error);
alert('Не удалось отправить сообщение');
} finally {
this.isLoading = false;
}
},
// Обработка нажатия на кнопку в сообщении бота
async handleButtonClick(button, messageId) {
console.log('Нажата кнопка:', button);
// Если кнопка отправляет текст
if (button.action === 'send_message') {
this.newMessage = button.payload || button.text;
await this.sendMessage();
return;
}
// Если кнопка выполняет действие (callback)
if (button.action === 'callback') {
try {
this.isLoading = true;
const response = await fetch(`/api/chats/${this.currentChatId}/button-callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
button_payload: button.payload,
message_id: messageId
})
});
if (response.ok) {
const data = await response.json();
if (data.bot_response) {
this.messages.push({
id: data.bot_response.id,
text: data.bot_response.text,
is_bot: true,
buttons: data.bot_response.buttons || []
});
this.scrollToBottom();
}
}
} catch (error) {
console.error('Ошибка обработки кнопки:', error);
} finally {
this.isLoading = false;
}
}
},
// Прокрутка вниз
scrollToBottom() {
this.$nextTick(() => {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
},
// Форматирование времени
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
}
}).mount('#chat-app');
</script>
</body>
</html>
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ChatController;
Route::middleware('web')->group(function () {
Route::get('/chats', [ChatController::class, 'index']);
Route::post('/chats', [ChatController::class, 'store']);
Route::get('/chats/{chatId}/messages', [ChatController::class, 'messages']);
Route::post('/chats/{chatId}/messages', [ChatController::class, 'sendMessage']);
Route::post('/chats/{chatId}/button-callback', [ChatController::class, 'buttonCallback']);
});