import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:dio/dio.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:trainhub_flutter/presentation/settings/ai_model_settings_state.dart'; part 'ai_model_settings_controller.g.dart'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const _llamaBuild = 'b8130'; const _nomicModelFile = 'nomic-embed-text-v1.5.Q4_K_M.gguf'; const _qwenModelFile = 'qwen2.5-7b-instruct-q4_k_m.gguf'; const _nomicModelUrl = 'https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf'; const _qwenModelUrl = 'https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf'; // --------------------------------------------------------------------------- // Platform helpers // --------------------------------------------------------------------------- /// Returns the llama.cpp archive download URL for the current platform. /// Throws [UnsupportedError] if the platform is not supported. Future _llamaArchiveUrl() async { if (Platform.isMacOS) { // Detect CPU architecture via `uname -m` final result = await Process.run('uname', ['-m']); final arch = (result.stdout as String).trim(); if (arch == 'arm64') { return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-arm64.tar.gz'; } else { return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-macos-x64.tar.gz'; } } else if (Platform.isWindows) { return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-win-vulkan-x64.zip'; } else if (Platform.isLinux) { return 'https://github.com/ggml-org/llama.cpp/releases/download/$_llamaBuild/llama-$_llamaBuild-bin-ubuntu-vulkan-x64.tar.gz'; } throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); } /// The expected llama-server binary name for the current platform. String get _serverBinaryName => Platform.isWindows ? 'llama-server.exe' : 'llama-server'; // --------------------------------------------------------------------------- // Controller // --------------------------------------------------------------------------- @riverpod class AiModelSettingsController extends _$AiModelSettingsController { final _dio = Dio(); @override AiModelSettingsState build() => const AiModelSettingsState(); // ------------------------------------------------------------------------- // Validation // ------------------------------------------------------------------------- /// Checks whether all required files exist on disk and updates /// [AiModelSettingsState.areModelsValidated]. Future validateModels() async { state = state.copyWith( currentTask: 'Checking installed files…', errorMessage: null, ); try { final dir = await getApplicationDocumentsDirectory(); final base = dir.path; final serverBin = File(p.join(base, _serverBinaryName)); final nomicModel = File(p.join(base, _nomicModelFile)); final qwenModel = File(p.join(base, _qwenModelFile)); final validated = serverBin.existsSync() && nomicModel.existsSync() && qwenModel.existsSync(); state = state.copyWith( areModelsValidated: validated, currentTask: validated ? 'All files present.' : 'Files missing.', ); } catch (e) { state = state.copyWith( areModelsValidated: false, currentTask: 'Validation failed.', errorMessage: e.toString(), ); } } // ------------------------------------------------------------------------- // Download & Install // ------------------------------------------------------------------------- /// Downloads and installs the llama.cpp binary and both model files. Future downloadAll() async { if (state.isDownloading) return; state = state.copyWith( isDownloading: true, progress: 0.0, areModelsValidated: false, errorMessage: null, ); try { final dir = await getApplicationDocumentsDirectory(); // -- 1. llama.cpp binary ----------------------------------------------- final archiveUrl = await _llamaArchiveUrl(); final archiveExt = archiveUrl.endsWith('.zip') ? '.zip' : '.tar.gz'; final archivePath = p.join(dir.path, 'llama_binary$archiveExt'); await _downloadFile( url: archiveUrl, savePath: archivePath, taskLabel: 'Downloading llama.cpp binary…', overallStart: 0.0, overallEnd: 0.2, ); state = state.copyWith( currentTask: 'Extracting llama.cpp binary…', progress: 0.2, ); await _extractBinary(archivePath, dir.path); // Clean up the archive once extracted final archiveFile = File(archivePath); if (archiveFile.existsSync()) archiveFile.deleteSync(); // -- 2. Nomic embedding model ------------------------------------------ await _downloadFile( url: _nomicModelUrl, savePath: p.join(dir.path, _nomicModelFile), taskLabel: 'Downloading Nomic embedding model…', overallStart: 0.2, overallEnd: 0.55, ); // -- 3. Qwen chat model ------------------------------------------------ await _downloadFile( url: _qwenModelUrl, savePath: p.join(dir.path, _qwenModelFile), taskLabel: 'Downloading Qwen 2.5 7B model…', overallStart: 0.55, overallEnd: 1.0, ); state = state.copyWith( isDownloading: false, progress: 1.0, currentTask: 'Download complete.', ); await validateModels(); } on DioException catch (e) { state = state.copyWith( isDownloading: false, currentTask: 'Download failed.', errorMessage: 'Network error: ${e.message}', ); } catch (e) { state = state.copyWith( isDownloading: false, currentTask: 'Download failed.', errorMessage: e.toString(), ); } } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /// Downloads a single file with progress mapped into [overallStart]..[overallEnd]. Future _downloadFile({ required String url, required String savePath, required String taskLabel, required double overallStart, required double overallEnd, }) async { state = state.copyWith(currentTask: taskLabel, progress: overallStart); await _dio.download( url, savePath, onReceiveProgress: (received, total) { if (total <= 0) return; final fileProgress = received / total; final overall = overallStart + fileProgress * (overallEnd - overallStart); state = state.copyWith(progress: overall); }, options: Options( followRedirects: true, maxRedirects: 5, receiveTimeout: const Duration(hours: 2), ), ); } /// Extracts the downloaded archive and moves `llama-server[.exe]` to [destDir]. Future _extractBinary(String archivePath, String destDir) async { final extractDir = p.join(destDir, '_llama_extract_tmp'); final extractDirObj = Directory(extractDir); if (extractDirObj.existsSync()) extractDirObj.deleteSync(recursive: true); extractDirObj.createSync(recursive: true); try { if (archivePath.endsWith('.zip')) { await extractFileToDisk(archivePath, extractDir); } else { // .tar.gz — use extractFileToDisk which handles both via the archive package await extractFileToDisk(archivePath, extractDir); } // Walk the extracted tree to find the server binary final binary = _findFile(extractDirObj, _serverBinaryName); if (binary == null) { throw FileSystemException( 'llama-server binary not found in archive.', archivePath, ); } final destBin = p.join(destDir, _serverBinaryName); binary.copySync(destBin); // Make executable on POSIX systems if (Platform.isMacOS || Platform.isLinux) { await Process.run('chmod', ['+x', destBin]); } } finally { // Always clean up the temp extraction directory if (extractDirObj.existsSync()) { extractDirObj.deleteSync(recursive: true); } } } /// Recursively searches [dir] for a file named [name]. File? _findFile(Directory dir, String name) { for (final entity in dir.listSync(recursive: true)) { if (entity is File && p.basename(entity.path) == name) { return entity; } } return null; } }