Skip to main content

flutter_edge_ai_llama 0.0.1 copy "flutter_edge_ai_llama: ^0.0.1" to clipboard
flutter_edge_ai_llama: ^0.0.1 copied to clipboard

Cross-platform GGUF inference for Flutter Edge AI, powered by llamadart with tools, thinking, multimodal input and native LoRA.

example/lib/main.dart

import 'dart:async';

import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_edge_ai/flutter_edge_ai.dart' as edge;
import 'package:flutter_edge_ai_llama/flutter_edge_ai_llama.dart';
import 'package:llamadart/llamadart.dart' as llama;

import 'model_import.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  edge.FlutterEdgeAi.logLevel = edge.EdgeAiLogLevel.none;
  llama.LlamaLogging.configure(level: llama.LlamaLogLevel.error);
  await edge.FlutterEdgeAi.initialize(
    inferenceEngines: const [LlamaEngineProvider()],
  );
  runApp(
    MaterialApp(
      title: 'GGUF Lab',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        colorSchemeSeed: const Color(0xFFB6E78C),
        scaffoldBackgroundColor: const Color(0xFF121817),
        useMaterial3: true,
      ),
      home: const _ChatPage(),
    ),
  );
}

class _ChatPage extends StatefulWidget {
  const _ChatPage();
  @override
  State<_ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<_ChatPage> with WidgetsBindingObserver {
  final _path = TextEditingController(
    text: const String.fromEnvironment('GGUF_MODEL_PATH'),
  );
  final _prompt = TextEditingController();
  final _scroll = ScrollController();
  final List<_Turn> _turns = [];
  edge.InferenceModel? _model;
  edge.InferenceChat? _chat;
  bool _loading = false;
  bool _generating = false;
  bool _stopped = false;
  Timer? _renderTimer;
  String _status = kIsWeb || defaultTargetPlatform == TargetPlatform.android
      ? 'Enter a GGUF URL to begin.'
      : 'Load a local GGUF model to begin.';
  String _backend = 'Not loaded';

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    unawaited(_resumeModel());
  }

