feat(studio): show the model download instead of a spinning button

Pulling a model moves gigabytes and could take fifteen minutes behind
a 14-pixel spinner. The model picker now follows the hub's pull stream
and shows a bar with the backend's own status and the megabytes for
the layer in flight.

The byte counts describe the current layer, not the whole model, so
the bar restarts per layer. That is deliberate and stated in the code:
the backend never says how many layers are still coming, so a single
overall number would have to be invented.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-09-08 17:51:28 +02:00
parent 14f9217007
commit fb809a4cbd
2 changed files with 119 additions and 9 deletions

View file

@ -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<PullStep> 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

View file

@ -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<ChainSystemAiEditor> {
});
}
/// Latest observation while a model downloads.
PullStep? _pullStep;
Future<void> _pullModel() async {
final wanted = _model.text.trim();
final l = AppLocalizations.of(context)!;
@ -357,16 +361,29 @@ class _FaiSystemAiEditorState extends State<ChainSystemAiEditor> {
}
setState(() {
_pulling = true;
_pullStep = null;
_error = null;
});
final r = await HubService.instance.pullSystemAiModel(
({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<ChainSystemAiEditor> {
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),