mt_llmkit 0.0.1-beta.1 copy "mt_llmkit: ^0.0.1-beta.1" to clipboard
mt_llmkit: ^0.0.1-beta.1 copied to clipboard

A Flutter plugin for running Large Language Models locally on Android and iOS via llama.cpp, with real-time streaming inference and performance metrics.

example/lib/main.dart

// example/lib/main.dart

import 'dart:async';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:mt_llmkit/mt_llmkit.dart';
import 'package:path_provider/path_provider.dart';

import 'rag_page.dart';
import 'rest_api_tab.dart';
import 'vision_page.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'llmcpp Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MainPage(),
    );
  }
}

// ── Main navigation ──────────────────────────────────────────────────────────

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

  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int _selectedIndex = 0;

  static const _pages = [LlmDemoPage(), VisionPage(), RagPage()];

  static const _labels = [
    NavigationDestination(
      icon: Icon(Icons.chat_outlined),
      selectedIcon: Icon(Icons.chat),
      label: 'LLM',
    ),
    NavigationDestination(
      icon: Icon(Icons.image_outlined),
      selectedIcon: Icon(Icons.image),
      label: 'Vision',
    ),
    NavigationDestination(
      icon: Icon(Icons.search_outlined),
      selectedIcon: Icon(Icons.search),
      label: 'RAG',
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(switch (_selectedIndex) {
          0 => 'llmcpp — LLM Demo',
          1 => 'llmcpp — Vision Demo',
          _ => 'llmcpp — RAG Demo',
        }),
      ),
      body: IndexedStack(index: _selectedIndex, children: _pages),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _selectedIndex,
        onDestinationSelected: (i) => setState(() => _selectedIndex = i),
        destinations: _labels,
      ),
    );
  }
}

// ── LLM page ──────────────────────────────────────────────────────────────────

enum ProviderType { localGGUF, restApi }

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

  @override
  State<LlmDemoPage> createState() => _LlmDemoPageState();
}

class _LlmDemoPageState extends State<LlmDemoPage> {
  LocalModel? _ggufPlugin;
  StreamSubscription<StreamingChunk>? _streamSubscription;

  ProviderType _selectedProvider = ProviderType.localGGUF;
  String? _modelPath;
  bool _isDownloading = false;
  double _downloadProgress = 0;
  bool _isModelReady = false;
  bool _isGenerating = false;

  final StringBuffer _outputBuffer = StringBuffer();
  String _output = '';
  String _metricsText = '';
  String _statusMessage = '';

  final _promptController = TextEditingController(
    text: 'What is the capital of Poland? Describe it in 2 sentences.',
  );
  final _scrollController = ScrollController();

  static const _modelUrl =
      'https://huggingface.co/unsloth/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf';

  @override
  void initState() {
    super.initState();
    _checkModelExists();
  }

  @override
  void dispose() {
    _streamSubscription?.cancel();
    _ggufPlugin?.dispose();
    _promptController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  Future<void> _checkModelExists() async {
    final dir = await getApplicationDocumentsDirectory();
    final file = File('${dir.path}/model.gguf');
    if (file.existsSync()) {
      setState(() {
        _modelPath = file.path;
        _isModelReady = true;
        _statusMessage = 'Model ready: Llama-3.2-1B-Instruct Q4_K_M';
      });
    }
  }

  Future<void> _downloadModel() async {
    setState(() {
      _isDownloading = true;
      _downloadProgress = 0;
      _statusMessage = 'Downloading model...';
    });

    try {
      final dir = await getApplicationDocumentsDirectory();
      final filePath = '${dir.path}/model.gguf';
      final httpClient = HttpClient();
      final request = await httpClient.getUrl(Uri.parse(_modelUrl));
      final response = await request.close();

      if (response.statusCode == 200) {
        final contentLength = response.contentLength;
        final file = File(filePath);
        final sink = file.openWrite();
        int downloadedBytes = 0;

        await for (final chunk in response) {
          sink.add(chunk);
          downloadedBytes += chunk.length;
          if (contentLength > 0) {
            setState(() {
              _downloadProgress = downloadedBytes / contentLength;
              _statusMessage =
                  'Downloading: ${(_downloadProgress * 100).toStringAsFixed(1)}%';
            });
          }
        }

        await sink.close();
        setState(() {
          _modelPath = filePath;
          _isModelReady = true;
          _isDownloading = false;
          _statusMessage = 'Model downloaded successfully';
        });
      } else {
        throw HttpException('HTTP ${response.statusCode}');
      }
    } catch (e) {
      setState(() {
        _isDownloading = false;
        _statusMessage = '';
      });
      _showError('Download error: $e');
    }
  }

  Future<bool> _initializeGguf() async {
    _ggufPlugin?.dispose();
    _ggufPlugin = null;

    try {
      _ggufPlugin = LocalModel(
        backend: ModelBackend.inProcess,
        config: LlmConfig(
          // topP: 0.1,
          temp: 0.7,
          nPredict: 120,
          // nBatch: 128,
          nThreads: 6,
          // nCtx: 1024,
        ),
      );
      await _ggufPlugin!.loadModel(_modelPath!);
      return true;
    } catch (e) {
      _showError('Initialization error: $e');
      _ggufPlugin = null;
      return false;
    }
  }

  Future<void> _sendPrompt() async {
    if (_isGenerating) return;
    setState(() {
      _isGenerating = true;
      _output = '';
      _metricsText = '';
      _statusMessage = 'Initializing...';
      _outputBuffer.clear();
    });

    await _sendGgufPrompt();
  }

  Future<void> _sendGgufPrompt() async {
    if (_ggufPlugin == null || !_ggufPlugin!.isInitialized) {
      final success = await _initializeGguf();
      if (!success) {
        setState(() => _isGenerating = false);
        return;
      }
    }

    setState(() => _statusMessage = 'Generating...');

    _streamSubscription = _ggufPlugin!
        .sendPromptStream(_promptController.text)
        .listen(
          (chunk) {
            setState(() {
              if (chunk.text.isNotEmpty) {
                _outputBuffer.write(chunk.text);
                _output = _outputBuffer.toString();
              }
              if (chunk.metrics != null) {
                final m = chunk.metrics!;
                _metricsText =
                    'Tokens: ${m.tokensGenerated} │ ${m.tokensPerSecond.toStringAsFixed(1)} t/s │ ${m.msPerToken.toStringAsFixed(0)} ms/token';
              }
              if (chunk.isFinal) {
                _isGenerating = false;
                _statusMessage = 'Done';
              }
            });
          },
          onError: (Object error) {
            setState(() {
              _output = 'Error: $error';
              _isGenerating = false;
              _statusMessage = 'Error';
            });
          },
          onDone: () {
            if (mounted) {
              setState(() {
                _isGenerating = false;
                if (_statusMessage == 'Generating...') _statusMessage = 'Done';
              });
            }
          },
        );
  }

  void _showError(String message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message), backgroundColor: Colors.red.shade700),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // ── Provider selector ─────────────────────────────────────────────────
        Padding(
          padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
          child: SegmentedButton<ProviderType>(
            segments: const [
              ButtonSegment(
                value: ProviderType.localGGUF,
                label: Text('Local GGUF'),
                icon: Icon(Icons.computer),
              ),
              ButtonSegment(
                value: ProviderType.restApi,
                label: Text('Rest API'),
                icon: Icon(Icons.cloud_outlined),
              ),
            ],
            selected: {_selectedProvider},
            onSelectionChanged: (selected) {
              setState(() {
                _selectedProvider = selected.first;
                _ggufPlugin?.dispose();
                _ggufPlugin = null;
              });
            },
          ),
        ),

