2025-08-10 01:20:45 +05:30
import ' dart:async ' ;
2025-08-12 13:07:10 +05:30
import ' dart:convert ' ;
2025-08-31 14:02:44 +05:30
import ' package:yaml/yaml.dart ' as yaml ;
2025-08-21 19:11:17 +05:30
2025-08-10 01:20:45 +05:30
import ' package:flutter/foundation.dart ' ;
import ' package:flutter_riverpod/flutter_riverpod.dart ' ;
import ' package:uuid/uuid.dart ' ;
import ' ../../../core/models/chat_message.dart ' ;
import ' ../../../core/models/conversation.dart ' ;
import ' ../../../core/providers/app_providers.dart ' ;
import ' ../../../core/auth/auth_state_manager.dart ' ;
import ' ../../../core/utils/stream_chunker.dart ' ;
2025-08-16 20:27:44 +05:30
import ' ../../../core/services/persistent_streaming_service.dart ' ;
2025-09-01 16:28:49 +05:30
import ' ../../../core/utils/debug_logger.dart ' ;
2025-08-17 16:11:19 +05:30
import ' ../services/reviewer_mode_service.dart ' ;
2025-08-10 01:20:45 +05:30
2025-09-01 16:47:41 +05:30
const bool kSocketVerboseLogging = false ;
2025-08-10 01:20:45 +05:30
// Chat messages for current conversation
final chatMessagesProvider =
StateNotifierProvider < ChatMessagesNotifier , List < ChatMessage > > ( ( ref ) {
return ChatMessagesNotifier ( ref ) ;
} ) ;
// Loading state for conversation (used to show chat skeletons during fetch)
final isLoadingConversationProvider = StateProvider < bool > ( ( ref ) = > false ) ;
2025-08-28 12:59:48 +05:30
// Prefilled input text (e.g., when sharing text from other apps)
final prefilledInputTextProvider = StateProvider < String ? > ( ( ref ) = > null ) ;
2025-08-28 14:45:46 +05:30
// Trigger to request focus on the chat input (increment to signal)
final inputFocusTriggerProvider = StateProvider < int > ( ( ref ) = > 0 ) ;
2025-08-10 01:20:45 +05:30
class ChatMessagesNotifier extends StateNotifier < List < ChatMessage > > {
final Ref _ref ;
StreamSubscription ? _messageStream ;
ProviderSubscription ? _conversationListener ;
final List < StreamSubscription > _subscriptions = [ ] ;
ChatMessagesNotifier ( this . _ref ) : super ( [ ] ) {
// Load messages when conversation changes with proper cleanup
_conversationListener = _ref . listen ( activeConversationProvider , (
previous ,
next ,
) {
2025-08-21 19:11:17 +05:30
debugPrint ( ' Conversation changed: ${ previous ? . id } -> ${ next ? . id } ' ) ;
2025-08-10 01:20:45 +05:30
// Only react when the conversation actually changes
if ( previous ? . id = = next ? . id ) {
2025-08-26 11:21:26 +05:30
// If same conversation but server updated it (e.g., title/content), avoid overwriting
// locally streamed assistant content with an outdated server copy.
2025-08-10 01:20:45 +05:30
if ( previous ? . updatedAt ! = next ? . updatedAt ) {
2025-08-26 11:21:26 +05:30
final serverMessages = next ? . messages ? ? const [ ] ;
// Only replace local messages if the server has strictly more messages
// (i.e., includes new content we don't have yet).
if ( serverMessages . length > state . length ) {
state = serverMessages ;
}
2025-08-10 01:20:45 +05:30
}
return ;
}
// Cancel any existing message stream when switching conversations
_cancelMessageStream ( ) ;
if ( next ! = null ) {
state = next . messages ;
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// Update selected model if conversation has a different model
_updateModelForConversation ( next ) ;
2025-08-10 01:20:45 +05:30
} else {
state = [ ] ;
}
} ) ;
// ProviderSubscription will be cleaned up in dispose method
}
void _addSubscription ( StreamSubscription subscription ) {
_subscriptions . add ( subscription ) ;
}
void _cancelMessageStream ( ) {
_messageStream ? . cancel ( ) ;
_messageStream = null ;
}
2025-08-17 00:26:12 +05:30
Future < void > _updateModelForConversation ( Conversation conversation ) async {
// Check if conversation has a model specified
if ( conversation . model = = null | | conversation . model ! . isEmpty ) {
return ;
}
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
final currentSelectedModel = _ref . read ( selectedModelProvider ) ;
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// If the conversation's model is different from the currently selected one
if ( currentSelectedModel ? . id ! = conversation . model ) {
// Get available models to find the matching one
try {
final models = await _ref . read ( modelsProvider . future ) ;
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
if ( models . isEmpty ) {
return ;
}
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// Look for exact match first
2025-08-21 14:37:49 +05:30
final conversationModel = models
. where ( ( model ) = > model . id = = conversation . model )
. firstOrNull ;
2025-08-17 00:26:12 +05:30
if ( conversationModel ! = null ) {
// Update the selected model
_ref . read ( selectedModelProvider . notifier ) . state = conversationModel ;
} else {
2025-08-21 19:11:17 +05:30
// Model not found in available models - silently continue
2025-08-17 00:26:12 +05:30
}
} catch ( e ) {
2025-08-21 19:11:17 +05:30
// Model update failed - silently continue
2025-08-17 00:26:12 +05:30
}
}
}
2025-08-10 01:20:45 +05:30
void setMessageStream ( StreamSubscription stream ) {
_cancelMessageStream ( ) ;
_messageStream = stream ;
// Add to tracked subscriptions for comprehensive cleanup
_addSubscription ( stream ) ;
}
void addMessage ( ChatMessage message ) {
state = [ . . . state , message ] ;
}
void removeLastMessage ( ) {
if ( state . isNotEmpty ) {
state = state . sublist ( 0 , state . length - 1 ) ;
}
}
void clearMessages ( ) {
state = [ ] ;
}
void setMessages ( List < ChatMessage > messages ) {
state = messages ;
}
void updateLastMessage ( String content ) {
if ( state . isEmpty ) return ;
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' ) return ;
2025-08-31 14:02:44 +05:30
// Ensure we never keep the typing placeholder in persisted content
String sanitized ( String s ) {
const ti = ' [TYPING_INDICATOR] ' ;
const searchBanner = ' 🔍 Searching the web... ' ;
if ( s . startsWith ( ti ) ) {
s = s . substring ( ti . length ) ;
}
if ( s . startsWith ( searchBanner ) ) {
s = s . substring ( searchBanner . length ) ;
}
return s ;
}
2025-08-10 01:20:45 +05:30
state = [
. . . state . sublist ( 0 , state . length - 1 ) ,
2025-08-31 14:02:44 +05:30
lastMessage . copyWith ( content: sanitized ( content ) ) ,
2025-08-10 01:20:45 +05:30
] ;
}
2025-08-21 14:37:49 +05:30
void updateLastMessageWithFunction (
ChatMessage Function ( ChatMessage ) updater ,
) {
2025-08-19 13:09:40 +05:30
if ( state . isEmpty ) return ;
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' ) return ;
2025-08-21 14:37:49 +05:30
state = [ . . . state . sublist ( 0 , state . length - 1 ) , updater ( lastMessage ) ] ;
2025-08-19 13:09:40 +05:30
}
2025-08-10 01:20:45 +05:30
void appendToLastMessage ( String content ) {
if ( state . isEmpty ) {
return ;
}
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' ) {
return ;
}
2025-08-31 19:07:19 +05:30
if ( ! lastMessage . isStreaming ) {
// Ignore late chunks when streaming already finished
return ;
}
2025-08-10 01:20:45 +05:30
2025-08-31 14:02:44 +05:30
// Strip a leading typing indicator if present, then append delta
const ti = ' [TYPING_INDICATOR] ' ;
const searchBanner = ' 🔍 Searching the web... ' ;
String current = lastMessage . content ;
if ( current . startsWith ( ti ) ) {
current = current . substring ( ti . length ) ;
}
if ( current . startsWith ( searchBanner ) ) {
current = current . substring ( searchBanner . length ) ;
}
final newContent = current . isEmpty ? content : current + content ;
2025-08-10 01:20:45 +05:30
state = [
. . . state . sublist ( 0 , state . length - 1 ) ,
lastMessage . copyWith ( content: newContent ) ,
] ;
}
void replaceLastMessageContent ( String content ) {
if ( state . isEmpty ) {
return ;
}
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' ) {
return ;
}
2025-08-31 14:02:44 +05:30
// Remove typing indicator if present in the replacement
String sanitized = content ;
const ti = ' [TYPING_INDICATOR] ' ;
const searchBanner = ' 🔍 Searching the web... ' ;
if ( sanitized . startsWith ( ti ) ) {
sanitized = sanitized . substring ( ti . length ) ;
}
if ( sanitized . startsWith ( searchBanner ) ) {
sanitized = sanitized . substring ( searchBanner . length ) ;
}
2025-08-10 01:20:45 +05:30
state = [
. . . state . sublist ( 0 , state . length - 1 ) ,
2025-08-31 14:02:44 +05:30
lastMessage . copyWith ( content: sanitized ) ,
2025-08-10 01:20:45 +05:30
] ;
}
void finishStreaming ( ) {
if ( state . isEmpty ) return ;
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' | | ! lastMessage . isStreaming ) return ;
2025-08-31 14:02:44 +05:30
// Also strip any leftover typing indicator before finalizing
const ti = ' [TYPING_INDICATOR] ' ;
const searchBanner = ' 🔍 Searching the web... ' ;
String cleaned = lastMessage . content ;
if ( cleaned . startsWith ( ti ) ) {
cleaned = cleaned . substring ( ti . length ) ;
}
if ( cleaned . startsWith ( searchBanner ) ) {
cleaned = cleaned . substring ( searchBanner . length ) ;
}
2025-08-10 01:20:45 +05:30
state = [
. . . state . sublist ( 0 , state . length - 1 ) ,
2025-08-31 14:02:44 +05:30
lastMessage . copyWith ( isStreaming: false , content: cleaned ) ,
2025-08-10 01:20:45 +05:30
] ;
}
@ override
void dispose ( ) {
debugPrint (
2025-08-21 19:11:17 +05:30
' ChatMessagesNotifier disposing - ${ _subscriptions . length } subscriptions ' ,
2025-08-10 01:20:45 +05:30
) ;
// Cancel all tracked subscriptions
for ( final subscription in _subscriptions ) {
subscription . cancel ( ) ;
}
_subscriptions . clear ( ) ;
// Cancel message stream specifically
_cancelMessageStream ( ) ;
// Cancel conversation listener specifically
_conversationListener ? . close ( ) ;
_conversationListener = null ;
super . dispose ( ) ;
}
}
// Start a new chat (unified function for both "New Chat" button and home screen)
void startNewChat ( dynamic ref ) {
// Clear active conversation
ref . read ( activeConversationProvider . notifier ) . state = null ;
// Clear messages
ref . read ( chatMessagesProvider . notifier ) . clearMessages ( ) ;
}
// Available tools provider
final availableToolsProvider = StateProvider < List < String > > ( ( ref ) = > [ ] ) ;
// Web search enabled state for API-based web search
final webSearchEnabledProvider = StateProvider < bool > ( ( ref ) = > false ) ;
2025-08-21 14:37:49 +05:30
// Image generation enabled state - behaves like web search
final imageGenerationEnabledProvider = StateProvider < bool > ( ( ref ) = > false ) ;
2025-08-10 01:20:45 +05:30
// Vision capable models provider
final visionCapableModelsProvider = StateProvider < List < String > > ( ( ref ) {
final selectedModel = ref . watch ( selectedModelProvider ) ;
if ( selectedModel = = null ) return [ ] ;
// Check if the model supports vision (multimodal)
if ( selectedModel . isMultimodal = = true ) {
return [ selectedModel . id ] ;
}
// For now, assume all models support vision unless explicitly marked
// This can be enhanced with proper model capability detection
return [ selectedModel . id ] ;
} ) ;
// File upload capable models provider
final fileUploadCapableModelsProvider = StateProvider < List < String > > ( ( ref ) {
final selectedModel = ref . watch ( selectedModelProvider ) ;
if ( selectedModel = = null ) return [ ] ;
// For now, assume all models support file upload
// This can be enhanced with proper model capability detection
return [ selectedModel . id ] ;
} ) ;
// Helper function to validate file size
bool validateFileSize ( int fileSize , int ? maxSizeMB ) {
if ( maxSizeMB = = null ) return true ;
final maxSizeBytes = maxSizeMB * 1024 * 1024 ;
return fileSize < = maxSizeBytes ;
}
// Helper function to validate file count
bool validateFileCount ( int currentCount , int newFilesCount , int ? maxCount ) {
if ( maxCount = = null ) return true ;
return ( currentCount + newFilesCount ) < = maxCount ;
}
// Helper function to get file content as base64
Future < String ? > _getFileAsBase64 ( dynamic api , String fileId ) async {
// Check if this is already a data URL (for images)
if ( fileId . startsWith ( ' data: ' ) ) {
return fileId ;
}
try {
// First, get file info to determine if it's an image
final fileInfo = await api . getFileInfo ( fileId ) ;
// Try different fields for filename - check all possible field names
final fileName =
fileInfo [ ' filename ' ] ? ?
fileInfo [ ' meta ' ] ? [ ' name ' ] ? ?
fileInfo [ ' name ' ] ? ?
fileInfo [ ' file_name ' ] ? ?
fileInfo [ ' original_name ' ] ? ?
fileInfo [ ' original_filename ' ] ? ?
' ' ;
final ext = fileName . toLowerCase ( ) . split ( ' . ' ) . last ;
// Only process image files
if ( ! [ ' jpg ' , ' jpeg ' , ' png ' , ' gif ' , ' webp ' ] . contains ( ext ) ) {
return null ;
}
// Get file content as base64 string
final fileContent = await api . getFileContent ( fileId ) ;
// The API service returns base64 string directly
return fileContent ;
} catch ( e ) {
return null ;
}
}
2025-08-16 20:27:44 +05:30
// Regenerate message function that doesn't duplicate user message
Future < void > regenerateMessage (
WidgetRef ref ,
String userMessageContent ,
List < String > ? attachments ,
) async {
final reviewerMode = ref . read ( reviewerModeProvider ) ;
final api = ref . read ( apiServiceProvider ) ;
final selectedModel = ref . read ( selectedModelProvider ) ;
if ( ( ! reviewerMode & & api = = null ) | | selectedModel = = null ) {
throw Exception ( ' No API service or model selected ' ) ;
}
final activeConversation = ref . read ( activeConversationProvider ) ;
if ( activeConversation = = null ) {
throw Exception ( ' No active conversation ' ) ;
}
// In reviewer mode, simulate response
if ( reviewerMode ) {
final assistantMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
2025-08-31 19:07:19 +05:30
content: ' ' ,
2025-08-16 20:27:44 +05:30
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantMessage ) ;
2025-09-01 16:28:49 +05:30
// Reviewer mode: no immediate tool preview (no tool context)
// Reviewer mode: no immediate tool preview (no tool context)
2025-08-17 16:11:19 +05:30
// Use canned response for regeneration
final responseText = ReviewerModeService . generateResponse (
userMessage: userMessageContent ,
) ;
2025-08-21 14:37:49 +05:30
2025-08-16 20:27:44 +05:30
// Simulate streaming response
2025-08-17 16:11:19 +05:30
final words = responseText . split ( ' ' ) ;
2025-08-16 20:27:44 +05:30
for ( final word in words ) {
await Future . delayed ( const Duration ( milliseconds: 40 ) ) ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( ' $ word ' ) ;
}
2025-08-21 14:37:49 +05:30
2025-08-16 20:27:44 +05:30
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
await _saveConversationLocally ( ref ) ;
return ;
}
// For real API, proceed with regeneration using existing conversation messages
try {
// Get conversation history for context (excluding the removed assistant message)
final List < ChatMessage > messages = ref . read ( chatMessagesProvider ) ;
2025-08-21 14:37:49 +05:30
final List < Map < String , dynamic > > conversationMessages =
< Map < String , dynamic > > [ ] ;
2025-08-16 20:27:44 +05:30
for ( final msg in messages ) {
if ( msg . role . isNotEmpty & & msg . content . isNotEmpty & & ! msg . isStreaming ) {
// Handle messages with attachments
if ( msg . attachmentIds ! = null & & msg . attachmentIds ! . isNotEmpty ) {
final List < Map < String , dynamic > > contentArray = [ ] ;
2025-08-21 14:37:49 +05:30
2025-08-16 20:27:44 +05:30
// Add text content first
if ( msg . content . isNotEmpty ) {
contentArray . add ( { ' type ' : ' text ' , ' text ' : msg . content } ) ;
}
conversationMessages . add ( {
' role ' : msg . role ,
' content ' : contentArray . isNotEmpty ? contentArray : msg . content ,
} ) ;
} else {
// Regular text message
2025-08-21 14:37:49 +05:30
conversationMessages . add ( { ' role ' : msg . role , ' content ' : msg . content } ) ;
2025-08-16 20:27:44 +05:30
}
}
}
// Stream response using SSE
2025-08-17 16:11:19 +05:30
final response = api ! . sendMessage (
2025-08-16 20:27:44 +05:30
messages: conversationMessages ,
model: selectedModel . id ,
conversationId: activeConversation . id ,
) ;
final stream = response . stream ;
final assistantMessageId = response . messageId ;
// Add assistant message placeholder
final assistantMessage = ChatMessage (
id: assistantMessageId ,
role: ' assistant ' ,
2025-08-31 19:07:19 +05:30
content: ' ' ,
2025-08-16 20:27:44 +05:30
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantMessage ) ;
2025-08-31 14:02:44 +05:30
// Handle streaming response (basic chunking for this path)
2025-08-16 20:27:44 +05:30
final chunkedStream = StreamChunker . chunkStream (
stream ,
enableChunking: true ,
minChunkSize: 5 ,
maxChunkLength: 3 ,
delayBetweenChunks: const Duration ( milliseconds: 15 ) ,
) ;
await for ( final chunk in chunkedStream ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( chunk ) ;
}
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
await _saveConversationLocally ( ref ) ;
} catch ( e ) {
rethrow ;
}
}
2025-08-10 01:20:45 +05:30
// Send message function for widgets
Future < void > sendMessage (
WidgetRef ref ,
String message ,
2025-08-19 20:26:19 +05:30
List < String > ? attachments , [
List < String > ? toolIds ,
] ) async {
await _sendMessageInternal ( ref , message , attachments , toolIds ) ;
2025-08-10 01:20:45 +05:30
}
2025-08-28 12:59:48 +05:30
// Service-friendly wrapper (accepts generic Ref)
Future < void > sendMessageFromService (
Ref ref ,
String message ,
List < String > ? attachments , [
List < String > ? toolIds ,
] ) async {
await _sendMessageInternal ( ref , message , attachments , toolIds ) ;
}
2025-08-10 01:20:45 +05:30
// Internal send message implementation
Future < void > _sendMessageInternal (
dynamic ref ,
String message ,
2025-08-19 20:26:19 +05:30
List < String > ? attachments , [
List < String > ? toolIds ,
] ) async {
2025-08-10 01:20:45 +05:30
final reviewerMode = ref . read ( reviewerModeProvider ) ;
final api = ref . read ( apiServiceProvider ) ;
final selectedModel = ref . read ( selectedModelProvider ) ;
if ( ( ! reviewerMode & & api = = null ) | | selectedModel = = null ) {
throw Exception ( ' No API service or model selected ' ) ;
}
// Check if we need to create a new conversation first
var activeConversation = ref . read ( activeConversationProvider ) ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Create user message first
2025-08-21 19:11:17 +05:30
2025-08-12 13:07:10 +05:30
final userMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' user ' ,
content: message ,
timestamp: DateTime . now ( ) ,
attachmentIds: attachments ,
) ;
2025-08-10 01:20:45 +05:30
if ( activeConversation = = null ) {
2025-08-12 13:07:10 +05:30
// Create new conversation with the first message included
2025-08-10 01:20:45 +05:30
final localConversation = Conversation (
id: const Uuid ( ) . v4 ( ) ,
2025-08-12 13:07:10 +05:30
title: ' New Chat ' ,
2025-08-10 01:20:45 +05:30
createdAt: DateTime . now ( ) ,
updatedAt: DateTime . now ( ) ,
2025-08-12 13:07:10 +05:30
messages: [ userMessage ] , // Include the user message
2025-08-10 01:20:45 +05:30
) ;
// Set as active conversation locally
ref . read ( activeConversationProvider . notifier ) . state = localConversation ;
activeConversation = localConversation ;
if ( ! reviewerMode ) {
2025-08-12 13:07:10 +05:30
// Try to create on server with the first message included
2025-08-10 01:20:45 +05:30
try {
final serverConversation = await api . createConversation (
2025-08-12 13:07:10 +05:30
title: ' New Chat ' ,
messages: [ userMessage ] , // Include the first message in creation
2025-08-10 01:20:45 +05:30
model: selectedModel . id ,
) ;
final updatedConversation = localConversation . copyWith (
id: serverConversation . id ,
2025-08-21 14:37:49 +05:30
messages: serverConversation . messages . isNotEmpty
? serverConversation . messages
2025-08-12 13:07:10 +05:30
: [ userMessage ] ,
2025-08-10 01:20:45 +05:30
) ;
ref . read ( activeConversationProvider . notifier ) . state =
updatedConversation ;
activeConversation = updatedConversation ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Set messages in the messages provider to keep UI in sync
ref . read ( chatMessagesProvider . notifier ) . clearMessages ( ) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( userMessage ) ;
2025-08-21 14:37:49 +05:30
2025-08-17 00:26:12 +05:30
// Invalidate conversations provider to refresh the list
2025-08-17 16:11:19 +05:30
// Adding a small delay to prevent rapid invalidations that could cause duplicates
Future . delayed ( const Duration ( milliseconds: 100 ) , ( ) {
2025-08-28 14:45:46 +05:30
try {
// Guard against using ref after widget disposal
if ( ref . mounted = = true ) {
ref . invalidate ( conversationsProvider ) ;
}
} catch ( _ ) {
// If ref doesn't support mounted or is disposed, skip
}
2025-08-17 16:11:19 +05:30
} ) ;
2025-08-10 01:20:45 +05:30
} catch ( e ) {
2025-08-12 13:07:10 +05:30
// Still add the message locally
ref . read ( chatMessagesProvider . notifier ) . addMessage ( userMessage ) ;
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
} else {
// Add message for reviewer mode
ref . read ( chatMessagesProvider . notifier ) . addMessage ( userMessage ) ;
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
} else {
// Add user message to existing conversation
ref . read ( chatMessagesProvider . notifier ) . addMessage ( userMessage ) ;
2025-08-10 01:20:45 +05:30
}
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
2025-08-25 22:14:40 +05:30
// 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
}
2025-08-10 01:20:45 +05:30
// Reviewer mode: simulate a response locally and return
if ( reviewerMode ) {
// Add assistant message placeholder
final assistantMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
2025-08-31 19:07:19 +05:30
content: ' ' ,
2025-08-10 01:20:45 +05:30
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantMessage ) ;
2025-08-17 16:11:19 +05:30
// Check if there are attachments
String ? filename ;
if ( attachments ! = null & & attachments . isNotEmpty ) {
// Get the first attachment filename for the response
// In reviewer mode, we just simulate having a file
filename = " demo_file.txt " ;
}
// Check if this is voice input
// In reviewer mode, we don't have actual voice input state
final isVoiceInput = false ;
// Generate appropriate canned response
final responseText = ReviewerModeService . generateResponse (
userMessage: message ,
filename: filename ,
isVoiceInput: isVoiceInput ,
) ;
2025-08-10 01:20:45 +05:30
// Simulate token-by-token streaming
2025-08-17 16:11:19 +05:30
final words = responseText . split ( ' ' ) ;
2025-08-10 01:20:45 +05:30
for ( final word in words ) {
await Future . delayed ( const Duration ( milliseconds: 40 ) ) ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( ' $ word ' ) ;
}
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
// Save locally
await _saveConversationLocally ( ref ) ;
return ;
}
// Get conversation history for context
final List < ChatMessage > messages = ref . read ( chatMessagesProvider ) ;
final List < Map < String , dynamic > > conversationMessages =
< Map < String , dynamic > > [ ] ;
for ( final msg in messages ) {
// 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 ) {
// Check if message has attachments (images and non-images)
if ( msg . attachmentIds ! = null & & msg . attachmentIds ! . isNotEmpty ) {
2025-08-21 19:11:17 +05:30
// All models use the same content array format (OpenWebUI standard)
2025-08-10 01:20:45 +05:30
// Use the same content array format for all models (OpenWebUI standard)
final List < Map < String , dynamic > > contentArray = [ ] ;
// Collect non-image files to include in the message map so API can forward top-level 'files'
final List < Map < String , dynamic > > nonImageFiles = [ ] ;
// Add text content first
if ( msg . content . isNotEmpty ) {
contentArray . add ( { ' type ' : ' text ' , ' text ' : msg . content } ) ;
}
// Add image attachments with proper MIME type handling; collect non-image attachments
for ( final attachmentId in msg . attachmentIds ! ) {
try {
final base64Data = await _getFileAsBase64 ( api , attachmentId ) ;
if ( base64Data ! = null ) {
// Check if this is already a data URL
if ( base64Data . startsWith ( ' data: ' ) ) {
contentArray . add ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : base64Data } ,
} ) ;
} else {
// For server files, determine MIME type from file extension
// Only call getFileInfo if attachmentId is not a data URL
if ( ! attachmentId . startsWith ( ' data: ' ) ) {
final fileInfo = await api . getFileInfo ( attachmentId ) ;
final fileName = fileInfo [ ' filename ' ] ? ? ' ' ;
final ext = fileName . toLowerCase ( ) . split ( ' . ' ) . last ;
String mimeType = ' image/png ' ; // default
if ( ext = = ' jpg ' | | ext = = ' jpeg ' ) {
mimeType = ' image/jpeg ' ;
} else if ( ext = = ' gif ' ) {
mimeType = ' image/gif ' ;
} else if ( ext = = ' webp ' ) {
mimeType = ' image/webp ' ;
}
contentArray . add ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : ' data: $ mimeType ;base64, $ base64Data ' } ,
} ) ;
}
}
} else {
// Treat as non-image file; include minimal descriptor so server can resolve by id
nonImageFiles . add ( { ' id ' : attachmentId , ' type ' : ' file ' } ) ;
}
} catch ( e ) {
2025-08-21 19:11:17 +05:30
// Handle attachment processing errors silently
2025-08-10 01:20:45 +05:30
}
}
final messageMap = < String , dynamic > {
' role ' : msg . role ,
' content ' : contentArray ,
} ;
if ( nonImageFiles . isNotEmpty ) {
messageMap [ ' files ' ] = nonImageFiles ;
}
conversationMessages . add ( messageMap ) ;
} else {
// Regular text-only message
conversationMessages . add ( { ' role ' : msg . role , ' content ' : msg . content } ) ;
}
}
}
2025-08-24 20:55:51 +05:30
// Check feature toggles for API (gated by server availability)
final webSearchEnabled =
ref . read ( webSearchEnabledProvider ) & &
ref . read ( webSearchAvailableProvider ) ;
2025-08-21 14:37:49 +05:30
final imageGenerationEnabled = ref . read ( imageGenerationEnabledProvider ) ;
2025-08-19 20:26:19 +05:30
// Prepare tools list - pass tool IDs directly
2025-08-21 14:37:49 +05:30
final List < String > ? toolIdsForApi = ( toolIds ! = null & & toolIds . isNotEmpty )
? toolIds
: null ;
2025-08-10 01:20:45 +05:30
try {
// Use the model's actual supported parameters if available
final supportedParams =
selectedModel . supportedParameters ? ?
[
' max_tokens ' ,
' tool_choice ' ,
' tools ' ,
' response_format ' ,
' structured_outputs ' ,
] ;
// Create comprehensive model item matching OpenWebUI format exactly
final modelItem = {
' id ' : selectedModel . id ,
' canonical_slug ' : selectedModel . id ,
' hugging_face_id ' : ' ' ,
' name ' : selectedModel . name ,
' created ' : 1754089419 , // Use example timestamp for consistency
' description ' :
selectedModel . description ? ?
' This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrouter/horizon-alpha) \n \n Note: It \' s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training. ' ,
' context_length ' : 256000 ,
' architecture ' : {
' modality ' : ' text+image->text ' ,
' input_modalities ' : [ ' image ' , ' text ' ] ,
' output_modalities ' : [ ' text ' ] ,
' tokenizer ' : ' Other ' ,
' instruct_type ' : null ,
} ,
' pricing ' : {
' prompt ' : ' 0 ' ,
' completion ' : ' 0 ' ,
' request ' : ' 0 ' ,
' image ' : ' 0 ' ,
' audio ' : ' 0 ' ,
' web_search ' : ' 0 ' ,
' internal_reasoning ' : ' 0 ' ,
} ,
' top_provider ' : {
' context_length ' : 256000 ,
' max_completion_tokens ' : 128000 ,
' is_moderated ' : false ,
} ,
' per_request_limits ' : null ,
' supported_parameters ' : supportedParams ,
' connection_type ' : ' external ' ,
' owned_by ' : ' openai ' ,
' openai ' : {
' id ' : selectedModel . id ,
' canonical_slug ' : selectedModel . id ,
' hugging_face_id ' : ' ' ,
' name ' : selectedModel . name ,
' created ' : 1754089419 ,
' description ' :
selectedModel . description ? ?
' This is a cloaked model provided to the community to gather feedback. This is an improved version of [Horizon Alpha](/openrout '
' er/horizon-alpha) \n \n Note: It \' s free to use during this testing period, and prompts and completions are logged by the model creator for feedback and training. ' ,
' context_length ' : 256000 ,
' architecture ' : {
' modality ' : ' text+image->text ' ,
' input_modalities ' : [ ' image ' , ' text ' ] ,
' output_modalities ' : [ ' text ' ] ,
' tokenizer ' : ' Other ' ,
' instruct_type ' : null ,
} ,
' pricing ' : {
' prompt ' : ' 0 ' ,
' completion ' : ' 0 ' ,
' request ' : ' 0 ' ,
' image ' : ' 0 ' ,
' audio ' : ' 0 ' ,
' web_search ' : ' 0 ' ,
' internal_reasoning ' : ' 0 ' ,
} ,
' top_provider ' : {
' context_length ' : 256000 ,
' max_completion_tokens ' : 128000 ,
' is_moderated ' : false ,
} ,
' per_request_limits ' : null ,
' supported_parameters ' : [
' max_tokens ' ,
' tool_choice ' ,
' tools ' ,
' response_format ' ,
' structured_outputs ' ,
] ,
' connection_type ' : ' external ' ,
} ,
' urlIdx ' : 0 ,
' actions ' : < dynamic > [ ] ,
' filters ' : < dynamic > [ ] ,
' tags ' : < dynamic > [ ] ,
} ;
2025-08-21 15:08:57 +05:30
// If image generation is enabled and we want image-only, skip assistant SSE
if ( imageGenerationEnabled ) {
// Create assistant placeholder
final imageOnlyAssistantId = const Uuid ( ) . v4 ( ) ;
final imageOnlyAssistant = ChatMessage (
id: imageOnlyAssistantId ,
role: ' assistant ' ,
content: ' ' ,
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( imageOnlyAssistant ) ;
try {
final imageResponse = await api . generateImage ( prompt: message ) ;
// Extract image URLs or base64 data URIs from response
List < Map < String , dynamic > > extractGeneratedFiles ( dynamic resp ) {
final results = < Map < String , dynamic > > [ ] ;
if ( resp is List ) {
for ( final item in resp ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
return results ;
}
if ( resp is ! Map ) return results ;
final data = resp [ ' data ' ] ;
if ( data is List ) {
for ( final item in data ) {
if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
} else if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
}
}
}
final images = resp [ ' images ' ] ;
if ( images is List ) {
for ( final item in images ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
}
final singleUrl = resp [ ' url ' ] ;
if ( singleUrl is String & & singleUrl . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : singleUrl } ) ;
}
final singleB64 = resp [ ' b64_json ' ] ? ? resp [ ' b64 ' ] ;
if ( singleB64 is String & & singleB64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ singleB64 ' ,
} ) ;
}
return results ;
}
final generatedFiles = extractGeneratedFiles ( imageResponse ) ;
if ( generatedFiles . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( ChatMessage m ) = >
m . copyWith ( files: generatedFiles , isStreaming: false ) ,
) ;
await _saveConversationToServer ( ref ) ;
2025-08-21 15:45:07 +05:30
// Trigger title generation for image-only flow
final activeConv = ref . read ( activeConversationProvider ) ;
if ( activeConv ! = null ) {
// Build minimal formatted messages
final currentMessages = ref . read ( chatMessagesProvider ) ;
final List < Map < String , dynamic > > formattedMessages = [ ] ;
for ( final msg in currentMessages ) {
formattedMessages . add ( {
' id ' : msg . id ,
' role ' : msg . role ,
' content ' : msg . content ,
' timestamp ' : msg . timestamp . millisecondsSinceEpoch ~ / 1000 ,
} ) ;
}
_triggerTitleGeneration (
ref ,
activeConv . id ,
formattedMessages ,
selectedModel . id ,
) ;
}
2025-08-21 15:08:57 +05:30
} else {
// No images; mark done
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} catch ( e ) {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
// Image-only done; do not start SSE
return ;
}
2025-08-16 17:36:02 +05:30
// Stream response using SSE
2025-08-31 14:02:44 +05:30
// Resolve Socket session for background tasks parity
final socketService = ref . read ( socketServiceProvider ) ;
final socketSessionId = socketService ? . sessionId ;
// Resolve tool servers from user settings (if any)
List < Map < String , dynamic > > ? toolServers ;
try {
final userSettings = await api . getUserSettings ( ) ;
final ui = userSettings [ ' ui ' ] as Map < String , dynamic > ? ;
final rawServers = ui ! = null ? ( ui [ ' toolServers ' ] as List ? ) : null ;
if ( rawServers ! = null & & rawServers . isNotEmpty ) {
toolServers = await _resolveToolServers ( rawServers , api ) ;
}
} catch ( _ ) { }
// Background tasks parity with Web client (safe defaults)
final bgTasks = < String , dynamic > {
' title_generation ' : true ,
' tags_generation ' : true ,
' follow_up_generation ' : true ,
} ;
2025-08-16 17:36:02 +05:30
final response = await api . sendMessage (
2025-08-10 01:20:45 +05:30
messages: conversationMessages ,
model: selectedModel . id ,
conversationId: activeConversation ? . id ,
2025-08-19 20:26:19 +05:30
toolIds: toolIdsForApi ,
2025-08-10 01:20:45 +05:30
enableWebSearch: webSearchEnabled ,
2025-08-21 15:08:57 +05:30
// Disable server-side image generation to avoid duplicate images;
// handled via pre-stream client-side request above
enableImageGeneration: false ,
2025-08-10 01:20:45 +05:30
modelItem: modelItem ,
2025-08-31 14:02:44 +05:30
sessionIdOverride: socketSessionId ,
toolServers: toolServers ,
backgroundTasks: bgTasks ,
2025-08-10 01:20:45 +05:30
) ;
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 ' ,
2025-08-31 19:07:19 +05:30
content: ' ' ,
2025-08-10 01:20:45 +05:30
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantMessage ) ;
2025-08-31 14:02:44 +05:30
// If socket is available, start listening for chat-events immediately
2025-08-31 19:07:19 +05:30
// For background-tools flow (when socket session is present), socket is the primary stream.
// In that case, do NOT suppress socket content.
2025-09-01 16:28:49 +05:30
// Suppress socket TEXT content when we already have a stream (SSE or polling)
// but DO allow tool_call status via socket to surface tiles immediately.
bool suppressSocketContent = ( socketSessionId = = null ) ; // text-only suppression
2025-08-31 14:02:44 +05:30
if ( socketService ! = null ) {
void chatHandler ( Map < String , dynamic > ev ) {
try {
final data = ev [ ' data ' ] ;
if ( data = = null ) return ;
final type = data [ ' type ' ] ;
final payload = data [ ' data ' ] ;
2025-09-01 16:28:49 +05:30
DebugLogger . stream ( ' Socket chat-events: type= $ type ' ) ;
2025-08-31 14:02:44 +05:30
if ( type = = ' chat:completion ' & & payload ! = null ) {
if ( payload is Map < String , dynamic > ) {
2025-08-31 19:07:19 +05:30
// Provider may emit tool_calls at the top level
2025-09-01 16:28:49 +05:30
// Always surface tool_calls status from socket for instant tiles
if ( payload . containsKey ( ' tool_calls ' ) ) {
2025-08-31 19:07:19 +05:30
final tc = payload [ ' tool_calls ' ] ;
if ( tc is List ) {
for ( final call in tc ) {
if ( call is Map < String , dynamic > ) {
final fn = call [ ' function ' ] ;
final name = ( fn is Map & & fn [ ' name ' ] is String ) ? fn [ ' name ' ] as String : null ;
if ( name is String & & name . isNotEmpty ) {
2025-09-01 16:47:41 +05:30
final msgs = ref . read ( chatMessagesProvider ) ;
final exists = ( msgs . isNotEmpty ) & & RegExp (
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + RegExp . escape ( name ) + r'\"' ,
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
final status = ' \n <details type="tool_calls" done="false" name=" $ name "><summary>Executing...</summary> \n </details> \n ' ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( status ) ;
}
2025-08-31 19:07:19 +05:30
}
}
}
}
}
if ( ! suppressSocketContent & & payload . containsKey ( ' choices ' ) ) {
2025-08-31 14:02:44 +05:30
final choices = payload [ ' choices ' ] ;
if ( choices is List & & choices . isNotEmpty ) {
final choice = choices . first ;
final delta = choice is Map ? choice [ ' delta ' ] : null ;
2025-08-31 19:07:19 +05:30
if ( delta is Map ) {
// Surface tool_calls status like SSE path
if ( delta . containsKey ( ' tool_calls ' ) ) {
final tc = delta [ ' tool_calls ' ] ;
if ( tc is List ) {
for ( final call in tc ) {
if ( call is Map < String , dynamic > ) {
final fn = call [ ' function ' ] ;
final name = ( fn is Map & & fn [ ' name ' ] is String ) ? fn [ ' name ' ] as String : null ;
if ( name is String & & name . isNotEmpty ) {
2025-09-01 16:47:41 +05:30
final msgs = ref . read ( chatMessagesProvider ) ;
final exists = ( msgs . isNotEmpty ) & & RegExp (
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' + RegExp . escape ( name ) + r'\"' ,
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
final status = ' \n <details type="tool_calls" done="false" name=" $ name "><summary>Executing...</summary> \n </details> \n ' ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( status ) ;
}
2025-08-31 19:07:19 +05:30
}
}
}
}
}
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( content ) ;
}
2025-08-31 14:02:44 +05:30
}
}
}
2025-08-31 19:07:19 +05:30
if ( ! suppressSocketContent & & payload . containsKey ( ' content ' ) ) {
2025-08-31 14:02:44 +05:30
final content = payload [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
final prev = msgs . last . content ;
2025-08-31 19:07:19 +05:30
if ( prev . isEmpty | | prev = = ' [TYPING_INDICATOR] ' ) {
2025-08-31 14:02:44 +05:30
ref
. read ( chatMessagesProvider . notifier )
. replaceLastMessageContent ( content ) ;
} else if ( content . startsWith ( prev ) ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content . substring ( prev . length ) ) ;
} else {
ref
. read ( chatMessagesProvider . notifier )
. replaceLastMessageContent ( content ) ;
}
} else {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
}
}
}
if ( payload [ ' done ' ] = = true ) {
2025-09-01 16:28:49 +05:30
// Stop listening to further socket events for this session.
2025-08-31 19:07:19 +05:30
try { socketService . offChatEvents ( ) ; } catch ( _ ) { }
2025-09-01 16:28:49 +05:30
2025-09-01 16:47:41 +05:30
// Notify server that chat is completed (mirrors web client)
try {
final apiSvc = ref . read ( apiServiceProvider ) ;
final chatId = activeConversation ? . id ? ? ' ' ;
if ( apiSvc ! = null & & chatId . isNotEmpty ) {
unawaited ( apiSvc
. sendChatCompleted (
chatId: chatId ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
)
. timeout ( const Duration ( seconds: 3 ) )
. catchError ( ( _ ) { } ) ) ;
}
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
// If no content was rendered yet, fetch final assistant message from server
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
final lastContent = msgs . last . content . trim ( ) ;
if ( lastContent . isEmpty ) {
final apiSvc = ref . read ( apiServiceProvider ) ;
final chatId = activeConversation ? . id ;
final msgId = assistantMessageId ;
if ( apiSvc ! = null & & chatId ! = null & & chatId . isNotEmpty ) {
Future . microtask ( ( ) async {
try {
final resp = await apiSvc . dio . get ( ' /api/v1/chats/ ' + chatId ) ;
final data = resp . data as Map < String , dynamic > ;
String content = ' ' ;
final chatObj = data [ ' chat ' ] as Map < String , dynamic > ? ;
if ( chatObj ! = null ) {
// Prefer chat.messages list
final list = chatObj [ ' messages ' ] ;
if ( list is List ) {
final target = list . firstWhere (
( m ) = > ( m is Map & & ( m [ ' id ' ] ? . toString ( ) = = msgId ) ) ,
orElse: ( ) = > null ,
) ;
if ( target ! = null ) {
final rawContent = ( target as Map ) [ ' content ' ] ;
if ( rawContent is String ) {
content = rawContent ;
} else if ( rawContent is List ) {
final textItem = rawContent . firstWhere (
( i ) = > i is Map & & i [ ' type ' ] = = ' text ' ,
orElse: ( ) = > null ,
) ;
if ( textItem ! = null ) {
content = textItem [ ' text ' ] ? . toString ( ) ? ? ' ' ;
}
}
}
}
// Fallback to history map
if ( content . isEmpty ) {
final history = chatObj [ ' history ' ] ;
if ( history is Map & & history [ ' messages ' ] is Map ) {
final Map < String , dynamic > messagesMap =
( history [ ' messages ' ] as Map ) . cast < String , dynamic > ( ) ;
final msg = messagesMap [ msgId ] ;
if ( msg is Map ) {
final rawContent = msg [ ' content ' ] ;
if ( rawContent is String ) {
content = rawContent ;
} else if ( rawContent is List ) {
final textItem = rawContent . firstWhere (
( i ) = > i is Map & & i [ ' type ' ] = = ' text ' ,
orElse: ( ) = > null ,
) ;
if ( textItem ! = null ) {
content = textItem [ ' text ' ] ? . toString ( ) ? ? ' ' ;
}
}
}
}
}
}
if ( content . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. replaceLastMessageContent ( content ) ;
}
} catch ( _ ) {
// Swallow; we'll still finish streaming
} finally {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} ) ;
return ; // Defer finish to microtask
}
}
}
// Normal path: finish now
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
}
} else if ( type = = ' request:chat:completion ' & & payload ! = null ) {
// Mirror web client's execute path: listen on provided dynamic channel
final channel = payload [ ' channel ' ] ;
if ( channel is String & & channel . isNotEmpty ) {
2025-09-01 17:33:44 +05:30
// Prefer dynamic channel for streaming content; suppress chat-events text to avoid duplicates
suppressSocketContent = true ;
if ( kSocketVerboseLogging ) {
DebugLogger . stream ( ' Socket request:chat:completion channel= $ channel ' ) ;
}
2025-09-01 16:28:49 +05:30
void channelLineHandler ( dynamic line ) {
try {
if ( line is String ) {
final s = line . trim ( ) ;
DebugLogger . stream ( ' Socket [ ' + channel + ' ] line= ' + ( s . length > 160 ? s . substring ( 0 , 160 ) + ' … ' : s ) ) ;
if ( s = = ' [DONE] ' | | s = = ' DONE ' ) {
socketService . offEvent ( channel ) ;
// Channel completed
2025-09-01 16:47:41 +05:30
try {
unawaited ( api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ) ;
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
return ;
}
if ( s . startsWith ( ' data: ' ) ) {
final dataStr = s . substring ( 5 ) . trim ( ) ;
if ( dataStr = = ' [DONE] ' ) {
socketService . offEvent ( channel ) ;
2025-09-01 16:47:41 +05:30
try {
unawaited ( api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ) ;
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
return ;
}
// Try to parse OpenAI-style delta JSON
try {
final Map < String , dynamic > j = jsonDecode ( dataStr ) ;
final choices = j [ ' choices ' ] ;
if ( choices is List & & choices . isNotEmpty ) {
final choice = choices . first ;
final delta = choice is Map ? choice [ ' delta ' ] : null ;
if ( delta is Map ) {
if ( delta . containsKey ( ' content ' ) ) {
final c = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( c . isNotEmpty ) {
DebugLogger . stream ( ' Socket [ ' + channel + ' ] delta.content len= ' + c . length . toString ( ) ) ;
}
}
// Surface tool_calls status
if ( delta . containsKey ( ' tool_calls ' ) ) {
2025-09-01 16:47:41 +05:30
if ( kSocketVerboseLogging ) {
DebugLogger . stream ( ' Socket [ ' + channel + ' ] delta.tool_calls detected ' ) ;
}
2025-09-01 16:28:49 +05:30
final tc = delta [ ' tool_calls ' ] ;
if ( tc is List ) {
for ( final call in tc ) {
if ( call is Map < String , dynamic > ) {
final fn = call [ ' function ' ] ;
final name = ( fn is Map & & fn [ ' name ' ] is String )
? fn [ ' name ' ] as String
: null ;
if ( name is String & & name . isNotEmpty ) {
2025-09-01 16:47:41 +05:30
final msgs = ref . read ( chatMessagesProvider ) ;
final exists = ( msgs . isNotEmpty ) & & RegExp (
r'<details\\s+type=\"tool_calls\"[^>]*\\bname=\"' + RegExp . escape ( name ) + r'\"' ,
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
final status = ' \n <details type="tool_calls" done="false" name=" $ name "><summary>Executing...</summary> \n </details> \n ' ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( status ) ;
}
2025-09-01 16:28:49 +05:30
}
}
}
}
}
// Append streamed content
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( content ) ;
}
}
}
} catch ( _ ) {
// Non-JSON line: append as-is
if ( s . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( s ) ;
}
}
} else {
// Plain text line
if ( s . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( s ) ;
}
}
} else if ( line is Map ) {
// If server sends { done: true } via channel
final done = line [ ' done ' ] = = true ;
if ( done ) {
socketService . offEvent ( channel ) ;
2025-09-01 16:47:41 +05:30
try {
unawaited ( api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ) ;
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
return ;
}
}
} catch ( _ ) { }
2025-08-31 14:02:44 +05:30
}
2025-09-01 16:28:49 +05:30
// Register dynamic channel listener
try {
socketService . onEvent ( channel , channelLineHandler ) ;
} catch ( _ ) { }
2025-08-31 14:02:44 +05:30
}
2025-09-01 16:28:49 +05:30
} else if ( type = = ' execute:tool ' & & payload ! = null ) {
// Show an executing tile immediately using provided tool info
try {
final name = payload [ ' name ' ] ? . toString ( ) ? ? ' tool ' ;
DebugLogger . stream ( ' Socket execute:tool name= ' + name ) ;
final status = ' \n <details type="tool_calls" done="false" name=" $ name "><summary>Executing...</summary> \n </details> \n ' ;
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( status ) ;
} catch ( _ ) { }
2025-08-31 14:02:44 +05:30
}
} catch ( _ ) { }
}
socketService . onChatEvents ( chatHandler ) ;
2025-09-01 16:28:49 +05:30
// Also mirror channel-events like the web client
void channelEventsHandler ( Map < String , dynamic > ev ) {
try {
final data = ev [ ' data ' ] ;
if ( data = = null ) return ;
final type = data [ ' type ' ] ;
final payload = data [ ' data ' ] ;
DebugLogger . stream ( ' Socket channel-events: type= ' + type . toString ( ) ) ;
// Handle generic channel progress messages if needed
if ( type = = ' message ' & & payload is Map ) {
final content = payload [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( content ) ;
}
}
} catch ( _ ) { }
}
socketService . onChannelEvents ( channelEventsHandler ) ;
2025-08-31 14:02:44 +05:30
Future . delayed ( const Duration ( seconds: 90 ) , ( ) {
try {
socketService . offChatEvents ( ) ;
2025-09-01 16:28:49 +05:30
socketService . offChannelEvents ( ) ;
2025-08-31 14:02:44 +05:30
} catch ( _ ) { }
} ) ;
}
2025-08-21 15:08:57 +05:30
// Prepare streaming and background handling BEFORE image generation
2025-08-10 01:20:45 +05:30
final chunkedStream = StreamChunker . chunkStream (
stream ,
enableChunking: true ,
minChunkSize: 5 ,
maxChunkLength: 3 ,
delayBetweenChunks: const Duration ( milliseconds: 15 ) ,
) ;
2025-08-16 20:27:44 +05:30
// Create a stream controller for persistent handling
final persistentController = StreamController < String > . broadcast ( ) ;
2025-08-21 14:37:49 +05:30
2025-08-16 20:27:44 +05:30
// Register stream with persistent service for app lifecycle handling
final persistentService = PersistentStreamingService ( ) ;
2025-08-21 15:08:57 +05:30
// Defer UI updates until images attach if image generation is enabled
bool deferUntilImagesAttached = imageGenerationEnabled ;
bool imagesAttached = ! imageGenerationEnabled ;
final StringBuffer prebuffer = StringBuffer ( ) ;
2025-08-16 20:27:44 +05:30
final streamId = persistentService . registerStream (
subscription: chunkedStream . listen (
( chunk ) {
2025-08-21 15:08:57 +05:30
// Buffer chunks until images are attached
if ( deferUntilImagesAttached & & ! imagesAttached ) {
prebuffer . write ( chunk ) ;
return ;
}
2025-08-16 20:27:44 +05:30
persistentController . add ( chunk ) ;
} ,
onDone: ( ) {
persistentController . close ( ) ;
} ,
onError: ( error ) {
persistentController . addError ( error ) ;
} ,
) ,
controller: persistentController ,
recoveryCallback: ( ) async {
// Recovery callback to restart streaming if interrupted
debugPrint ( ' DEBUG: Attempting to recover interrupted stream ' ) ;
// TODO: Implement stream recovery logic
} ,
metadata: {
' conversationId ' : activeConversation ? . id ,
' messageId ' : assistantMessageId ,
' modelId ' : selectedModel . id ,
} ,
) ;
2025-08-21 15:08:57 +05:30
// If image generation is enabled, trigger it BEFORE starting the SSE stream
if ( imageGenerationEnabled ) {
try {
debugPrint (
' DEBUG: Image generation enabled - triggering request (pre-stream) ' ,
) ;
final imageResponse = await api . generateImage ( prompt: message ) ;
// Extract image URLs or base64 data URIs from response
List < Map < String , dynamic > > extractGeneratedFiles ( dynamic resp ) {
final results = < Map < String , dynamic > > [ ] ;
// If it's already a list (e.g., list of URLs or file maps)
if ( resp is List ) {
for ( final item in resp ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
return results ;
}
if ( resp is ! Map ) return results ;
// Common patterns: { data: [ { url }, { b64_json } ] }
final data = resp [ ' data ' ] ;
if ( data is List ) {
for ( final item in data ) {
if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
} else if ( item is String & & item . isNotEmpty ) {
// Some servers may return a list of URLs
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
}
}
}
// Alternative patterns
final images = resp [ ' images ' ] ;
if ( images is List ) {
for ( final item in images ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
}
// Single fields
final singleUrl = resp [ ' url ' ] ;
if ( singleUrl is String & & singleUrl . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : singleUrl } ) ;
}
final singleB64 = resp [ ' b64_json ' ] ? ? resp [ ' b64 ' ] ;
if ( singleB64 is String & & singleB64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ singleB64 ' ,
} ) ;
}
return results ;
}
final generatedFiles = extractGeneratedFiles ( imageResponse ) ;
if ( generatedFiles . isNotEmpty ) {
debugPrint (
' DEBUG: Image generation returned ${ generatedFiles . length } file(s) (pre-stream) ' ,
) ;
// Attach images to the last assistant message (placeholder)
ref . read ( chatMessagesProvider . notifier ) . updateLastMessageWithFunction (
( ChatMessage m ) {
final currentFiles = m . files ? ? < Map < String , dynamic > > [ ] ;
return m . copyWith ( files: [ . . . currentFiles , . . . generatedFiles ] ) ;
} ,
) ;
// Save updated conversation with images before streaming content
await _saveConversationToServer ( ref ) ;
// Now that images are attached and persisted, allow streaming to flow
imagesAttached = true ;
if ( deferUntilImagesAttached & & prebuffer . isNotEmpty ) {
// Flush buffered chunks
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( prebuffer . toString ( ) ) ;
prebuffer . clear ( ) ;
}
} else {
debugPrint (
' DEBUG: No images found in generation response (pre-stream) ' ,
) ;
2025-08-31 14:02:44 +05:30
// Do not block streaming if no images are produced
imagesAttached = true ;
if ( deferUntilImagesAttached & & prebuffer . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( prebuffer . toString ( ) ) ;
prebuffer . clear ( ) ;
}
2025-08-21 15:08:57 +05:30
}
} catch ( e ) {
debugPrint ( ' DEBUG: Image generation failed (pre-stream): $ e ' ) ;
2025-08-31 14:02:44 +05:30
// Fail open: allow text streaming to continue
imagesAttached = true ;
if ( deferUntilImagesAttached & & prebuffer . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( prebuffer . toString ( ) ) ;
prebuffer . clear ( ) ;
}
2025-08-21 15:08:57 +05:30
}
}
// For built-in web search, the status will be updated when function calls are detected
// in the streaming response. Manual status update is not needed here.
// (moved above) streaming registration is already set up
2025-08-19 13:09:40 +05:30
// Track web search status
bool isSearching = false ;
2025-08-21 14:37:49 +05:30
2025-08-16 20:27:44 +05:30
final streamSubscription = persistentController . stream . listen (
2025-08-10 01:20:45 +05:30
( chunk ) {
2025-08-31 14:02:44 +05:30
var effectiveChunk = chunk ;
2025-08-19 13:09:40 +05:30
// Check for web search indicators in the stream
if ( webSearchEnabled & & ! isSearching ) {
// Check if this is the start of web search
2025-08-21 14:37:49 +05:30
if ( chunk . contains ( ' [SEARCHING] ' ) | |
chunk . contains ( ' Searching the web ' ) | |
2025-08-19 13:09:40 +05:30
chunk . contains ( ' web search ' ) ) {
isSearching = true ;
// Update the message to show search status
2025-08-21 14:37:49 +05:30
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( message ) = > message . copyWith (
content: ' 🔍 Searching the web... ' ,
metadata: { ' webSearchActive ' : true } ,
) ,
) ;
2025-08-19 13:09:40 +05:30
return ; // Don't append this chunk
}
}
2025-08-21 14:37:49 +05:30
2025-08-19 13:09:40 +05:30
// Check if web search is complete
2025-08-21 14:37:49 +05:30
if ( isSearching & &
( chunk . contains ( ' [/SEARCHING] ' ) | |
chunk . contains ( ' Search complete ' ) ) ) {
2025-08-19 13:09:40 +05:30
isSearching = false ;
2025-08-31 14:02:44 +05:30
// Only update metadata; keep content to avoid flicker/indicator reappearing
2025-08-21 14:37:49 +05:30
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( message ) = > message . copyWith (
metadata: { ' webSearchActive ' : false } ,
) ,
) ;
2025-08-31 14:02:44 +05:30
// Strip markers from this chunk and continue processing
effectiveChunk = effectiveChunk
. replaceAll ( ' [SEARCHING] ' , ' ' )
. replaceAll ( ' [/SEARCHING] ' , ' ' ) ;
2025-08-19 13:09:40 +05:30
}
2025-08-21 14:37:49 +05:30
2025-08-21 15:08:57 +05:30
// If we buffered chunks before images attached, flush once
if ( deferUntilImagesAttached & & ! imagesAttached ) {
// do nothing; still waiting
return ;
}
2025-08-31 14:02:44 +05:30
// Regular content - append to message (markers removed above)
if ( effectiveChunk . trim ( ) . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( effectiveChunk ) ;
2025-08-19 13:09:40 +05:30
}
2025-08-10 01:20:45 +05:30
} ,
onDone: ( ) async {
2025-08-16 20:27:44 +05:30
// Unregister from persistent service
persistentService . unregisterStream ( streamId ) ;
2025-08-31 19:07:19 +05:30
// Stop socket events now that streaming finished only for SSE-driven streams
if ( socketService ! = null & & suppressSocketContent = = true ) {
try { socketService . offChatEvents ( ) ; } catch ( _ ) { }
}
// Allow socket content again for future sessions (harmless if already false)
suppressSocketContent = false ;
2025-09-01 16:28:49 +05:30
// If this path was SSE-driven (no background socket), finish now.
// Otherwise keep streaming state until socket/dynamic channel signals done.
if ( socketService = = null ) {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
2025-08-10 01:20:45 +05:30
// Send chat completed notification to OpenWebUI
final messages = ref . read ( chatMessagesProvider ) ;
if ( messages . isNotEmpty & & activeConversation ! = null ) {
final lastMessage = messages . last ;
if ( lastMessage . role = = ' assistant ' ) {
try {
// Convert messages to the format expected by /api/chat/completed
final List < Map < String , dynamic > > formattedMessages = [ ] ;
for ( final msg in messages ) {
final messageMap = < String , dynamic > {
' id ' : msg . id ,
' role ' : msg . role ,
' content ' : msg . content ,
' timestamp ' : msg . timestamp . millisecondsSinceEpoch ~ / 1000 ,
} ;
2025-08-12 13:07:10 +05:30
// For assistant messages, add completion details
if ( msg . role = = ' assistant ' ) {
messageMap [ ' model ' ] = selectedModel . id ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Add mock usage data if not available (OpenWebUI expects this)
if ( msg . usage ! = null ) {
messageMap [ ' usage ' ] = msg . usage ;
} else if ( msg = = messages . last ) {
// Add basic usage for the last assistant message
messageMap [ ' usage ' ] = {
' prompt_tokens ' : 10 ,
' completion_tokens ' : msg . content . split ( ' ' ) . length ,
' total_tokens ' : 10 + msg . content . split ( ' ' ) . length ,
} ;
}
2025-08-10 01:20:45 +05:30
}
formattedMessages . add ( messageMap ) ;
}
// Send chat completed notification to OpenWebUI first
2025-08-31 19:07:19 +05:30
// Fire-and-forget with a short timeout; non-critical endpoint
2025-08-10 01:20:45 +05:30
try {
2025-08-31 19:07:19 +05:30
unawaited (
api
. sendChatCompleted (
chatId: activeConversation . id ,
messageId:
assistantMessageId , // Use message ID from response
messages: formattedMessages ,
model: selectedModel . id ,
modelItem: modelItem , // Include model metadata
sessionId: sessionId , // Include session ID
)
. timeout ( const Duration ( seconds: 3 ) )
. catchError ( ( _ ) { } ) ,
2025-08-10 01:20:45 +05:30
) ;
2025-08-31 19:07:19 +05:30
} catch ( _ ) {
// Ignore
2025-08-10 01:20:45 +05:30
}
2025-08-25 22:14:40 +05:30
// Fetch the latest conversation state
2025-08-21 14:37:49 +05:30
2025-08-10 01:20:45 +05:30
try {
2025-08-12 13:07:10 +05:30
// Quick fetch to get the current state - no waiting for title generation
2025-08-10 01:20:45 +05:30
final updatedConv = await api . getConversation (
activeConversation . id ,
) ;
2025-08-12 13:07:10 +05:30
// Check if we should update the title (only on first response and if server has one)
2025-08-10 01:20:45 +05:30
final shouldUpdateTitle =
messages . length < = 2 & &
updatedConv . title ! = ' New Chat ' & &
updatedConv . title . isNotEmpty ;
2025-08-21 14:37:49 +05:30
2025-08-10 01:20:45 +05:30
if ( shouldUpdateTitle ) {
// Ensure the title is reasonable (not too long)
final cleanTitle = updatedConv . title . length > 100
? ' ${ updatedConv . title . substring ( 0 , 100 ) } ... '
: updatedConv . title ;
// Update the conversation with title and combined messages
final updatedConversation = activeConversation . copyWith (
title: cleanTitle ,
updatedAt: DateTime . now ( ) ,
) ;
ref . read ( activeConversationProvider . notifier ) . state =
updatedConversation ;
} else {
2025-08-31 19:07:19 +05:30
// Keep local messages and only refresh conversations list
ref . invalidate ( conversationsProvider ) ;
2025-08-10 01:20:45 +05:30
}
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-25 22:14:40 +05:30
// Removed post-assistant title trigger/background check; handled right after user message
2025-08-10 01:20:45 +05:30
} catch ( e ) {
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-10 01:20:45 +05:30
}
} catch ( e ) {
// Continue without failing the entire process
// Note: Conversation still syncs via _saveConversationToServer
2025-08-12 13:07:10 +05:30
// Streaming already marked as complete when stream ended
2025-08-10 01:20:45 +05:30
}
}
}
// 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 ) ) ;
2025-08-12 13:07:10 +05:30
await _saveConversationToServer ( ref ) ;
2025-08-21 14:37:49 +05:30
2025-08-25 22:14:40 +05:30
// Removed post-assistant image generation; images are handled immediately after user message
2025-08-10 01:20:45 +05:30
} ,
onError: ( error ) {
// Mark streaming as complete on error
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
2025-08-31 19:07:19 +05:30
// Stop socket events to avoid duplicates after error (only for SSE-driven)
if ( socketService ! = null & & suppressSocketContent = = true ) {
try { socketService . offChatEvents ( ) ; } catch ( _ ) { }
}
2025-08-10 01:20:45 +05:30
// Special handling for Socket.IO streaming failures
// These indicate the server generated a response but we couldn't stream it
if ( error . toString ( ) . contains (
' Socket.IO streaming not fully implemented ' ,
) ) {
// Don't remove the message - let the server content replacement handle it
// The onDone callback will fetch the actual response from the server
return ; // Exit early to avoid removing the message
}
// Handle streaming error - remove the assistant message placeholder for other errors
ref . read ( chatMessagesProvider . notifier ) . removeLastMessage ( ) ;
// Handle different types of errors
if ( error . toString ( ) . contains ( ' 400 ' ) ) {
// Bad request errors - likely malformed request format
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
2025-08-21 14:37:49 +05:30
content: ''' ⚠️ **Message Format Error**
2025-08-16 20:27:44 +05:30
This might be because:
• Image attachment couldn ' t be processed
• Request format incompatible with selected model
• Message contains unsupported content
2025-08-10 01:20:45 +05:30
2025-08-16 20:27:44 +05:30
* * 💡 Solutions: * *
• Long press this message and select " Retry "
• Try removing attachments and resending
• Switch to a different model and retry
2025-08-10 01:20:45 +05:30
2025-08-16 20:27:44 +05:30
* Long press this message to access retry options . * ''' ,
2025-08-10 01:20:45 +05:30
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
} else if ( error . toString ( ) . contains ( ' 401 ' ) | |
error . toString ( ) . contains ( ' 403 ' ) ) {
// Authentication errors - clear auth state and redirect to login
ref . invalidate ( authStateManagerProvider ) ;
} else if ( error . toString ( ) . contains ( ' 500 ' ) ) {
// Server errors - add user-friendly error message
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
2025-08-21 14:37:49 +05:30
content: ''' ⚠️ **Server Error**
2025-08-16 20:27:44 +05:30
This usually means:
• OpenWebUI server is experiencing issues
• Selected model might be unavailable
• Temporary connection problem
* * 💡 Solutions: * *
• Long press this message and select " Retry "
• Wait a moment and try again
• Switch to a different model
• Check with your server administrator
* Long press this message to access retry options . * ''' ,
2025-08-10 01:20:45 +05:30
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
} else if ( error . toString ( ) . contains ( ' timeout ' ) ) {
// Timeout errors
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
2025-08-21 14:37:49 +05:30
content: ''' ⏱️ **Request Timeout**
2025-08-16 20:27:44 +05:30
This might be because:
• Server taking too long to respond
• Internet connection is slow
• Model processing a complex request
* * 💡 Solutions: * *
• Long press this message and select " Retry "
• Try a shorter message
• Check your internet connection
• Switch to a faster model
* Long press this message to access retry options . * ''' ,
2025-08-10 01:20:45 +05:30
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
}
// Don't throw the error to prevent unhandled exceptions
// The error message has been added to the chat
} ,
) ;
// Register the stream subscription for proper cleanup
ref
. read ( chatMessagesProvider . notifier )
. setMessageStream ( streamSubscription ) ;
} catch ( e ) {
// Handle error - remove the assistant message placeholder
ref . read ( chatMessagesProvider . notifier ) . removeLastMessage ( ) ;
// Add user-friendly error message instead of rethrowing
if ( e . toString ( ) . contains ( ' 400 ' ) ) {
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
content:
''' ⚠️ There was an issue with the message format. This might be because:
• The image attachment couldn ' t be processed
• The request format is incompatible with the selected model
• The message contains unsupported content
Please try sending the message again , or try without attachments . ''' ,
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
} else if ( e . toString ( ) . contains ( ' 500 ' ) ) {
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
content:
' ⚠️ Unable to connect to the AI model. The server returned an error (500). \n \n '
' This is typically a server-side issue. Please try again or contact your administrator. ' ,
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
} else if ( e . toString ( ) . contains ( ' 404 ' ) ) {
debugPrint ( ' DEBUG: Model or endpoint not found (404) ' ) ;
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
content:
' 🤖 The selected AI model doesn \' t seem to be available. \n \n '
' Please try selecting a different model or check with your administrator. ' ,
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
} else {
// For other errors, provide a generic message and rethrow
final errorMessage = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
content:
' ❌ An unexpected error occurred while processing your request. \n \n '
' Please try again or check your connection. ' ,
timestamp: DateTime . now ( ) ,
isStreaming: false ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( errorMessage ) ;
}
}
}
2025-08-16 17:36:02 +05:30
// Trigger title generation using the dedicated endpoint
Future < void > _triggerTitleGeneration (
dynamic ref ,
String conversationId ,
List < Map < String , dynamic > > messages ,
String model ,
) async {
try {
final api = ref . read ( apiServiceProvider ) ;
if ( api = = null ) return ;
2025-08-21 14:37:49 +05:30
2025-08-16 17:36:02 +05:30
// Call the title generation endpoint
final generatedTitle = await api . generateTitle (
conversationId: conversationId ,
messages: messages ,
model: model ,
) ;
2025-08-21 14:37:49 +05:30
if ( generatedTitle ! = null & &
generatedTitle . isNotEmpty & &
generatedTitle ! = ' New Chat ' ) {
2025-08-16 17:36:02 +05:30
// 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 ;
2025-08-21 14:37:49 +05:30
2025-08-16 17:36:02 +05:30
// Save the updated title to the server
try {
final currentMessages = ref . read ( chatMessagesProvider ) ;
await api . updateConversationWithMessages (
conversationId ,
currentMessages ,
title: generatedTitle ,
model: model ,
) ;
} catch ( e ) {
2025-08-21 19:11:17 +05:30
// Handle title save errors silently
2025-08-16 17:36:02 +05:30
}
2025-08-21 14:37:49 +05:30
2025-08-16 17:36:02 +05:30
// 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 ) ;
}
}
2025-08-12 13:07:10 +05:30
// Background function to check for title updates without blocking UI
2025-08-21 14:37:49 +05:30
Future < void > _checkForTitleInBackground (
dynamic ref ,
String conversationId ,
) async {
2025-08-12 13:07:10 +05:30
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 ) ) ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Try a few times with increasing delays
for ( int i = 0 ; i < 3 ; i + + ) {
try {
final updatedConv = await api . getConversation ( conversationId ) ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
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 ) . state = updated ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Refresh the conversations list
ref . invalidate ( conversationsProvider ) ;
}
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
return ; // Title found, stop checking
}
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// 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 ) {
2025-08-21 19:11:17 +05:30
// Handle background title check errors silently
2025-08-12 13:07:10 +05:30
}
}
2025-08-10 01:20:45 +05:30
// Save current conversation to OpenWebUI server
Future < void > _saveConversationToServer ( dynamic ref ) async {
try {
final api = ref . read ( apiServiceProvider ) ;
final messages = ref . read ( chatMessagesProvider ) ;
final activeConversation = ref . read ( activeConversationProvider ) ;
final selectedModel = ref . read ( selectedModelProvider ) ;
if ( api = = null | | messages . isEmpty | | activeConversation = = null ) {
return ;
}
2025-08-21 15:45:07 +05:30
// Check if the last assistant message is truly empty (no text and no files)
2025-08-10 01:20:45 +05:30
final lastMessage = messages . last ;
2025-08-21 15:45:07 +05:30
if ( lastMessage . role = = ' assistant ' & &
lastMessage . content . trim ( ) . isEmpty & &
( lastMessage . files = = null | | lastMessage . files ! . isEmpty ) & &
( lastMessage . attachmentIds = = null | |
lastMessage . attachmentIds ! . isEmpty ) ) {
2025-08-10 01:20:45 +05:30
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
2025-08-17 16:11:19 +05:30
// Adding a small delay to prevent rapid invalidations that could cause duplicates
Future . delayed ( const Duration ( milliseconds: 100 ) , ( ) {
2025-08-28 14:45:46 +05:30
try {
if ( ref . mounted = = true ) {
ref . invalidate ( conversationsProvider ) ;
}
} catch ( _ ) { }
2025-08-17 16:11:19 +05:30
} ) ;
2025-08-10 01:20:45 +05:30
} catch ( e ) {
// Fallback to local storage
await _saveConversationLocally ( ref ) ;
}
}
// Fallback: Save current conversation to local storage
Future < void > _saveConversationLocally ( dynamic ref ) async {
try {
final storage = ref . read ( optimizedStorageServiceProvider ) ;
final messages = ref . read ( chatMessagesProvider ) ;
final activeConversation = ref . read ( activeConversationProvider ) ;
if ( messages . isEmpty ) return ;
// Create or update conversation locally
final conversation =
activeConversation ? ?
Conversation (
id: const Uuid ( ) . v4 ( ) ,
title: _generateConversationTitle ( messages ) ,
createdAt: DateTime . now ( ) ,
updatedAt: DateTime . now ( ) ,
messages: messages ,
) ;
final updatedConversation = conversation . copyWith (
messages: messages ,
updatedAt: DateTime . now ( ) ,
) ;
2025-08-12 13:07:10 +05:30
// Store conversation locally using the storage service's actual methods
final conversationsJson = await storage . getString ( ' conversations ' ) ? ? ' [] ' ;
final List < dynamic > conversations = jsonDecode ( conversationsJson ) ;
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
// Find and update or add the conversation
2025-08-21 14:37:49 +05:30
final existingIndex = conversations . indexWhere (
( c ) = > c [ ' id ' ] = = updatedConversation . id ,
) ;
2025-08-12 13:07:10 +05:30
if ( existingIndex > = 0 ) {
conversations [ existingIndex ] = updatedConversation . toJson ( ) ;
2025-08-10 01:20:45 +05:30
} else {
2025-08-12 13:07:10 +05:30
conversations . add ( updatedConversation . toJson ( ) ) ;
2025-08-10 01:20:45 +05:30
}
2025-08-21 14:37:49 +05:30
2025-08-12 13:07:10 +05:30
await storage . setString ( ' conversations ' , jsonEncode ( conversations ) ) ;
ref . read ( activeConversationProvider . notifier ) . state = updatedConversation ;
2025-08-10 01:20:45 +05:30
ref . invalidate ( conversationsProvider ) ;
} catch ( e ) {
2025-08-21 19:11:17 +05:30
// Handle local storage errors silently
2025-08-10 01:20:45 +05:30
}
}
String _generateConversationTitle ( List < ChatMessage > messages ) {
final firstUserMessage = messages . firstWhere (
( msg ) = > msg . role = = ' user ' ,
orElse: ( ) = > ChatMessage (
id: ' ' ,
role: ' user ' ,
content: ' New Chat ' ,
timestamp: DateTime . now ( ) ,
) ,
) ;
// Use first 50 characters of the first user message as title
final title = firstUserMessage . content . length > 50
? ' ${ firstUserMessage . content . substring ( 0 , 50 ) } ... '
: firstUserMessage . content ;
return title . isEmpty ? ' New Chat ' : title ;
}
// Pin/Unpin conversation
Future < void > pinConversation (
WidgetRef ref ,
String conversationId ,
bool pinned ,
) async {
try {
final api = ref . read ( apiServiceProvider ) ;
if ( api = = null ) throw Exception ( ' No API service available ' ) ;
await api . pinConversation ( conversationId , pinned ) ;
// Refresh conversations list to reflect the change
ref . invalidate ( conversationsProvider ) ;
// Update active conversation if it's the one being pinned
final activeConversation = ref . read ( activeConversationProvider ) ;
if ( activeConversation ? . id = = conversationId ) {
ref . read ( activeConversationProvider . notifier ) . state = activeConversation !
. copyWith ( pinned: pinned ) ;
}
} catch ( e ) {
debugPrint ( ' Error ${ pinned ? ' pinning ' : ' unpinning ' } conversation: $ e ' ) ;
rethrow ;
}
}
// Archive/Unarchive conversation
Future < void > archiveConversation (
WidgetRef ref ,
String conversationId ,
bool archived ,
) async {
final api = ref . read ( apiServiceProvider ) ;
final activeConversation = ref . read ( activeConversationProvider ) ;
// Update local state first
if ( activeConversation ? . id = = conversationId & & archived ) {
ref . read ( activeConversationProvider . notifier ) . state = null ;
ref . read ( chatMessagesProvider . notifier ) . clearMessages ( ) ;
}
try {
if ( api = = null ) throw Exception ( ' No API service available ' ) ;
await api . archiveConversation ( conversationId , archived ) ;
// Refresh conversations list to reflect the change
ref . invalidate ( conversationsProvider ) ;
} catch ( e ) {
debugPrint (
' Error ${ archived ? ' archiving ' : ' unarchiving ' } conversation: $ e ' ,
) ;
// If server operation failed and we archived locally, restore the conversation
if ( activeConversation ? . id = = conversationId & & archived ) {
ref . read ( activeConversationProvider . notifier ) . state = activeConversation ;
// Messages will be restored through the listener
}
rethrow ;
}
}
// Share conversation
Future < String ? > shareConversation ( WidgetRef ref , String conversationId ) async {
try {
final api = ref . read ( apiServiceProvider ) ;
if ( api = = null ) throw Exception ( ' No API service available ' ) ;
final shareId = await api . shareConversation ( conversationId ) ;
// Refresh conversations list to reflect the change
ref . invalidate ( conversationsProvider ) ;
return shareId ;
} catch ( e ) {
debugPrint ( ' Error sharing conversation: $ e ' ) ;
rethrow ;
}
}
// Clone conversation
Future < void > cloneConversation ( WidgetRef ref , String conversationId ) async {
try {
final api = ref . read ( apiServiceProvider ) ;
if ( api = = null ) throw Exception ( ' No API service available ' ) ;
final clonedConversation = await api . cloneConversation ( conversationId ) ;
// Set the cloned conversation as active
ref . read ( activeConversationProvider . notifier ) . state = clonedConversation ;
// Load messages through the listener mechanism
// The ChatMessagesNotifier will automatically load messages when activeConversation changes
// Refresh conversations list to show the new conversation
ref . invalidate ( conversationsProvider ) ;
} catch ( e ) {
debugPrint ( ' Error cloning conversation: $ e ' ) ;
rethrow ;
}
}
// Regenerate last message
2025-08-21 16:19:21 +05:30
final regenerateLastMessageProvider = Provider < Future < void > Function ( ) > ( ( ref ) {
2025-08-10 01:20:45 +05:30
return ( ) async {
final messages = ref . read ( chatMessagesProvider ) ;
if ( messages . length < 2 ) return ;
// Find last user message with proper bounds checking
ChatMessage ? lastUserMessage ;
2025-08-21 15:45:07 +05:30
// Detect if last assistant message had generated images
final ChatMessage ? lastAssistantMessage = messages . isNotEmpty
? messages . last
: null ;
final bool lastAssistantHadImages =
lastAssistantMessage ! = null & &
lastAssistantMessage . role = = ' assistant ' & &
( lastAssistantMessage . files ? . any ( ( f ) = > f [ ' type ' ] = = ' image ' ) = = true ) ;
2025-08-10 01:20:45 +05:30
for ( int i = messages . length - 2 ; i > = 0 & & i < messages . length ; i - - ) {
if ( i > = 0 & & messages [ i ] . role = = ' user ' ) {
lastUserMessage = messages [ i ] ;
break ;
}
}
if ( lastUserMessage = = null ) return ;
// Remove last assistant message
ref . read ( chatMessagesProvider . notifier ) . removeLastMessage ( ) ;
2025-08-21 15:45:07 +05:30
// If previous assistant was image-only or had images, regenerate images instead of text
if ( lastAssistantHadImages ) {
final api = ref . read ( apiServiceProvider ) ;
final selectedModel = ref . read ( selectedModelProvider ) ;
if ( api = = null | | selectedModel = = null ) return ;
// Add assistant placeholder
final placeholder = ChatMessage (
id: const Uuid ( ) . v4 ( ) ,
role: ' assistant ' ,
content: ' ' ,
timestamp: DateTime . now ( ) ,
model: selectedModel . name ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( placeholder ) ;
try {
final imageResponse = await api . generateImage (
prompt: lastUserMessage . content ,
) ;
List < Map < String , dynamic > > extractGeneratedFiles ( dynamic resp ) {
final results = < Map < String , dynamic > > [ ] ;
if ( resp is List ) {
for ( final item in resp ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
return results ;
}
if ( resp is ! Map ) return results ;
final data = resp [ ' data ' ] ;
if ( data is List ) {
for ( final item in data ) {
if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
} else if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
}
}
}
final images = resp [ ' images ' ] ;
if ( images is List ) {
for ( final item in images ) {
if ( item is String & & item . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : item } ) ;
} else if ( item is Map ) {
final url = item [ ' url ' ] ;
final b64 = item [ ' b64_json ' ] ? ? item [ ' b64 ' ] ;
if ( url is String & & url . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
} else if ( b64 is String & & b64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ b64 ' ,
} ) ;
}
}
}
}
final singleUrl = resp [ ' url ' ] ;
if ( singleUrl is String & & singleUrl . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : singleUrl } ) ;
}
final singleB64 = resp [ ' b64_json ' ] ? ? resp [ ' b64 ' ] ;
if ( singleB64 is String & & singleB64 . isNotEmpty ) {
results . add ( {
' type ' : ' image ' ,
' url ' : ' data:image/png;base64, $ singleB64 ' ,
} ) ;
}
return results ;
}
final generatedFiles = extractGeneratedFiles ( imageResponse ) ;
if ( generatedFiles . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( ChatMessage m ) = >
m . copyWith ( files: generatedFiles , isStreaming: false ) ,
) ;
await _saveConversationToServer ( ref ) ;
// Trigger title generation after image-only regenerate
final activeConv = ref . read ( activeConversationProvider ) ;
if ( activeConv ! = null ) {
final currentMsgs = ref . read ( chatMessagesProvider ) ;
final List < Map < String , dynamic > > formatted = [ ] ;
for ( final msg in currentMsgs ) {
formatted . add ( {
' id ' : msg . id ,
' role ' : msg . role ,
' content ' : msg . content ,
' timestamp ' : msg . timestamp . millisecondsSinceEpoch ~ / 1000 ,
} ) ;
}
_triggerTitleGeneration (
ref ,
activeConv . id ,
formatted ,
selectedModel . id ,
) ;
}
} else {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} catch ( e ) {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
return ;
}
// Resend the message via normal flow
2025-08-10 01:20:45 +05:30
await _sendMessageInternal (
ref ,
lastUserMessage . content ,
lastUserMessage . attachmentIds ,
) ;
} ;
} ) ;
// Stop generation provider
final stopGenerationProvider = Provider < void Function ( ) > ( ( ref ) {
return ( ) {
// This would need to be implemented with proper cancellation support
// For now, just mark streaming as complete
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
} ;
} ) ;
2025-08-31 14:02:44 +05:30
// ========== Tool Servers (OpenAPI) Helpers ==========
Future < List < Map < String , dynamic > > > _resolveToolServers (
List rawServers ,
dynamic api ,
) async {
final List < Map < String , dynamic > > resolved = [ ] ;
for ( final s in rawServers ) {
try {
if ( s is ! Map ) continue ;
final cfg = s [ ' config ' ] ;
if ( cfg is Map & & cfg [ ' enable ' ] ! = true ) continue ;
final url = ( s [ ' url ' ] ? ? ' ' ) . toString ( ) ;
final path = ( s [ ' path ' ] ? ? ' ' ) . toString ( ) ;
if ( url . isEmpty | | path . isEmpty ) continue ;
final fullUrl = path . contains ( ' :// ' )
? path
: ' $ url ${ path . startsWith ( ' / ' ) ? ' ' : ' / ' } $ path ' ;
// Fetch OpenAPI spec (supports YAML/JSON)
Map < String , dynamic > ? openapi ;
try {
final resp = await api . dio . get ( fullUrl ) ;
final ct = resp . headers . map [ ' content-type ' ] ? . join ( ' , ' ) ? ? ' ' ;
if ( fullUrl . toLowerCase ( ) . endsWith ( ' .yaml ' ) | |
fullUrl . toLowerCase ( ) . endsWith ( ' .yml ' ) | |
ct . contains ( ' yaml ' ) ) {
final doc = yaml . loadYaml ( resp . data ) ;
openapi = json . decode ( json . encode ( doc ) ) as Map < String , dynamic > ;
} else {
final data = resp . data ;
if ( data is Map < String , dynamic > ) {
openapi = data ;
} else if ( data is String ) {
openapi = json . decode ( data ) as Map < String , dynamic > ;
}
}
} catch ( _ ) {
continue ;
}
if ( openapi = = null ) continue ;
// Convert OpenAPI to tool specs
final specs = _convertOpenApiToToolPayload ( openapi ) ;
resolved . add ( {
' url ' : url ,
' openapi ' : openapi ,
' info ' : openapi [ ' info ' ] ,
' specs ' : specs ,
} ) ;
} catch ( _ ) {
continue ;
}
}
return resolved ;
}
Map < String , dynamic > ? _resolveRef ( String ref , Map < String , dynamic > ? components ) {
// e.g., #/components/schemas/MySchema
if ( ! ref . startsWith ( ' #/ ' ) ) return null ;
final parts = ref . split ( ' / ' ) ;
if ( parts . length < 4 ) return null ;
final type = parts [ 2 ] ; // schemas
final name = parts [ 3 ] ;
final section = components ? [ type ] ;
if ( section is Map < String , dynamic > ) {
final schema = section [ name ] ;
if ( schema is Map < String , dynamic > ) return Map < String , dynamic > . from ( schema ) ;
}
return null ;
}
Map < String , dynamic > _resolveSchemaSimple (
dynamic schema ,
Map < String , dynamic > ? components ,
) {
if ( schema is Map < String , dynamic > ) {
if ( schema . containsKey ( r'$ref' ) ) {
final ref = schema [ r'$ref' ] as String ;
final resolved = _resolveRef ( ref , components ) ;
if ( resolved ! = null ) return _resolveSchemaSimple ( resolved , components ) ;
}
final type = schema [ ' type ' ] ;
final out = < String , dynamic > { } ;
if ( type is String ) {
out [ ' type ' ] = type ;
if ( schema [ ' description ' ] ! = null ) out [ ' description ' ] = schema [ ' description ' ] ;
if ( type = = ' object ' ) {
out [ ' properties ' ] = < String , dynamic > { } ;
if ( schema [ ' required ' ] is List ) out [ ' required ' ] = List . from ( schema [ ' required ' ] ) ;
final props = schema [ ' properties ' ] ;
if ( props is Map < String , dynamic > ) {
props . forEach ( ( k , v ) {
out [ ' properties ' ] [ k ] = _resolveSchemaSimple ( v , components ) ;
} ) ;
}
} else if ( type = = ' array ' ) {
out [ ' items ' ] = _resolveSchemaSimple ( schema [ ' items ' ] , components ) ;
}
}
return out ;
}
return < String , dynamic > { } ;
}
List < Map < String , dynamic > > _convertOpenApiToToolPayload ( Map < String , dynamic > openApi ) {
final tools = < Map < String , dynamic > > [ ] ;
final paths = openApi [ ' paths ' ] ;
if ( paths is ! Map ) return tools ;
paths . forEach ( ( path , methods ) {
if ( methods is ! Map ) return ;
methods . forEach ( ( method , operation ) {
if ( operation is Map & & operation [ ' operationId ' ] ! = null ) {
final tool = < String , dynamic > {
' name ' : operation [ ' operationId ' ] ,
' description ' : operation [ ' description ' ] ? ? operation [ ' summary ' ] ? ? ' No description available. ' ,
' parameters ' : {
' type ' : ' object ' ,
' properties ' : < String , dynamic > { } ,
' required ' : < dynamic > [ ] ,
} ,
} ;
// Parameters
final params = operation [ ' parameters ' ] ;
if ( params is List ) {
for ( final p in params ) {
if ( p is Map ) {
final name = p [ ' name ' ] ;
final schema = p [ ' schema ' ] as Map ? ;
if ( name ! = null & & schema ! = null ) {
String desc = ( schema [ ' description ' ] ? ? p [ ' description ' ] ? ? ' ' ) . toString ( ) ;
if ( schema [ ' enum ' ] is List ) {
desc = ' $ desc . Possible values: ${ ( schema [ ' enum ' ] as List ) . join ( ' , ' ) } ' ;
}
tool [ ' parameters ' ] [ ' properties ' ] [ name ] = {
' type ' : schema [ ' type ' ] ,
' description ' : desc ,
} ;
if ( p [ ' required ' ] = = true ) {
( tool [ ' parameters ' ] [ ' required ' ] as List ) . add ( name ) ;
}
}
}
}
}
// requestBody
final reqBody = operation [ ' requestBody ' ] ;
if ( reqBody is Map ) {
final content = reqBody [ ' content ' ] ;
if ( content is Map & & content [ ' application/json ' ] is Map ) {
final schema = content [ ' application/json ' ] [ ' schema ' ] ;
final resolved = _resolveSchemaSimple ( schema , openApi [ ' components ' ] as Map < String , dynamic > ? ) ;
if ( resolved [ ' properties ' ] is Map ) {
tool [ ' parameters ' ] [ ' properties ' ] = {
. . . tool [ ' parameters ' ] [ ' properties ' ] ,
. . . resolved [ ' properties ' ] as Map < String , dynamic > ,
} ;
if ( resolved [ ' required ' ] is List ) {
final req = Set . from ( tool [ ' parameters ' ] [ ' required ' ] as List )
. . addAll ( resolved [ ' required ' ] as List ) ;
tool [ ' parameters ' ] [ ' required ' ] = req . toList ( ) ;
}
} else if ( resolved [ ' type ' ] = = ' array ' ) {
tool [ ' parameters ' ] = resolved ;
}
}
}
tools . add ( tool ) ;
}
} ) ;
} ) ;
return tools ;
}