Chat
AI-ассистент для помощи в обучении с SSE streaming.
Структура
src/features/chat/
├── api/
│ ├── queries.ts # fetchChatHistory()
│ └── mutations.ts # saveChatMessage()
├── hooks/
│ ├── use-streaming-chat.ts # SSE streaming
│ └── use-chat-history.ts # История чатов
├── components/
│ ├── chat-message.tsx # Bubble сообщения
│ ├── chat-input.tsx # Input форма
│ ├── chat-panel.tsx # Контейнер чата
│ ├── chat-fab.tsx # Плавающая кнопка
│ ├── typing-indicator.tsx # Анимация "печатает..."
│ ├── limit-banner.tsx # Баннер лимита
│ └── quick-actions.tsx # Быстрые действия
├── types.ts
└── index.ts
Доменная модель
ChatMessage
├── id: string
├── lessonId: string
├── userId: string
├── role: 'user' | 'assistant'
├── content: string
├── createdAt: Date
SSE Streaming
Используем expo/fetch для поддержки ReadableStream:
// src/features/chat/hooks/use-streaming-chat.ts
import { fetch as expoFetch } from 'expo/fetch';
export function useStreamingChat(lessonId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = async (content: string) => {
// Добавить сообщение пользователя
const userMessage = { role: 'user', content };
setMessages(prev => [...prev, userMessage]);
setIsStreaming(true);
try {
const response = await expoFetch(`${API_URL}/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
lessonId,
message: content,
}),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let assistantMessage = '';
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
assistantMessage += data.content;
// Обновить сообщение в реальном времени
setMessages(prev => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last?.role === 'assistant') {
last.content = assistantMessage;
} else {
updated.push({ role: 'assistant', content: assistantMessage });
}
return updated;
});
}
}
}
} finally {
setIsStreaming(false);
}
};
return { messages, sendMessage, isStreaming };
}
Компоненты
ChatPanel
Полный UI чата:
function ChatPanel({ lessonId }) {
const { messages, sendMessage, isStreaming } = useStreamingChat(lessonId);
const { data: history } = useChatHistory(lessonId);
const allMessages = [...(history || []), ...messages];
return (
<View className="flex-1">
<FlatList
data={allMessages}
renderItem={({ item }) => <ChatMessage message={item} />}
inverted
/>
{isStreaming && <TypingIndicator />}
<ChatInput
onSend={sendMessage}
disabled={isStreaming}
/>
</View>
);
}
ChatFAB
Плавающая кнопка для открытия чата:
function LessonScreen() {
const [chatVisible, setChatVisible] = useState(false);
return (
<View className="flex-1">
<LessonContent />
<ChatFAB onPress={() => setChatVisible(true)} />
<Modal visible={chatVisible}>
<ChatPanel lessonId={lessonId} />
</Modal>
</View>
);
}
QuickActions
Предустановленные вопросы:
<QuickActions
actions={[
{ label: 'Объясни проще', prompt: 'Объясни это проще' },
{ label: 'Приведи пример', prompt: 'Приведи практический пример' },
{ label: 'Почему это важно?', prompt: 'Почему это важно знать?' },
]}
onSelect={(prompt) => sendMessage(prompt)}
/>
Zustand Store
chat-store.ts хранит состояние чата:
interface ChatStore {
messages: ChatMessage[];
isStreaming: boolean;
messagesUsedToday: number;
dailyLimit: number;
addMessage: (message: ChatMessage) => void;
setStreaming: (isStreaming: boolean) => void;
clearMessages: () => void;
}
Лимиты сообщений
Разные тарифы имеют разные лимиты:
| Тариф | Лимит в день |
|---|---|
| FREE | 5 сообщений |
| LEARNER | 50 сообщений |
| PRO | Безлимит |
| TEAM | Безлимит |
function ChatInput({ onSend }) {
const { messagesUsedToday, dailyLimit } = useChatStore();
const isLimitReached = messagesUsedToday >= dailyLimit;
if (isLimitReached) {
return <LimitBanner />;
}
return <TextInput onSubmitEditing={onSend} />;
}
Query Keys
['chat', lessonId, 'history'] // История чата урока
['chat', 'limits'] // Лимиты пользователя