feat: demo data, AI assistant, UI fixes for presentation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0a19a03b6e
commit
48d80137ac
@ -56,7 +56,7 @@ export default function DashboardPage() {
|
||||
const personnelIssues = data.personnel?.non_compliant || 0;
|
||||
|
||||
return (
|
||||
<PageLayout title="📊 Дашборд АСУ ТК" subtitle="Калининградский филиал — контроль лётной годности">
|
||||
<PageLayout title="📊 Дашборд АСУ ТК" subtitle="REFLY — система контроля лётной годности">
|
||||
{loading ? <div className="text-center py-16 text-gray-400">⏳ Загрузка данных...</div> : (
|
||||
<div className="space-y-6">
|
||||
{/* Critical alerts banner */}
|
||||
|
||||
@ -5,6 +5,7 @@ import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||
import SkipToMain from '@/components/SkipToMain'
|
||||
import AIAssistant from '@/components/AIAssistant'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'REFLY — Контроль лётной годности',
|
||||
@ -30,7 +31,8 @@ export default function RootLayout({
|
||||
<Providers>
|
||||
<ErrorBoundary>
|
||||
<SkipToMain />
|
||||
{children}
|
||||
{children}
|
||||
<AIAssistant />
|
||||
</ErrorBoundary>
|
||||
</Providers>
|
||||
<Script id="sw-register" strategy="afterInteractive">
|
||||
|
||||
@ -16,7 +16,7 @@ from .checklist_audits import router as checklist_audits_router
|
||||
from .inbox import router as inbox_router
|
||||
from .tasks import router as tasks_router
|
||||
from .audit import router as audit_router
|
||||
from .ai import router as ai_router
|
||||
from .ai_assistant import router as ai_router
|
||||
from .document_templates import router as document_templates_router
|
||||
|
||||
__all__ = [
|
||||
|
||||
77
backend/app/api/routes/ai_assistant.py
Normal file
77
backend/app/api/routes/ai_assistant.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""AI-помощник REFLY — чат с контекстом из БД (для демо и докладов)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
import httpx
|
||||
|
||||
router = APIRouter(prefix="/ai", tags=["AI Assistant"])
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
reply: str
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def ai_chat(req: ChatRequest, user=Depends(get_current_user)):
|
||||
api_key = getattr(settings, "ANTHROPIC_API_KEY", None) or ""
|
||||
if not api_key or api_key == "":
|
||||
raise HTTPException(400, "AI assistant not configured")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.aircraft_db import Aircraft
|
||||
from app.models.organization import Organization
|
||||
|
||||
aircraft_count = db.query(Aircraft).count()
|
||||
org_count = db.query(Organization).count()
|
||||
context = (
|
||||
f"В системе: {aircraft_count} воздушных судов, {org_count} организаций. "
|
||||
f"Роль пользователя: {user.role}. Имя: {user.display_name}."
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
system_prompt = f"""Ты — AI-помощник системы REFLY АСУ ТК (контроль лётной годности воздушных судов).
|
||||
Ты помогаешь с вопросами о:
|
||||
- Состоянии воздушных судов и их лётной годности
|
||||
- Директивах лётной годности и сервисных бюллетенях
|
||||
- Планировании ТО и инспекций
|
||||
- Нормативных документах (ФАП, EASA, ICAO)
|
||||
- Сертификации и допусках
|
||||
- Управлении рисками безопасности полётов
|
||||
|
||||
Контекст системы: {context}
|
||||
|
||||
Отвечай на русском языке. Будь конкретным и профессиональным.
|
||||
Используй авиационную терминологию где уместно."""
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": getattr(settings, "ANTHROPIC_MODEL", "claude-sonnet-4-20250514"),
|
||||
"max_tokens": 1024,
|
||||
"system": system_prompt,
|
||||
"messages": [{"role": "user", "content": req.message}],
|
||||
},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(502, f"AI service error: {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
content = data.get("content", [{}])
|
||||
reply = content[0].get("text", "Ошибка получения ответа") if content else "Ошибка получения ответа"
|
||||
return ChatResponse(reply=reply)
|
||||
@ -37,9 +37,9 @@ def _serialize_aircraft(a: Aircraft, db: Session) -> AircraftOut:
|
||||
"serial_number": a.serial_number,
|
||||
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||
"total_time": float(a.total_time) if a.total_time is not None else None,
|
||||
"total_time": float(a.total_time) if getattr(a, 'total_time', None) is not None else None,
|
||||
"total_cycles": getattr(a, 'total_cycles', None),
|
||||
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||
"current_status": getattr(a, 'current_status', None) or getattr(a, 'status', 'in_service') or "in_service",
|
||||
"configuration": getattr(a, 'configuration', None),
|
||||
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||
@ -81,7 +81,7 @@ def list_aircraft(
|
||||
"""List aircraft with pagination. Returns {items, total, page, per_page, pages}."""
|
||||
query = _base_query(db, user)
|
||||
if q:
|
||||
query = query.filter(or_(Aircraft.registration_number.ilike(f"%{q}%"), Aircraft.drawing_numbers.ilike(f"%{q}%")))
|
||||
query = query.filter(Aircraft.registration_number.ilike(f"%{q}%"))
|
||||
query = query.order_by(Aircraft.registration_number)
|
||||
total = query.count()
|
||||
items_raw = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
261
backend/app/demo/seed_full_demo.py
Normal file
261
backend/app/demo/seed_full_demo.py
Normal file
@ -0,0 +1,261 @@
|
||||
"""
|
||||
Полное наполнение демо-данными для доклада: пользователи, аудиты, дефекты, заявки, риски, персонал ПЛГ.
|
||||
Запускается из main.py lifespan после seed_checklists и seed_organizations.
|
||||
"""
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models import (
|
||||
User,
|
||||
Organization,
|
||||
Audit,
|
||||
ChecklistTemplate,
|
||||
CertApplication,
|
||||
CertApplicationStatus,
|
||||
RiskAlert,
|
||||
)
|
||||
from app.models.aircraft_db import Aircraft
|
||||
from app.models.personnel_plg import PLGSpecialist, PLGQualification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_org_id(db, name_substr: str):
|
||||
o = db.query(Organization).filter(Organization.name.ilike(f"%{name_substr}%")).first()
|
||||
return str(o.id) if o else None
|
||||
|
||||
|
||||
def _get_aircraft_id_by_reg(db, reg: str):
|
||||
a = db.query(Aircraft).filter(Aircraft.registration_number == reg).first()
|
||||
return str(a.id) if a else None
|
||||
|
||||
|
||||
def _get_first_aircraft_id(db):
|
||||
a = db.query(Aircraft).first()
|
||||
return str(a.id) if a else None
|
||||
|
||||
|
||||
def _get_template_id(db):
|
||||
t = db.query(ChecklistTemplate).first()
|
||||
return str(t.id) if t else None
|
||||
|
||||
|
||||
def seed_full_demo():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# ─── 1. Пользователи ─────────────────────────────────────────────
|
||||
demo_users = [
|
||||
("Иванов Сергей А.", "ivanov@refly.ru", "admin", None),
|
||||
("Петрова Елена В.", "petrova@refly.ru", "authority_inspector", None),
|
||||
("Козлов Дмитрий И.", "kozlov@refly.ru", "operator_manager", "S7"),
|
||||
("Сидорова Анна М.", "sidorova@refly.ru", "operator_user", "S7"),
|
||||
("Волков Алексей Н.", "volkov@refly.ru", "mro_manager", "Домодедово"),
|
||||
("Морозова Ольга С.", "morozova@refly.ru", "mro_user", "Домодедово"),
|
||||
("Николаев Павел Р.", "nikolaev@refly.ru", "authority_inspector", None),
|
||||
("Федорова Мария К.", "fedorova@refly.ru", "operator_user", "Smartavia"),
|
||||
]
|
||||
created_users = {}
|
||||
for display_name, email, role, org_key in demo_users:
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
continue
|
||||
org_id = _get_org_id(db, org_key) if org_key else None
|
||||
uid = str(uuid.uuid4())
|
||||
db.add(
|
||||
User(
|
||||
id=uid,
|
||||
external_subject=f"demo-{email}",
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
role=role,
|
||||
organization_id=org_id,
|
||||
)
|
||||
)
|
||||
created_users[email] = uid
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: users checked/created")
|
||||
|
||||
# ─── 2. Аудиты (нужен template_id и aircraft_id) ─────────────────
|
||||
template_id = _get_template_id(db)
|
||||
first_aircraft_id = _get_first_aircraft_id(db)
|
||||
inspector_id = db.query(User).filter(User.role == "authority_inspector").first()
|
||||
inspector_id = str(inspector_id.id) if inspector_id else None
|
||||
|
||||
if template_id and first_aircraft_id and inspector_id:
|
||||
audits_data = [
|
||||
("Плановая проверка ТОиР S7 Airlines", "completed", "scheduled"),
|
||||
("Внеплановая проверка после инцидента", "in_progress", "unscheduled"),
|
||||
("Сертификационный аудит АТЦ Домодедово", "draft", "certification"),
|
||||
("Проверка СМК авиакомпании Smartavia", "completed", "scheduled"),
|
||||
("Инспекция рампы Шереметьево", "in_progress", "ramp"),
|
||||
("Аудит системы управления безопасностью", "draft", "sms"),
|
||||
]
|
||||
base_date = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
||||
existing_count = db.query(Audit).count()
|
||||
if existing_count >= len(audits_data):
|
||||
pass # already enough
|
||||
else:
|
||||
for i, (title, status, audit_type) in enumerate(audits_data):
|
||||
if existing_count + i >= len(audits_data):
|
||||
break
|
||||
planned = base_date + timedelta(days=30 * i)
|
||||
completed = planned + timedelta(days=2) if status == "completed" else None
|
||||
a = Audit(
|
||||
template_id=template_id,
|
||||
aircraft_id=first_aircraft_id,
|
||||
status=status,
|
||||
planned_at=planned,
|
||||
completed_at=completed,
|
||||
inspector_user_id=inspector_id,
|
||||
notes=f"{audit_type}: {title}",
|
||||
)
|
||||
db.add(a)
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: audits checked/created")
|
||||
|
||||
# ─── 3. Дефекты (in-memory _defects в роуте) ───────────────────
|
||||
try:
|
||||
from app.api.routes.defects import _defects
|
||||
|
||||
aircraft_regs = [r[0] for r in db.query(Aircraft.registration_number).limit(5).all()]
|
||||
reg = aircraft_regs[0] if aircraft_regs else "RA-00000"
|
||||
defects_demo = [
|
||||
("Трещина обшивки фюзеляжа секция 41", "critical", "open"),
|
||||
("Течь гидросистемы левая стойка шасси", "high", "deferred"),
|
||||
("Коррозия лонжерона крыла зона 2", "high", "open"),
|
||||
("Неисправность датчика угла атаки", "critical", "rectified"),
|
||||
("Износ тормозных дисков выше допуска", "medium", "open"),
|
||||
("Повреждение обтекателя антенны", "low", "closed"),
|
||||
("Утечка топлива бак №2", "critical", "open"),
|
||||
("Вибрация двигателя №1 выше нормы", "high", "deferred"),
|
||||
]
|
||||
for desc, severity, status in defects_demo:
|
||||
if any(d.get("description") == desc for d in _defects.values()):
|
||||
continue
|
||||
did = str(uuid.uuid4())
|
||||
_defects[did] = {
|
||||
"id": did,
|
||||
"aircraft_reg": reg,
|
||||
"description": desc,
|
||||
"severity": severity,
|
||||
"status": status,
|
||||
"ata_chapter": "32" if "шасси" in desc else "53" if "топлив" in desc else "21",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
logger.info("seed_full_demo: defects (in-memory) populated")
|
||||
except Exception as e:
|
||||
logger.warning("seed_full_demo: defects skip %s", e)
|
||||
|
||||
# ─── 4. Заявки на сертификацию ─────────────────────────────────
|
||||
app_org = db.query(Organization).filter(Organization.kind == "operator").first()
|
||||
app_user = db.query(User).filter(User.role == "operator_manager").first()
|
||||
if app_org and app_user:
|
||||
apps_data = [
|
||||
("Заявка на СЛГ RA-89060", CertApplicationStatus.APPROVED, "airworthiness_certificate"),
|
||||
("Заявка на одобрение ТОиР АТЦ Кольцово", CertApplicationStatus.UNDER_REVIEW, "mro_approval"),
|
||||
("Продление СЛГ RA-73801", CertApplicationStatus.SUBMITTED, "renewal"),
|
||||
("Заявка на допуск ETOPS", CertApplicationStatus.DRAFT, "special_approval"),
|
||||
("Сертификация нового типа Ту-214", CertApplicationStatus.UNDER_REVIEW, "type_certificate"),
|
||||
]
|
||||
for i, (subject, status, app_type) in enumerate(apps_data):
|
||||
num = f"KLG-DEMO-{datetime.now().strftime('%Y%m%d')}-{1001 + i}"
|
||||
if db.query(CertApplication).filter(CertApplication.number == num).first():
|
||||
continue
|
||||
db.add(
|
||||
CertApplication(
|
||||
applicant_org_id=str(app_org.id),
|
||||
created_by_user_id=str(app_user.id),
|
||||
number=num,
|
||||
status=status,
|
||||
subject=f"[{app_type}] {subject}",
|
||||
description=subject,
|
||||
submitted_at=datetime.now(timezone.utc) - timedelta(days=5 - i)
|
||||
if status != CertApplicationStatus.DRAFT
|
||||
else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: cert applications checked/created")
|
||||
|
||||
# ─── 5. Риски (RiskAlert) ──────────────────────────────────────
|
||||
risk_titles = [
|
||||
("Просрочка директивы лётной годности AD-2025-0142", "critical", False),
|
||||
("Недостаточная квалификация персонала ТОиР", "high", True),
|
||||
("Устаревшее оборудование контроля", "medium", False),
|
||||
("Нарушение сроков периодического ТО", "high", False),
|
||||
("Отсутствие резервных компонентов", "medium", True),
|
||||
("Риск усталостного разрушения фюзеляжа", "critical", True),
|
||||
]
|
||||
ac_id = _get_first_aircraft_id(db)
|
||||
for title, severity, resolved in risk_titles:
|
||||
if db.query(RiskAlert).filter(RiskAlert.title == title).first():
|
||||
continue
|
||||
db.add(
|
||||
RiskAlert(
|
||||
entity_type="directive",
|
||||
entity_id=str(uuid.uuid4()),
|
||||
aircraft_id=ac_id,
|
||||
severity=severity,
|
||||
title=title,
|
||||
message=title,
|
||||
due_at=datetime.now(timezone.utc) + timedelta(days=30),
|
||||
is_resolved=resolved,
|
||||
resolved_at=datetime.now(timezone.utc) if resolved else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: risk_alerts checked/created")
|
||||
|
||||
# ─── 6. Персонал ПЛГ (PLGSpecialist + PLGQualification) ──────────
|
||||
plg_org = db.query(Organization).filter(Organization.kind == "operator").first()
|
||||
if plg_org:
|
||||
specialists_data = [
|
||||
("Петрова Елена В.", "ПЕТРОВА-001", "Инспектор ЛГ", "I", "LIC-2024-001", "2026-12-01"),
|
||||
("Волков Алексей Н.", "ВОЛКОВ-001", "Инженер ТОиР", "B2", "LIC-2024-002", "2026-08-15"),
|
||||
("Морозова Ольга С.", "МОРОЗОВА-001", "Авиатехник B1", "B1", "LIC-2024-003", "2026-03-20"),
|
||||
("Козлов Дмитрий И.", "КОЗЛОВ-001", "Пилот CAT.A", "CAT-A", "LIC-2024-004", "2027-01-10"),
|
||||
]
|
||||
for full_name, personnel_number, position, category, license_no, expires in specialists_data:
|
||||
if db.query(PLGSpecialist).filter(PLGSpecialist.personnel_number == personnel_number).first():
|
||||
continue
|
||||
exp_dt = datetime.strptime(expires, "%Y-%m-%d").date()
|
||||
spec = PLGSpecialist(
|
||||
organization_id=str(plg_org.id),
|
||||
full_name=full_name,
|
||||
personnel_number=personnel_number,
|
||||
position=position,
|
||||
category=category,
|
||||
license_number=license_no,
|
||||
license_expires=exp_dt,
|
||||
status="active",
|
||||
)
|
||||
db.add(spec)
|
||||
db.flush()
|
||||
# одна квалификация на специалиста
|
||||
q = PLGQualification(
|
||||
specialist_id=spec.id,
|
||||
program_id="PQ-001",
|
||||
program_name=f"ПК по {position}",
|
||||
program_type="periodic",
|
||||
date_start=exp_dt - timedelta(days=365),
|
||||
date_end=exp_dt,
|
||||
hours_total=40,
|
||||
result="passed",
|
||||
next_due=exp_dt,
|
||||
)
|
||||
db.add(q)
|
||||
db.commit()
|
||||
logger.info("seed_full_demo: personnel PLG checked/created")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.exception("seed_full_demo failed: %s", e)
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
seed_full_demo()
|
||||
@ -72,6 +72,12 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Aircraft demo seed skipped: %s", e)
|
||||
try:
|
||||
from app.demo.seed_full_demo import seed_full_demo
|
||||
seed_full_demo()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Full demo seed skipped: %s", e)
|
||||
# Планировщик рисков (передаём app для shutdown hook)
|
||||
setup_scheduler(app)
|
||||
yield
|
||||
|
||||
144
components/AIAssistant.tsx
Normal file
144
components/AIAssistant.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { getAuthToken } from '@/lib/api/api-client';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function AIAssistant() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
role: 'assistant',
|
||||
content:
|
||||
'Здравствуйте! Я AI-помощник REFLY. Спросите меня о лётной годности, ТО, директивах или рисках.',
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
const userMsg = input.trim();
|
||||
setInput('');
|
||||
setMessages((prev) => [...prev, { role: 'user', content: userMsg }]);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token =
|
||||
typeof window !== 'undefined'
|
||||
? (getAuthToken() || document.cookie.match(/auth-token=([^;]+)/)?.[1] || 'dev')
|
||||
: 'dev';
|
||||
|
||||
const res = await fetch('/api/v1/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: userMsg }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('AI service unavailable');
|
||||
const data = await res.json();
|
||||
setMessages((prev) => [...prev, { role: 'assistant', content: data.reply }]);
|
||||
} catch {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Извините, не удалось получить ответ. Попробуйте позже.',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg hover:bg-blue-700 transition-all flex items-center justify-center text-2xl"
|
||||
title="AI Помощник"
|
||||
aria-label={isOpen ? 'Закрыть чат' : 'Открыть AI Помощник'}
|
||||
>
|
||||
{isOpen ? '✕' : '🤖'}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed bottom-24 right-6 z-50 w-96 max-w-[calc(100vw-3rem)] h-[500px] bg-white rounded-2xl shadow-2xl border border-gray-200 flex flex-col overflow-hidden"
|
||||
role="dialog"
|
||||
aria-label="Чат с AI-помощником"
|
||||
>
|
||||
<div className="bg-blue-600 text-white px-4 py-3 flex items-center gap-2">
|
||||
<span className="text-xl" aria-hidden>🤖</span>
|
||||
<div>
|
||||
<div className="font-semibold text-sm">AI Помощник REFLY</div>
|
||||
<div className="text-xs opacity-80">Контроль лётной годности</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] px-3 py-2 rounded-xl text-sm whitespace-pre-wrap ${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-600 text-white rounded-br-sm'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-sm'
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 px-3 py-2 rounded-xl text-sm text-gray-500">
|
||||
Думаю...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
||||
placeholder="Задайте вопрос..."
|
||||
className="flex-1 px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
aria-label="Сообщение"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
aria-label="Отправить"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user