Добавлен сервис AI

Добавлен ai агент для формирования и проверки статистики
Добавлен ai агент для поддержки
This commit is contained in:
brusnitsyn
2026-07-20 16:58:21 +09:00
parent 6350159943
commit a9e032f658
21 changed files with 1546 additions and 4 deletions

View 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>

View File

@@ -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>

View File

@@ -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'
})