Merge pull request #122 from cogwheel0/implement-worker-manager

implement-worker-manager
This commit is contained in:
cogwheel
2025-11-01 22:31:48 +05:30
committed by GitHub
17 changed files with 2048 additions and 1453 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View 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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
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>{});
}

View File

@@ -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([]);
}

View File

@@ -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);
}

View File

@@ -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 (_) {}
}

View 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);
}
}

View File

@@ -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.');

View File

@@ -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');

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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();

View File

@@ -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,