Перейти к основному содержимому

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;
}

Лимиты сообщений

Разные тарифы имеют разные лимиты:

ТарифЛимит в день
FREE5 сообщений
LEARNER50 сообщений
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'] // Лимиты пользователя