ollama-app/lib/widgets/model_adder.dart

552 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:ollama_dart/ollama_dart.dart' as ollama;
import 'package:url_launcher/url_launcher.dart';
import '../l10n/gen/app_localizations.dart';
import '../services/services.dart';
import 'alert_dialog_child.dart';
final modelTagRegex = RegExp(
r"^((?:(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*|\[(?:[a-fA-F0-9:]+)\])(?::[0-9]+)?/)?[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*)(?::([\w][\w.-]{0,127}))?$",
);
Map<String, StreamSubscription<ollama.StatusEvent>> _downloadingModels = {};
Map<String, _DownloadingModelsState> _downloadingModelsState = {};
class _DownloadingModelsState with ChangeNotifier {
double? progress;
int? total;
bool completedType;
bool? pageState;
bool cancelFlag;
bool cancelledType;
bool errorNotFoundType;
bool errorNetworkType;
bool errorCorruptionType;
void _notify() => notifyListeners();
_DownloadingModelsState()
: progress = null,
total = null,
completedType = false,
pageState = null,
cancelFlag = false,
cancelledType = false,
errorNotFoundType = false,
errorNetworkType = false,
errorCorruptionType = false;
}
class ModelAdder extends StatefulWidget {
const ModelAdder({super.key});
@override
State<ModelAdder> createState() => _ModelAdderState();
}
class _ModelAdderState extends State<ModelAdder> {
bool? get _pageState =>
modelName != null ? _downloadingModelsState[modelName]?.pageState : false;
set _pageState(bool? value) => modelName != null
? _downloadingModelsState[modelName]!.pageState = value
: null;
bool _wantPop = false;
final _controller = TextEditingController();
final _focusNode = FocusNode();
String? Function() _errorText = () => null;
bool _errorIsCorruption = false;
String? modelName;
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_downloadingModelsState.forEach(
(_, state) => state.removeListener(onUpdate),
);
super.dispose();
}
Future<void> _downloadModel([String? model]) async {
var modelName = model ?? _controller.text.trim();
if (modelName.isEmpty) {
_focusNode.requestFocus();
return;
}
if (!modelName.contains(":")) {
modelName = "$modelName:latest";
}
_errorText = () => null;
_errorIsCorruption = false;
if (mounted) setState(() {});
if (ModelManager.instance.models.any((model) => model.name == modelName)) {
_errorText = () =>
AppLocalizations.of(context).modelDialogAddPromptAlreadyExists;
if (mounted) setState(() {});
_focusNode.requestFocus();
return;
}
if (!modelTagRegex.hasMatch(modelName)) {
_errorText = () =>
AppLocalizations.of(context).modelDialogAddPromptInvalid;
if (mounted) setState(() {});
_focusNode.requestFocus();
return;
}
_pageState = null;
if (mounted) setState(() {});
try {
_downloadingModelsState
.putIfAbsent(modelName, _DownloadingModelsState.new)
.addListener(onUpdate);
this.modelName = modelName;
var sub = _downloadingModels[modelName];
sub ??= ollamaClient.models
.pullStream(
request: ollama.PullRequest(model: modelName, stream: true),
)
.listen(
(event) {
final state = _downloadingModelsState[modelName]!;
if (state.cancelFlag) {
state
..cancelledType = true
.._notify();
this.modelName = null;
_downloadingModels.remove(modelName);
_downloadingModelsState.remove(modelName);
sub?.cancel();
return;
} else if (event.status == "success") {
state
..completedType = true
.._notify();
this.modelName = null;
_downloadingModels.remove(modelName);
_downloadingModelsState.remove(modelName);
return;
}
if ((event.total ?? 0) == 0 && (event.progress ?? 0) == 0) return;
_pageState = true;
if ((event.progress ?? -1) > (state.progress ?? -1)) {
state.progress = event.progress;
}
if ((event.total ?? -1) > (state.total ?? -1)) {
state.total = event.total;
}
if (!_downloadingModels.containsKey(modelName)) {
_downloadingModels[modelName] = sub!;
}
state._notify();
},
onError: (Object error) {
final state = _downloadingModelsState[modelName];
if (error is ollama.StreamException) {
if (error.message.contains("EOF")) {
state?.errorCorruptionType = true;
} else {
state?.errorNotFoundType = true;
}
} else {
state?.errorNetworkType = true;
}
state?._notify();
this.modelName = null;
_downloadingModels.remove(modelName);
_downloadingModelsState.remove(modelName);
sub?.cancel();
},
);
} catch (_) {
if (!mounted) return;
_downloadingModelsState[modelName]
?..errorNetworkType = true
.._notify();
_downloadingModels.remove(modelName);
_downloadingModelsState.remove(modelName);
if (mounted) {
setState(() {});
_focusNode.requestFocus();
}
}
}
void onUpdate() {
final state = modelName != null ? _downloadingModelsState[modelName] : null;
if (state == null || !mounted) return;
if (state.completedType || state.cancelledType) {
final modelName = state.completedType ? this.modelName : null;
ModelManager.instance.loadModels().then((_) {
if (mounted) Navigator.of(context).pop(modelName);
});
return;
} else if (state.errorNotFoundType ||
state.errorNetworkType ||
state.errorCorruptionType) {
_errorText = () {
if (state.errorNotFoundType) {
return AppLocalizations.of(context).modelDialogAddPromptNotFound;
} else if (state.errorNetworkType) {
return AppLocalizations.of(context).modelDialogAddPromptNetworkError;
} else if (state.errorCorruptionType) {
_errorIsCorruption = true;
return AppLocalizations.of(
context,
).modelDialogAddPromptCorruptionError;
}
return null;
};
if (mounted) setState(() {});
_focusNode.requestFocus();
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final state = modelName != null ? _downloadingModelsState[modelName] : null;
final colorScheme = ColorScheme.of(context);
final textTheme = TextTheme.of(context);
final appLocalizations = AppLocalizations.of(context);
final breakpoint = Breakpoint.of(context);
final embeddedNavigation = breakpoint.panesRecommended >= 2;
final alignment = !embeddedNavigation
? AlignmentDirectional.bottomCenter
: AlignmentDirectional.topStart;
const constraints = BoxConstraints(minWidth: 280, maxWidth: 360);
final childInput = AlertDialogChild(
key: const ValueKey("input"),
title: Text(appLocalizations.modelDialogAddPromptTitle),
content: SizedBox(
width: 560,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(appLocalizations.modelDialogAddPromptDescription),
const SizedBox(height: 12),
TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.done,
maxLines: 1,
onSubmitted: (_) => _downloadModel(),
decoration: InputDecoration(
hintText: "gemma4:latest",
errorText: _errorText.call(),
errorMaxLines: 4,
),
),
if (_errorIsCorruption) ...[
ListTile(
contentPadding: EdgeInsets.zero,
minTileHeight: 0,
title: Text.rich(
TextSpan(
style: TextStyle(color: colorScheme.error),
children: [
TextSpan(text: appLocalizations.learnMore),
const TextSpan(text: " "),
WidgetSpan(
child: Builder(
builder: (context) => Transform.translate(
offset: const Offset(0, -1),
child: Icon(
Icons.open_in_new,
size: DefaultTextStyle.of(context).style.fontSize,
color: colorScheme.error,
),
),
),
),
],
),
),
onTap: () => launchUrl(
webOnlyWindowName: "_blank",
Uri.parse(
"https://github.com/ollama/ollama/issues/8088#issuecomment-2541889671",
),
),
),
Text.rich(
TextSpan(
children: [
TextSpan(
text: appLocalizations
.modelDialogAddPromptCorruptionErrorSolution
.split("\n")
.first,
),
const TextSpan(text: "\n"),
TextSpan(
text: r"$ rm -f $OLLAMA_MODELS/blobs/sha256-*partial*",
style: textTheme.labelMedium?.copyWith(
fontFamily: "GoogleSansCode",
color: colorScheme.error,
),
),
const TextSpan(text: "\n"),
TextSpan(
text: appLocalizations
.modelDialogAddPromptCorruptionErrorSolution
.split("\n")
.last,
),
],
style: textTheme.labelMedium?.copyWith(
color: colorScheme.error,
),
),
),
],
if (_downloadingModels.isNotEmpty) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: Card.outlined(
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsetsDirectional.only(
top: 6,
start: 8,
),
child: Text(appLocalizations.modelDialogAddCurrently),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 4,
bottom: 8,
start: 8,
end: 8,
),
child: Wrap(
spacing: 4,
runSpacing: 4,
children: _downloadingModels.keys
.map(
(modelName) => ActionChip(
label: Text(modelName),
onPressed: () => _downloadModel(modelName),
),
)
.toList(),
),
),
],
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appLocalizations.modelDialogAddCancelConfirm),
),
TextButton(
onPressed: _downloadModel,
child: Text(appLocalizations.modelDialogAdd),
),
],
);
const childLoading = SizedBox(
key: ValueKey("loading"),
width: 560,
child: Padding(
padding: EdgeInsets.all(24),
child: LinearProgressIndicator(),
),
);
final childDownload = Builder(
builder: (context) {
final closeInput = [
if (embeddedNavigation) const Divider(height: 1),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 24,
bottom: 4,
start: 24,
end: 24,
),
child: Text(appLocalizations.modelDialogAddCancelTitle),
),
Padding(
padding: const EdgeInsetsDirectional.only(
bottom: 24,
start: 24,
end: 24,
),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton(
onPressed: () async => state!.cancelFlag = true,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
),
child: Text(appLocalizations.modelDialogAddCancelConfirm),
),
const SizedBox(width: 4),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appLocalizations.modelDialogAddCancelHide),
),
],
),
),
if (!embeddedNavigation) const Divider(height: 1),
];
return SizedBox(
key: const ValueKey("download"),
width: 560,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_wantPop && !embeddedNavigation) ...closeInput,
Padding(
padding: const EdgeInsets.all(24),
child: ListTile(
minTileHeight: 0,
minVerticalPadding: 0,
contentPadding: EdgeInsets.zero,
title: Builder(
builder: (context) => Text(
modelName ?? appLocalizations.noSelectedModel,
style: DefaultTextStyle.of(
context,
).style.copyWith(fontFamily: "GoogleSansCode"),
),
),
subtitle: Builder(
builder: (context) => Wrap(
children: [
Text(
NumberFormat.decimalPercentPattern(
locale: appLocalizations.localeName,
decimalDigits: 2,
).format(state?.progress ?? 0),
style: DefaultTextStyle.of(context).style.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
Text(" " * 3),
const Text("/"),
Text(" " * 3),
Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Transform.translate(
offset: const Offset(0, -1),
child: Icon(
Icons.sd_storage_outlined,
size: DefaultTextStyle.of(
context,
).style.fontSize,
),
),
),
const TextSpan(text: " "),
TextSpan(
text: bytesToHumanReadable(
state?.total ?? 0,
locale: appLocalizations.localeName,
),
),
],
),
),
],
),
),
trailing: CircularProgressIndicator(
value: state?.progress ?? 0,
),
),
),
if (_wantPop && embeddedNavigation) ...closeInput,
],
),
);
},
);
return PopScope(
canPop: _pageState == false,
onPopInvokedWithResult: (didPop, result) {
if (_pageState != true || _wantPop) return;
_wantPop = true;
if (mounted) setState(() {});
},
child: Dialog(
alignment: alignment,
constraints: constraints,
insetPadding: const EdgeInsets.all(12), // 24
child: AnimatedSize(
duration: ExpressiveCurves.expressiveSpatial.normalDuration,
curve: ExpressiveCurves.expressiveSpatial.normal,
alignment: embeddedNavigation
? AlignmentGeometry.topCenter
: AlignmentGeometry.bottomCenter,
child: AnimatedSwitcher(
duration: ExpressiveCurves.expressiveSpatial.normalDuration,
switchInCurve: ExpressiveCurves.expressiveSpatial.normal,
switchOutCurve: ExpressiveCurves.expressiveEffects.slow.flipped,
transitionBuilder: (child, animation) => SizeTransition(
sizeFactor: animation,
alignment: embeddedNavigation
? AlignmentDirectional.bottomCenter
: AlignmentDirectional.topCenter,
child: FadeTransition(opacity: animation, child: child),
),
child: _pageState == null
? childLoading
: (_pageState! ? childDownload : childInput),
),
),
),
);
}
}
Future<String?> showModelAdderDialog(BuildContext context) async =>
showDialog<String>(
context: context,
builder: (context) => const ModelAdder(),
);