【筆記】在 Flutter 運行 Gemma 本地 AI 模型

Gemma:在任何手機裝置皆能運行,支援多模態(文字、影像)。

步驟1:安裝 flutter_gemma 套件

Package 連結:https://pub.dev/packages/flutter_gemma

flutter_gemma: ^0.9.0

在「assets/models/」資料夾中放入「gemma-3n-E2B-it-int4.task」。
注意:此檔案約 3.14 GB

  assets:
    - assets/models/

步驟2:系統設定

2-1 Error: The plugin “flutter_gemma” requires a higher minimum iOS deployment version than your application is targeting. To build, increase your application’s deployment target to at least 16.0

ios/Podfile
platform :ios, '16.0'

2-2 Error: The ‘Pods-Runner’ target has transitive dependencies that include statically linked binaries: (…MediaPipeTasksGenAI.xcframework…)

ios/Podfile
// use_frameworks!
use_frameworks! :linkage => :static

2-3. Info.plist

Info.plist
<key>UIFileSharingEnabled</key>
<true/>

步驟3:程式碼

main.dart
import 'package:flutter/material.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma/core/chat.dart';
import 'package:flutter_gemma/core/model.dart';
import 'package:flutter_gemma/pigeon.g.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        brightness: Brightness.dark,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          brightness: Brightness.dark,
        ),
      ),
      debugShowCheckedModeBanner: false,
      home: const ChatScreen(),
    );
  }
}

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  // 1. 狀態變數
  final _gemma = FlutterGemmaPlugin.instance;

  // 保留 InferenceModel 和 InferenceChat 的實例,以便後續操作與釋放資源
  InferenceModel? _inferenceModel;
  InferenceChat? _chat;

  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  // 儲存對話訊息的列表
  final List<Map<String, dynamic>> _messages = [];
  bool _isLoading = true; // 用於顯示模型載入狀態
  bool _isReplying = false; // 用於顯示 Gemma 是否正在回覆

  // 2. 初始化
  @override
  void initState() {
    super.initState();
    _initializeGemma();
  }

  Future<void> _initializeGemma() async {
    try {
      // 指定模型檔案的 assets 路徑
      const modelAssetPath = 'models/gemma-3n-E2B-it-int4.task';

      // 直接呼叫 install 方法,它會處理將 asset 複製到可執行目錄的邏輯。
      // 這個方法會自動檢查檔案是否已存在,若存在則不會重複複製。
      await _gemma.modelManager.installModelFromAsset(modelAssetPath);

      // 在安裝後,設定模型路徑,讓 createModel 知道要用哪個模型
      await _gemma.modelManager.setModelPath(modelAssetPath);

      // 建立模型實例,並將其存到 _inferenceModel 變數中
      _inferenceModel = await _gemma.createModel(
        modelType: ModelType.gemmaIt, // Gemma 指令調整模型
        preferredBackend: PreferredBackend.gpu, // 優先使用 GPU 以獲得更好效能
        supportImage: false, // 本範例不使用圖片功能
      );

      // 使用 _inferenceModel 建立對話實例
      _chat = await _inferenceModel!.createChat();

      // 更新 UI,顯示歡迎訊息
      setState(() {
        _isLoading = false;
        _messages.add({
          'text': '模型已載入!你好,我是 Gemma。有什麼可以幫助你的嗎?',
          'isUser': false,
        });
      });
    } catch (e) {
      // 處理初始化過程中的錯誤
      setState(() {
        _isLoading = false;
        _messages.add({'text': '模型載入失敗: $e', 'isUser': false});
      });
      debugPrint('初始化 Gemma 時發生錯誤: $e');
    }
  }

  // 4. 處理訊息發送
  Future<void> _sendMessage() async {
    final queryText = _textController.text.trim();
    // 如果輸入為空或正在回覆中,則不執行
    if (queryText.isEmpty || _chat == null) return;

    // 清空輸入框並更新 UI
    _textController.clear();
    setState(() {
      _messages.add({'text': queryText, 'isUser': true});
      _isReplying = true; // 開始等待回覆
      _messages.add({'text': '...', 'isUser': false}); // 添加一個 "正在輸入..." 的佔位符
    });
    _scrollToBottom();

    try {
      // 將使用者輸入加入對話
      await _chat!.addQueryChunk(Message.text(text: queryText, isUser: true));

      String currentResponse = "";
      // 使用異步串流接收回應,逐字更新 UI
      await for (final token in _chat!.generateChatResponseAsync()) {
        setState(() {
          currentResponse += token;
          // 更新最後一條訊息 (Gemma 的回覆)
          _messages.last['text'] = currentResponse;
        });
        _scrollToBottom();
      }
    } catch (e) {
      // 處理生成回應時的錯誤
      setState(() {
        _messages.last['text'] = '發生錯誤: $e';
      });
      debugPrint('生成回應時發生錯誤: $e');
    } finally {
      // 不論成功或失敗,最後都將回覆狀態設為 false
      setState(() {
        _isReplying = false;
      });
    }
  }

  // 滾動到對話列表底部
  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  // 3. UI 介面
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(
          'Gemma',
          style: TextStyle(color: Theme.of(context).colorScheme.onPrimary),
        ),
        backgroundColor: Theme.of(context).colorScheme.primary,
      ),
      body: Column(
        children: [
          Expanded(
            child: _isLoading
                ? const Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        CircularProgressIndicator(),
                        SizedBox(height: 16),
                        Text('正在載入模型,請稍候...'),
                      ],
                    ),
                  )
                : ListView.builder(
                    controller: _scrollController,
                    padding: const EdgeInsets.all(8.0),
                    itemCount: _messages.length,
                    itemBuilder: (context, index) {
                      final message = _messages[index];
                      return _buildMessageBubble(
                        message['text'],
                        message['isUser'],
                      );
                    },
                  ),
          ),
          _buildChatInput(),
        ],
      ),
    );
  }

  // 建立對話氣泡
  Widget _buildMessageBubble(String text, bool isUser) {
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        constraints: BoxConstraints(
          maxWidth: MediaQuery.of(context).size.width * 0.75,
        ),
        margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 8.0),
        padding: const EdgeInsets.all(12.0),
        decoration: BoxDecoration(
          color: isUser
              ? Theme.of(context).colorScheme.primary
              : Theme.of(context).colorScheme.secondaryContainer,
          borderRadius: BorderRadius.circular(16.0),
        ),
        child: Text(
          text,
          style: TextStyle(
            color: isUser
                ? Theme.of(context).colorScheme.onPrimary
                : Theme.of(context).colorScheme.onSecondaryContainer,
          ),
        ),
      ),
    );
  }

  // 建立聊天輸入框
  Widget _buildChatInput() {
    return Container(
      padding: const EdgeInsets.all(8.0),
      decoration: BoxDecoration(
        color: Theme.of(context).scaffoldBackgroundColor,
        boxShadow: [
          BoxShadow(
            offset: const Offset(0, -1),
            blurRadius: 2,
            color: Colors.black.withValues(alpha: 0.1),
          ),
        ],
      ),
      child: SafeArea(
        child: Row(
          children: [
            Expanded(
              child: TextField(
                controller: _textController,
                decoration: InputDecoration(
                  hintText: '輸入訊息...',
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(20.0),
                    borderSide: BorderSide.none,
                  ),
                  filled: true,
                  fillColor: Theme.of(
                    context,
                  ).colorScheme.surfaceContainerHighest,
                  contentPadding: const EdgeInsets.symmetric(horizontal: 16.0),
                ),
                onSubmitted: (_) => _isReplying ? null : _sendMessage(),
              ),
            ),
            const SizedBox(width: 8.0),
            IconButton(
              icon: const Icon(Icons.send),
              onPressed: _isReplying ? null : _sendMessage,
              style: IconButton.styleFrom(
                backgroundColor: Theme.of(context).colorScheme.primary,
                foregroundColor: Theme.of(context).colorScheme.onPrimary,
                padding: const EdgeInsets.all(12),
              ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _textController.dispose();
    _scrollController.dispose();
    _inferenceModel?.close();
    super.dispose();
  }
}

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *