Skip to content

Commit 6e329d0

Browse files
fix(ai): harden model switching, downloads, and migration
- cancel and await in-flight model downloads before switching configs\n- ignore stale download callbacks so old transfers cannot mutate the new model state\n- dispose LlamaEngine instances on unload and error paths in both chat and embedding services\n- reject non-success HTTP responses before caching model files and normalize forced-cancel errors\n- share GGUF URL validation and filename derivation between settings and model config creation\n- move embedding migration into a reusable post-load flow used by splash and settings\n- keep flutter analyze and both lite/full release APK builds passing
1 parent da98430 commit 6e329d0

7 files changed

Lines changed: 287 additions & 85 deletions

File tree

lib/main.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ late BiomarkerDictionary biomarkerDictionary;
2222
late LlmService llmService;
2323
late LlmEmbeddingService embeddingService;
2424
late VectorStoreService vectorStoreService;
25+
Future<void>? _embeddingMigrationTask;
2526

2627
/// Whether AI features are enabled. Set by the entry point
2728
/// (main_full.dart vs main_lite.dart).
@@ -38,6 +39,38 @@ Future<void> appMain({required bool aiEnabled}) async {
3839
runApp(const KoshikaApp());
3940
}
4041

42+
Future<void> migrateEmbeddingsIfNeeded() {
43+
if (!kAiEnabled || !embeddingService.isLoaded) {
44+
return Future.value();
45+
}
46+
47+
return _embeddingMigrationTask ??= _runEmbeddingMigration().whenComplete(() {
48+
_embeddingMigrationTask = null;
49+
});
50+
}
51+
52+
Future<void> _runEmbeddingMigration() async {
53+
try {
54+
final allResults = objectbox.biomarkerResultBox.getAll();
55+
if (allResults.isEmpty) return;
56+
57+
final needsMigration = allResults.any(
58+
(result) =>
59+
result.embedding == null ||
60+
result.embedding!.isEmpty ||
61+
result.embedding!.length != 384,
62+
);
63+
if (!needsMigration) return;
64+
65+
debugPrint(
66+
'KoshikaApp: migrating ${allResults.length} embeddings to 384-dim',
67+
);
68+
await vectorStoreService.rebuildIndex(allResults);
69+
} catch (e) {
70+
debugPrint('Embedding migration failed (non-fatal): $e');
71+
}
72+
}
73+
4174
class KoshikaApp extends StatelessWidget {
4275
const KoshikaApp({super.key});
4376

lib/models/llm_model_config.dart

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,59 @@
1+
class CustomModelUrlDetails {
2+
final String normalizedUrl;
3+
final String filename;
4+
5+
const CustomModelUrlDetails({
6+
required this.normalizedUrl,
7+
required this.filename,
8+
});
9+
10+
String get suggestedName =>
11+
filename.replaceFirst(RegExp(r'\.gguf$', caseSensitive: false), '');
12+
}
13+
14+
String? _extractGgufFilename(Uri uri) {
15+
if (uri.pathSegments.isNotEmpty) {
16+
final candidate = uri.pathSegments.last;
17+
if (candidate.isNotEmpty && candidate.toLowerCase().endsWith('.gguf')) {
18+
return candidate;
19+
}
20+
}
21+
22+
for (final values in uri.queryParametersAll.values) {
23+
for (final value in values) {
24+
final candidate = _extractGgufFilenameFromValue(value);
25+
if (candidate != null) return candidate;
26+
}
27+
}
28+
29+
return null;
30+
}
31+
32+
String? _extractGgufFilenameFromValue(String value) {
33+
final trimmed = value.trim();
34+
if (trimmed.isEmpty) return null;
35+
36+
final uri = Uri.tryParse(trimmed);
37+
final candidate = uri != null && uri.pathSegments.isNotEmpty
38+
? uri.pathSegments.last
39+
: trimmed.split('/').last;
40+
41+
if (candidate.isEmpty || !candidate.toLowerCase().endsWith('.gguf')) {
42+
return null;
43+
}
44+
45+
return candidate;
46+
}
47+
48+
String _filenameFromDownloadUrl(
49+
String downloadUrl, {
50+
required String fallbackId,
51+
}) {
52+
final uri = Uri.tryParse(downloadUrl);
53+
if (uri == null) return '$fallbackId.gguf';
54+
return _extractGgufFilename(uri) ?? '$fallbackId.gguf';
55+
}
56+
157
/// Configuration for a downloadable GGUF model (curated or custom).
258
class LlmModelConfig {
359
final String id;
@@ -18,8 +74,7 @@ class LlmModelConfig {
1874

1975
/// Derive the on-disk filename from the download URL.
2076
String get filename {
21-
final uri = Uri.parse(downloadUrl);
22-
return uri.pathSegments.isNotEmpty ? uri.pathSegments.last : '$id.gguf';
77+
return _filenameFromDownloadUrl(downloadUrl, fallbackId: id);
2378
}
2479

2580
/// Human-readable size string.
@@ -86,16 +141,43 @@ abstract final class LlmModelRegistry {
86141
required String name,
87142
required String downloadUrl,
88143
}) {
144+
final parsed = inspectCustomDownloadUrl(downloadUrl);
145+
final displayName = name.trim().isNotEmpty
146+
? name.trim()
147+
: parsed.suggestedName;
148+
89149
return LlmModelConfig(
90150
id: 'custom',
91-
name: name,
92-
downloadUrl: downloadUrl,
151+
name: displayName,
152+
downloadUrl: parsed.normalizedUrl,
93153
estimatedSizeMB: 0,
94154
description: 'Custom GGUF model',
95155
isCustom: true,
96156
);
97157
}
98158

159+
static CustomModelUrlDetails inspectCustomDownloadUrl(String rawUrl) {
160+
final normalizedUrl = rawUrl.trim();
161+
if (normalizedUrl.isEmpty) {
162+
throw ArgumentError('URL is required');
163+
}
164+
165+
final uri = Uri.tryParse(normalizedUrl);
166+
if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) {
167+
throw ArgumentError('URL must be a valid HTTPS link');
168+
}
169+
170+
final filename = _extractGgufFilename(uri);
171+
if (filename == null) {
172+
throw ArgumentError('URL must point to a .gguf file');
173+
}
174+
175+
return CustomModelUrlDetails(
176+
normalizedUrl: normalizedUrl,
177+
filename: filename,
178+
);
179+
}
180+
99181
/// Look up a curated model by id. Returns null for unknown ids.
100182
static LlmModelConfig? findById(String id) {
101183
for (final m in curated) {

lib/screens/settings_screen.dart

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
117117
if (result != null) await _onModelSelected(result);
118118
}
119119

120+
Future<void> _loadEmbeddingModel() async {
121+
await embeddingService.loadModel();
122+
await migrateEmbeddingsIfNeeded();
123+
}
124+
120125
// ─── Data Actions ───────────────────────────────────────────────────
121126

122127
Future<void> _deleteAllData() async {
@@ -252,7 +257,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
252257
_EmbeddingModelTile(
253258
modelInfo: _embeddingInfo,
254259
onDownload: () => embeddingService.downloadModel(),
255-
onLoad: () => embeddingService.loadModel(),
260+
onLoad: _loadEmbeddingModel,
256261
onUnload: () => embeddingService.unloadModel(),
257262
),
258263
const SizedBox(height: KoshikaSpacing.sm),
@@ -955,24 +960,14 @@ class _CustomModelDialogState extends State<_CustomModelDialog> {
955960
setState(() => _urlError = 'URL is required');
956961
return;
957962
}
958-
if (!url.toLowerCase().endsWith('.gguf')) {
959-
setState(() => _urlError = 'URL must point to a .gguf file');
960-
return;
961-
}
962-
963-
final displayName = name.isNotEmpty ? name : _filenameFromUrl(url);
964-
Navigator.of(
965-
context,
966-
).pop(LlmModelRegistry.custom(name: displayName, downloadUrl: url));
967-
}
968-
969-
String _filenameFromUrl(String url) {
970963
try {
971-
final uri = Uri.parse(url);
972-
final filename = uri.pathSegments.last;
973-
return filename.replaceAll('.gguf', '');
974-
} catch (_) {
975-
return 'Custom Model';
964+
final parsed = LlmModelRegistry.inspectCustomDownloadUrl(url);
965+
final displayName = name.isNotEmpty ? name : parsed.suggestedName;
966+
Navigator.of(
967+
context,
968+
).pop(LlmModelRegistry.custom(name: displayName, downloadUrl: url));
969+
} on ArgumentError catch (e) {
970+
setState(() => _urlError = e.message?.toString() ?? 'Invalid GGUF URL');
976971
}
977972
}
978973

lib/screens/splash_screen.dart

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ class _SplashScreenState extends State<SplashScreen>
8888
ModelStatus.ready) {
8989
// ignore: unawaited_futures
9090
app_main.embeddingService.loadModel().then((_) {
91-
// Re-embed any results that have stale or missing embeddings
92-
_migrateEmbeddingsIfNeeded();
91+
// Re-embed any results that have stale or missing embeddings.
92+
app_main.migrateEmbeddingsIfNeeded();
9393
});
9494
}
9595
}
@@ -120,35 +120,6 @@ class _SplashScreenState extends State<SplashScreen>
120120
}
121121
}
122122

