fllamer 0.2.1 copy "fllamer: ^0.2.1" to clipboard
fllamer: ^0.2.1 copied to clipboard

Mobile-first local llama.cpp inference, embeddings, RAG, multimodal, LoRA, and speculative decoding for Dart and Flutter.

example/lib/main.dart

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

import 'package:file_selector/file_selector.dart';
import 'package:fllamer/fllamer.dart';
import 'package:flutter/material.dart';

const _ggufFiles = XTypeGroup(
  label: 'GGUF models',
  extensions: <String>['gguf'],
  mimeTypes: <String>['application/octet-stream'],
  uniformTypeIdentifiers: <String>['public.data'],
);

const _imageFiles = XTypeGroup(
  label: 'Images',
  extensions: <String>['jpg', 'jpeg', 'png', 'webp', 'bmp'],
  mimeTypes: <String>['image/*'],
  uniformTypeIdentifiers: <String>['public.image'],
);

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

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

  @override
  Widget build(BuildContext context) {
    final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF006A65))
        .copyWith(
          secondary: const Color(0xFF8A5700),
          surface: const Color(0xFFFCFCF9),
        );
    return MaterialApp(
      title: 'fllamer',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: colorScheme,
        useMaterial3: true,
        scaffoldBackgroundColor: const Color(0xFFF5F7F4),
        cardTheme: const CardThemeData(
          elevation: 0,
          margin: EdgeInsets.zero,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.all(Radius.circular(6)),
          ),
        ),
        inputDecorationTheme: const InputDecorationTheme(
          border: OutlineInputBorder(
            borderRadius: BorderRadius.all(Radius.circular(6)),
          ),
        ),
      ),
      home: const InferenceWorkspace(),
    );
  }
}

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

  @override
  State<InferenceWorkspace> createState() => _InferenceWorkspaceState();
}

class _InferenceWorkspaceState extends State<InferenceWorkspace> {
  final _promptController = TextEditingController();
  final _scrollController = ScrollController();
  final _transcript = <_TranscriptEntry>[];

  late final LlamaRuntimeCapabilities _runtimeCapabilities;
  _SelectedFile? _modelFile;
  _SelectedFile? _projectorFile;
  _SelectedFile? _pendingImage;
  LlamaEngine? _engine;
  LlamaContextInfo? _contextInfo;
  StreamSubscription<GenerationChunk>? _generationSubscription;
  GenerationTelemetry? _lastTelemetry;
  String? _error;
  bool _isLoading = false;
  bool _isGenerating = false;
  int _generationId = 0;
  int? _streamingEntryIndex;

  @override
  void initState() {
    super.initState();
    _runtimeCapabilities = LlamaRuntime.currentCapabilities();
    _promptController.addListener(_onPromptChanged);
  }