  Future<void> _resumeModel() async {
    if (edge.FlutterEdgeAiPlugin.instance.modelManager.activeInferenceModel ==
        null) {
      return;
    }
    setState(() {
      _loading = true;
      _status = 'Restoring the installed model…';
    });
    try {
      final loaded = await edge.FlutterEdgeAi.getActiveModel(maxTokens: 2048);
      if (!mounted) {
        await loaded.close();
        return;
      }
      _model = loaded;
      final restoredSource = edge
          .FlutterEdgeAiPlugin
          .instance
          .modelManager
          .activeInferenceModel!
          .files
          .first
          .source;
      _path.text = restoredModelInput(
        restoredSource,
        urlsOnly: kIsWeb || defaultTargetPlatform == TargetPlatform.android,
      );
      if (!kIsWeb && restoredSource is edge.FileSource) {
        await pruneImportedModels(restoredSource.path);
      }
      await _newChat();
      if (mounted) {
        setState(() {
          _backend = loaded.activeBackend?.name.toUpperCase() ?? 'UNKNOWN';
          _status = 'Installed model restored. Start a new conversation.';
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() => _status = 'Could not restore the model: $error');
      }
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.paused ||
        state == AppLifecycleState.hidden) {
      unawaited(_stop());
    }
  }

  Future<void> _chooseModel() async {
    if (_loading || _generating) return;
    if (kIsWeb || defaultTargetPlatform == TargetPlatform.android) {
      await _load();
      return;
    }
    setState(() => _loading = true);
    try {
      final file = await openFile(
        acceptedTypeGroups: [
          XTypeGroup(
            label: 'GGUF models',
            extensions: const ['gguf'],
            uniformTypeIdentifiers: defaultTargetPlatform == TargetPlatform.iOS
                ? const ['public.data']
                : null,
          ),
        ],
      );
      if (file == null || !mounted) return;
      setState(() => _status = 'Importing model into app storage…');
      final imported = await importModel(
        file.path,
        cleanupPickerInbox: defaultTargetPlatform == TargetPlatform.iOS,
      );
      if (!mounted) return;
      _path.text = imported;
      await _load();
    } catch (error) {
      if (mounted) setState(() => _status = error.toString());
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  void _clearTurns() {
    for (final turn in _turns) {
      turn.dispose();
    }
    _turns.clear();
  }

  Future<void> _load() async {
    if (_path.text.trim().isEmpty) return;
    final network = kIsWeb || defaultTargetPlatform == TargetPlatform.android;
    final selected = _path.text.trim();
    if (network) {
      try {
        validateModelUrl(selected);
      } on ArgumentError catch (error) {
        setState(() => _status = error.message.toString());
        return;
      }
    }
    final previous =
        edge.FlutterEdgeAiPlugin.instance.modelManager.activeInferenceModel;
    setState(() {
      _loading = true;
      _chat = null;
      _status = 'Loading model…';
      _backend = 'Not loaded';
      _clearTurns();
    });
    try {
      await _model?.close();
      _model = null;
      final installation = edge.FlutterEdgeAi.installModel(
        modelType: edge.ModelType.general,
        fileType: edge.ModelFileType.gguf,
      );
      if (network) {
        await installation.fromNetwork(selected).install();
      } else {
        await installation.fromFile(selected).install();
      }
      final loaded = await edge.FlutterEdgeAi.getActiveModel(maxTokens: 2048);
      if (!mounted) {
        await loaded.close();
        return;
      }
      _model = loaded;
      if (!kIsWeb && defaultTargetPlatform != TargetPlatform.android) {
        await pruneImportedModels(_path.text.trim());
      }
      await _newChat();
      if (mounted) {
        setState(() {
          _backend = _model!.activeBackend?.name.toUpperCase() ?? 'UNKNOWN';
          _status =
              'Ready · ${_model!.maxTokens} context tokens · 128 output tokens';
        });
      }
      final active =
          edge.FlutterEdgeAiPlugin.instance.modelManager.activeInferenceModel;
      if ((kIsWeb || defaultTargetPlatform == TargetPlatform.android) &&
          shouldUninstallPreviousModel(previous, active)) {
        try {
          await edge.FlutterEdgeAi.uninstallModel(
            previous!.files.first.filename,
          );
        } catch (error) {
          // A cleanup failure must not discard the newly loaded model.
          if (mounted) {
            setState(
              () => _status =
                  'Ready. Could not remove the previous download: $error',
            );
          }
        }
      }
    } catch (error) {
      if (mounted) setState(() => _status = error.toString());
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _newChat() async {
    final chat = await _model!.createChat(
      systemInstruction: 'You are a helpful assistant. Keep answers concise.',
      modelType: edge.ModelType.general,
      temperature: .7,
      topK: 40,
      topP: .9,
      maxOutputTokens: 128,
    );
    if (!mounted) {
      await chat.close();
      return;
    }
    _chat = chat;
    if (mounted) {
      setState(() {
        _clearTurns();
        _status = 'New conversation ready.';
      });
    }
  }

  Future<void> _send() async {
    final input = _prompt.text.trim();
    final chat = _chat;
    if (input.isEmpty || chat == null || _generating || _loading) return;
    final reply = _Turn(false, '');
    setState(() {
      _generating = true;
      _stopped = false;
      _status = 'Generating locally…';
      _turns.addAll([_Turn(true, input), reply]);
      _prompt.clear();
    });
    var committed = false;
    try {
      await chat.addQueryChunk(edge.Message.text(text: input, isUser: true));
      await for (final response in chat.generateChatResponseAsync()) {
        if (!mounted) break;
        if (response is edge.TextResponse) {
          reply.append(response.token);
          _renderTimer ??= Timer(const Duration(milliseconds: 33), () {
            _renderTimer = null;
            if (!mounted) return;
            final follow =
                !_scroll.hasClients || _scroll.position.extentAfter < 120;
            reply.flush();
            if (follow) {
              WidgetsBinding.instance.addPostFrameCallback((_) {
                if (mounted && _scroll.hasClients) {
                  _scroll.jumpTo(_scroll.position.maxScrollExtent);
                }
              });
            }
          });
        }
      }
      committed = !_stopped;
      final metrics = chat.session.getSessionMetrics();
      if (mounted) {
        setState(() {
          _status = _stopped
              ? 'Stopped. The interrupted turn was discarded; you can continue.'
              : '${metrics.inputTokens} input · ${metrics.outputTokens > 0 ? '${metrics.outputTokens} output tokens' : 'output count unavailable'}';
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() {
          _status = '$error The failed turn was discarded.';
        });
      }
    } finally {
      _renderTimer?.cancel();
      _renderTimer = null;
      if (mounted) {
        reply.flush();
        setState(() {
          if (!committed) {
            _turns.remove(reply);
            reply.dispose();
            if (_turns.isNotEmpty && _turns.last.isUser) {
              _turns.removeLast().dispose();
            }
          }
          _generating = false;
        });
      }
    }
  }

  Future<void> _stop() async {
    _stopped = true;
    await _chat?.session.stopGeneration();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _renderTimer?.cancel();
    _clearTurns();
    unawaited(_model?.close());
    _path.dispose();
    _prompt.dispose();
    _scroll.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    body: SafeArea(
      child: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 960),
          child: Padding(
            padding: EdgeInsets.all(
              MediaQuery.sizeOf(context).width < 520 ? 12 : 28,
            ),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (MediaQuery.viewInsetsOf(context).bottom == 0) ...[
                  Wrap(
                    spacing: 16,
                    runSpacing: 8,
                    crossAxisAlignment: WrapCrossAlignment.center,
                    children: [
                      const Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(
                            Icons.memory,
                            color: Color(0xFFB6E78C),
                            size: 32,
                          ),
                          SizedBox(width: 12),
                          Text(
                            'GGUF Lab',
                            style: TextStyle(
                              fontSize: 30,
                              fontWeight: FontWeight.w700,
                            ),
                          ),
                        ],
                      ),
                      Chip(label: Text(_backend)),
                    ],
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    'Flutter Edge AI × llamadart',
                    style: TextStyle(color: Color(0xFFA5B9B0), fontSize: 16),
                  ),
                  const SizedBox(height: 24),
                  LayoutBuilder(
                    builder: (context, constraints) {
                      final field = TextField(
                        controller: _path,
                        readOnly:
                            !kIsWeb &&
                            defaultTargetPlatform != TargetPlatform.android,
                        decoration: InputDecoration(
                          labelText:
                              kIsWeb ||
                                  defaultTargetPlatform ==
                                      TargetPlatform.android
                              ? 'GGUF model URL'
                              : 'Local GGUF model',
                          border: OutlineInputBorder(),
                        ),
                      );
                      final button = FilledButton(
                        onPressed: _loading || _generating
                            ? null
                            : _chooseModel,
                        child: Text(
                          _loading
                              ? 'Loading…'
                              : kIsWeb ||
                                    defaultTargetPlatform ==
                                        TargetPlatform.android
                              ? 'Load URL'
                              : 'Choose model',
                        ),
                      );
                      if (constraints.maxWidth < 520) {
                        return Column(
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: [field, const SizedBox(height: 8), button],
                        );
                      }
                      return Row(
                        children: [
                          Expanded(child: field),
                          const SizedBox(width: 12),
                          button,
                        ],
                      );
                    },
                  ),
                  const SizedBox(height: 12),
                  Row(
                    children: [
                      Expanded(
                        child: Text(
                          _status,
                          style: const TextStyle(color: Color(0xFFA5B9B0)),
                        ),
                      ),
                      TextButton(
                        onPressed: _model == null || _generating || _loading
                            ? null
                            : _newChat,
                        child: const Text('New chat'),
                      ),
                    ],
                  ),
                ],
                const Divider(height: 28),
                Expanded(
                  child: _turns.isEmpty
                      ? const Center(
                          child: Text(
                            'Your model. Your device.\nStart a conversation below.',
                            textAlign: TextAlign.center,
                            style: TextStyle(
                              fontSize: 22,
                              color: Color(0xFFA5B9B0),
                            ),
                          ),
                        )
                      : ListView.builder(
                          controller: _scroll,
                          itemCount: _turns.length,
                          itemBuilder: (context, index) {
                            final turn = _turns[index];
                            return Padding(
                              padding: const EdgeInsets.only(bottom: 16),
                              child: DecoratedBox(
                                decoration: BoxDecoration(
                                  color: turn.isUser
                                      ? const Color(0xFF253A31)
                                      : const Color(0xFF1B2621),
                                  borderRadius: BorderRadius.circular(14),
                                ),
                                child: Padding(
                                  padding: const EdgeInsets.all(18),
                                  child: Column(
                                    crossAxisAlignment:
                                        CrossAxisAlignment.start,
                                    children: [
                                      Text(
                                        turn.isUser ? 'YOU' : 'LOCAL MODEL',
                                        style: const TextStyle(
                                          fontSize: 11,
                                          letterSpacing: 1.5,
                                          color: Color(0xFFB6E78C),
                                        ),
                                      ),
                                      const SizedBox(height: 8),
                                      ValueListenableBuilder<String>(
                                        valueListenable: turn.text,
                                        builder: (_, text, _) => SelectableText(
                                          text.isEmpty ? '…' : text,
                                          style: const TextStyle(
                                            fontSize: 16,
                                            height: 1.5,
                                          ),
                                        ),
                                      ),
                                    ],
                                  ),
                                ),
                              ),
                            );
                          },
                        ),
                ),
                const SizedBox(height: 16),
                Row(
                  children: [
                    Expanded(
                      child: TextField(
                        controller: _prompt,
                        enabled: _chat != null && !_generating,
                        decoration: const InputDecoration(
                          hintText: 'Ask your local model…',
                          border: OutlineInputBorder(),
                        ),
                        onSubmitted: (_) => _send(),
                      ),
                    ),
                    const SizedBox(width: 12),
                    FilledButton(
                      onPressed: _generating
                          ? _stop
                          : _chat == null
                          ? null
                          : _send,
                      child: Text(_generating ? 'Stop' : 'Send'),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    ),
  );
}

class _Turn {
  _Turn(this.isUser, String initial) : text = ValueNotifier(initial);
  final bool isUser;
  final ValueNotifier<String> text;
  final StringBuffer _buffer = StringBuffer();

  void append(String token) => _buffer.write(token);
  void flush() => text.value = _buffer.toString();
  void dispose() => text.dispose();
}

/// Rejects names and incomplete URLs before replacing the loaded model.
void validateModelUrl(String value) {
  final uri = Uri.tryParse(value);
  if (uri == null ||
      !(uri.scheme == 'https' ||
          uri.scheme == 'http' &&
              ['localhost', '127.0.0.1', '::1'].contains(uri.host)) ||
      uri.host.isEmpty ||
      uri.pathSegments.isEmpty ||
      uri.pathSegments.last.isEmpty ||
      !kIsWeb &&
          defaultTargetPlatform == TargetPlatform.android &&
          uri.scheme != 'https') {
    throw ArgumentError(
      'Enter a complete HTTPS GGUF model URL (HTTP is allowed only for local testing outside Android).',
    );
  }
}

/// Keeps a rehydrated local cache path out of the URL-only replacement field.
@visibleForTesting
String restoredModelInput(edge.ModelSource source, {required bool urlsOnly}) =>
    switch (source) {
      edge.NetworkSource(:final url) => url,
      edge.FileSource(:final path) => urlsOnly ? '' : path,
      _ => '',
    };

/// Core owns deletion policy: uninstall removes managed downloads, while
/// external fromFile installs keep their bytes even after restart hydration.
@visibleForTesting
bool shouldUninstallPreviousModel(
  edge.ModelSpec? previous,
  edge.ModelSpec? active,
) =>
    previous != null &&
    active != null &&
    previous.files.first.filename != active.files.first.filename;
0
likes
0
points
51
downloads

Publisher

unverified uploader

Weekly Downloads

Cross-platform GGUF inference for Flutter Edge AI, powered by llamadart with tools, thinking, multimodal input and native LoRA.

Homepage
Repository (GitHub)
View/report issues

Topics

#llm #gguf #on-device #inference

License

unknown (license)

Dependencies

flutter, flutter_edge_ai, llamadart

More

Packages that depend on flutter_edge_ai_llama