Главная / Статьи / ИИ-чат на Flutter с Gemini: потоковые ответы и Riverpod

ИИ-чат на Flutter с Gemini: потоковые ответы и Riverpod

Собираем чат-бота на Flutter поверх Gemini API: потоковые ответы, память через ChatSession, состояние на Riverpod, безопасное обращение с API-ключом, типичные ошибки и практические советы.

1604 слов

Добавить в Flutter-приложение чат на Gemini API — один из самых быстрых способов сделать его «умным». Но по пути легко застрять на стриминге, работе с API-ключом или управлении состоянием. Ниже — полный разбор рабочего чат-бота с потоковыми ответами, от создания проекта до экрана чата, а также ошибки, на которых люди спотыкаются чаще всего, и привычки, которые стоит завести до релиза.

Почему такие интеграции идут не так

Большинство относится к ИИ-чату как к любому другому REST-вызову: отправил запрос, подождал, показал результат. С языковыми моделями это быстро разваливается, и причин четыре.

  • Ответы идут долго. Без стриминга после каждого сообщения пользователь примерно 3–8 секунд смотрит на пустой экран.
  • Состояние чата запутаннее обычного состояния приложения. Нужно одновременно отслеживать историю сообщений, флаги загрузки, ошибки и потоковые токены.
  • API-ключи прописывают прямо в коде. Многие руководства вставляют ключ Gemini прямо в приложение, а это рискованно: Flutter-приложения поставляются скомпилированными бинарниками, которые можно разреверсить.
  • Контекст пропадает. Если каждое сообщение отправлять отдельно, Gemini забывает предыдущие реплики.

В итоге приложение кажется тормозным, небезопасным или туповатым, хотя модель за ним сильная.

План

Вот подход, который используется ниже:

  1. Общаться с Gemini через официальный Dart-пакет google_generative_ai.
  2. Стримить ответ токен за токеном, чтобы это выглядело как набор текста человеком.
  3. Хранить историю в ChatSession Gemini, чтобы контекст сохранялся.
  4. Управлять состоянием через Riverpod, чтобы UI оставался реактивным и легко тестировался.
  5. Не держать API-ключ в клиенте: конфигурация через окружение, а в продакшене — прокси на бэкенде.

Результат ведёт себя как ChatGPT, а не как медленная форма «запрос — ответ».

Шаг 1: создаём проект

Создайте новый Flutter-проект или возьмите уже существующий:

flutter create ai_chat_gemini
cd ai_chat_gemini

Получите API-ключ Gemini в Google AI Studio на aistudio.google.com, затем объявите зависимости:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  google_generative_ai: ^0.4.6
  flutter_riverpod: ^2.5.1
  flutter_dotenv: ^5.1.0

Загрузите их:

flutter pub get

Шаг 2: настраиваем ключ

Храните ключ в файле .env, а не в коде, и сразу добавьте этот файл в .gitignore.

# .env
GEMINI_API_KEY=your_api_key_here

Загрузите его в main.dart, начиная с импортов:

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'chat_screen.dart';

Затем инициализируйте dotenv до запуска приложения, обернув его в ProviderScope:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load(fileName: ".env");
  runApp(const ProviderScope(child: MyApp()));
}class MyApp extends StatelessWidget {
  const MyApp({super.key});  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Gemini AI Chat',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const ChatScreen(),
    );
  }
}

Предупреждение для всего публичного: вообще не поставляйте ключ Gemini внутри клиента. Отправляйте запросы через небольшой бэкенд, например Firebase Cloud Functions или простой эндпоинт на Node или Laravel, чтобы ключ оставался на сервере. То, что собрано здесь, годится для прототипов и внутренних инструментов.

Шаг 3: основной код

Модель сообщения

Каждое сообщение содержит текст, отправителя и признак того, идёт ли ещё стриминг.

// lib/models/chat_message.dart
class ChatMessage {
  final String text;
  final bool isUser;
  final bool isStreaming;

Метод copyWith позволяет обновлять только текст или флаг стриминга:

