Skip to content

Commit 6cf3da5

Browse files
committed
feat: add model download controller
Refs #135
1 parent 5d0a701 commit 6cf3da5

7 files changed

Lines changed: 820 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
* Added WebGPU readiness guidance covering browser capability checks,
55
cross-origin isolation, bridge asset/version diagnostics, fallback behavior,
66
model/configuration pressure, and the Flutter Web real-model smoke path.
7+
* **Model download UX**:
8+
* Added `ModelDownloadController`, a dependency-free helper that turns
9+
`ModelDownloadManager` cache/download work into app-facing lifecycle states
10+
for resolving, cache checks, downloads, verification, ready, failed,
11+
cancelled, and retry flows.
712

813
## 0.6.13
914

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
import 'dart:async';
2+
3+
import '../../exceptions.dart';
4+
import '../model_load_options.dart';
5+
import '../model_source.dart';
6+
import 'model_download_manager_base.dart';
7+
import 'model_download_manager_stub.dart'
8+
if (dart.library.io) '../../../platform/io/model_download_manager_io.dart';
9+
10+
/// High-level lifecycle stage for an app-facing model download task.
11+
enum ModelDownloadTaskStage {
12+
/// No task has started yet.
13+
idle,
14+
15+
/// The source and task options are being prepared.
16+
resolving,
17+
18+
/// The package-managed cache is being checked before network work starts.
19+
checkingCache,
20+
21+
/// Remote bytes are being downloaded or cached by the manager.
22+
downloading,
23+
24+
/// The manager is finalizing, verifying, or promoting the resolved file.
25+
verifying,
26+
27+
/// The model is available as a [ModelCacheEntry].
28+
ready,
29+
30+
/// The task failed with an actionable, redacted [ModelDownloadTaskSnapshot.errorMessage].
31+
failed,
32+
33+
/// The task was cancelled cooperatively.
34+
cancelled,
35+
}
36+
37+
/// Immutable app-facing state for a [ModelDownloadController].
38+
class ModelDownloadTaskSnapshot {
39+
/// Creates a model download task snapshot.
40+
const ModelDownloadTaskSnapshot({
41+
required this.stage,
42+
this.source,
43+
this.entry,
44+
this.progress,
45+
this.errorMessage,
46+
});
47+
48+
/// Initial idle snapshot.
49+
const ModelDownloadTaskSnapshot.idle()
50+
: stage = ModelDownloadTaskStage.idle,
51+
source = null,
52+
entry = null,
53+
progress = null,
54+
errorMessage = null;
55+
56+
/// Current lifecycle stage.
57+
final ModelDownloadTaskStage stage;
58+
59+
/// Source being resolved or downloaded, when a task has started.
60+
final ModelSource? source;
61+
62+
/// Resolved cache entry after [stage] becomes [ModelDownloadTaskStage.ready].
63+
final ModelCacheEntry? entry;
64+
65+
/// Latest byte-level progress reported by the underlying manager.
66+
final ModelDownloadProgress? progress;
67+
68+
/// Redacted user-facing failure/cancellation message, when available.
69+
final String? errorMessage;
70+
71+
/// Whether the task is actively doing asynchronous work.
72+
bool get isRunning {
73+
return switch (stage) {
74+
ModelDownloadTaskStage.resolving ||
75+
ModelDownloadTaskStage.checkingCache ||
76+
ModelDownloadTaskStage.downloading ||
77+
ModelDownloadTaskStage.verifying => true,
78+
_ => false,
79+
};
80+
}
81+
82+
/// Whether [ModelDownloadController.retry] can retry this snapshot's source.
83+
bool get canRetry {
84+
return source != null &&
85+
(stage == ModelDownloadTaskStage.failed ||
86+
stage == ModelDownloadTaskStage.cancelled);
87+
}
88+
89+
/// Best-known completion fraction, or null when unknown.
90+
double? get fraction {
91+
if (stage == ModelDownloadTaskStage.ready) {
92+
return 1.0;
93+
}
94+
return progress?.fraction;
95+
}
96+
}
97+
98+
/// Small, dependency-free controller for app model download/cache UX.
99+
///
100+
/// The controller wraps a [ModelDownloadManager] and converts low-level cache
101+
/// and byte progress callbacks into stable app states: resolving, cache check,
102+
/// downloading, verifying, ready, failed, and cancelled. It intentionally uses
103+
/// `dart:async` streams rather than Flutter types so it can be adapted to
104+
/// `ValueNotifier`, `ChangeNotifier`, BLoC, Riverpod, or any other UI layer.
105+
class ModelDownloadController {
106+
/// Creates a model download controller.
107+
///
108+
/// When [manager] is omitted the platform default manager is used. On
109+
/// platforms without package-managed download support, starting a task emits a
110+
/// failed snapshot with the manager's unsupported-operation message.
111+
ModelDownloadController({ModelDownloadManager? manager})
112+
: manager = manager ?? DefaultModelDownloadManager();
113+
114+
/// Low-level manager used to inspect caches and resolve/download models.
115+
final ModelDownloadManager manager;
116+
117+
final StreamController<ModelDownloadTaskSnapshot> _snapshots =
118+
StreamController<ModelDownloadTaskSnapshot>.broadcast(sync: true);
119+
120+
ModelDownloadTaskSnapshot _snapshot = const ModelDownloadTaskSnapshot.idle();
121+
ModelDownloadCancelToken? _cancelToken;
122+
ModelSource? _lastSource;
123+
ModelLoadOptions _lastOptions = ModelLoadOptions.defaults;
124+
bool _isDisposed = false;
125+
int _generation = 0;
126+
127+
/// Latest snapshot, synchronously updated before each stream event is emitted.
128+
ModelDownloadTaskSnapshot get snapshot => _snapshot;
129+
130+
/// Broadcast stream of task snapshots.
131+
Stream<ModelDownloadTaskSnapshot> get snapshots => _snapshots.stream;
132+
133+
/// Starts resolving [source] with [options].
134+
///
135+
/// Throws [StateError] when another task is already running. The returned
136+
/// future completes with the ready [ModelCacheEntry] or rethrows the manager's
137+
/// failure after emitting a failed/cancelled snapshot.
138+
Future<ModelCacheEntry> start(
139+
ModelSource source, {
140+
ModelLoadOptions options = ModelLoadOptions.defaults,
141+
}) {
142+
_throwIfDisposed();
143+
if (options.cancelToken != null) {
144+
throw ArgumentError.value(
145+
options.cancelToken,
146+
'options.cancelToken',
147+
'ModelDownloadController owns cancellation; call cancel() on the controller instead.',
148+
);
149+
}
150+
if (_snapshot.isRunning) {
151+
throw StateError('A model download task is already running.');
152+
}
153+
_lastSource = source;
154+
_lastOptions = options;
155+
final generation = _generation + 1;
156+
_generation = generation;
157+
final cancelToken = ModelDownloadCancelToken();
158+
_cancelToken = cancelToken;
159+
final effectiveOptions = _withCancelToken(options, cancelToken);
160+
return _run(generation, source, options, effectiveOptions, cancelToken);
161+
}
162+
163+
/// Retries the last source with the last options passed to [start].
164+
Future<ModelCacheEntry> retry() {
165+
final source = _lastSource;
166+
if (source == null) {
167+
throw StateError('No model download task is available to retry.');
168+
}
169+
return start(source, options: _lastOptions);
170+
}
171+
172+
/// Requests cooperative cancellation for the active task.
173+
void cancel() {
174+
_cancelToken?.cancel();
175+
}
176+
177+
/// Cancels any active task and closes the snapshot stream.
178+
Future<void> dispose() async {
179+
if (_isDisposed) {
180+
return;
181+
}
182+
_isDisposed = true;
183+
cancel();
184+
await _snapshots.close();
185+
}
186+
187+
Future<ModelCacheEntry> _run(
188+
int generation,
189+
ModelSource source,
190+
ModelLoadOptions originalOptions,
191+
ModelLoadOptions effectiveOptions,
192+
ModelDownloadCancelToken cancelToken,
193+
) async {
194+
ModelDownloadProgress? latestProgress;
195+
try {
196+
_emit(
197+
generation,
198+
ModelDownloadTaskSnapshot(
199+
stage: ModelDownloadTaskStage.resolving,
200+
source: source,
201+
),
202+
);
203+
_throwIfCancelled(cancelToken);
204+
205+
final shouldCheckCache =
206+
source.isRemote &&
207+
originalOptions.cachePolicy != ModelCachePolicy.refresh &&
208+
originalOptions.cachePolicy != ModelCachePolicy.noCache;
209+
var cacheHit = false;
210+
if (shouldCheckCache) {
211+
_emit(
212+
generation,
213+
ModelDownloadTaskSnapshot(
214+
stage: ModelDownloadTaskStage.checkingCache,
215+
source: source,
216+
),
217+
);
218+
final cached = await manager.get(
219+
source.cacheKey,
220+
cacheDirectory: originalOptions.cacheDirectory,
221+
);
222+
_throwIfCancelled(cancelToken);
223+
if (cached != null) {
224+
cacheHit = true;
225+
}
226+
}
227+
228+
final shouldReportDownload =
229+
source.isRemote &&
230+
!cacheHit &&
231+
originalOptions.cachePolicy != ModelCachePolicy.cacheOnly;
232+
if (shouldReportDownload) {
233+
_emit(
234+
generation,
235+
ModelDownloadTaskSnapshot(
236+
stage: ModelDownloadTaskStage.downloading,
237+
source: source,
238+
),
239+
);
240+
} else {
241+
_emit(
242+
generation,
243+
ModelDownloadTaskSnapshot(
244+
stage: ModelDownloadTaskStage.verifying,
245+
source: source,
246+
),
247+
);
248+
}
249+
250+
final entry = await manager.ensureModel(
251+
source,
252+
options: effectiveOptions,
253+
onProgress: (progress) {
254+
latestProgress = progress;
255+
_emit(
256+
generation,
257+
ModelDownloadTaskSnapshot(
258+
stage: ModelDownloadTaskStage.downloading,
259+
source: source,
260+
progress: progress,
261+
),
262+
);
263+
},
264+
);
265+
_throwIfCancelled(cancelToken);
266+
267+
if (_snapshot.stage != ModelDownloadTaskStage.verifying) {
268+
_emit(
269+
generation,
270+
ModelDownloadTaskSnapshot(
271+
stage: ModelDownloadTaskStage.verifying,
272+
source: source,
273+
progress: latestProgress,
274+
),
275+
);
276+
}
277+
_emit(
278+
generation,
279+
ModelDownloadTaskSnapshot(
280+
stage: ModelDownloadTaskStage.ready,
281+
source: source,
282+
entry: entry,
283+
progress: latestProgress,
284+
),
285+
);
286+
return entry;
287+
} catch (error) {
288+
if (_isCancelledError(cancelToken)) {
289+
_emit(
290+
generation,
291+
ModelDownloadTaskSnapshot(
292+
stage: ModelDownloadTaskStage.cancelled,
293+
source: source,
294+
progress: latestProgress,
295+
errorMessage: 'Download cancelled for ${source.displayName}.',
296+
),
297+
);
298+
} else {
299+
_emit(
300+
generation,
301+
ModelDownloadTaskSnapshot(
302+
stage: ModelDownloadTaskStage.failed,
303+
source: source,
304+
progress: latestProgress,
305+
errorMessage: _redactedErrorMessage(error),
306+
),
307+
);
308+
}
309+
rethrow;
310+
} finally {
311+
if (generation == _generation) {
312+
_cancelToken = null;
313+
}
314+
}
315+
}
316+
317+
void _emit(int generation, ModelDownloadTaskSnapshot snapshot) {
318+
if (_isDisposed || generation != _generation) {
319+
return;
320+
}
321+
_snapshot = snapshot;
322+
_snapshots.add(snapshot);
323+
}
324+
325+
void _throwIfDisposed() {
326+
if (_isDisposed) {
327+
throw StateError('ModelDownloadController has been disposed.');
328+
}
329+
}
330+
}
331+
332+
ModelLoadOptions _withCancelToken(
333+
ModelLoadOptions options,
334+
ModelDownloadCancelToken cancelToken,
335+
) {
336+
return ModelLoadOptions(
337+
cachePolicy: options.cachePolicy,
338+
cacheDirectory: options.cacheDirectory,
339+
sha256: options.sha256,
340+
bearerToken: options.bearerToken,
341+
headers: options.headers,
342+
cancelToken: cancelToken,
343+
resume: options.resume,
344+
maxRetries: options.maxRetries,
345+
);
346+
}
347+
348+
void _throwIfCancelled(ModelDownloadCancelToken cancelToken) {
349+
if (cancelToken.isCancelled) {
350+
throw LlamaStateException('Model download was cancelled.');
351+
}
352+
}
353+
354+
bool _isCancelledError(ModelDownloadCancelToken cancelToken) {
355+
return cancelToken.isCancelled;
356+
}
357+
358+
String _redactedErrorMessage(Object error) {
359+
return error.toString().replaceAllMapped(_urlPattern, (match) {
360+
final value = match.group(0)!;
361+
final trailing = _trailingPunctuation.firstMatch(value)?.group(0) ?? '';
362+
final candidate = trailing.isEmpty
363+
? value
364+
: value.substring(0, value.length - trailing.length);
365+
final uri = Uri.tryParse(candidate);
366+
if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) {
367+
return '<redacted-url>$trailing';
368+
}
369+
final redacted = Uri(
370+
scheme: uri.scheme,
371+
host: uri.host,
372+
port: uri.hasPort ? uri.port : null,
373+
path: uri.path,
374+
);
375+
return '${redacted.toString()}$trailing';
376+
});
377+
}
378+
379+
final RegExp _urlPattern = RegExp(r'https?:\/\/\S+');
380+
final RegExp _trailingPunctuation = RegExp(r'[.?!:\)\]\}>]+$');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export 'model_download_controller.dart';
12
export 'model_download_manager_base.dart';
23
export 'model_download_manager_stub.dart'
34
if (dart.library.io) '../../../platform/io/model_download_manager_io.dart';

0 commit comments

Comments
 (0)