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: onMouseDown → mousemove/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:
Етап 1 — Backend: інтеграція OpenRouter¶
Що зроблено¶
Нові файли:
- backend/eswf_chat/llm.py — LLM-клієнт
- backend/.env — OPENROUTER_API_KEY=sk-or-v1-...
- backend/.env.example — шаблон
Змінені файли:
- backend/eswf/settings/base.py — OPENROUTER_API_KEY = os.environ.get(...)
- backend/requirements.txt — додано requests>=2.31,<3.0
- backend/eswf_chat/views.py — send_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?¶
Без цьогоsendMessage (useCallback) доведеться додавати callbacks у deps → при кожному рендері нові об'єкти → постійне перестворення. З ref — sendMessage стабільний, але callbacks завжди свіжі.
Чому оптимістичне user-повідомлення?¶
При стримінгу backend зберігає user message у Phase 0, але фронтенд дізнається про це тільки після done → invalidate → refetch. Без оптимістичного UI user бачив би: надіслав → порожньо → з'явилось. З pendingUserContent — надіслав → одразу бачить своє повідомлення → стримінг відповіді.
Markdown тільки для збережених повідомлень¶
Під час стримінгу контент частковий → ReactMarkdown може видавати артефакти (незакритий **, половина таблиці тощо). Plain text + ▌ дає чіткий сигнал що відповідь ще генерується. Після done → повідомлення зберігається → відображається через <AssistantMessage> з повним Markdown.
Залежності (додані)¶
Backend¶
Frontend¶
react-markdown@^8.0.7 # Markdown рендеринг
remark-gfm@^3.0.1 # GitHub Flavored Markdown (таблиці, strikethrough)
Конфігурація¶
OpenRouter API Key¶
Моделі (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) | висока |
| Пошук по всіх сесіях | середня |