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

@ -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(
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<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),