Перейти до змісту

ESWF-Chat — AI-асистент + Корпоративний Месенджер

Дата: 2026-02-20 Модель: OpenRouter → anthropic/claude-3.5-sonnet (за замовчуванням) Статус: ✅ AI-чат реалізовано (5 етапів) + ✅ Месенджер реалізовано (Фази 1-5)

НОВИНКА: Django Channels + WebSocket DM між користувачами. Запуск через Daphne: python -m daphne eswf.asgi:application


Загальна архітектура

┌─────────────────────────────────────────────────────────┐
│  Frontend (React + Mantine)                             │
│                                                         │
│  ChatFAB (FAB-кнопка)                                   │
│    └─► ChatDrawer (компактна бічна панель)              │
│    └─► /chat → ChatPage (повноекранний чат)             │
│                                                         │
│  useChatStream hook                                     │
│    └─► fetch SSE  POST /api/v1/chat/sessions/{id}/stream│
└───────────────────────────┬─────────────────────────────┘
                            │ SSE (text/event-stream)
┌───────────────────────────▼─────────────────────────────┐
│  Backend (Django REST Framework)                        │
│                                                         │
│  ChatSessionViewSet                                     │
│    ├─ CRUD сесій та повідомлень                         │
│    └─ stream action → StreamingHttpResponse             │
│         ├─ Phase 0: зберегти user message               │
│         ├─ Phase 1: non-stream виклик з tools           │
│         ├─ Phase 2: виконати DOP-інструменти            │
│         └─ Phase 3: stream фінальної відповіді          │
│                                                         │
│  llm.py → OpenRouter API                               │
│  tools.py → ORM-запити до DOP-моделей                  │
└─────────────────────────────────────────────────────────┘

Етап 3 — Frontend: базовий Chat UI

Виконано першим, щоб одразу мати UI для тестування

Що зроблено

Нові файли: - frontend/erp/src/api/chat.ts — API-клієнт для чату - frontend/erp/src/pages/ChatPage.tsx — повноекранна сторінка чату - frontend/erp/src/components/Chat/ChatDrawer.tsx — компактна бічна панель

Змінені файли: - frontend/erp/src/App.tsx — додано маршрут /chat - frontend/erp/src/components/Layout/AppLayout.tsx — додано <ChatDrawer /> - frontend/erp/src/components/Layout/Header.tsx — кнопка IconMessage → відкриває Drawer - frontend/erp/src/store/uiStore.ts — стан chatDrawerOpen, методи toggleChatDrawer, setChatDrawerOpen - frontend/erp/src/i18n/locales/en.json + ua.json — ключі секції chat

ChatPage (/chat)

Двопанельний layout: - Ліва панель (220px) — список сесій: назва, дата, кнопки видалення та перейменування - Права панель — повідомлення + поле вводу

Особливості: - Бульбашки повідомлень: user (синя, справа) / assistant (сіра, зліва) з аватарами - Вибір моделі через <Select> у хедері чату - Ctrl+Enter для надсилання - Кнопка "Згорнути в панель" → setChatDrawerOpen(true) + navigate(-1)

ChatDrawer

Mantine <Drawer position="right">: - Ширина регулюється мишею (300–860px) за рахунок handle на лівому краю - Handle: onMouseDownmousemove/mouseup на window, delta = startX - currentX - Кнопка розгортання → navigate('/chat')

api/chat.ts

// Основні методи:
getSessions()           GET  /chat/sessions/
createSession()         POST /chat/sessions/
updateSession(id, data) PATCH /chat/sessions/{id}/
deleteSession(id)       DELETE /chat/sessions/{id}/
getMessages(sessionId)  GET  /chat/sessions/{id}/messages/
sendMessage(id, text)   POST /chat/sessions/{id}/messages/

// Константи
CHAT_MODELS = [
  { value: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet' },
  { value: 'openai/gpt-4o',               label: 'GPT-4o' },
  { value: 'meta-llama/llama-3.1-8b-instruct:free', label: 'Llama 3.1 8B (free)' },
]

Обробка пагінованої відповіді DRF:

Array.isArray(r.data) ? r.data : r.data.results


Етап 1 — Backend: інтеграція OpenRouter

Що зроблено

Нові файли: - backend/eswf_chat/llm.py — LLM-клієнт - backend/.envOPENROUTER_API_KEY=sk-or-v1-... - backend/.env.example — шаблон

Змінені файли: - backend/eswf/settings/base.pyOPENROUTER_API_KEY = os.environ.get(...) - backend/requirements.txt — додано requests>=2.31,<3.0 - backend/eswf_chat/views.pysend_message використовує call_openrouter_with_tools

llm.py — структура

OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MAX_TOOL_ROUNDS = 3        # максимум раундів інструментів
REQUEST_TIMEOUT = 60       # секунд

SYSTEM_PROMPT = """
You are an AI assistant embedded in ESWF DOP system.
You can access real DOP data via tools. Tenant ID: {tenant_id}.
Always respond in the language the user writes in.
"""

# Функції:
_get_headers()                dict із Authorization Bearer
_post(headers, payload)       dict (розпарсена відповідь JSON)
build_messages(history, content)  list[dict] для OpenRouter
call_openrouter(model, messages)  str (проста відповідь)
call_openrouter_with_tools(model, messages, tenant_id)  dict
generate_title(content)       str (40 символів, без tools)
stream_response(model, messages, headers)  Generator[str]

call_openrouter_with_tools

Цикл до MAX_TOOL_ROUNDS: 1. POST до OpenRouter з tools + tool_choice: "auto" 2. Якщо finish_reason == "tool_calls" → виконати всі tool_calls 3. Додати результати (role: "tool") до messages 4. Повторити 5. Повернути фінальний content + метадані tool_calls, tool_results, tokens

stream_response

def stream_response(model, messages, headers):
    payload = {, "stream": True}
    with requests.post(, stream=True) as resp:
        for raw_line in resp.iter_lines():
            line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line
            if not line.startswith("data: "): continue
            if line[6:].strip() == "[DONE]": return
            token = json.loads(line[6:])["choices"][0]["delta"].get("content") or ""
            if token: yield token

Етап 2 — Backend: Tool Use (DOP-інструменти)

Що зроблено

Новий файл: backend/eswf_chat/tools.py

tools.py — структура

6 інструментів у форматі OpenAI function calling:

Інструмент Що робить
search_clients Пошук клієнтів за рядком, повертає ≤10
get_invoices Список накладних з фільтрами (статус, ліміт)
get_invoice_detail Деталі накладної + рядки
search_vehicles Пошук ТЗ за держномером / маркою
get_waybills Путні листи з фільтром дати
get_erp_stats Статистика по tenant: клієнти, накладні, ТЗ
DOP_TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "search_clients",
            "description": "Search clients/customers by name",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string", "description": "Search term"},
                    "limit": {"type": "integer", "default": 10},
                },
                "required": ["query"],
            },
        },
    },
    # … інші 5 інструментів
]

def execute_tool(name: str, arguments: dict, tenant_id: int) -> str:
    """Dispatcher → виклик відповідного ORM-запиту."""
    if name == "search_clients": return _search_clients(arguments, tenant_id)
    # …
    return json.dumps({"error": f"Unknown tool: {name}"})

Всі інструменти: - Фільтруються по tenant_id (multi-tenancy) - Повертають JSON-рядок - Обгорнуті в try/except ImportError для graceful unavailability


Етап 4 — Frontend: SSE Streaming + Tool UI

Що зроблено

Нові файли: - frontend/erp/src/hooks/useChatStream.ts — хук для SSE-стримінгу

Змінені файли: - backend/eswf_chat/views.py — SSE endpoint stream - frontend/erp/src/pages/ChatPage.tsx — стримінг-UI + tool-індикатори - frontend/erp/src/components/Chat/ChatDrawer.tsx — те саме

Backend: SSE endpoint

POST /api/v1/chat/sessions/{id}/stream/
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no

SSE events (формат data: {json}\n\n):

Event Поля Коли
user_message message Одразу після збереження
tool_start tool Перед виконанням інструменту
tool_done tool Після виконання
token content Кожен токен від LLM
done assistant_message, session Все завершено
error message При помилці

Трифазовий алгоритм:

Phase 0: зберегти user message → emit user_message
Phase 1: non-streaming POST з tools → detect tool_calls
Phase 2: виконати tools (до 3 раундів) → emit tool_start / tool_done
Phase 3: якщо tools були → streaming POST → emit token
         якщо tools не були → content з Phase 1 → emit token (одним блоком)
emit done

Frontend: useChatStream hook

export function useChatStream(
  sessionId: number | null,
  callbacks?: { onDone?: () => void; onError?: (msg: string) => void }
)  { sendMessage, isStreaming, streamingContent, activeTools, abort }