        // ── Rest API tab — fully self-contained ───────────────────────────────
        if (_selectedProvider == ProviderType.restApi)
          const Expanded(child: RestApiTab())
        // ── Local GGUF tab ────────────────────────────────────────────────────
        else ...[
          _buildLocalGGUFSection(),
          const Divider(height: 1),
          if (_isModelReady)
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
              child: TextField(
                controller: _promptController,
                maxLines: 3,
                minLines: 1,
                decoration: const InputDecoration(
                  labelText: 'Prompt',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
          if (_metricsText.isNotEmpty)
            Container(
              color: Colors.blue.shade50,
              width: double.infinity,
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              child: Text(
                _metricsText,
                style: TextStyle(
                  fontSize: 11,
                  color: Colors.blue.shade700,
                  fontFamily: 'monospace',
                ),
              ),
            ),
          Expanded(
            child: SingleChildScrollView(
              controller: _scrollController,
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  if (_output.isNotEmpty)
                    SelectableText(
                      _output,
                      style: Theme.of(context).textTheme.bodyLarge,
                    )
                  else if (!_isGenerating && _isModelReady)
                    Text(
                      'Press ▶ to generate...',
                      style: TextStyle(color: Colors.grey.shade400),
                    ),
                  if (_isGenerating)
                    const Padding(
                      padding: EdgeInsets.only(top: 12),
                      child: SizedBox(
                        width: 16,
                        height: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      ),
                    ),
                ],
              ),
            ),
          ),
          if (_statusMessage.isNotEmpty)
            Container(
              color: Colors.grey.shade100,
              width: double.infinity,
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
              child: Text(
                _statusMessage,
                style: const TextStyle(fontSize: 11, color: Colors.black54),
              ),
            ),
        ],
      ],
    );
  }

  Widget _buildLocalGGUFSection() {
    if (_isModelReady) {
      return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
        child: Row(
          children: [
            Icon(Icons.check_circle, color: Colors.green.shade600, size: 18),
            const SizedBox(width: 8),
            const Expanded(
              child: Text(
                'Llama-3.2-1B-Instruct Q4_K_M',
                style: TextStyle(fontSize: 13),
              ),
            ),
            if (_isGenerating)
              IconButton(
                icon: const Icon(Icons.stop_circle_outlined),
                onPressed: () async {
                  await _streamSubscription?.cancel();
                  setState(() => _isGenerating = false);
                },
              )
            else
              IconButton(icon: const Icon(Icons.send), onPressed: _sendPrompt),
          ],
        ),
      );
    }
    if (_isDownloading) {
      return Padding(
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Downloading: ${(_downloadProgress * 100).toStringAsFixed(1)}%',
              style: const TextStyle(fontSize: 13),
            ),
            const SizedBox(height: 6),
            LinearProgressIndicator(value: _downloadProgress),
          ],
        ),
      );
    }
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
      child: ElevatedButton.icon(
        onPressed: _downloadModel,
        icon: const Icon(Icons.download),
        label: const Text('Download GGUF model (~800MB)'),
      ),
    );
  }
}
0
likes
150
points
13
downloads

Documentation

API reference

Publisher

verified publishermobitouch.net

Weekly Downloads

A Flutter plugin for running Large Language Models locally on Android and iOS via llama.cpp, with real-time streaming inference and performance metrics.

Repository (GitHub)
View/report issues

Topics

#llm #llama-cpp #ai #on-device-ai #flutter-plugin

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, http, llamadart, plugin_platform_interface, web

More

Packages that depend on mt_llmkit

Packages that implement mt_llmkit