refactor: more flows
This commit is contained in:
@@ -9,6 +9,8 @@ import '../../features/auth/providers/unified_auth_providers.dart';
|
|||||||
import '../../features/chat/providers/chat_providers.dart';
|
import '../../features/chat/providers/chat_providers.dart';
|
||||||
import '../../features/chat/services/file_attachment_service.dart';
|
import '../../features/chat/services/file_attachment_service.dart';
|
||||||
import '../../core/providers/app_providers.dart';
|
import '../../core/providers/app_providers.dart';
|
||||||
|
import '../../shared/services/tasks/task_queue.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
import 'navigation_service.dart';
|
import 'navigation_service.dart';
|
||||||
// Server chat creation/title generation occur on first send via chat providers
|
// Server chat creation/title generation occur on first send via chat providers
|
||||||
|
|
||||||
@@ -152,13 +154,17 @@ Future<void> _processPayload(Ref ref, SharedPayload payload) async {
|
|||||||
if (files.isNotEmpty) {
|
if (files.isNotEmpty) {
|
||||||
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
ref.read(attachedFilesProvider.notifier).addFiles(files);
|
||||||
|
|
||||||
|
// Enqueue uploads via task queue to unify progress + retry
|
||||||
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
final uploadStream = svc.uploadFile(file);
|
try {
|
||||||
uploadStream.listen((state) {
|
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||||
ref
|
conversationId: activeConv?.id,
|
||||||
.read(attachedFilesProvider.notifier)
|
filePath: file.path,
|
||||||
.updateFileState(file.path, state);
|
fileName: path.basename(file.path),
|
||||||
}, onError: (_) {});
|
fileSize: await file.length(),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2026,51 +2026,13 @@ Future<void> _triggerTitleGeneration(
|
|||||||
List<Map<String, dynamic>> messages,
|
List<Map<String, dynamic>> messages,
|
||||||
String model,
|
String model,
|
||||||
) async {
|
) async {
|
||||||
|
// Enqueue background title generation task
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
await ref
|
||||||
if (api == null) return;
|
.read(taskQueueProvider.notifier)
|
||||||
|
.enqueueGenerateTitle(conversationId: conversationId);
|
||||||
// Call the title generation endpoint
|
} catch (_) {
|
||||||
final generatedTitle = await api.generateTitle(
|
// Best effort background check remains
|
||||||
conversationId: conversationId,
|
|
||||||
messages: messages,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (generatedTitle != null &&
|
|
||||||
generatedTitle.isNotEmpty &&
|
|
||||||
generatedTitle != 'New Chat') {
|
|
||||||
// Update the active conversation with the new title
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
|
||||||
if (activeConversation?.id == conversationId) {
|
|
||||||
final updated = activeConversation!.copyWith(
|
|
||||||
title: generatedTitle,
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
ref.read(activeConversationProvider.notifier).state = updated;
|
|
||||||
|
|
||||||
// Save the updated title to the server
|
|
||||||
try {
|
|
||||||
final currentMessages = ref.read(chatMessagesProvider);
|
|
||||||
await api.updateConversationWithMessages(
|
|
||||||
conversationId,
|
|
||||||
currentMessages,
|
|
||||||
title: generatedTitle,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Handle title save errors silently
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the conversations list
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fall back to background checking
|
|
||||||
_checkForTitleInBackground(ref, conversationId);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fall back to background checking
|
|
||||||
_checkForTitleInBackground(ref, conversationId);
|
_checkForTitleInBackground(ref, conversationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2124,59 +2086,13 @@ Future<void> _checkForTitleInBackground(
|
|||||||
|
|
||||||
// Save current conversation to OpenWebUI server
|
// Save current conversation to OpenWebUI server
|
||||||
Future<void> _saveConversationToServer(dynamic ref) async {
|
Future<void> _saveConversationToServer(dynamic ref) async {
|
||||||
|
// Enqueue save task; local fallback remains if queue fails
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
final messages = ref.read(chatMessagesProvider);
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
final activeConversation = ref.read(activeConversationProvider);
|
||||||
final selectedModel = ref.read(selectedModelProvider);
|
await ref
|
||||||
|
.read(taskQueueProvider.notifier)
|
||||||
if (api == null || messages.isEmpty || activeConversation == null) {
|
.enqueueSaveConversation(conversationId: activeConversation?.id);
|
||||||
return;
|
} catch (_) {
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the last assistant message is truly empty (no text and no files)
|
|
||||||
final lastMessage = messages.last;
|
|
||||||
if (lastMessage.role == 'assistant' &&
|
|
||||||
lastMessage.content.trim().isEmpty &&
|
|
||||||
(lastMessage.files == null || lastMessage.files!.isEmpty) &&
|
|
||||||
(lastMessage.attachmentIds == null ||
|
|
||||||
lastMessage.attachmentIds!.isEmpty)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the existing conversation with all messages (including assistant response)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.updateConversationWithMessages(
|
|
||||||
activeConversation.id,
|
|
||||||
messages,
|
|
||||||
model: selectedModel?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update local state
|
|
||||||
final updatedConversation = activeConversation.copyWith(
|
|
||||||
messages: messages,
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to local storage if server update fails
|
|
||||||
await _saveConversationLocally(ref);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh conversations list to show the updated conversation
|
|
||||||
// Adding a small delay to prevent rapid invalidations that could cause duplicates
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
|
||||||
try {
|
|
||||||
if (ref.mounted == true) {
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to local storage
|
|
||||||
await _saveConversationLocally(ref);
|
await _saveConversationLocally(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,30 @@ abstract class OutboundTask with _$OutboundTask {
|
|||||||
String? error,
|
String? error,
|
||||||
}) = GenerateImageTask;
|
}) = GenerateImageTask;
|
||||||
|
|
||||||
|
const factory OutboundTask.saveConversation({
|
||||||
|
required String id,
|
||||||
|
String? conversationId,
|
||||||
|
@Default(TaskStatus.queued) TaskStatus status,
|
||||||
|
@Default(0) int attempt,
|
||||||
|
String? idempotencyKey,
|
||||||
|
DateTime? enqueuedAt,
|
||||||
|
DateTime? startedAt,
|
||||||
|
DateTime? completedAt,
|
||||||
|
String? error,
|
||||||
|
}) = SaveConversationTask;
|
||||||
|
|
||||||
|
const factory OutboundTask.generateTitle({
|
||||||
|
required String id,
|
||||||
|
required String conversationId,
|
||||||
|
@Default(TaskStatus.queued) TaskStatus status,
|
||||||
|
@Default(0) int attempt,
|
||||||
|
String? idempotencyKey,
|
||||||
|
DateTime? enqueuedAt,
|
||||||
|
DateTime? startedAt,
|
||||||
|
DateTime? completedAt,
|
||||||
|
String? error,
|
||||||
|
}) = GenerateTitleTask;
|
||||||
|
|
||||||
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
factory OutboundTask.fromJson(Map<String, dynamic> json) =>
|
||||||
_$OutboundTaskFromJson(json);
|
_$OutboundTaskFromJson(json);
|
||||||
|
|
||||||
|
|||||||
@@ -226,4 +226,63 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
|
|||||||
await _save();
|
await _save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> enqueueUploadMedia({
|
||||||
|
required String? conversationId,
|
||||||
|
required String filePath,
|
||||||
|
required String fileName,
|
||||||
|
int? fileSize,
|
||||||
|
String? mimeType,
|
||||||
|
String? checksum,
|
||||||
|
}) async {
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final task = OutboundTask.uploadMedia(
|
||||||
|
id: id,
|
||||||
|
conversationId: conversationId,
|
||||||
|
filePath: filePath,
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize,
|
||||||
|
mimeType: mimeType,
|
||||||
|
checksum: checksum,
|
||||||
|
enqueuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
state = [...state, task];
|
||||||
|
await _save();
|
||||||
|
_process();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> enqueueSaveConversation({
|
||||||
|
required String? conversationId,
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final task = OutboundTask.saveConversation(
|
||||||
|
id: id,
|
||||||
|
conversationId: conversationId,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
enqueuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
state = [...state, task];
|
||||||
|
await _save();
|
||||||
|
_process();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> enqueueGenerateTitle({
|
||||||
|
required String conversationId,
|
||||||
|
String? idempotencyKey,
|
||||||
|
}) async {
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final task = OutboundTask.generateTitle(
|
||||||
|
id: id,
|
||||||
|
conversationId: conversationId,
|
||||||
|
idempotencyKey: idempotencyKey,
|
||||||
|
enqueuedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
state = [...state, task];
|
||||||
|
await _save();
|
||||||
|
_process();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class TaskWorker {
|
|||||||
uploadMedia: _performUploadMedia,
|
uploadMedia: _performUploadMedia,
|
||||||
executeToolCall: _performExecuteToolCall,
|
executeToolCall: _performExecuteToolCall,
|
||||||
generateImage: _performGenerateImage,
|
generateImage: _performGenerateImage,
|
||||||
|
saveConversation: _performSaveConversation,
|
||||||
|
generateTitle: _performGenerateTitle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,4 +280,78 @@ class TaskWorker {
|
|||||||
_ref.read(chat.chatMessagesProvider.notifier).finishStreaming();
|
_ref.read(chat.chatMessagesProvider.notifier).finishStreaming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _performSaveConversation(SaveConversationTask task) async {
|
||||||
|
final api = _ref.read(apiServiceProvider);
|
||||||
|
final messages = _ref.read(chat.chatMessagesProvider);
|
||||||
|
final activeConv = _ref.read(activeConversationProvider);
|
||||||
|
final selectedModel = _ref.read(selectedModelProvider);
|
||||||
|
if (api == null || messages.isEmpty || activeConv == null) return;
|
||||||
|
|
||||||
|
// Skip if last assistant is empty placeholder
|
||||||
|
final last = messages.last;
|
||||||
|
if (last.role == 'assistant' &&
|
||||||
|
last.content.trim().isEmpty &&
|
||||||
|
(last.files?.isEmpty ?? true) &&
|
||||||
|
(last.attachmentIds?.isEmpty ?? true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateConversationWithMessages(
|
||||||
|
activeConv.id,
|
||||||
|
messages,
|
||||||
|
model: selectedModel?.id,
|
||||||
|
);
|
||||||
|
final updated = activeConv.copyWith(
|
||||||
|
messages: messages,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_ref.read(activeConversationProvider.notifier).state = updated;
|
||||||
|
_ref.invalidate(conversationsProvider);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _performGenerateTitle(GenerateTitleTask task) async {
|
||||||
|
final api = _ref.read(apiServiceProvider);
|
||||||
|
final activeConv = _ref.read(activeConversationProvider);
|
||||||
|
final selectedModel = _ref.read(selectedModelProvider);
|
||||||
|
if (api == null || selectedModel == null) return;
|
||||||
|
try {
|
||||||
|
final messages = _ref.read(chat.chatMessagesProvider);
|
||||||
|
final formatted = <Map<String, dynamic>>[];
|
||||||
|
for (final msg in messages) {
|
||||||
|
formatted.add({
|
||||||
|
'id': msg.id,
|
||||||
|
'role': msg.role,
|
||||||
|
'content': msg.content,
|
||||||
|
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final title = await api.generateTitle(
|
||||||
|
conversationId: task.conversationId,
|
||||||
|
messages: formatted,
|
||||||
|
model: selectedModel.id,
|
||||||
|
);
|
||||||
|
if (title != null && title.isNotEmpty && title != 'New Chat') {
|
||||||
|
if (activeConv != null && activeConv.id == task.conversationId) {
|
||||||
|
final updated = activeConv.copyWith(
|
||||||
|
title: title.length > 100 ? '${title.substring(0, 100)}...' : title,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
_ref.read(activeConversationProvider.notifier).state = updated;
|
||||||
|
try {
|
||||||
|
final cur = _ref.read(chat.chatMessagesProvider);
|
||||||
|
await api.updateConversationWithMessages(
|
||||||
|
updated.id,
|
||||||
|
cur,
|
||||||
|
title: updated.title,
|
||||||
|
model: selectedModel.id,
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
_ref.invalidate(conversationsProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user