  @override
  void dispose() {
    _generationId++;
    final subscription = _generationSubscription;
    if (subscription != null) {
      unawaited(subscription.cancel());
    }
    final engine = _engine;
    if (engine != null) {
      unawaited(engine.close());
    }
    _promptController.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  bool get _canSend {
    return _engine != null &&
        !_isLoading &&
        !_isGenerating &&
        (_promptController.text.trim().isNotEmpty || _pendingImage != null);
  }

  void _onPromptChanged() {
    if (mounted) {
      setState(() {});
    }
  }

  Future<void> _selectGguf({required bool projector}) async {
    try {
      final file = await openFile(
        acceptedTypeGroups: const <XTypeGroup>[_ggufFiles],
      );
      if (!mounted || file == null) {
        return;
      }
      if (file.path.isEmpty) {
        throw const FileSystemException('The selected file has no local path.');
      }
      setState(() {
        final selected = _SelectedFile(path: file.path, name: file.name);
        if (projector) {
          _projectorFile = selected;
        } else {
          _modelFile = selected;
        }
        _error = null;
      });
    } catch (error) {
      _showError(error);
    }
  }

  Future<void> _selectImage() async {
    try {
      final file = await openFile(
        acceptedTypeGroups: const <XTypeGroup>[_imageFiles],
      );
      if (!mounted || file == null) {
        return;
      }
      if (file.path.isEmpty) {
        throw const FileSystemException(
          'The selected image has no local path.',
        );
      }
      setState(() {
        _pendingImage = _SelectedFile(path: file.path, name: file.name);
        _error = null;
      });
    } catch (error) {
      _showError(error);
    }
  }

  Future<void> _loadModel() async {
    final modelFile = _modelFile;
    if (modelFile == null || _isLoading) {
      return;
    }
    setState(() {
      _isLoading = true;
      _error = null;
    });

    LlamaEngine? engine;
    try {
      await LlamaModel.validateFile(modelFile.path);
      final projectorFile = _projectorFile;
      if (projectorFile != null) {
        await LlamaModel.validateFile(projectorFile.path);
      }
      engine = await LlamaEngine.load(
        LlamaModelConfig(
          modelPath: modelFile.path,
          mmprojPath: projectorFile?.path,
          contextSize: 4096,
          batchSize: 512,
          gpu: const GpuConfig.auto(),
        ),
      );
      final contextInfo = await engine.contextInfo();
      if (!mounted) {
        await engine.close();
        return;
      }
      setState(() {
        _engine = engine;
        _contextInfo = contextInfo;
        _transcript.clear();
        _pendingImage = null;
        _lastTelemetry = null;
        _isLoading = false;
      });
    } catch (error) {
      if (engine != null) {
        await engine.close();
      }
      if (mounted) {
        setState(() {
          _isLoading = false;
          _error = _errorText(error);
        });
      }
    }
  }

  Future<void> _unloadModel() async {
    final engine = _engine;
    if (engine == null || _isLoading) {
      return;
    }
    setState(() {
      _isLoading = true;
      _error = null;
    });
    try {
      await _cancelGeneration(markCancelled: false);
      await engine.close();
      if (mounted) {
        setState(() {
          _engine = null;
          _contextInfo = null;
          _transcript.clear();
          _pendingImage = null;
          _lastTelemetry = null;
          _isLoading = false;
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() {
          _isLoading = false;
          _error = _errorText(error);
        });
      }
    }
  }

  Future<void> _sendPrompt() async {
    final engine = _engine;
    final contextInfo = _contextInfo;
    if (engine == null || contextInfo == null || !_canSend) {
      return;
    }
    final image = _pendingImage;
    if (image != null && !contextInfo.supportsVision) {
      _showError(
        const UnsupportedFeatureException(
          'The loaded projector does not support image input.',
        ),
      );
      return;
    }

    var prompt = _promptController.text.trim();
    if (prompt.isEmpty && image != null) {
      prompt = 'Describe this image.';
    }
    final userEntry = _TranscriptEntry(
      role: ChatRole.user,
      text: prompt,
      attachmentPath: image?.path,
      attachmentName: image?.name,
    );
    final requestEntries = <_TranscriptEntry>[..._transcript, userEntry];
    final messages = requestEntries.map(_toChatMessage).toList();

    setState(() {
      _isGenerating = true;
      _error = null;
      _lastTelemetry = null;
    });
    try {
      await engine.reset();
      if (!mounted) {
        return;
      }
      _promptController.clear();
      final assistantEntry = _TranscriptEntry(
        role: ChatRole.assistant,
        text: '',
        isStreaming: true,
      );
      setState(() {
        _transcript
          ..add(userEntry)
          ..add(assistantEntry);
        _pendingImage = null;
        _streamingEntryIndex = _transcript.length - 1;
      });
      _scrollToEnd();

      final generationId = ++_generationId;
      final stream = engine.chat(
        messages: messages,
        config: const GenerationConfig(
          maxTokens: 256,
          temperature: 0.7,
          streamChunkTokens: 4,
        ),
      );
      _generationSubscription = stream.listen(
        (chunk) => _handleGenerationChunk(generationId, chunk),
        onError: (Object error, StackTrace stackTrace) {
          _handleGenerationError(generationId, error);
        },
        onDone: () => _handleGenerationDone(generationId),
        cancelOnError: true,
      );
    } catch (error) {
      if (mounted) {
        final index = _streamingEntryIndex;
        setState(() {
          if (index != null && index < _transcript.length) {
            if (_transcript[index].text.isEmpty) {
              _transcript.removeAt(index);
            } else {
              _transcript[index].isStreaming = false;
            }
          }
          _isGenerating = false;
          _streamingEntryIndex = null;
          _error = _errorText(error);
        });
      }
    }
  }

  void _handleGenerationChunk(int generationId, GenerationChunk chunk) {
    if (!mounted || generationId != _generationId) {
      return;
    }
    final index = _streamingEntryIndex;
    if (index == null || index >= _transcript.length) {
      return;
    }
    setState(() {
      _transcript[index].text += chunk.text;
      _lastTelemetry = chunk.telemetry ?? _lastTelemetry;
      if (chunk.isDone) {
        _transcript[index].isStreaming = false;
      }
    });
    _scrollToEnd();
  }

  void _handleGenerationDone(int generationId) {
    if (!mounted || generationId != _generationId) {
      return;
    }
    final index = _streamingEntryIndex;
    setState(() {
      if (index != null && index < _transcript.length) {
        _transcript[index].isStreaming = false;
      }
      _isGenerating = false;
      _streamingEntryIndex = null;
      _generationSubscription = null;
    });
  }

  void _handleGenerationError(int generationId, Object error) {
    if (!mounted || generationId != _generationId) {
      return;
    }
    final index = _streamingEntryIndex;
    setState(() {
      if (index != null && index < _transcript.length) {
        if (_transcript[index].text.isEmpty) {
          _transcript.removeAt(index);
        } else {
          _transcript[index].isStreaming = false;
        }
      }
      _generationId++;
      _isGenerating = false;
      _streamingEntryIndex = null;
      _generationSubscription = null;
      _error = _errorText(error);
    });
  }

  Future<void> _cancelGeneration({bool markCancelled = true}) async {
    final subscription = _generationSubscription;
    if (subscription == null && !_isGenerating) {
      return;
    }
    _generationId++;
    _generationSubscription = null;
    await subscription?.cancel();
    if (!mounted) {
      return;
    }
    final index = _streamingEntryIndex;
    setState(() {
      if (index != null && index < _transcript.length) {
        final entry = _transcript[index];
        entry.isStreaming = false;
        if (markCancelled && entry.text.isEmpty) {
          entry.text = 'Generation cancelled.';
        }
      }
      _streamingEntryIndex = null;
      _isGenerating = false;
    });
  }

  Future<void> _clearConversation() async {
    final engine = _engine;
    if (engine == null || _isLoading) {
      return;
    }
    try {
      await _cancelGeneration(markCancelled: false);
      await engine.reset();
      if (mounted) {
        setState(() {
          _transcript.clear();
          _lastTelemetry = null;
          _error = null;
        });
      }
    } catch (error) {
      _showError(error);
    }
  }

  ChatMessage _toChatMessage(_TranscriptEntry entry) {
    if (entry.role == ChatRole.user && entry.attachmentPath != null) {
      return ChatMessage.content(
        role: ChatRole.user,
        parts: <ChatContentPart>[
          TextPart(entry.text),
          ImagePart.fromFile(entry.attachmentPath!),
        ],
      );
    }
    return switch (entry.role) {
      ChatRole.system => ChatMessage.system(entry.text),
      ChatRole.user => ChatMessage.user(entry.text),
      ChatRole.assistant => ChatMessage.assistant(entry.text),
      ChatRole.tool => ChatMessage.tool(entry.text),
    };
  }

  void _showError(Object error) {
    if (mounted) {
      setState(() => _error = _errorText(error));
    }
  }

  void _scrollToEnd() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || !_scrollController.hasClients) {
        return;
      }
      _scrollController.animateTo(
        _scrollController.position.maxScrollExtent,
        duration: const Duration(milliseconds: 180),
        curve: Curves.easeOut,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    final contextInfo = _contextInfo;
    return Scaffold(
      appBar: AppBar(
        title: const Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.memory),
            SizedBox(width: 10),
            Text('fllamer'),
          ],
        ),
        actions: <Widget>[
          _RuntimeStatus(capabilities: _runtimeCapabilities),
          const SizedBox(width: 12),
        ],
      ),
      body: SafeArea(
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 1000),
            child: Column(
              children: <Widget>[
                if (_engine == null)
                  _ModelSetup(
                    modelFile: _modelFile,
                    projectorFile: _projectorFile,
                    isLoading: _isLoading,
                    onSelectModel: () => _selectGguf(projector: false),
                    onSelectProjector: () => _selectGguf(projector: true),
                    onClearProjector: () {
                      setState(() => _projectorFile = null);
                    },
                    onLoad: _modelFile == null ? null : _loadModel,
                  )
                else
                  _LoadedModelBar(
                    modelName: _modelFile?.name ?? 'Model',
                    contextInfo: contextInfo!,
                    isLoading: _isLoading,
                    onClear: _clearConversation,
                    onUnload: _unloadModel,
                  ),
                if (_isLoading) const LinearProgressIndicator(minHeight: 2),
                if (_error case final error?)
                  _ErrorBar(
                    message: error,
                    onDismiss: () => setState(() => _error = null),
                  ),
                Expanded(
                  child: _TranscriptView(
                    entries: _transcript,
                    scrollController: _scrollController,
                  ),
                ),
                if (_lastTelemetry case final telemetry?)
                  _TelemetryBar(telemetry: telemetry),
                _Composer(
                  controller: _promptController,
                  enabled: _engine != null && !_isLoading,
                  canAttachImage: contextInfo?.supportsVision ?? false,
                  canSend: _canSend,
                  isGenerating: _isGenerating,
                  pendingImage: _pendingImage,
                  onAttachImage: _selectImage,
                  onRemoveImage: () => setState(() => _pendingImage = null),
                  onSend: _sendPrompt,
                  onCancel: _cancelGeneration,
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _RuntimeStatus extends StatelessWidget {
  const _RuntimeStatus({required this.capabilities});

  final LlamaRuntimeCapabilities capabilities;

  @override
  Widget build(BuildContext context) {
    final available = capabilities.nativeBridgeAvailable;
    final label = available
        ? 'Native bridge ABI ${capabilities.bridgeAbiVersion}'
        : 'Native bridge unavailable';
    final showLabel = MediaQuery.sizeOf(context).width >= 600;
    return Tooltip(
      message: label,
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Icon(
            available ? Icons.check_circle : Icons.error_outline,
            size: 18,
            color: available
                ? const Color(0xFF2E7D32)
                : Theme.of(context).colorScheme.error,
          ),
          if (showLabel) ...<Widget>[
            const SizedBox(width: 6),
            Text(label, style: Theme.of(context).textTheme.labelMedium),
          ],
        ],
      ),
    );
  }
}

class _ModelSetup extends StatelessWidget {
  const _ModelSetup({
    required this.modelFile,
    required this.projectorFile,
    required this.isLoading,
    required this.onSelectModel,
    required this.onSelectProjector,
    required this.onClearProjector,
    required this.onLoad,
  });

  final _SelectedFile? modelFile;
  final _SelectedFile? projectorFile;
  final bool isLoading;
  final VoidCallback onSelectModel;
  final VoidCallback onSelectProjector;
  final VoidCallback onClearProjector;
  final VoidCallback? onLoad;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surface,
        border: Border(
          bottom: BorderSide(
            color: Theme.of(context).colorScheme.outlineVariant,
          ),
        ),
      ),
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text('Local model', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            _FileRow(
              key: const ValueKey<String>('model-file-row'),
              icon: Icons.data_object,
              label: 'Model',
              file: modelFile,
              selectTooltip: 'Select GGUF model',
              onSelect: isLoading ? null : onSelectModel,
            ),
            const Divider(height: 1),
            _FileRow(
              key: const ValueKey<String>('projector-file-row'),
              icon: Icons.visibility_outlined,
              label: 'Projector',
              file: projectorFile,
              selectTooltip: 'Select multimodal projector',
              onSelect: isLoading ? null : onSelectProjector,
              onClear: projectorFile == null || isLoading
                  ? null
                  : onClearProjector,
            ),
            const SizedBox(height: 10),
            Align(
              alignment: Alignment.centerRight,
              child: FilledButton.icon(
                key: const ValueKey<String>('load-model-button'),
                onPressed: isLoading ? null : onLoad,
                icon: const Icon(Icons.play_arrow),
                label: const Text('Load model'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _FileRow extends StatelessWidget {
  const _FileRow({
    super.key,
    required this.icon,
    required this.label,
    required this.file,
    required this.selectTooltip,
    required this.onSelect,
    this.onClear,
  });

  final IconData icon;
  final String label;
  final _SelectedFile? file;
  final String selectTooltip;
  final VoidCallback? onSelect;
  final VoidCallback? onClear;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 58,
      child: Row(
        children: <Widget>[
          Icon(icon, size: 21),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(label, style: Theme.of(context).textTheme.labelLarge),
                Text(
                  file?.name ?? 'Not selected',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            ),
          ),
          if (onClear != null)
            IconButton(
              tooltip: 'Clear $label',
              onPressed: onClear,
              icon: const Icon(Icons.close),
            ),
          IconButton.filledTonal(
            key: ValueKey<String>('select-${label.toLowerCase()}-button'),
            tooltip: selectTooltip,
            onPressed: onSelect,
            icon: const Icon(Icons.folder_open),
          ),
        ],
      ),
    );
  }
}

class _LoadedModelBar extends StatelessWidget {
  const _LoadedModelBar({
    required this.modelName,
    required this.contextInfo,
    required this.isLoading,
    required this.onClear,
    required this.onUnload,
  });

  final String modelName;
  final LlamaContextInfo contextInfo;
  final bool isLoading;
  final VoidCallback onClear;
  final VoidCallback onUnload;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 62,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surface,
        border: Border(
          bottom: BorderSide(
            color: Theme.of(context).colorScheme.outlineVariant,
          ),
        ),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.memory, size: 21),
          const SizedBox(width: 10),
          Expanded(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  modelName,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: Theme.of(context).textTheme.labelLarge,
                ),
                Text(
                  '${contextInfo.gpuBackend.name.toUpperCase()} | '
                  '${contextInfo.contextSize} context',
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ],
            ),
          ),
          if (contextInfo.supportsVision)
            const Tooltip(message: 'Image input', child: Icon(Icons.image)),
          if (contextInfo.supportsAudio) ...<Widget>[
            const SizedBox(width: 8),
            const Tooltip(message: 'Audio input', child: Icon(Icons.mic)),
          ],
          const SizedBox(width: 8),
          IconButton(
            tooltip: 'Clear conversation',
            onPressed: isLoading ? null : onClear,
            icon: const Icon(Icons.delete_sweep_outlined),
          ),
          IconButton(
            tooltip: 'Unload model',
            onPressed: isLoading ? null : onUnload,
            icon: const Icon(Icons.power_settings_new),
          ),
        ],
      ),
    );
  }
}

