diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 8426185..922171d 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -605,6 +605,36 @@ class HubService { return (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs); } + /// Pull a model while watching it happen. + /// + /// Emits one [PullStep] per observation from the backend. Completes + /// when the pull ends; [onFinished] carries the outcome, whose + /// `errorKind` is non-empty on failure. + Stream pullSystemAiModelStreaming({ + required String endpoint, + required String model, + String apiKeyEnv = '', + void Function(({String errorKind, String text, int elapsedMs}) result)? + onFinished, + }) { + return _client + .pullSystemAiModelStreaming( + endpoint: endpoint, + model: model, + apiKeyEnv: apiKeyEnv, + onFinished: (r) => onFinished?.call( + (errorKind: r.errorKind, text: r.text, elapsedMs: r.elapsedMs), + ), + ) + .map( + (u) => PullStep( + status: u.status, + completed: u.completed.toInt(), + total: u.total.toInt(), + ), + ); + } + /// Detected host hardware. Used by the System-AI editor to /// mark models as "recommended" / "may be slow" against the /// operator's actual machine. @@ -1573,6 +1603,35 @@ class ModuleSummary { /// slug plus renamable presentation metadata. `isolation` is /// `open` (pure label) or `protected` (logically separated — no /// hard process barrier; every UI keeps that honest wording). +/// One observation while a model downloads. +/// +/// [status] is the backend's own wording ("pulling manifest", +/// "verifying sha256:..."), passed through rather than translated: it +/// names layers and digests Studio cannot reconstruct. Byte counts +/// refer to the layer that status is about, not to the whole model, +/// so a bar built from them restarts per layer — which is honest, and +/// better than a single number that would have to be invented. +class PullStep { + /// The backend's status text. + final String status; + + /// Bytes fetched so far for the current layer. + final int completed; + + /// Size of the current layer; 0 when the status carries no size. + final int total; + + const PullStep({ + required this.status, + required this.completed, + required this.total, + }); + + /// Fraction of the current layer, or null while not measurable. + double? get fraction => + total > 0 ? (completed / total).clamp(0.0, 1.0) : null; +} + /// One observation while a module installs. /// /// [percent] is the overall value across all phases and may stand diff --git a/lib/widgets/chain_system_ai_editor.dart b/lib/widgets/chain_system_ai_editor.dart index 154ce2e..e5972eb 100644 --- a/lib/widgets/chain_system_ai_editor.dart +++ b/lib/widgets/chain_system_ai_editor.dart @@ -13,6 +13,7 @@ import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'chain_error_box.dart'; import 'chain_pill.dart'; +import 'chain_progress_bar.dart'; /// Provider preset metadata kept in sync with /// `chain_hub::operator_config::SystemLlmProvider`. Anything that @@ -348,6 +349,9 @@ class _FaiSystemAiEditorState extends State { }); } + /// Latest observation while a model downloads. + PullStep? _pullStep; + Future _pullModel() async { final wanted = _model.text.trim(); final l = AppLocalizations.of(context)!; @@ -357,16 +361,29 @@ class _FaiSystemAiEditorState extends State { } setState(() { _pulling = true; + _pullStep = null; _error = null; }); - final r = await HubService.instance.pullSystemAiModel( - endpoint: _endpoint.text.trim(), - model: wanted, - apiKeyEnv: _apiKeyEnv.text.trim(), - ); + ({String errorKind, String text, int elapsedMs})? outcome; + try { + // Streamed: a model is gigabytes. The unary call left this + // button spinning for minutes with nothing to show. + await for (final step in HubService.instance.pullSystemAiModelStreaming( + endpoint: _endpoint.text.trim(), + model: wanted, + apiKeyEnv: _apiKeyEnv.text.trim(), + onFinished: (r) => outcome = r, + )) { + if (mounted) setState(() => _pullStep = step); + } + } catch (e) { + outcome = (errorKind: 'network', text: e.toString(), elapsedMs: 0); + } if (!mounted) return; + final r = outcome ?? (errorKind: '', text: '', elapsedMs: 0); setState(() { _pulling = false; + _pullStep = null; if (r.errorKind.isNotEmpty) { _error = l.systemAiPullFailedError(r.text); } else { @@ -446,6 +463,7 @@ class _FaiSystemAiEditorState extends State { onPull: _saving || _testing || _loadingModels || _pulling ? null : _pullModel, + pullStep: _pullStep, ), const SizedBox(height: ChainSpace.md), TextField( @@ -677,8 +695,10 @@ class _TestResultPanel extends StatelessWidget { if (ok) SelectableText( l.systemAiReplyPrefix(result.text), - style: - ChainTheme.mono(size: 11, color: theme.colorScheme.onSurface), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), ) else ChainErrorBox(text: result.text, isError: true, maxHeight: 200), @@ -718,6 +738,9 @@ class _ModelPicker extends StatelessWidget { final VoidCallback? onRefresh; final VoidCallback? onPull; + /// Latest observation while a pull runs; null when idle. + final PullStep? pullStep; + const _ModelPicker({ required this.controller, required this.preset, @@ -729,6 +752,7 @@ class _ModelPicker extends StatelessWidget { required this.curatedById, required this.onRefresh, required this.onPull, + this.pullStep, }); bool get _isOllama => preset.wire == 'ollama'; @@ -794,6 +818,27 @@ class _ModelPicker extends StatelessWidget { ], ], ), + if (pulling) ...[ + const SizedBox(height: ChainSpace.sm), + // The byte counts describe the layer currently downloading, + // not the whole model, so the bar restarts per layer. That + // is honest; a single overall number would have to be + // invented, because the backend never says how many layers + // are still coming. + ChainProgressBar( + value: pullStep?.fraction, + stage: pullStep?.status.isNotEmpty == true + ? pullStep!.status + : l.systemAiPulling, + detail: (pullStep?.total ?? 0) > 0 + ? l.installBytes( + (pullStep!.completed / 1048576).toStringAsFixed(1), + (pullStep!.total / 1048576).toStringAsFixed(1), + ) + : null, + height: 6, + ), + ], if (modelsError != null) ...[ const SizedBox(height: 4), Text( @@ -1058,7 +1103,10 @@ class _HardwareBanner extends StatelessWidget { ? l.systemAiHwReviewed(lastReviewed!) : ''; return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.sm, + vertical: 6, + ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm), @@ -1159,7 +1207,10 @@ class _CacheStatusRow extends StatelessWidget { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( - padding: const EdgeInsets.symmetric(horizontal: ChainSpace.sm, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.sm, + vertical: 6, + ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(ChainRadius.sm),