123-
/// One-time migration: recompute all embeddings when the embedding
124-
/// dimension changes (768 → 384 after flutter_gemma → llama_cpp_dart).
125-
Future<void> _migrateEmbeddingsIfNeeded() async {
126-
if (!app_main.kAiEnabled) return;
127-
if (!app_main.embeddingService.isLoaded) return;
128-
129-
try {
130-
final allResults = app_main.objectbox.biomarkerResultBox.getAll();
131-
if (allResults.isEmpty) return;
132-
133-
// Check if any result has stale (768-dim) or missing embeddings
134-
final needsMigration = allResults.any(
135-
(r) =>
136-
r.embedding == null ||
137-
r.embedding!.isEmpty ||
138-
r.embedding!.length != 384,
139-
);
140-
141-
if (needsMigration) {
142-
debugPrint(
143-
'SplashScreen: migrating ${allResults.length} embeddings to 384-dim',
144-
);
145-
await app_main.vectorStoreService.rebuildIndex(allResults);
146-
}
147-
} catch (e) {
148-
debugPrint('Embedding migration failed (non-fatal): $e');
149-
}
150-
}
151-
152123
@override
153124
void dispose() {
154125
_controller.dispose();

lib/services/llm_embedding_service.dart

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,7 @@ class LlmEmbeddingService {
149149
await _engine!.loadModel(path);
150150
_updateStatus(_modelInfo.copyWith(status: ModelStatus.loaded));
151151
} catch (e) {
152-
await _engine?.dispose();
153-
_engine = null;
152+
await _disposeEngine();
154153
_updateStatus(
155154
_modelInfo.copyWith(
156155
status: ModelStatus.error,
@@ -185,9 +184,7 @@ class LlmEmbeddingService {
185184
// ═══════════════════════════════════════════════════════════════════════
186185

187186
Future<void> unloadModel() async {
188-
if (_engine != null) {
189-
await _engine!.unloadModel();
190-
}
187+
await _disposeEngine(unloadFirst: true);
191188
if (_modelInfo.status == ModelStatus.loaded ||
192189
_modelInfo.status == ModelStatus.loading) {
193190
_updateStatus(_modelInfo.copyWith(status: ModelStatus.ready));
@@ -203,9 +200,22 @@ class LlmEmbeddingService {
203200
if (!_statusController.isClosed) _statusController.add(info);
204201
}
205202

206-
Future<void> dispose() async {
207-
await _engine?.dispose();
203+
Future<void> _disposeEngine({bool unloadFirst = false}) async {
204+
final engine = _engine;
208205
_engine = null;
206+
if (engine == null) return;
207+
208+
try {
209+
if (unloadFirst && engine.isReady) {
210+
await engine.unloadModel();
211+
}
212+
} finally {
213+
await engine.dispose();
214+
}
215+
}
216+
217+
Future<void> dispose() async {
218+
await _disposeEngine();
209219
_statusController.close();
210220
}
211221
}

0 commit comments

Comments
 (0)