class _ErrorBar extends StatelessWidget {
  const _ErrorBar({required this.message, required this.onDismiss});

  final String message;
  final VoidCallback onDismiss;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return Container(
      color: colors.errorContainer,
      padding: const EdgeInsets.only(left: 16),
      child: Row(
        children: <Widget>[
          Icon(Icons.error_outline, color: colors.onErrorContainer),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              message,
              maxLines: 3,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(color: colors.onErrorContainer),
            ),
          ),
          IconButton(
            tooltip: 'Dismiss error',
            onPressed: onDismiss,
            icon: const Icon(Icons.close),
          ),
        ],
      ),
    );
  }
}

class _TranscriptView extends StatelessWidget {
  const _TranscriptView({
    required this.entries,
    required this.scrollController,
  });

  final List<_TranscriptEntry> entries;
  final ScrollController scrollController;

  @override
  Widget build(BuildContext context) {
    if (entries.isEmpty) {
      return const Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(Icons.forum_outlined, size: 36),
            SizedBox(height: 10),
            Text('Local session'),
          ],
        ),
      );
    }
    return ListView.separated(
      controller: scrollController,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
      itemCount: entries.length,
      separatorBuilder: (_, _) => const SizedBox(height: 10),
      itemBuilder: (context, index) => _MessageBubble(entry: entries[index]),
    );
  }
}

