Добавлен сервис AI
Добавлен ai агент для формирования и проверки статистики Добавлен ai агент для поддержки
This commit is contained in:
376
resources/js/Components/Ai/AiAskWidget.vue
Normal file
376
resources/js/Components/Ai/AiAskWidget.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<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>
|
||||
@@ -7,10 +7,14 @@ import Noise from "../Components/Noise.vue";
|
||||
import { useThemeVars } from "naive-ui";
|
||||
import { computed } from "vue";
|
||||
import Tour from "../Components/Tour/Tour.vue";
|
||||
import AiAskWidget from "../Components/Ai/AiAskWidget.vue";
|
||||
import { usePage } from "@inertiajs/vue3";
|
||||
|
||||
const {isGlobalLoading} = useGlobalLoading()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const pageProps = usePage().props
|
||||
|
||||
const blobBg = computed(() => {
|
||||
const p = themeVars.value.primaryColor
|
||||
const w = themeVars.value.warningColor
|
||||
@@ -92,6 +96,7 @@ const blobBg = computed(() => {
|
||||
</NLayout>
|
||||
|
||||
</NLayout>
|
||||
<AiAskWidget :period-start="pageProps.date ? pageProps.date[0] : null" :period-end="pageProps.date ? pageProps.date[1] : null" />
|
||||
<Tour />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -212,12 +212,12 @@ const submit = () => {
|
||||
|
||||
const reportCreator = computed(() => {
|
||||
if (props.latestReport === null) return ''
|
||||
if (props.latestReport.doctor?.LPUDoctorID === 0) return 'Отчет создан системой'
|
||||
if (props.latestReport.doctor.LPUDoctorID === 0) return 'Отчет создан системой'
|
||||
else return `Отчет создан: ${props.latestReport.doctor.FAM_V} ${props.latestReport.doctor.IM_V} ${props.latestReport.doctor.OT_V}`
|
||||
})
|
||||
const reportCreatorType = computed(() => {
|
||||
if (props.latestReport === null) return 'warning'
|
||||
if (props.latestReport.doctor?.LPUDoctorID === 0) return 'error'
|
||||
if (props.latestReport.doctor.LPUDoctorID === 0) return 'error'
|
||||
else return 'warning'
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user