neoagent 2.4.1-beta.12 → 2.4.1-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,7 @@ part 'main_recordings.dart';
65
65
  part 'main_chat.dart';
66
66
  part 'main_account_settings.dart';
67
67
  part 'main_settings.dart';
68
+ part 'main_model_picker.dart';
68
69
  part 'main_operations.dart';
69
70
  part 'main_admin.dart';
70
71
  part 'main_unified.dart';
@@ -0,0 +1,631 @@
1
+ part of 'main.dart';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Model Picker — option type, helpers, button, and dialog
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ class _ModelPickerOption {
8
+ const _ModelPickerOption({
9
+ required this.value,
10
+ required this.label,
11
+ this.group = '',
12
+ this.subtitle,
13
+ this.color,
14
+ this.icon,
15
+ this.isAuto = false,
16
+ });
17
+
18
+ final String value;
19
+ final String label;
20
+ final String group;
21
+ final String? subtitle;
22
+ final Color? color;
23
+ final IconData? icon;
24
+ final bool isAuto;
25
+ }
26
+
27
+ // ─── Provider helpers ─────────────────────────────────────────────────────────
28
+
29
+ Color _providerPickerColor(String provider) {
30
+ final String p = provider.toLowerCase();
31
+ if (p.contains('google') || p.contains('gemini')) {
32
+ return const Color(0xFF4285F4);
33
+ }
34
+ if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
35
+ return const Color(0xFF10A37F);
36
+ }
37
+ if (p.contains('anthropic') || p.contains('claude')) {
38
+ return const Color(0xFFD97757);
39
+ }
40
+ if (p.contains('meta') || p.contains('llama')) return const Color(0xFF0668E1);
41
+ if (p.contains('mistral')) return const Color(0xFFF97316);
42
+ if (p.contains('grok') || p.contains('xai')) return const Color(0xFF9B5DE5);
43
+ if (p.contains('copilot') || p.contains('github')) {
44
+ return const Color(0xFF238636);
45
+ }
46
+ if (p.contains('deepgram')) return const Color(0xFF13D4A0);
47
+ if (p.contains('ollama')) return const Color(0xFF6C8EBF);
48
+ return const Color(0xFF7C8CFF);
49
+ }
50
+
51
+ IconData _providerPickerIcon(String provider) {
52
+ final String p = provider.toLowerCase();
53
+ if (p.contains('google') || p.contains('gemini')) {
54
+ return Icons.auto_awesome_rounded;
55
+ }
56
+ if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
57
+ return Icons.bolt_rounded;
58
+ }
59
+ if (p.contains('anthropic') || p.contains('claude')) {
60
+ return Icons.menu_book_rounded;
61
+ }
62
+ if (p.contains('meta') || p.contains('llama')) {
63
+ return Icons.visibility_rounded;
64
+ }
65
+ if (p.contains('grok') || p.contains('xai')) return Icons.psychology_rounded;
66
+ if (p.contains('copilot') || p.contains('github')) return Icons.code_rounded;
67
+ if (p.contains('deepgram')) return Icons.hearing_rounded;
68
+ if (p.contains('ollama')) return Icons.device_hub_rounded;
69
+ return Icons.memory_rounded;
70
+ }
71
+
72
+ String _providerPickerLabel(String id) {
73
+ const Map<String, String> labels = <String, String>{
74
+ 'anthropic': 'Anthropic',
75
+ 'openai': 'OpenAI',
76
+ 'google': 'Google',
77
+ 'gemini': 'Google',
78
+ 'meta': 'Meta',
79
+ 'mistral': 'Mistral',
80
+ 'grok': 'xAI',
81
+ 'xai': 'xAI',
82
+ 'ollama': 'Ollama',
83
+ 'github-copilot': 'GitHub Copilot',
84
+ 'openai-codex': 'OpenAI',
85
+ 'deepgram': 'Deepgram',
86
+ };
87
+ return labels[id.toLowerCase()] ?? id;
88
+ }
89
+
90
+ // ─── Option builders ──────────────────────────────────────────────────────────
91
+
92
+ List<_ModelPickerOption> _modelPickerOptions(
93
+ List<ModelMeta> models, {
94
+ bool allowAuto = false,
95
+ }) {
96
+ return <_ModelPickerOption>[
97
+ if (allowAuto)
98
+ const _ModelPickerOption(
99
+ value: 'auto',
100
+ label: 'Smart Selector',
101
+ subtitle: 'Auto-routes to the best available model',
102
+ icon: Icons.auto_awesome_outlined,
103
+ isAuto: true,
104
+ ),
105
+ ...models.map((ModelMeta m) {
106
+ final List<String> parts = <String>[];
107
+ if (m.provider.isNotEmpty) parts.add(_providerPickerLabel(m.provider));
108
+ if (m.purpose.isNotEmpty) parts.add(m.purpose);
109
+ return _ModelPickerOption(
110
+ value: m.id,
111
+ label: m.label,
112
+ group: m.provider,
113
+ subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
114
+ color: _providerPickerColor(m.provider),
115
+ icon: _providerPickerIcon(m.provider),
116
+ );
117
+ }),
118
+ ];
119
+ }
120
+
121
+ List<_ModelPickerOption> _simplePickerOptions(List<String> values) {
122
+ return values
123
+ .map((String v) => _ModelPickerOption(value: v, label: v))
124
+ .toList();
125
+ }
126
+
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // Model Picker Button — drop-in trigger that opens the dialog
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
131
+ class _ModelPickerButton extends StatelessWidget {
132
+ const _ModelPickerButton({
133
+ required this.value,
134
+ required this.options,
135
+ required this.onChanged,
136
+ required this.dialogTitle,
137
+ });
138
+
139
+ final String value;
140
+ final List<_ModelPickerOption> options;
141
+ final ValueChanged<String?> onChanged;
142
+ final String dialogTitle;
143
+
144
+ _ModelPickerOption get _current => options.firstWhere(
145
+ (o) => o.value == value,
146
+ orElse: () => _ModelPickerOption(value: value, label: value),
147
+ );
148
+
149
+ @override
150
+ Widget build(BuildContext context) {
151
+ final _ModelPickerOption current = _current;
152
+ final Color iconColor = current.isAuto
153
+ ? _accentHover
154
+ : (current.color ?? _textSecondary);
155
+ final IconData iconData = current.isAuto
156
+ ? Icons.auto_awesome_outlined
157
+ : (current.icon ?? Icons.memory_rounded);
158
+ final bool showShell = current.icon != null || current.isAuto;
159
+
160
+ return Material(
161
+ color: Colors.transparent,
162
+ child: InkWell(
163
+ onTap: () => _openPicker(context),
164
+ borderRadius: BorderRadius.circular(12),
165
+ child: Container(
166
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
167
+ decoration: BoxDecoration(
168
+ color: _bgCard,
169
+ borderRadius: BorderRadius.circular(12),
170
+ border: Border.all(color: _border),
171
+ ),
172
+ child: Row(
173
+ children: <Widget>[
174
+ if (showShell) ...<Widget>[
175
+ Container(
176
+ width: 32,
177
+ height: 32,
178
+ decoration: BoxDecoration(
179
+ color: iconColor.withValues(alpha: 0.14),
180
+ borderRadius: BorderRadius.circular(8),
181
+ ),
182
+ child: Icon(iconData, size: 16, color: iconColor),
183
+ ),
184
+ const SizedBox(width: 10),
185
+ ],
186
+ Expanded(
187
+ child: Text(
188
+ current.label,
189
+ style: TextStyle(
190
+ fontSize: 14,
191
+ fontWeight: FontWeight.w600,
192
+ color: _textPrimary,
193
+ ),
194
+ maxLines: 1,
195
+ overflow: TextOverflow.ellipsis,
196
+ ),
197
+ ),
198
+ const SizedBox(width: 6),
199
+ Icon(Icons.unfold_more_rounded, size: 16, color: _textMuted),
200
+ ],
201
+ ),
202
+ ),
203
+ ),
204
+ );
205
+ }
206
+
207
+ Future<void> _openPicker(BuildContext context) async {
208
+ await showGeneralDialog<void>(
209
+ context: context,
210
+ barrierDismissible: true,
211
+ barrierLabel: 'Dismiss',
212
+ barrierColor: Colors.black.withValues(alpha: 0.55),
213
+ transitionDuration: const Duration(milliseconds: 230),
214
+ transitionBuilder: (
215
+ BuildContext ctx,
216
+ Animation<double> animation,
217
+ Animation<double> secondary,
218
+ Widget child,
219
+ ) {
220
+ return FadeTransition(
221
+ opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
222
+ child: SlideTransition(
223
+ position: Tween<Offset>(
224
+ begin: const Offset(0, 0.04),
225
+ end: Offset.zero,
226
+ ).animate(
227
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
228
+ ),
229
+ child: child,
230
+ ),
231
+ );
232
+ },
233
+ pageBuilder: (
234
+ BuildContext dialogContext,
235
+ Animation<double> animation,
236
+ Animation<double> secondary,
237
+ ) {
238
+ return _ModelPickerDialog(
239
+ title: dialogTitle,
240
+ options: options,
241
+ currentValue: value,
242
+ onChanged: (String v) {
243
+ onChanged(v);
244
+ Navigator.of(dialogContext).pop();
245
+ },
246
+ );
247
+ },
248
+ );
249
+ }
250
+ }
251
+
252
+ // ─────────────────────────────────────────────────────────────────────────────
253
+ // Model Picker Dialog
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+
256
+ class _ModelPickerDialog extends StatefulWidget {
257
+ const _ModelPickerDialog({
258
+ required this.title,
259
+ required this.options,
260
+ required this.currentValue,
261
+ required this.onChanged,
262
+ });
263
+
264
+ final String title;
265
+ final List<_ModelPickerOption> options;
266
+ final String currentValue;
267
+ final ValueChanged<String> onChanged;
268
+
269
+ @override
270
+ State<_ModelPickerDialog> createState() => _ModelPickerDialogState();
271
+ }
272
+
273
+ class _ModelPickerDialogState extends State<_ModelPickerDialog> {
274
+ final TextEditingController _searchCtrl = TextEditingController();
275
+ String _query = '';
276
+
277
+ @override
278
+ void dispose() {
279
+ _searchCtrl.dispose();
280
+ super.dispose();
281
+ }
282
+
283
+ List<_ModelPickerOption> get _filtered {
284
+ if (_query.isEmpty) return widget.options;
285
+ final String q = _query.toLowerCase();
286
+ return widget.options.where((_ModelPickerOption o) {
287
+ return o.label.toLowerCase().contains(q) ||
288
+ (o.subtitle?.toLowerCase().contains(q) ?? false) ||
289
+ o.group.toLowerCase().contains(q) ||
290
+ o.value.toLowerCase().contains(q);
291
+ }).toList();
292
+ }
293
+
294
+ @override
295
+ Widget build(BuildContext context) {
296
+ final List<_ModelPickerOption> filtered = _filtered;
297
+ final _ModelPickerOption? autoOption =
298
+ filtered.where((o) => o.isAuto).firstOrNull;
299
+ final List<_ModelPickerOption> regularOptions =
300
+ filtered.where((o) => !o.isAuto).toList();
301
+
302
+ final List<String> groups = <String>[];
303
+ final Map<String, List<_ModelPickerOption>> grouped =
304
+ <String, List<_ModelPickerOption>>{};
305
+ for (final _ModelPickerOption opt in regularOptions) {
306
+ if (!grouped.containsKey(opt.group)) {
307
+ groups.add(opt.group);
308
+ grouped[opt.group] = <_ModelPickerOption>[];
309
+ }
310
+ grouped[opt.group]!.add(opt);
311
+ }
312
+
313
+ final bool hasGroups = groups.any((String g) => g.isNotEmpty);
314
+
315
+ return Center(
316
+ child: ConstrainedBox(
317
+ constraints: BoxConstraints(
318
+ maxWidth: 520,
319
+ minWidth: 300,
320
+ maxHeight: MediaQuery.sizeOf(context).height * 0.76,
321
+ ),
322
+ child: Padding(
323
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
324
+ child: Material(
325
+ color: _bgCard,
326
+ borderRadius: BorderRadius.circular(20),
327
+ elevation: 24,
328
+ shadowColor: Colors.black.withValues(alpha: 0.5),
329
+ child: Container(
330
+ decoration: BoxDecoration(
331
+ borderRadius: BorderRadius.circular(20),
332
+ border: Border.all(color: _borderLight),
333
+ ),
334
+ child: ClipRRect(
335
+ borderRadius: BorderRadius.circular(20),
336
+ child: Column(
337
+ mainAxisSize: MainAxisSize.min,
338
+ crossAxisAlignment: CrossAxisAlignment.stretch,
339
+ children: <Widget>[
340
+ // Header
341
+ Padding(
342
+ padding: const EdgeInsets.fromLTRB(20, 16, 10, 12),
343
+ child: Row(
344
+ children: <Widget>[
345
+ Expanded(
346
+ child: Text(
347
+ widget.title,
348
+ style: TextStyle(
349
+ fontSize: 17,
350
+ fontWeight: FontWeight.w700,
351
+ color: _textPrimary,
352
+ ),
353
+ ),
354
+ ),
355
+ IconButton(
356
+ onPressed: () => Navigator.of(context).pop(),
357
+ icon: Icon(
358
+ Icons.close_rounded,
359
+ size: 20,
360
+ color: _textSecondary,
361
+ ),
362
+ style: IconButton.styleFrom(
363
+ minimumSize: const Size(36, 36),
364
+ padding: EdgeInsets.zero,
365
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
366
+ ),
367
+ ),
368
+ ],
369
+ ),
370
+ ),
371
+ // Search bar
372
+ Padding(
373
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
374
+ child: TextField(
375
+ controller: _searchCtrl,
376
+ autofocus: true,
377
+ onChanged: (String v) =>
378
+ setState(() => _query = v.trim()),
379
+ style: TextStyle(color: _textPrimary, fontSize: 14),
380
+ decoration: InputDecoration(
381
+ hintText: 'Search…',
382
+ hintStyle: TextStyle(color: _textMuted, fontSize: 14),
383
+ prefixIcon: Icon(
384
+ Icons.search_rounded,
385
+ size: 18,
386
+ color: _textMuted,
387
+ ),
388
+ suffixIcon: _query.isNotEmpty
389
+ ? GestureDetector(
390
+ onTap: () => setState(() {
391
+ _searchCtrl.clear();
392
+ _query = '';
393
+ }),
394
+ child: Padding(
395
+ padding: const EdgeInsets.all(10),
396
+ child: Icon(
397
+ Icons.cancel_rounded,
398
+ size: 16,
399
+ color: _textMuted,
400
+ ),
401
+ ),
402
+ )
403
+ : null,
404
+ isDense: true,
405
+ contentPadding:
406
+ const EdgeInsets.symmetric(vertical: 10),
407
+ filled: true,
408
+ fillColor: _bgSecondary,
409
+ border: OutlineInputBorder(
410
+ borderRadius: BorderRadius.circular(12),
411
+ borderSide: BorderSide(color: _border),
412
+ ),
413
+ enabledBorder: OutlineInputBorder(
414
+ borderRadius: BorderRadius.circular(12),
415
+ borderSide: BorderSide(color: _border),
416
+ ),
417
+ focusedBorder: OutlineInputBorder(
418
+ borderRadius: BorderRadius.circular(12),
419
+ borderSide: BorderSide(color: _accent, width: 1.5),
420
+ ),
421
+ ),
422
+ ),
423
+ ),
424
+ Divider(height: 1, thickness: 1, color: _border),
425
+ // List
426
+ Flexible(
427
+ child: filtered.isEmpty
428
+ ? Padding(
429
+ padding: const EdgeInsets.all(36),
430
+ child: Column(
431
+ mainAxisSize: MainAxisSize.min,
432
+ children: <Widget>[
433
+ Icon(
434
+ Icons.search_off_rounded,
435
+ size: 36,
436
+ color: _textMuted,
437
+ ),
438
+ const SizedBox(height: 12),
439
+ Text(
440
+ 'No results for "$_query"',
441
+ style: TextStyle(
442
+ color: _textSecondary,
443
+ fontSize: 14,
444
+ ),
445
+ ),
446
+ ],
447
+ ),
448
+ )
449
+ : ListView(
450
+ padding:
451
+ const EdgeInsets.symmetric(vertical: 6),
452
+ shrinkWrap: true,
453
+ children: <Widget>[
454
+ if (autoOption != null) ...<Widget>[
455
+ _PickerRow(
456
+ option: autoOption,
457
+ selected:
458
+ widget.currentValue == autoOption.value,
459
+ onTap: () =>
460
+ widget.onChanged(autoOption.value),
461
+ ),
462
+ if (regularOptions.isNotEmpty)
463
+ Divider(
464
+ height: 1,
465
+ indent: 14,
466
+ endIndent: 14,
467
+ color: _border,
468
+ ),
469
+ ],
470
+ for (final String group in groups) ...<Widget>[
471
+ if (hasGroups && group.isNotEmpty)
472
+ _PickerGroupHeader(
473
+ label: _providerPickerLabel(group),
474
+ color: _providerPickerColor(group),
475
+ ),
476
+ for (final _ModelPickerOption opt
477
+ in grouped[group]!)
478
+ _PickerRow(
479
+ option: opt,
480
+ selected:
481
+ widget.currentValue == opt.value,
482
+ onTap: () => widget.onChanged(opt.value),
483
+ ),
484
+ ],
485
+ ],
486
+ ),
487
+ ),
488
+ ],
489
+ ),
490
+ ),
491
+ ),
492
+ ),
493
+ ),
494
+ ),
495
+ );
496
+ }
497
+ }
498
+
499
+ // ─────────────────────────────────────────────────────────────────────────────
500
+ // Sub-widgets
501
+ // ─────────────────────────────────────────────────────────────────────────────
502
+
503
+ class _PickerGroupHeader extends StatelessWidget {
504
+ const _PickerGroupHeader({required this.label, required this.color});
505
+
506
+ final String label;
507
+ final Color color;
508
+
509
+ @override
510
+ Widget build(BuildContext context) {
511
+ return Padding(
512
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
513
+ child: Row(
514
+ children: <Widget>[
515
+ Container(
516
+ width: 6,
517
+ height: 6,
518
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
519
+ ),
520
+ const SizedBox(width: 8),
521
+ Text(
522
+ label.toUpperCase(),
523
+ style: TextStyle(
524
+ fontSize: 10.5,
525
+ fontWeight: FontWeight.w700,
526
+ color: _textMuted,
527
+ letterSpacing: 0.8,
528
+ ),
529
+ ),
530
+ const SizedBox(width: 10),
531
+ Expanded(child: Container(height: 1, color: _border)),
532
+ ],
533
+ ),
534
+ );
535
+ }
536
+ }
537
+
538
+ class _PickerRow extends StatelessWidget {
539
+ const _PickerRow({
540
+ required this.option,
541
+ required this.selected,
542
+ required this.onTap,
543
+ });
544
+
545
+ final _ModelPickerOption option;
546
+ final bool selected;
547
+ final VoidCallback onTap;
548
+
549
+ @override
550
+ Widget build(BuildContext context) {
551
+ final Color iconColor = option.isAuto
552
+ ? _accentHover
553
+ : (option.color ?? _textSecondary);
554
+ final IconData iconData = option.isAuto
555
+ ? Icons.auto_awesome_outlined
556
+ : (option.icon ?? Icons.memory_rounded);
557
+ final bool showShell = option.icon != null || option.isAuto;
558
+
559
+ return Material(
560
+ color: selected
561
+ ? _accentMuted.withValues(alpha: 0.15)
562
+ : Colors.transparent,
563
+ child: InkWell(
564
+ onTap: onTap,
565
+ child: Padding(
566
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
567
+ child: Row(
568
+ children: <Widget>[
569
+ if (showShell) ...<Widget>[
570
+ Container(
571
+ width: 38,
572
+ height: 38,
573
+ decoration: BoxDecoration(
574
+ color:
575
+ iconColor.withValues(alpha: selected ? 0.2 : 0.11),
576
+ borderRadius: BorderRadius.circular(10),
577
+ border: selected
578
+ ? Border.all(
579
+ color: iconColor.withValues(alpha: 0.38),
580
+ )
581
+ : null,
582
+ ),
583
+ child: Icon(iconData, size: 18, color: iconColor),
584
+ ),
585
+ const SizedBox(width: 12),
586
+ ] else
587
+ const SizedBox(width: 4),
588
+ Expanded(
589
+ child: Column(
590
+ crossAxisAlignment: CrossAxisAlignment.start,
591
+ children: <Widget>[
592
+ Text(
593
+ option.label,
594
+ style: TextStyle(
595
+ fontSize: 14,
596
+ fontWeight: FontWeight.w600,
597
+ color: selected ? _accentHover : _textPrimary,
598
+ ),
599
+ maxLines: 1,
600
+ overflow: TextOverflow.ellipsis,
601
+ ),
602
+ if (option.subtitle != null) ...<Widget>[
603
+ const SizedBox(height: 2),
604
+ Text(
605
+ option.subtitle!,
606
+ style: TextStyle(fontSize: 12, color: _textMuted),
607
+ maxLines: 1,
608
+ overflow: TextOverflow.ellipsis,
609
+ ),
610
+ ],
611
+ ],
612
+ ),
613
+ ),
614
+ const SizedBox(width: 8),
615
+ SizedBox(
616
+ width: 20,
617
+ child: selected
618
+ ? Icon(
619
+ Icons.check_circle_rounded,
620
+ size: 18,
621
+ color: _accentHover,
622
+ )
623
+ : null,
624
+ ),
625
+ ],
626
+ ),
627
+ ),
628
+ ),
629
+ );
630
+ }
631
+ }