class _MessageBubble extends StatelessWidget {
  const _MessageBubble({required this.entry});

  final _TranscriptEntry entry;

  @override
  Widget build(BuildContext context) {
    final isUser = entry.role == ChatRole.user;
    final colors = Theme.of(context).colorScheme;
    return Align(
      alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 720),
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: isUser ? colors.secondaryContainer : colors.surface,
            border: Border.all(color: colors.outlineVariant),
            borderRadius: BorderRadius.circular(6),
          ),
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    Icon(
                      isUser ? Icons.person_outline : Icons.memory,
                      size: 17,
                    ),
                    const SizedBox(width: 6),
                    Text(
                      isUser ? 'You' : 'Model',
                      style: Theme.of(context).textTheme.labelMedium,
                    ),
                  ],
                ),
                if (entry.attachmentPath case final path?) ...<Widget>[
                  const SizedBox(height: 10),
                  ClipRRect(
                    borderRadius: BorderRadius.circular(4),
                    child: Image.file(
                      File(path),
                      width: 240,
                      height: 150,
                      fit: BoxFit.cover,
                      errorBuilder: (_, _, _) => Container(
                        width: 240,
                        height: 72,
                        alignment: Alignment.center,
                        color: colors.surfaceContainerHighest,
                        child: Text(entry.attachmentName ?? 'Image'),
                      ),
                    ),
                  ),
                ],
                if (entry.text.isNotEmpty) ...<Widget>[
                  const SizedBox(height: 8),
                  SelectableText(entry.text),
                ],
                if (entry.isStreaming) ...<Widget>[
                  const SizedBox(height: 10),
                  const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                ],
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _TelemetryBar extends StatelessWidget {
  const _TelemetryBar({required this.telemetry});

  final GenerationTelemetry telemetry;

  @override
  Widget build(BuildContext context) {
    final text =
        '${telemetry.generatedTokens} tokens | '
        '${telemetry.decodeTokensPerSecond.toStringAsFixed(1)} tok/s | '
        'TTFT ${telemetry.timeToFirstTokenMs.toStringAsFixed(0)} ms';
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      color: Theme.of(context).colorScheme.surfaceContainerLow,
      child: Text(
        text,
        textAlign: TextAlign.right,
        style: Theme.of(context).textTheme.labelSmall,
      ),
    );
  }
}