  const ChatMessage({
    required this.text,
    required this.isUser,
    this.isStreaming = false,
  });  ChatMessage copyWith({String? text, bool? isStreaming}) {
    return ChatMessage(
      text: text ?? this.text,
      isUser: isUser,
      isStreaming: isStreaming ?? this.isStreaming,
    );
  }
}

Сервис Gemini

Этот фрагмент оборачивает ChatSession Gemini, чтобы контекст разговора сохранялся между сообщениями.

// lib/services/gemini_service.dart
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';

Класс создаёт модель с gemini-1.5-flash, запускает чат, отдаёт поток текстовых фрагментов и предоставляет метод сброса:

class GeminiService {
  late final GenerativeModel _model;
  late ChatSession _chatSession;  GeminiService() {
    _model = GenerativeModel(
      model: 'gemini-1.5-flash',
      apiKey: dotenv.env['GEMINI_API_KEY']!,
    );
    _chatSession = _model.startChat();
  }  Stream<String> sendMessageStream(String message) async* {
    final response = _chatSession.sendMessageStream(
      Content.text(message),
    );    await for (final chunk in response) {
      if (chunk.text != null) {
        yield chunk.text!;
      }
    }
  }  void resetChat() {
    _chatSession = _model.startChat();
  }
}

Главная деталь: startChat() возвращает ChatSession, который сам отслеживает историю сообщений. Gemini помнит предыдущие реплики, и вам не нужно каждый раз заново отправлять весь разговор.

Состояние на Riverpod

// lib/providers/chat_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/chat_message.dart';
import '../services/gemini_service.dart';

Notifier добавляет сообщение пользователя, затем пустое сообщение бота с флагом стриминга. По мере прихода фрагментов он дописывает их только в это последнее сообщение, а когда поток заканчивается, снимает флаг:

final geminiServiceProvider = Provider((ref) => GeminiService());class ChatNotifier extends StateNotifier<List<ChatMessage>> {
  final GeminiService _geminiService;  ChatNotifier(this._geminiService) : super([]);  Future<void> sendMessage(String text) async {
    state = [...state, ChatMessage(text: text, isUser: true)];    final botMessage = ChatMessage(text: '', isUser: false, isStreaming: true);
    state = [...state, botMessage];    String fullResponse = '';    await for (final chunk in _geminiService.sendMessageStream(text)) {
      fullResponse += chunk;
      final updated = [...state];
      updated[updated.length - 1] = updated.last.copyWith(
        text: fullResponse,
        isStreaming: true,
      );
      state = updated;
    }    final finalList = [...state];
    finalList[finalList.length - 1] =
        finalList.last.copyWith(isStreaming: false);
    state = finalList;
  }
}final chatProvider =
    StateNotifierProvider<ChatNotifier, List<ChatMessage>>((ref) {
  return ChatNotifier(ref.read(geminiServiceProvider));
});

Шаг 4: экран чата

// lib/chat_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/chat_provider.dart';

Экран следит за списком сообщений и рисует прокручиваемый список пузырей: сообщения пользователя справа, цветом индиго, сообщения ИИ слева, серым, а внизу — текстовое поле и кнопка отправки:

