This commit is contained in:
263
lib/presentation/settings/ai_model_settings_controller.dart
Normal file
263
lib/presentation/settings/ai_model_settings_controller.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
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<String> _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<void> 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<void> 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<void> _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<void> _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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user