10 files changed
+828
-63
lib/features/chat/data/chat_api_error.dart
+27
-2
@@ -19,12 +19,37 @@ class ChatApiError {
19
(code?.isNotEmpty ?? false) ||
20
(param?.isNotEmpty ?? false);
21
22
- String encode() => jsonEncode({
22
+ String encode() => jsonEncode(toMap());
23
+
24
+ Map<String, dynamic> toMap() => {
25
'message': message,
26
if (type != null && type!.isNotEmpty) 'type': type,
27
if (code != null && code!.isNotEmpty) 'code': code,
28
if (param != null && param!.isNotEmpty) 'param': param,
27
- });
29
+ };
30
+
31
+ ChatApiError copyWithCodeIfMissing(String? newCode) {
32
+ if (code != null && code!.isNotEmpty) return this;
33
+ return ChatApiError(
34
+ message: message,
35
+ type: type,
36
+ code: newCode,
37
+ param: param,
38
+ );
39
+ }
40
+
41
+ ChatApiError copyWithTypeIfMissing(String? newType) {
42
+ if (type != null && type!.isNotEmpty) return this;
43
+ return ChatApiError(
44
+ message: message,
45
+ type: newType,
46
+ code: code,
47
+ param: param,
48
+ );
49
+ }
50
+
51
+ ChatApiError copyWithMessage(String newMessage) =>
52
+ ChatApiError(message: newMessage, type: type, code: code, param: param);
53
54
static ChatApiError? tryParse(String? raw) {
55
if (raw == null || raw.isEmpty) return null;
lib/features/chat/data/chat_error_formatter.dart
new
+286
@@ -0,0 +1,286 @@
1
+import 'dart:convert';
2
+
3
+import 'chat_api_error.dart';
4
+
5
+/// Best-effort mapping for the most common chat API failure modes into the
6
+/// `ChatApiError` JSON shape rendered by `ChatErrorDisplay`.
7
+///
8
+/// The same shape works whether the upstream is Ollama, LM Studio,
9
+/// OpenAI-compatible, OpenRouter, or a remote service. Anything that can't be
10
+/// confidently identified falls back to a plain `message` so the bubble still
11
+/// shows something useful instead of raw JSON.
12
+class ChatErrorFormatter {
13
+ const ChatErrorFormatter._();
14
+
15
+ /// Convenience wrapper used by stream handlers that already have a body.
16
+ static String formatBody(String rawBody, {int? statusCode}) {
17
+ final decoded = _tryDecodeObject(rawBody);
18
+ if (decoded == null) {
19
+ final trimmed = rawBody.trim();
20
+ if (trimmed.isEmpty) return _genericFromStatus(statusCode).encode();
21
+ return ChatApiError(message: trimmed).encode();
22
+ }
23
+ return _fromKnownShape(decoded, statusCode).encode();
24
+ }
25
+
26
+ /// Map a `DioException` into the same shape, decoding the response body
27
+ /// when it's a stream (which is the case for streaming chat requests).
28
+ static Future<String> formatDioException(
29
+ dynamic error, {
30
+ Future<String> Function(dynamic)? readBody,
31
+ }) async {
32
+ int? statusCode;
33
+ String? rawBody;
34
+ final errorType = _readErrorType(error);
35
+
36
+ if (errorType != null) {
37
+ statusCode = errorType.statusCode;
38
+ final data = errorType.response?.data;
39
+ if (data != null) {
40
+ if (readBody != null) {
41
+ try {
42
+ rawBody = await readBody(data);
43
+ } catch (_) {
44
+ rawBody = null;
45
+ }
46
+ } else if (data is String) {
47
+ rawBody = data;
48
+ }
49
+ }
50
+ }
51
+
52
+ if (rawBody != null && rawBody.isNotEmpty) {
53
+ final body = formatBody(rawBody, statusCode: statusCode);
54
+ // If the body produced a generic-shaped message, layer in the status
55
+ // code so the bubble shows it.
56
+ if (statusCode != null && statusCode >= 400) {
57
+ final decoded = _tryDecodeObject(body);
58
+ if (decoded != null) {
59
+ final merged = ChatApiError.fromErrorMap(decoded);
60
+ if (merged != null) {
61
+ return merged.copyWithCodeIfMissing(statusCode.toString()).encode();
62
+ }
63
+ }
64
+ }
65
+ return body;
66
+ }
67
+
68
+ if (errorType != null) {
69
+ final fromStatus = _genericFromStatus(statusCode);
70
+ return fromStatus.copyWithTypeIfMissing(_dioTypeLabel(errorType)).encode();
71
+ }
72
+
73
+ return ChatApiError(message: error.toString()).encode();
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Shape detection
78
+ // ---------------------------------------------------------------------------
79
+
80
+ static ChatApiError _fromKnownShape(
81
+ Map<String, dynamic> decoded,
82
+ int? statusCode,
83
+ ) {
84
+ // Ollama / llama.cpp structured error: {"error": {...}}.
85
+ final errorField = decoded['error'];
86
+ if (errorField is Map) {
87
+ final nested = errorField;
88
+ final message = nested['message']?.toString();
89
+ if (message != null && message.isNotEmpty) {
90
+ return ChatApiError(
91
+ message: _humanize(message),
92
+ type: nested['type']?.toString(),
93
+ code: nested['code']?.toString() ?? statusCode?.toString(),
94
+ );
95
+ }
96
+ }
97
+ if (errorField is String && errorField.isNotEmpty) {
98
+ return ChatApiError(
99
+ message: _humanize(errorField),
100
+ code: statusCode?.toString(),
101
+ );
102
+ }
103
+
104
+ // OpenAI / OpenRouter / LM Studio: {"error": {"message": ..., "type": ...,
105
+ // "code": ..., "param": ...}} or top-level {"message": ...}.
106
+ final topMessage = decoded['message'];
107
+ if (topMessage is String && topMessage.isNotEmpty) {
108
+ return ChatApiError(
109
+ message: _humanize(topMessage),
110
+ type: decoded['type']?.toString(),
111
+ code: decoded['code']?.toString() ?? statusCode?.toString(),
112
+ param: decoded['param']?.toString(),
113
+ );
114
+ }
115
+
116
+ // Anthropic-style: {"type": "error", "error": {"type": ..., "message": ...}}
117
+ final anthropicType = decoded['type'];
118
+ if (anthropicType == 'error' && errorField is Map) {
119
+ final nested = errorField;
120
+ final message = nested['message']?.toString();
121
+ if (message != null && message.isNotEmpty) {
122
+ return ChatApiError(
123
+ message: _humanize(message),
124
+ type: nested['type']?.toString(),
125
+ code: statusCode?.toString(),
126
+ );
127
+ }
128
+ }
129
+
130
+ return _genericFromStatus(statusCode);
131
+ }
132
+
133
+ static ChatApiError _genericFromStatus(int? statusCode) {
134
+ switch (statusCode) {
135
+ case 400:
136
+ return const ChatApiError(
137
+ message:
138
+ 'The server rejected the request. Check the model name, message format, and any attached files.',
139
+ );
140
+ case 401:
141
+ return const ChatApiError(
142
+ message: 'Invalid API key or unauthorized.',
143
+ type: 'authentication',
144
+ );
145
+ case 402:
146
+ return const ChatApiError(
147
+ message: 'Monthly credit limit reached or insufficient balance.',
148
+ type: 'billing',
149
+ );
150
+ case 403:
151
+ return const ChatApiError(
152
+ message:
153
+ 'Access forbidden. Your API key might not have permission for this model.',
154
+ type: 'authorization',
155
+ );
156
+ case 404:
157
+ return const ChatApiError(
158
+ message:
159
+ 'Model not found. Pull or load the model in Ollama, then try again.',
160
+ type: 'model_not_found',
161
+ );
162
+ case 408:
163
+ case 504:
164
+ return const ChatApiError(
165
+ message: 'The model took too long to respond.',
166
+ type: 'timeout',
167
+ );
168
+ case 413:
169
+ return const ChatApiError(
170
+ message:
171
+ 'Image too large for the server to accept. Try enabling image compression in Settings.',
172
+ type: 'payload_too_large',
173
+ );
174
+ case 429:
175
+ return const ChatApiError(
176
+ message: 'Rate limit exceeded. Please wait before trying again.',
177
+ type: 'rate_limited',
178
+ );
179
+ case 500:
180
+ return const ChatApiError(
181
+ message: 'Internal server error from the AI provider.',
182
+ type: 'server_error',
183
+ );
184
+ case 502:
185
+ return const ChatApiError(
186
+ message: 'Bad gateway. The provider is having issues.',
187
+ type: 'bad_gateway',
188
+ );
189
+ case 503:
190
+ return const ChatApiError(
191
+ message: 'Service unavailable. The provider might be overloaded.',
192
+ type: 'service_unavailable',
193
+ );
194
+ }
195
+ return const ChatApiError(message: 'Chat request failed.');
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Message-level pattern detection
200
+ // ---------------------------------------------------------------------------
201
+
202
+ static String _humanize(String message) {
203
+ final lower = message.toLowerCase();
204
+
205
+ if (lower.contains('exceed') &&
206
+ (lower.contains('context') || lower.contains('n_ctx'))) {
207
+ return 'The conversation is longer than the model\'s context window. '
208
+ 'Increase `num_ctx` for the model or start a new chat.';
209
+ }
210
+ if (lower.contains('model') && lower.contains('not found')) {
211
+ return 'The selected model is not loaded on the server. Pull or load it '
212
+ 'in Ollama, then try again.';
213
+ }
214
+ if (lower.contains('unable to load model')) {
215
+ return 'Ollama could not load the model. It may need to be re-pulled '
216
+ '(e.g. `ollama pull qwen3-vl:2b`).';
217
+ }
218
+ if (lower.contains('unable to decode image')) {
219
+ return 'The server could not decode the attached image. Try a smaller '
220
+ 'or different-format image, or enable image compression in Settings.';
221
+ }
222
+ if (lower.contains('does not support images')) {
223
+ return 'The selected model does not support images. Choose a vision '
224
+ 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.';
225
+ }
226
+ if (lower.contains('econnrefused') ||
227
+ lower.contains('connection refused') ||
228
+ lower.contains('failed to connect')) {
229
+ return 'Could not reach the local AI server. Confirm Ollama is running '
230
+ '(`ollama serve`) and the address/port in Settings is correct.';
231
+ }
232
+ if (lower.contains('timeout') || lower.contains('timed out')) {
233
+ return 'The model took too long to respond. Try again or increase the '
234
+ 'server timeout in Settings.';
235
+ }
236
+ if (lower.contains('rate limit')) {
237
+ return 'Rate limit reached. Wait a moment before retrying.';
238
+ }
239
+ return message;
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Helpers
244
+ // ---------------------------------------------------------------------------
245
+
246
+ static Map<String, dynamic>? _tryDecodeObject(String raw) {
247
+ final trimmed = raw.trim();
248
+ if (trimmed.isEmpty || !trimmed.startsWith('{')) return null;
249
+ try {
250
+ final decoded = jsonDecode(trimmed);
251
+ if (decoded is Map<String, dynamic>) return decoded;
252
+ if (decoded is Map) return Map<String, dynamic>.from(decoded);
253
+ } catch (_) {
254
+ // Not JSON — leave it for the caller to treat as a plain message.
255
+ }
256
+ return null;
257
+ }
258
+
259
+ /// Returns the underlying Dio exception type when available, otherwise
260
+ /// null. Defined as a free-standing function instead of importing dio here
261
+ /// so the formatter stays free of HTTP-coupling.
262
+ static dynamic _readErrorType(dynamic error) {
263
+ // Heuristic: anything with a `response` getter and `type` getter is
264
+ // likely a DioException. We avoid a hard dependency so callers can pass
265
+ // their own typed exception.
266
+ try {
267
+ final dyn = error as dynamic;
268
+ final response = dyn.response;
269
+ final type = dyn.type;
270
+ if (response != null || type != null) return error;
271
+ } catch (_) {
272
+ // Not a Dio-shaped object.
273
+ }
274
+ return null;
275
+ }
276
+
277
+ static String? _dioTypeLabel(dynamic error) {
278
+ try {
279
+ final dyn = error as dynamic;
280
+ final type = dyn.type?.toString();
281
+ return type == null || type.isEmpty ? null : type;
282
+ } catch (_) {
283
+ return null;
284
+ }
285
+ }
286
+}
\ No newline at end of file
lib/features/chat/data/chat_service.dart
+124
-48
@@ -19,6 +19,7 @@ import 'tools/adapters/openai_tool_adapter.dart';
19
import 'tools/adapters/openrouter_tool_adapter.dart';
20
import 'tools/adapters/ollama_tool_adapter.dart';
21
import 'chat_api_error.dart';
22
+import 'chat_error_formatter.dart';
23
24
abstract class ChatService {
25
Stream<ChatResponse> sendMessage({
@@ -675,6 +676,36 @@ class OpenAICompatibleChatService implements ChatService {
676
}
677
}
678
679
+String _formatOllamaErrorContent(dynamic error) {
680
+ if (error is String) {
681
+ return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
682
+ }
683
+ if (error is Map) {
684
+ return ChatErrorFormatter.formatBody(jsonEncode({'error': error}));
685
+ }
686
+ return ChatApiError(message: error.toString()).encode();
687
+}
688
+
689
+Future<String> _handleOllamaChatError(DioException e) async {
690
+ return ChatErrorFormatter.formatDioException(
691
+ e,
692
+ readBody: (data) async {
693
+ if (data is ResponseBody) {
694
+ return data.stream.cast<List<int>>().transform(utf8.decoder).join();
695
+ }
696
+ if (data is String) return data;
697
+ return '';
698
+ },
699
+ );
700
+}
701
+
702
+String? _extractOllamaErrorMessage(dynamic data) {
703
+ if (data == null) return null;
704
+ final raw = data is String ? data : jsonEncode(data);
705
+ if (raw.trim().isEmpty) return null;
706
+ return ChatErrorFormatter.formatBody(raw);
707
+}
708
+
709
class OllamaChatService implements ChatService {
710
final Dio _dio;
711
final bool imageCompressionEnabled;
@@ -725,12 +756,25 @@ class OllamaChatService implements ChatService {
756
}
757
}
758
759
+ final imageCount = apiMessages.fold<int>(0, (count, message) {
760
+ final images = message['images'];
761
+ return count + (images is List ? images.length : 0);
762
+ });
763
+ Log.debug(
764
+ 'Ollama request: endpoint=${server.chatEndpoint}, model=$modelId, '
765
+ 'messages=${apiMessages.length}, images=$imageCount, '
766
+ 'tools=${tools?.length ?? 0}',
767
+ );
768
+
769
try {
770
final response = await _dio.post<ResponseBody>(
771
server.chatEndpoint,
772
data: body,
773
options: Options(
774
responseType: ResponseType.stream,
775
+ // Ollama returns useful JSON error bodies for 4xx responses. Keep
776
+ // the stream available so the actual error reaches the UI.
777
+ validateStatus: (status) => status != null,
778
headers: {
779
'Content-Type': 'application/json',
780
...buildServerAuthHeaders(server),
@@ -739,7 +783,26 @@ class OllamaChatService implements ChatService {
783
cancelToken: _cancelToken,
784
);
785
742
- final stream = response.data!.stream.cast<List<int>>().transform(
786
+ final responseBody = response.data!;
787
+ final statusCode = response.statusCode ?? 0;
788
+ if (statusCode < 200 || statusCode >= 300) {
789
+ final rawError = await responseBody.stream
790
+ .cast<List<int>>()
791
+ .transform(utf8.decoder)
792
+ .join();
793
+ final parsedError = _extractOllamaErrorMessage(rawError);
794
+ Log.error('Ollama HTTP $statusCode: ${parsedError ?? rawError}');
795
+ yield ChatResponse(
796
+ type: ChatResponseType.error,
797
+ content: parsedError ??
798
+ (rawError.trim().isNotEmpty
799
+ ? rawError.trim()
800
+ : 'Ollama request failed (HTTP $statusCode).'),
801
+ );
802
+ return;
803
+ }
804
+
805
+ final stream = responseBody.stream.cast<List<int>>().transform(
806
utf8.decoder,
807
);
808
String buffer = '';
@@ -754,6 +817,21 @@ class OllamaChatService implements ChatService {
817
try {
818
final json = jsonDecode(line) as Map<String, dynamic>;
819
toolAdapter.consumeDynamicChunk(json);
820
+
821
+ // Ollama surfaces mid-stream errors as a top-level `error`
822
+ // field, often alongside HTTP 200. Surface them so the UI
823
+ // shows something more specific than the generic
824
+ // "unknown error" fallback when — for instance — the
825
+ // selected model doesn't actually support images.
826
+ final errorField = json['error'];
827
+ if (errorField != null) {
828
+ yield ChatResponse(
829
+ type: ChatResponseType.error,
830
+ content: _formatOllamaErrorContent(errorField),
831
+ );
832
+ return;
833
+ }
834
+
835
final message = json['message'];
836
if (message != null && message is Map<String, dynamic>) {
837
final content = message['content'] as String?;
@@ -793,10 +871,10 @@ class OllamaChatService implements ChatService {
871
}
872
} catch (e) {
873
Log.error('Ollama connection error: $e');
796
- yield ChatResponse(
797
- type: ChatResponseType.error,
798
- content: _handleChatError(e),
799
- );
874
+ final content = e is DioException
875
+ ? await _handleOllamaChatError(e)
876
+ : _handleChatError(e);
877
+ yield ChatResponse(type: ChatResponseType.error, content: content);
878
return;
879
}
880
yield const ChatResponse(type: ChatResponseType.done);
@@ -1128,68 +1206,66 @@ void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1206
}
1207
1208
String _formatApiErrorContent(dynamic error) {
1131
- if (error is Map<String, dynamic>) {
1132
- final parsed = ChatApiError.fromErrorMap(error);
1133
- if (parsed != null) return parsed.encode();
1134
- } else if (error is Map) {
1135
- final parsed = ChatApiError.fromErrorMap(
1136
- Map<String, dynamic>.from(error),
1137
- );
1138
- if (parsed != null) return parsed.encode();
1209
+ if (error is Map) {
1210
+ final map = Map<String, dynamic>.from(error);
1211
+ final parsed = ChatApiError.fromErrorMap(map);
1212
+ if (parsed != null) {
1213
+ // Use the unified formatter so the bubble benefits from humanising
1214
+ // and structured metadata even when the body is already shaped like
1215
+ // an OpenAI error.
1216
+ try {
1217
+ return ChatErrorFormatter.formatBody(jsonEncode(map));
1218
+ } catch (_) {
1219
+ return parsed.encode();
1220
+ }
1221
+ }
1222
}
1140
- return error.toString();
1223
+ return ChatApiError(message: error.toString()).encode();
1224
}
1225
1226
String _handleChatError(dynamic e) {
1227
if (e is DioException) {
1228
if (e.response != null) {
1146
- final parsed = ChatApiError.fromResponseBody(e.response!.data);
1147
- if (parsed != null) return parsed.encode();
1148
-
1229
final status = e.response!.statusCode;
1150
-
1151
- switch (status) {
1152
- case 401:
1153
- return 'Invalid API key or unauthorized.';
1154
- case 402:
1155
- return 'Monthly credit limit reached or insufficient balance.';
1156
- case 403:
1157
- return 'Access forbidden. Your API key might not have permission for this model.';
1158
- case 404:
1159
- return 'Model not found or API endpoint is incorrect.';
1160
- case 408:
1161
- return 'Request timeout. The server took too long to respond.';
1162
- case 413:
1163
- return 'Image too large for the server to accept. Try enabling '
1164
- 'image compression in Settings.';
1165
- case 429:
1166
- return 'Rate limit exceeded. Please wait a moment before trying again.';
1167
- case 500:
1168
- return 'Internal server error from the AI provider.';
1169
- case 502:
1170
- return 'Bad gateway. The provider is having issues.';
1171
- case 503:
1172
- return 'Service unavailable. The provider might be overloaded.';
1173
- case 504:
1174
- return 'Gateway timeout. The model took too long to respond.';
1230
+ final data = e.response!.data;
1231
+ if (data is String && data.trim().isNotEmpty) {
1232
+ return ChatErrorFormatter.formatBody(data, statusCode: status);
1233
+ }
1234
+ final parsed = ChatApiError.fromResponseBody(data);
1235
+ if (parsed != null) {
1236
+ return parsed.copyWithCodeIfMissing(status?.toString()).encode();
1237
}
1176
- return 'Server error ($status): ${e.message}';
1238
+ // Let the unified formatter map known HTTP status codes.
1239
+ return ChatErrorFormatter.formatBody('', statusCode: status);
1240
}
1241
1242
switch (e.type) {
1243
case DioExceptionType.connectionTimeout:
1244
case DioExceptionType.sendTimeout:
1245
case DioExceptionType.receiveTimeout:
1183
- return 'Connection timed out. Check your internet connection.';
1246
+ return const ChatApiError(
1247
+ message: 'The model took too long to respond.',
1248
+ type: 'timeout',
1249
+ ).encode();
1250
case DioExceptionType.connectionError:
1185
- return 'Connection failed. Check if the server is accessible.';
1251
+ return const ChatApiError(
1252
+ message:
1253
+ 'Could not reach the AI server. Confirm it is running and the address in Settings is correct.',
1254
+ type: 'connection_error',
1255
+ ).encode();
1256
case DioExceptionType.cancel:
1187
- return 'Request cancelled.';
1257
+ return const ChatApiError(
1258
+ message: 'Request cancelled.',
1259
+ type: 'cancelled',
1260
+ ).encode();
1261
default:
1189
- return 'Connection error: ${e.message}';
1262
+ return ChatApiError(
1263
+ message: e.message ?? 'Connection error.',
1264
+ type: e.type.name,
1265
+ ).encode();
1266
}
1267
}
1192
- return e.toString();
1268
+ return ChatApiError(message: e.toString()).encode();
1269
}
1270
1271
String _roleToString(MessageRole role) {
lib/features/chat/providers/chat_notifier.dart
+21
@@ -872,6 +872,7 @@ class ChatNotifier extends Notifier<ChatState> {
872
final collectedToolCalls = <ToolCallData>[];
873
874
bool stateNeedsUpdate = false;
875
+ bool streamHadError = false;
876
877
void updateUiState() {
878
if (!stateNeedsUpdate) return;
@@ -961,6 +962,7 @@ class ChatNotifier extends Notifier<ChatState> {
962
break;
963
case ChatResponseType.timeoutError:
964
case ChatResponseType.error:
965
+ streamHadError = true;
966
_uiUpdateTimer?.cancel();
967
_uiUpdateTimer = null;
968
streamingAssistantMessage = streamingAssistantMessage.copyWith(
@@ -1007,6 +1009,17 @@ class ChatNotifier extends Notifier<ChatState> {
1009
_uiUpdateTimer = null;
1010
updateUiState();
1011
1012
+ if (streamHadError) {
1013
+ // The error branch already finalised the message and
1014
+ // stopped streaming — do not overwrite the error state
1015
+ // with success/default text.
1016
+ if (!_isInMemoryChat) {
1017
+ await _saveService?.flush();
1018
+ }
1019
+ ref.read(chatBackgroundServiceProvider).stop();
1020
+ return;
1021
+ }
1022
+
1023
if (!_isInMemoryChat) {
1024
await _saveService?.flush();
1025
}
@@ -1851,6 +1864,7 @@ class ChatNotifier extends Notifier<ChatState> {
1864
: const <ToolDefinition>[];
1865
1866
bool stateNeedsUpdate = false;
1867
+ bool streamHadError = false;
1868
1869
void updateUiState() {
1870
if (!stateNeedsUpdate) return;
@@ -1941,6 +1955,7 @@ class ChatNotifier extends Notifier<ChatState> {
1955
break;
1956
case ChatResponseType.timeoutError:
1957
case ChatResponseType.error:
1958
+ streamHadError = true;
1959
_uiUpdateTimer?.cancel();
1960
_uiUpdateTimer = null;
1961
streamingAssistantMessage = _finalizeStreamMessage(
@@ -1977,6 +1992,12 @@ class ChatNotifier extends Notifier<ChatState> {
1992
onDone: () async {
1993
_uiUpdateTimer?.cancel();
1994
_uiUpdateTimer = null;
1995
+ if (streamHadError) {
1996
+ // The error branch already finalised the message and
1997
+ // stopped streaming — do not overwrite the error state
1998
+ // with a successful complete message.
1999
+ return;
2000
+ }
2001
final streamConvId = assistantMessage.conversationId;
2002
final isCurrentContext = _activeConversationId == streamConvId;
2003
lib/features/models/components/model_tile.dart
+11
-6
@@ -62,7 +62,11 @@ class ModelTile extends StatelessWidget {
62
Row(
63
children: [
64
if (isFavorite) ...[
65
- HugeIcon(icon: HugeIcons.strokeRoundedStar, size: 16, color: Colors.amber[600]),
65
+ HugeIcon(
66
+ icon: HugeIcons.strokeRoundedStar,
67
+ size: 16,
68
+ color: Colors.amber[600],
69
+ ),
70
const SizedBox(width: 6),
71
],
72
Expanded(
@@ -134,8 +138,8 @@ class ModelTile extends StatelessWidget {
138
model.serverType == ServerType.ollama) &&
139
isLoaded) ...[
140
IconButton(
137
- icon: HugeIcon(icon:
138
- HugeIcons.strokeRoundedPower,
141
+ icon: HugeIcon(
142
+ icon: HugeIcons.strokeRoundedPower,
143
size: 18,
144
color: Colors.red[400],
145
),
@@ -158,13 +162,14 @@ class ModelTile extends StatelessWidget {
162
height: 22,
163
child: CircularProgressIndicator(strokeWidth: 2, color: accent),
164
)
161
- else if (isSelected)
165
+ else if (isSelected) ...[
166
+ SizedBox(width: 4),
167
HugeIcon(
168
icon: HugeIcons.strokeRoundedCheckmarkCircle01,
169
color: accent,
170
size: 22,
166
- )
167
- else
171
+ ),
172
+ ] else
173
HugeIcon(
174
icon: HugeIcons.strokeRoundedArrowRight01,
175
size: 20,
lib/features/servers/data/models/server.dart
+12
@@ -205,6 +205,18 @@ class Server {
205
}
206
}
207
208
+ String get modelDetailsEndpoint {
209
+ switch (type) {
210
+ case ServerType.ollama:
211
+ return '$baseUrl${_apiPath('/api/show')}';
212
+ case ServerType.lmStudio:
213
+ case ServerType.openAICompatible:
214
+ case ServerType.openRouter:
215
+ case ServerType.onDevice:
216
+ return '';
217
+ }
218
+ }
219
+
220
String get loadModelEndpoint {
221
switch (type) {
222
case ServerType.lmStudio:
lib/features/servers/data/server_api_service.dart
+59
-4
@@ -46,17 +46,23 @@ class ServerApiService {
46
options: Options(headers: buildServerAuthHeaders(server)),
47
);
48
49
+ List<ModelInfo> models;
50
switch (server.type) {
51
case ServerType.lmStudio:
52
case ServerType.openAICompatible:
52
- return _parseOpenAICompatibleModels(response.data, server);
53
+ models = _parseOpenAICompatibleModels(response.data, server);
54
+ break;
55
case ServerType.ollama:
54
- return _parseOllamaModels(response.data, server);
56
+ models = await _parseAndEnrichOllamaModels(response.data, server);
57
+ break;
58
case ServerType.openRouter:
56
- return _parseOpenRouterModels(response.data, server);
59
+ models = _parseOpenRouterModels(response.data, server);
60
+ break;
61
case ServerType.onDevice:
58
- return [];
62
+ models = [];
63
+ break;
64
}
65
+ return models;
66
} catch (e) {
67
throw Exception('Failed to fetch models: $e');
68
}
@@ -409,6 +415,55 @@ class ServerApiService {
415
return null;
416
}
417
418
+ Future<List<ModelInfo>> _parseAndEnrichOllamaModels(
419
+ dynamic data,
420
+ Server server,
421
+ ) async {
422
+ final models = _parseOllamaModels(data, server);
423
+ if (models.isEmpty) return models;
424
+
425
+ return Future.wait(
426
+ models.map((model) async {
427
+ try {
428
+ final response = await _dio.post(
429
+ server.modelDetailsEndpoint,
430
+ data: {'name': model.id},
431
+ options: Options(headers: buildServerAuthHeaders(server)),
432
+ );
433
+ final capabilities = _parseOllamaCapabilityList(response.data);
434
+ return model.copyWith(
435
+ supportsVision: capabilities.supportsVision,
436
+ supportsToolUse: capabilities.supportsToolUse,
437
+ );
438
+ } catch (error) {
439
+ Log.warning(
440
+ 'Unable to read Ollama capabilities for ${model.id}: $error',
441
+ );
442
+ return model;
443
+ }
444
+ }),
445
+ );
446
+ }
447
+
448
+ ({bool supportsVision, bool supportsToolUse}) _parseOllamaCapabilityList(
449
+ dynamic data,
450
+ ) {
451
+ if (data is! Map) {
452
+ return (supportsVision: false, supportsToolUse: false);
453
+ }
454
+ final rawCapabilities = data['capabilities'];
455
+ if (rawCapabilities is! List) {
456
+ return (supportsVision: false, supportsToolUse: false);
457
+ }
458
+ final capabilities = rawCapabilities
459
+ .map((value) => value.toString().trim().toLowerCase())
460
+ .toSet();
461
+ return (
462
+ supportsVision: capabilities.contains('vision'),
463
+ supportsToolUse: capabilities.contains('tools'),
464
+ );
465
+ }
466
+
467
List<ModelInfo> _parseOllamaModels(dynamic data, Server server) {
468
final List<ModelInfo> models = [];
469
final modelsData = data['models'];
test/features/chat/data/chat_error_formatter_test.dart
new
+55
@@ -0,0 +1,55 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/features/chat/data/chat_api_error.dart';
3
+import 'package:localmind/features/chat/data/chat_error_formatter.dart';
4
+
5
+void main() {
6
+ group('ChatErrorFormatter', () {
7
+ test('decodes Ollama envelope with type tag and humanises context error',
8
+ () {
9
+ final formatted = ChatErrorFormatter.formatBody(
10
+ '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error"}}',
11
+ statusCode: 400,
12
+ );
13
+ final parsed = ChatApiError.tryParse(formatted)!;
14
+ expect(parsed.type, 'exceed_context_size_error');
15
+ expect(parsed.code, '400');
16
+ expect(parsed.message, contains('context window'));
17
+ });
18
+
19
+ test('decodes Ollama string error and humanises "does not support images"',
20
+ () {
21
+ final formatted = ChatErrorFormatter.formatBody(
22
+ '{"error":"this model does not support images"}',
23
+ );
24
+ final parsed = ChatApiError.tryParse(formatted)!;
25
+ expect(parsed.message, contains('vision model'));
26
+ });
27
+
28
+ test('decodes OpenAI-style nested error and attaches status code', () {
29
+ final formatted = ChatErrorFormatter.formatBody(
30
+ '{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}',
31
+ statusCode: 401,
32
+ );
33
+ final parsed = ChatApiError.tryParse(formatted)!;
34
+ expect(parsed.message, contains('API key'));
35
+ expect(parsed.type, 'invalid_request_error');
36
+ // Either the body's code or the status code should appear.
37
+ expect(parsed.code, anyOf('invalid_api_key', '401'));
38
+ });
39
+
40
+ test('falls back to status-only message when body is empty', () {
41
+ final formatted = ChatErrorFormatter.formatBody('', statusCode: 429);
42
+ final parsed = ChatApiError.tryParse(formatted)!;
43
+ expect(parsed.type, 'rate_limited');
44
+ expect(parsed.message, contains('Rate limit'));
45
+ });
46
+
47
+ test('humanises ECONNREFUSED-style messages', () {
48
+ final formatted = ChatErrorFormatter.formatBody(
49
+ '{"error":"dial tcp 127.0.0.1:11434: connect: connection refused"}',
50
+ );
51
+ final parsed = ChatApiError.tryParse(formatted)!;
52
+ expect(parsed.message.toLowerCase(), contains('ollama'));
53
+ });
54
+ });
55
+}
\ No newline at end of file
test/features/chat/tools/chat_service_adapter_selection_test.dart
+116
-3
@@ -5,6 +5,7 @@ import 'dart:typed_data';
5
import 'package:dio/dio.dart';
6
import 'package:flutter_test/flutter_test.dart';
7
import 'package:localmind/core/models/enums.dart';
8
+import 'package:localmind/features/chat/data/chat_api_error.dart';
9
import 'package:localmind/features/chat/data/chat_service.dart';
10
import 'package:localmind/features/chat/data/models/message.dart';
11
import 'package:localmind/features/chat/data/models/chat_parameters.dart';
@@ -16,7 +17,8 @@ import 'package:localmind/features/servers/data/models/server.dart';
17
18
class StreamInterceptor extends Interceptor {
19
final List<String> lines;
19
- StreamInterceptor(this.lines);
20
+ final int statusCode;
21
+ StreamInterceptor(this.lines, {this.statusCode = 200});
22
23
@override
24
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
@@ -25,12 +27,12 @@ class StreamInterceptor extends Interceptor {
27
requestOptions: options,
28
data: ResponseBody(
29
controller.stream,
28
- 200,
30
+ statusCode,
31
headers: {
32
Headers.contentTypeHeader: ['application/json'],
33
},
34
),
33
- statusCode: 200,
35
+ statusCode: statusCode,
36
));
37
38
// Emit lines asynchronously
@@ -309,6 +311,117 @@ void main() {
311
expect(message.containsKey('images'), isFalse);
312
expect(responses.last.type, ChatResponseType.done);
313
});
314
+
315
+ test('surfaces Ollama native stream errors', () async {
316
+ final interceptor = CapturingStreamInterceptor([
317
+ '{"error":"this model does not support images"}',
318
+ ]);
319
+ final service = OllamaChatService(Dio()..interceptors.add(interceptor));
320
+
321
+ final responses = await service
322
+ .sendMessage(
323
+ server: _ollamaTestServer(),
324
+ modelId: 'vision-model',
325
+ messages: [
326
+ Message(
327
+ id: 'failed-vision-message',
328
+ conversationId: 'conversation',
329
+ role: MessageRole.user,
330
+ content: 'What is shown?',
331
+ createdAt: DateTime.now(),
332
+ ),
333
+ ],
334
+ params: ChatParameters.defaults(),
335
+ )
336
+ .toList();
337
+
338
+ expect(responses, hasLength(1));
339
+ expect(responses.single.type, ChatResponseType.error);
340
+ final parsed = ChatApiError.tryParse(responses.single.content);
341
+ expect(parsed, isNotNull);
342
+ expect(
343
+ parsed!.message,
344
+ 'The selected model does not support images. Choose a vision '
345
+ 'model such as llava, qwen2.5vl, gemma3, or llama3.2-vision.',
346
+ );
347
+ });
348
+
349
+ test('decodes Ollama HTTP 400 error bodies', () async {
350
+ final interceptor = StreamInterceptor(
351
+ ['{"error":"unable to decode image"}'],
352
+ statusCode: 400,
353
+ );
354
+ final service = OllamaChatService(Dio()..interceptors.add(interceptor));
355
+
356
+ final responses = await service
357
+ .sendMessage(
358
+ server: _ollamaTestServer(),
359
+ modelId: 'qwen3-vl:2b',
360
+ messages: [
361
+ Message(
362
+ id: 'http-error-message',
363
+ conversationId: 'conversation',
364
+ role: MessageRole.user,
365
+ content: 'Describe the image',
366
+ createdAt: DateTime.now(),
367
+ ),
368
+ ],
369
+ params: ChatParameters.defaults(),
370
+ )
371
+ .toList();
372
+
373
+ expect(responses, hasLength(1));
374
+ expect(responses.single.type, ChatResponseType.error);
375
+ final parsed = ChatApiError.tryParse(responses.single.content);
376
+ expect(parsed, isNotNull);
377
+ expect(
378
+ parsed!.message,
379
+ 'The server could not decode the attached image. Try a smaller '
380
+ 'or different-format image, or enable image compression in Settings.',
381
+ );
382
+ });
383
+
384
+ test(
385
+ 'decodes structured Ollama HTTP 400 error envelope with type tag',
386
+ () async {
387
+ final interceptor = StreamInterceptor(
388
+ [
389
+ '{"error":{"code":400,"message":"request (4286 tokens) exceeds the available context size (4096 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":4286,"n_ctx":4096}}',
390
+ ],
391
+ statusCode: 400,
392
+ );
393
+ final service = OllamaChatService(Dio()..interceptors.add(interceptor));
394
+
395
+ final responses = await service
396
+ .sendMessage(
397
+ server: _ollamaTestServer(),
398
+ modelId: 'qwen3-vl:2b',
399
+ messages: [
400
+ Message(
401
+ id: 'context-error-message',
402
+ conversationId: 'conversation',
403
+ role: MessageRole.user,
404
+ content: 'Tell me a story',
405
+ createdAt: DateTime.now(),
406
+ ),
407
+ ],
408
+ params: ChatParameters.defaults(),
409
+ )
410
+ .toList();
411
+
412
+ expect(responses, hasLength(1));
413
+ expect(responses.single.type, ChatResponseType.error);
414
+ final parsed = ChatApiError.tryParse(responses.single.content);
415
+ expect(parsed, isNotNull);
416
+ expect(
417
+ parsed!.message,
418
+ 'The conversation is longer than the model\'s context window. '
419
+ 'Increase `num_ctx` for the model or start a new chat.',
420
+ );
421
+ expect(parsed.type, 'exceed_context_size_error');
422
+ expect(parsed.code, '400');
423
+ },
424
+ );
425
});
426
}
427
test/features/servers/server_api_service_test.dart
+117
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
3
import 'package:localmind/core/models/enums.dart';
4
import 'package:localmind/features/servers/data/models/server.dart';
5
import 'package:localmind/features/servers/data/server_api_service.dart';
6
+import 'dart:convert';
7
8
class TestInterceptor extends Interceptor {
9
final dynamic responseData;
@@ -49,6 +50,68 @@ class FailingInterceptor extends Interceptor {
50
}
51
}
52
53
+class RoutingInterceptor extends Interceptor {
54
+ RoutingInterceptor(this.responses);
55
+
56
+ /// Maps a request key ("METHOD /path" or "METHOD /path:body-name") to a
57
+ /// mock response body. Keys match against the request's path suffix so
58
+ /// tests don't need a baseUrl.
59
+ final Map<String, dynamic> responses;
60
+
61
+ @override
62
+ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
63
+ final key = _matchKey(options);
64
+ final body = key == null ? null : responses[key];
65
+ if (body == null) {
66
+ handler.reject(
67
+ DioException(
68
+ requestOptions: options,
69
+ type: DioExceptionType.badResponse,
70
+ message: 'No mock for ${options.method} ${options.path}',
71
+ ),
72
+ );
73
+ return;
74
+ }
75
+ handler.resolve(
76
+ Response(requestOptions: options, statusCode: 200, data: body),
77
+ );
78
+ }
79
+
80
+ String? _matchKey(RequestOptions options) {
81
+ final method = options.method.toUpperCase();
82
+ final path = options.path;
83
+ final base = '$method $path';
84
+ final suffix = '$method ${_suffix(path)}';
85
+ for (final candidate in responses.keys) {
86
+ if (candidate == base || candidate == suffix) return candidate;
87
+ }
88
+ final name = _bodyName(options.data);
89
+ if (name == null) return null;
90
+ final baseKey = '$base:$name';
91
+ if (responses.containsKey(baseKey)) return baseKey;
92
+ return '$suffix:$name';
93
+ }
94
+
95
+ String _suffix(String path) {
96
+ final idx = path.indexOf('/api/');
97
+ if (idx < 0) return path;
98
+ return path.substring(idx);
99
+ }
100
+
101
+ String? _bodyName(dynamic data) {
102
+ if (data is Map) return data['name']?.toString();
103
+ if (data is String && data.isNotEmpty) {
104
+ try {
105
+ final decoded = jsonDecode(data);
106
+ if (decoded is Map) return decoded['name']?.toString();
107
+ } catch (_) {
108
+ // Not JSON — leave name null.
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+}
114
+
115
void main() {
116
group('ServerApiService - OpenAI/llama.cpp model list parsing', () {
117
late Server testServer;
@@ -363,6 +426,60 @@ void main() {
426
expect(recorder.requests.first.data, containsPair('prompt', ''));
427
});
428
429
+ test(
430
+ 'fetchModels enriches Ollama models with vision capability from /api/show',
431
+ () async {
432
+ final ollamaServer = Server(
433
+ id: 'test-ollama',
434
+ name: 'Test Ollama',
435
+ type: ServerType.ollama,
436
+ host: 'localhost',
437
+ port: 11434,
438
+ createdAt: DateTime.now(),
439
+ lastConnectedAt: DateTime.now(),
440
+ );
441
+
442
+ final interceptor = RoutingInterceptor({
443
+ 'GET /api/tags': {
444
+ 'models': [
445
+ {
446
+ 'name': 'qwen3-vl:2b',
447
+ 'size': 1800000000,
448
+ 'details': {
449
+ 'parameter_size': '2.1B',
450
+ 'quantization_level': 'Q4_K_M',
451
+ 'family': 'qwen3',
452
+ },
453
+ },
454
+ {
455
+ 'name': 'llama3.2:3b',
456
+ 'size': 2000000000,
457
+ 'details': {
458
+ 'parameter_size': '3B',
459
+ 'quantization_level': 'Q4_K_M',
460
+ 'family': 'llama',
461
+ },
462
+ },
463
+ ],
464
+ },
465
+ 'POST /api/show:qwen3-vl:2b': {
466
+ 'capabilities': ['completion', 'vision'],
467
+ },
468
+ 'POST /api/show:llama3.2:3b': {
469
+ 'capabilities': ['completion'],
470
+ },
471
+ });
472
+
473
+ final service = ServerApiService(Dio()..interceptors.add(interceptor));
474
+ final models = await service.fetchModels(ollamaServer);
475
+
476
+ expect(models, hasLength(2));
477
+ final byId = {for (final m in models) m.id: m};
478
+ expect(byId['qwen3-vl:2b']?.supportsVision, isTrue);
479
+ expect(byId['llama3.2:3b']?.supportsVision, isFalse);
480
+ },
481
+ );
482
+
483
test('unloadModel for Ollama surfaces failures', () async {
484
final ollamaServer = Server(
485
id: 'test-ollama',