refactor: titles
This commit is contained in:
@@ -1819,107 +1819,6 @@ class ApiService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate title for conversation using dedicated endpoint
|
|
||||||
Future<String?> generateTitle({
|
|
||||||
required String conversationId,
|
|
||||||
required List<Map<String, dynamic>> messages,
|
|
||||||
required String model,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
debugPrint('DEBUG: Generating title for conversation: $conversationId');
|
|
||||||
|
|
||||||
final response = await _dio.post(
|
|
||||||
'/api/v1/tasks/title/completions',
|
|
||||||
data: {'chat_id': conversationId, 'messages': messages, 'model': model},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode == 200 && response.data != null) {
|
|
||||||
DebugLogger.log('Raw title response received successfully');
|
|
||||||
|
|
||||||
// Parse the complex response structure
|
|
||||||
String? extractedTitle;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final responseData = response.data as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// Check if there's a direct title field
|
|
||||||
if (responseData.containsKey('title')) {
|
|
||||||
extractedTitle = responseData['title']?.toString();
|
|
||||||
}
|
|
||||||
// Check if it's in choices format (OpenAI-style response)
|
|
||||||
else if (responseData.containsKey('choices') &&
|
|
||||||
responseData['choices'] is List) {
|
|
||||||
final choices = responseData['choices'] as List;
|
|
||||||
if (choices.isNotEmpty) {
|
|
||||||
final firstChoice = choices[0] as Map<String, dynamic>;
|
|
||||||
if (firstChoice.containsKey('message')) {
|
|
||||||
final message = firstChoice['message'] as Map<String, dynamic>;
|
|
||||||
final content = message['content']?.toString() ?? '';
|
|
||||||
|
|
||||||
// Extract title from JSON-formatted content
|
|
||||||
if (content.contains('```json') && content.contains('```')) {
|
|
||||||
// Extract JSON from markdown code block
|
|
||||||
final jsonStart = content.indexOf('```json') + 7;
|
|
||||||
final jsonEnd = content.lastIndexOf('```');
|
|
||||||
if (jsonEnd > jsonStart) {
|
|
||||||
final jsonString = content
|
|
||||||
.substring(jsonStart, jsonEnd)
|
|
||||||
.trim();
|
|
||||||
try {
|
|
||||||
final jsonData =
|
|
||||||
jsonDecode(jsonString) as Map<String, dynamic>;
|
|
||||||
extractedTitle = jsonData['title']?.toString();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint(
|
|
||||||
'DEBUG: Failed to parse JSON from title response: $e',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Try to parse the content directly as JSON
|
|
||||||
try {
|
|
||||||
final jsonData =
|
|
||||||
jsonDecode(content) as Map<String, dynamic>;
|
|
||||||
extractedTitle = jsonData['title']?.toString();
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, use content as-is
|
|
||||||
extractedTitle = content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the extracted title
|
|
||||||
if (extractedTitle != null && extractedTitle.isNotEmpty) {
|
|
||||||
// Remove any remaining markdown formatting
|
|
||||||
extractedTitle = extractedTitle
|
|
||||||
.replaceAll(RegExp(r'```.*?```', dotAll: true), '')
|
|
||||||
.trim();
|
|
||||||
extractedTitle = extractedTitle
|
|
||||||
.replaceAll(RegExp(r'^[{"]|["}]$'), '')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// Ensure it's not just "New Chat" or empty
|
|
||||||
if (extractedTitle.isNotEmpty && extractedTitle != 'New Chat') {
|
|
||||||
debugPrint(
|
|
||||||
'DEBUG: Successfully extracted title: $extractedTitle',
|
|
||||||
);
|
|
||||||
return extractedTitle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Error parsing title response: $e');
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('DEBUG: Could not extract valid title from response');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('DEBUG: Failed to generate title: $e');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send chat completed notification
|
// Send chat completed notification
|
||||||
Future<void> sendChatCompleted({
|
Future<void> sendChatCompleted({
|
||||||
required String chatId,
|
required String chatId,
|
||||||
|
|||||||
@@ -219,6 +219,57 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool refreshingSnapshot = false;
|
||||||
|
Future<void> refreshConversationSnapshot() async {
|
||||||
|
if (refreshingSnapshot) return;
|
||||||
|
final chatId = activeConversationId;
|
||||||
|
if (chatId == null || chatId.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (api == null) return;
|
||||||
|
|
||||||
|
refreshingSnapshot = true;
|
||||||
|
try {
|
||||||
|
final conversation = await api.getConversation(chatId);
|
||||||
|
|
||||||
|
if (conversation.title.isNotEmpty && conversation.title != 'New Chat') {
|
||||||
|
onChatTitleUpdated?.call(conversation.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation.messages.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessage? foundAssistant;
|
||||||
|
for (final message in conversation.messages.reversed) {
|
||||||
|
if (message.role == 'assistant') {
|
||||||
|
foundAssistant = message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final assistant = foundAssistant;
|
||||||
|
if (assistant == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFollowUps(assistant.id, assistant.followUps);
|
||||||
|
updateMessageById(assistant.id, (current) {
|
||||||
|
return current.copyWith(
|
||||||
|
followUps: List<String>.from(assistant.followUps),
|
||||||
|
statusHistory: assistant.statusHistory,
|
||||||
|
sources: assistant.sources,
|
||||||
|
metadata: {...?current.metadata, ...?assistant.metadata},
|
||||||
|
usage: assistant.usage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort refresh; ignore failures.
|
||||||
|
} finally {
|
||||||
|
refreshingSnapshot = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void channelLineHandlerFactory(String channel) {
|
void channelLineHandlerFactory(String channel) {
|
||||||
void handler(dynamic line) {
|
void handler(dynamic line) {
|
||||||
try {
|
try {
|
||||||
@@ -446,6 +497,8 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
Future.microtask(refreshConversationSnapshot);
|
||||||
|
|
||||||
final msgs = getMessages();
|
final msgs = getMessages();
|
||||||
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
|
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
|
||||||
final lastContent = msgs.last.content.trim();
|
final lastContent = msgs.last.content.trim();
|
||||||
@@ -897,6 +950,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
// If SSE-driven (no dynamic channel/background flow), finish now
|
// If SSE-driven (no dynamic channel/background flow), finish now
|
||||||
if (!usingDynamicChannel && !isBackgroundFlow) {
|
if (!usingDynamicChannel && !isBackgroundFlow) {
|
||||||
finishStreaming();
|
finishStreaming();
|
||||||
|
Future.microtask(refreshConversationSnapshot);
|
||||||
}
|
}
|
||||||
socketWatchdog?.stop();
|
socketWatchdog?.stop();
|
||||||
},
|
},
|
||||||
@@ -906,6 +960,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
suppressSocketContent = false;
|
suppressSocketContent = false;
|
||||||
finishStreaming();
|
finishStreaming();
|
||||||
|
Future.microtask(refreshConversationSnapshot);
|
||||||
socketWatchdog?.stop();
|
socketWatchdog?.stop();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1463,34 +1463,6 @@ Future<void> _sendMessageInternal(
|
|||||||
|
|
||||||
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
|
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
|
||||||
|
|
||||||
// Immediately trigger title generation after user message is sent (first turn only)
|
|
||||||
try {
|
|
||||||
final currentConversation = ref.read(activeConversationProvider);
|
|
||||||
if (currentConversation != null &&
|
|
||||||
currentConversation.title == 'New Chat') {
|
|
||||||
final currentMessages = ref.read(chatMessagesProvider);
|
|
||||||
if (currentMessages.length == 1 && currentMessages.first.role == 'user') {
|
|
||||||
final List<Map<String, dynamic>> formatted = [
|
|
||||||
{
|
|
||||||
'id': currentMessages.first.id,
|
|
||||||
'role': currentMessages.first.role,
|
|
||||||
'content': currentMessages.first.content,
|
|
||||||
'timestamp':
|
|
||||||
currentMessages.first.timestamp.millisecondsSinceEpoch ~/ 1000,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
_triggerTitleGeneration(
|
|
||||||
ref,
|
|
||||||
currentConversation.id,
|
|
||||||
formatted,
|
|
||||||
selectedModel.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silent fail for early title generation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reviewer mode: simulate a response locally and return
|
// Reviewer mode: simulate a response locally and return
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
// Add assistant message placeholder
|
// Add assistant message placeholder
|
||||||
@@ -1936,71 +1908,6 @@ Please try sending the message again, or try without attachments.''',
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger title generation using the dedicated endpoint
|
|
||||||
Future<void> _triggerTitleGeneration(
|
|
||||||
dynamic ref,
|
|
||||||
String conversationId,
|
|
||||||
List<Map<String, dynamic>> messages,
|
|
||||||
String model,
|
|
||||||
) async {
|
|
||||||
// Enqueue background title generation task
|
|
||||||
try {
|
|
||||||
await ref
|
|
||||||
.read(taskQueueProvider.notifier)
|
|
||||||
.enqueueGenerateTitle(conversationId: conversationId);
|
|
||||||
} catch (_) {
|
|
||||||
// Best effort background check remains
|
|
||||||
_checkForTitleInBackground(ref, conversationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background function to check for title updates without blocking UI
|
|
||||||
Future<void> _checkForTitleInBackground(
|
|
||||||
dynamic ref,
|
|
||||||
String conversationId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) return;
|
|
||||||
|
|
||||||
// Wait a bit before first check to give server time to generate
|
|
||||||
await Future.delayed(const Duration(seconds: 3));
|
|
||||||
|
|
||||||
// Try a few times with increasing delays
|
|
||||||
for (int i = 0; i < 3; i++) {
|
|
||||||
try {
|
|
||||||
final updatedConv = await api.getConversation(conversationId);
|
|
||||||
|
|
||||||
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
|
|
||||||
// Update the active conversation with the new title
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
|
||||||
if (activeConversation?.id == conversationId) {
|
|
||||||
final updated = activeConversation!.copyWith(
|
|
||||||
title: updatedConv.title,
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
ref.read(activeConversationProvider.notifier).set(updated);
|
|
||||||
|
|
||||||
// Refresh the conversations list
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
return; // Title found, stop checking
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next check (3s, 5s, 7s)
|
|
||||||
if (i < 2) {
|
|
||||||
await Future.delayed(Duration(seconds: 2 + (i * 2)));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
break; // Stop on error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Handle background title check errors silently
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save current conversation to OpenWebUI server
|
// Save current conversation to OpenWebUI server
|
||||||
// Removed server persistence; only local caching is used in mobile app.
|
// Removed server persistence; only local caching is used in mobile app.
|
||||||
|
|
||||||
|
|||||||
@@ -68,18 +68,6 @@ abstract class OutboundTask with _$OutboundTask {
|
|||||||
String? error,
|
String? error,
|
||||||
}) = GenerateImageTask;
|
}) = GenerateImageTask;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const factory OutboundTask.imageToDataUrl({
|
const factory OutboundTask.imageToDataUrl({
|
||||||
required String id,
|
required String id,
|
||||||
String? conversationId,
|
String? conversationId,
|
||||||
@@ -103,7 +91,6 @@ abstract class OutboundTask with _$OutboundTask {
|
|||||||
uploadMedia: (t) => t.conversationId,
|
uploadMedia: (t) => t.conversationId,
|
||||||
executeToolCall: (t) => t.conversationId,
|
executeToolCall: (t) => t.conversationId,
|
||||||
generateImage: (t) => t.conversationId,
|
generateImage: (t) => t.conversationId,
|
||||||
generateTitle: (t) => t.conversationId,
|
|
||||||
imageToDataUrl: (t) => t.conversationId,
|
imageToDataUrl: (t) => t.conversationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -301,23 +301,6 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
|||||||
|
|
||||||
// Removed: enqueueSaveConversation — mobile app no longer persists chats to server.
|
// Removed: enqueueSaveConversation — mobile app no longer persists chats to server.
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> enqueueImageToDataUrl({
|
Future<String> enqueueImageToDataUrl({
|
||||||
required String? conversationId,
|
required String? conversationId,
|
||||||
required String filePath,
|
required String filePath,
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ class TaskWorker {
|
|||||||
executeToolCall: _performExecuteToolCall,
|
executeToolCall: _performExecuteToolCall,
|
||||||
generateImage: _performGenerateImage,
|
generateImage: _performGenerateImage,
|
||||||
imageToDataUrl: _performImageToDataUrl,
|
imageToDataUrl: _performImageToDataUrl,
|
||||||
// saveConversation removed — we no longer push chat state to server
|
|
||||||
generateTitle: _performGenerateTitle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,42 +336,4 @@ class TaskWorker {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// _performSaveConversation removed
|
|
||||||
|
|
||||||
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).set(updated);
|
|
||||||
// Do not push full messages to server; skip remote update.
|
|
||||||
// Optionally refresh list to reflect server-side title when it’s generated there.
|
|
||||||
_ref.invalidate(conversationsProvider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user