neoagent 2.4.1-beta.9 → 2.4.1

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.
Files changed (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -0,0 +1,685 @@
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
+ this.priceTier,
17
+ });
18
+
19
+ final String value;
20
+ final String label;
21
+ final String group;
22
+ final String? subtitle;
23
+ final Color? color;
24
+ final IconData? icon;
25
+ final bool isAuto;
26
+ /// 'free' | 'cheap' | 'medium' | 'expensive' | null
27
+ final String? priceTier;
28
+ }
29
+
30
+ // ─── Provider helpers ─────────────────────────────────────────────────────────
31
+
32
+ Color _providerPickerColor(String provider) {
33
+ final String p = provider.toLowerCase();
34
+ if (p.contains('google') || p.contains('gemini')) {
35
+ return const Color(0xFF4285F4);
36
+ }
37
+ if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
38
+ return const Color(0xFF10A37F);
39
+ }
40
+ if (p.contains('anthropic') || p.contains('claude')) {
41
+ return const Color(0xFFD97757);
42
+ }
43
+ if (p.contains('meta') || p.contains('llama')) return const Color(0xFF0668E1);
44
+ if (p.contains('mistral')) return const Color(0xFFF97316);
45
+ if (p.contains('grok') || p.contains('xai')) return const Color(0xFF9B5DE5);
46
+ if (p.contains('copilot') || p.contains('github')) {
47
+ return const Color(0xFF238636);
48
+ }
49
+ if (p.contains('openrouter')) return const Color(0xFF6366F1);
50
+ if (p.contains('nvidia')) return const Color(0xFF76B900);
51
+ if (p.contains('minimax')) return const Color(0xFF0EA5E9);
52
+ if (p.contains('deepgram')) return const Color(0xFF13D4A0);
53
+ if (p.contains('ollama')) return const Color(0xFF6C8EBF);
54
+ return const Color(0xFF7C8CFF);
55
+ }
56
+
57
+ IconData _providerPickerIcon(String provider) {
58
+ final String p = provider.toLowerCase();
59
+ if (p.contains('google') || p.contains('gemini')) {
60
+ return Icons.auto_awesome_rounded;
61
+ }
62
+ if (p.contains('openai') || p.contains('gpt') || p.contains('codex')) {
63
+ return Icons.bolt_rounded;
64
+ }
65
+ if (p.contains('anthropic') || p.contains('claude')) {
66
+ return Icons.menu_book_rounded;
67
+ }
68
+ if (p.contains('meta') || p.contains('llama')) {
69
+ return Icons.visibility_rounded;
70
+ }
71
+ if (p.contains('grok') || p.contains('xai')) return Icons.psychology_rounded;
72
+ if (p.contains('copilot') || p.contains('github')) return Icons.code_rounded;
73
+ if (p.contains('openrouter')) return Icons.hub_rounded;
74
+ if (p.contains('nvidia')) return Icons.speed_rounded;
75
+ if (p.contains('minimax')) return Icons.water_rounded;
76
+ if (p.contains('deepgram')) return Icons.hearing_rounded;
77
+ if (p.contains('ollama')) return Icons.device_hub_rounded;
78
+ return Icons.memory_rounded;
79
+ }
80
+
81
+ String _providerPickerLabel(String id) {
82
+ const Map<String, String> labels = <String, String>{
83
+ 'anthropic': 'Anthropic',
84
+ 'openai': 'OpenAI',
85
+ 'google': 'Google',
86
+ 'gemini': 'Google',
87
+ 'meta': 'Meta',
88
+ 'mistral': 'Mistral',
89
+ 'grok': 'xAI',
90
+ 'grok-oauth': 'xAI (OAuth)',
91
+ 'xai': 'xAI',
92
+ 'ollama': 'Ollama',
93
+ 'github-copilot': 'GitHub Copilot',
94
+ 'openai-codex': 'OpenAI Codex',
95
+ 'claude-code': 'Claude Code',
96
+ 'openrouter': 'OpenRouter',
97
+ 'nvidia': 'NVIDIA NIM',
98
+ 'minimax': 'MiniMax',
99
+ 'deepgram': 'Deepgram',
100
+ };
101
+ return labels[id.toLowerCase()] ?? id;
102
+ }
103
+
104
+ // ─── Option builders ──────────────────────────────────────────────────────────
105
+
106
+ List<_ModelPickerOption> _modelPickerOptions(
107
+ List<ModelMeta> models, {
108
+ bool allowAuto = false,
109
+ }) {
110
+ return <_ModelPickerOption>[
111
+ if (allowAuto)
112
+ const _ModelPickerOption(
113
+ value: 'auto',
114
+ label: 'Smart Selector',
115
+ subtitle: 'Auto-routes to the best available model',
116
+ icon: Icons.auto_awesome_outlined,
117
+ isAuto: true,
118
+ ),
119
+ ...models.map((ModelMeta m) {
120
+ final List<String> parts = <String>[];
121
+ if (m.provider.isNotEmpty) parts.add(_providerPickerLabel(m.provider));
122
+ if (m.purpose.isNotEmpty) parts.add(m.purpose);
123
+ return _ModelPickerOption(
124
+ value: m.id,
125
+ label: m.label,
126
+ group: m.provider,
127
+ subtitle: parts.isNotEmpty ? parts.join(' · ') : null,
128
+ color: _providerPickerColor(m.provider),
129
+ icon: _providerPickerIcon(m.provider),
130
+ priceTier: m.priceTier,
131
+ );
132
+ }),
133
+ ];
134
+ }
135
+
136
+ List<_ModelPickerOption> _simplePickerOptions(List<String> values) {
137
+ return values
138
+ .map((String v) => _ModelPickerOption(value: v, label: v))
139
+ .toList();
140
+ }
141
+
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+ // Model Picker Button — drop-in trigger that opens the dialog
144
+ // ─────────────────────────────────────────────────────────────────────────────
145
+
146
+ class _ModelPickerButton extends StatelessWidget {
147
+ const _ModelPickerButton({
148
+ required this.value,
149
+ required this.options,
150
+ required this.onChanged,
151
+ required this.dialogTitle,
152
+ });
153
+
154
+ final String value;
155
+ final List<_ModelPickerOption> options;
156
+ final ValueChanged<String?> onChanged;
157
+ final String dialogTitle;
158
+
159
+ _ModelPickerOption get _current => options.firstWhere(
160
+ (o) => o.value == value,
161
+ orElse: () => _ModelPickerOption(value: value, label: value),
162
+ );
163
+
164
+ @override
165
+ Widget build(BuildContext context) {
166
+ final _ModelPickerOption current = _current;
167
+ final Color iconColor = current.isAuto
168
+ ? _accentHover
169
+ : (current.color ?? _textSecondary);
170
+ final IconData iconData = current.isAuto
171
+ ? Icons.auto_awesome_outlined
172
+ : (current.icon ?? Icons.memory_rounded);
173
+ final bool showShell = current.icon != null || current.isAuto;
174
+
175
+ return Material(
176
+ color: Colors.transparent,
177
+ child: InkWell(
178
+ onTap: () => _openPicker(context),
179
+ borderRadius: BorderRadius.circular(12),
180
+ child: Container(
181
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
182
+ decoration: BoxDecoration(
183
+ color: _bgCard,
184
+ borderRadius: BorderRadius.circular(12),
185
+ border: Border.all(color: _border),
186
+ ),
187
+ child: Row(
188
+ children: <Widget>[
189
+ if (showShell) ...<Widget>[
190
+ Container(
191
+ width: 32,
192
+ height: 32,
193
+ decoration: BoxDecoration(
194
+ color: iconColor.withValues(alpha: 0.14),
195
+ borderRadius: BorderRadius.circular(8),
196
+ ),
197
+ child: Icon(iconData, size: 16, color: iconColor),
198
+ ),
199
+ const SizedBox(width: 10),
200
+ ],
201
+ Expanded(
202
+ child: Text(
203
+ current.label,
204
+ style: TextStyle(
205
+ fontSize: 14,
206
+ fontWeight: FontWeight.w600,
207
+ color: _textPrimary,
208
+ ),
209
+ maxLines: 1,
210
+ overflow: TextOverflow.ellipsis,
211
+ ),
212
+ ),
213
+ const SizedBox(width: 6),
214
+ Icon(Icons.unfold_more_rounded, size: 16, color: _textMuted),
215
+ ],
216
+ ),
217
+ ),
218
+ ),
219
+ );
220
+ }
221
+
222
+ Future<void> _openPicker(BuildContext context) async {
223
+ await showGeneralDialog<void>(
224
+ context: context,
225
+ barrierDismissible: true,
226
+ barrierLabel: 'Dismiss',
227
+ barrierColor: Colors.black.withValues(alpha: 0.55),
228
+ transitionDuration: const Duration(milliseconds: 230),
229
+ transitionBuilder: (
230
+ BuildContext ctx,
231
+ Animation<double> animation,
232
+ Animation<double> secondary,
233
+ Widget child,
234
+ ) {
235
+ return FadeTransition(
236
+ opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
237
+ child: SlideTransition(
238
+ position: Tween<Offset>(
239
+ begin: const Offset(0, 0.04),
240
+ end: Offset.zero,
241
+ ).animate(
242
+ CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
243
+ ),
244
+ child: child,
245
+ ),
246
+ );
247
+ },
248
+ pageBuilder: (
249
+ BuildContext dialogContext,
250
+ Animation<double> animation,
251
+ Animation<double> secondary,
252
+ ) {
253
+ return _ModelPickerDialog(
254
+ title: dialogTitle,
255
+ options: options,
256
+ currentValue: value,
257
+ onChanged: (String v) {
258
+ onChanged(v);
259
+ Navigator.of(dialogContext).pop();
260
+ },
261
+ );
262
+ },
263
+ );
264
+ }
265
+ }
266
+
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+ // Model Picker Dialog
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+
271
+ class _ModelPickerDialog extends StatefulWidget {
272
+ const _ModelPickerDialog({
273
+ required this.title,
274
+ required this.options,
275
+ required this.currentValue,
276
+ required this.onChanged,
277
+ });
278
+
279
+ final String title;
280
+ final List<_ModelPickerOption> options;
281
+ final String currentValue;
282
+ final ValueChanged<String> onChanged;
283
+
284
+ @override
285
+ State<_ModelPickerDialog> createState() => _ModelPickerDialogState();
286
+ }
287
+
288
+ class _ModelPickerDialogState extends State<_ModelPickerDialog> {
289
+ final TextEditingController _searchCtrl = TextEditingController();
290
+ String _query = '';
291
+
292
+ @override
293
+ void dispose() {
294
+ _searchCtrl.dispose();
295
+ super.dispose();
296
+ }
297
+
298
+ List<_ModelPickerOption> get _filtered {
299
+ if (_query.isEmpty) return widget.options;
300
+ final String q = _query.toLowerCase();
301
+ return widget.options.where((_ModelPickerOption o) {
302
+ return o.label.toLowerCase().contains(q) ||
303
+ (o.subtitle?.toLowerCase().contains(q) ?? false) ||
304
+ o.group.toLowerCase().contains(q) ||
305
+ o.value.toLowerCase().contains(q);
306
+ }).toList();
307
+ }
308
+
309
+ @override
310
+ Widget build(BuildContext context) {
311
+ final List<_ModelPickerOption> filtered = _filtered;
312
+ final _ModelPickerOption? autoOption =
313
+ filtered.where((o) => o.isAuto).firstOrNull;
314
+ final List<_ModelPickerOption> regularOptions =
315
+ filtered.where((o) => !o.isAuto).toList();
316
+
317
+ final List<String> groups = <String>[];
318
+ final Map<String, List<_ModelPickerOption>> grouped =
319
+ <String, List<_ModelPickerOption>>{};
320
+ for (final _ModelPickerOption opt in regularOptions) {
321
+ if (!grouped.containsKey(opt.group)) {
322
+ groups.add(opt.group);
323
+ grouped[opt.group] = <_ModelPickerOption>[];
324
+ }
325
+ grouped[opt.group]!.add(opt);
326
+ }
327
+
328
+ final bool hasGroups = groups.any((String g) => g.isNotEmpty);
329
+
330
+ return Center(
331
+ child: ConstrainedBox(
332
+ constraints: BoxConstraints(
333
+ maxWidth: 520,
334
+ minWidth: 300,
335
+ maxHeight: MediaQuery.sizeOf(context).height * 0.76,
336
+ ),
337
+ child: Padding(
338
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
339
+ child: Material(
340
+ color: _bgCard,
341
+ borderRadius: BorderRadius.circular(20),
342
+ elevation: 24,
343
+ shadowColor: Colors.black.withValues(alpha: 0.5),
344
+ child: Container(
345
+ decoration: BoxDecoration(
346
+ borderRadius: BorderRadius.circular(20),
347
+ border: Border.all(color: _borderLight),
348
+ ),
349
+ child: ClipRRect(
350
+ borderRadius: BorderRadius.circular(20),
351
+ child: Column(
352
+ mainAxisSize: MainAxisSize.min,
353
+ crossAxisAlignment: CrossAxisAlignment.stretch,
354
+ children: <Widget>[
355
+ // Header
356
+ Padding(
357
+ padding: const EdgeInsets.fromLTRB(20, 16, 10, 12),
358
+ child: Row(
359
+ children: <Widget>[
360
+ Expanded(
361
+ child: Text(
362
+ widget.title,
363
+ style: TextStyle(
364
+ fontSize: 17,
365
+ fontWeight: FontWeight.w700,
366
+ color: _textPrimary,
367
+ ),
368
+ ),
369
+ ),
370
+ IconButton(
371
+ onPressed: () => Navigator.of(context).pop(),
372
+ icon: Icon(
373
+ Icons.close_rounded,
374
+ size: 20,
375
+ color: _textSecondary,
376
+ ),
377
+ style: IconButton.styleFrom(
378
+ minimumSize: const Size(36, 36),
379
+ padding: EdgeInsets.zero,
380
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
381
+ ),
382
+ ),
383
+ ],
384
+ ),
385
+ ),
386
+ // Search bar
387
+ Padding(
388
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
389
+ child: TextField(
390
+ controller: _searchCtrl,
391
+ autofocus: true,
392
+ onChanged: (String v) =>
393
+ setState(() => _query = v.trim()),
394
+ style: TextStyle(color: _textPrimary, fontSize: 14),
395
+ decoration: InputDecoration(
396
+ hintText: 'Search…',
397
+ hintStyle: TextStyle(color: _textMuted, fontSize: 14),
398
+ prefixIcon: Icon(
399
+ Icons.search_rounded,
400
+ size: 18,
401
+ color: _textMuted,
402
+ ),
403
+ suffixIcon: _query.isNotEmpty
404
+ ? GestureDetector(
405
+ onTap: () => setState(() {
406
+ _searchCtrl.clear();
407
+ _query = '';
408
+ }),
409
+ child: Padding(
410
+ padding: const EdgeInsets.all(10),
411
+ child: Icon(
412
+ Icons.cancel_rounded,
413
+ size: 16,
414
+ color: _textMuted,
415
+ ),
416
+ ),
417
+ )
418
+ : null,
419
+ isDense: true,
420
+ contentPadding:
421
+ const EdgeInsets.symmetric(vertical: 10),
422
+ filled: true,
423
+ fillColor: _bgSecondary,
424
+ border: OutlineInputBorder(
425
+ borderRadius: BorderRadius.circular(12),
426
+ borderSide: BorderSide(color: _border),
427
+ ),
428
+ enabledBorder: OutlineInputBorder(
429
+ borderRadius: BorderRadius.circular(12),
430
+ borderSide: BorderSide(color: _border),
431
+ ),
432
+ focusedBorder: OutlineInputBorder(
433
+ borderRadius: BorderRadius.circular(12),
434
+ borderSide: BorderSide(color: _accent, width: 1.5),
435
+ ),
436
+ ),
437
+ ),
438
+ ),
439
+ Divider(height: 1, thickness: 1, color: _border),
440
+ // List
441
+ Flexible(
442
+ child: filtered.isEmpty
443
+ ? Padding(
444
+ padding: const EdgeInsets.all(36),
445
+ child: Column(
446
+ mainAxisSize: MainAxisSize.min,
447
+ children: <Widget>[
448
+ Icon(
449
+ Icons.search_off_rounded,
450
+ size: 36,
451
+ color: _textMuted,
452
+ ),
453
+ const SizedBox(height: 12),
454
+ Text(
455
+ 'No results for "$_query"',
456
+ style: TextStyle(
457
+ color: _textSecondary,
458
+ fontSize: 14,
459
+ ),
460
+ ),
461
+ ],
462
+ ),
463
+ )
464
+ : ListView(
465
+ padding:
466
+ const EdgeInsets.symmetric(vertical: 6),
467
+ shrinkWrap: true,
468
+ children: <Widget>[
469
+ if (autoOption != null) ...<Widget>[
470
+ _PickerRow(
471
+ option: autoOption,
472
+ selected:
473
+ widget.currentValue == autoOption.value,
474
+ onTap: () =>
475
+ widget.onChanged(autoOption.value),
476
+ ),
477
+ if (regularOptions.isNotEmpty)
478
+ Divider(
479
+ height: 1,
480
+ indent: 14,
481
+ endIndent: 14,
482
+ color: _border,
483
+ ),
484
+ ],
485
+ for (final String group in groups) ...<Widget>[
486
+ if (hasGroups && group.isNotEmpty)
487
+ _PickerGroupHeader(
488
+ label: _providerPickerLabel(group),
489
+ color: _providerPickerColor(group),
490
+ ),
491
+ for (final _ModelPickerOption opt
492
+ in grouped[group]!)
493
+ _PickerRow(
494
+ option: opt,
495
+ selected:
496
+ widget.currentValue == opt.value,
497
+ onTap: () => widget.onChanged(opt.value),
498
+ ),
499
+ ],
500
+ ],
501
+ ),
502
+ ),
503
+ ],
504
+ ),
505
+ ),
506
+ ),
507
+ ),
508
+ ),
509
+ ),
510
+ );
511
+ }
512
+ }
513
+
514
+ // ─────────────────────────────────────────────────────────────────────────────
515
+ // Sub-widgets
516
+ // ─────────────────────────────────────────────────────────────────────────────
517
+
518
+ class _PriceTierChip extends StatelessWidget {
519
+ const _PriceTierChip({required this.tier});
520
+
521
+ final String tier;
522
+
523
+ @override
524
+ Widget build(BuildContext context) {
525
+ final (String text, Color color) = switch (tier) {
526
+ 'free' => ('FREE', const Color(0xFF22C55E)),
527
+ 'cheap' => ('\$', const Color(0xFF4ADE80)),
528
+ 'medium' => ('\$\$', const Color(0xFFF59E0B)),
529
+ 'expensive' => ('\$\$\$', const Color(0xFFEF4444)),
530
+ _ => ('', Colors.transparent),
531
+ };
532
+ if (text.isEmpty) return const SizedBox.shrink();
533
+ return Container(
534
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
535
+ decoration: BoxDecoration(
536
+ color: color.withValues(alpha: 0.15),
537
+ borderRadius: BorderRadius.circular(5),
538
+ border: Border.all(color: color.withValues(alpha: 0.35), width: 0.8),
539
+ ),
540
+ child: Text(
541
+ text,
542
+ style: TextStyle(
543
+ fontSize: 10,
544
+ fontWeight: FontWeight.w700,
545
+ color: color,
546
+ letterSpacing: 0.2,
547
+ ),
548
+ ),
549
+ );
550
+ }
551
+ }
552
+
553
+ class _PickerGroupHeader extends StatelessWidget {
554
+ const _PickerGroupHeader({required this.label, required this.color});
555
+
556
+ final String label;
557
+ final Color color;
558
+
559
+ @override
560
+ Widget build(BuildContext context) {
561
+ return Padding(
562
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
563
+ child: Row(
564
+ children: <Widget>[
565
+ Container(
566
+ width: 6,
567
+ height: 6,
568
+ decoration: BoxDecoration(color: color, shape: BoxShape.circle),
569
+ ),
570
+ const SizedBox(width: 8),
571
+ Text(
572
+ label.toUpperCase(),
573
+ style: TextStyle(
574
+ fontSize: 10.5,
575
+ fontWeight: FontWeight.w700,
576
+ color: _textMuted,
577
+ letterSpacing: 0.8,
578
+ ),
579
+ ),
580
+ const SizedBox(width: 10),
581
+ Expanded(child: Container(height: 1, color: _border)),
582
+ ],
583
+ ),
584
+ );
585
+ }
586
+ }
587
+
588
+ class _PickerRow extends StatelessWidget {
589
+ const _PickerRow({
590
+ required this.option,
591
+ required this.selected,
592
+ required this.onTap,
593
+ });
594
+
595
+ final _ModelPickerOption option;
596
+ final bool selected;
597
+ final VoidCallback onTap;
598
+
599
+ @override
600
+ Widget build(BuildContext context) {
601
+ final Color iconColor = option.isAuto
602
+ ? _accentHover
603
+ : (option.color ?? _textSecondary);
604
+ final IconData iconData = option.isAuto
605
+ ? Icons.auto_awesome_outlined
606
+ : (option.icon ?? Icons.memory_rounded);
607
+ final bool showShell = option.icon != null || option.isAuto;
608
+
609
+ return Material(
610
+ color: selected
611
+ ? _accentMuted.withValues(alpha: 0.15)
612
+ : Colors.transparent,
613
+ child: InkWell(
614
+ onTap: onTap,
615
+ child: Padding(
616
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
617
+ child: Row(
618
+ children: <Widget>[
619
+ if (showShell) ...<Widget>[
620
+ Container(
621
+ width: 38,
622
+ height: 38,
623
+ decoration: BoxDecoration(
624
+ color:
625
+ iconColor.withValues(alpha: selected ? 0.2 : 0.11),
626
+ borderRadius: BorderRadius.circular(10),
627
+ border: selected
628
+ ? Border.all(
629
+ color: iconColor.withValues(alpha: 0.38),
630
+ )
631
+ : null,
632
+ ),
633
+ child: Icon(iconData, size: 18, color: iconColor),
634
+ ),
635
+ const SizedBox(width: 12),
636
+ ] else
637
+ const SizedBox(width: 4),
638
+ Expanded(
639
+ child: Column(
640
+ crossAxisAlignment: CrossAxisAlignment.start,
641
+ children: <Widget>[
642
+ Text(
643
+ option.label,
644
+ style: TextStyle(
645
+ fontSize: 14,
646
+ fontWeight: FontWeight.w600,
647
+ color: selected ? _accentHover : _textPrimary,
648
+ ),
649
+ maxLines: 1,
650
+ overflow: TextOverflow.ellipsis,
651
+ ),
652
+ if (option.subtitle != null) ...<Widget>[
653
+ const SizedBox(height: 2),
654
+ Text(
655
+ option.subtitle!,
656
+ style: TextStyle(fontSize: 12, color: _textMuted),
657
+ maxLines: 1,
658
+ overflow: TextOverflow.ellipsis,
659
+ ),
660
+ ],
661
+ ],
662
+ ),
663
+ ),
664
+ const SizedBox(width: 8),
665
+ if (option.priceTier != null) ...<Widget>[
666
+ _PriceTierChip(tier: option.priceTier!),
667
+ const SizedBox(width: 6),
668
+ ],
669
+ SizedBox(
670
+ width: 20,
671
+ child: selected
672
+ ? Icon(
673
+ Icons.check_circle_rounded,
674
+ size: 18,
675
+ color: _accentHover,
676
+ )
677
+ : null,
678
+ ),
679
+ ],
680
+ ),
681
+ ),
682
+ ),
683
+ );
684
+ }
685
+ }