@tduncan / Cortana / commits / 51996bb

feat: add support for Ollama Cloud and implement thinking mode selector UI

abdulmominsakib committed Jul 30, 2026 at 11:35 UTC 51996bbd6196a7458bfac829c1fe639e67f029de
40 files changed +1274 -273
lib/core/constants/app_constants.dart
+2
@@ -22,4 +22,6 @@ class AppConstants {
22 static const int ollamaDefaultPort = 11434;
23
24 static const String openRouterBaseUrl = 'https://openrouter.ai/api/v1';
25 + static const String ollamaCloudBaseUrl = 'https://ollama.com';
26 + static const int ollamaCloudDefaultPort = 443;
27 }
lib/core/models/enums.dart
+8 -1
@@ -200,7 +200,14 @@ Voice? voiceFromSettings(String? voiceId, EngineId engine) {
200 }
201 }
202
203 -enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
203 +enum ServerType {
204 + lmStudio,
205 + openAICompatible,
206 + ollama,
207 + ollamaCloud,
208 + openRouter,
209 + onDevice,
210 +}
211
212 enum ConnectionStatus { connected, disconnected, checking, error }
213
lib/features/chat/data/chat_service.dart
+2
@@ -53,6 +53,7 @@ abstract class ChatService {
53 case ServerType.openAICompatible:
54 return OpenAICompatibleChatService(dio);
55 case ServerType.ollama:
56 + case ServerType.ollamaCloud:
57 return OllamaChatService(
58 dio,
59 imageCompressionEnabled: imageCompressionEnabled,
@@ -1351,4 +1352,5 @@ ToolTransportAdapter createAdapterForServerType(ServerType type) => switch (type
1352 ServerType.lmStudio => OpenAiToolAdapter(),
1353 ServerType.onDevice => OpenAiToolAdapter(),
1354 ServerType.ollama => OllamaToolAdapter(),
1355 + ServerType.ollamaCloud => OllamaToolAdapter(),
1356 };
lib/features/chat/views/components/chat_input_bar.dart
+33 -119
@@ -22,6 +22,7 @@ import '../../providers/chat_providers.dart';
22 import '../../utils/attachment_helpers.dart';
23 import '../../utils/image_upload_utils.dart';
24 import '../../../models/views/model_picker_sheet.dart';
25 +import '../../../models/components/thinking_mode_chip.dart';
26 import 'attach_sheet.dart';
27 import 'image_preview_dialog.dart';
28 import 'model_chip.dart';
@@ -586,105 +587,6 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
587 );
588 }
589
589 - String _effortLabel(ReasoningEffort effort) {
590 - final l10n = AppLocalizations.of(context)!;
591 - return switch (effort) {
592 - ReasoningEffort.low => l10n.reasoning_effort_low,
593 - ReasoningEffort.medium => l10n.reasoning_effort_medium,
594 - ReasoningEffort.high => l10n.reasoning_effort_high,
595 - };
596 - }
597 -
598 - Widget _buildThinkButton(ThemeData theme) {
599 - final selectedModel = ref.watch(selectedModelProvider);
600 - if (selectedModel?.supportsReasoning != true) {
601 - return const SizedBox.shrink();
602 - }
603 -
604 - final l10n = AppLocalizations.of(context)!;
605 - final reasoningConfig = ref.watch(chatReasoningConfigProvider);
606 - final enabled = reasoningConfig.enabled;
607 - final activeColor = theme.colorScheme.primary;
608 - final fgColor = enabled
609 - ? activeColor
610 - : theme.colorScheme.onSurface.withValues(alpha: 0.6);
611 -
612 - return Padding(
613 - padding: const EdgeInsets.only(left: 6),
614 - child: Container(
615 - height: 32,
616 - decoration: BoxDecoration(
617 - color: enabled
618 - ? activeColor.withValues(alpha: 0.15)
619 - : theme.colorScheme.onSurface.withValues(alpha: 0.06),
620 - borderRadius: BorderRadius.circular(16),
621 - ),
622 - child: Row(
623 - mainAxisSize: MainAxisSize.min,
624 - children: [
625 - InkWell(
626 - borderRadius: BorderRadius.circular(16),
627 - onTap: () {
628 - Haptics.vibrate(HapticsType.light);
629 - ref
630 - .read(chatReasoningConfigProvider.notifier)
631 - .setEnabled(!enabled);
632 - },
633 - child: Padding(
634 - padding: const EdgeInsetsDirectional.only(
635 - start: 10,
636 - end: 4,
637 - top: 6,
638 - bottom: 6,
639 - ),
640 - child: HugeIcon(
641 - icon: enabled
642 - ? HugeIcons.strokeRoundedTick01
643 - : HugeIcons.strokeRoundedSquare01,
644 - size: 18,
645 - color: fgColor,
646 - ),
647 - ),
648 - ),
649 - PopupMenuButton<ReasoningEffort>(
650 - tooltip: '',
651 - padding: EdgeInsets.zero,
652 - onSelected: (effort) {
653 - Haptics.vibrate(HapticsType.light);
654 - ref
655 - .read(chatReasoningConfigProvider.notifier)
656 - .setEffort(effort);
657 - },
658 - itemBuilder: (context) => ReasoningEffort.values
659 - .map(
660 - (effort) => PopupMenuItem(
661 - value: effort,
662 - child: Text(_effortLabel(effort)),
663 - ),
664 - )
665 - .toList(),
666 - child: Padding(
667 - padding: const EdgeInsetsDirectional.only(
668 - end: 12,
669 - top: 6,
670 - bottom: 6,
671 - ),
672 - child: Text(
673 - '${l10n.think_button_label} (${_effortLabel(reasoningConfig.effort)})',
674 - style: TextStyle(
675 - fontSize: 13,
676 - fontWeight: FontWeight.w600,
677 - color: fgColor,
678 - ),
679 - ),
680 - ),
681 - ),
682 - ],
683 - ),
684 - ),
685 - );
686 - }
687 -
590 static const _holdDuration = Duration(milliseconds: 3000);
591 static const _insertHoldThreshold = 500 / 3000;
592
@@ -955,9 +857,9 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
857 if (ref.read(isStreamingProvider)) return;
858
859 _preSpeechText = _controller.text;
958 - await ref.read(sttProvider.notifier).startListening(
959 - onResult: _onSpeechResult,
960 - );
860 + await ref
861 + .read(sttProvider.notifier)
862 + .startListening(onResult: _onSpeechResult);
863 }
864 });
865
@@ -1226,9 +1128,13 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1128 ),
1129
1130 // Bottom action row: attach button, model picker chip, optional
1229 - // reasoning/role controls, mic, send. Mirrors the modern
1230 - // composer layout where composer actions sit in a single
1231 - // horizontal row directly below the text input.
1131 + // thinking-mode chip, optional role swap, mic, voice, send.
1132 + //
1133 + // The model chip is wrapped in `Flexible` so a long model name
1134 + // can take up more horizontal room without pushing the mic /
1135 + // voice / send buttons off-screen. The right-side cluster has
1136 + // fixed widths so the layout is stable regardless of how long
1137 + // the model name gets.
1138 Row(
1139 crossAxisAlignment: CrossAxisAlignment.center,
1140 children: [
@@ -1236,30 +1142,38 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1142
1143 const SizedBox(width: 8),
1144
1239 - ModelChip(
1240 - model: selectedModel,
1241 - enabled: isConnected && widget.enabled,
1242 - onTap: () {
1243 - Haptics.vibrate(HapticsType.light);
1244 - showModalBottomSheet(
1245 - context: context,
1246 - isScrollControlled: true,
1247 - useSafeArea: true,
1248 - builder: (_) => const ModelPickerSheet(),
1249 - );
1250 - },
1145 + Flexible(
1146 + child: ModelChip(
1147 + model: selectedModel,
1148 + enabled: isConnected && widget.enabled,
1149 + onTap: () {
1150 + Haptics.vibrate(HapticsType.light);
1151 + showModalBottomSheet(
1152 + context: context,
1153 + isScrollControlled: true,
1154 + useSafeArea: true,
1155 + builder: (_) => const ModelPickerSheet(),
1156 + );
1157 + },
1158 + ),
1159 ),
1160
1161 if (selectedModel?.supportsReasoning == true) ...[
1162 const SizedBox(width: 6),
1255 - _buildThinkButton(theme),
1163 + ThinkingModeChip(
1164 + model: selectedModel,
1165 + isDark: isDark,
1166 + compact: true,
1167 + ),
1168 ],
1169 if (showRoleSwapButton) ...[
1170 const SizedBox(width: 4),
1171 _buildRoleSwapButton(theme),
1172 ],
1173
1262 - const Spacer(),
1174 + // Fixed gap so we never collapse the right-side cluster
1175 + // against the chips, even when the model name is short.
1176 + const SizedBox(width: 8),
1177
1178 _buildMicButton(isListening, theme),
1179
lib/features/chat/views/components/model_chip.dart
+9 -6
@@ -36,8 +36,8 @@ class ModelChip extends StatelessWidget {
36 final bg = hasModel
37 ? theme.colorScheme.primary.withValues(alpha: 0.12)
38 : (isDark
39 - ? AppColors.darkSurfaceInput
40 - : AppColors.lightSurface.withValues(alpha: 0.0));
39 + ? AppColors.darkSurfaceInput
40 + : AppColors.lightSurface.withValues(alpha: 0.0));
41 final borderColor = hasModel
42 ? theme.colorScheme.primary.withValues(alpha: 0.4)
43 : (isDark ? AppColors.darkBorder : AppColors.lightBorder);
@@ -50,6 +50,10 @@ class ModelChip extends StatelessWidget {
50 onTap: enabled ? onTap : null,
51 borderRadius: BorderRadius.circular(999),
52 child: Container(
53 + // Constrain the pill itself instead of the entire action row. This
54 + // lets longer model names be readable while Flexible in the parent
55 + // row still protects the fixed-size action buttons on the right.
56 + constraints: const BoxConstraints(minWidth: 0, maxWidth: 280),
57 padding: const EdgeInsetsDirectional.fromSTEB(12, 6, 10, 6),
58 decoration: BoxDecoration(
59 color: bg,
@@ -59,8 +63,7 @@ class ModelChip extends StatelessWidget {
63 child: Row(
64 mainAxisSize: MainAxisSize.min,
65 children: [
62 - ConstrainedBox(
63 - constraints: const BoxConstraints(maxWidth: 160),
66 + Flexible(
67 child: Text(
68 label,
69 maxLines: 1,
@@ -72,7 +75,7 @@ class ModelChip extends StatelessWidget {
75 ),
76 ),
77 ),
75 - const SizedBox(width: 4),
78 + const SizedBox(width: 6),
79 HugeIcon(
80 icon: HugeIcons.strokeRoundedArrowDown01,
81 size: 12,
@@ -84,4 +87,4 @@ class ModelChip extends StatelessWidget {
87 ),
88 );
89 }
87 -}
\ No newline at end of file
90 +}
lib/features/models/components/model_list.dart
+1 -1
@@ -401,7 +401,7 @@ class _ModelListState extends ConsumerState<ModelList> {
401 }
402
403 if (context.mounted) {
404 - final message = activeServer.type == ServerType.ollama
404 + final message = activeServer.isOllamaFamily
405 ? l10n.model_unloaded_ollama(model.name)
406 : l10n.model_unloaded_success(model.name);
407 ScaffoldMessenger.of(context).showSnackBar(
lib/features/models/components/thinking_mode_chip.dart new
+222
@@ -0,0 +1,222 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:hugeicons/hugeicons.dart';
4 +
5 +import 'package:localmind/core/theme/colors.dart';
6 +import 'package:localmind/features/chat/providers/chat_reasoning_providers.dart';
7 +import 'package:localmind/features/models/data/models/model_info.dart';
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +/// Compact, two-part chip used to toggle and configure the reasoning
11 +/// ("thinking") mode for the actively selected model.
12 +///
13 +/// • Left side: tap toggles thinking on / off.
14 +/// • Right side: opens a popup to choose the effort level (Low / Medium /
15 +/// High). The picker collapses to a single tap-toggle button when
16 +/// thinking is disabled, since effort is irrelevant in that state.
17 +///
18 +/// Rendered as a [ConsumerWidget] so both surfaces (the model picker
19 +/// sheet and the chat input bar) stay perfectly in sync with the
20 +/// underlying [chatReasoningConfigProvider].
21 +class ThinkingModeChip extends ConsumerWidget {
22 + const ThinkingModeChip({
23 + super.key,
24 + required this.model,
25 + required this.isDark,
26 + this.compact = false,
27 + this.fullWidth = false,
28 + });
29 +
30 + /// When `null` or a model that doesn't support reasoning is passed the
31 + /// chip returns [SizedBox.shrink] so callers can drop it into any layout
32 + /// without conditionals.
33 + final ModelInfo? model;
34 +
35 + /// Theme context used for the on- / off-state colors.
36 + final bool isDark;
37 +
38 + /// When true, uses a slightly tighter horizontal padding so the chip
39 + /// fits comfortably inside the chat input bar's action row.
40 + final bool compact;
41 +
42 + /// When true, the chip will occupy the full available width instead of
43 + /// hugging its contents. Used by the model picker header so the chip
44 + /// sits flush under the title.
45 + final bool fullWidth;
46 +
47 + @override
48 + Widget build(BuildContext context, WidgetRef ref) {
49 + final supportsReasoning = model?.supportsReasoning ?? false;
50 + if (!supportsReasoning) return const SizedBox.shrink();
51 +
52 + final l10n = AppLocalizations.of(context)!;
53 + final theme = Theme.of(context);
54 + final config = ref.watch(chatReasoningConfigProvider);
55 + final notifier = ref.read(chatReasoningConfigProvider.notifier);
56 +
57 + final accent = theme.colorScheme.primary;
58 + final enabledFg = accent;
59 + final disabledFg = isDark
60 + ? AppColors.darkMutedText
61 + : AppColors.lightMutedText;
62 + final fgColor = config.enabled ? enabledFg : disabledFg;
63 + final bgColor = config.enabled
64 + ? accent.withValues(alpha: 0.15)
65 + : theme.colorScheme.onSurface.withValues(alpha: 0.06);
66 + final borderColor = config.enabled
67 + ? accent.withValues(alpha: 0.4)
68 + : (isDark ? AppColors.darkBorder : AppColors.lightBorder);
69 +
70 + final hPad = compact ? 10.0 : 12.0;
71 + final vPad = compact ? 6.0 : 8.0;
72 + final borderRadius = BorderRadius.circular(999);
73 +
74 + final labelText = fullWidth
75 + ? l10n.thinking_mode_title
76 + : l10n.think_button_label;
77 +
78 + final toggleSide = InkWell(
79 + onTap: () {
80 + notifier.setEnabled(!config.enabled);
81 + },
82 + borderRadius: borderRadius,
83 + child: Padding(
84 + padding: EdgeInsetsDirectional.fromSTEB(hPad, vPad, hPad, vPad),
85 + child: Row(
86 + mainAxisSize: MainAxisSize.min,
87 + children: [
88 + HugeIcon(
89 + icon: HugeIcons.strokeRoundedBrain,
90 + size: compact ? 14 : 16,
91 + color: fgColor,
92 + ),
93 + SizedBox(width: compact ? 4 : 6),
94 + Flexible(
95 + child: Text(
96 + labelText,
97 + maxLines: 1,
98 + overflow: TextOverflow.ellipsis,
99 + style: TextStyle(
100 + fontSize: compact ? 12 : 13,
101 + fontWeight: FontWeight.w600,
102 + color: fgColor,
103 + ),
104 + ),
105 + ),
106 + if (config.enabled) ...[
107 + SizedBox(width: compact ? 4 : 6),
108 + Container(
109 + padding: EdgeInsets.symmetric(
110 + horizontal: compact ? 6 : 8,
111 + vertical: 2,
112 + ),
113 + decoration: BoxDecoration(
114 + color: accent.withValues(alpha: 0.18),
115 + borderRadius: BorderRadius.circular(999),
116 + ),
117 + child: Text(
118 + _effortShortLabel(l10n, config.effort),
119 + style: TextStyle(
120 + fontSize: compact ? 10 : 11,
121 + fontWeight: FontWeight.w700,
122 + color: enabledFg,
123 + ),
124 + ),
125 + ),
126 + ],
127 + ],
128 + ),
129 + ),
130 + );
131 +
132 + // Right-side effort picker. Hidden when thinking is off because the
133 + // choice has no effect — the entire chip is then a single tap toggle.
134 + Widget pickerSide = const SizedBox.shrink();
135 + if (config.enabled) {
136 + pickerSide = PopupMenuButton<ReasoningEffort>(
137 + tooltip: '',
138 + padding: EdgeInsets.zero,
139 + position: PopupMenuPosition.under,
140 + onSelected: (effort) {
141 + notifier.setEnabled(true);
142 + notifier.setEffort(effort);
143 + },
144 + itemBuilder: (context) => ReasoningEffort.values
145 + .map(
146 + (effort) => PopupMenuItem(
147 + value: effort,
148 + child: Row(
149 + mainAxisSize: MainAxisSize.min,
150 + children: [
151 + HugeIcon(
152 + icon: effort == config.effort
153 + ? HugeIcons.strokeRoundedTick01
154 + : HugeIcons.strokeRoundedSquare01,
155 + size: 16,
156 + color: effort == config.effort
157 + ? accent
158 + : Colors.transparent,
159 + ),
160 + const SizedBox(width: 8),
161 + Text(_effortShortLabel(l10n, effort)),
162 + ],
163 + ),
164 + ),
165 + )
166 + .toList(),
167 + child: Padding(
168 + padding: EdgeInsetsDirectional.fromSTEB(2, vPad, hPad, vPad),
169 + child: Row(
170 + mainAxisSize: MainAxisSize.min,
171 + children: [
172 + HugeIcon(
173 + icon: HugeIcons.strokeRoundedArrowDown01,
174 + size: compact ? 12 : 14,
175 + color: fgColor.withValues(alpha: 0.7),
176 + ),
177 + ],
178 + ),
179 + ),
180 + );
181 + }
182 +
183 + final chip = Material(
184 + color: Colors.transparent,
185 + child: DecoratedBox(
186 + decoration: BoxDecoration(
187 + color: bgColor,
188 + border: Border.all(color: borderColor),
189 + borderRadius: borderRadius,
190 + ),
191 + child: Row(
192 + mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
193 + mainAxisAlignment: fullWidth
194 + ? MainAxisAlignment.spaceBetween
195 + : MainAxisAlignment.start,
196 + children: [
197 + // Toggle side: flexes to absorb available width when [fullWidth]
198 + // is true, but stays hugging its content otherwise.
199 + if (fullWidth) Expanded(child: toggleSide) else toggleSide,
200 + pickerSide,
201 + ],
202 + ),
203 + ),
204 + );
205 +
206 + return fullWidth ? SizedBox(width: double.infinity, child: chip) : chip;
207 + }
208 +
209 + static String _effortShortLabel(
210 + AppLocalizations l10n,
211 + ReasoningEffort effort,
212 + ) {
213 + switch (effort) {
214 + case ReasoningEffort.low:
215 + return l10n.reasoning_effort_low;
216 + case ReasoningEffort.medium:
217 + return l10n.reasoning_effort_medium;
218 + case ReasoningEffort.high:
219 + return l10n.reasoning_effort_high;
220 + }
221 + }
222 +}
lib/features/models/views/model_picker_sheet.dart
+141 -106
@@ -13,13 +13,15 @@ import 'package:localmind/features/on_device/providers/on_device_providers.dart'
13 import 'package:localmind/features/servers/providers/server_providers.dart';
14 import 'package:localmind/features/lm_studio_catalog/views/lm_studio_download_widgets.dart';
15 import 'package:localmind/core/providers/app_providers.dart';
16 -import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
16 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
17 + as conv;
18 import 'package:localmind/l10n/app_localizations.dart';
19 import '../components/model_list.dart';
20 import '../components/model_search_field.dart';
21 import '../components/model_sort_control.dart';
22 import '../components/no_server_state.dart';
23 import '../components/thinking_indicator.dart';
24 +import '../components/thinking_mode_chip.dart';
25 import '../providers/model_picker_providers.dart';
26
27 class ModelPickerSheet extends ConsumerStatefulWidget {
@@ -63,10 +65,7 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
65 ScaffoldMessenger.of(context).showSnackBar(
66 SnackBar(
67 content: Text(
66 - l10n.model_loaded(
67 - loadedName,
68 - next.backend?.name ?? 'CPU',
69 - ),
68 + l10n.model_loaded(loadedName, next.backend?.name ?? 'CPU'),
69 ),
70 ),
71 );
@@ -75,8 +74,9 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
74
75 final serversAsync = ref.watch(serversProvider);
76 final servers = serversAsync.value ?? [];
78 - final currentServer =
79 - servers.where((s) => s.id == activeServer?.id).firstOrNull;
77 + final currentServer = servers
78 + .where((s) => s.id == activeServer?.id)
79 + .firstOrNull;
80 final isOnDevice =
81 currentServer != null && currentServer.type == ServerType.onDevice;
82 final isLmStudio =
@@ -99,90 +99,105 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
99 return Cue.onMount(
100 motion: .smooth(),
101 child: Container(
102 - padding: const EdgeInsets.all(16),
103 - decoration: BoxDecoration(
104 - color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
105 - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
106 - ),
107 - child: Column(
108 - crossAxisAlignment: CrossAxisAlignment.start,
109 - children: [
110 - Center(
111 - child: Container(
112 - width: 40,
113 - height: 4,
114 - decoration: BoxDecoration(
115 - color: isDark ? Colors.grey[600] : Colors.grey[300],
116 - borderRadius: BorderRadius.circular(2),
117 - ),
118 - ),
102 + padding: const EdgeInsets.all(16),
103 + decoration: BoxDecoration(
104 + color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
105 + borderRadius: const BorderRadius.vertical(
106 + top: Radius.circular(20),
107 ),
120 - const SizedBox(height: 16),
121 - _ModelPickerHeader(
122 - isDark: isDark,
123 - isThinking: isThinking,
124 - loadedCount: loadedCount,
125 - showBrowseButton: isLmStudio,
126 - onBrowseModels: isLmStudio
127 - ? () {
128 - Navigator.of(context).pop();
129 - context.push(
130 - AppRoutes.lmStudioModelBrowser,
131 - extra: currentServer,
132 - );
133 - }
134 - : null,
135 - onRefresh: activeServer != null
136 - ? () {
137 - ref.invalidate(availableModelsProvider(activeServer.id));
138 - ref.invalidate(loadedModelsProvider(activeServer));
139 - }
140 - : null,
141 - onUnloadAll: activeServer != null && loadedCount > 0
142 - ? () => _unloadAllModels(context, ref, activeServer)
143 - : null,
144 - ),
145 - if (activeServer != null)
146 - Padding(
147 - padding: const EdgeInsets.only(top: 4),
148 - child: Text(
149 - activeServer.name,
150 - style: TextStyle(
151 - fontSize: 13,
152 - color: isDark
153 - ? AppColors.darkMutedText
154 - : AppColors.lightMutedText,
108 + ),
109 + child: Column(
110 + crossAxisAlignment: CrossAxisAlignment.start,
111 + children: [
112 + Center(
113 + child: Container(
114 + width: 40,
115 + height: 4,
116 + decoration: BoxDecoration(
117 + color: isDark ? Colors.grey[600] : Colors.grey[300],
118 + borderRadius: BorderRadius.circular(2),
119 ),
120 ),
121 ),
158 - const SizedBox(height: 12),
159 - Row(
160 - children: [
161 - const Expanded(child: ModelSearchField()),
162 - const SizedBox(width: 8),
163 - const ModelSortControl(),
122 + const SizedBox(height: 16),
123 + _ModelPickerHeader(
124 + isDark: isDark,
125 + isThinking: isThinking,
126 + loadedCount: loadedCount,
127 + showBrowseButton: isLmStudio,
128 + onBrowseModels: isLmStudio
129 + ? () {
130 + Navigator.of(context).pop();
131 + context.push(
132 + AppRoutes.lmStudioModelBrowser,
133 + extra: currentServer,
134 + );
135 + }
136 + : null,
137 + onRefresh: activeServer != null
138 + ? () {
139 + ref.invalidate(
140 + availableModelsProvider(activeServer.id),
141 + );
142 + ref.invalidate(loadedModelsProvider(activeServer));
143 + }
144 + : null,
145 + onUnloadAll: activeServer != null && loadedCount > 0
146 + ? () => _unloadAllModels(context, ref, activeServer)
147 + : null,
148 + ),
149 + if (activeServer != null)
150 + Padding(
151 + padding: const EdgeInsets.only(top: 4),
152 + child: Text(
153 + activeServer.name,
154 + style: TextStyle(
155 + fontSize: 13,
156 + color: isDark
157 + ? AppColors.darkMutedText
158 + : AppColors.lightMutedText,
159 + ),
160 + ),
161 + ),
162 + // Thinking-mode selector surfaces only when the currently
163 + // selected model can actually reason. Renders nothing
164 + // otherwise so it never adds dead space.
165 + if (selectedModel?.supportsReasoning == true) ...[
166 + const SizedBox(height: 12),
167 + ThinkingModeChip(
168 + model: selectedModel,
169 + isDark: isDark,
170 + fullWidth: true,
171 + ),
172 ],
165 - ),
166 - const SizedBox(height: 12),
167 - Expanded(
168 - child: activeServer == null
169 - ? NoServerState(isDark: isDark)
170 - : isOnDevice
171 - ? OnDevicePickerSection(
172 - selectedModelId: selectedModel?.id,
173 - isDark: isDark,
174 - scrollController: scrollController,
175 - )
176 - : ModelList(
177 - serverId: activeServer.id,
178 - selectedModelId: selectedModel?.id,
179 - isDark: isDark,
180 - scrollController: scrollController,
181 - ),
182 - ),
183 - ],
173 + const SizedBox(height: 12),
174 + Row(
175 + children: [
176 + const Expanded(child: ModelSearchField()),
177 + const SizedBox(width: 8),
178 + const ModelSortControl(),
179 + ],
180 + ),
181 + const SizedBox(height: 12),
182 + Expanded(
183 + child: activeServer == null
184 + ? NoServerState(isDark: isDark)
185 + : isOnDevice
186 + ? OnDevicePickerSection(
187 + selectedModelId: selectedModel?.id,
188 + isDark: isDark,
189 + scrollController: scrollController,
190 + )
191 + : ModelList(
192 + serverId: activeServer.id,
193 + selectedModelId: selectedModel?.id,
194 + isDark: isDark,
195 + scrollController: scrollController,
196 + ),
197 + ),
198 + ],
199 + ),
200 ),
185 - ),
201 );
202 },
203 );
@@ -199,8 +214,9 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
214 if (activeServer.type == ServerType.onDevice) {
215 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
216 } else {
202 - final loadedInstances =
203 - await ref.read(loadedModelsProvider(activeServer).future);
217 + final loadedInstances = await ref.read(
218 + loadedModelsProvider(activeServer).future,
219 + );
220 final apiService = ref.read(serverApiServiceProvider);
221 await apiService.unloadAllInstances(activeServer, loadedInstances);
222 }
@@ -209,9 +225,9 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
225 ref.read(selectedModelProvider.notifier).clear();
226
227 if (context.mounted) {
212 - ScaffoldMessenger.of(context).showSnackBar(
213 - SnackBar(content: Text(l10n.all_models_unloaded)),
214 - );
228 + ScaffoldMessenger.of(
229 + context,
230 + ).showSnackBar(SnackBar(content: Text(l10n.all_models_unloaded)));
231 }
232 } catch (e) {
233 if (context.mounted) {
@@ -277,10 +293,11 @@ class _ModelPickerHeader extends ConsumerWidget {
293 vertical: 2,
294 ),
295 decoration: BoxDecoration(
280 - color: (isDark
281 - ? AppColors.darkAccent
282 - : AppColors.lightAccent)
283 - .withValues(alpha: 0.15),
296 + color:
297 + (isDark
298 + ? AppColors.darkAccent
299 + : AppColors.lightAccent)
300 + .withValues(alpha: 0.15),
301 borderRadius: BorderRadius.circular(999),
302 ),
303 child: Text(
@@ -318,7 +335,10 @@ class _ModelPickerHeader extends ConsumerWidget {
335 ),
336 borderRadius: BorderRadius.circular(16),
337 child: Container(
321 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
338 + padding: const EdgeInsets.symmetric(
339 + horizontal: 10,
340 + vertical: 6,
341 + ),
342 decoration: BoxDecoration(
343 border: Border.all(
344 color: isDark ? Colors.grey[800]! : Colors.grey[300]!,
@@ -329,8 +349,8 @@ class _ModelPickerHeader extends ConsumerWidget {
349 child: Row(
350 mainAxisSize: MainAxisSize.min,
351 children: [
332 - HugeIcon(icon:
333 - HugeIcons.strokeRoundedLayers01,
352 + HugeIcon(
353 + icon: HugeIcons.strokeRoundedLayers01,
354 size: 14,
355 color: isDark ? Colors.grey[400] : Colors.grey[600],
356 ),
@@ -353,8 +373,8 @@ class _ModelPickerHeader extends ConsumerWidget {
373 IconButton(
374 onPressed: onUnloadAll,
375 tooltip: l10n.unload_all_models,
356 - icon: HugeIcon(icon:
357 - HugeIcons.strokeRoundedPower,
376 + icon: HugeIcon(
377 + icon: HugeIcons.strokeRoundedPower,
378 size: 20,
379 color: Colors.red[400],
380 ),
@@ -363,12 +383,18 @@ class _ModelPickerHeader extends ConsumerWidget {
383 IconButton(
384 onPressed: onBrowseModels,
385 tooltip: l10n.lm_studio_browse_models,
366 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedCompass01, size: 20),
386 + icon: const HugeIcon(
387 + icon: HugeIcons.strokeRoundedCompass01,
388 + size: 20,
389 + ),
390 ),
391 const LmDownloadIndicatorButton(compact: true),
392 if (onRefresh != null)
393 IconButton(
371 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedRefresh, size: 20),
394 + icon: const HugeIcon(
395 + icon: HugeIcons.strokeRoundedRefresh,
396 + size: 20,
397 + ),
398 onPressed: onRefresh,
399 tooltip: l10n.refresh_models,
400 ),
@@ -433,7 +459,9 @@ class __ContextLengthEditDialogState
459 if (widget.activeConversationId == null) {
460 ref.read(settingsProvider.notifier).setContextLength(parsed);
461 } else {
436 - ref.read(conv.conversationsProvider.notifier).updateChatParams(
462 + ref
463 + .read(conv.conversationsProvider.notifier)
464 + .updateChatParams(
465 widget.activeConversationId!,
466 contextLength: parsed,
467 );
@@ -451,7 +479,9 @@ class __ContextLengthEditDialogState
479 final suggestions = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
480
481 return Dialog(
454 - backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightSurface,
482 + backgroundColor: isDark
483 + ? AppColors.darkBackground
484 + : AppColors.lightSurface,
485 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
486 child: SingleChildScrollView(
487 child: Padding(
@@ -481,7 +511,10 @@ class __ContextLengthEditDialogState
511 borderRadius: BorderRadius.circular(10),
512 ),
513 suffixIcon: IconButton(
484 - icon: const HugeIcon(icon: HugeIcons.strokeRoundedCancel01, size: 18),
514 + icon: const HugeIcon(
515 + icon: HugeIcons.strokeRoundedCancel01,
516 + size: 18,
517 + ),
518 onPressed: () => _controller.clear(),
519 ),
520 ),
@@ -502,7 +535,9 @@ class __ContextLengthEditDialogState
535 fontWeight: FontWeight.w500,
536 color: isDark ? Colors.white70 : Colors.black87,
537 ),
505 - backgroundColor: isDark ? Colors.grey[850] : Colors.grey[200],
538 + backgroundColor: isDark
539 + ? Colors.grey[850]
540 + : Colors.grey[200],
541 shape: RoundedRectangleBorder(
542 borderRadius: BorderRadius.circular(8),
543 side: BorderSide.none,
@@ -544,4 +579,4 @@ class __ContextLengthEditDialogState
579 ),
580 );
581 }
547 -}
\ No newline at end of file
582 +}
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+47 -13
@@ -44,19 +44,28 @@ class _OnboardingServerSetupScreenState
44 super.initState();
45 String defaultName = '';
46 String defaultPort = '';
47 + String defaultHost = '';
48
49 switch (widget.selectedType) {
50 case ServerType.lmStudio:
51 defaultName = 'LM Studio';
52 defaultPort = AppConstants.lmStudioDefaultPort.toString();
53 + defaultHost = Platform.isAndroid ? '192.168.0.0' : '127.0.0.1';
54 break;
55 case ServerType.openAICompatible:
56 defaultName = 'OpenAI Compatible';
57 defaultPort = AppConstants.openAICompatibleDefaultPort.toString();
58 + defaultHost = Platform.isAndroid ? '192.168.0.0' : '127.0.0.1';
59 break;
60 case ServerType.ollama:
61 defaultName = 'Ollama';
62 defaultPort = AppConstants.ollamaDefaultPort.toString();
63 + defaultHost = Platform.isAndroid ? '192.168.0.0' : '127.0.0.1';
64 + break;
65 + case ServerType.ollamaCloud:
66 + defaultName = 'Ollama Cloud';
67 + defaultHost = AppConstants.ollamaCloudBaseUrl;
68 + defaultPort = AppConstants.ollamaCloudDefaultPort.toString();
69 break;
70 case ServerType.openRouter:
71 defaultName = 'OpenRouter';
@@ -69,11 +78,7 @@ class _OnboardingServerSetupScreenState
78 }
79
80 _nameController = TextEditingController(text: defaultName);
72 - _hostController = TextEditingController(
73 - text: widget.selectedType == ServerType.openRouter
74 - ? ''
75 - : (Platform.isAndroid ? '192.168.0.0' : '127.0.0.1'),
76 - );
81 + _hostController = TextEditingController(text: defaultHost);
82 _portController = TextEditingController(text: defaultPort);
83 _apiKeyController = TextEditingController();
84 }
@@ -88,13 +93,22 @@ class _OnboardingServerSetupScreenState
93 }
94
95 Server _buildServer() {
96 + final requiresCloudConfig =
97 + widget.selectedType == ServerType.ollamaCloud;
98 + final host = requiresCloudConfig
99 + ? AppConstants.ollamaCloudBaseUrl
100 + : _hostController.text.trim();
101 + final port = requiresCloudConfig
102 + ? AppConstants.ollamaCloudDefaultPort
103 + : int.parse(_portController.text.trim());
104 +
105 return Server(
106 id: DateTime.now().millisecondsSinceEpoch.toString(),
107 name: _nameController.text.trim(),
108 type: widget.selectedType,
95 - host: _hostController.text.trim(),
96 - port: int.parse(_portController.text.trim()),
97 - apiKey: widget.selectedType == ServerType.openRouter
109 + host: host,
110 + port: port,
111 + apiKey: _isCloudProvider
112 ? _apiKeyController.text.trim()
113 : (_apiKeyController.text.trim().isNotEmpty
114 ? _apiKeyController.text.trim()
@@ -106,8 +120,12 @@ class _OnboardingServerSetupScreenState
120 );
121 }
122
123 + bool get _isCloudProvider =>
124 + widget.selectedType == ServerType.openRouter ||
125 + widget.selectedType == ServerType.ollamaCloud;
126 +
127 String? _validateHost(String? value) {
110 - if (widget.selectedType == ServerType.openRouter) return null;
128 + if (_isCloudProvider) return null;
129 final l10n = AppLocalizations.of(context)!;
130 if (value == null || value.trim().isEmpty) {
131 return l10n.host_required;
@@ -119,7 +137,7 @@ class _OnboardingServerSetupScreenState
137 }
138
139 String? _validatePort(String? value) {
122 - if (widget.selectedType == ServerType.openRouter) return null;
140 + if (_isCloudProvider) return null;
141 final l10n = AppLocalizations.of(context)!;
142 if (value == null || value.trim().isEmpty) {
143 return l10n.port_required;
@@ -208,7 +226,8 @@ class _OnboardingServerSetupScreenState
226 Widget build(BuildContext context) {
227 final l10n = AppLocalizations.of(context)!;
228 final theme = Theme.of(context);
211 - final isCloud = widget.selectedType == ServerType.openRouter;
229 + final isCloud = _isCloudProvider;
230 + final isOllamaCloud = widget.selectedType == ServerType.ollamaCloud;
231
232 return Scaffold(
233 appBar: AppBar(title: Text(l10n.setup_connection)),
@@ -288,10 +307,23 @@ class _OnboardingServerSetupScreenState
307 : l10n.api_key_optional,
308 filled: true,
309 fillColor: theme.colorScheme.surface,
310 + hintText: isCloud
311 + ? (isOllamaCloud
312 + ? l10n.api_key_hint_ollama_cloud
313 + : l10n.api_key_hint_openrouter)
314 + : null,
315 ),
316 validator: (val) {
317 if (isCloud && (val == null || val.trim().isEmpty)) {
294 - return l10n.api_key_required_openrouter;
318 + return isOllamaCloud
319 + ? l10n.api_key_required_ollama_cloud
320 + : l10n.api_key_required_openrouter;
321 + }
322 + if (isCloud &&
323 + widget.selectedType == ServerType.openRouter &&
324 + val != null &&
325 + !val.trim().startsWith('sk-')) {
326 + return l10n.api_key_format;
327 }
328 return null;
329 },
@@ -319,7 +351,9 @@ class _OnboardingServerSetupScreenState
351 const SizedBox(width: 8),
352 Expanded(
353 child: Text(
322 - l10n.openrouter_disclosure,
354 + isOllamaCloud
355 + ? l10n.ollama_cloud_disclosure
356 + : l10n.openrouter_disclosure,
357 style: theme.textTheme.bodySmall?.copyWith(
358 color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
359 height: 1.4,
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+13 -1
@@ -189,7 +189,7 @@ class _OnboardingServerTypeScreenState
189 type: ServerType.ollama,
190 title: l10n.server_type_ollama,
191 subtitle: l10n.server_type_ollama_desc,
192 - iconWidget: HugeIcon(icon:
192 + iconWidget: HugeIcon(icon:
193 HugeIcons.strokeRoundedRobot01,
194 color: _selectedType == ServerType.ollama
195 ? theme.colorScheme.primary
@@ -197,6 +197,18 @@ class _OnboardingServerTypeScreenState
197 ),
198 theme: theme,
199 ),
200 + _buildServerCard(
201 + type: ServerType.ollamaCloud,
202 + title: l10n.server_type_ollama_cloud,
203 + subtitle: l10n.server_type_ollama_cloud_sub,
204 + iconWidget: HugeIcon(
205 + icon: HugeIcons.strokeRoundedAiCloud,
206 + color: _selectedType == ServerType.ollamaCloud
207 + ? theme.colorScheme.primary
208 + : theme.colorScheme.onSurface,
209 + ),
210 + theme: theme,
211 + ),
212 _buildServerCard(
213 type: ServerType.openRouter,
214 title: l10n.server_type_openrouter,
lib/features/servers/data/models/server.dart
+20 -4
@@ -1,3 +1,4 @@
1 +import '../../../../core/constants/app_constants.dart';
2 import '../../../../core/models/enums.dart';
3
4 bool _hasExplicitHttpScheme(String input) {
@@ -34,6 +35,9 @@ String displayServerAddress(String host, int port, ServerType type) {
35 if (type == ServerType.openRouter) {
36 return 'openrouter.ai';
37 }
38 + if (type == ServerType.ollamaCloud) {
39 + return 'ollama.com';
40 + }
41 if (type == ServerType.onDevice) {
42 return 'Local inference';
43 }
@@ -65,6 +69,9 @@ String buildServerBaseUrl(String host, int port, ServerType type) {
69 if (type == ServerType.openRouter) {
70 return 'https://openrouter.ai/api/v1';
71 }
72 + if (type == ServerType.ollamaCloud) {
73 + return AppConstants.ollamaCloudBaseUrl;
74 + }
75 if (type == ServerType.onDevice) {
76 return 'on-device';
77 }
@@ -148,7 +155,10 @@ class Server {
155 String get apiPathPrefix => normalizeServerPathPrefix(pathPrefix);
156
157 String _apiPath(String suffix) {
151 - if (type != ServerType.ollama || apiPathPrefix.isEmpty) {
158 + if (apiPathPrefix.isEmpty) {
159 + return suffix;
160 + }
161 + if (type != ServerType.ollama && type != ServerType.ollamaCloud) {
162 return suffix;
163 }
164 return '$apiPathPrefix$suffix';
@@ -167,6 +177,7 @@ class Server {
177 case ServerType.openAICompatible:
178 return '$baseUrl/v1/chat/completions';
179 case ServerType.ollama:
180 + case ServerType.ollamaCloud:
181 return '$baseUrl${_apiPath('/api/chat')}';
182 case ServerType.openRouter:
183 return '$baseUrl/chat/completions';
@@ -182,6 +193,7 @@ class Server {
193 case ServerType.openAICompatible:
194 return '$baseUrl/v1/models';
195 case ServerType.ollama:
196 + case ServerType.ollamaCloud:
197 return '$baseUrl${_apiPath('/api/tags')}';
198 case ServerType.openRouter:
199 return '$baseUrl/models';
@@ -198,8 +210,8 @@ class Server {
210 return '$baseUrl/v1/models';
211 case ServerType.ollama:
212 return '$baseUrl${_apiPath('/api/ps')}';
213 + case ServerType.ollamaCloud:
214 case ServerType.openRouter:
202 - return '';
215 case ServerType.onDevice:
216 return '';
217 }
@@ -208,6 +220,7 @@ class Server {
220 String get modelDetailsEndpoint {
221 switch (type) {
222 case ServerType.ollama:
223 + case ServerType.ollamaCloud:
224 return '$baseUrl${_apiPath('/api/show')}';
225 case ServerType.lmStudio:
226 case ServerType.openAICompatible:
@@ -225,8 +238,8 @@ class Server {
238 return '$baseUrl/v1/models/load';
239 case ServerType.ollama:
240 return '$baseUrl${_apiPath('/api/generate')}';
241 + case ServerType.ollamaCloud:
242 case ServerType.openRouter:
229 - return '';
243 case ServerType.onDevice:
244 return '';
245 }
@@ -240,14 +253,17 @@ class Server {
253 return '$baseUrl/v1/models/unload';
254 case ServerType.ollama:
255 return '$baseUrl${_apiPath('/api/generate')}';
256 + case ServerType.ollamaCloud:
257 case ServerType.openRouter:
244 - return '';
258 case ServerType.onDevice:
259 return '';
260 }
261 }
262
263 bool get isOnDevice => type == ServerType.onDevice;
264 + bool get isOllamaCloud => type == ServerType.ollamaCloud;
265 + bool get isOllamaFamily =>
266 + type == ServerType.ollama || type == ServerType.ollamaCloud;
267
268 String get displayAddress => displayServerAddress(host, port, type);
269
lib/features/servers/data/server_api_service.dart
+15 -6
@@ -53,6 +53,7 @@ class ServerApiService {
53 models = _parseOpenAICompatibleModels(response.data, server);
54 break;
55 case ServerType.ollama:
56 + case ServerType.ollamaCloud:
57 models = await _parseAndEnrichOllamaModels(response.data, server);
58 break;
59 case ServerType.openRouter:
@@ -110,9 +111,11 @@ class ServerApiService {
111 }
112 break;
113 case ServerType.ollama:
113 - // Ollama auto-loads models on /api/chat. Calling /api/generate here
114 - // would trigger an auto-pull (download) when the model is not already
115 - // present on the Ollama server, which is not what the user wants.
114 + case ServerType.ollamaCloud:
115 + // Ollama (and Ollama Cloud) auto-loads models on /api/chat. Calling
116 + // /api/generate here would trigger an auto-pull (download) when the
117 + // model is not already present on the server, which is not what the
118 + // user wants.
119 return;
120 case ServerType.openRouter:
121 case ServerType.onDevice:
@@ -152,9 +155,11 @@ class ServerApiService {
155 throw Exception(_extractApiErrorMessage(e.response?.data) ?? e.message);
156 }
157 case ServerType.ollama:
155 - // Ollama auto-loads models on /api/chat. Calling /api/generate here
156 - // would trigger an auto-pull (download) when the model is not already
157 - // present on the Ollama server, which is not what the user wants.
158 + case ServerType.ollamaCloud:
159 + // Ollama (and Ollama Cloud) auto-loads models on /api/chat. Calling
160 + // /api/generate here would trigger an auto-pull (download) when the
161 + // model is not already present on the server, which is not what the
162 + // user wants.
163 return null;
164 case ServerType.openRouter:
165 case ServerType.onDevice:
@@ -215,6 +220,10 @@ class ServerApiService {
220 options: Options(headers: buildServerAuthHeaders(server)),
221 );
222 break;
223 + case ServerType.ollamaCloud:
224 + // Ollama Cloud manages model lifecycle server-side; no client-side
225 + // unload action is supported.
226 + return;
227 case ServerType.openRouter:
228 case ServerType.onDevice:
229 break;
lib/features/servers/views/add_server_screen.dart
+48 -12
@@ -39,8 +39,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
39 String? _testResult;
40
41 bool get _isEditing => widget.editServer != null;
42 - bool get _requiresEndpoint => _selectedType != ServerType.openRouter;
43 - bool get _requiresMandatoryApiKey => _selectedType == ServerType.openRouter;
42 + bool get _requiresEndpoint =>
43 + _selectedType != ServerType.openRouter &&
44 + _selectedType != ServerType.ollamaCloud;
45 + bool get _requiresMandatoryApiKey =>
46 + _selectedType == ServerType.openRouter ||
47 + _selectedType == ServerType.ollamaCloud;
48
49 @override
50 void initState() {
@@ -82,6 +86,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
86
87 if (type == ServerType.openRouter) {
88 _portController.text = '443';
89 + } else if (type == ServerType.ollamaCloud) {
90 + _portController.text = AppConstants.ollamaCloudDefaultPort.toString();
91 + _hostController.text = AppConstants.ollamaCloudBaseUrl;
92 } else if (type == ServerType.lmStudio) {
93 _portController.text = AppConstants.lmStudioDefaultPort.toString();
94 } else if (type == ServerType.openAICompatible) {
@@ -134,15 +141,24 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
141 }
142
143 Server _buildServer() {
144 + final rawHost = _hostController.text.trim();
145 + final rawPort = _portController.text.trim();
146 + final host = _selectedType == ServerType.ollamaCloud
147 + ? AppConstants.ollamaCloudBaseUrl
148 + : rawHost;
149 + final port = _selectedType == ServerType.ollamaCloud
150 + ? AppConstants.ollamaCloudDefaultPort
151 + : int.parse(rawPort);
152 +
153 return Server(
154 id:
155 widget.editServer?.id ??
156 DateTime.now().millisecondsSinceEpoch.toString(),
157 name: _nameController.text.trim(),
158 type: _selectedType,
143 - host: _hostController.text.trim(),
144 - port: int.parse(_portController.text.trim()),
145 - apiKey: _selectedType == ServerType.openRouter
159 + host: host,
160 + port: port,
161 + apiKey: _requiresMandatoryApiKey
162 ? _apiKeyController.text.trim()
163 : (_apiKeyController.text.trim().isNotEmpty
164 ? _apiKeyController.text.trim()
@@ -252,9 +268,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
268 if (_requiresMandatoryApiKey) {
269 final l10n = AppLocalizations.of(context)!;
270 if (value == null || value.trim().isEmpty) {
255 - return l10n.api_key_required_openrouter;
271 + return _selectedType == ServerType.ollamaCloud
272 + ? l10n.api_key_required_ollama_cloud
273 + : l10n.api_key_required_openrouter;
274 }
257 - if (!value.trim().startsWith('sk-')) {
275 + if (_selectedType == ServerType.openRouter &&
276 + !value.trim().startsWith('sk-')) {
277 return l10n.api_key_format;
278 }
279 }
@@ -486,7 +505,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
505 ? l10n.api_key_required
506 : l10n.api_key_optional,
507 hintText: _requiresMandatoryApiKey
489 - ? l10n.api_key_hint_openrouter
508 + ? (_selectedType == ServerType.ollamaCloud
509 + ? l10n.api_key_hint_ollama_cloud
510 + : l10n.api_key_hint_openrouter)
511 : l10n.api_key_hint_generic,
512 ),
513 validator: _validateApiKey,
@@ -515,7 +536,9 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
536 const SizedBox(width: 8),
537 Expanded(
538 child: Text(
518 - l10n.openrouter_disclosure,
539 + _selectedType == ServerType.ollamaCloud
540 + ? l10n.ollama_cloud_disclosure
541 + : l10n.openrouter_disclosure,
542 style: theme.textTheme.bodySmall?.copyWith(
543 color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
544 height: 1.4,
@@ -576,9 +599,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
599 ),
600 const SizedBox(height: 6),
601 Text(
579 - _requiresEndpoint
580 - ? 'Configure a local or self-hosted endpoint, then verify the connection before saving.'
581 - : 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.',
602 + _addServerHeaderSubtitle(l10n),
603 style: theme.textTheme.bodyMedium?.copyWith(
604 color: colorScheme.onSurfaceVariant,
605 height: 1.4,
@@ -801,8 +822,23 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
822 ServerType.lmStudio => l10n.server_type_lm_studio,
823 ServerType.openAICompatible => l10n.server_type_openai_display,
824 ServerType.ollama => l10n.server_type_ollama,
825 + ServerType.ollamaCloud => l10n.server_type_ollama_cloud,
826 ServerType.openRouter => l10n.server_type_openrouter,
827 ServerType.onDevice => l10n.server_type_on_device_display,
828 };
829 }
830 +
831 + String _addServerHeaderSubtitle(AppLocalizations l10n) {
832 + switch (_selectedType) {
833 + case ServerType.lmStudio:
834 + case ServerType.openAICompatible:
835 + case ServerType.ollama:
836 + case ServerType.onDevice:
837 + return l10n.add_server_endpoint_subtitle;
838 + case ServerType.ollamaCloud:
839 + return l10n.add_server_ollama_cloud_subtitle;
840 + case ServerType.openRouter:
841 + return l10n.add_server_openrouter_subtitle;
842 + }
843 + }
844 }
lib/features/servers/views/components/server_card.dart
+7
@@ -44,6 +44,8 @@ class ServerCard extends StatelessWidget {
44 return l10n.server_type_openai_display;
45 case ServerType.ollama:
46 return l10n.server_type_ollama_display;
47 + case ServerType.ollamaCloud:
48 + return l10n.server_type_ollama_cloud_display;
49 case ServerType.openRouter:
50 return l10n.server_type_openrouter_display;
51 case ServerType.onDevice:
@@ -55,6 +57,9 @@ class ServerCard extends StatelessWidget {
57 if (server.type == ServerType.openRouter) {
58 return l10n.server_address_openrouter;
59 }
60 + if (server.type == ServerType.ollamaCloud) {
61 + return l10n.server_address_ollama_cloud;
62 + }
63 if (server.type == ServerType.onDevice) {
64 return l10n.server_address_on_device;
65 }
@@ -95,6 +100,8 @@ class ServerCard extends StatelessWidget {
100 return HugeIcons.strokeRoundedAiChat01;
101 case ServerType.ollama:
102 return HugeIcons.strokeRoundedRobot01;
103 + case ServerType.ollamaCloud:
104 + return HugeIcons.strokeRoundedAiCloud;
105 case ServerType.openRouter:
106 return HugeIcons.strokeRoundedCloud;
107 case ServerType.onDevice:
lib/features/servers/views/components/server_icon_picker.dart
+5
@@ -234,6 +234,11 @@ HugeIconData? getDefaultServerIcon(String? serverType) {
234 (icon) => icon.name == 'Bot',
235 orElse: () => serverIcons.first,
236 );
237 + case 'ollamaCloud':
238 + return serverIcons.firstWhere(
239 + (icon) => icon.name == 'AI Cloud',
240 + orElse: () => serverIcons.first,
241 + );
242 case 'openRouter':
243 return serverIcons.firstWhere(
244 (icon) => icon.name == 'Cloud',
lib/features/servers/views/components/server_type_selector.dart
+6
@@ -38,6 +38,12 @@ class ServerTypeSelector extends StatelessWidget {
38 HugeIcons.strokeRoundedPencilEdit02,
39 Colors.orange,
40 ),
41 + (
42 + ServerType.ollamaCloud,
43 + l10n.server_type_ollama_cloud,
44 + HugeIcons.strokeRoundedAiCloud,
45 + Colors.deepOrange,
46 + ),
47 (
48 ServerType.openRouter,
49 l10n.server_type_openrouter,
lib/features/settings/views/settings_screen.dart
+1
@@ -475,6 +475,7 @@ class SettingsViews extends ConsumerWidget {
475 providers: [
476 l10n.server_type_lm_studio,
477 l10n.server_type_ollama,
478 + l10n.server_type_ollama_cloud,
479 l10n.server_type_openrouter,
480 ],
481 highlights: [
lib/l10n/app_ar.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "الذاكرة المخصصة للتوصيات",
820 "lm_studio_memory_settings_desc": "تُستخدم لتقدير ما إذا كانت النماذج تتناسب مع جهازك في متصفح النماذج.",
821 "think_button_label": "تفكير",
822 + "thinking_mode_title": "وضع التفكير",
823 "reasoning_effort_low": "منخفض",
824 "reasoning_effort_medium": "متوسط",
825 "reasoning_effort_high": "مرتفع",
lib/l10n/app_bn.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "সুপারিশের জন্য মেমরি",
820 "lm_studio_memory_settings_desc": "মডেল ব্রাউজারে মডেলগুলি আপনার ডিভাইসে চলবে কিনা তা অনুমান করতে ব্যবহৃত হয়।",
821 "think_button_label": "চিন্তা করুন",
822 + "thinking_mode_title": "চিন্তার মোড",
823 "reasoning_effort_low": "কম",
824 "reasoning_effort_medium": "মাঝারি",
825 "reasoning_effort_high": "উচ্চ",
lib/l10n/app_en.arb
+45 -1
@@ -1345,7 +1345,7 @@
1345 "@onboarding_connect_server": {
1346 "description": "Onboarding heading for server connection"
1347 },
1348 - "onboarding_connect_desc": "Connect to LM Studio, Ollama, or\nOpenRouter to start your private AI\nexperience.",
1348 + "onboarding_connect_desc": "Connect to LM Studio, Ollama,\nOllama Cloud, or OpenRouter to\nstart your private AI experience.",
1349 "@onboarding_connect_desc": {
1350 "description": "Onboarding server connection description"
1351 },
@@ -1377,6 +1377,46 @@
1377 "@server_type_ollama": {
1378 "description": "Ollama server type card title"
1379 },
1380 + "server_type_ollama_cloud": "Ollama Cloud",
1381 + "@server_type_ollama_cloud": {
1382 + "description": "Ollama Cloud server type card title"
1383 + },
1384 + "server_type_ollama_cloud_sub": "CLOUD MANAGED",
1385 + "@server_type_ollama_cloud_sub": {
1386 + "description": "Ollama Cloud server type card subtitle"
1387 + },
1388 + "server_type_ollama_cloud_display": "Ollama Cloud",
1389 + "@server_type_ollama_cloud_display": {
1390 + "description": "Ollama Cloud server type display"
1391 + },
1392 + "server_address_ollama_cloud": "ollama.com",
1393 + "@server_address_ollama_cloud": {
1394 + "description": "Ollama Cloud default address"
1395 + },
1396 + "ollama_cloud_disclosure": "By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama's managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.",
1397 + "@ollama_cloud_disclosure": {
1398 + "description": "Disclosure shown when connecting to Ollama Cloud about data being sent to their servers"
1399 + },
1400 + "api_key_required_ollama_cloud": "API Key required for Ollama Cloud",
1401 + "@api_key_required_ollama_cloud": {
1402 + "description": "Validation: API key required for Ollama Cloud"
1403 + },
1404 + "api_key_hint_ollama_cloud": "Paste your Ollama Cloud API key",
1405 + "@api_key_hint_ollama_cloud": {
1406 + "description": "Hint for the Ollama Cloud API key input"
1407 + },
1408 + "add_server_ollama_cloud_subtitle": "Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.",
1409 + "@add_server_ollama_cloud_subtitle": {
1410 + "description": "Add server screen subtitle for Ollama Cloud"
1411 + },
1412 + "add_server_openrouter_subtitle": "Connect through OpenRouter with a valid API key and keep this profile ready for model routing.",
1413 + "@add_server_openrouter_subtitle": {
1414 + "description": "Add server screen subtitle for OpenRouter"
1415 + },
1416 + "add_server_endpoint_subtitle": "Configure a local or self-hosted endpoint, then verify the connection before saving.",
1417 + "@add_server_endpoint_subtitle": {
1418 + "description": "Add server screen subtitle for endpoint-based servers"
1419 + },
1420 "server_type_openrouter": "OpenRouter",
1421 "@server_type_openrouter": {
1422 "description": "OpenRouter server type card title"
@@ -2977,6 +3017,10 @@
3017 "@think_button_label": {
3018 "description": "Label for the reasoning-toggle button shown for reasoning-capable models"
3019 },
3020 + "thinking_mode_title": "Thinking mode",
3021 + "@thinking_mode_title": {
3022 + "description": "Title for the reasoning mode selector shown in the model picker"
3023 + },
3024 "reasoning_effort_low": "Low",
3025 "@reasoning_effort_low": {
3026 "description": "Low reasoning effort level"
lib/l10n/app_es.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "Memoria para recomendaciones",
820 "lm_studio_memory_settings_desc": "Se utiliza para estimar si los modelos caben en tu máquina en el explorador de modelos.",
821 "think_button_label": "Pensar",
822 + "thinking_mode_title": "Modo de pensamiento",
823 "reasoning_effort_low": "Bajo",
824 "reasoning_effort_medium": "Medio",
825 "reasoning_effort_high": "Alto",
lib/l10n/app_hi.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "सिफारिशों के लिए मेमोरी",
820 "lm_studio_memory_settings_desc": "मॉडल ब्राउज़र में यह अनुमान लगाने के लिए उपयोग किया जाता है कि क्या मॉडल आपकी मशीन में फिट होंगे।",
821 "think_button_label": "सोचें",
822 + "thinking_mode_title": "सोच का तरीका",
823 "reasoning_effort_low": "कम",
824 "reasoning_effort_medium": "मध्यम",
825 "reasoning_effort_high": "उच्च",
lib/l10n/app_it.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "Memoria per raccomandazioni",
820 "lm_studio_memory_settings_desc": "Usato per stimare se i modelli entrano nella memoria della tua macchina nell'esploratore di modelli.",
821 "think_button_label": "Pensa",
822 + "thinking_mode_title": "Modalità di pensiero",
823 "reasoning_effort_low": "Basso",
824 "reasoning_effort_medium": "Medio",
825 "reasoning_effort_high": "Alto",
lib/l10n/app_ja.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "推奨用メモリ設定",
820 "lm_studio_memory_settings_desc": "モデルブラウザーでモデルがお使いのデバイスに収まるかどうかを判断するために使用されます。",
821 "think_button_label": "思考",
822 + "thinking_mode_title": "思考モード",
823 "reasoning_effort_low": "低",
824 "reasoning_effort_medium": "中",
825 "reasoning_effort_high": "高",
lib/l10n/app_localizations.dart
+67 -1
@@ -1922,7 +1922,7 @@ abstract class AppLocalizations {
1922 /// Onboarding server connection description
1923 ///
1924 /// In en, this message translates to:
1925 - /// **'Connect to LM Studio, Ollama, or\nOpenRouter to start your private AI\nexperience.'**
1925 + /// **'Connect to LM Studio, Ollama,\nOllama Cloud, or OpenRouter to\nstart your private AI experience.'**
1926 String get onboarding_connect_desc;
1927
1928 /// Subtitle for the OpenAI compatible server card in onboarding
@@ -1967,6 +1967,66 @@ abstract class AppLocalizations {
1967 /// **'Ollama'**
1968 String get server_type_ollama;
1969
1970 + /// Ollama Cloud server type card title
1971 + ///
1972 + /// In en, this message translates to:
1973 + /// **'Ollama Cloud'**
1974 + String get server_type_ollama_cloud;
1975 +
1976 + /// Ollama Cloud server type card subtitle
1977 + ///
1978 + /// In en, this message translates to:
1979 + /// **'CLOUD MANAGED'**
1980 + String get server_type_ollama_cloud_sub;
1981 +
1982 + /// Ollama Cloud server type display
1983 + ///
1984 + /// In en, this message translates to:
1985 + /// **'Ollama Cloud'**
1986 + String get server_type_ollama_cloud_display;
1987 +
1988 + /// Ollama Cloud default address
1989 + ///
1990 + /// In en, this message translates to:
1991 + /// **'ollama.com'**
1992 + String get server_address_ollama_cloud;
1993 +
1994 + /// Disclosure shown when connecting to Ollama Cloud about data being sent to their servers
1995 + ///
1996 + /// In en, this message translates to:
1997 + /// **'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.'**
1998 + String get ollama_cloud_disclosure;
1999 +
2000 + /// Validation: API key required for Ollama Cloud
2001 + ///
2002 + /// In en, this message translates to:
2003 + /// **'API Key required for Ollama Cloud'**
2004 + String get api_key_required_ollama_cloud;
2005 +
2006 + /// Hint for the Ollama Cloud API key input
2007 + ///
2008 + /// In en, this message translates to:
2009 + /// **'Paste your Ollama Cloud API key'**
2010 + String get api_key_hint_ollama_cloud;
2011 +
2012 + /// Add server screen subtitle for Ollama Cloud
2013 + ///
2014 + /// In en, this message translates to:
2015 + /// **'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.'**
2016 + String get add_server_ollama_cloud_subtitle;
2017 +
2018 + /// Add server screen subtitle for OpenRouter
2019 + ///
2020 + /// In en, this message translates to:
2021 + /// **'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.'**
2022 + String get add_server_openrouter_subtitle;
2023 +
2024 + /// Add server screen subtitle for endpoint-based servers
2025 + ///
2026 + /// In en, this message translates to:
2027 + /// **'Configure a local or self-hosted endpoint, then verify the connection before saving.'**
2028 + String get add_server_endpoint_subtitle;
2029 +
2030 /// OpenRouter server type card title
2031 ///
2032 /// In en, this message translates to:
@@ -5027,6 +5087,12 @@ abstract class AppLocalizations {
5087 /// **'Think'**
5088 String get think_button_label;
5089
5090 + /// Title for the reasoning mode selector shown in the model picker
5091 + ///
5092 + /// In en, this message translates to:
5093 + /// **'Thinking mode'**
5094 + String get thinking_mode_title;
5095 +
5096 /// Low reasoning effort level
5097 ///
5098 /// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+38
@@ -1007,6 +1007,41 @@ class AppLocalizationsAr extends AppLocalizations {
1007 @override
1008 String get server_type_ollama => 'Ollama';
1009
1010 + @override
1011 + String get server_type_ollama_cloud => 'Ollama Cloud';
1012 +
1013 + @override
1014 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1015 +
1016 + @override
1017 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1018 +
1019 + @override
1020 + String get server_address_ollama_cloud => 'ollama.com';
1021 +
1022 + @override
1023 + String get ollama_cloud_disclosure =>
1024 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1025 +
1026 + @override
1027 + String get api_key_required_ollama_cloud =>
1028 + 'API Key required for Ollama Cloud';
1029 +
1030 + @override
1031 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1032 +
1033 + @override
1034 + String get add_server_ollama_cloud_subtitle =>
1035 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1036 +
1037 + @override
1038 + String get add_server_openrouter_subtitle =>
1039 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1040 +
1041 + @override
1042 + String get add_server_endpoint_subtitle =>
1043 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1044 +
1045 @override
1046 String get server_type_openrouter => 'OpenRouter';
1047
@@ -2672,6 +2707,9 @@ class AppLocalizationsAr extends AppLocalizations {
2707 @override
2708 String get think_button_label => 'تفكير';
2709
2710 + @override
2711 + String get thinking_mode_title => 'وضع التفكير';
2712 +
2713 @override
2714 String get reasoning_effort_low => 'منخفض';
2715
lib/l10n/app_localizations_bn.dart
+38
@@ -1013,6 +1013,41 @@ class AppLocalizationsBn extends AppLocalizations {
1013 @override
1014 String get server_type_ollama => 'Ollama';
1015
1016 + @override
1017 + String get server_type_ollama_cloud => 'Ollama Cloud';
1018 +
1019 + @override
1020 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1021 +
1022 + @override
1023 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1024 +
1025 + @override
1026 + String get server_address_ollama_cloud => 'ollama.com';
1027 +
1028 + @override
1029 + String get ollama_cloud_disclosure =>
1030 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1031 +
1032 + @override
1033 + String get api_key_required_ollama_cloud =>
1034 + 'API Key required for Ollama Cloud';
1035 +
1036 + @override
1037 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1038 +
1039 + @override
1040 + String get add_server_ollama_cloud_subtitle =>
1041 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1042 +
1043 + @override
1044 + String get add_server_openrouter_subtitle =>
1045 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1046 +
1047 + @override
1048 + String get add_server_endpoint_subtitle =>
1049 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1050 +
1051 @override
1052 String get server_type_openrouter => 'OpenRouter';
1053
@@ -2693,6 +2728,9 @@ class AppLocalizationsBn extends AppLocalizations {
2728 @override
2729 String get think_button_label => 'চিন্তা করুন';
2730
2731 + @override
2732 + String get thinking_mode_title => 'চিন্তার মোড';
2733 +
2734 @override
2735 String get reasoning_effort_low => 'কম';
2736
lib/l10n/app_localizations_en.dart
+39 -1
@@ -981,7 +981,7 @@ class AppLocalizationsEn extends AppLocalizations {
981
982 @override
983 String get onboarding_connect_desc =>
984 - 'Connect to LM Studio, Ollama, or\nOpenRouter to start your private AI\nexperience.';
984 + 'Connect to LM Studio, Ollama,\nOllama Cloud, or OpenRouter to\nstart your private AI experience.';
985
986 @override
987 String get openai_compatible_api => 'OpenAI-compatible API';
@@ -1004,6 +1004,41 @@ class AppLocalizationsEn extends AppLocalizations {
1004 @override
1005 String get server_type_ollama => 'Ollama';
1006
1007 + @override
1008 + String get server_type_ollama_cloud => 'Ollama Cloud';
1009 +
1010 + @override
1011 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1012 +
1013 + @override
1014 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1015 +
1016 + @override
1017 + String get server_address_ollama_cloud => 'ollama.com';
1018 +
1019 + @override
1020 + String get ollama_cloud_disclosure =>
1021 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1022 +
1023 + @override
1024 + String get api_key_required_ollama_cloud =>
1025 + 'API Key required for Ollama Cloud';
1026 +
1027 + @override
1028 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1029 +
1030 + @override
1031 + String get add_server_ollama_cloud_subtitle =>
1032 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1033 +
1034 + @override
1035 + String get add_server_openrouter_subtitle =>
1036 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1037 +
1038 + @override
1039 + String get add_server_endpoint_subtitle =>
1040 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1041 +
1042 @override
1043 String get server_type_openrouter => 'OpenRouter';
1044
@@ -2681,6 +2716,9 @@ class AppLocalizationsEn extends AppLocalizations {
2716 @override
2717 String get think_button_label => 'Think';
2718
2719 + @override
2720 + String get thinking_mode_title => 'Thinking mode';
2721 +
2722 @override
2723 String get reasoning_effort_low => 'Low';
2724
lib/l10n/app_localizations_es.dart
+38
@@ -1014,6 +1014,41 @@ class AppLocalizationsEs extends AppLocalizations {
1014 @override
1015 String get server_type_ollama => 'Ollama';
1016
1017 + @override
1018 + String get server_type_ollama_cloud => 'Ollama Cloud';
1019 +
1020 + @override
1021 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1022 +
1023 + @override
1024 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1025 +
1026 + @override
1027 + String get server_address_ollama_cloud => 'ollama.com';
1028 +
1029 + @override
1030 + String get ollama_cloud_disclosure =>
1031 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1032 +
1033 + @override
1034 + String get api_key_required_ollama_cloud =>
1035 + 'API Key required for Ollama Cloud';
1036 +
1037 + @override
1038 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1039 +
1040 + @override
1041 + String get add_server_ollama_cloud_subtitle =>
1042 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1043 +
1044 + @override
1045 + String get add_server_openrouter_subtitle =>
1046 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1047 +
1048 + @override
1049 + String get add_server_endpoint_subtitle =>
1050 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1051 +
1052 @override
1053 String get server_type_openrouter => 'OpenRouter';
1054
@@ -2697,6 +2732,9 @@ class AppLocalizationsEs extends AppLocalizations {
2732 @override
2733 String get think_button_label => 'Pensar';
2734
2735 + @override
2736 + String get thinking_mode_title => 'Modo de pensamiento';
2737 +
2738 @override
2739 String get reasoning_effort_low => 'Bajo';
2740
lib/l10n/app_localizations_hi.dart
+38
@@ -1012,6 +1012,41 @@ class AppLocalizationsHi extends AppLocalizations {
1012 @override
1013 String get server_type_ollama => 'Ollama';
1014
1015 + @override
1016 + String get server_type_ollama_cloud => 'Ollama Cloud';
1017 +
1018 + @override
1019 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1020 +
1021 + @override
1022 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1023 +
1024 + @override
1025 + String get server_address_ollama_cloud => 'ollama.com';
1026 +
1027 + @override
1028 + String get ollama_cloud_disclosure =>
1029 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1030 +
1031 + @override
1032 + String get api_key_required_ollama_cloud =>
1033 + 'API Key required for Ollama Cloud';
1034 +
1035 + @override
1036 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1037 +
1038 + @override
1039 + String get add_server_ollama_cloud_subtitle =>
1040 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1041 +
1042 + @override
1043 + String get add_server_openrouter_subtitle =>
1044 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1045 +
1046 + @override
1047 + String get add_server_endpoint_subtitle =>
1048 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1049 +
1050 @override
1051 String get server_type_openrouter => 'OpenRouter';
1052
@@ -2686,6 +2721,9 @@ class AppLocalizationsHi extends AppLocalizations {
2721 @override
2722 String get think_button_label => 'सोचें';
2723
2724 + @override
2725 + String get thinking_mode_title => 'सोच का तरीका';
2726 +
2727 @override
2728 String get reasoning_effort_low => 'कम';
2729
lib/l10n/app_localizations_it.dart
+38
@@ -1014,6 +1014,41 @@ class AppLocalizationsIt extends AppLocalizations {
1014 @override
1015 String get server_type_ollama => 'Ollama';
1016
1017 + @override
1018 + String get server_type_ollama_cloud => 'Ollama Cloud';
1019 +
1020 + @override
1021 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1022 +
1023 + @override
1024 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1025 +
1026 + @override
1027 + String get server_address_ollama_cloud => 'ollama.com';
1028 +
1029 + @override
1030 + String get ollama_cloud_disclosure =>
1031 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1032 +
1033 + @override
1034 + String get api_key_required_ollama_cloud =>
1035 + 'API Key required for Ollama Cloud';
1036 +
1037 + @override
1038 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1039 +
1040 + @override
1041 + String get add_server_ollama_cloud_subtitle =>
1042 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1043 +
1044 + @override
1045 + String get add_server_openrouter_subtitle =>
1046 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1047 +
1048 + @override
1049 + String get add_server_endpoint_subtitle =>
1050 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1051 +
1052 @override
1053 String get server_type_openrouter => 'OpenRouter';
1054
@@ -2699,6 +2734,9 @@ class AppLocalizationsIt extends AppLocalizations {
2734 @override
2735 String get think_button_label => 'Pensa';
2736
2737 + @override
2738 + String get thinking_mode_title => 'Modalità di pensiero';
2739 +
2740 @override
2741 String get reasoning_effort_low => 'Basso';
2742
lib/l10n/app_localizations_ja.dart
+38
@@ -991,6 +991,41 @@ class AppLocalizationsJa extends AppLocalizations {
991 @override
992 String get server_type_ollama => 'Ollama';
993
994 + @override
995 + String get server_type_ollama_cloud => 'Ollama Cloud';
996 +
997 + @override
998 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
999 +
1000 + @override
1001 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1002 +
1003 + @override
1004 + String get server_address_ollama_cloud => 'ollama.com';
1005 +
1006 + @override
1007 + String get ollama_cloud_disclosure =>
1008 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1009 +
1010 + @override
1011 + String get api_key_required_ollama_cloud =>
1012 + 'API Key required for Ollama Cloud';
1013 +
1014 + @override
1015 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1016 +
1017 + @override
1018 + String get add_server_ollama_cloud_subtitle =>
1019 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1020 +
1021 + @override
1022 + String get add_server_openrouter_subtitle =>
1023 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1024 +
1025 + @override
1026 + String get add_server_endpoint_subtitle =>
1027 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1028 +
1029 @override
1030 String get server_type_openrouter => 'OpenRouter';
1031
@@ -2634,6 +2669,9 @@ class AppLocalizationsJa extends AppLocalizations {
2669 @override
2670 String get think_button_label => '思考';
2671
2672 + @override
2673 + String get thinking_mode_title => '思考モード';
2674 +
2675 @override
2676 String get reasoning_effort_low => '低';
2677
lib/l10n/app_localizations_ru.dart
+38
@@ -1013,6 +1013,41 @@ class AppLocalizationsRu extends AppLocalizations {
1013 @override
1014 String get server_type_ollama => 'Оллама';
1015
1016 + @override
1017 + String get server_type_ollama_cloud => 'Ollama Cloud';
1018 +
1019 + @override
1020 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
1021 +
1022 + @override
1023 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
1024 +
1025 + @override
1026 + String get server_address_ollama_cloud => 'ollama.com';
1027 +
1028 + @override
1029 + String get ollama_cloud_disclosure =>
1030 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1031 +
1032 + @override
1033 + String get api_key_required_ollama_cloud =>
1034 + 'API Key required for Ollama Cloud';
1035 +
1036 + @override
1037 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1038 +
1039 + @override
1040 + String get add_server_ollama_cloud_subtitle =>
1041 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1042 +
1043 + @override
1044 + String get add_server_openrouter_subtitle =>
1045 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1046 +
1047 + @override
1048 + String get add_server_endpoint_subtitle =>
1049 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1050 +
1051 @override
1052 String get server_type_openrouter => 'OpenRouter';
1053
@@ -2700,6 +2735,9 @@ class AppLocalizationsRu extends AppLocalizations {
2735 @override
2736 String get think_button_label => 'Мыслить';
2737
2738 + @override
2739 + String get thinking_mode_title => 'Режим мышления';
2740 +
2741 @override
2742 String get reasoning_effort_low => 'Низкая';
2743
lib/l10n/app_localizations_zh.dart
+41
@@ -984,6 +984,41 @@ class AppLocalizationsZh extends AppLocalizations {
984 @override
985 String get server_type_ollama => 'Ollama';
986
987 + @override
988 + String get server_type_ollama_cloud => 'Ollama Cloud';
989 +
990 + @override
991 + String get server_type_ollama_cloud_sub => 'CLOUD MANAGED';
992 +
993 + @override
994 + String get server_type_ollama_cloud_display => 'Ollama Cloud';
995 +
996 + @override
997 + String get server_address_ollama_cloud => 'ollama.com';
998 +
999 + @override
1000 + String get ollama_cloud_disclosure =>
1001 + 'By connecting Ollama Cloud, your chat messages and inputs are sent to Ollama\'s managed servers for inference. LocalMind does not track or store your conversations. You can revoke this key any time at ollama.com/settings/keys.';
1002 +
1003 + @override
1004 + String get api_key_required_ollama_cloud =>
1005 + 'API Key required for Ollama Cloud';
1006 +
1007 + @override
1008 + String get api_key_hint_ollama_cloud => 'Paste your Ollama Cloud API key';
1009 +
1010 + @override
1011 + String get add_server_ollama_cloud_subtitle =>
1012 + 'Connect to Ollama Cloud with an API key from ollama.com/settings/keys to access managed cloud models.';
1013 +
1014 + @override
1015 + String get add_server_openrouter_subtitle =>
1016 + 'Connect through OpenRouter with a valid API key and keep this profile ready for model routing.';
1017 +
1018 + @override
1019 + String get add_server_endpoint_subtitle =>
1020 + 'Configure a local or self-hosted endpoint, then verify the connection before saving.';
1021 +
1022 @override
1023 String get server_type_openrouter => 'OpenRouter';
1024
@@ -2615,6 +2650,9 @@ class AppLocalizationsZh extends AppLocalizations {
2650 @override
2651 String get think_button_label => '思考';
2652
2653 + @override
2654 + String get thinking_mode_title => '思考模式';
2655 +
2656 @override
2657 String get reasoning_effort_low => '低';
2658
@@ -5380,6 +5418,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
5418 @override
5419 String get think_button_label => '思考';
5420
5421 + @override
5422 + String get thinking_mode_title => '思考模式';
5423 +
5424 @override
5425 String get reasoning_effort_low => '低';
5426
lib/l10n/app_ru.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "Память для рекомендаций",
820 "lm_studio_memory_settings_desc": "Используется для оценки того, подходят ли модели для вашего устройства.",
821 "think_button_label": "Мыслить",
822 + "thinking_mode_title": "Режим мышления",
823 "reasoning_effort_low": "Низкая",
824 "reasoning_effort_medium": "Средняя",
825 "reasoning_effort_high": "Высокая",
lib/l10n/app_zh.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "用于推荐的内存大小",
820 "lm_studio_memory_settings_desc": "用于在模型浏览器中预估模型是否可以在您的设备上运行。",
821 "think_button_label": "思考",
822 + "thinking_mode_title": "思考模式",
823 "reasoning_effort_low": "低",
824 "reasoning_effort_medium": "中",
825 "reasoning_effort_high": "高",
lib/l10n/app_zh_TW.arb
+1
@@ -819,6 +819,7 @@
819 "lm_studio_memory_settings_title": "用於推薦的記憶體大小",
820 "lm_studio_memory_settings_desc": "用於在模型瀏覽器中預估模型是否可以在您的設備上運行。",
821 "think_button_label": "思考",
822 + "thinking_mode_title": "思考模式",
823 "reasoning_effort_low": "低",
824 "reasoning_effort_medium": "中",
825 "reasoning_effort_high": "高",
lib/l10n/untranslated.txt
+118 -1
@@ -1 +1,118 @@
1 -{}
\ No newline at end of file
1 +{
2 + "ar": [
3 + "server_type_ollama_cloud",
4 + "server_type_ollama_cloud_sub",
5 + "server_type_ollama_cloud_display",
6 + "server_address_ollama_cloud",
7 + "ollama_cloud_disclosure",
8 + "api_key_required_ollama_cloud",
9 + "api_key_hint_ollama_cloud",
10 + "add_server_ollama_cloud_subtitle",
11 + "add_server_openrouter_subtitle",
12 + "add_server_endpoint_subtitle"
13 + ],
14 +
15 + "bn": [
16 + "server_type_ollama_cloud",
17 + "server_type_ollama_cloud_sub",
18 + "server_type_ollama_cloud_display",
19 + "server_address_ollama_cloud",
20 + "ollama_cloud_disclosure",
21 + "api_key_required_ollama_cloud",
22 + "api_key_hint_ollama_cloud",
23 + "add_server_ollama_cloud_subtitle",
24 + "add_server_openrouter_subtitle",
25 + "add_server_endpoint_subtitle"
26 + ],
27 +
28 + "es": [
29 + "server_type_ollama_cloud",
30 + "server_type_ollama_cloud_sub",
31 + "server_type_ollama_cloud_display",
32 + "server_address_ollama_cloud",
33 + "ollama_cloud_disclosure",
34 + "api_key_required_ollama_cloud",
35 + "api_key_hint_ollama_cloud",
36 + "add_server_ollama_cloud_subtitle",
37 + "add_server_openrouter_subtitle",
38 + "add_server_endpoint_subtitle"
39 + ],
40 +
41 + "hi": [
42 + "server_type_ollama_cloud",
43 + "server_type_ollama_cloud_sub",
44 + "server_type_ollama_cloud_display",
45 + "server_address_ollama_cloud",
46 + "ollama_cloud_disclosure",
47 + "api_key_required_ollama_cloud",
48 + "api_key_hint_ollama_cloud",
49 + "add_server_ollama_cloud_subtitle",
50 + "add_server_openrouter_subtitle",
51 + "add_server_endpoint_subtitle"
52 + ],
53 +
54 + "it": [
55 + "server_type_ollama_cloud",
56 + "server_type_ollama_cloud_sub",
57 + "server_type_ollama_cloud_display",
58 + "server_address_ollama_cloud",
59 + "ollama_cloud_disclosure",
60 + "api_key_required_ollama_cloud",
61 + "api_key_hint_ollama_cloud",
62 + "add_server_ollama_cloud_subtitle",
63 + "add_server_openrouter_subtitle",
64 + "add_server_endpoint_subtitle"
65 + ],
66 +
67 + "ja": [
68 + "server_type_ollama_cloud",
69 + "server_type_ollama_cloud_sub",
70 + "server_type_ollama_cloud_display",
71 + "server_address_ollama_cloud",
72 + "ollama_cloud_disclosure",
73 + "api_key_required_ollama_cloud",
74 + "api_key_hint_ollama_cloud",
75 + "add_server_ollama_cloud_subtitle",
76 + "add_server_openrouter_subtitle",
77 + "add_server_endpoint_subtitle"
78 + ],
79 +
80 + "ru": [
81 + "server_type_ollama_cloud",
82 + "server_type_ollama_cloud_sub",
83 + "server_type_ollama_cloud_display",
84 + "server_address_ollama_cloud",
85 + "ollama_cloud_disclosure",
86 + "api_key_required_ollama_cloud",
87 + "api_key_hint_ollama_cloud",
88 + "add_server_ollama_cloud_subtitle",
89 + "add_server_openrouter_subtitle",
90 + "add_server_endpoint_subtitle"
91 + ],
92 +
93 + "zh": [
94 + "server_type_ollama_cloud",
95 + "server_type_ollama_cloud_sub",
96 + "server_type_ollama_cloud_display",
97 + "server_address_ollama_cloud",
98 + "ollama_cloud_disclosure",
99 + "api_key_required_ollama_cloud",
100 + "api_key_hint_ollama_cloud",
101 + "add_server_ollama_cloud_subtitle",
102 + "add_server_openrouter_subtitle",
103 + "add_server_endpoint_subtitle"
104 + ],
105 +
106 + "zh_TW": [
107 + "server_type_ollama_cloud",
108 + "server_type_ollama_cloud_sub",
109 + "server_type_ollama_cloud_display",
110 + "server_address_ollama_cloud",
111 + "ollama_cloud_disclosure",
112 + "api_key_required_ollama_cloud",
113 + "api_key_hint_ollama_cloud",
114 + "add_server_ollama_cloud_subtitle",
115 + "add_server_openrouter_subtitle",
116 + "add_server_endpoint_subtitle"
117 + ]
118 +}
test/features/chat/tools/chat_service_adapter_selection_test.dart
+5
@@ -74,6 +74,11 @@ void main() {
74 final adapter = createAdapterForServerType(ServerType.ollama);
75 expect(adapter, isA<OllamaToolAdapter>());
76 });
77 +
78 + test('uses Ollama adapter for Ollama Cloud servers', () {
79 + final adapter = createAdapterForServerType(ServerType.ollamaCloud);
80 + expect(adapter, isA<OllamaToolAdapter>());
81 + });
82 });
83
84 group('ChatService implementation tool call adapter integration', () {
test/features/servers/server_ollama_cloud_test.dart new
+104
@@ -0,0 +1,104 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/core/constants/app_constants.dart';
3 +import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/features/servers/data/models/server.dart';
5 +
6 +void main() {
7 + group('Server base URL & endpoints for Ollama Cloud', () {
8 + Server makeCloudServer({String? apiKey}) {
9 + return Server(
10 + id: 'cloud-1',
11 + name: 'Ollama Cloud',
12 + type: ServerType.ollamaCloud,
13 + host: AppConstants.ollamaCloudBaseUrl,
14 + port: AppConstants.ollamaCloudDefaultPort,
15 + apiKey: apiKey,
16 + createdAt: DateTime.now(),
17 + lastConnectedAt: DateTime.now(),
18 + );
19 + }
20 +
21 + test('baseUrl resolves to https://ollama.com', () {
22 + final server = makeCloudServer();
23 + expect(server.baseUrl, 'https://ollama.com');
24 + });
25 +
26 + test('displayAddress renders as ollama.com', () {
27 + final server = makeCloudServer();
28 + expect(server.displayAddress, 'ollama.com');
29 + });
30 +
31 + test('chat endpoint uses /api/chat on ollama.com', () {
32 + final server = makeCloudServer();
33 + expect(server.chatEndpoint, 'https://ollama.com/api/chat');
34 + });
35 +
36 + test('models endpoint uses /api/tags on ollama.com', () {
37 + final server = makeCloudServer();
38 + expect(server.modelsEndpoint, 'https://ollama.com/api/tags');
39 + });
40 +
41 + test('running models endpoint is empty for cloud (no /api/ps)', () {
42 + final server = makeCloudServer();
43 + expect(server.runningModelsEndpoint, isEmpty);
44 + });
45 +
46 + test('load/unload endpoints are empty for cloud', () {
47 + final server = makeCloudServer();
48 + expect(server.loadModelEndpoint, isEmpty);
49 + expect(server.unloadModelEndpoint, isEmpty);
50 + });
51 +
52 + test('api key is sent as Bearer authorization header', () {
53 + final server = makeCloudServer(apiKey: 'test-key-123');
54 + expect(server.authHeaders, {
55 + 'Authorization': 'Bearer test-key-123',
56 + });
57 + });
58 +
59 + test('no Authorization header when api key is missing', () {
60 + final server = makeCloudServer();
61 + expect(server.authHeaders, isEmpty);
62 + });
63 +
64 + test('isOllamaCloud and isOllamaFamily getters work', () {
65 + final cloud = makeCloudServer();
66 + expect(cloud.isOllamaCloud, isTrue);
67 + expect(cloud.isOllamaFamily, isTrue);
68 +
69 + final local = Server(
70 + id: 'local-1',
71 + name: 'Local',
72 + type: ServerType.ollama,
73 + host: 'localhost',
74 + port: 11434,
75 + createdAt: DateTime.now(),
76 + lastConnectedAt: DateTime.now(),
77 + );
78 + expect(local.isOllamaCloud, isFalse);
79 + expect(local.isOllamaFamily, isTrue);
80 + });
81 + });
82 +
83 + group('buildServerBaseUrl ollamaCloud short-circuits', () {
84 + test('returns https://ollama.com regardless of host', () {
85 + final url = buildServerBaseUrl(
86 + 'some-host.example',
87 + 12345,
88 + ServerType.ollamaCloud,
89 + );
90 + expect(url, 'https://ollama.com');
91 + });
92 + });
93 +
94 + group('displayServerAddress for ollamaCloud', () {
95 + test('returns ollama.com', () {
96 + final display = displayServerAddress(
97 + 'localhost',
98 + 11434,
99 + ServerType.ollamaCloud,
100 + );
101 + expect(display, 'ollama.com');
102 + });
103 + });
104 +}