flutter_local_llm 1.0.0
flutter_local_llm: ^1.0.0 copied to clipboard
High-performance, production-grade Flutter plugin for on-device local LLM inference using llama.cpp via Dart FFI and Native Assets.
example/lib/main.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_local_llm/flutter_local_llm.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const LocalLlmDemoApp());
}
class LocalLlmDemoApp extends StatelessWidget {
const LocalLlmDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Local LLM Studio',
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
darkTheme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: const Color(0xFF0B0F19),
colorScheme: const ColorScheme.dark(
primary: Color(0xFF6366F1),
secondary: Color(0xFF06B6D4),
surface: Color(0xFF131B2E),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF0B0F19),
elevation: 0,
scrolledUnderElevation: 0,
),
),
home: const MainStudioScreen(),
);
}
}
class GgufModelPreset {
final String name;
final String parameterSize;
final String quantization;
final String fileSize;
final String url;
final String filename;
final ChatTemplate template;
final String? sha256;
const GgufModelPreset({
required this.name,
required this.parameterSize,
required this.quantization,
required this.fileSize,
required this.url,
required this.filename,
required this.template,
this.sha256,
});
}
const List<GgufModelPreset> kModelPresets = [
GgufModelPreset(
name: 'SmolLM-135M Instruct',
parameterSize: '135M',
quantization: 'Q4_K_M',
fileSize: '89 MB',
url: 'https://huggingface.co/HuggingFaceTB/SmolLM-135M-Instruct-GGUF/resolve/main/smollm-135m-instruct-q4_k_m.gguf',
filename: 'smollm-135m-instruct-q4_k_m.gguf',
template: ChatMlTemplate(),
),
GgufModelPreset(
name: 'Qwen2.5-0.5B Instruct',
parameterSize: '0.5B',
quantization: 'Q4_K_M',
fileSize: '390 MB',
url: 'https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf',
filename: 'qwen2.5-0.5b-instruct-q4_k_m.gguf',
template: ChatMlTemplate(),
),
GgufModelPreset(
name: 'TinyLlama 1.1B Chat',
parameterSize: '1.1B',
quantization: 'Q4_K_M',
fileSize: '669 MB',
url: 'https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf',
filename: 'tinyllama-1.1b-chat-q4_k_m.gguf',
template: ChatMlTemplate(),
),
];
class MainStudioScreen extends StatefulWidget {
const MainStudioScreen({super.key});
@override
State<MainStudioScreen> createState() => _MainStudioScreenState();
}
class _MainStudioScreenState extends State<MainStudioScreen> {
int _currentIndex = 0;
// Engine state
LocalLlmEngine? _engine;
LlmSession? _session;
String? _loadedModelName;
bool _isLoadingModel = false;
// Hyperparameters
double _temperature = 0.7;
double _topP = 0.9;
int _maxTokens = 512;
int _gpuLayers = 99;
int _contextSize = 2048;
bool _jsonGrammarMode = false;
ChatTemplate _activeTemplate = const ChatMlTemplate();
// Chat UI state
final List<ChatMessage> _messages = [];
final TextEditingController _promptController = TextEditingController();
final ScrollController _scrollController = ScrollController();
String _currentStreamResponse = '';
bool _isGenerating = false;
// Telemetry HUD
double _lastTokensPerSec = 0.0;
Duration _lastTtft = Duration.zero;
int _lastTokenCount = 0;
// Downloader state
ModelDownloader? _downloader;
DownloadProgress? _downloadProgress;
String? _activeDownloadingFilename;
@override
void initState() {
super.initState();
_initDefaultModelIfAvailable();
}
@override
void dispose() {
_downloader?.dispose();
_session?.dispose();
_engine?.dispose();
_promptController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<String> _getModelsDirectory() async {
final docDir = await getApplicationDocumentsDirectory();
final dir = Directory('${docDir.path}/gguf_models');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir.path;
}
Future<void> _initDefaultModelIfAvailable() async {
final dirPath = await _getModelsDirectory();
for (final preset in kModelPresets) {
final file = File('$dirPath/${preset.filename}');
if (await file.exists()) {
await _loadModel(file.path, preset.name, preset.template);
break;
}
}
}
Future<void> _loadModel(String path, String name, ChatTemplate template) async {
setState(() {
_isLoadingModel = true;
});
try {
_session?.dispose();
_engine?.dispose();
final engine = await LocalLlmEngine.loadModel(
modelPath: path,
params: ModelParams(
contextSize: _contextSize,
gpuLayers: _gpuLayers,
),
);
final session = engine.createSession(
defaultTemplate: template,
);
setState(() {
_engine = engine;
_session = session;
_loadedModelName = name;
_activeTemplate = template;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Successfully loaded $name (Hardware Acceleration Active)'),
backgroundColor: const Color(0xFF10B981),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load model: $e'),
backgroundColor: const Color(0xFFEF4444),
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoadingModel = false;
});
}
}
}
Future<void> _startDownload(GgufModelPreset preset) async {
final dirPath = await _getModelsDirectory();
final destPath = '$dirPath/${preset.filename}';
_downloader = ModelDownloader();
setState(() {
_activeDownloadingFilename = preset.filename;
});
final stream = _downloader!.download(
url: preset.url,
destinationPath: destPath,
expectedSha256: preset.sha256,
allowResume: true,
);
stream.listen(
(progress) {
setState(() {
_downloadProgress = progress;
});
if (progress.status == DownloadStatus.completed) {
_loadModel(destPath, preset.name, preset.template);
}
},
onError: (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Download failed: $e'),
backgroundColor: const Color(0xFFEF4444),
),
);
}
},
);
}
void _sendMessage() {
final text = _promptController.text.trim();
if (text.isEmpty || _session == null || _isGenerating) return;
final userMessage = ChatMessage.user(text);
setState(() {
_messages.add(userMessage);
_currentStreamResponse = '';
_isGenerating = true;
_promptController.clear();
});
_scrollToBottom();
String? grammar;
if (_jsonGrammarMode) {
grammar = GrammarHelper.genericJsonGrammar();
}
final stream = _session!.chat(
[userMessage],
params: SamplingParams(
temperature: _temperature,
topP: _topP,
maxTokens: _maxTokens,
),
template: _activeTemplate,
jsonSchemaGrammar: grammar,
onMetrics: (metrics) {
setState(() {
_lastTokensPerSec = metrics.tokensPerSecond;
_lastTtft = metrics.timeToFirstToken;
_lastTokenCount = metrics.totalTokens;
});
},
);
stream.listen(
(token) {
setState(() {
_currentStreamResponse += token;
});
_scrollToBottom();
},
onError: (err) {
setState(() {
_isGenerating = false;
_messages.add(ChatMessage.assistant('Error: $err'));
});
},
onDone: () {
setState(() {
_messages.add(ChatMessage.assistant(_currentStreamResponse));
_currentStreamResponse = '';
_isGenerating = false;
});
_scrollToBottom();
},
);
}
void _cancelGeneration() {
_session?.cancel();
setState(() {
if (_currentStreamResponse.isNotEmpty) {
_messages.add(ChatMessage.assistant('$_currentStreamResponse [Cancelled]'));
_currentStreamResponse = '';
}
_isGenerating = false;
});
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent + 100,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.4)),
),
child: const Icon(Icons.psychology, color: Color(0xFF818CF8), size: 20),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Local LLM Studio',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
Text(
_loadedModelName ?? 'No model loaded',
style: TextStyle(
fontSize: 12,
color: _loadedModelName != null ? const Color(0xFF34D399) : Colors.grey,
),
),
],
),
],
),
actions: [
if (_loadedModelName != null)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
avatar: const Icon(Icons.bolt, color: Color(0xFFF59E0B), size: 16),
label: Text(
_lastTokensPerSec > 0 ? '${_lastTokensPerSec.toStringAsFixed(1)} t/s' : 'Ready',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
backgroundColor: const Color(0xFF1E293B),
side: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
),
),
],
),
body: IndexedStack(
index: _currentIndex,
children: [
_buildChatView(),
_buildModelHubView(),
_buildSettingsView(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) => setState(() => _currentIndex = index),
backgroundColor: const Color(0xFF0F172A),
indicatorColor: const Color(0xFF6366F1).withValues(alpha: 0.3),
destinations: const [
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble, color: Color(0xFF818CF8)),
label: 'Chat',
),
NavigationDestination(
icon: Icon(Icons.cloud_download_outlined),
selectedIcon: Icon(Icons.cloud_download, color: Color(0xFF818CF8)),
label: 'Model Hub',
),
NavigationDestination(
icon: Icon(Icons.tune_outlined),
selectedIcon: Icon(Icons.tune, color: Color(0xFF818CF8)),
label: 'Parameters',
),
],
),
);
}
Widget _buildChatView() {
return Column(
children: [
// Live Telemetry HUD Bar
if (_loadedModelName != null) _buildTelemetryHud(),
// Messages List
Expanded(
child: _messages.isEmpty && _currentStreamResponse.isEmpty
? _buildEmptyState()
: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: _messages.length + (_currentStreamResponse.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index < _messages.length) {
return _buildMessageBubble(_messages[index]);
} else {
return _buildStreamingBubble();
}
},
),
),
// Input Area
_buildInputBar(),
],
);
}
Widget _buildTelemetryHud() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
border: Border(bottom: BorderSide(color: Colors.white.withValues(alpha: 0.05))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildHudMetric('Speed', '${_lastTokensPerSec.toStringAsFixed(1)} tok/s', Icons.speed),
_buildHudMetric('TTFT', '${_lastTtft.inMilliseconds} ms', Icons.timer_outlined),
_buildHudMetric('Tokens', '$_lastTokenCount eval', Icons.generating_tokens_outlined),
_buildHudMetric(
'Format',
_jsonGrammarMode ? 'JSON Mode' : _activeTemplate.name,
Icons.code,
),
],
),
);
}
Widget _buildHudMetric(String label, String value, IconData icon) {
return Row(
children: [
Icon(icon, size: 14, color: const Color(0xFF818CF8)),
const SizedBox(width: 4),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: const TextStyle(fontSize: 10, color: Colors.grey)),
Text(
value,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white),
),
],
),
],
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1E293B).withValues(alpha: 0.6),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.3)),
),
child: const Icon(Icons.auto_awesome, color: Color(0xFF818CF8), size: 48),
),
const SizedBox(height: 24),
Text(
_loadedModelName != null ? 'Ready for On-Device Chat' : 'Download a Model to Start',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
Text(
_loadedModelName != null
? 'Private, zero-latency inference running locally with GPU acceleration.'
: 'Head over to the Model Hub tab to download a high-performance GGUF model.',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
if (_loadedModelName != null) ...[
const SizedBox(height: 24),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_buildQuickPromptChip('Explain quantum computing simply'),
_buildQuickPromptChip('Write a Flutter Riverpod counter'),
_buildQuickPromptChip('Generate JSON user profile'),
],
),
],
],
),
),
);
}
Widget _buildQuickPromptChip(String prompt) {
return ActionChip(
label: Text(prompt, style: const TextStyle(fontSize: 12, color: Colors.white70)),
backgroundColor: const Color(0xFF1E293B),
side: BorderSide(color: const Color(0xFF6366F1).withValues(alpha: 0.3)),
onPressed: () {
_promptController.text = prompt;
_sendMessage();
},
);
}
Widget _buildMessageBubble(ChatMessage message) {
final isUser = message.role == ChatMessageRole.user;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(14),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
decoration: BoxDecoration(
color: isUser ? const Color(0xFF6366F1) : const Color(0xFF1E293B),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isUser ? 16 : 4),
bottomRight: Radius.circular(isUser ? 4 : 16),
),
border: Border.all(
color: isUser
? Colors.transparent
: Colors.white.withValues(alpha: 0.08),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isUser ? Icons.person : Icons.smart_toy,
size: 14,
color: isUser ? Colors.white70 : const Color(0xFF818CF8),
),
const SizedBox(width: 6),
Text(
isUser ? 'You' : (_loadedModelName ?? 'Assistant'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isUser ? Colors.white70 : const Color(0xFF818CF8),
),
),
],
),
const SizedBox(height: 6),
SelectableText(
message.content,
style: const TextStyle(fontSize: 14, height: 1.4, color: Colors.white),
),
],
),
),
);
}
Widget _buildStreamingBubble() {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(14),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
bottomLeft: Radius.circular(4),
bottomRight: Radius.circular(16),
),
border: Border.all(color: const Color(0xFF6366F1).withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF818CF8)),
),
const SizedBox(width: 8),
Text(
_loadedModelName ?? 'Generating...',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF818CF8)),
),
],
),
const SizedBox(height: 6),
SelectableText(
_currentStreamResponse,
style: const TextStyle(fontSize: 14, height: 1.4, color: Colors.white),
),
],
),
),
);
}
Widget _buildInputBar() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
border: Border(top: BorderSide(color: Colors.white.withValues(alpha: 0.08))),
),
child: SafeArea(
child: Row(
children: [
if (_isGenerating)
IconButton(
icon: const Icon(Icons.stop_circle, color: Color(0xFFEF4444)),
tooltip: 'Stop generation',
onPressed: _cancelGeneration,
)
else
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.grey),
tooltip: 'Clear history',
onPressed: () {
_session?.clearHistory();
setState(() => _messages.clear());
},
),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: TextField(
controller: _promptController,
decoration: const InputDecoration(
hintText: 'Type a message or instruction...',
hintStyle: TextStyle(color: Colors.grey, fontSize: 14),
border: InputBorder.none,
),
style: const TextStyle(color: Colors.white, fontSize: 14),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: Icon(_isGenerating ? Icons.hourglass_top : Icons.arrow_upward),
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
foregroundColor: Colors.white,
),
onPressed: _isGenerating || _session == null ? null : _sendMessage,
),
],
),
),
);
}
Widget _buildModelHubView() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Curated GGUF Models',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 8),
const Text(
'Optimized lightweight models ready for on-device inference with zero cloud dependency.',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 16),
...kModelPresets.map((preset) => _buildPresetCard(preset)),
],
);
}
Widget _buildPresetCard(GgufModelPreset preset) {
final isLoaded = _loadedModelName == preset.name;
final isDownloading = _activeDownloadingFilename == preset.filename &&
_downloadProgress?.status == DownloadStatus.downloading;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLoaded
? const Color(0xFF10B981)
: Colors.white.withValues(alpha: 0.08),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
preset.name,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white),
),
if (isLoaded)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Active Model',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Color(0xFF34D399)),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
_buildBadge(preset.parameterSize, const Color(0xFF818CF8)),
const SizedBox(width: 6),
_buildBadge(preset.quantization, const Color(0xFF06B6D4)),
const SizedBox(width: 6),
_buildBadge(preset.fileSize, const Color(0xFFF59E0B)),
],
),
const SizedBox(height: 12),
if (isDownloading && _downloadProgress != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _downloadProgress!.progress,
backgroundColor: const Color(0xFF1E293B),
color: const Color(0xFF6366F1),
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_downloadProgress!.downloadedFormatted,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
Text(
_downloadProgress!.speedFormatted,
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF818CF8)),
),
],
),
] else
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
icon: Icon(isLoaded ? Icons.check : Icons.download),
label: Text(isLoaded ? 'Loaded' : 'Download & Run'),
style: ElevatedButton.styleFrom(
backgroundColor: isLoaded ? const Color(0xFF10B981) : const Color(0xFF6366F1),
foregroundColor: Colors.white,
),
onPressed: isLoaded || _isLoadingModel ? null : () => _startDownload(preset),
),
],
),
],
),
);
}
Widget _buildBadge(String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color),
),
);
}
Widget _buildSettingsView() {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Inference Parameters',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 16),
_buildSliderTile(
title: 'Temperature',
subtitle: 'Controls response creativity vs determinism',
value: _temperature,
min: 0.0,
max: 1.5,
divisions: 15,
onChanged: (val) => setState(() => _temperature = val),
),
_buildSliderTile(
title: 'Top-P Sampling',
subtitle: 'Nucleus sampling threshold',
value: _topP,
min: 0.1,
max: 1.0,
divisions: 9,
onChanged: (val) => setState(() => _topP = val),
),
_buildSliderTile(
title: 'Max Output Tokens',
subtitle: 'Maximum token limit for reply generation',
value: _maxTokens.toDouble(),
min: 64,
max: 2048,
divisions: 31,
onChanged: (val) => setState(() => _maxTokens = val.toInt()),
),
const Divider(color: Colors.white12, height: 32),
const Text(
'Hardware & Acceleration',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Structured JSON Mode (GBNF Grammar)'),
subtitle: const Text('Enforces strict valid JSON output adhering to GBNF schema'),
value: _jsonGrammarMode,
activeThumbColor: const Color(0xFF6366F1),
onChanged: (val) => setState(() => _jsonGrammarMode = val),
),
ListTile(
title: const Text('Context Window Size'),
subtitle: Text('$_contextSize tokens'),
trailing: DropdownButton<int>(
value: _contextSize,
dropdownColor: const Color(0xFF1E293B),
items: const [
DropdownMenuItem(value: 2048, child: Text('2048 tokens')),
DropdownMenuItem(value: 4096, child: Text('4096 tokens')),
DropdownMenuItem(value: 8192, child: Text('8192 tokens')),
],
onChanged: (val) {
if (val != null) setState(() => _contextSize = val);
},
),
),
ListTile(
title: const Text('GPU Layer Offloading'),
subtitle: Text('$_gpuLayers layers to Metal/Vulkan (99 = All Layers)'),
trailing: DropdownButton<int>(
value: _gpuLayers,
dropdownColor: const Color(0xFF1E293B),
items: const [
DropdownMenuItem(value: 99, child: Text('Full GPU (Metal/Vulkan)')),
DropdownMenuItem(value: 16, child: Text('Hybrid (16 Layers)')),
DropdownMenuItem(value: 0, child: Text('CPU Only (OpenMP)')),
],
onChanged: (val) {
if (val != null) setState(() => _gpuLayers = val);
},
),
),
],
);
}
Widget _buildSliderTile({
required String title,
required String subtitle,
required double value,
required double min,
required double max,
required int divisions,
required ValueChanged<double> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF131B2E),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)),
Text(
value.toStringAsFixed(value is int ? 0 : 2),
style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF818CF8)),
),
],
),
const SizedBox(height: 2),
Text(subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
activeColor: const Color(0xFF6366F1),
onChanged: onChanged,
),
],
),
);
}
}