Ключові рішення: - callbacksRef патерн — callbacks не в deps useCallback, щоб не перестворювати sendMessage - Читання SSE: fetch + ReadableStream + TextDecoder + буфер для неповних блоків - qc.invalidateQueries при done → автоматичний рефетч повідомлень

Streaming UI

Оптимістичне user-повідомлення:

// Показується одразу при надсиланні
const [pendingUserContent, setPendingUserContent] = useState<string | null>(null);

// Зникає коли з'являється в рефетченому списку
const showOptimisticUser = pendingUserContent !== null &&
  !messages.some(m => m.role === 'user' && m.content === pendingUserContent);

Tool-індикатори: - Spinner + orange badge поки status === 'running' - Checkmark + teal badge після status === 'done'

Streaming bubble: - Поки немає токенів і є tools: IconWand + "Використовую інструменти..." - Поки немає нічого: <Loader type="dots" /> - Є токени: plain text + курсор


Етап 5 — Інтеграція та полірування

Що зроблено

Нові файли: - frontend/erp/src/components/Chat/AssistantMessage.tsx — Markdown renderer - frontend/erp/src/components/Chat/ChatSuggestions.tsx — контекстні підказки - frontend/erp/src/components/Chat/ChatFAB.tsx — floating action button - frontend/erp/src/components/Chat/ChatFAB.module.css — анімація пульсації

Змінені файли: - frontend/erp/src/pages/ChatPage.tsx — Markdown + rename + suggestions - frontend/erp/src/components/Chat/ChatDrawer.tsx — те саме - frontend/erp/src/components/Layout/AppLayout.tsx<ChatFAB /> - frontend/erp/src/i18n/locales/en.json + ua.json — нові ключі

5.1 Markdown Rendering

Пакети: react-markdown@8.0.7 + remark-gfm@3.0.1

// AssistantMessage.tsx
<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  components={{
    p:           <Text size={size} />
    code:        inline ? <Code> : <Code block>
    h1/h2/h3:   <Text fw={700/600}>
    ul/ol/li:   <Box component="ul/ol/li">
    a:           <Anchor target="_blank">
    table/th/td  styled <table> з overflow-x: auto
    blockquote   Box з borderLeft
  }}
>

Стратегія: - Markdown тільки для збережених assistant messages - Streaming bubble — plain text + (markdown при неповному тексті дає артефакти) - User messages — завжди plain text (users не пишуть markdown)

5.2 Session Rename

ChatPage (sidebar): - SessionItem — локальний стан [editing, setEditing] - Тригер: подвійний клік на назві або кнопка IconPencil - <TextInput autoFocus> → Enter/blur → save → chatApi.updateSession

ChatDrawer (header): - Стан [isRenaming, setIsRenaming] в компоненті - IconPencil кнопка → замінює рядок header на TextInput - Escape → скасування, Enter/blur → збереження

5.3 Context-Aware Suggestions

// ChatSuggestions.tsx
const SUGGESTIONS = {
  essentials: {
    en: ['Show recent invoices', 'Active clients count', ...],
    ua: ['Останні накладні', 'Активні клієнти', ...],
  },
  fleet: { en: [...], ua: [...] },
  logistic: { ... },
  registers: { ... },
  // default: для /dashboard та інших
}

const section = pathname.split('/')[1] || '';
const lang = i18n.language.startsWith('ua') ? 'ua' : 'en';
  • Відображається тільки коли сесія порожня (messages.length === 0)
  • Клік на чіп → setInput(prompt) (pre-fill, не auto-send)
  • Автоматично перемикається між en/ua

5.4 Floating Chat Button (FAB)

// ChatFAB.tsx
if (pathname === '/chat') return null; // прихований на повній сторінці

const bottom = hasTabs ? 16 + 36 + 4 : 16; // враховує TabBar (36px)
  • position: fixed, bottom, right: 16, zIndex: 150
  • Коли drawer закритий: IconMessage + CSS анімація пульсації
  • Коли drawer відкритий: IconX (без анімації)
  • zIndex: 150 — під Drawer overlay (200), над контентом

CSS анімація (ChatFAB.module.css):

@keyframes chatPulse {
  0%   { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0.45); }
  70%  { box-shadow: 0 0 0 12px rgba(34, 139, 230, 0); }
  100% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0); }
}
.pulse { animation: chatPulse 2.4s ease-out infinite; }


Повний список файлів

Backend (нові)

Файл Призначення
backend/eswf_chat/llm.py OpenRouter клієнт, stream_response
backend/eswf_chat/tools.py 6 DOP-інструментів + execute_tool
backend/.env API ключ
backend/.env.example Шаблон

