Files
onboard/resources/js/Components/Ai/AiAskWidget.vue
brusnitsyn a9e032f658 Добавлен сервис AI
Добавлен ai агент для формирования и проверки статистики
Добавлен ai агент для поддержки
2026-07-20 16:58:21 +09:00

377 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { NDrawer, NDrawerContent, NInput, NButton, NAlert, NIcon, NSpin, NFlex, NEl } from 'naive-ui';
import { TbChartBubble, TbX, TbSend } from 'vue-icons-plus/tb';
import axios from 'axios';
import { marked } from 'marked'
import {useAuthStore} from "../../Stores/auth.js";
// Пропсы (период, может быть null)
const props = defineProps({
periodStart: {
type: String,
default: null,
},
periodEnd: {
type: String,
default: null,
},
});
const userStore = useAuthStore()
// Состояние
const isOpen = ref(false);
const question = ref('');
const loading = ref(false);
const error = ref(null);
const history = ref([]);
const chatContainer = ref(null);
let abortController = null;
let isFirstOpen = true;
// Загрузка истории из localStorage
onMounted(() => {
const saved = localStorage.getItem('ai_stats_history');
if (saved) {
try {
history.value = JSON.parse(saved).slice(0, 50);
} catch (e) {
history.value = [];
}
}
});
// Сохранение истории
function saveHistory() {
localStorage.setItem('ai_stats_history', JSON.stringify(history.value));
}
// Добавление нового сообщения в историю (в конец)
function addMessage(type, content, structured = null) {
history.value.push({
type, // 'user' или 'assistant'
content,
structured,
time: new Date().toLocaleString(),
});
saveHistory();
scrollToBottom();
}
// Обновление последнего сообщения (для замены "Думаю..." на ответ)
function updateLastMessage(content, structured = null) {
const last = history.value[history.value.length - 1];
if (last && last.type === 'assistant') {
last.content = content;
last.structured = structured || null;
saveHistory();
scrollToBottom();
}
}
// Очистка истории
function clearHistory() {
history.value = [];
localStorage.removeItem('ai_stats_history');
}
// Прокрутка вниз
function scrollToBottom() {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
});
}
// Основной метод запроса
async function ask() {
const userQuestion = question.value.trim();
if (!userQuestion) return;
// Отменяем предыдущий запрос
if (abortController) {
abortController.abort();
abortController = null;
}
// Сброс ошибки
error.value = null;
// Добавляем вопрос пользователя в историю
addMessage('user', userQuestion);
question.value = '';
// Добавляем заглушку ответа ассистента ("Думаю...")
addMessage('assistant', 'Думаю...', null);
loading.value = true;
abortController = new AbortController();
try {
const response = await axios.post(
'/api/ai/ask',
{
question: userQuestion,
period_start: props.periodStart,
period_end: props.periodEnd,
},
{
signal: abortController.signal,
}
);
if (response.data.success) {
const { answer, structured } = response.data;
// Обновляем последнее сообщение ассистента
updateLastMessage(answer, structured || null);
} else {
error.value = response.data.message || 'Ошибка получения ответа';
// Удаляем заглушку, если ошибка
history.value.pop();
saveHistory();
}
} catch (err) {
if (err.name === 'AbortError') {
// Запрос отменён удаляем заглушку и вопрос? Оставим вопрос, заглушку убираем
history.value.pop(); // убираем "Думаю..."
saveHistory();
return;
}
error.value = 'Не удалось получить ответ. Проверьте соединение и попробуйте снова.';
console.error('AI stats error:', err);
// Удаляем заглушку
history.value.pop();
saveHistory();
} finally {
loading.value = false;
abortController = null;
scrollToBottom();
}
}
// Отмена запроса при размонтировании
onBeforeUnmount(() => {
if (abortController) {
abortController.abort();
}
});
// Переключение дровера
function toggleOpen() {
isOpen.value = !isOpen.value;
if (isOpen.value && isFirstOpen) {
isFirstOpen = false;
setTimeout(scrollToBottom, 300);
}
}
function renderMarkdown(text) {
if (!text) return ''
return marked.parse(text)
}
</script>
<template>
<div class="ai-fab-container">
<!-- FAB -->
<n-button
v-if="userStore.hasPermission('ai.access')"
circle
type="primary"
size="large"
class="ai-fab"
@click="toggleOpen"
>
<n-icon size="24">
<TbChartBubble />
</n-icon>
</n-button>
<!-- Drawer -->
<n-drawer v-model:show="isOpen" width="calc(100vw / 3)" placement="right" :mask-closable="true">
<n-drawer-content :native-scrollbar="false">
<template #header>
<div class="flex items-center justify-between w-full">
<span class="text-lg font-medium">Ассистент</span>
<div class="flex gap-2">
<n-button size="small" quaternary @click="clearHistory" v-if="history.length">
Очистить историю
</n-button>
<n-button size="small" quaternary @click="toggleOpen">
<n-icon><TbX /></n-icon>
</n-button>
</div>
</div>
</template>
<!-- Контейнер чата -->
<div class="chat-container">
<!-- Область сообщений -->
<div ref="chatContainer" class="chat-messages">
<!-- Пустое состояние -->
<div v-if="!history.length" class="empty-state">
<p class="text-gray-400 text-sm">Задайте вопрос, и я постараюсь помочь вам.</p>
</div>
<!-- Сообщения -->
<div v-for="(item, idx) in history" :key="idx" class="message-item">
<!-- Если тип 'user' -->
<NEl v-if="item.type === 'user'" class="message message-user">
<div class="message-bubble user-bubble">
<div class="message-header">
<span class="text-xs">{{ item.time }}</span>
</div>
<div class="message-text">{{ item.content }}</div>
</div>
</NEl>
<!-- Если тип 'assistant' -->
<NEl v-else class="message message-assistant">
<div class="message-bubble assistant-bubble">
<div class="message-header">
<span class="text-xs text-gray-400">Ассистент</span>
<span class="text-xs text-gray-400">{{ item.time }}</span>
</div>
<div class="message-text" v-if="item.content !== 'Думаю...'" v-html="renderMarkdown(item.content)" />
<div v-else class="message-text flex items-center gap-2 min-w-[200px]">
<n-spin size="small" scale="0.5" />
<span>Думаю...</span>
</div>
</div>
</NEl>
</div>
</div>
</div>
<template #footer>
<NSpace vertical class="w-full">
<!-- Ошибка -->
<n-alert v-if="error" type="error" class="mb-2" closable @close="error = null">
{{ error }}
</n-alert>
<!-- Форма ввода -->
<NEl class="p-2 rounded-lg" style="background-color: var(--input-color);">
<n-input
v-model:value="question"
type="textarea"
placeholder="Задайте вопрос..."
:autosize="{ minRows: 2, maxRows: 2 }"
:disabled="loading"
:focusable="false"
@keydown.enter.prevent="ask"
style="--n-color: transparent; --n-color-disabled: transparent; --n-border: none; --n-border-hover: none; --n-border-focus: none; --n-color-focus: none; --n-box-shadow-focus: none;"
/>
<NFlex justify="end" align="end">
<n-button
circle
type="primary"
:loading="loading"
:disabled="!question.trim()"
@click="ask"
>
<template #icon>
<TbSend size="18" />
</template>
</n-button>
</NFlex>
</NEl>
</NSpace>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<style scoped>
.ai-fab-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
}
.ai-fab {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: transform 0.2s;
}
.ai-fab:hover {
transform: scale(1.05);
}
/* Контейнер чата внутри DrawerContent */
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
}
.chat-messages {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 200px;
}
.empty-state {
text-align: center;
padding: 2rem 0;
color: #aaa;
}
.message-item {
display: flex;
flex-direction: column;
}
.message {
display: flex;
}
.message-user {
justify-content: flex-end;
}
.message-assistant {
justify-content: flex-start;
}
.message-bubble {
max-width: 80%;
padding: 0.6rem 0.8rem;
border-radius: 12px;
word-wrap: break-word;
}
.user-bubble {
background: var(--primary-color-suppl);
color: white;
}
.assistant-bubble {
background: var(--input-color);
color: white;
}
.message-header {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: #888;
margin-bottom: 0.2rem;
}
.user-bubble .message-header {
color: rgba(255,255,255,0.7);
}
.message-text {
font-size: 0.9rem;
line-height: 1.4;
}
.message-text .text-gray-400 {
color: #999;
}
.chat-input .n-input {
flex: 1;
}
</style>