refactor: pre seed responses
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 it’s generated there.
|
||||
_ref.invalidate(conversationsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user