class _Composer extends StatelessWidget {
  const _Composer({
    required this.controller,
    required this.enabled,
    required this.canAttachImage,
    required this.canSend,
    required this.isGenerating,
    required this.pendingImage,
    required this.onAttachImage,
    required this.onRemoveImage,
    required this.onSend,
    required this.onCancel,
  });

  final TextEditingController controller;
  final bool enabled;
  final bool canAttachImage;
  final bool canSend;
  final bool isGenerating;
  final _SelectedFile? pendingImage;
  final VoidCallback onAttachImage;
  final VoidCallback onRemoveImage;
  final VoidCallback onSend;
  final VoidCallback onCancel;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surface,
        border: Border(
          top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
        ),
      ),
      child: Column(
        children: <Widget>[
          if (pendingImage case final image?)
            Padding(
              padding: const EdgeInsets.only(bottom: 8),
              child: Row(
                children: <Widget>[
                  const Icon(Icons.image_outlined, size: 19),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      image.name,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                  IconButton(
                    tooltip: 'Remove image',
                    onPressed: onRemoveImage,
                    icon: const Icon(Icons.close),
                  ),
                ],
              ),
            ),
          Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: <Widget>[
              IconButton(
                key: const ValueKey<String>('attach-image-button'),
                tooltip: 'Attach image',
                onPressed: enabled && canAttachImage && !isGenerating
                    ? onAttachImage
                    : null,
                icon: const Icon(Icons.add_photo_alternate_outlined),
              ),
              const SizedBox(width: 4),
              Expanded(
                child: TextField(
                  key: const ValueKey<String>('prompt-field'),
                  controller: controller,
                  enabled: enabled && !isGenerating,
                  minLines: 1,
                  maxLines: 4,
                  textCapitalization: TextCapitalization.sentences,
                  decoration: const InputDecoration(
                    hintText: 'Message',
                    isDense: true,
                  ),
                ),
              ),
              const SizedBox(width: 8),
              if (isGenerating)
                IconButton.filled(
                  key: const ValueKey<String>('cancel-generation-button'),
                  tooltip: 'Stop generation',
                  onPressed: onCancel,
                  icon: const Icon(Icons.stop),
                )
              else
                IconButton.filled(
                  key: const ValueKey<String>('send-button'),
                  tooltip: 'Send',
                  onPressed: canSend ? onSend : null,
                  icon: const Icon(Icons.send),
                ),
            ],
          ),
        ],
      ),
    );
  }
}

final class _SelectedFile {
  const _SelectedFile({required this.path, required this.name});

  final String path;
  final String name;
}

final class _TranscriptEntry {
  _TranscriptEntry({
    required this.role,
    required this.text,
    this.attachmentPath,
    this.attachmentName,
    this.isStreaming = false,
  });

  final ChatRole role;
  String text;
  final String? attachmentPath;
  final String? attachmentName;
  bool isStreaming;
}

String _errorText(Object error) {
  return switch (error) {
    LlamaException() => error.message,
    FileSystemException() => error.message,
    _ => error.toString(),
  };
}
0
likes
150
points
264
downloads

Documentation

API reference

Publisher

verified publisherorcalogy.com

Weekly Downloads

Mobile-first local llama.cpp inference, embeddings, RAG, multimodal, LoRA, and speculative decoding for Dart and Flutter.

Repository (GitHub)
View/report issues

Topics

#ffi #llama-cpp #local-ai #rag

License

GPL-3.0 (license)

Dependencies

code_assets, crypto, ffi, hooks

More

Packages that depend on fllamer