Backend (змінені)

Файл Що змінено
backend/eswf_chat/views.py stream SSE action, json+StreamingHttpResponse
backend/eswf/settings/base.py OPENROUTER_API_KEY
backend/requirements.txt requests>=2.31

Frontend (нові)

Файл Призначення
src/api/chat.ts API клієнт для ChatSession/Message
src/pages/ChatPage.tsx Повноекранний чат
src/components/Chat/ChatDrawer.tsx Компактна панель (Drawer)
src/components/Chat/AssistantMessage.tsx Markdown renderer
src/components/Chat/ChatSuggestions.tsx Контекстні підказки
src/components/Chat/ChatFAB.tsx FAB кнопка
src/components/Chat/ChatFAB.module.css Анімація пульсації
src/hooks/useChatStream.ts SSE streaming хук

Frontend (змінені)

Файл Що змінено
src/App.tsx Маршрут /chat
src/components/Layout/AppLayout.tsx <ChatDrawer />, <ChatFAB />
src/components/Layout/Header.tsx Кнопка IconMessage → toggleChatDrawer
src/store/uiStore.ts chatDrawerOpen, toggle/set методи
src/i18n/locales/en.json Секція chat.* (20+ ключів)
src/i18n/locales/ua.json Те саме, українською

Ключові технічні рішення

Чому SSE, а не WebSocket?

SSE (Server-Sent Events) — одностороннє з'єднання сервер→клієнт поверх HTTP/1.1. Для стримінгу тексту це ідеально: простіше налаштувати з Django (StreamingHttpResponse), не потребує додаткового сервера або каналів. WebSocket виправданий тільки для двонаправленого real-time (наприклад, collaborative editing).

Чому трифазовий алгоритм (non-stream → tools → stream)?

OpenRouter streaming з одночасним tool-use погано підтримується деякими моделями. Надійніше: 1. Зробити non-streaming виклик для детектування tool_calls (швидко — тільки tool definitions) 2. Виконати tools синхронно, слати SSE-події 3. Зробити streaming виклик для фінального тексту

Чому callbacksRef у useChatStream?

const cbRef = useRef(callbacks);
cbRef.current = callbacks; // завжди актуально
Без цього sendMessage (useCallback) доведеться додавати callbacks у deps → при кожному рендері нові об'єкти → постійне перестворення. З refsendMessage стабільний, але callbacks завжди свіжі.

Чому оптимістичне user-повідомлення?

При стримінгу backend зберігає user message у Phase 0, але фронтенд дізнається про це тільки після done → invalidate → refetch. Без оптимістичного UI user бачив би: надіслав → порожньо → з'явилось. З pendingUserContentнадіслав → одразу бачить своє повідомлення → стримінг відповіді.

Markdown тільки для збережених повідомлень

Під час стримінгу контент частковий → ReactMarkdown може видавати артефакти (незакритий **, половина таблиці тощо). Plain text + дає чіткий сигнал що відповідь ще генерується. Після done → повідомлення зберігається → відображається через <AssistantMessage> з повним Markdown.


Залежності (додані)

Backend

requests>=2.31,<3.0    # HTTP-клієнт для OpenRouter

Frontend

react-markdown@^8.0.7  # Markdown рендеринг
remark-gfm@^3.0.1      # GitHub Flavored Markdown (таблиці, strikethrough)

Конфігурація

OpenRouter API Key

# backend/.env
OPENROUTER_API_KEY=sk-or-v1-...

Моделі (chat.ts)

export const CHAT_MODELS = [
  { value: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet' },
  { value: 'openai/gpt-4o',               label: 'GPT-4o' },
  { value: 'meta-llama/llama-3.1-8b-instruct:free', label: 'Llama 3.1 8B (free)' },
];

DOP-інструменти (tools.py)

# Доступні tools для LLM:
search_clients(query, limit)
get_invoices(status, limit, date_from, date_to)
get_invoice_detail(invoice_id)
search_vehicles(query, limit)
get_waybills(status, limit, date_from)
get_erp_stats()

Що можна додати в майбутньому

Функція Складність
Drag-to-expand drawer (touch) низька
Badge з кількістю непрочитаних середня
Експорт сесії в PDF/TXT середня
Кастомний system prompt per session низька
Прикріплення файлів (documents, images) висока
Голосовий ввід (Web Speech API) середня
Нові DOP-інструменти (warehouses, documents) низька
Streaming з tool-use за один запит (OpenAI format) висока
Пошук по всіх сесіях середня