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