refactor: pre seed responses

This commit is contained in:
cogwheel0
2025-09-05 11:15:39 +05:30
parent 6af18c97b2
commit 58d5cf076b
7 changed files with 149 additions and 118 deletions

View File

@@ -1130,7 +1130,7 @@ class ApiService {
if (msg.role == 'assistant' && msg.model != null)
'modelName': msg.model,
if (msg.role == 'assistant') 'modelIdx': 0,
if (msg.role == 'assistant') 'done': true,
if (msg.role == 'assistant') 'done': !msg.isStreaming,
if (msg.role == 'user' && model != null) 'models': [model],
if (combinedFilesMap.isNotEmpty) 'files': combinedFilesMap,
};
@@ -1166,7 +1166,7 @@ class ApiService {
if (msg.role == 'assistant' && msg.model != null)
'modelName': msg.model,
if (msg.role == 'assistant') 'modelIdx': 0,
if (msg.role == 'assistant') 'done': true,
if (msg.role == 'assistant') 'done': !msg.isStreaming,
if (msg.role == 'user' && model != null) 'models': [model],
if (combinedFilesArray.isNotEmpty) 'files': combinedFilesArray,
});
@@ -2652,11 +2652,14 @@ class ApiService {
String? sessionIdOverride,
List<Map<String, dynamic>>? toolServers,
Map<String, dynamic>? backgroundTasks,
String? responseMessageId,
}) {
final streamController = StreamController<String>();
// Generate unique IDs
final messageId = const Uuid().v4();
final messageId = (responseMessageId != null && responseMessageId.isNotEmpty)
? responseMessageId
: const Uuid().v4();
final sessionId =
(sessionIdOverride != null && sessionIdOverride.isNotEmpty)
? sessionIdOverride
@@ -2809,6 +2812,8 @@ class ApiService {
// Always use task-based background flow for unified pipeline.
// When a dynamic channel (session_id) is not provided, this method falls
// back to polling and streams deltas to the UI.
// Always use background task flow (matches web client) to ensure
// server maintains correct history with pre-seeded assistant id.
final bool useBackgroundTasks = true;
// Use background flow only when required; otherwise prefer SSE even with chat_id.

View File

@@ -236,6 +236,71 @@ class ToolCallsParser {
return raw.length > max ? '${raw.substring(0, max)}' : raw;
}
}
/// Sanitize assistant/user content before sending to the API, mirroring
/// the web client's `processDetails` behavior:
/// - Remove <details type="reasoning"> and <details type="code_interpreter"> blocks
/// - Replace <details type="tool_calls" ...>...</details> blocks with the
/// JSON-serialized `result` attribute (as a quoted string) when available;
/// otherwise replace with an empty string.
static String sanitizeForApi(String content) {
if (content.isEmpty) return content;
// Remove blocks we never want to include in conversation context
final removeTypes = ['reasoning', 'code_interpreter'];
for (final t in removeTypes) {
content = content.replaceAll(
RegExp(
'<details\\s+type=\"${t}\"[^>]*>[\\s\\S]*?<\\/details>',
multiLine: true,
dotAll: true,
),
'',
);
}
if (!content.contains('<details')) return content.trim();
// Replace tool_calls blocks in-order with their results
final segs = segments(content);
if (segs == null || segs.isEmpty) return content.trim();
final buf = StringBuffer();
for (final seg in segs) {
if (seg.isToolCall && seg.entry != null) {
final entry = seg.entry!;
dynamic res = entry.result;
String out;
if (res == null) {
out = '';
} else {
try {
out = json.encode(res);
} catch (_) {
out = res.toString();
}
}
// Match web behavior: wrap in quotes so it's clearly a string payload
if (out.isNotEmpty && !(out.startsWith('"') && out.endsWith('"'))) {
out = '"$out"';
}
buf.write(out);
} else {
// Keep the raw text, but also remove any stray non-tool_calls details blocks
final t = (seg.text ?? '').replaceAll(
RegExp(
r'<details(?!\s+type=\"tool_calls\")[^>]*>[\s\S]*?<\/details>',
multiLine: true,
dotAll: true,
),
'',
);
if (t.isNotEmpty) buf.write(t);
}
}
return buf.toString().trim();
}
}
/// Ordered piece of content: either plain text or a tool-call entry