class ChatScreen extends ConsumerStatefulWidget {
  const ChatScreen({super.key});  @override
  ConsumerState<ChatScreen> createState() => _ChatScreenState();
}class _ChatScreenState extends ConsumerState<ChatScreen> {
  final _controller = TextEditingController();
  final _scrollController = ScrollController();  void _handleSend() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;    ref.read(chatProvider.notifier).sendMessage(text);
    _controller.clear();
  }  @override
  Widget build(BuildContext context) {
    final messages = ref.watch(chatProvider);    return Scaffold(
      appBar: AppBar(title: const Text('Gemini AI Chat')),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.all(12),
              itemCount: messages.length,
              itemBuilder: (context, index) {
                final message = messages[index];
                return Align(
                  alignment: message.isUser
                      ? Alignment.centerRight
                      : Alignment.centerLeft,
                  child: Container(
                    margin: const EdgeInsets.symmetric(vertical: 4),
                    padding: const EdgeInsets.all(12),
                    constraints: BoxConstraints(
                      maxWidth: MediaQuery.of(context).size.width * 0.75,
                    ),
                    decoration: BoxDecoration(
                      color: message.isUser
                          ? Colors.indigo
                          : Colors.grey.shade200,
                      borderRadius: BorderRadius.circular(14),
                    ),
                    child: Text(
                      message.text.isEmpty ? '...' : message.text,
                      style: TextStyle(
                        color: message.isUser ? Colors.white : Colors.black87,
                      ),
                    ),
                  ),
                );
              },
            ),
          ),
          SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      decoration: const InputDecoration(
                        hintText: 'Ask something...',
                        border: OutlineInputBorder(),
                      ),
                      onSubmitted: (_) => _handleSend(),
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.send),
                    onPressed: _handleSend,
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Так вы получаете потоковый UI чата с Gemini и чистое разделение интерфейса и бизнес-логики.

Ошибки, которые повторяются снова и снова

Ключ прямо в коде. Любой, кто декомпилирует APK или IPA, сможет его прочитать. Используйте переменные окружения в разработке и прокси на бэкенде в продакшене.

Отказ от ChatSession. Изолированные вызовы generateContent() не дают Gemini памяти о прошлых сообщениях. Используйте startChat(), чтобы контекст сохранялся.

Игнорирование стриминга. Если ждать полного ответа, прежде чем что-то нарисовать, приложение кажется медленным, а показ токенов по мере поступления заметно улучшает ощущаемую скорость.

Перестройка всего списка на каждый токен. Небрежные обновления состояния вызывают дорогие перестройки на каждый фрагмент. Обновляйте точечно, как в notifier выше, который трогает только последнее сообщение.

Нет обработки ошибок. Лимиты запросов и сбои сети случаются. Оберните sendMessageStream в try/catch и предложите повторить попытку, вместо того чтобы молча падать.

Привычки, которые стоит завести

  • Сделайте debounce для отправки, чтобы нельзя было долбить кнопку, пока стримится ответ.
  • Ограничьте историю разговора. Длинные сессии увеличивают расход токенов и стоимость, так что при необходимости обрезайте старые сообщения.
  • Берите gemini-1.5-flash ради скорости и gemini-1.5-pro для более сложных рассуждений. Для большинства чатов Flash дешевле и быстрее.
  • Добавьте кнопку «стоп», которая отменяет подписку на поток, когда пользователь хочет оборвать длинный ответ.
  • Сохраняйте историю локально в Hive или SQLite, чтобы чаты переживали перезапуск.
  • Используйте чистую архитектуру с сервисом, notifier'ами и UI в разных слоях, чтобы позже переход на OpenAI или Claude не требовал переписывать интерфейс.

Где пригодится этот шаблон

Та же структура подходит SaaS-приложениям, которые добавляют встроенного ассистента для поддержки или онбординга, мобильным приложениям с помощниками для письма, суммаризаторами или вопросами-ответами, админ-панелям, где сотрудники запрашивают данные обычным языком, и любой системе, которой нужен лёгкий чат-бот без развёртывания ML-инфраструктуры. Поскольку сервис Gemini изолирован от UI, экран чата легко переиспользовать для бота поддержки, внутреннего ассистента по базе знаний или нишевого ИИ-инструмента внутри большого приложения.

Итоги

Теперь у вас есть потоковый чат-бот на Gemini во Flutter с нормальной работой с состоянием, контекстом разговора и отзывчивым UI. Запомните четыре вещи: используйте ChatSession для контекста, стримьте, а не ждите, не держите API-ключ в клиенте в продакшене и отделяйте логику Gemini от UI с помощью Riverpod или любого другого менеджера состояния. Этой основы хватит для релиза, и её легко расширить до сохранения истории, нескольких моделей или собственных системных промптов.