feat(i18n/socket): add WebSocket error messages and show connect errors
This commit is contained in:
@@ -3262,7 +3262,7 @@ class ApiService {
|
||||
_traceApi('Initiating background tools flow (task-based)');
|
||||
_traceApi('Posting to /api/chat/completions');
|
||||
|
||||
// Fire in background; poll chat for updates and stream deltas to UI
|
||||
// Fire in background; all updates will come via WebSocket events
|
||||
() async {
|
||||
try {
|
||||
final resp = await _dio.post('/api/chat/completions', data: data);
|
||||
@@ -3272,25 +3272,10 @@ class ApiService {
|
||||
: null;
|
||||
_traceApi('Background task created: $taskId');
|
||||
|
||||
// If no session/socket provided, fall back to polling for updates.
|
||||
final pollChatId = (conversationId != null && conversationId.isNotEmpty)
|
||||
? conversationId
|
||||
: null;
|
||||
final requiresPolling =
|
||||
sessionIdOverride == null || sessionIdOverride.isEmpty;
|
||||
|
||||
if (requiresPolling && pollChatId != null) {
|
||||
final chatId = pollChatId;
|
||||
await _pollChatForMessageUpdates(
|
||||
chatId: chatId,
|
||||
messageId: messageId,
|
||||
streamController: streamController,
|
||||
);
|
||||
} else {
|
||||
// Close the controller so listeners don't hang waiting for chunks
|
||||
if (!streamController.isClosed) {
|
||||
streamController.close();
|
||||
}
|
||||
// Close the controller immediately - all streaming will happen via WebSocket
|
||||
// No polling fallback to avoid duplication issues
|
||||
if (!streamController.isClosed) {
|
||||
streamController.close();
|
||||
}
|
||||
} catch (e) {
|
||||
_traceApi('Background tools flow failed: $e');
|
||||
@@ -3329,224 +3314,6 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Poll the server chat until the assistant message is populated with tool results,
|
||||
// then stream deltas to the UI and close.
|
||||
Future<void> _pollChatForMessageUpdates({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
required StreamController<String> streamController,
|
||||
}) async {
|
||||
String last = '';
|
||||
int stableCount = 0;
|
||||
final started = DateTime.now();
|
||||
|
||||
bool containsDone(String s) =>
|
||||
s.contains('<details type="tool_calls"') && s.contains('done="true"');
|
||||
|
||||
// Allow much longer time for large completions, matching OpenWebUI's generous timeouts
|
||||
while (DateTime.now().difference(started).inSeconds < 600) {
|
||||
// Increased from 180 to 600 seconds (10 minutes)
|
||||
try {
|
||||
// Small delay between polls
|
||||
await Future.delayed(const Duration(milliseconds: 900));
|
||||
|
||||
final resp = await _dio.get('/api/v1/chats/$chatId');
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
|
||||
// Locate assistant content from multiple shapes
|
||||
String content = '';
|
||||
|
||||
Map<String, dynamic>? chatObj = (data['chat'] is Map<String, dynamic>)
|
||||
? data['chat'] as Map<String, dynamic>
|
||||
: null;
|
||||
|
||||
// 1) Preferred: chat.messages (list) – try exact id first
|
||||
if (chatObj != null && chatObj['messages'] is List) {
|
||||
final List messagesList = chatObj['messages'] as List;
|
||||
final target = messagesList.firstWhere(
|
||||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (target != null) {
|
||||
final rawContent = (target as Map)['content'];
|
||||
if (rawContent is List) {
|
||||
final textItem = rawContent.firstWhere(
|
||||
(i) => i is Map && i['type'] == 'text',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = textItem['text']?.toString() ?? '';
|
||||
}
|
||||
} else if (rawContent is String) {
|
||||
content = rawContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback: chat.history.messages (map) – try exact id
|
||||
if (content.isEmpty && chatObj != null) {
|
||||
final history = chatObj['history'];
|
||||
if (history is Map && history['messages'] is Map) {
|
||||
final Map<String, dynamic> messagesMap =
|
||||
(history['messages'] as Map).cast<String, dynamic>();
|
||||
final msg = messagesMap[messageId];
|
||||
if (msg is Map) {
|
||||
final rawContent = msg['content'];
|
||||
if (rawContent is String) {
|
||||
content = rawContent;
|
||||
} else if (rawContent is List) {
|
||||
final textItem = rawContent.firstWhere(
|
||||
(i) => i is Map && i['type'] == 'text',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = textItem['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Last resort: top-level messages (list) – try exact id
|
||||
if (content.isEmpty && data['messages'] is List) {
|
||||
final List topMessages = data['messages'] as List;
|
||||
final target = topMessages.firstWhere(
|
||||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (target != null) {
|
||||
final rawContent = (target as Map)['content'];
|
||||
if (rawContent is String) {
|
||||
content = rawContent;
|
||||
} else if (rawContent is List) {
|
||||
final textItem = rawContent.firstWhere(
|
||||
(i) => i is Map && i['type'] == 'text',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = textItem['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: We intentionally removed the fallback to "any latest assistant message"
|
||||
// because it causes duplication issues in multi-turn conversations.
|
||||
// If we can't find the specific message by ID, we skip this poll iteration
|
||||
// and wait for the next one rather than showing content from a different message.
|
||||
|
||||
if (content.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stream only the delta when content grows monotonically
|
||||
if (content.startsWith(last)) {
|
||||
final delta = content.substring(last.length);
|
||||
if (delta.isNotEmpty && !streamController.isClosed) {
|
||||
streamController.add(delta);
|
||||
}
|
||||
} else {
|
||||
// Fallback: replace entire content by emitting a separator + full content
|
||||
if (!streamController.isClosed) {
|
||||
streamController.add('\n');
|
||||
streamController.add(content);
|
||||
}
|
||||
}
|
||||
// Stop when we detect done=true on tool_calls or when content stabilizes
|
||||
if (containsDone(content)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If content hasn't changed for several polls, assume completion,
|
||||
// but be more conservative to avoid cutting off long responses.
|
||||
// OpenWebUI relies more on explicit done signals than stability checks.
|
||||
final prev = last;
|
||||
if (content == prev && content.isNotEmpty) {
|
||||
stableCount++;
|
||||
} else if (content != prev) {
|
||||
stableCount = 0;
|
||||
}
|
||||
// Increased threshold from 3 to 8 polls to be more conservative
|
||||
// This gives ~7-8 seconds of stability before assuming completion
|
||||
if (content.isNotEmpty && stableCount >= 8) {
|
||||
DebugLogger.log(
|
||||
'Content stable for $stableCount polls, assuming completion',
|
||||
scope: 'api/polling',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
last = content;
|
||||
} catch (e) {
|
||||
// Ignore transient errors and continue polling
|
||||
}
|
||||
}
|
||||
|
||||
// Final backfill: one last attempt to fetch the latest content
|
||||
// in case the server wrote the final message after our last poll.
|
||||
try {
|
||||
if (!streamController.isClosed) {
|
||||
final resp = await _dio.get('/api/v1/chats/$chatId');
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
String content = '';
|
||||
Map<String, dynamic>? chatObj = (data['chat'] is Map<String, dynamic>)
|
||||
? data['chat'] as Map<String, dynamic>
|
||||
: null;
|
||||
if (chatObj != null && chatObj['messages'] is List) {
|
||||
final List messagesList = chatObj['messages'] as List;
|
||||
final target = messagesList.firstWhere(
|
||||
(m) => (m is Map && (m['id']?.toString() == messageId)),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (target != null) {
|
||||
final rawContent = (target as Map)['content'];
|
||||
if (rawContent is String) {
|
||||
content = rawContent;
|
||||
} else if (rawContent is List) {
|
||||
final textItem = rawContent.firstWhere(
|
||||
(i) => i is Map && i['type'] == 'text',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = (textItem as Map)['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.isEmpty && chatObj != null) {
|
||||
final history = chatObj['history'];
|
||||
if (history is Map && history['messages'] is Map) {
|
||||
final Map<String, dynamic> messagesMap =
|
||||
(history['messages'] as Map).cast<String, dynamic>();
|
||||
final msg = messagesMap[messageId];
|
||||
if (msg is Map) {
|
||||
final rawContent = msg['content'];
|
||||
if (rawContent is String) {
|
||||
content = rawContent;
|
||||
} else if (rawContent is List) {
|
||||
final textItem = rawContent.firstWhere(
|
||||
(i) => i is Map && i['type'] == 'text',
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = (textItem as Map)['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.isNotEmpty && content != last) {
|
||||
streamController.add('\n');
|
||||
streamController.add(content);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!streamController.isClosed) {
|
||||
streamController.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel an active streaming message by its messageId (client-side abort)
|
||||
void cancelStreamingMessage(String messageId) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user