455 lines
13 KiB
Dart
455 lines
13 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get_time_ago/get_time_ago.dart';
|
|
import 'package:intl/intl.dart' as intl;
|
|
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../services/services.dart';
|
|
import '../util/extensions.dart';
|
|
import 'model_adder.dart';
|
|
import 'sized_circular_progress_indicator.dart';
|
|
import 'two_state_widget.dart';
|
|
|
|
// MARK: Model Selector Dialog
|
|
|
|
class _ModelSelectorDialog extends StatelessWidget {
|
|
final GlobalKey? anchorKey;
|
|
|
|
const _ModelSelectorDialog({this.anchorKey});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final breakpoint = Breakpoint.of(context);
|
|
final embeddedNavigation = breakpoint.panesRecommended >= 2;
|
|
if (!embeddedNavigation) {
|
|
return const Dialog.fullscreen(
|
|
child: ModelSelector(embeddedNavigation: false),
|
|
);
|
|
}
|
|
|
|
var anchorRect = Rect.zero;
|
|
final renderBox = anchorKey?.currentContext?.findRenderObject();
|
|
if (renderBox is RenderBox) {
|
|
anchorRect = renderBox.localToGlobal(Offset.zero) & renderBox.size;
|
|
}
|
|
|
|
final screenSize = MediaQuery.sizeOf(context);
|
|
final isRtl = Directionality.of(context) == TextDirection.rtl;
|
|
const minWidth = 280.0;
|
|
const maxWidth = 560.0;
|
|
const edgeGap = 24.0;
|
|
|
|
final anchorEdge = isRtl
|
|
? screenSize.width - anchorRect.right
|
|
: anchorRect.left;
|
|
final position = anchorEdge
|
|
.clamp(0.0, max(0.0, screenSize.width - maxWidth))
|
|
.toDouble();
|
|
final effectiveMaxWidth = (screenSize.width - position - edgeGap).clamp(
|
|
minWidth,
|
|
maxWidth,
|
|
);
|
|
final top = anchorRect.bottom - 24;
|
|
|
|
final popup = ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: minWidth,
|
|
maxWidth: effectiveMaxWidth,
|
|
maxHeight: screenSize.height - top - edgeGap,
|
|
),
|
|
child: Material(
|
|
type: MaterialType.card,
|
|
elevation: 6,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: const ModelSelector(embeddedNavigation: true),
|
|
),
|
|
);
|
|
|
|
return Stack(
|
|
children: [
|
|
if (isRtl)
|
|
Positioned(right: position, top: top, child: popup)
|
|
else
|
|
Positioned(left: position, top: top, child: popup),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> showModelSelector({
|
|
required BuildContext context,
|
|
required GlobalKey? anchorKey,
|
|
}) => showDialog(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
barrierColor: Colors.transparent,
|
|
builder: (context) => _ModelSelectorDialog(anchorKey: anchorKey),
|
|
);
|
|
|
|
// MARK: Model Selector
|
|
|
|
class ModelSelector extends StatefulWidget {
|
|
final bool embeddedNavigation;
|
|
const ModelSelector({super.key, required this.embeddedNavigation});
|
|
|
|
@override
|
|
State<ModelSelector> createState() => _ModelSelectorState();
|
|
}
|
|
|
|
class _ModelSelectorState extends State<ModelSelector> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
ModelManager.instance.addListener(onUpdate);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
ModelManager.instance.removeListener(onUpdate);
|
|
super.dispose();
|
|
}
|
|
|
|
void onUpdate() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final breakpoint = Breakpoint.of(context);
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ModelSelectorHeader(embeddedNavigation: widget.embeddedNavigation),
|
|
...ModelManager.instance.models.map((model) {
|
|
final tmp = ModelSelectorTile(
|
|
model: model,
|
|
onTap: () {
|
|
ModelManager.instance.currentModel = model;
|
|
Navigator.of(context).pop();
|
|
},
|
|
);
|
|
if (widget.embeddedNavigation) {
|
|
return tmp;
|
|
} else {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: breakpoint.spacing / 2,
|
|
),
|
|
child: tmp,
|
|
);
|
|
}
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ModelSelectorHeader extends StatefulWidget {
|
|
final bool embeddedNavigation;
|
|
const ModelSelectorHeader({super.key, required this.embeddedNavigation});
|
|
|
|
@override
|
|
State<ModelSelectorHeader> createState() => _ModelSelectorHeaderState();
|
|
}
|
|
|
|
class _ModelSelectorHeaderState extends State<ModelSelectorHeader> {
|
|
bool _isLoading = false;
|
|
bool _loadingFromAdd = false;
|
|
|
|
Future<void> refreshModels() async {
|
|
if (_isLoading) return;
|
|
if (HostManager.instance.host == null) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
_isLoading = true;
|
|
if (mounted) setState(() {});
|
|
|
|
if (mounted) {
|
|
await errorGuard(context, "N3AI17J1", () async {
|
|
try {
|
|
await Future.wait([
|
|
ModelManager.instance.loadModels(),
|
|
Future.delayed(Durations.long1),
|
|
]).catchError(Error.throwWithStackTrace);
|
|
} finally {
|
|
_isLoading = false;
|
|
_loadingFromAdd = false;
|
|
if (mounted) setState(() {});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final iconTheme = IconTheme.of(context);
|
|
final textTheme = TextTheme.of(context);
|
|
final appLocalizations = AppLocalizations.of(context);
|
|
|
|
final title = Text(
|
|
appLocalizations.modelDialogTitle,
|
|
style: widget.embeddedNavigation ? textTheme.titleMedium : null,
|
|
);
|
|
final actions = [
|
|
TwoStateWidgetManaged(
|
|
state: _isLoading && _loadingFromAdd,
|
|
dimension: (iconTheme.size ?? 24) + (8 * 2),
|
|
from: IconButton(
|
|
icon: const Icon(Icons.add),
|
|
tooltip: appLocalizations.modelDialogAdd,
|
|
onPressed: !_isLoading
|
|
? () async {
|
|
final addedModel = await showModelAdderDialog(context);
|
|
if (addedModel != null) {
|
|
ModelManager.instance.currentModelName = addedModel;
|
|
if (!context.mounted) return;
|
|
Navigator.of(context).pop();
|
|
|
|
ScaffoldMessenger.of(context).clearSnackBars();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
appLocalizations.modelDialogAddDownloadSuccess,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
to: const AspectRatio(
|
|
aspectRatio: 1 / 1,
|
|
child: SizedBox.expand(
|
|
child: Center(
|
|
child: SizedCircularProgressIndicator(
|
|
dimension: 24,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsetsDirectional.only(
|
|
end: widget.embeddedNavigation ? 0 : 8,
|
|
),
|
|
child: TwoStateWidgetManaged(
|
|
state: _isLoading && !_loadingFromAdd,
|
|
dimension: (iconTheme.size ?? 24) + (8 * 2),
|
|
from: IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: appLocalizations.modelDialogRefresh,
|
|
onPressed: !_isLoading ? refreshModels : null,
|
|
),
|
|
to: const AspectRatio(
|
|
aspectRatio: 1 / 1,
|
|
child: SizedBox.expand(
|
|
child: Center(
|
|
child: SizedCircularProgressIndicator(
|
|
dimension: 24,
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
|
|
return widget.embeddedNavigation
|
|
? Row(mainAxisAlignment: MainAxisAlignment.end, children: actions)
|
|
: AppBar(
|
|
leading: const AspectRatio(
|
|
aspectRatio: 1 / 1,
|
|
child: SizedBox.expand(child: BackButton()),
|
|
),
|
|
title: title,
|
|
actions: actions,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ModelSelectorTile extends StatelessWidget {
|
|
final Model model;
|
|
final VoidCallback? onTap;
|
|
|
|
const ModelSelectorTile({
|
|
super.key,
|
|
required this.model,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final iconTheme = IconTheme.of(context);
|
|
final appLocalizations = AppLocalizations.of(context);
|
|
|
|
final Widget subtitle = Builder(
|
|
builder: (context) => Wrap(
|
|
children: [
|
|
Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
WidgetSpan(
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -1),
|
|
child: Icon(
|
|
Icons.functions,
|
|
size: DefaultTextStyle.of(context).style.fontSize,
|
|
),
|
|
),
|
|
),
|
|
const TextSpan(text: " "),
|
|
TextSpan(
|
|
text: model.parameterSize.replaceAll(
|
|
".",
|
|
intl.NumberFormat.decimalPattern(
|
|
appLocalizations.localeName,
|
|
).symbols.DECIMAL_SEP,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(" " * 3),
|
|
if ((model.families.isEmpty ? [model.family] : model.families)
|
|
.length !=
|
|
1 ||
|
|
(model.families.isEmpty ? [model.family] : model.families)
|
|
.first !=
|
|
model.name.split(":").first) ...[
|
|
Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
WidgetSpan(
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -1),
|
|
child: Icon(
|
|
Icons.group,
|
|
size: DefaultTextStyle.of(context).style.fontSize,
|
|
),
|
|
),
|
|
),
|
|
const TextSpan(text: " "),
|
|
TextSpan(
|
|
children:
|
|
(model.families.isEmpty
|
|
? [model.family]
|
|
: model.families)
|
|
.map((family) => TextSpan(text: family))
|
|
.joinElements(const TextSpan(text: ", "))
|
|
.toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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(
|
|
model.size,
|
|
locale: appLocalizations.localeName,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(" " * 3),
|
|
Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
WidgetSpan(
|
|
child: Transform.translate(
|
|
offset: const Offset(0, -1),
|
|
child: Icon(
|
|
Icons.calendar_today,
|
|
size: DefaultTextStyle.of(context).style.fontSize,
|
|
),
|
|
),
|
|
),
|
|
const TextSpan(text: " "),
|
|
TextSpan(
|
|
text: GetTimeAgo.parse(
|
|
model.modifiedAt,
|
|
locale: appLocalizations.localeName,
|
|
pattern: intl.DateFormat.yMd(
|
|
appLocalizations.localeName,
|
|
).pattern,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
final tmp = ListTile(
|
|
title: Builder(
|
|
builder: (context) => Text.rich(
|
|
TextSpan(children: model.nameColored(context)),
|
|
style: DefaultTextStyle.of(
|
|
context,
|
|
).style.copyWith(fontFamily: "GoogleSansCode"),
|
|
),
|
|
),
|
|
subtitle: subtitle,
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: model.capabilities
|
|
.map(
|
|
(capability) => Padding(
|
|
padding: const EdgeInsetsDirectional.only(start: 2),
|
|
child: Tooltip(
|
|
message: capability.name.toTitleCase(),
|
|
child: Icon(
|
|
capability.icon,
|
|
size: (iconTheme.size ?? 24) - ((iconTheme.size ?? 24) / 3),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
onTap: onTap,
|
|
);
|
|
|
|
return AnimatedContainer(
|
|
duration: ExpressiveCurves.expressiveEffects.fastDuration,
|
|
curve: ExpressiveCurves.expressiveEffects.fast,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: ModelManager.instance.currentModel == model
|
|
? Border.all(color: colorScheme.outline, width: 2)
|
|
: Border.all(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.primary.withValues(alpha: 0),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: tmp,
|
|
);
|
|
}
|
|
}
|