Merge pull request #122 from cogwheel0/implement-worker-manager
implement-worker-manager
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:conduit/core/providers/app_providers.dart';
|
||||
import 'package:conduit/core/services/api_service.dart';
|
||||
import 'package:conduit/features/auth/providers/unified_auth_providers.dart';
|
||||
|
||||
/// Builds HTTP headers for protected image requests.
|
||||
@@ -8,14 +9,14 @@ import 'package:conduit/features/auth/providers/unified_auth_providers.dart';
|
||||
/// Includes Authorization (Bearer token or API key) and any server-configured
|
||||
/// custom headers. Returns `null` if no headers are needed.
|
||||
Map<String, String>? buildImageHeadersFromRef(Ref ref) {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final token = ref.read(authTokenProvider3);
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
final token = ref.watch(authTokenProvider3);
|
||||
return _build(api, token);
|
||||
}
|
||||
|
||||
Map<String, String>? buildImageHeadersFromWidgetRef(WidgetRef ref) {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final token = ref.read(authTokenProvider3);
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
final token = ref.watch(authTokenProvider3);
|
||||
return _build(api, token);
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ Map<String, String>? buildImageHeadersFromContainer(
|
||||
return _build(api, token);
|
||||
}
|
||||
|
||||
Map<String, String>? _build(dynamic api, String? token) {
|
||||
Map<String, String>? _build(ApiService? api, String? token) {
|
||||
final headers = <String, String>{};
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
@@ -39,8 +40,9 @@ Map<String, String>? _build(dynamic api, String? token) {
|
||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||
}
|
||||
|
||||
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
||||
headers.addAll(api.serverConfig.customHeaders);
|
||||
final customHeaders = api?.serverConfig.customHeaders ?? {};
|
||||
if (customHeaders.isNotEmpty) {
|
||||
headers.addAll(customHeaders);
|
||||
}
|
||||
|
||||
return headers.isEmpty ? null : headers;
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../providers/app_providers.dart';
|
||||
/// Notes
|
||||
/// - Scoped to the configured host and (optionally) port only.
|
||||
/// - Not available on web (browsers enforce TLS validation).
|
||||
final selfSignedImageCacheManagerProvider = Provider<CacheManager?>((ref) {
|
||||
final selfSignedImageCacheManagerProvider = Provider<BaseCacheManager?>((ref) {
|
||||
final active = ref.watch(activeServerProvider);
|
||||
|
||||
return active.maybeWhen(
|
||||
@@ -26,7 +26,7 @@ final selfSignedImageCacheManagerProvider = Provider<CacheManager?>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
CacheManager? _buildForServer(ServerConfig server) {
|
||||
BaseCacheManager? _buildForServer(ServerConfig server) {
|
||||
if (kIsWeb) return null;
|
||||
if (!server.allowSelfSignedCertificates) return null;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import '../services/optimized_storage_service.dart';
|
||||
import '../services/socket_service.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import '../models/socket_event.dart';
|
||||
import '../services/worker_manager.dart';
|
||||
import '../../shared/theme/tweakcn_themes.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../features/tools/providers/tools_providers.dart';
|
||||
@@ -57,6 +58,7 @@ final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
return OptimizedStorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
boxes: ref.watch(hiveBoxesProvider),
|
||||
workerManager: ref.watch(workerManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -259,6 +261,7 @@ final apiServiceProvider = Provider<ApiService?>((ref) {
|
||||
return null;
|
||||
}
|
||||
final activeServer = ref.watch(activeServerProvider);
|
||||
final workerManager = ref.watch(workerManagerProvider);
|
||||
|
||||
return activeServer.maybeWhen(
|
||||
data: (server) {
|
||||
@@ -266,6 +269,7 @@ final apiServiceProvider = Provider<ApiService?>((ref) {
|
||||
|
||||
final apiService = ApiService(
|
||||
serverConfig: server,
|
||||
workerManager: workerManager,
|
||||
authToken: null, // Will be set by auth state manager
|
||||
);
|
||||
|
||||
@@ -879,311 +883,345 @@ class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp {
|
||||
void set(DateTime? timestamp) => state = timestamp;
|
||||
}
|
||||
|
||||
/// Clears the in-memory timestamp cache and invalidates the conversations
|
||||
/// provider so the next read forces a refetch. Optionally invalidates the
|
||||
/// folders provider when folder metadata must stay in sync with conversations.
|
||||
/// Clears the in-memory timestamp cache and triggers a refresh of the
|
||||
/// conversations provider. Optionally refreshes the folders provider so folder
|
||||
/// metadata stays in sync.
|
||||
void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
|
||||
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
|
||||
ref.invalidate(conversationsProvider);
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
unawaited(notifier.refresh(includeFolders: includeFolders));
|
||||
if (includeFolders) {
|
||||
ref.invalidate(foldersProvider);
|
||||
final foldersNotifier = ref.read(foldersProvider.notifier);
|
||||
unawaited(foldersNotifier.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation providers - Now using correct OpenWebUI API with caching
|
||||
// keepAlive to maintain cache during authenticated session
|
||||
// Conversation providers - Now using correct OpenWebUI API with caching and
|
||||
// immediate mutation helpers.
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<Conversation>> conversations(Ref ref) async {
|
||||
// Do not fetch protected data until authenticated. Use watch so we refetch
|
||||
// when the auth state transitions in either direction.
|
||||
final authed = ref.watch(isAuthenticatedProvider2);
|
||||
if (!authed) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'conversations');
|
||||
return [];
|
||||
}
|
||||
// Check if we have a recent cache (within 5 seconds)
|
||||
final lastFetch = ref.read(_conversationsCacheTimestampProvider);
|
||||
if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) {
|
||||
DebugLogger.log(
|
||||
'cache-hit',
|
||||
scope: 'conversations',
|
||||
data: {'ageSecs': DateTime.now().difference(lastFetch).inSeconds},
|
||||
);
|
||||
// Note: Can't read our own provider here, would cause a cycle
|
||||
// The caching is handled by Riverpod's built-in mechanism
|
||||
}
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
// Provide a simple local demo conversation list
|
||||
return [
|
||||
Conversation(
|
||||
id: 'demo-conv-1',
|
||||
title: 'Welcome to Conduit (Demo)',
|
||||
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
messages: [
|
||||
ChatMessage(
|
||||
id: 'demo-msg-1',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.',
|
||||
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
model: 'Gemma 2 Mini (Demo)',
|
||||
isStreaming: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'conversations');
|
||||
return [];
|
||||
class Conversations extends _$Conversations {
|
||||
@override
|
||||
Future<List<Conversation>> build() async {
|
||||
final authed = ref.watch(isAuthenticatedProvider2);
|
||||
if (!authed) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'conversations');
|
||||
_updateCacheTimestamp(null);
|
||||
return const [];
|
||||
}
|
||||
|
||||
if (ref.watch(reviewerModeProvider)) {
|
||||
return _demoConversations();
|
||||
}
|
||||
|
||||
return _loadRemoteConversations();
|
||||
}
|
||||
|
||||
try {
|
||||
DebugLogger.log('fetch-start', scope: 'conversations');
|
||||
final conversations = await api
|
||||
.getConversations(); // Fetch all conversations
|
||||
DebugLogger.log(
|
||||
'fetch-ok',
|
||||
scope: 'conversations',
|
||||
data: {'count': conversations.length},
|
||||
);
|
||||
Future<void> refresh({bool includeFolders = false}) async {
|
||||
final authed = ref.read(isAuthenticatedProvider2);
|
||||
if (!authed) {
|
||||
_updateCacheTimestamp(null);
|
||||
state = AsyncData<List<Conversation>>(<Conversation>[]);
|
||||
if (includeFolders) {
|
||||
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.read(reviewerModeProvider)) {
|
||||
state = AsyncData<List<Conversation>>(_demoConversations());
|
||||
if (includeFolders) {
|
||||
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await AsyncValue.guard(_loadRemoteConversations);
|
||||
if (!ref.mounted) return;
|
||||
state = result;
|
||||
if (includeFolders) {
|
||||
unawaited(ref.read(foldersProvider.notifier).refresh());
|
||||
}
|
||||
}
|
||||
|
||||
void removeConversation(String id) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final updated = current
|
||||
.where((conversation) => conversation.id != id)
|
||||
.toList(growable: true);
|
||||
state = AsyncData<List<Conversation>>(_sortByUpdatedAt(updated));
|
||||
}
|
||||
|
||||
void upsertConversation(Conversation conversation) {
|
||||
final current = state.asData?.value ?? const <Conversation>[];
|
||||
final updated = <Conversation>[...current];
|
||||
final index = updated.indexWhere(
|
||||
(element) => element.id == conversation.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
updated[index] = conversation;
|
||||
} else {
|
||||
updated.add(conversation);
|
||||
}
|
||||
state = AsyncData<List<Conversation>>(_sortByUpdatedAt(updated));
|
||||
}
|
||||
|
||||
void updateConversation(
|
||||
String id,
|
||||
Conversation Function(Conversation conversation) transform,
|
||||
) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final index = current.indexWhere((conversation) => conversation.id == id);
|
||||
if (index < 0) return;
|
||||
final updated = <Conversation>[...current];
|
||||
updated[index] = transform(updated[index]);
|
||||
state = AsyncData<List<Conversation>>(_sortByUpdatedAt(updated));
|
||||
}
|
||||
|
||||
List<Conversation> _demoConversations() => [
|
||||
Conversation(
|
||||
id: 'demo-conv-1',
|
||||
title: 'Welcome to Conduit (Demo)',
|
||||
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
messages: [
|
||||
ChatMessage(
|
||||
id: 'demo-msg-1',
|
||||
role: 'assistant',
|
||||
content:
|
||||
'**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.',
|
||||
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
model: 'Gemma 2 Mini (Demo)',
|
||||
isStreaming: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
Future<List<Conversation>> _loadRemoteConversations() async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'conversations');
|
||||
return const [];
|
||||
}
|
||||
|
||||
// Also fetch folder information and update conversations with folder IDs
|
||||
try {
|
||||
final foldersData = await api.getFolders();
|
||||
DebugLogger.log('fetch-start', scope: 'conversations');
|
||||
final conversations = await api.getConversations();
|
||||
DebugLogger.log(
|
||||
'folders-fetched',
|
||||
'fetch-ok',
|
||||
scope: 'conversations',
|
||||
data: {'count': foldersData.length},
|
||||
data: {'count': conversations.length},
|
||||
);
|
||||
|
||||
// Parse folder data into Folder objects
|
||||
final folders = foldersData
|
||||
.map((folderData) => Folder.fromJson(folderData))
|
||||
.toList();
|
||||
|
||||
// Create a map of conversation ID to folder ID
|
||||
final conversationToFolder = <String, String>{};
|
||||
for (final folder in folders) {
|
||||
try {
|
||||
final foldersData = await api.getFolders();
|
||||
DebugLogger.log(
|
||||
'folder',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'id': folder.id,
|
||||
'name': folder.name,
|
||||
'count': folder.conversationIds.length,
|
||||
},
|
||||
'folders-fetched',
|
||||
scope: 'conversations',
|
||||
data: {'count': foldersData.length},
|
||||
);
|
||||
for (final conversationId in folder.conversationIds) {
|
||||
conversationToFolder[conversationId] = folder.id;
|
||||
DebugLogger.log(
|
||||
'map',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': conversationId, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update conversations with folder IDs, preferring explicit folder_id from chat if present
|
||||
// Use a map to ensure uniqueness by ID throughout the merge process
|
||||
final conversationMap = <String, Conversation>{};
|
||||
final folders = foldersData
|
||||
.map((folderData) => Folder.fromJson(folderData))
|
||||
.toList();
|
||||
|
||||
for (final conversation in conversations) {
|
||||
// Prefer server-provided folderId on the chat itself
|
||||
final explicitFolderId = conversation.folderId;
|
||||
final mappedFolderId = conversationToFolder[conversation.id];
|
||||
final folderIdToUse = explicitFolderId ?? mappedFolderId;
|
||||
if (folderIdToUse != null) {
|
||||
conversationMap[conversation.id] = conversation.copyWith(
|
||||
folderId: folderIdToUse,
|
||||
);
|
||||
final conversationToFolder = <String, String>{};
|
||||
for (final folder in folders) {
|
||||
DebugLogger.log(
|
||||
'update-folder',
|
||||
'folder',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'conversationId': conversation.id,
|
||||
'folderId': folderIdToUse,
|
||||
'explicit': explicitFolderId != null,
|
||||
'id': folder.id,
|
||||
'name': folder.name,
|
||||
'count': folder.conversationIds.length,
|
||||
},
|
||||
);
|
||||
for (final conversationId in folder.conversationIds) {
|
||||
conversationToFolder[conversationId] = folder.id;
|
||||
DebugLogger.log(
|
||||
'map',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': conversationId, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final conversationMap = <String, Conversation>{};
|
||||
|
||||
for (final conversation in conversations) {
|
||||
final explicitFolderId = conversation.folderId;
|
||||
final mappedFolderId = conversationToFolder[conversation.id];
|
||||
final folderIdToUse = explicitFolderId ?? mappedFolderId;
|
||||
if (folderIdToUse != null) {
|
||||
conversationMap[conversation.id] = conversation.copyWith(
|
||||
folderId: folderIdToUse,
|
||||
);
|
||||
DebugLogger.log(
|
||||
'update-folder',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'conversationId': conversation.id,
|
||||
'folderId': folderIdToUse,
|
||||
'explicit': explicitFolderId != null,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
conversationMap[conversation.id] = conversation;
|
||||
}
|
||||
}
|
||||
|
||||
final existingIds = conversationMap.keys.toSet();
|
||||
final missingInBase = conversationToFolder.keys
|
||||
.where((id) => !existingIds.contains(id))
|
||||
.toList();
|
||||
if (missingInBase.isNotEmpty) {
|
||||
DebugLogger.warning(
|
||||
'missing-in-base',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'count': missingInBase.length,
|
||||
'preview': missingInBase.take(5).toList(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
conversationMap[conversation.id] = conversation;
|
||||
DebugLogger.log('folders-synced', scope: 'conversations/map');
|
||||
}
|
||||
}
|
||||
|
||||
// Merge conversations that are in folders but missing from the main list
|
||||
// Build a set of existing IDs from the fetched list
|
||||
final existingIds = conversationMap.keys.toSet();
|
||||
for (final folder in folders) {
|
||||
final missingIds = folder.conversationIds
|
||||
.where((id) => !existingIds.contains(id))
|
||||
.toList();
|
||||
|
||||
// Diagnostics: count how many folder-mapped IDs are missing from the main list
|
||||
final missingInBase = conversationToFolder.keys
|
||||
.where((id) => !existingIds.contains(id))
|
||||
.toList();
|
||||
if (missingInBase.isNotEmpty) {
|
||||
DebugLogger.warning(
|
||||
'missing-in-base',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'count': missingInBase.length,
|
||||
'preview': missingInBase.take(5).toList(),
|
||||
},
|
||||
final hasKnownConversations = conversationMap.values.any(
|
||||
(conversation) => conversation.folderId == folder.id,
|
||||
);
|
||||
|
||||
final shouldFetchFolder =
|
||||
missingIds.isNotEmpty ||
|
||||
(!hasKnownConversations && folder.conversationIds.isEmpty);
|
||||
|
||||
List<Conversation> folderConvs = const [];
|
||||
if (shouldFetchFolder) {
|
||||
try {
|
||||
folderConvs = await api.getConversationsInFolder(folder.id);
|
||||
DebugLogger.log(
|
||||
'folder-sync',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'folderId': folder.id,
|
||||
'fetched': folderConvs.length,
|
||||
'missingIds': missingIds.length,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error(
|
||||
'folder-fetch-failed',
|
||||
scope: 'conversations/map',
|
||||
error: e,
|
||||
data: {'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final fetchedMap = {for (final c in folderConvs) c.id: c};
|
||||
|
||||
for (final convId in missingIds) {
|
||||
final fetched = fetchedMap[convId];
|
||||
if (fetched != null) {
|
||||
final toAdd = fetched.folderId == null
|
||||
? fetched.copyWith(folderId: folder.id)
|
||||
: fetched;
|
||||
conversationMap[toAdd.id] = toAdd;
|
||||
existingIds.add(toAdd.id);
|
||||
DebugLogger.log(
|
||||
'add-missing',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': toAdd.id, 'folderId': folder.id},
|
||||
);
|
||||
} else {
|
||||
final placeholder = Conversation(
|
||||
id: convId,
|
||||
title: 'Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: const [],
|
||||
folderId: folder.id,
|
||||
);
|
||||
conversationMap[convId] = placeholder;
|
||||
existingIds.add(convId);
|
||||
DebugLogger.log(
|
||||
'add-placeholder',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': convId, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) {
|
||||
for (final conv in folderConvs) {
|
||||
final toAdd = conv.folderId == null
|
||||
? conv.copyWith(folderId: folder.id)
|
||||
: conv;
|
||||
conversationMap[toAdd.id] = toAdd;
|
||||
existingIds.add(toAdd.id);
|
||||
DebugLogger.log(
|
||||
'add-folder-fetch',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': toAdd.id, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final sortedConversations = _sortByUpdatedAt(
|
||||
conversationMap.values.toList(),
|
||||
);
|
||||
} else {
|
||||
DebugLogger.log('folders-synced', scope: 'conversations/map');
|
||||
}
|
||||
|
||||
// Attempt to fetch missing conversations per-folder to construct accurate entries
|
||||
// If per-folder fetch fails, fall back to creating minimal placeholder entries
|
||||
final apiSvc = ref.read(apiServiceProvider);
|
||||
for (final folder in folders) {
|
||||
// Collect IDs in this folder that are missing
|
||||
final missingIds = folder.conversationIds
|
||||
.where((id) => !existingIds.contains(id))
|
||||
.toList();
|
||||
|
||||
final hasKnownConversations = conversationMap.values.any(
|
||||
(conversation) => conversation.folderId == folder.id,
|
||||
DebugLogger.log(
|
||||
'sort',
|
||||
scope: 'conversations',
|
||||
data: {'source': 'folder-sync'},
|
||||
);
|
||||
|
||||
final shouldFetchFolder =
|
||||
apiSvc != null &&
|
||||
(missingIds.isNotEmpty ||
|
||||
(!hasKnownConversations && folder.conversationIds.isEmpty));
|
||||
|
||||
List<Conversation> folderConvs = const [];
|
||||
if (shouldFetchFolder) {
|
||||
try {
|
||||
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
|
||||
DebugLogger.log(
|
||||
'folder-sync',
|
||||
scope: 'conversations/map',
|
||||
data: {
|
||||
'folderId': folder.id,
|
||||
'fetched': folderConvs.length,
|
||||
'missingIds': missingIds.length,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error(
|
||||
'folder-fetch-failed',
|
||||
scope: 'conversations/map',
|
||||
error: e,
|
||||
data: {'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Index fetched folder conversations for quick lookup
|
||||
final fetchedMap = {for (final c in folderConvs) c.id: c};
|
||||
|
||||
for (final convId in missingIds) {
|
||||
final fetched = fetchedMap[convId];
|
||||
if (fetched != null) {
|
||||
final toAdd = fetched.folderId == null
|
||||
? fetched.copyWith(folderId: folder.id)
|
||||
: fetched;
|
||||
// Use map to prevent duplicates - this will overwrite if ID already exists
|
||||
conversationMap[toAdd.id] = toAdd;
|
||||
existingIds.add(toAdd.id);
|
||||
DebugLogger.log(
|
||||
'add-missing',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': toAdd.id, 'folderId': folder.id},
|
||||
);
|
||||
} else {
|
||||
// Create a minimal placeholder if not returned by folder API
|
||||
final placeholder = Conversation(
|
||||
id: convId,
|
||||
title: 'Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: const [],
|
||||
folderId: folder.id,
|
||||
);
|
||||
// Use map to prevent duplicates
|
||||
conversationMap[convId] = placeholder;
|
||||
existingIds.add(convId);
|
||||
DebugLogger.log(
|
||||
'add-placeholder',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': convId, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderConvs.isNotEmpty && folder.conversationIds.isEmpty) {
|
||||
for (final conv in folderConvs) {
|
||||
final toAdd = conv.folderId == null
|
||||
? conv.copyWith(folderId: folder.id)
|
||||
: conv;
|
||||
conversationMap[toAdd.id] = toAdd;
|
||||
existingIds.add(toAdd.id);
|
||||
DebugLogger.log(
|
||||
'add-folder-fetch',
|
||||
scope: 'conversations/map',
|
||||
data: {'conversationId': toAdd.id, 'folderId': folder.id},
|
||||
);
|
||||
}
|
||||
}
|
||||
_updateCacheTimestamp(DateTime.now());
|
||||
return sortedConversations;
|
||||
} catch (e) {
|
||||
DebugLogger.error(
|
||||
'folders-fetch-failed',
|
||||
scope: 'conversations',
|
||||
error: e,
|
||||
);
|
||||
final sorted = _sortByUpdatedAt(conversations.toList());
|
||||
DebugLogger.log(
|
||||
'sort',
|
||||
scope: 'conversations',
|
||||
data: {'source': 'fallback'},
|
||||
);
|
||||
_updateCacheTimestamp(DateTime.now());
|
||||
return sorted;
|
||||
}
|
||||
|
||||
// Convert map back to list - this ensures no duplicates by ID
|
||||
final sortedConversations = conversationMap.values.toList();
|
||||
|
||||
// Sort conversations by updatedAt in descending order (most recent first)
|
||||
sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
DebugLogger.log(
|
||||
'sort',
|
||||
scope: 'conversations',
|
||||
data: {'source': 'folder-sync'},
|
||||
);
|
||||
|
||||
// Update cache timestamp
|
||||
ref
|
||||
.read(_conversationsCacheTimestampProvider.notifier)
|
||||
.set(DateTime.now());
|
||||
|
||||
return sortedConversations;
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'folders-fetch-failed',
|
||||
'fetch-failed',
|
||||
scope: 'conversations',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
// Sort conversations even when folder fetch fails
|
||||
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
DebugLogger.log(
|
||||
'sort',
|
||||
scope: 'conversations',
|
||||
data: {'source': 'fallback'},
|
||||
);
|
||||
|
||||
// Update cache timestamp
|
||||
ref
|
||||
.read(_conversationsCacheTimestampProvider.notifier)
|
||||
.set(DateTime.now());
|
||||
|
||||
return conversations; // Return original conversations if folder fetch fails
|
||||
if (e.toString().contains('403')) {
|
||||
DebugLogger.warning('endpoint-403', scope: 'conversations');
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'fetch-failed',
|
||||
scope: 'conversations',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
// If conversations endpoint returns 403, this should now clear auth token
|
||||
// and redirect user to login since it's marked as a core endpoint
|
||||
if (e.toString().contains('403')) {
|
||||
DebugLogger.warning('endpoint-403', scope: 'conversations');
|
||||
}
|
||||
List<Conversation> _sortByUpdatedAt(List<Conversation> conversations) {
|
||||
final sorted = [...conversations];
|
||||
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return List<Conversation>.unmodifiable(sorted);
|
||||
}
|
||||
|
||||
// Return empty list instead of re-throwing to allow app to continue functioning
|
||||
return [];
|
||||
void _updateCacheTimestamp(DateTime? timestamp) {
|
||||
ref.read(_conversationsCacheTimestampProvider.notifier).set(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1789,52 +1827,166 @@ final webSearchAvailableProvider = Provider<bool>((ref) {
|
||||
|
||||
// Folders provider
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<Folder>> folders(Ref ref) async {
|
||||
// Protected: require authentication
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'folders');
|
||||
return [];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'folders');
|
||||
return [];
|
||||
class Folders extends _$Folders {
|
||||
@override
|
||||
Future<List<Folder>> build() async {
|
||||
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'folders');
|
||||
return const [];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'folders');
|
||||
return const [];
|
||||
}
|
||||
return _load(api);
|
||||
}
|
||||
|
||||
try {
|
||||
final foldersData = await api.getFolders();
|
||||
final folders = foldersData
|
||||
.map((folderData) => Folder.fromJson(folderData))
|
||||
.toList();
|
||||
DebugLogger.log(
|
||||
'fetch-ok',
|
||||
scope: 'folders',
|
||||
data: {'count': folders.length},
|
||||
);
|
||||
return folders;
|
||||
} catch (e) {
|
||||
DebugLogger.error('fetch-failed', scope: 'folders', error: e);
|
||||
return [];
|
||||
Future<void> refresh() async {
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
state = const AsyncData<List<Folder>>([]);
|
||||
return;
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
state = const AsyncData<List<Folder>>([]);
|
||||
return;
|
||||
}
|
||||
final result = await AsyncValue.guard(() => _load(api));
|
||||
if (!ref.mounted) return;
|
||||
state = result;
|
||||
}
|
||||
|
||||
void upsertFolder(Folder folder) {
|
||||
final current = state.asData?.value ?? const <Folder>[];
|
||||
final updated = <Folder>[...current];
|
||||
final index = updated.indexWhere((existing) => existing.id == folder.id);
|
||||
if (index >= 0) {
|
||||
updated[index] = folder;
|
||||
} else {
|
||||
updated.add(folder);
|
||||
}
|
||||
state = AsyncData<List<Folder>>(_sort(updated));
|
||||
}
|
||||
|
||||
void updateFolder(String id, Folder Function(Folder folder) transform) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final index = current.indexWhere((folder) => folder.id == id);
|
||||
if (index < 0) return;
|
||||
final updated = <Folder>[...current];
|
||||
updated[index] = transform(updated[index]);
|
||||
state = AsyncData<List<Folder>>(_sort(updated));
|
||||
}
|
||||
|
||||
void removeFolder(String id) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final updated = current
|
||||
.where((folder) => folder.id != id)
|
||||
.toList(growable: true);
|
||||
state = AsyncData<List<Folder>>(_sort(updated));
|
||||
}
|
||||
|
||||
Future<List<Folder>> _load(ApiService api) async {
|
||||
try {
|
||||
final foldersData = await api.getFolders();
|
||||
final folders = foldersData
|
||||
.map((folderData) => Folder.fromJson(folderData))
|
||||
.toList();
|
||||
DebugLogger.log(
|
||||
'fetch-ok',
|
||||
scope: 'folders',
|
||||
data: {'count': folders.length},
|
||||
);
|
||||
return _sort(folders);
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'fetch-failed',
|
||||
scope: 'folders',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<Folder> _sort(List<Folder> input) {
|
||||
final sorted = [...input];
|
||||
sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return List<Folder>.unmodifiable(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
// Files provider
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<FileInfo>> userFiles(Ref ref) async {
|
||||
// Protected: require authentication
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'files');
|
||||
return [];
|
||||
class UserFiles extends _$UserFiles {
|
||||
@override
|
||||
Future<List<FileInfo>> build() async {
|
||||
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'files');
|
||||
return const [];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return const [];
|
||||
return _load(api);
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final filesData = await api.getUserFiles();
|
||||
return filesData.map((fileData) => FileInfo.fromJson(fileData)).toList();
|
||||
} catch (e) {
|
||||
DebugLogger.error('files-failed', scope: 'files', error: e);
|
||||
return [];
|
||||
Future<void> refresh() async {
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
state = const AsyncData<List<FileInfo>>([]);
|
||||
return;
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
state = const AsyncData<List<FileInfo>>([]);
|
||||
return;
|
||||
}
|
||||
final result = await AsyncValue.guard(() => _load(api));
|
||||
if (!ref.mounted) return;
|
||||
state = result;
|
||||
}
|
||||
|
||||
void upsert(FileInfo file) {
|
||||
final current = state.asData?.value ?? const <FileInfo>[];
|
||||
final updated = <FileInfo>[...current];
|
||||
final index = updated.indexWhere((existing) => existing.id == file.id);
|
||||
if (index >= 0) {
|
||||
updated[index] = file;
|
||||
} else {
|
||||
updated.add(file);
|
||||
}
|
||||
state = AsyncData<List<FileInfo>>(_sort(updated));
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final updated = current
|
||||
.where((file) => file.id != id)
|
||||
.toList(growable: true);
|
||||
state = AsyncData<List<FileInfo>>(_sort(updated));
|
||||
}
|
||||
|
||||
Future<List<FileInfo>> _load(ApiService api) async {
|
||||
try {
|
||||
final files = await api.getUserFiles();
|
||||
return _sort(files);
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'files-failed',
|
||||
scope: 'files',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<FileInfo> _sort(List<FileInfo> input) {
|
||||
final sorted = [...input];
|
||||
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return List<FileInfo>.unmodifiable(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1864,21 +2016,75 @@ Future<String> fileContent(Ref ref, String fileId) async {
|
||||
|
||||
// Knowledge Base providers
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<KnowledgeBase>> knowledgeBases(Ref ref) async {
|
||||
// Protected: require authentication
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'knowledge');
|
||||
return [];
|
||||
class KnowledgeBases extends _$KnowledgeBases {
|
||||
@override
|
||||
Future<List<KnowledgeBase>> build() async {
|
||||
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'knowledge');
|
||||
return const [];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return const [];
|
||||
return _load(api);
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final kbData = await api.getKnowledgeBases();
|
||||
return kbData.map((data) => KnowledgeBase.fromJson(data)).toList();
|
||||
} catch (e) {
|
||||
DebugLogger.error('knowledge-bases-failed', scope: 'knowledge', error: e);
|
||||
return [];
|
||||
Future<void> refresh() async {
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
state = const AsyncData<List<KnowledgeBase>>([]);
|
||||
return;
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
state = const AsyncData<List<KnowledgeBase>>([]);
|
||||
return;
|
||||
}
|
||||
final result = await AsyncValue.guard(() => _load(api));
|
||||
if (!ref.mounted) return;
|
||||
state = result;
|
||||
}
|
||||
|
||||
void upsert(KnowledgeBase knowledgeBase) {
|
||||
final current = state.asData?.value ?? const <KnowledgeBase>[];
|
||||
final updated = <KnowledgeBase>[...current];
|
||||
final index = updated.indexWhere(
|
||||
(existing) => existing.id == knowledgeBase.id,
|
||||
);
|
||||
if (index >= 0) {
|
||||
updated[index] = knowledgeBase;
|
||||
} else {
|
||||
updated.add(knowledgeBase);
|
||||
}
|
||||
state = AsyncData<List<KnowledgeBase>>(_sort(updated));
|
||||
}
|
||||
|
||||
void remove(String id) {
|
||||
final current = state.asData?.value;
|
||||
if (current == null) return;
|
||||
final updated = current
|
||||
.where((knowledgeBase) => knowledgeBase.id != id)
|
||||
.toList(growable: true);
|
||||
state = AsyncData<List<KnowledgeBase>>(_sort(updated));
|
||||
}
|
||||
|
||||
Future<List<KnowledgeBase>> _load(ApiService api) async {
|
||||
try {
|
||||
final knowledgeBases = await api.getKnowledgeBases();
|
||||
return _sort(knowledgeBases);
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'knowledge-bases-failed',
|
||||
scope: 'knowledge',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<KnowledgeBase> _sort(List<KnowledgeBase> input) {
|
||||
final sorted = [...input];
|
||||
sorted.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return List<KnowledgeBase>.unmodifiable(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1893,8 +2099,7 @@ Future<List<KnowledgeBaseItem>> knowledgeBaseItems(Ref ref, String kbId) async {
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
final itemsData = await api.getKnowledgeBaseItems(kbId);
|
||||
return itemsData.map((data) => KnowledgeBaseItem.fromJson(data)).toList();
|
||||
return await api.getKnowledgeBaseItems(kbId);
|
||||
} catch (e) {
|
||||
DebugLogger.error('knowledge-items-failed', scope: 'knowledge', error: e);
|
||||
return [];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
696
lib/core/services/conversation_parsing.dart
Normal file
696
lib/core/services/conversation_parsing.dart
Normal file
@@ -0,0 +1,696 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Utilities for converting OpenWebUI conversation payloads into JSON maps
|
||||
/// that match the app's `Conversation` / `ChatMessage` schemas. All helpers
|
||||
/// here are isolate-safe (they only work with primitive JSON types) so they
|
||||
/// can be executed inside a background worker.
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
Map<String, dynamic> parseConversationSummary(Map<String, dynamic> chatData) {
|
||||
final id = (chatData['id'] ?? '').toString();
|
||||
final title = _stringOr(chatData['title'], 'Chat');
|
||||
|
||||
final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt'];
|
||||
final createdAtRaw = chatData['created_at'] ?? chatData['createdAt'];
|
||||
|
||||
final pinned = chatData['pinned'] as bool? ?? false;
|
||||
final archived = chatData['archived'] as bool? ?? false;
|
||||
final shareId = chatData['share_id']?.toString();
|
||||
final folderId = chatData['folder_id']?.toString();
|
||||
|
||||
String? systemPrompt;
|
||||
final chatObject = chatData['chat'];
|
||||
if (chatObject is Map<String, dynamic>) {
|
||||
final value = chatObject['system'];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
systemPrompt = value;
|
||||
}
|
||||
} else if (chatData['system'] is String) {
|
||||
final value = (chatData['system'] as String).trim();
|
||||
if (value.isNotEmpty) systemPrompt = value;
|
||||
}
|
||||
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'createdAt': _parseTimestamp(createdAtRaw).toIso8601String(),
|
||||
'updatedAt': _parseTimestamp(updatedAtRaw).toIso8601String(),
|
||||
'model': chatData['model']?.toString(),
|
||||
'systemPrompt': systemPrompt,
|
||||
'messages': const <Map<String, dynamic>>[],
|
||||
'metadata': _coerceJsonMap(chatData['metadata']),
|
||||
'pinned': pinned,
|
||||
'archived': archived,
|
||||
'shareId': shareId,
|
||||
'folderId': folderId,
|
||||
'tags': _coerceStringList(chatData['tags']),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> parseFullConversation(Map<String, dynamic> chatData) {
|
||||
final id = (chatData['id'] ?? '').toString();
|
||||
final title = _stringOr(chatData['title'], 'Chat');
|
||||
|
||||
final updatedAt = _parseTimestamp(
|
||||
chatData['updated_at'] ?? chatData['updatedAt'],
|
||||
);
|
||||
final createdAt = _parseTimestamp(
|
||||
chatData['created_at'] ?? chatData['createdAt'],
|
||||
);
|
||||
final pinned = chatData['pinned'] as bool? ?? false;
|
||||
final archived = chatData['archived'] as bool? ?? false;
|
||||
final shareId = chatData['share_id']?.toString();
|
||||
final folderId = chatData['folder_id']?.toString();
|
||||
|
||||
String? systemPrompt;
|
||||
final chatObject = chatData['chat'];
|
||||
if (chatObject is Map<String, dynamic>) {
|
||||
final value = chatObject['system'];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
systemPrompt = value;
|
||||
}
|
||||
} else if (chatData['system'] is String) {
|
||||
final value = (chatData['system'] as String).trim();
|
||||
if (value.isNotEmpty) systemPrompt = value;
|
||||
}
|
||||
|
||||
String? model;
|
||||
Map<String, dynamic>? historyMessagesMap;
|
||||
List<Map<String, dynamic>>? messagesList;
|
||||
|
||||
if (chatObject is Map<String, dynamic>) {
|
||||
final history = chatObject['history'];
|
||||
if (history is Map<String, dynamic>) {
|
||||
if (history['messages'] is Map<String, dynamic>) {
|
||||
historyMessagesMap = history['messages'] as Map<String, dynamic>;
|
||||
messagesList = _buildMessagesListFromHistory(history);
|
||||
}
|
||||
}
|
||||
|
||||
if ((messagesList == null || messagesList.isEmpty) &&
|
||||
chatObject['messages'] is List) {
|
||||
messagesList = (chatObject['messages'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
final models = chatObject['models'];
|
||||
if (models is List && models.isNotEmpty) {
|
||||
model = models.first?.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if ((messagesList == null || messagesList.isEmpty) &&
|
||||
chatData['messages'] is List) {
|
||||
messagesList = (chatData['messages'] as List)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
final messages = <Map<String, dynamic>>[];
|
||||
if (messagesList != null) {
|
||||
var index = 0;
|
||||
while (index < messagesList.length) {
|
||||
final msgData = Map<String, dynamic>.from(messagesList[index]);
|
||||
final historyMsg = historyMessagesMap != null
|
||||
? (historyMessagesMap[msgData['id']] as Map<String, dynamic>?)
|
||||
: null;
|
||||
|
||||
final toolCalls = _extractToolCalls(msgData, historyMsg);
|
||||
if ((msgData['role']?.toString() ?? '') == 'assistant' &&
|
||||
toolCalls != null) {
|
||||
final results = <Map<String, dynamic>>[];
|
||||
var j = index + 1;
|
||||
while (j < messagesList.length) {
|
||||
final nextRaw = messagesList[j];
|
||||
if ((nextRaw['role']?.toString() ?? '') != 'tool') break;
|
||||
results.add({
|
||||
'tool_call_id': nextRaw['tool_call_id']?.toString(),
|
||||
'content': nextRaw['content'],
|
||||
if (nextRaw.containsKey('files')) 'files': nextRaw['files'],
|
||||
});
|
||||
j++;
|
||||
}
|
||||
|
||||
final synthesized = _synthesizeToolDetailsFromToolCallsWithResults(
|
||||
toolCalls,
|
||||
results,
|
||||
);
|
||||
final merged = Map<String, dynamic>.from(msgData);
|
||||
if (synthesized.isNotEmpty) {
|
||||
merged['content'] = synthesized;
|
||||
}
|
||||
|
||||
messages.add(
|
||||
_parseOpenWebUIMessageToJson(merged, historyMsg: historyMsg),
|
||||
);
|
||||
index = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.add(
|
||||
_parseOpenWebUIMessageToJson(msgData, historyMsg: historyMsg),
|
||||
);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'model': model,
|
||||
'systemPrompt': systemPrompt,
|
||||
'messages': messages,
|
||||
'metadata': _coerceJsonMap(chatData['metadata']),
|
||||
'pinned': pinned,
|
||||
'archived': archived,
|
||||
'shareId': shareId,
|
||||
'folderId': folderId,
|
||||
'tags': _coerceStringList(chatData['tags']),
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>>? _extractToolCalls(
|
||||
Map<String, dynamic> msgData,
|
||||
Map<String, dynamic>? historyMsg,
|
||||
) {
|
||||
final toolCallsRaw =
|
||||
msgData['tool_calls'] ??
|
||||
historyMsg?['tool_calls'] ??
|
||||
historyMsg?['toolCalls'];
|
||||
if (toolCallsRaw is List) {
|
||||
return toolCallsRaw.whereType<Map>().map(_coerceJsonMap).toList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
||||
Map<String, dynamic> msgData, {
|
||||
Map<String, dynamic>? historyMsg,
|
||||
}) {
|
||||
dynamic content = msgData['content'];
|
||||
if ((content == null || (content is String && content.isEmpty)) &&
|
||||
historyMsg != null &&
|
||||
historyMsg['content'] != null) {
|
||||
content = historyMsg['content'];
|
||||
}
|
||||
|
||||
var contentString = '';
|
||||
if (content is List) {
|
||||
final buffer = StringBuffer();
|
||||
for (final entry in content) {
|
||||
if (entry is Map && entry['type'] == 'text') {
|
||||
final text = entry['text']?.toString();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
buffer.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
contentString = buffer.toString();
|
||||
if (contentString.trim().isEmpty) {
|
||||
final synthesized = _synthesizeToolDetailsFromContentArray(content);
|
||||
if (synthesized.isNotEmpty) {
|
||||
contentString = synthesized;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentString = content?.toString() ?? '';
|
||||
}
|
||||
|
||||
if (historyMsg != null) {
|
||||
final histContent = historyMsg['content'];
|
||||
if (histContent is String && histContent.length > contentString.length) {
|
||||
contentString = histContent;
|
||||
} else if (histContent is List) {
|
||||
final buf = StringBuffer();
|
||||
for (final entry in histContent) {
|
||||
if (entry is Map && entry['type'] == 'text') {
|
||||
final text = entry['text']?.toString();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
buf.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
final combined = buf.toString();
|
||||
if (combined.length > contentString.length) {
|
||||
contentString = combined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final toolCallsList = _extractToolCalls(msgData, historyMsg);
|
||||
if (contentString.trim().isEmpty && toolCallsList != null) {
|
||||
final synthesized = _synthesizeToolDetailsFromToolCalls(toolCallsList);
|
||||
if (synthesized.isNotEmpty) {
|
||||
contentString = synthesized;
|
||||
}
|
||||
}
|
||||
|
||||
final role = _resolveRole(msgData);
|
||||
|
||||
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
|
||||
List<String>? attachmentIds;
|
||||
List<Map<String, dynamic>>? files;
|
||||
if (effectiveFiles is List) {
|
||||
final attachments = <String>[];
|
||||
final allFiles = <Map<String, dynamic>>[];
|
||||
for (final entry in effectiveFiles) {
|
||||
if (entry is! Map) continue;
|
||||
if (entry['file_id'] != null) {
|
||||
attachments.add(entry['file_id'].toString());
|
||||
} else if (entry['type'] != null && entry['url'] != null) {
|
||||
final fileMap = <String, dynamic>{
|
||||
'type': entry['type'],
|
||||
'url': entry['url'],
|
||||
};
|
||||
if (entry['name'] != null) fileMap['name'] = entry['name'];
|
||||
if (entry['size'] != null) fileMap['size'] = entry['size'];
|
||||
allFiles.add(fileMap);
|
||||
|
||||
final url = entry['url'].toString();
|
||||
final match = RegExp(r'/api/v1/files/([^/]+)/content').firstMatch(url);
|
||||
if (match != null) {
|
||||
attachments.add(match.group(1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
attachmentIds = attachments.isNotEmpty ? attachments : null;
|
||||
files = allFiles.isNotEmpty ? allFiles : null;
|
||||
}
|
||||
|
||||
final statusHistoryRaw =
|
||||
historyMsg != null && historyMsg.containsKey('statusHistory')
|
||||
? historyMsg['statusHistory']
|
||||
: msgData['statusHistory'];
|
||||
final followUpsRaw = historyMsg != null && historyMsg.containsKey('followUps')
|
||||
? historyMsg['followUps']
|
||||
: msgData['followUps'] ?? msgData['follow_ups'];
|
||||
final codeExecRaw = historyMsg != null
|
||||
? historyMsg['code_executions'] ?? historyMsg['codeExecutions']
|
||||
: msgData['code_executions'] ?? msgData['codeExecutions'];
|
||||
final sourcesRaw = historyMsg != null && historyMsg.containsKey('sources')
|
||||
? historyMsg['sources']
|
||||
: msgData['sources'];
|
||||
|
||||
return <String, dynamic>{
|
||||
'id': (msgData['id'] ?? _uuid.v4()).toString(),
|
||||
'role': role,
|
||||
'content': contentString,
|
||||
'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(),
|
||||
'model': msgData['model']?.toString(),
|
||||
'isStreaming': msgData['isStreaming'] as bool? ?? false,
|
||||
if (attachmentIds != null) 'attachmentIds': attachmentIds,
|
||||
if (files != null) 'files': files,
|
||||
'metadata': _coerceJsonMap(msgData['metadata']),
|
||||
'statusHistory': _parseStatusHistoryField(statusHistoryRaw),
|
||||
'followUps': _coerceStringList(followUpsRaw),
|
||||
'codeExecutions': _parseCodeExecutionsField(codeExecRaw),
|
||||
'sources': _parseSourcesField(sourcesRaw),
|
||||
'usage': _coerceJsonMap(msgData['usage']),
|
||||
'versions': const <Map<String, dynamic>>[],
|
||||
};
|
||||
}
|
||||
|
||||
String _resolveRole(Map<String, dynamic> msgData) {
|
||||
if (msgData['role'] != null) {
|
||||
return msgData['role'].toString();
|
||||
}
|
||||
if (msgData['model'] != null) {
|
||||
return 'assistant';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _buildMessagesListFromHistory(
|
||||
Map<String, dynamic> history,
|
||||
) {
|
||||
final messagesMap = history['messages'];
|
||||
final currentId = history['currentId']?.toString();
|
||||
if (messagesMap is! Map<String, dynamic> || currentId == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> buildChain(String? id) {
|
||||
if (id == null) return const [];
|
||||
final raw = messagesMap[id];
|
||||
if (raw is! Map) return const [];
|
||||
final msg = _coerceJsonMap(raw);
|
||||
msg['id'] = id;
|
||||
final parentId = msg['parentId']?.toString();
|
||||
if (parentId != null && parentId.isNotEmpty) {
|
||||
return [...buildChain(parentId), msg];
|
||||
}
|
||||
return [msg];
|
||||
}
|
||||
|
||||
return buildChain(currentId);
|
||||
}
|
||||
|
||||
DateTime _parseTimestamp(dynamic timestamp) {
|
||||
if (timestamp == null) return DateTime.now();
|
||||
if (timestamp is int) {
|
||||
final ts = timestamp > 1000000000000 ? timestamp : timestamp * 1000;
|
||||
return DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
}
|
||||
if (timestamp is String) {
|
||||
final parsedInt = int.tryParse(timestamp);
|
||||
if (parsedInt != null) {
|
||||
final ts = parsedInt > 1000000000000 ? parsedInt : parsedInt * 1000;
|
||||
return DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
}
|
||||
return DateTime.tryParse(timestamp) ?? DateTime.now();
|
||||
}
|
||||
if (timestamp is double) {
|
||||
final ts = timestamp > 1000000000000
|
||||
? timestamp.round()
|
||||
: (timestamp * 1000).round();
|
||||
return DateTime.fromMillisecondsSinceEpoch(ts);
|
||||
}
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseStatusHistoryField(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((entry) => _coerceJsonMap(entry))
|
||||
.toList(growable: false);
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
List<String> _coerceStringList(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.whereType<dynamic>()
|
||||
.map((value) => value?.toString().trim() ?? '')
|
||||
.where((value) => value.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
if (raw is String && raw.trim().isNotEmpty) {
|
||||
return [raw.trim()];
|
||||
}
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseCodeExecutionsField(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((entry) => _coerceJsonMap(entry))
|
||||
.toList(growable: false);
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _parseSourcesField(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw.whereType<Map>().map(_coerceJsonMap).toList(growable: false);
|
||||
}
|
||||
if (raw is Map) {
|
||||
return [_coerceJsonMap(raw)];
|
||||
}
|
||||
if (raw is String) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
return decoded.whereType<Map>().map(_coerceJsonMap).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _coerceJsonMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value.map((key, v) => MapEntry(key.toString(), _coerceJsonValue(v)));
|
||||
}
|
||||
if (value is Map) {
|
||||
final result = <String, dynamic>{};
|
||||
value.forEach((key, v) {
|
||||
result[key.toString()] = _coerceJsonValue(v);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
dynamic _coerceJsonValue(dynamic value) {
|
||||
if (value is Map) {
|
||||
return _coerceJsonMap(value);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_coerceJsonValue).toList();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String _stringOr(dynamic value, String fallback) {
|
||||
if (value is String && value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String _synthesizeToolDetailsFromToolCalls(List<Map> calls) {
|
||||
final buffer = StringBuffer();
|
||||
for (final rawCall in calls) {
|
||||
final call = Map<String, dynamic>.from(rawCall);
|
||||
final function = call['function'];
|
||||
final name =
|
||||
(function is Map ? function['name'] : call['name'])?.toString() ??
|
||||
'tool';
|
||||
final id =
|
||||
(call['id']?.toString() ??
|
||||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||||
final done = call['done']?.toString() ?? 'true';
|
||||
final argsRaw = function is Map ? function['arguments'] : call['arguments'];
|
||||
final resRaw =
|
||||
call['result'] ??
|
||||
call['output'] ??
|
||||
(function is Map ? function['result'] : null);
|
||||
final attrs = StringBuffer()
|
||||
..write('type="tool_calls"')
|
||||
..write(' done="${_escapeHtmlAttr(done)}"')
|
||||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||||
..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"');
|
||||
final resultStr = _jsonStringify(resRaw);
|
||||
if (resultStr.isNotEmpty) {
|
||||
attrs.write(' result="${_escapeHtmlAttr(resultStr)}"');
|
||||
}
|
||||
buffer.writeln(
|
||||
'<details ${attrs.toString()}><summary>Tool Executed</summary></details>',
|
||||
);
|
||||
}
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
String _synthesizeToolDetailsFromToolCallsWithResults(
|
||||
List<Map> calls,
|
||||
List<Map> results,
|
||||
) {
|
||||
final buffer = StringBuffer();
|
||||
final resultsMap = <String, Map<String, dynamic>>{};
|
||||
for (final rawResult in results) {
|
||||
final result = Map<String, dynamic>.from(rawResult);
|
||||
final id = result['tool_call_id']?.toString();
|
||||
if (id != null) {
|
||||
resultsMap[id] = result;
|
||||
}
|
||||
}
|
||||
|
||||
for (final rawCall in calls) {
|
||||
final call = Map<String, dynamic>.from(rawCall);
|
||||
final function = call['function'];
|
||||
final name =
|
||||
(function is Map ? function['name'] : call['name'])?.toString() ??
|
||||
'tool';
|
||||
final id =
|
||||
(call['id']?.toString() ??
|
||||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||||
final argsRaw = function is Map ? function['arguments'] : call['arguments'];
|
||||
final resultEntry = resultsMap[id];
|
||||
final resRaw = resultEntry != null ? resultEntry['content'] : null;
|
||||
final filesRaw = resultEntry != null ? resultEntry['files'] : null;
|
||||
|
||||
final attrs = StringBuffer()
|
||||
..write('type="tool_calls"')
|
||||
..write(
|
||||
' done="${_escapeHtmlAttr(resultEntry != null ? 'true' : 'false')}"',
|
||||
)
|
||||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||||
..write(' arguments="${_escapeHtmlAttr(_jsonStringify(argsRaw))}"');
|
||||
final resultStr = _jsonStringify(resRaw);
|
||||
if (resultStr.isNotEmpty) {
|
||||
attrs.write(' result="${_escapeHtmlAttr(resultStr)}"');
|
||||
}
|
||||
final filesStr = _jsonStringify(filesRaw);
|
||||
if (filesStr.isNotEmpty) {
|
||||
attrs.write(' files="${_escapeHtmlAttr(filesStr)}"');
|
||||
}
|
||||
buffer.writeln(
|
||||
'<details ${attrs.toString()}><summary>${resultEntry != null ? 'Tool Executed' : 'Executing...'}</summary></details>',
|
||||
);
|
||||
}
|
||||
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
String _synthesizeToolDetailsFromContentArray(List<dynamic> content) {
|
||||
final buffer = StringBuffer();
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
final type = item['type']?.toString();
|
||||
if (type == null) continue;
|
||||
if (type == 'tool_calls') {
|
||||
final calls = <Map<String, dynamic>>[];
|
||||
if (item['content'] is List) {
|
||||
for (final entry in item['content'] as List) {
|
||||
if (entry is Map) {
|
||||
calls.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final results = <Map<String, dynamic>>[];
|
||||
if (item['results'] is List) {
|
||||
for (final entry in item['results'] as List) {
|
||||
if (entry is Map) {
|
||||
results.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
final synthesized = _synthesizeToolDetailsFromToolCallsWithResults(
|
||||
calls,
|
||||
results,
|
||||
);
|
||||
if (synthesized.isNotEmpty) {
|
||||
buffer.writeln(synthesized);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type == 'tool_call' || type == 'function_call') {
|
||||
final name = (item['name'] ?? item['tool'] ?? 'tool').toString();
|
||||
final id =
|
||||
(item['id']?.toString() ??
|
||||
'call_${DateTime.now().millisecondsSinceEpoch}');
|
||||
final argsStr = _jsonStringify(item['arguments'] ?? item['args']);
|
||||
final resStr = item['result'] ?? item['output'] ?? item['response'];
|
||||
final attrs = StringBuffer()
|
||||
..write('type="tool_calls"')
|
||||
..write(' done="${_escapeHtmlAttr(resStr != null ? 'true' : 'false')}"')
|
||||
..write(' id="${_escapeHtmlAttr(id)}"')
|
||||
..write(' name="${_escapeHtmlAttr(name)}"')
|
||||
..write(' arguments="${_escapeHtmlAttr(argsStr)}"');
|
||||
final result = _jsonStringify(resStr);
|
||||
if (result.isNotEmpty) {
|
||||
attrs.write(' result="${_escapeHtmlAttr(result)}"');
|
||||
}
|
||||
buffer.writeln(
|
||||
'<details ${attrs.toString()}><summary>${resStr != null ? 'Tool Executed' : 'Executing...'}</summary></details>',
|
||||
);
|
||||
}
|
||||
}
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
|
||||
String _jsonStringify(dynamic value) {
|
||||
if (value == null) return '';
|
||||
try {
|
||||
return jsonEncode(value);
|
||||
} catch (_) {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _escapeHtmlAttr(String value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> parseConversationSummariesWorker(
|
||||
Map<String, dynamic> payload,
|
||||
) {
|
||||
final pinnedRaw = payload['pinned'];
|
||||
final archivedRaw = payload['archived'];
|
||||
final regularRaw = payload['regular'];
|
||||
|
||||
final pinned = <Map<String, dynamic>>[];
|
||||
if (pinnedRaw is List) {
|
||||
for (final entry in pinnedRaw) {
|
||||
if (entry is Map) {
|
||||
pinned.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final archived = <Map<String, dynamic>>[];
|
||||
if (archivedRaw is List) {
|
||||
for (final entry in archivedRaw) {
|
||||
if (entry is Map) {
|
||||
archived.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final regular = <Map<String, dynamic>>[];
|
||||
if (regularRaw is List) {
|
||||
for (final entry in regularRaw) {
|
||||
if (entry is Map) {
|
||||
regular.add(Map<String, dynamic>.from(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final summaries = <Map<String, dynamic>>[];
|
||||
final pinnedIds = <String>{};
|
||||
final archivedIds = <String>{};
|
||||
|
||||
for (final entry in pinned) {
|
||||
final summary = parseConversationSummary(entry);
|
||||
summary['pinned'] = true;
|
||||
summaries.add(summary);
|
||||
pinnedIds.add(summary['id'] as String);
|
||||
}
|
||||
|
||||
for (final entry in archived) {
|
||||
final summary = parseConversationSummary(entry);
|
||||
summary['archived'] = true;
|
||||
summaries.add(summary);
|
||||
archivedIds.add(summary['id'] as String);
|
||||
}
|
||||
|
||||
for (final entry in regular) {
|
||||
final summary = parseConversationSummary(entry);
|
||||
final id = summary['id'] as String;
|
||||
if (pinnedIds.contains(id) || archivedIds.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
summaries.add(summary);
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
Map<String, dynamic> parseFullConversationWorker(Map<String, dynamic> payload) {
|
||||
final raw = payload['conversation'];
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return parseFullConversation(raw);
|
||||
}
|
||||
if (raw is Map) {
|
||||
return parseFullConversation(Map<String, dynamic>.from(raw));
|
||||
}
|
||||
return parseFullConversation(<String, dynamic>{});
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart';
|
||||
import '../persistence/persistence_keys.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
import 'worker_manager.dart';
|
||||
|
||||
/// Optimized storage service backed by Hive for non-sensitive data and
|
||||
/// FlutterSecureStorage for credentials.
|
||||
@@ -16,19 +17,22 @@ class OptimizedStorageService {
|
||||
OptimizedStorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required HiveBoxes boxes,
|
||||
required WorkerManager workerManager,
|
||||
}) : _preferencesBox = boxes.preferences,
|
||||
_cachesBox = boxes.caches,
|
||||
_attachmentQueueBox = boxes.attachmentQueue,
|
||||
_metadataBox = boxes.metadata,
|
||||
_secureCredentialStorage = SecureCredentialStorage(
|
||||
instance: secureStorage,
|
||||
);
|
||||
),
|
||||
_workerManager = workerManager;
|
||||
|
||||
final Box<dynamic> _preferencesBox;
|
||||
final Box<dynamic> _cachesBox;
|
||||
final Box<dynamic> _attachmentQueueBox;
|
||||
final Box<dynamic> _metadataBox;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
final WorkerManager _workerManager;
|
||||
|
||||
static const String _authTokenKey = 'auth_token_v3';
|
||||
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
|
||||
@@ -298,19 +302,13 @@ class OptimizedStorageService {
|
||||
if (stored == null) {
|
||||
return const [];
|
||||
}
|
||||
if (stored is String) {
|
||||
final decoded = jsonDecode(stored) as List<dynamic>;
|
||||
return decoded.map((item) => Conversation.fromJson(item)).toList();
|
||||
}
|
||||
if (stored is List) {
|
||||
return stored
|
||||
.map(
|
||||
(item) =>
|
||||
Conversation.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
final parsed = await _workerManager
|
||||
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
|
||||
_decodeStoredConversationsWorker,
|
||||
{'stored': stored},
|
||||
debugLabel: 'decode_local_conversations',
|
||||
);
|
||||
return parsed.map(Conversation.fromJson).toList(growable: false);
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to retrieve local conversations',
|
||||
@@ -324,9 +322,13 @@ class OptimizedStorageService {
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
final serialized = conversations
|
||||
final jsonReady = conversations
|
||||
.map((conversation) => conversation.toJson())
|
||||
.toList();
|
||||
final serialized = await _workerManager
|
||||
.schedule<Map<String, dynamic>, String>(_encodeConversationsWorker, {
|
||||
'conversations': jsonReady,
|
||||
}, debugLabel: 'encode_local_conversations');
|
||||
await _cachesBox.put(_localConversationsKey, serialized);
|
||||
DebugLogger.log(
|
||||
'Saved ${conversations.length} local conversations',
|
||||
@@ -455,3 +457,40 @@ class OptimizedStorageService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _decodeStoredConversationsWorker(
|
||||
Map<String, dynamic> payload,
|
||||
) {
|
||||
final stored = payload['stored'];
|
||||
if (stored is String) {
|
||||
final decoded = jsonDecode(stored);
|
||||
if (decoded is List) {
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
}
|
||||
return <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
if (stored is List) {
|
||||
return stored
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
String _encodeConversationsWorker(Map<String, dynamic> payload) {
|
||||
final raw = payload['conversations'];
|
||||
if (raw is List) {
|
||||
return jsonEncode(raw);
|
||||
}
|
||||
if (raw is String) {
|
||||
// Already encoded.
|
||||
return raw;
|
||||
}
|
||||
return jsonEncode([]);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,7 @@ class PromptsService {
|
||||
|
||||
Future<List<Prompt>> getPrompts() async {
|
||||
try {
|
||||
final List<Map<String, dynamic>> response = await _apiService
|
||||
.getPrompts();
|
||||
return response
|
||||
.map((item) => Prompt.fromJson(item))
|
||||
.where((prompt) => prompt.command.isNotEmpty)
|
||||
.toList();
|
||||
return await _apiService.getPrompts();
|
||||
} on DioException catch (error) {
|
||||
throw ApiErrorHandler().transformError(error);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../utils/debug_logger.dart';
|
||||
import '../utils/openwebui_source_parser.dart';
|
||||
import 'streaming_response_controller.dart';
|
||||
import 'api_service.dart';
|
||||
import 'worker_manager.dart';
|
||||
|
||||
// Keep local verbosity toggle for socket logs
|
||||
const bool kSocketVerboseLogging = false;
|
||||
@@ -43,6 +44,74 @@ final _imageFilePattern = RegExp(
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
List<Map<String, dynamic>> _collectImageReferencesWorker(String content) {
|
||||
final collected = <Map<String, dynamic>>[];
|
||||
if (content.isEmpty) {
|
||||
return collected;
|
||||
}
|
||||
|
||||
if (content.contains('<details') && content.contains('</details>')) {
|
||||
final parsed = ToolCallsParser.parse(content);
|
||||
if (parsed != null) {
|
||||
for (final entry in parsed.toolCalls) {
|
||||
if (entry.files != null && entry.files!.isNotEmpty) {
|
||||
collected.addAll(_extractFilesFromResult(entry.files));
|
||||
}
|
||||
if (entry.result != null) {
|
||||
collected.addAll(_extractFilesFromResult(entry.result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (collected.isNotEmpty) {
|
||||
return collected;
|
||||
}
|
||||
|
||||
final base64Matches = _base64ImagePattern.allMatches(content);
|
||||
for (final match in base64Matches) {
|
||||
final url = match.group(0);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final urlMatches = _urlImagePattern.allMatches(content);
|
||||
for (final match in urlMatches) {
|
||||
final url = match.group(0);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final jsonMatches = _jsonImagePattern.allMatches(content);
|
||||
for (final match in jsonMatches) {
|
||||
final url = _jsonUrlExtractPattern
|
||||
.firstMatch(match.group(0) ?? '')
|
||||
?.group(1);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final partialMatches = _partialResultsPattern.allMatches(content);
|
||||
for (final match in partialMatches) {
|
||||
final attrValue = match.group(2);
|
||||
if (attrValue == null) continue;
|
||||
try {
|
||||
final decoded = json.decode(attrValue);
|
||||
collected.addAll(_extractFilesFromResult(decoded));
|
||||
} catch (_) {
|
||||
if (attrValue.startsWith('data:image/') ||
|
||||
_imageFilePattern.hasMatch(attrValue)) {
|
||||
collected.add({'type': 'image', 'url': attrValue});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
class ActiveSocketStream {
|
||||
ActiveSocketStream({
|
||||
required this.controller,
|
||||
@@ -70,6 +139,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
required String? activeConversationId,
|
||||
required ApiService api,
|
||||
required SocketService? socketService,
|
||||
required WorkerManager workerManager,
|
||||
RegisterConversationDeltaListener? registerDeltaListener,
|
||||
// Message update callbacks
|
||||
required void Function(String) appendToLastMessage,
|
||||
@@ -228,88 +298,44 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
final content = msgs.last.content;
|
||||
if (content.isEmpty) return;
|
||||
|
||||
final collected = <Map<String, dynamic>>[];
|
||||
|
||||
// Quick check: only parse tool calls if complete details blocks exist
|
||||
if (content.contains('<details') && content.contains('</details>')) {
|
||||
final parsed = ToolCallsParser.parse(content);
|
||||
if (parsed != null) {
|
||||
for (final entry in parsed.toolCalls) {
|
||||
if (entry.files != null && entry.files!.isNotEmpty) {
|
||||
collected.addAll(_extractFilesFromResult(entry.files));
|
||||
}
|
||||
if (entry.result != null) {
|
||||
collected.addAll(_extractFilesFromResult(entry.result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (collected.isEmpty) {
|
||||
// Use pre-compiled patterns for better performance
|
||||
final base64Matches = _base64ImagePattern.allMatches(content);
|
||||
for (final match in base64Matches) {
|
||||
final url = match.group(0);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final urlMatches = _urlImagePattern.allMatches(content);
|
||||
for (final match in urlMatches) {
|
||||
final url = match.group(0);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final jsonMatches = _jsonImagePattern.allMatches(content);
|
||||
for (final match in jsonMatches) {
|
||||
final url = _jsonUrlExtractPattern
|
||||
.firstMatch(match.group(0) ?? '')
|
||||
?.group(1);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
collected.add({'type': 'image', 'url': url});
|
||||
}
|
||||
}
|
||||
|
||||
final partialMatches = _partialResultsPattern.allMatches(content);
|
||||
for (final match in partialMatches) {
|
||||
final attrValue = match.group(2);
|
||||
if (attrValue != null) {
|
||||
try {
|
||||
final decoded = json.decode(attrValue);
|
||||
collected.addAll(_extractFilesFromResult(decoded));
|
||||
} catch (_) {
|
||||
if (attrValue.startsWith('data:image/') ||
|
||||
_imageFilePattern.hasMatch(attrValue)) {
|
||||
collected.add({'type': 'image', 'url': attrValue});
|
||||
final targetMessageId = msgs.last.id;
|
||||
unawaited(
|
||||
workerManager
|
||||
.schedule<String, List<Map<String, dynamic>>>(
|
||||
_collectImageReferencesWorker,
|
||||
content,
|
||||
debugLabel: 'stream_collect_images',
|
||||
)
|
||||
.then((collected) {
|
||||
if (collected.isEmpty) return;
|
||||
final currentMessages = getMessages();
|
||||
if (currentMessages.isEmpty) return;
|
||||
final last = currentMessages.last;
|
||||
if (last.id != targetMessageId || last.role != 'assistant') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (collected.isEmpty) return;
|
||||
final existing = last.files ?? <Map<String, dynamic>>[];
|
||||
final seen = <String>{
|
||||
for (final f in existing)
|
||||
if (f['url'] is String) (f['url'] as String) else '',
|
||||
}..removeWhere((e) => e.isEmpty);
|
||||
|
||||
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
|
||||
final seen = <String>{
|
||||
for (final f in existing)
|
||||
if (f['url'] is String) (f['url'] as String) else '',
|
||||
}..removeWhere((e) => e.isEmpty);
|
||||
final merged = <Map<String, dynamic>>[...existing];
|
||||
for (final f in collected) {
|
||||
final url = f['url'] as String?;
|
||||
if (url != null && url.isNotEmpty && !seen.contains(url)) {
|
||||
merged.add({'type': 'image', 'url': url});
|
||||
seen.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
final merged = <Map<String, dynamic>>[...existing];
|
||||
for (final f in collected) {
|
||||
final url = f['url'] as String?;
|
||||
if (url != null && url.isNotEmpty && !seen.contains(url)) {
|
||||
merged.add({'type': 'image', 'url': url});
|
||||
seen.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.length != existing.length) {
|
||||
updateLastMessageWith((m) => m.copyWith(files: merged));
|
||||
}
|
||||
if (merged.length != existing.length) {
|
||||
updateLastMessageWith((m) => m.copyWith(files: merged));
|
||||
}
|
||||
})
|
||||
.catchError((_) {}),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
202
lib/core/services/worker_manager.dart
Normal file
202
lib/core/services/worker_manager.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
part 'worker_manager.g.dart';
|
||||
|
||||
/// Signature of a task that can be executed by [WorkerManager].
|
||||
typedef WorkerTask<Q, R> = ComputeCallback<Q, R>;
|
||||
|
||||
/// Coordinates CPU intensive work off the UI isolate with lightweight pooling.
|
||||
///
|
||||
/// The manager throttles concurrent isolate usage to avoid overwhelming the
|
||||
/// platform while still enabling parallel work. On web the callback executes
|
||||
/// synchronously because secondary isolates are not supported.
|
||||
class WorkerManager {
|
||||
WorkerManager({int maxConcurrentTasks = _defaultMaxConcurrentTasks})
|
||||
: _maxConcurrentTasks = math.max(1, maxConcurrentTasks) {
|
||||
DebugLogger.log(
|
||||
'initialized',
|
||||
scope: 'worker',
|
||||
data: {'max': _maxConcurrentTasks},
|
||||
);
|
||||
}
|
||||
|
||||
static const int _defaultMaxConcurrentTasks = 2;
|
||||
|
||||
final int _maxConcurrentTasks;
|
||||
final Queue<_EnqueuedJob> _pendingJobs = Queue<_EnqueuedJob>();
|
||||
bool _disposed = false;
|
||||
int _activeJobs = 0;
|
||||
int _jobCounter = 0;
|
||||
|
||||
/// Schedule [callback] with [message] to run on a worker isolate.
|
||||
///
|
||||
/// The [callback] must be a top-level or static function, mirroring the
|
||||
/// constraints of `compute`. Errors from the task are propagated to the
|
||||
/// returned [Future].
|
||||
Future<R> schedule<Q, R>(
|
||||
WorkerTask<Q, R> callback,
|
||||
Q message, {
|
||||
String? debugLabel,
|
||||
}) {
|
||||
if (_disposed) {
|
||||
return Future.error(StateError('WorkerManager has been disposed'));
|
||||
}
|
||||
|
||||
final jobId = ++_jobCounter;
|
||||
final completer = Completer<R>();
|
||||
final job = _EnqueuedJob(
|
||||
id: jobId,
|
||||
debugLabel: debugLabel,
|
||||
run: () {
|
||||
if (kIsWeb) {
|
||||
return Future<R>.sync(() => callback(message));
|
||||
}
|
||||
return compute(callback, message);
|
||||
},
|
||||
onComplete: (value) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(value as R);
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_pendingJobs.add(job);
|
||||
|
||||
DebugLogger.log(
|
||||
'queued',
|
||||
scope: 'worker',
|
||||
data: {
|
||||
'id': jobId,
|
||||
if (debugLabel != null) 'label': debugLabel,
|
||||
'pending': _pendingJobs.length,
|
||||
'active': _activeJobs,
|
||||
},
|
||||
);
|
||||
|
||||
_processQueue();
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Dispose the manager and reject all pending work.
|
||||
void dispose() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
|
||||
while (_pendingJobs.isNotEmpty) {
|
||||
final job = _pendingJobs.removeFirst();
|
||||
job.cancel(
|
||||
StateError('WorkerManager disposed before job ${job.id} started'),
|
||||
);
|
||||
}
|
||||
|
||||
DebugLogger.log('disposed', scope: 'worker', data: {'active': _activeJobs});
|
||||
}
|
||||
|
||||
void _processQueue() {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (_activeJobs < _maxConcurrentTasks && _pendingJobs.isNotEmpty) {
|
||||
final job = _pendingJobs.removeFirst();
|
||||
_startJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
void _startJob(_EnqueuedJob job) {
|
||||
_activeJobs++;
|
||||
|
||||
DebugLogger.log(
|
||||
'started',
|
||||
scope: 'worker',
|
||||
data: {
|
||||
'id': job.id,
|
||||
if (job.debugLabel != null) 'label': job.debugLabel,
|
||||
'active': _activeJobs,
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(_runJob(job));
|
||||
}
|
||||
|
||||
Future<void> _runJob(_EnqueuedJob job) async {
|
||||
try {
|
||||
final result = await job.run();
|
||||
job.onComplete(result);
|
||||
|
||||
DebugLogger.log(
|
||||
'completed',
|
||||
scope: 'worker',
|
||||
data: {
|
||||
'id': job.id,
|
||||
if (job.debugLabel != null) 'label': job.debugLabel,
|
||||
'pending': _pendingJobs.length,
|
||||
},
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
job.onError(error, stackTrace);
|
||||
|
||||
DebugLogger.error(
|
||||
'failed',
|
||||
scope: 'worker',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
data: {
|
||||
'id': job.id,
|
||||
if (job.debugLabel != null) 'label': job.debugLabel,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
_activeJobs = math.max(0, _activeJobs - 1);
|
||||
_processQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a single [WorkerManager] alive across the app.
|
||||
@Riverpod(keepAlive: true)
|
||||
class WorkerManagerNotifier extends _$WorkerManagerNotifier {
|
||||
@override
|
||||
WorkerManager build() {
|
||||
final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks;
|
||||
final manager = WorkerManager(maxConcurrentTasks: concurrency);
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
|
||||
class _EnqueuedJob {
|
||||
_EnqueuedJob({
|
||||
required this.id,
|
||||
required this.run,
|
||||
required this.onComplete,
|
||||
required this.onError,
|
||||
this.debugLabel,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final FutureOr<dynamic> Function() run;
|
||||
final void Function(dynamic value) onComplete;
|
||||
final void Function(Object error, StackTrace stackTrace) onError;
|
||||
final String? debugLabel;
|
||||
final DateTime queuedAt = DateTime.now();
|
||||
|
||||
void cancel(Object error) {
|
||||
onError(error, StackTrace.current);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../../../core/models/server_config.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
import '../../../core/services/input_validation_service.dart';
|
||||
import '../../../core/services/navigation_service.dart';
|
||||
import '../../../core/widgets/error_boundary.dart';
|
||||
@@ -81,7 +82,11 @@ class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
allowSelfSignedCertificates: _allowSelfSignedCertificates,
|
||||
);
|
||||
|
||||
final api = ApiService(serverConfig: tempConfig);
|
||||
final workerManager = ref.read(workerManagerProvider);
|
||||
final api = ApiService(
|
||||
serverConfig: tempConfig,
|
||||
workerManager: workerManager,
|
||||
);
|
||||
final isHealthy = await api.checkHealth();
|
||||
if (!isHealthy) {
|
||||
throw Exception('This does not appear to be an Open-WebUI server.');
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/conversation_delta_listener.dart';
|
||||
import '../../../core/services/streaming_helper.dart';
|
||||
import '../../../core/services/streaming_response_controller.dart';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/utils/markdown_stream_formatter.dart';
|
||||
import '../../../core/utils/tool_calls_parser.dart';
|
||||
@@ -718,9 +719,40 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
|
||||
_messageStream = null;
|
||||
_stopRemoteTaskMonitor();
|
||||
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
if (activeConversation != null) {
|
||||
final updatedActive = activeConversation.copyWith(
|
||||
messages: List<ChatMessage>.unmodifiable(state),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).set(updatedActive);
|
||||
|
||||
final conversationsAsync = ref.read(conversationsProvider);
|
||||
Conversation? summary;
|
||||
conversationsAsync.maybeWhen(
|
||||
data: (conversations) {
|
||||
for (final conversation in conversations) {
|
||||
if (conversation.id == updatedActive.id) {
|
||||
summary = conversation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
final updatedSummary =
|
||||
(summary ?? updatedActive.copyWith(messages: const [])).copyWith(
|
||||
updatedAt: updatedActive.updatedAt,
|
||||
);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(updatedSummary.copyWith(messages: const []));
|
||||
}
|
||||
|
||||
// Trigger a refresh of the conversations list so UI like the Chats Drawer
|
||||
// can pick up updated titles and ordering once streaming completes.
|
||||
// Best-effort: ignore if ref lifecycle/context prevents invalidation.
|
||||
// can reconcile with the server once streaming completes. Best-effort:
|
||||
// ignore if ref lifecycle/context prevents invalidation.
|
||||
try {
|
||||
refreshConversationsCache(ref);
|
||||
} catch (_) {}
|
||||
@@ -1449,6 +1481,7 @@ Future<void> regenerateMessage(
|
||||
activeConversationId: activeConversation.id,
|
||||
api: api!,
|
||||
socketService: socketService,
|
||||
workerManager: ref.read(workerManagerProvider),
|
||||
registerDeltaListener: registerDeltaListener,
|
||||
appendToLastMessage: (c) =>
|
||||
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),
|
||||
@@ -1478,6 +1511,15 @@ Future<void> regenerateMessage(
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.set(active.copyWith(title: newTitle));
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
active.id,
|
||||
(conversation) => conversation.copyWith(
|
||||
title: newTitle,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
refreshConversationsCache(ref);
|
||||
},
|
||||
@@ -1490,6 +1532,9 @@ Future<void> regenerateMessage(
|
||||
try {
|
||||
final refreshed = await api.getConversation(active.id);
|
||||
ref.read(activeConversationProvider.notifier).set(refreshed);
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(refreshed.copyWith(messages: const []));
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
@@ -1623,6 +1668,12 @@ Future<void> _sendMessageInternal(
|
||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(
|
||||
updatedConversation.copyWith(updatedAt: DateTime.now()),
|
||||
);
|
||||
|
||||
// Invalidate conversations provider to refresh the list
|
||||
// Adding a small delay to prevent rapid invalidations that could cause duplicates
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
@@ -1997,6 +2048,7 @@ Future<void> _sendMessageInternal(
|
||||
activeConversationId: activeConversation?.id,
|
||||
api: api!,
|
||||
socketService: socketService,
|
||||
workerManager: ref.read(workerManagerProvider),
|
||||
registerDeltaListener: registerDeltaListener,
|
||||
appendToLastMessage: (c) =>
|
||||
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),
|
||||
@@ -2026,6 +2078,15 @@ Future<void> _sendMessageInternal(
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.set(active.copyWith(title: newTitle));
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
active.id,
|
||||
(conversation) => conversation.copyWith(
|
||||
title: newTitle,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
refreshConversationsCache(ref);
|
||||
},
|
||||
@@ -2038,6 +2099,9 @@ Future<void> _sendMessageInternal(
|
||||
try {
|
||||
final refreshed = await api.getConversation(active.id);
|
||||
ref.read(activeConversationProvider.notifier).set(refreshed);
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(refreshed.copyWith(messages: const []));
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
@@ -2201,6 +2265,14 @@ Future<void> pinConversation(
|
||||
|
||||
await api.pinConversation(conversationId, pinned);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
conversationId,
|
||||
(conversation) =>
|
||||
conversation.copyWith(pinned: pinned, updatedAt: DateTime.now()),
|
||||
);
|
||||
|
||||
// Refresh conversations list to reflect the change
|
||||
refreshConversationsCache(ref);
|
||||
|
||||
@@ -2240,6 +2312,16 @@ Future<void> archiveConversation(
|
||||
|
||||
await api.archiveConversation(conversationId, archived);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
conversationId,
|
||||
(conversation) => conversation.copyWith(
|
||||
archived: archived,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Refresh conversations list to reflect the change
|
||||
refreshConversationsCache(ref);
|
||||
} catch (e) {
|
||||
@@ -2266,6 +2348,16 @@ Future<String?> shareConversation(WidgetRef ref, String conversationId) async {
|
||||
|
||||
final shareId = await api.shareConversation(conversationId);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
conversationId,
|
||||
(conversation) => conversation.copyWith(
|
||||
shareId: shareId,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Refresh conversations list to reflect the change
|
||||
refreshConversationsCache(ref);
|
||||
|
||||
@@ -2290,6 +2382,11 @@ Future<void> cloneConversation(WidgetRef ref, String conversationId) async {
|
||||
// The ChatMessagesNotifier will automatically load messages when activeConversation changes
|
||||
|
||||
// Refresh conversations list to show the new conversation
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(
|
||||
clonedConversation.copyWith(updatedAt: DateTime.now()),
|
||||
);
|
||||
refreshConversationsCache(ref);
|
||||
} catch (e) {
|
||||
DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers');
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../providers/chat_providers.dart' show sendMessageWithContainer;
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import 'sources/openwebui_sources.dart';
|
||||
import '../providers/assistant_response_builder_provider.dart';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
|
||||
// Pre-compiled regex patterns for image processing (performance optimization)
|
||||
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
|
||||
@@ -104,7 +105,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
);
|
||||
|
||||
// Parse reasoning and tool-calls sections
|
||||
_reparseSections();
|
||||
unawaited(_reparseSections());
|
||||
_updateTypingIndicatorGate();
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
|
||||
// Re-parse sections when message content changes
|
||||
if (oldWidget.message.content != widget.message.content) {
|
||||
_reparseSections();
|
||||
unawaited(_reparseSections());
|
||||
_updateTypingIndicatorGate();
|
||||
}
|
||||
|
||||
@@ -141,7 +142,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
}
|
||||
|
||||
void _reparseSections() {
|
||||
Future<void> _reparseSections() async {
|
||||
final raw0 = _activeVersionIndex >= 0
|
||||
? (widget.message.versions[_activeVersionIndex].content as String?) ??
|
||||
''
|
||||
@@ -162,11 +163,13 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
|
||||
final out = <MessageSegment>[];
|
||||
final textBuf = StringBuffer();
|
||||
final textSegments = <String>[];
|
||||
if (rSegs == null || rSegs.isEmpty) {
|
||||
final tSegs = ToolCallsParser.segments(raw);
|
||||
if (tSegs == null || tSegs.isEmpty) {
|
||||
out.add(MessageSegment.text(raw));
|
||||
textBuf.write(raw);
|
||||
textSegments.add(raw);
|
||||
} else {
|
||||
for (final s in tSegs) {
|
||||
if (s.isToolCall && s.entry != null) {
|
||||
@@ -174,6 +177,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
} else if ((s.text ?? '').isNotEmpty) {
|
||||
out.add(MessageSegment.text(s.text!));
|
||||
textBuf.write(s.text);
|
||||
textSegments.add(s.text!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +191,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
if (tSegs == null || tSegs.isEmpty) {
|
||||
out.add(MessageSegment.text(t));
|
||||
textBuf.write(t);
|
||||
textSegments.add(t);
|
||||
} else {
|
||||
for (final s in tSegs) {
|
||||
if (s.isToolCall && s.entry != null) {
|
||||
@@ -194,6 +199,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
} else if ((s.text ?? '').isNotEmpty) {
|
||||
out.add(MessageSegment.text(s.text!));
|
||||
textBuf.write(s.text);
|
||||
textSegments.add(s.text!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,8 +208,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
|
||||
final segments = out.isEmpty ? [MessageSegment.text(raw)] : out;
|
||||
final speechText = _buildTtsPlainText(segments, raw);
|
||||
String speechText;
|
||||
try {
|
||||
final worker = ref.read(workerManagerProvider);
|
||||
speechText = await worker.schedule<Map<String, dynamic>, String>(
|
||||
_buildTtsPlainTextWorker,
|
||||
{'segments': textSegments, 'fallback': raw},
|
||||
debugLabel: 'tts_plain_text',
|
||||
);
|
||||
} catch (_) {
|
||||
speechText = _buildTtsPlainTextFallback(textSegments, raw);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_segments = segments;
|
||||
_ttsPlainText = speechText;
|
||||
@@ -248,18 +265,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
}
|
||||
|
||||
String _buildTtsPlainText(List<MessageSegment> segments, String fallback) {
|
||||
String _buildTtsPlainTextFallback(List<String> segments, String fallback) {
|
||||
if (segments.isEmpty) {
|
||||
return MarkdownToText.convert(fallback);
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final segment in segments) {
|
||||
if (!segment.isText) {
|
||||
continue;
|
||||
}
|
||||
final text = segment.text ?? '';
|
||||
final sanitized = MarkdownToText.convert(text);
|
||||
final sanitized = MarkdownToText.convert(segment);
|
||||
if (sanitized.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
@@ -1157,7 +1170,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
} else if (_activeVersionIndex > 0) {
|
||||
_activeVersionIndex -= 1;
|
||||
}
|
||||
_reparseSections();
|
||||
unawaited(_reparseSections());
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1177,7 +1190,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
} else {
|
||||
_activeVersionIndex = -1; // move to live
|
||||
}
|
||||
_reparseSections();
|
||||
unawaited(_reparseSections());
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -1329,6 +1342,34 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
}
|
||||
}
|
||||
|
||||
String _buildTtsPlainTextWorker(Map<String, dynamic> payload) {
|
||||
final rawSegments = payload['segments'];
|
||||
final fallback = payload['fallback'] as String? ?? '';
|
||||
final segments = rawSegments is List ? rawSegments.cast<dynamic>() : const [];
|
||||
|
||||
if (segments.isEmpty) {
|
||||
return MarkdownToText.convert(fallback);
|
||||
}
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (final segment in segments) {
|
||||
if (segment is! String || segment.isEmpty) continue;
|
||||
final sanitized = MarkdownToText.convert(segment);
|
||||
if (sanitized.isEmpty) continue;
|
||||
if (buffer.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
buffer.writeln();
|
||||
}
|
||||
buffer.write(sanitized);
|
||||
}
|
||||
|
||||
final result = buffer.toString().trim();
|
||||
if (result.isEmpty) {
|
||||
return MarkdownToText.convert(fallback);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class StatusHistoryTimeline extends StatefulWidget {
|
||||
const StatusHistoryTimeline({
|
||||
super.key,
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
|
||||
class EnhancedAttachment extends ConsumerStatefulWidget {
|
||||
final String attachmentId;
|
||||
@@ -102,12 +103,14 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final filePath = '${dir.path}/$filename';
|
||||
|
||||
final worker = ref.read(workerManagerProvider);
|
||||
try {
|
||||
if (content.length > 128 &&
|
||||
RegExp(
|
||||
r'^[A-Za-z0-9+/=\r\n]+$',
|
||||
).hasMatch(content.replaceAll('\n', ''))) {
|
||||
final bytes = base64Decode(content.replaceAll('\n', ''));
|
||||
if (_looksLikeBase64(content)) {
|
||||
final bytes = await worker.schedule<String, Uint8List>(
|
||||
_decodeAttachmentBase64,
|
||||
content,
|
||||
debugLabel: 'attachment_decode_bytes',
|
||||
);
|
||||
await File(filePath).writeAsBytes(bytes, flush: true);
|
||||
} else {
|
||||
await File(filePath).writeAsString(content, flush: true);
|
||||
@@ -291,3 +294,14 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
|
||||
bool _looksLikeBase64(String content) {
|
||||
if (content.length <= 128) return false;
|
||||
final sanitized = content.replaceAll('\n', '');
|
||||
return RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(sanitized);
|
||||
}
|
||||
|
||||
Uint8List _decodeAttachmentBase64(String raw) {
|
||||
final sanitized = raw.replaceAll('\n', '');
|
||||
return base64Decode(sanitized);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -15,6 +15,7 @@ import '../../auth/providers/unified_auth_providers.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../core/network/self_signed_image_cache_manager.dart';
|
||||
import '../../../core/network/image_header_utils.dart';
|
||||
import '../../../core/services/worker_manager.dart';
|
||||
|
||||
// Simple global cache to prevent reloading
|
||||
final _globalImageCache = <String, String>{};
|
||||
@@ -23,13 +24,6 @@ final _globalErrorStates = <String, String>{};
|
||||
final _globalImageBytesCache = <String, Uint8List>{};
|
||||
final _base64WhitespacePattern = RegExp(r'\s');
|
||||
|
||||
Future<Uint8List> _decodeImageDataAsync(String data) async {
|
||||
if (kIsWeb) {
|
||||
return _decodeImageData(data);
|
||||
}
|
||||
return compute(_decodeImageData, data);
|
||||
}
|
||||
|
||||
Uint8List _decodeImageData(String data) {
|
||||
var payload = data;
|
||||
if (payload.startsWith('data:')) {
|
||||
@@ -233,7 +227,12 @@ class _EnhancedImageAttachmentState
|
||||
if (_isDecoding) return;
|
||||
_isDecoding = true;
|
||||
try {
|
||||
final bytes = await _decodeImageDataAsync(data);
|
||||
final worker = ref.read(workerManagerProvider);
|
||||
final bytes = await worker.schedule(
|
||||
_decodeImageData,
|
||||
data,
|
||||
debugLabel: 'decode_image',
|
||||
);
|
||||
_globalImageBytesCache[widget.attachmentId] = bytes;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -418,7 +417,7 @@ class _EnhancedImageAttachmentState
|
||||
// Get authentication headers if available
|
||||
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||
|
||||
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||
final imageWidget = CachedNetworkImage(
|
||||
key: ValueKey('image_${widget.attachmentId}'),
|
||||
imageUrl: _cachedImageData!,
|
||||
@@ -549,7 +548,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
||||
// Get authentication headers if available
|
||||
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||
|
||||
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||
imageWidget = CachedNetworkImage(
|
||||
imageUrl: imageData,
|
||||
fit: BoxFit.contain,
|
||||
|
||||
@@ -770,8 +770,10 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.createFolder(name: name);
|
||||
final created = await api.createFolder(name: name);
|
||||
final folder = Folder.fromJson(Map<String, dynamic>.from(created));
|
||||
HapticFeedback.lightImpact();
|
||||
ref.read(foldersProvider.notifier).upsertFolder(folder);
|
||||
refreshConversationsCache(ref, includeFolders: true);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted) return;
|
||||
@@ -813,6 +815,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.moveConversationToFolder(details.data.id, folderId);
|
||||
HapticFeedback.selectionClick();
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
details.data.id,
|
||||
(conversation) => conversation.copyWith(
|
||||
folderId: folderId,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
refreshConversationsCache(ref, includeFolders: true);
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
@@ -1085,6 +1096,13 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.updateFolder(folderId, name: newName);
|
||||
HapticFeedback.selectionClick();
|
||||
ref
|
||||
.read(foldersProvider.notifier)
|
||||
.updateFolder(
|
||||
folderId,
|
||||
(folder) =>
|
||||
folder.copyWith(name: newName, updatedAt: DateTime.now()),
|
||||
);
|
||||
refreshConversationsCache(ref, includeFolders: true);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted) return;
|
||||
@@ -1120,6 +1138,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.deleteFolder(folderId);
|
||||
HapticFeedback.mediumImpact();
|
||||
ref.read(foldersProvider.notifier).removeFolder(folderId);
|
||||
refreshConversationsCache(ref, includeFolders: true);
|
||||
} catch (e, stackTrace) {
|
||||
if (!mounted) return;
|
||||
@@ -1153,6 +1172,15 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.moveConversationToFolder(details.data.id, null);
|
||||
HapticFeedback.selectionClick();
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
details.data.id,
|
||||
(conversation) => conversation.copyWith(
|
||||
folderId: null,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
refreshConversationsCache(ref, includeFolders: true);
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
|
||||
@@ -221,6 +221,13 @@ Future<void> _renameConversation(
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.updateConversation(conversationId, title: newName);
|
||||
HapticFeedback.selectionClick();
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.updateConversation(
|
||||
conversationId,
|
||||
(conversation) =>
|
||||
conversation.copyWith(title: newName, updatedAt: DateTime.now()),
|
||||
);
|
||||
refreshConversationsCache(ref);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (active?.id == conversationId) {
|
||||
@@ -257,6 +264,7 @@ Future<void> _confirmAndDeleteConversation(
|
||||
if (api == null) throw Exception('No API service');
|
||||
await api.deleteConversation(conversationId);
|
||||
HapticFeedback.mediumImpact();
|
||||
ref.read(conversationsProvider.notifier).removeConversation(conversationId);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (active?.id == conversationId) {
|
||||
ref.read(activeConversationProvider.notifier).clear();
|
||||
|
||||
@@ -2,13 +2,13 @@ import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:conduit/core/network/image_header_utils.dart';
|
||||
import 'package:conduit/core/network/self_signed_image_cache_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/brand_service.dart';
|
||||
import '../theme/theme_extensions.dart';
|
||||
import 'package:conduit/core/network/self_signed_image_cache_manager.dart';
|
||||
import 'package:conduit/core/network/image_header_utils.dart';
|
||||
|
||||
typedef AvatarWidgetBuilder =
|
||||
Widget Function(BuildContext context, double size);
|
||||
@@ -59,7 +59,7 @@ class AvatarImage extends ConsumerWidget {
|
||||
// Build auth/custom headers when loading from network
|
||||
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||
|
||||
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: _radius,
|
||||
|
||||
Reference in New Issue
Block a user