refactor: titles

This commit is contained in:
cogwheel0
2025-09-25 21:15:47 +05:30
parent 0943621731
commit db0261ffed
6 changed files with 55 additions and 264 deletions

View File

@@ -1819,107 +1819,6 @@ class ApiService {
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
Future<void> sendChatCompleted({
required String chatId,

View File

@@ -219,6 +219,57 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} 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 handler(dynamic line) {
try {
@@ -446,6 +497,8 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
);
} catch (_) {}
Future.microtask(refreshConversationSnapshot);
final msgs = getMessages();
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final lastContent = msgs.last.content.trim();
@@ -897,6 +950,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
// If SSE-driven (no dynamic channel/background flow), finish now
if (!usingDynamicChannel && !isBackgroundFlow) {
finishStreaming();
Future.microtask(refreshConversationSnapshot);
}
socketWatchdog?.stop();
},
@@ -906,6 +960,7 @@ StreamSubscription<String> attachUnifiedChunkedStreaming({
} catch (_) {}
suppressSocketContent = false;
finishStreaming();
Future.microtask(refreshConversationSnapshot);
socketWatchdog?.stop();
},
);

View File

@@ -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)
// 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
if (reviewerMode) {
// 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
// Removed server persistence; only local caching is used in mobile app.

View File

@@ -68,18 +68,6 @@ abstract class OutboundTask with _$OutboundTask {
String? error,
}) = 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({
required String id,
String? conversationId,
@@ -103,7 +91,6 @@ abstract class OutboundTask with _$OutboundTask {
uploadMedia: (t) => t.conversationId,
executeToolCall: (t) => t.conversationId,
generateImage: (t) => t.conversationId,
generateTitle: (t) => t.conversationId,
imageToDataUrl: (t) => t.conversationId,
);

View File

@@ -301,23 +301,6 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
// 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({
required String? conversationId,
required String filePath,

View File

@@ -21,8 +21,6 @@ class TaskWorker {
executeToolCall: _performExecuteToolCall,
generateImage: _performGenerateImage,
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 its generated there.
_ref.invalidate(conversationsProvider);
}
}
} catch (_) {}
}
}