View File

@@ -450,37 +450,31 @@ Future<void> regenerateMessage(
for (final msg in messages) {
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
// Clean up tool/details markup to match web client behavior
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
// Handle messages with attachments
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
final List<Map<String, dynamic>> contentArray = [];
// Add text content first
if (msg.content.isNotEmpty) {
contentArray.add({'type': 'text', 'text': msg.content});
if (cleaned.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleaned});
}
conversationMessages.add({
'role': msg.role,
'content': contentArray.isNotEmpty ? contentArray : msg.content,
'content': contentArray.isNotEmpty ? contentArray : cleaned,
});
} else {
// Regular text message
conversationMessages.add({'role': msg.role, 'content': msg.content});
conversationMessages.add({'role': msg.role, 'content': cleaned});
}
}
}
// Stream response using SSE
final response = api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
);
final stream = response.stream;
final assistantMessageId = response.messageId;
// Add assistant message placeholder
// Pre-seed assistant skeleton
final String assistantMessageId = const Uuid().v4();
final assistantMessage = ChatMessage(
id: assistantMessageId,
role: 'assistant',
@@ -490,6 +484,24 @@ Future<void> regenerateMessage(
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
try {
final msgsForSeed = ref.read(chatMessagesProvider);
await api!.updateConversationWithMessages(
activeConversation.id,
msgsForSeed,
model: selectedModel.id,
);
} catch (_) {}
// Stream response via background task (socket/dynamic channel or polling)
final response = api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
responseMessageId: assistantMessageId,
);
final stream = response.stream;
// Handle streaming response (basic chunking for this path)
final chunkedStream = StreamChunker.chunkStream(
@@ -705,6 +717,9 @@ Future<void> _sendMessageInternal(
// Skip only empty assistant message placeholders that are currently streaming
// Include completed messages (both user and assistant) for conversation history
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
// Prepare cleaned text content (strip tool details etc.)
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
// Check if message has attachments (images and non-images)
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
// All models use the same content array format (OpenWebUI standard)
@@ -715,8 +730,8 @@ Future<void> _sendMessageInternal(
final List<Map<String, dynamic>> nonImageFiles = [];
// Add text content first
if (msg.content.isNotEmpty) {
contentArray.add({'type': 'text', 'text': msg.content});
if (cleaned.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleaned});
}
// Add image attachments with proper MIME type handling; collect non-image attachments
@@ -772,7 +787,7 @@ Future<void> _sendMessageInternal(
conversationMessages.add(messageMap);
} else {
// Regular text-only message
conversationMessages.add({'role': msg.role, 'content': msg.content});
conversationMessages.add({'role': msg.role, 'content': cleaned});
}
}
}
@@ -789,6 +804,33 @@ Future<void> _sendMessageInternal(
: null;
try {
// Pre-seed assistant skeleton on server to ensure correct chain
// Generate assistant message id now (must be consistent across client/server)
final String assistantMessageId = const Uuid().v4();
// Add assistant placeholder locally before sending
final assistantPlaceholder = ChatMessage(
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: DateTime.now(),
model: selectedModel.id,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
// Persist skeleton chain to server so web can load correct history
try {
final activeConvForSeed = ref.read(activeConversationProvider);
if (activeConvForSeed != null) {
final msgsForSeed = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages(
activeConvForSeed.id,
msgsForSeed,
model: selectedModel.id,
);
}
} catch (_) {}
// Use the model's actual supported parameters if available
final supportedParams =
selectedModel.supportedParameters ??
@@ -1028,11 +1070,12 @@ Future<void> _sendMessageInternal(
(conv.title == 'New Chat' && msgs.length <= 1);
} catch (_) {}
// Match web client: request background follow-ups always; title/tags on first turn
final bgTasks = <String, dynamic>{
if (shouldGenerateTitle) 'title_generation': true,
if (shouldGenerateTitle) 'tags_generation': true,
'follow_up_generation': true,
if (webSearchEnabled) 'web_search': true, // enable bg workflow for web search
if (webSearchEnabled) 'web_search': true, // enable bg web search
if (imageGenerationEnabled) 'image_generation': true, // enable bg image flow
};
@@ -1056,23 +1099,12 @@ Future<void> _sendMessageInternal(
sessionIdOverride: wantSessionBinding ? socketSessionId : null,
toolServers: toolServers,
backgroundTasks: bgTasks,
responseMessageId: assistantMessageId,
);
final stream = response.stream;
final assistantMessageId = response.messageId;
final sessionId = response.sessionId;
// Add assistant message placeholder with the generated ID and immediate typing indicator
final assistantMessage = ChatMessage(
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: DateTime.now(),
model: selectedModel.id,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
// If socket is available, start listening for chat-events immediately
// Background-tools flow OR any session-bound flow relies on socket/dynamic channel for
// streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress
@@ -1718,10 +1750,10 @@ Future<void> _sendMessageInternal(
}
}
// Save conversation to OpenWebUI server only after streaming is complete
// Add a small delay to ensure the last message content is fully updated
await Future.delayed(const Duration(milliseconds: 100));
await _saveConversationToServer(ref);
// Do not persist conversation to server here. Server manages chat state.
// Keep local save only for quick resume.
await Future.delayed(const Duration(milliseconds: 50));
await _saveConversationLocally(ref);
// Removed post-assistant image generation; images are handled immediately after user message
},
@@ -1956,17 +1988,7 @@ Future<void> _checkForTitleInBackground(
}
// Save current conversation to OpenWebUI server
Future<void> _saveConversationToServer(dynamic ref) async {
// Enqueue save task; local fallback remains if queue fails
try {
final activeConversation = ref.read(activeConversationProvider);
await ref
.read(taskQueueProvider.notifier)
.enqueueSaveConversation(conversationId: activeConversation?.id);
} catch (_) {
await _saveConversationLocally(ref);
}
}
// Removed server persistence; only local caching is used in mobile app.
// Fallback: Save current conversation to local storage
Future<void> _saveConversationLocally(dynamic ref) async {

View File

@@ -1030,7 +1030,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
ref.read(chatMessagesProvider.notifier).finishStreaming();
}
await _saveConversationBeforeLeaving(ref);
// Do not push conversation state back to server on exit.
// Server already maintains chat state from message sends.
// Keep any local persistence only.
if (context.mounted) {
final canPopNavigator = Navigator.of(context).canPop();

View File

@@ -74,17 +74,6 @@ abstract class OutboundTask with _$OutboundTask {
String? error,
}) = 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,
@@ -121,7 +110,6 @@ abstract class OutboundTask with _$OutboundTask {
uploadMedia: (t) => t.conversationId,
executeToolCall: (t) => t.conversationId,
generateImage: (t) => t.conversationId,
saveConversation: (t) => t.conversationId,
generateTitle: (t) => t.conversationId,
imageToDataUrl: (t) => t.conversationId,
);

View File

@@ -273,22 +273,7 @@ class TaskQueueNotifier extends StateNotifier<List<OutboundTask>> {
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;
}
// Removed: enqueueSaveConversation — mobile app no longer persists chats to server.
Future<String> enqueueGenerateTitle({
required String conversationId,

View File

@@ -22,7 +22,7 @@ class TaskWorker {
executeToolCall: _performExecuteToolCall,
generateImage: _performGenerateImage,
imageToDataUrl: _performImageToDataUrl,
saveConversation: _performSaveConversation,
// saveConversation removed — we no longer push chat state to server
generateTitle: _performGenerateTitle,
);
}
@@ -328,36 +328,7 @@ class TaskWorker {
}
}
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 (_) {}
}
// _performSaveConversation removed
Future<void> _performGenerateTitle(GenerateTitleTask task) async {
final api = _ref.read(apiServiceProvider);
@@ -387,15 +358,8 @@ class TaskWorker {
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 (_) {}
// 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);
}
}