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 ' ;
2025-09-05 02:54:59 +05:30
import ' ../../../core/utils/tool_calls_parser.dart ' ;
2025-09-07 21:41:13 +05:30
import ' ../../../core/services/streaming_helper.dart ' ;
2025-08-10 01:20:45 +05:30
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-09-07 23:17:26 +05:30
import ' ../../../core/utils/inactivity_watchdog.dart ' ;
2025-08-17 16:11:19 +05:30
import ' ../services/reviewer_mode_service.dart ' ;
2025-09-01 23:41:22 +05:30
import ' ../../../shared/services/tasks/task_queue.dart ' ;
2025-09-07 21:41:13 +05:30
import ' ../../tools/providers/tools_providers.dart ' ;
import ' dart:async ' ;
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-09-08 01:15:31 +05:30
// Whether the chat composer currently has focus
final composerHasFocusProvider = StateProvider < bool > ( ( ref ) = > false ) ;
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 = [ ] ;
2025-09-07 23:17:26 +05:30
// Activity-based watchdog to prevent stuck typing indicator
InactivityWatchdog ? _typingWatchdog ;
2025-08-10 01:20:45 +05:30
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 [ ] ;
2025-09-07 11:13:05 +05:30
// Primary rule: adopt server messages when there are strictly more of them.
2025-08-26 11:21:26 +05:30
if ( serverMessages . length > state . length ) {
state = serverMessages ;
2025-09-07 11:13:05 +05:30
return ;
}
// Secondary rule: if counts are equal but the last assistant message grew,
// adopt the server copy to recover from missed socket events.
if ( serverMessages . isNotEmpty & & state . isNotEmpty ) {
final serverLast = serverMessages . last ;
final localLast = state . last ;
final serverText = serverLast . content . trim ( ) ;
final localText = localLast . content . trim ( ) ;
final sameLastId = serverLast . id = = localLast . id ;
final isAssistant = serverLast . role = = ' assistant ' ;
2025-09-07 21:41:13 +05:30
final serverHasMore =
serverText . isNotEmpty & & serverText . length > localText . length ;
final localEmptyButServerHas =
localText . isEmpty & & serverText . isNotEmpty ;
if ( sameLastId & &
isAssistant & &
( serverHasMore | | localEmptyButServerHas ) ) {
2025-09-07 11:13:05 +05:30
state = serverMessages ;
return ;
}
2025-08-26 11:21:26 +05:30
}
2025-08-10 01:20:45 +05:30
}
return ;
}
// Cancel any existing message stream when switching conversations
_cancelMessageStream ( ) ;
2025-09-05 23:08:23 +05:30
// Also cancel typing guard on conversation switch
_cancelTypingGuard ( ) ;
2025-08-10 01:20:45 +05:30
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-09-05 23:08:23 +05:30
void _cancelTypingGuard ( ) {
2025-09-07 23:17:26 +05:30
_typingWatchdog ? . stop ( ) ;
_typingWatchdog = null ;
2025-09-05 23:08:23 +05:30
}
void _scheduleTypingGuard ( { Duration ? timeout } ) {
// Default timeout tuned to balance long tool gaps and UX
final effectiveTimeout = timeout ? ? const Duration ( seconds: 25 ) ;
2025-09-07 23:17:26 +05:30
_typingWatchdog ? ? = InactivityWatchdog (
window: effectiveTimeout ,
onTimeout: ( ) async {
try {
if ( state . isEmpty ) return ;
final last = state . last ;
// Still the same streaming message and no finish signal
if ( last . role = = ' assistant ' & & last . isStreaming ) {
// Attempt a soft recovery: if content is still empty, try fetching final content from server
if ( ( last . content ) . trim ( ) . isEmpty ) {
try {
final apiSvc = _ref . read ( apiServiceProvider ) ;
final activeConv = _ref . read ( activeConversationProvider ) ;
final msgId = last . id ;
final chatId = activeConv ? . id ;
if ( apiSvc ! = null & & chatId ! = null & & chatId . isNotEmpty ) {
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 ) {
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 ' ] ;
2025-09-05 23:08:23 +05:30
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 ) {
2025-09-07 23:17:26 +05:30
content = ( textItem as Map ) [ ' text ' ] ? . toString ( ) ? ? ' ' ;
}
}
}
}
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 as Map ) [ ' text ' ] ? . toString ( ) ? ? ' ' ;
}
2025-09-05 23:08:23 +05:30
}
}
}
}
}
2025-09-07 23:17:26 +05:30
if ( content . isNotEmpty ) {
replaceLastMessageContent ( content ) ;
}
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
} catch ( _ ) { }
}
// Regardless of fetch result, ensure UI is not stuck
finishStreaming ( ) ;
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
} finally {
_cancelTypingGuard ( ) ;
2025-09-05 23:08:23 +05:30
}
2025-09-07 23:17:26 +05:30
} ,
) ;
_typingWatchdog ! . setWindow ( effectiveTimeout ) ;
_typingWatchdog ! . ping ( ) ;
2025-09-05 23:08:23 +05:30
}
void _touchStreamingActivity ( ) {
// Keep guard alive while streaming
if ( state . isNotEmpty ) {
final last = state . last ;
if ( last . role = = ' assistant ' & & last . isStreaming ) {
// Compute a dynamic timeout based on flow type
Duration timeout = const Duration ( seconds: 25 ) ;
try {
final meta = last . metadata ? ? const < String , dynamic > { } ;
final isBgFlow = ( meta [ ' backgroundFlow ' ] = = true ) ;
final isWebSearchFlow =
2025-09-07 21:41:13 +05:30
( meta [ ' webSearchFlow ' ] = = true ) | |
( meta [ ' webSearchActive ' ] = = true ) ;
2025-09-05 23:08:23 +05:30
final isImageGenFlow = ( meta [ ' imageGenerationFlow ' ] = = true ) ;
// Also consult global toggles if metadata not present
final globalWebSearch = _ref . read ( webSearchEnabledProvider ) ;
final webSearchAvailable = _ref . read ( webSearchAvailableProvider ) ;
final globalImageGen = _ref . read ( imageGenerationEnabledProvider ) ;
2025-09-07 23:17:26 +05:30
// Extend guard windows to tolerate long reasoning/tools (> 1 min)
2025-09-05 23:08:23 +05:30
if ( isWebSearchFlow | | ( globalWebSearch & & webSearchAvailable ) ) {
2025-09-07 23:17:26 +05:30
if ( timeout . inSeconds < 60 ) timeout = const Duration ( seconds: 60 ) ;
2025-09-05 23:08:23 +05:30
}
if ( isBgFlow ) {
2025-09-07 23:17:26 +05:30
// Background tools/dynamic channel can be much longer
if ( timeout . inSeconds < 120 ) timeout = const Duration ( seconds: 120 ) ;
2025-09-05 23:08:23 +05:30
}
if ( isImageGenFlow | | globalImageGen ) {
// Image generation tends to be the longest
2025-09-07 23:17:26 +05:30
if ( timeout . inSeconds < 180 ) timeout = const Duration ( seconds: 180 ) ;
2025-09-05 23:08:23 +05:30
}
} catch ( _ ) { }
_scheduleTypingGuard ( timeout: timeout ) ;
}
}
}
2025-09-01 20:26:29 +05:30
// Public wrapper to cancel the currently active stream (used by Stop)
void cancelActiveMessageStream ( ) {
_cancelMessageStream ( ) ;
}
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 ] ;
2025-09-05 23:08:23 +05:30
if ( message . role = = ' assistant ' & & message . isStreaming ) {
_touchStreamingActivity ( ) ;
}
2025-08-10 01:20:45 +05:30
}
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-09-05 23:08:23 +05:30
_touchStreamingActivity ( ) ;
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-09-05 23:08:23 +05:30
final updated = updater ( lastMessage ) ;
state = [ . . . state . sublist ( 0 , state . length - 1 ) , updated ] ;
if ( updated . isStreaming ) {
_touchStreamingActivity ( ) ;
}
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 ) ,
] ;
2025-09-05 23:08:23 +05:30
_touchStreamingActivity ( ) ;
2025-08-10 01:20:45 +05:30
}
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
] ;
2025-09-05 23:08:23 +05:30
_touchStreamingActivity ( ) ;
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
] ;
2025-09-05 23:08:23 +05:30
_cancelTypingGuard ( ) ;
2025-09-07 18:51:59 +05:30
// Trigger a refresh of the conversations list so UI like the Chats Drawer
// can pick up updated titles and ordering once streaming completes.
// Best-effort: ignore if ref lifecycle/context prevents invalidation.
try {
_ref . invalidate ( conversationsProvider ) ;
} catch ( _ ) { }
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 ( ) ;
2025-09-05 23:08:23 +05:30
// Cancel any active typing guard
_cancelTypingGuard ( ) ;
2025-08-10 01:20:45 +05:30
// Cancel conversation listener specifically
_conversationListener ? . close ( ) ;
_conversationListener = null ;
super . dispose ( ) ;
}
}
2025-09-05 11:48:43 +05:30
// Pre-seed an assistant skeleton message (with a given id or a new one),
// persist it to the server to keep the chain correct, and return the id.
Future < String > _preseedAssistantAndPersist (
dynamic ref , {
String ? existingAssistantId ,
required String modelId ,
} ) async {
final api = ref . read ( apiServiceProvider ) ;
final activeConv = ref . read ( activeConversationProvider ) ;
// Choose id: reuse existing if provided, else create new
final String assistantMessageId =
( existingAssistantId ! = null & & existingAssistantId . isNotEmpty )
2025-09-07 21:41:13 +05:30
? existingAssistantId
: const Uuid ( ) . v4 ( ) ;
2025-09-05 11:48:43 +05:30
// If the message with this id doesn't exist locally, add a placeholder
final msgs = ref . read ( chatMessagesProvider ) ;
final exists = msgs . any ( ( m ) = > m . id = = assistantMessageId ) ;
if ( ! exists ) {
final placeholder = ChatMessage (
id: assistantMessageId ,
role: ' assistant ' ,
content: ' ' ,
timestamp: DateTime . now ( ) ,
model: modelId ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( placeholder ) ;
} else {
// If it exists and is the last assistant, ensure we mark it streaming
try {
final last = msgs . isNotEmpty ? msgs . last : null ;
2025-09-07 21:41:13 +05:30
if ( last ! = null & &
last . id = = assistantMessageId & &
last . role = = ' assistant ' & &
! last . isStreaming ) {
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
2025-09-05 11:48:43 +05:30
( m ) = > m . copyWith ( isStreaming: true ) ,
) ;
}
} catch ( _ ) { }
}
// Persist the skeleton to the server so the web client sees a correct chain
try {
if ( api ! = null & & activeConv ! = null ) {
final current = ref . read ( chatMessagesProvider ) ;
await api . updateConversationWithMessages (
activeConv . id ,
current ,
model: modelId ,
) ;
}
} catch ( _ ) { }
return assistantMessageId ;
}
2025-08-10 01:20:45 +05:30
// 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-09-07 21:41:13 +05:30
// Small internal helper to convert a message with attachments into the
// OpenWebUI content payload format (text + image_url + files).
// - Adds text first (if non-empty)
// - Converts image attachments to image_url with data URLs (resolving MIME type when needed)
// - Includes non-image attachments in a 'files' array for server-side resolution
Future < Map < String , dynamic > > _buildMessagePayloadWithAttachments ( {
required dynamic api ,
required String role ,
required String cleanedText ,
required List < String > attachmentIds ,
} ) async {
final List < Map < String , dynamic > > contentArray = [ ] ;
final List < Map < String , dynamic > > nonImageFiles = [ ] ;
if ( cleanedText . isNotEmpty ) {
contentArray . add ( { ' type ' : ' text ' , ' text ' : cleanedText } ) ;
}
for ( final attachmentId in attachmentIds ) {
try {
final base64Data = await _getFileAsBase64 ( api , attachmentId ) ;
if ( base64Data ! = null ) {
if ( base64Data . startsWith ( ' data: ' ) ) {
contentArray . add ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : base64Data } ,
} ) ;
} else {
if ( ! attachmentId . startsWith ( ' data: ' ) ) {
final fileInfo = await api . getFileInfo ( attachmentId ) ;
final fileName = fileInfo [ ' filename ' ] ? ? ' ' ;
final ext = fileName . toLowerCase ( ) . split ( ' . ' ) . last ;
String mimeType = ' image/png ' ;
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 {
nonImageFiles . add ( { ' id ' : attachmentId , ' type ' : ' file ' } ) ;
}
} catch ( _ ) {
// Swallow and continue to keep regeneration robust
}
}
final messageMap = < String , dynamic > {
' role ' : role ,
' content ' : contentArray . isNotEmpty ? contentArray : cleanedText ,
} ;
if ( nonImageFiles . isNotEmpty ) {
messageMap [ ' files ' ] = nonImageFiles ;
}
return messageMap ;
}
2025-08-16 20:27:44 +05:30
// Regenerate message function that doesn't duplicate user message
Future < void > regenerateMessage (
2025-09-07 21:41:13 +05:30
dynamic ref ,
2025-08-16 20:27:44 +05:30
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 ( ) ,
2025-09-01 18:49:43 +05:30
model: selectedModel . id ,
2025-08-16 20:27:44 +05:30
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantMessage ) ;
2025-09-05 02:54:59 +05:30
// Helpers defined above
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 {
2025-09-07 21:41:13 +05:30
// Include selected tool ids so provider-native tool calling is triggered
final selectedToolIds = ref . read ( selectedToolIdsProvider ) ;
2025-08-16 20:27:44 +05:30
// 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
2025-09-07 21:41:13 +05:30
for ( int i = 0 ; i < messages . length ; i + + ) {
final msg = messages [ i ] ;
2025-08-16 20:27:44 +05:30
if ( msg . role . isNotEmpty & & msg . content . isNotEmpty & & ! msg . isStreaming ) {
2025-09-05 11:15:39 +05:30
final cleaned = ToolCallsParser . sanitizeForApi ( msg . content ) ;
2025-09-07 21:41:13 +05:30
// Prefer provided attachments for the last user message; otherwise use message attachments
final bool isLastUser = ( i = = messages . length - 1 ) & & msg . role = = ' user ' ;
final List < String > messageAttachments =
( isLastUser & & ( attachments ! = null & & attachments . isNotEmpty ) )
2025-09-07 23:17:26 +05:30
? List < String > . from ( attachments )
2025-09-07 21:41:13 +05:30
: ( msg . attachmentIds ? ? const < String > [ ] ) ;
if ( messageAttachments . isNotEmpty ) {
final messageMap = await _buildMessagePayloadWithAttachments (
api: api ,
role: msg . role ,
cleanedText: cleaned ,
attachmentIds: messageAttachments ,
) ;
conversationMessages . add ( messageMap ) ;
2025-08-16 20:27:44 +05:30
} else {
2025-09-05 11:15:39 +05:30
conversationMessages . add ( { ' role ' : msg . role , ' content ' : cleaned } ) ;
2025-08-16 20:27:44 +05:30
}
}
}
2025-09-05 11:48:43 +05:30
// Pre-seed assistant skeleton and persist chain
final String assistantMessageId = await _preseedAssistantAndPersist (
ref ,
modelId: selectedModel . id ,
2025-08-16 20:27:44 +05:30
) ;
2025-09-05 11:15:39 +05:30
2025-09-07 21:41:13 +05:30
// Feature toggles
final webSearchEnabled =
ref . read ( webSearchEnabledProvider ) & &
ref . read ( webSearchAvailableProvider ) ;
final imageGenerationEnabled = ref . read ( imageGenerationEnabledProvider ) ;
// Model metadata for completion notifications
final supportedParams =
selectedModel . supportedParameters ? ?
[
' max_tokens ' ,
' tool_choice ' ,
' tools ' ,
' response_format ' ,
' structured_outputs ' ,
] ;
final modelItem = {
' 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](/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 > [ ] ,
} ;
// Socket binding for background flows
final socketService = ref . read ( socketServiceProvider ) ;
2025-09-07 22:37:52 +05:30
String ? socketSessionId = socketService ? . sessionId ;
bool wantSessionBinding =
2025-09-07 21:41:13 +05:30
( socketService ? . isConnected = = true ) & &
( socketSessionId ! = null & & socketSessionId . isNotEmpty ) ;
2025-09-07 22:37:52 +05:30
// When regenerating with tools, make a best-effort to ensure a live socket.
if ( ! wantSessionBinding & & socketService ! = null ) {
try {
final ok = await socketService . ensureConnected ( ) ;
if ( ok ) {
socketSessionId = socketService . sessionId ;
wantSessionBinding =
socketSessionId ! = null & & socketSessionId . isNotEmpty ;
}
} catch ( _ ) { }
}
2025-09-07 21:41:13 +05:30
// 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)
bool shouldGenerateTitle = false ;
try {
final conv = ref . read ( activeConversationProvider ) ;
final nonSystemCount = conversationMessages
. where ( ( m ) = > ( m [ ' role ' ] ? . toString ( ) ? ? ' ' ) ! = ' system ' )
. length ;
shouldGenerateTitle =
( conv = = null ) | |
( ( conv . title = = ' New Chat ' | | ( conv . title . isEmpty ) ) & &
nonSystemCount = = 1 ) ;
} catch ( _ ) { }
final bgTasks = < String , dynamic > {
if ( shouldGenerateTitle ) ' title_generation ' : true ,
if ( shouldGenerateTitle ) ' tags_generation ' : true ,
' follow_up_generation ' : true ,
if ( webSearchEnabled ) ' web_search ' : true ,
if ( imageGenerationEnabled ) ' image_generation ' : true ,
} ;
final bool isBackgroundToolsFlowPre =
( selectedToolIds . isNotEmpty ) | |
( toolServers ! = null & & toolServers . isNotEmpty ) ;
final bool isBackgroundWebSearchPre = webSearchEnabled ;
// Dispatch using unified send pipeline (background tools flow)
2025-09-05 11:15:39 +05:30
final response = api ! . sendMessage (
messages: conversationMessages ,
model: selectedModel . id ,
conversationId: activeConversation . id ,
2025-09-07 21:41:13 +05:30
toolIds: selectedToolIds . isNotEmpty ? selectedToolIds : null ,
enableWebSearch: webSearchEnabled ,
enableImageGeneration: imageGenerationEnabled ,
modelItem: modelItem ,
sessionIdOverride: wantSessionBinding ? socketSessionId : null ,
toolServers: toolServers ,
backgroundTasks: bgTasks ,
2025-09-05 11:15:39 +05:30
responseMessageId: assistantMessageId ,
) ;
final stream = response . stream ;
2025-09-07 21:41:13 +05:30
final sessionId = response . sessionId ;
2025-08-16 20:27:44 +05:30
2025-09-07 21:41:13 +05:30
// New unified streaming path via helper; bypass old inline socket block
final bool _isBackgroundFlow =
isBackgroundToolsFlowPre | |
isBackgroundWebSearchPre | |
imageGenerationEnabled | |
wantSessionBinding ;
try {
ref . read ( chatMessagesProvider . notifier ) . updateLastMessageWithFunction ( (
m ,
) {
final mergedMeta = {
if ( m . metadata ! = null ) . . . m . metadata ! ,
' backgroundFlow ' : _isBackgroundFlow ,
if ( isBackgroundWebSearchPre ) ' webSearchFlow ' : true ,
if ( imageGenerationEnabled ) ' imageGenerationFlow ' : true ,
} ;
return m . copyWith ( metadata: mergedMeta ) ;
} ) ;
} catch ( _ ) { }
2025-08-16 20:27:44 +05:30
2025-09-07 21:41:13 +05:30
final _sendStreamSub = attachUnifiedChunkedStreaming (
stream: stream ,
webSearchEnabled: webSearchEnabled ,
isBackgroundFlow: _isBackgroundFlow ,
suppressSocketContentInitially: ! _isBackgroundFlow ,
usingDynamicChannelInitially: false ,
assistantMessageId: assistantMessageId ,
modelId: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
activeConversationId: activeConversation . id ,
api: api ,
socketService: socketService ,
appendToLastMessage: ( c ) = >
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( c ) ,
replaceLastMessageContent: ( c ) = >
ref . read ( chatMessagesProvider . notifier ) . replaceLastMessageContent ( c ) ,
updateLastMessageWith: ( updater ) = > ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction ( updater ) ,
finishStreaming: ( ) = >
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ,
getMessages: ( ) = > ref . read ( chatMessagesProvider ) ,
) ;
ref . read ( chatMessagesProvider . notifier ) . setMessageStream ( _sendStreamSub ) ;
return ;
2025-08-16 20:27:44 +05:30
} 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 ( ) ,
2025-09-01 18:49:43 +05:30
model: selectedModel . id ,
2025-08-12 13:07:10 +05:30
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 ( ) ,
2025-09-01 18:49:43 +05:30
model: selectedModel . id ,
2025-08-10 01:20:45 +05:30
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 ) {
2025-09-05 11:15:39 +05:30
// Prepare cleaned text content (strip tool details etc.)
final cleaned = ToolCallsParser . sanitizeForApi ( msg . content ) ;
2025-09-07 21:41:13 +05:30
final List < String > ids = msg . attachmentIds ? ? const < String > [ ] ;
if ( ids . isNotEmpty ) {
final messageMap = await _buildMessagePayloadWithAttachments (
api: api ,
role: msg . role ,
cleanedText: cleaned ,
attachmentIds: ids ,
) ;
2025-08-10 01:20:45 +05:30
conversationMessages . add ( messageMap ) ;
} else {
// Regular text-only message
2025-09-05 11:15:39 +05:30
conversationMessages . add ( { ' role ' : msg . role , ' content ' : cleaned } ) ;
2025-08-10 01:20:45 +05:30
}
}
}
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 {
2025-09-05 11:15:39 +05:30
// Pre-seed assistant skeleton on server to ensure correct chain
// Generate assistant message id now (must be consistent across client/server)
final String assistantMessageId = const Uuid ( ) . v4 ( ) ;
// Add assistant placeholder locally before sending
final assistantPlaceholder = ChatMessage (
id: assistantMessageId ,
role: ' assistant ' ,
content: ' ' ,
timestamp: DateTime . now ( ) ,
model: selectedModel . id ,
isStreaming: true ,
) ;
ref . read ( chatMessagesProvider . notifier ) . addMessage ( assistantPlaceholder ) ;
// Persist skeleton chain to server so web can load correct history
try {
final activeConvForSeed = ref . read ( activeConversationProvider ) ;
if ( activeConvForSeed ! = null ) {
final msgsForSeed = ref . read ( chatMessagesProvider ) ;
await api . updateConversationWithMessages (
activeConvForSeed . id ,
msgsForSeed ,
model: selectedModel . id ,
) ;
}
} catch ( _ ) { }
2025-08-10 01:20:45 +05:30
// 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-09-02 21:19:07 +05:30
// Stream response using server-push via Socket when available, otherwise fallback
2025-08-31 14:02:44 +05:30
// Resolve Socket session for background tasks parity
final socketService = ref . read ( socketServiceProvider ) ;
final socketSessionId = socketService ? . sessionId ;
2025-09-02 21:19:07 +05:30
final bool wantSessionBinding =
( socketService ? . isConnected = = true ) & &
( socketSessionId ! = null & & socketSessionId . isNotEmpty ) ;
2025-08-31 14:02:44 +05:30
// 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)
2025-09-05 11:20:39 +05:30
// Enable title/tags generation on the very first user turn of a new chat.
2025-09-05 02:54:59 +05:30
bool shouldGenerateTitle = false ;
try {
final conv = ref . read ( activeConversationProvider ) ;
2025-09-05 11:20:39 +05:30
// Use the outbound conversationMessages we just built (excludes streaming placeholders)
final nonSystemCount = conversationMessages
. where ( ( m ) = > ( m [ ' role ' ] ? . toString ( ) ? ? ' ' ) ! = ' system ' )
. length ;
2025-09-07 21:41:13 +05:30
shouldGenerateTitle =
( conv = = null ) | |
( ( conv . title = = ' New Chat ' | | ( conv . title . isEmpty ) ) & &
nonSystemCount = = 1 ) ;
2025-09-05 02:54:59 +05:30
} catch ( _ ) { }
2025-09-05 11:15:39 +05:30
// Match web client: request background follow-ups always; title/tags on first turn
2025-08-31 14:02:44 +05:30
final bgTasks = < String , dynamic > {
2025-09-05 02:54:59 +05:30
if ( shouldGenerateTitle ) ' title_generation ' : true ,
if ( shouldGenerateTitle ) ' tags_generation ' : true ,
2025-08-31 14:02:44 +05:30
' follow_up_generation ' : true ,
2025-09-05 11:15:39 +05:30
if ( webSearchEnabled ) ' web_search ' : true , // enable bg web search
2025-09-07 21:41:13 +05:30
if ( imageGenerationEnabled )
' image_generation ' : true , // enable bg image flow
2025-08-31 14:02:44 +05:30
} ;
2025-09-02 22:17:54 +05:30
// Determine if we need background task flow (tools/tool servers or web search)
2025-09-01 23:41:22 +05:30
final bool isBackgroundToolsFlowPre =
( toolIdsForApi ! = null & & toolIdsForApi . isNotEmpty ) | |
( toolServers ! = null & & toolServers . isNotEmpty ) ;
2025-09-02 22:17:54 +05:30
final bool isBackgroundWebSearchPre = webSearchEnabled ;
2025-09-01 23:41:22 +05:30
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-09-05 02:54:59 +05:30
// Enable image generation on the server when requested
enableImageGeneration: imageGenerationEnabled ,
2025-08-10 01:20:45 +05:30
modelItem: modelItem ,
2025-09-02 21:19:07 +05:30
// Bind to Socket session whenever available so the server can push
// streaming updates to this client (improves first-turn streaming).
sessionIdOverride: wantSessionBinding ? socketSessionId : null ,
2025-08-31 14:02:44 +05:30
toolServers: toolServers ,
backgroundTasks: bgTasks ,
2025-09-05 11:15:39 +05:30
responseMessageId: assistantMessageId ,
2025-08-10 01:20:45 +05:30
) ;
final stream = response . stream ;
final sessionId = response . sessionId ;
2025-09-07 21:41:13 +05:30
// (socket handlers attached below after flow flags are set)
2025-08-31 14:02:44 +05:30
// If socket is available, start listening for chat-events immediately
2025-09-02 21:19:07 +05:30
// Background-tools flow OR any session-bound flow relies on socket/dynamic channel for
// streaming content. Allow socket TEXT in those modes. For pure SSE/polling flows, suppress
2025-09-01 17:41:55 +05:30
// socket TEXT to avoid duplicates (still surface tool_call status).
2025-09-02 22:17:54 +05:30
final bool isBackgroundFlow =
2025-09-07 21:41:13 +05:30
isBackgroundToolsFlowPre | |
isBackgroundWebSearchPre | |
wantSessionBinding ;
bool suppressSocketContent =
! isBackgroundFlow ; // allow socket text when session-bound or tools
2025-09-01 17:34:05 +05:30
bool usingDynamicChannel = false ; // set true when server provides a channel
2025-09-07 21:41:13 +05:30
// Attach socket handlers for background flows/dynamic channels
if ( socketService ! = null ) {
_attachSocketStreamingHandlers (
ref: ref ,
socketService: socketService ,
assistantMessageId: assistantMessageId ,
modelId: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
isBackgroundFlow: isBackgroundFlow ,
suppressSocketContentInitially: suppressSocketContent ,
activeConversationId: activeConversation ? . id ,
) ;
}
2025-09-05 23:08:23 +05:30
// Enrich the assistant placeholder metadata so the typing guard can use longer timeouts
try {
2025-09-07 21:41:13 +05:30
ref . read ( chatMessagesProvider . notifier ) . updateLastMessageWithFunction ( (
m ,
) {
2025-09-05 23:08:23 +05:30
final mergedMeta = {
if ( m . metadata ! = null ) . . . m . metadata ! ,
' backgroundFlow ' : isBackgroundFlow ,
if ( isBackgroundWebSearchPre ) ' webSearchFlow ' : true ,
if ( imageGenerationEnabled ) ' imageGenerationFlow ' : true ,
} ;
return m . copyWith ( metadata: mergedMeta ) ;
} ) ;
} catch ( _ ) { }
2025-08-31 14:02:44 +05:30
if ( socketService ! = null ) {
2025-09-07 23:17:26 +05:30
// Activity-based watchdog for chat/channel events (resets on activity)
final _chatWatchdog = InactivityWatchdog (
window: const Duration ( minutes: 5 ) ,
onTimeout: ( ) {
try {
socketService . offChatEvents ( ) ;
socketService . offChannelEvents ( ) ;
} catch ( _ ) { }
// As a final safeguard, if we're still in streaming state, finish it
try {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & &
msgs . last . role = = ' assistant ' & &
msgs . last . isStreaming ) {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} catch ( _ ) { }
} ,
) . . start ( ) ;
2025-08-31 14:02:44 +05:30
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-09-07 23:17:26 +05:30
// Any chat event indicates activity; reset inactivity watchdog
// (watchdog defined below, near handler registration)
_chatWatchdog . ping ( ) ;
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 ' ] ;
2025-09-07 21:41:13 +05:30
final name = ( fn is Map & & fn [ ' name ' ] is String )
? fn [ ' name ' ] as String
: null ;
2025-08-31 19:07:19 +05:30
if ( name is String & & name . isNotEmpty ) {
2025-09-01 16:47:41 +05:30
final msgs = ref . read ( chatMessagesProvider ) ;
2025-09-07 21:41:13 +05:30
final exists =
( msgs . isNotEmpty ) & &
RegExp (
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' +
RegExp . escape ( name ) +
r'\"' ,
2025-09-01 16:47:41 +05:30
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
2025-09-07 21:41:13 +05:30
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:47:41 +05:30
}
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 ' ] ;
2025-09-07 21:41:13 +05:30
final name = ( fn is Map & & fn [ ' name ' ] is String )
? fn [ ' name ' ] as String
: null ;
2025-08-31 19:07:19 +05:30
if ( name is String & & name . isNotEmpty ) {
2025-09-01 16:47:41 +05:30
final msgs = ref . read ( chatMessagesProvider ) ;
2025-09-07 21:41:13 +05:30
final exists =
( msgs . isNotEmpty ) & &
RegExp (
r'<details\s+type=\"tool_calls\"[^>]*\bname=\"' +
RegExp . escape ( name ) +
r'\"' ,
2025-09-01 16:47:41 +05:30
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
2025-09-07 21:41:13 +05:30
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:47:41 +05:30
}
2025-08-31 19:07:19 +05:30
}
}
}
}
}
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
2025-08-31 19:07:19 +05:30
}
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 ) ;
}
2025-09-07 21:41:13 +05:30
_updateImagesFromCurrentContent ( ref ) ;
2025-08-31 14:02:44 +05:30
} else {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
2025-09-07 21:41:13 +05:30
_updateImagesFromCurrentContent ( ref ) ;
2025-08-31 14:02:44 +05:30
}
}
}
if ( payload [ ' done ' ] = = true ) {
2025-09-01 16:28:49 +05:30
// Stop listening to further socket events for this session.
2025-09-07 21:41:13 +05:30
try {
socketService . offChatEvents ( ) ;
} catch ( _ ) { }
2025-09-07 23:17:26 +05:30
try {
_chatWatchdog . ping ( ) ; // ensure timer exists
_chatWatchdog . stop ( ) ;
} 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 ) {
2025-09-07 21:41:13 +05:30
unawaited (
apiSvc
. sendChatCompleted (
chatId: chatId ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
)
. timeout ( const Duration ( seconds: 3 ) )
. catchError ( ( _ ) { } ) ,
) ;
2025-09-01 16:47:41 +05:30
}
} 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 {
2025-09-07 21:41:13 +05:30
final resp = await apiSvc . dio . get (
' /api/v1/chats/ $ chatId ' ,
) ;
2025-09-01 16:28:49 +05:30
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 (
2025-09-07 21:41:13 +05:30
( m ) = >
( m is Map & &
( m [ ' id ' ] ? . toString ( ) = = msgId ) ) ,
2025-09-01 16:28:49 +05:30
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 ) {
2025-09-07 21:41:13 +05:30
content =
textItem [ ' text ' ] ? . toString ( ) ? ? ' ' ;
2025-09-01 16:28:49 +05:30
}
}
}
}
// Fallback to history map
if ( content . isEmpty ) {
final history = chatObj [ ' history ' ] ;
2025-09-07 21:41:13 +05:30
if ( history is Map & &
history [ ' messages ' ] is Map ) {
2025-09-01 16:28:49 +05:30
final Map < String , dynamic > messagesMap =
2025-09-07 21:41:13 +05:30
( history [ ' messages ' ] as Map )
. cast < String , dynamic > ( ) ;
2025-09-01 16:28:49 +05:30
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 ) {
2025-09-07 21:41:13 +05:30
content =
textItem [ ' text ' ] ? . toString ( ) ? ? ' ' ;
2025-09-01 16:28:49 +05:30
}
}
}
}
}
}
if ( content . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. replaceLastMessageContent ( content ) ;
}
} catch ( _ ) {
// Swallow; we'll still finish streaming
} finally {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. finishStreaming ( ) ;
2025-09-01 16:28:49 +05:30
}
} ) ;
return ; // Defer finish to microtask
}
}
}
// Normal path: finish now
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
2025-09-07 23:17:26 +05:30
try {
_chatWatchdog . stop ( ) ;
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
}
}
} 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 ;
2025-09-01 17:34:05 +05:30
usingDynamicChannel = true ;
2025-09-01 17:41:55 +05:30
usingDynamicChannel = true ;
2025-09-01 17:33:44 +05:30
if ( kSocketVerboseLogging ) {
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket request:chat:completion channel= $ channel ' ,
) ;
2025-09-01 17:33:44 +05:30
}
2025-09-01 16:28:49 +05:30
void channelLineHandler ( dynamic line ) {
try {
if ( line is String ) {
final s = line . trim ( ) ;
2025-09-07 23:17:26 +05:30
// Dynamic channel activity
try {
_chatWatchdog . ping ( ) ;
} catch ( _ ) { }
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket [ $ channel ] line= ${ s . length > 160 ? ' $ {s.substring(0, 160) } … ' : s } ' ,
) ;
2025-09-01 16:28:49 +05:30
if ( s = = ' [DONE] ' | | s = = ' DONE ' ) {
socketService . offEvent ( channel ) ;
// Channel completed
2025-09-01 16:47:41 +05:30
try {
2025-09-07 21:41:13 +05:30
unawaited (
api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ,
) ;
2025-09-01 16:47:41 +05:30
} 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 {
2025-09-07 21:41:13 +05:30
unawaited (
api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ,
) ;
2025-09-01 16:47:41 +05:30
} catch ( _ ) { }
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. finishStreaming ( ) ;
2025-09-01 16:28:49 +05:30
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 ) {
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket [ $ channel ] delta.content len= ${ c . length } ' ,
) ;
2025-09-01 16:28:49 +05:30
}
}
// Surface tool_calls status
if ( delta . containsKey ( ' tool_calls ' ) ) {
2025-09-01 16:47:41 +05:30
if ( kSocketVerboseLogging ) {
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket [ $ channel ] delta.tool_calls detected ' ,
) ;
2025-09-01 16:47:41 +05:30
}
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 ' ] ;
2025-09-07 21:41:13 +05:30
final name =
( fn is Map & & fn [ ' name ' ] is String )
2025-09-01 16:28:49 +05:30
? fn [ ' name ' ] as String
: null ;
if ( name is String & & name . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
final msgs = ref . read (
chatMessagesProvider ,
) ;
final exists =
( msgs . isNotEmpty ) & &
RegExp (
r'<details\\s+type=\"tool_calls\"[^>]*\\bname=\"' +
RegExp . escape ( name ) +
r'\"' ,
2025-09-01 16:47:41 +05:30
multiLine: true ,
) . hasMatch ( msgs . last . content ) ;
if ( ! exists ) {
2025-09-07 21:41:13 +05:30
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:47:41 +05:30
}
2025-09-01 16:28:49 +05:30
}
}
}
}
}
// Append streamed content
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
2025-09-01 16:28:49 +05:30
}
}
}
} catch ( _ ) {
// Non-JSON line: append as-is
if ( s . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( s ) ;
_updateImagesFromCurrentContent ( ref ) ;
2025-09-01 16:28:49 +05:30
}
}
} else {
// Plain text line
if ( s . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( s ) ;
_updateImagesFromCurrentContent ( ref ) ;
2025-09-01 16:28:49 +05:30
}
}
} 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 {
2025-09-07 21:41:13 +05:30
unawaited (
api . sendChatCompleted (
chatId: activeConversation ? . id ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: selectedModel . id ,
modelItem: modelItem ,
sessionId: sessionId ,
) ,
) ;
2025-09-01 16:47:41 +05:30
} 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-08 13:22:28 +05:30
} else if ( type = = ' chat:message:error ' & & payload ! = null ) {
// Surface error associated with the current assistant message
try {
dynamic err = payload is Map ? payload [ ' error ' ] : null ;
String content = ' ' ;
if ( err is Map ) {
final c = err [ ' content ' ] ;
if ( c is String ) {
content = c ;
} else if ( c ! = null ) {
content = c . toString ( ) ;
}
} else if ( err is String ) {
content = err ;
} else if ( payload is Map & & payload [ ' message ' ] is String ) {
content = payload [ ' message ' ] ;
}
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . replaceLastMessageContent ( ' ⚠️ ' + content ) ;
}
} catch ( _ ) { }
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
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 ' ;
2025-09-02 00:04:21 +05:30
DebugLogger . stream ( ' Socket execute:tool name= $ name ' ) ;
2025-09-07 21:41:13 +05:30
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-05 02:54:59 +05:30
// If tool payload already carries files/result, try to extract images for grid
try {
final files = _extractFilesFromResult ( payload [ ' files ' ] ) ;
final resultFiles = _extractFilesFromResult ( payload [ ' result ' ] ) ;
final all = [ . . . files , . . . resultFiles ] ;
if ( all . isNotEmpty ) {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
2025-09-07 21:41:13 +05:30
final existing =
msgs . last . files ? ? < Map < String , dynamic > > [ ] ;
2025-09-05 02:54:59 +05:30
final seen = < String > {
for ( final f in existing )
if ( f [ ' url ' ] is String ) ( f [ ' url ' ] as String ) else ' ' ,
} . . removeWhere ( ( e ) = > e . isEmpty ) ;
final merged = < Map < String , dynamic > > [ . . . existing ] ;
for ( final f in all ) {
final url = f [ ' url ' ] as String ? ;
2025-09-07 21:41:13 +05:30
if ( url ! = null & &
url . isNotEmpty & &
! seen . contains ( url ) ) {
2025-09-05 02:54:59 +05:30
merged . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
seen . add ( url ) ;
}
}
if ( merged . length ! = existing . length ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
2025-09-05 02:54:59 +05:30
( m ) = > m . copyWith ( files: merged ) ,
) ;
}
}
}
} catch ( _ ) { }
2025-09-01 16:28:49 +05:30
} catch ( _ ) { }
2025-09-08 13:22:28 +05:30
} else if ( ( type = = ' files ' | | type = = ' chat:message:files ' ) & & payload ! = null ) {
2025-09-05 21:05:58 +05:30
// Handle files event from socket (image generation results)
try {
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket files event received: ${ payload . toString ( ) } ' ,
) ;
2025-09-05 21:05:58 +05:30
final files = _extractFilesFromResult ( payload ) ;
if ( files . isNotEmpty ) {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
final existing = msgs . last . files ? ? < Map < String , dynamic > > [ ] ;
final seen = < String > {
for ( final f in existing )
if ( f [ ' url ' ] is String ) ( f [ ' url ' ] as String ) else ' ' ,
} . . removeWhere ( ( e ) = > e . isEmpty ) ;
final merged = < Map < String , dynamic > > [ . . . existing ] ;
for ( final f in files ) {
final url = f [ ' url ' ] as String ? ;
if ( url ! = null & & url . isNotEmpty & & ! seen . contains ( url ) ) {
merged . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
seen . add ( url ) ;
}
}
if ( merged . length ! = existing . length ) {
2025-09-07 21:41:13 +05:30
DebugLogger . stream (
' Socket files: Adding ${ merged . length - existing . length } new images ' ,
) ;
final updatedMessage = ref
. read ( chatMessagesProvider )
. last
. copyWith ( files: merged ) ;
DebugLogger . stream (
' Socket files: Updated message files count: ${ updatedMessage . files ? . length } ' ,
) ;
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
2025-09-05 21:05:58 +05:30
( ChatMessage m ) = > m . copyWith ( files: merged ) ,
) ;
}
}
}
} catch ( e ) {
DebugLogger . stream ( ' Socket files event error: $ e ' ) ;
}
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 ' ] ;
2025-09-02 00:04:21 +05:30
DebugLogger . stream ( ' Socket channel-events: type= $ type ' ) ;
2025-09-01 16:28:49 +05:30
// Handle generic channel progress messages if needed
if ( type = = ' message ' & & payload is Map ) {
final content = payload [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
2025-09-07 21:41:13 +05:30
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
2025-09-07 23:17:26 +05:30
_chatWatchdog . ping ( ) ;
2025-09-01 16:28:49 +05:30
}
}
} catch ( _ ) { }
}
2025-09-07 21:41:13 +05:30
2025-09-01 16:28:49 +05:30
socketService . onChannelEvents ( channelEventsHandler ) ;
2025-09-07 23:17:26 +05:30
// Start activity watchdog
_chatWatchdog . ping ( ) ;
2025-08-31 14:02:44 +05:30
}
2025-09-05 02:54:59 +05:30
// Prepare streaming and background handling
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
2025-08-16 20:27:44 +05:30
final streamId = persistentService . registerStream (
subscription: chunkedStream . listen (
( chunk ) {
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-09-05 02:54:59 +05:30
// Image generation handled server-side via tools; no client pre-request
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-09-05 02:54:59 +05:30
// Helpers were defined above
2025-09-07 23:17:26 +05:30
int _chunkSeq = 0 ;
2025-08-16 20:27:44 +05:30
final streamSubscription = persistentController . stream . listen (
2025-08-10 01:20:45 +05:30
( chunk ) {
2025-09-07 23:17:26 +05:30
_chunkSeq + = 1 ;
try {
persistentService . updateStreamProgress (
streamId ,
chunkSequence: _chunkSeq ,
appendedContent: chunk ,
) ;
} catch ( _ ) { }
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 (
2025-09-07 21:41:13 +05:30
( message ) = >
message . copyWith ( metadata: { ' webSearchActive ' : false } ) ,
2025-08-21 14:37:49 +05:30
) ;
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-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-09-07 21:41:13 +05:30
_updateImagesFromCurrentContent ( ref ) ;
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 ) {
2025-09-07 21:41:13 +05:30
try {
socketService . offChatEvents ( ) ;
} catch ( _ ) { }
2025-08-31 19:07:19 +05:30
}
// Allow socket content again for future sessions (harmless if already false)
suppressSocketContent = false ;
2025-09-01 17:41:55 +05:30
// If this path was SSE-driven (no background tools/dynamic channel), finish now.
2025-09-01 16:28:49 +05:30
// Otherwise keep streaming state until socket/dynamic channel signals done.
2025-09-02 21:19:07 +05:30
if ( ! usingDynamicChannel & & ! isBackgroundFlow ) {
2025-09-01 16:28:49 +05:30
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 ) ;
}
2025-09-01 17:41:55 +05:30
// Only notify completion immediately for non-background SSE flows.
// For background tools/dynamic-channel flows, defer completion
// until the socket/dynamic channel signals done.
2025-09-02 21:19:07 +05:30
if ( ! isBackgroundFlow & & ! usingDynamicChannel ) {
2025-09-01 17:41:55 +05:30
try {
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 ( ( _ ) { } ) ,
) ;
} 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-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
}
}
}
2025-09-05 11:15:39 +05:30
// Do not persist conversation to server here. Server manages chat state.
// Keep local save only for quick resume.
await Future . delayed ( const Duration ( milliseconds: 50 ) ) ;
await _saveConversationLocally ( ref ) ;
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 ) {
2025-09-07 21:41:13 +05:30
try {
socketService . offChatEvents ( ) ;
} catch ( _ ) { }
2025-08-31 19:07:19 +05:30
}
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 {
2025-09-02 13:20:02 +05:30
// Enqueue background title generation task
2025-08-16 17:36:02 +05:30
try {
2025-09-02 13:20:02 +05:30
await ref
. read ( taskQueueProvider . notifier )
. enqueueGenerateTitle ( conversationId: conversationId ) ;
} catch ( _ ) {
// Best effort background check remains
2025-08-16 17:36:02 +05:30
_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
2025-09-05 11:15:39 +05:30
// Removed server persistence; only local caching is used in mobile app.
2025-08-10 01:20:45 +05:30
// 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 ) {
2025-09-05 02:54:59 +05:30
final prev = ref . read ( imageGenerationEnabledProvider ) ;
2025-08-21 15:45:07 +05:30
try {
2025-09-07 21:41:13 +05:30
// Force image generation enabled during regeneration
2025-09-05 02:54:59 +05:30
ref . read ( imageGenerationEnabledProvider . notifier ) . state = true ;
2025-09-07 21:41:13 +05:30
await regenerateMessage (
ref ,
lastUserMessage . content ,
lastUserMessage . attachmentIds ,
) ;
2025-09-05 02:54:59 +05:30
} finally {
// restore previous state
ref . read ( imageGenerationEnabledProvider . notifier ) . state = prev ;
2025-08-21 15:45:07 +05:30
}
return ;
}
2025-09-07 21:41:13 +05:30
// Text regeneration without duplicating user message
await regenerateMessage (
ref ,
lastUserMessage . content ,
lastUserMessage . attachmentIds ,
) ;
2025-08-10 01:20:45 +05:30
} ;
} ) ;
// Stop generation provider
final stopGenerationProvider = Provider < void Function ( ) > ( ( ref ) {
return ( ) {
2025-09-01 20:26:29 +05:30
try {
final messages = ref . read ( chatMessagesProvider ) ;
if ( messages . isNotEmpty & &
messages . last . role = = ' assistant ' & &
messages . last . isStreaming ) {
final lastId = messages . last . id ;
// Cancel the network stream (SSE) if active
final api = ref . read ( apiServiceProvider ) ;
api ? . cancelStreamingMessage ( lastId ) ;
// Stop any active socket listeners for chat/channel events
try {
final socketService = ref . read ( socketServiceProvider ) ;
socketService ? . offChatEvents ( ) ;
socketService ? . offChannelEvents ( ) ;
} catch ( _ ) { }
// Cancel local stream subscription to stop propagating further chunks
ref . read ( chatMessagesProvider . notifier ) . cancelActiveMessageStream ( ) ;
}
} catch ( _ ) { }
// Best-effort: stop any background tasks associated with this chat (parity with web)
try {
final api = ref . read ( apiServiceProvider ) ;
final activeConv = ref . read ( activeConversationProvider ) ;
if ( api ! = null & & activeConv ! = null ) {
unawaited ( ( ) async {
try {
final ids = await api . getTaskIdsByChat ( activeConv . id ) ;
for ( final t in ids ) {
2025-09-07 21:41:13 +05:30
try {
await api . stopTask ( t ) ;
} catch ( _ ) { }
2025-09-01 20:26:29 +05:30
}
} catch ( _ ) { }
} ( ) ) ;
2025-09-01 23:41:22 +05:30
// Also cancel local queue tasks for this conversation
try {
// Fire-and-forget local queue cancellation
// ignore: unawaited_futures
ref
. read ( taskQueueProvider . notifier )
. cancelByConversation ( activeConv . id ) ;
} catch ( _ ) { }
2025-09-01 20:26:29 +05:30
}
} catch ( _ ) { }
// Ensure UI transitions out of streaming state
2025-08-10 01:20:45 +05:30
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
} ;
} ) ;
2025-08-31 14:02:44 +05:30
2025-09-07 21:41:13 +05:30
// ========== Shared Streaming Utilities ==========
List < Map < String , dynamic > > _extractFilesFromResult ( dynamic resp ) {
final results = < Map < String , dynamic > > [ ] ;
if ( resp = = null ) return results ;
dynamic r = resp ;
if ( r is String ) {
try {
r = jsonDecode ( r ) ;
} catch ( _ ) { }
}
if ( r is List ) {
for ( final item in r ) {
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 ( r is ! Map ) return results ;
final data = r [ ' 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 = r [ ' 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 files = r [ ' files ' ] ;
if ( files is List ) {
results . addAll ( _extractFilesFromResult ( files ) ) ;
}
final singleUrl = r [ ' url ' ] ;
if ( singleUrl is String & & singleUrl . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : singleUrl } ) ;
}
final singleB64 = r [ ' b64_json ' ] ? ? r [ ' b64 ' ] ;
if ( singleB64 is String & & singleB64 . isNotEmpty ) {
results . add ( { ' type ' : ' image ' , ' url ' : ' data:image/png;base64, $ singleB64 ' } ) ;
}
return results ;
}
void _updateImagesFromCurrentContent ( dynamic ref ) {
try {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isEmpty | | msgs . last . role ! = ' assistant ' ) return ;
final content = msgs . last . content ;
if ( content . isEmpty ) return ;
final collected = < Map < String , dynamic > > [ ] ;
if ( content . contains ( ' <details ' ) ) {
final parsed = ToolCallsParser . parse ( content ) ;
if ( parsed ! = null ) {
for ( final entry in parsed . toolCalls ) {
if ( entry . files ! = null & & entry . files ! . isNotEmpty ) {
collected . addAll ( _extractFilesFromResult ( entry . files ) ) ;
}
if ( entry . result ! = null ) {
collected . addAll ( _extractFilesFromResult ( entry . result ) ) ;
}
}
}
}
if ( collected . isEmpty ) {
final base64Pattern = RegExp (
r'data:image/[^;\s]+;base64,[A-Za-z0-9+/]+=*' ,
) ;
final base64Matches = base64Pattern . allMatches ( content ) ;
for ( final match in base64Matches ) {
final url = match . group ( 0 ) ;
if ( url ! = null & & url . isNotEmpty ) {
collected . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
}
}
final urlPattern = RegExp (
r'https?://[^\s<>\"]+\.(jpg|jpeg|png|gif|webp)' ,
caseSensitive: false ,
) ;
final urlMatches = urlPattern . allMatches ( content ) ;
for ( final match in urlMatches ) {
final url = match . group ( 0 ) ;
if ( url ! = null & & url . isNotEmpty ) {
collected . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
}
}
final jsonPattern = RegExp (
r'\{[^}]*"url"[^}]*:[^}]*"(data:image/[^"]+|https?://[^"]+\.(jpg|jpeg|png|gif|webp))"[^}]*\}' ,
caseSensitive: false ,
) ;
final jsonMatches = jsonPattern . allMatches ( content ) ;
for ( final match in jsonMatches ) {
final url = RegExp (
r'"url"[^:]*:[^"]*"([^"]+)"' ,
) . firstMatch ( match . group ( 0 ) ? ? ' ' ) ? . group ( 1 ) ;
if ( url ! = null & & url . isNotEmpty ) {
collected . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
}
}
final partialResultsPattern = RegExp (
r'(result|files)="([^"]*(?:data:image/[^"]*|https?://[^"]*\.(jpg|jpeg|png|gif|webp))[^"]*)"' ,
caseSensitive: false ,
) ;
final partialMatches = partialResultsPattern . allMatches ( content ) ;
for ( final match in partialMatches ) {
final attrValue = match . group ( 2 ) ;
if ( attrValue ! = null ) {
try {
final decoded = json . decode ( attrValue ) ;
collected . addAll ( _extractFilesFromResult ( decoded ) ) ;
} catch ( _ ) {
if ( attrValue . startsWith ( ' data:image/ ' ) | |
RegExp (
r'https?://[^\s]+\.(jpg|jpeg|png|gif|webp)$' ,
caseSensitive: false ,
) . hasMatch ( attrValue ) ) {
collected . add ( { ' type ' : ' image ' , ' url ' : attrValue } ) ;
}
}
}
}
}
if ( collected . isEmpty ) return ;
final existing = msgs . last . files ? ? < Map < String , dynamic > > [ ] ;
final seen = < String > {
for ( final f in existing )
if ( f [ ' url ' ] is String ) ( f [ ' url ' ] as String ) else ' ' ,
} . . removeWhere ( ( e ) = > e . isEmpty ) ;
final merged = < Map < String , dynamic > > [ . . . existing ] ;
for ( final f in collected ) {
final url = f [ ' url ' ] as String ? ;
if ( url ! = null & & url . isNotEmpty & & ! seen . contains ( url ) ) {
merged . add ( { ' type ' : ' image ' , ' url ' : url } ) ;
seen . add ( url ) ;
}
}
if ( merged . length ! = existing . length ) {
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction ( ( m ) = > m . copyWith ( files: merged ) ) ;
}
} catch ( _ ) { }
}
void _attachSocketStreamingHandlers ( {
required dynamic ref ,
required dynamic socketService ,
required String assistantMessageId ,
required String modelId ,
required Map < String , dynamic > modelItem ,
required String sessionId ,
required bool isBackgroundFlow ,
required bool suppressSocketContentInitially ,
String ? activeConversationId ,
} ) {
bool suppressSocketContent = suppressSocketContentInitially ;
final api = ref . read ( apiServiceProvider ) ;
2025-09-07 23:17:26 +05:30
// Activity-based watchdog for socket-driven streaming (resets on activity)
final _socketWatchdog = InactivityWatchdog (
window: const Duration ( minutes: 5 ) ,
onTimeout: ( ) {
try {
socketService . offChatEvents ( ) ;
socketService . offChannelEvents ( ) ;
} catch ( _ ) { }
try {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & &
msgs . last . role = = ' assistant ' & &
msgs . last . isStreaming ) {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} catch ( _ ) { }
} ,
) . . start ( ) ;
2025-09-07 21:41:13 +05:30
void channelLineHandlerFactory ( String channel ) {
void handler ( dynamic line ) {
try {
if ( line is String ) {
final s = line . trim ( ) ;
2025-09-07 23:17:26 +05:30
// Any socket line is activity
_socketWatchdog . ping ( ) ;
2025-09-07 21:41:13 +05:30
if ( s = = ' [DONE] ' | | s = = ' DONE ' ) {
try {
socketService . offEvent ( channel ) ;
} catch ( _ ) { }
try {
unawaited (
api ? . sendChatCompleted (
chatId: activeConversationId ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: modelId ,
modelItem: modelItem ,
sessionId: sessionId ,
) ,
) ;
} catch ( _ ) { }
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
2025-09-07 23:17:26 +05:30
_socketWatchdog . stop ( ) ;
2025-09-07 21:41:13 +05:30
return ;
}
if ( s . startsWith ( ' data: ' ) ) {
final dataStr = s . substring ( 5 ) . trim ( ) ;
if ( dataStr = = ' [DONE] ' ) {
try {
socketService . offEvent ( channel ) ;
} catch ( _ ) { }
try {
unawaited (
api ? . sendChatCompleted (
chatId: activeConversationId ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: modelId ,
modelItem: modelItem ,
sessionId: sessionId ,
) ,
) ;
} catch ( _ ) { }
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
2025-09-07 23:17:26 +05:30
_socketWatchdog . stop ( ) ;
2025-09-07 21:41:13 +05:30
return ;
}
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 ( ' 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 ) {
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 ) ;
}
}
}
}
}
}
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
}
} catch ( _ ) {
if ( s . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( s ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
} else {
if ( s . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( s ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
} else if ( line is Map ) {
2025-09-07 23:17:26 +05:30
_socketWatchdog . ping ( ) ;
2025-09-07 21:41:13 +05:30
if ( line [ ' done ' ] = = true ) {
try {
socketService . offEvent ( channel ) ;
} catch ( _ ) { }
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
2025-09-07 23:17:26 +05:30
_socketWatchdog . stop ( ) ;
2025-09-07 21:41:13 +05:30
return ;
}
}
} catch ( _ ) { }
}
socketService . onEvent ( channel , handler ) ;
2025-09-07 23:17:26 +05:30
// Start activity watchdog now that handler is attached
_socketWatchdog . ping ( ) ;
2025-09-07 21:41:13 +05:30
}
void chatHandler ( Map < String , dynamic > ev ) {
try {
final data = ev [ ' data ' ] ;
if ( data = = null ) return ;
final type = data [ ' type ' ] ;
final payload = data [ ' data ' ] ;
if ( type = = ' chat:completion ' & & payload ! = null ) {
if ( payload is Map < String , dynamic > ) {
if ( payload . containsKey ( ' tool_calls ' ) ) {
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 ) {
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 ) ;
}
}
}
}
}
}
if ( ! suppressSocketContent & & payload . containsKey ( ' choices ' ) ) {
final choices = payload [ ' 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 ( ' 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 ) {
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 ) ;
}
}
}
}
}
}
final content = delta [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
}
}
if ( payload [ ' done ' ] = = true ) {
try {
socketService . offChatEvents ( ) ;
} catch ( _ ) { }
2025-09-07 23:17:26 +05:30
try {
_socketWatchdog . stop ( ) ;
} catch ( _ ) { }
2025-09-07 21:41:13 +05:30
try {
unawaited (
api
? . sendChatCompleted (
chatId: activeConversationId ? ? ' ' ,
messageId: assistantMessageId ,
messages: const [ ] ,
model: modelId ,
modelItem: modelItem ,
sessionId: sessionId ,
)
? . timeout ( const Duration ( seconds: 3 ) )
. catchError ( ( _ ) { } ) ,
) ;
} catch ( _ ) { }
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
final lastContent = msgs . last . content . trim ( ) ;
if ( lastContent . isEmpty ) {
Future . microtask ( ( ) async {
try {
final chatId = activeConversationId ;
if ( chatId ! = null & & chatId . isNotEmpty ) {
final resp = await api ? . 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 ) {
final list = chatObj [ ' messages ' ] ;
if ( list is List ) {
final target = list . firstWhere (
( m ) = >
( m is Map & &
( m [ ' id ' ] ? . toString ( ) = = assistantMessageId ) ) ,
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 ( ) ? ? ' ' ;
}
}
}
}
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 [ assistantMessageId ] ;
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 ( _ ) {
} finally {
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
} ) ;
return ;
}
}
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ;
}
}
} else if ( type = = ' request:chat:completion ' & & payload ! = null ) {
final channel = payload [ ' channel ' ] ;
if ( channel is String & & channel . isNotEmpty ) {
suppressSocketContent = true ;
channelLineHandlerFactory ( channel ) ;
}
} else if ( type = = ' event:status ' & & payload ! = null ) {
final status = payload [ ' status ' ] ? . toString ( ) ? ? ' ' ;
if ( status . isNotEmpty ) {
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( m ) = > m . copyWith ( metadata: { . . . ? m . metadata , ' status ' : status } ) ,
) ;
}
} else if ( type = = ' event:tool ' & & payload ! = null ) {
final files = _extractFilesFromResult ( payload [ ' result ' ] ) ;
if ( files . isNotEmpty ) {
final msgs = ref . read ( chatMessagesProvider ) ;
if ( msgs . isNotEmpty & & msgs . last . role = = ' assistant ' ) {
final existing = msgs . last . files ? ? < Map < String , dynamic > > [ ] ;
final merged = [ . . . existing , . . . files ] ;
ref
. read ( chatMessagesProvider . notifier )
. updateLastMessageWithFunction (
( m ) = > m . copyWith ( files: merged ) ,
) ;
}
}
} else if ( type = = ' event:message:delta ' & & payload ! = null ) {
if ( suppressSocketContent ) return ;
final content = payload [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
} catch ( _ ) { }
}
void channelEventsHandler ( Map < String , dynamic > ev ) {
try {
final data = ev [ ' data ' ] ;
if ( data = = null ) return ;
final type = data [ ' type ' ] ;
final payload = data [ ' data ' ] ;
if ( type = = ' message ' & & payload is Map ) {
final content = payload [ ' content ' ] ? . toString ( ) ? ? ' ' ;
if ( content . isNotEmpty ) {
ref . read ( chatMessagesProvider . notifier ) . appendToLastMessage ( content ) ;
_updateImagesFromCurrentContent ( ref ) ;
}
}
} catch ( _ ) { }
}
socketService . onChatEvents ( chatHandler ) ;
socketService . onChannelEvents ( channelEventsHandler ) ;
2025-09-07 23:17:26 +05:30
// Start activity watchdog for chat/channel events
_socketWatchdog . ping ( ) ;
2025-09-07 21:41:13 +05:30
}
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 ;
}
2025-09-07 21:41:13 +05:30
Map < String , dynamic > ? _resolveRef (
String ref ,
Map < String , dynamic > ? components ,
) {
2025-08-31 14:02:44 +05:30
// 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 ] ;
2025-09-07 21:41:13 +05:30
if ( schema is Map < String , dynamic > )
return Map < String , dynamic > . from ( schema ) ;
2025-08-31 14:02:44 +05:30
}
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 ;
2025-09-07 21:41:13 +05:30
if ( schema [ ' description ' ] ! = null )
out [ ' description ' ] = schema [ ' description ' ] ;
2025-08-31 14:02:44 +05:30
if ( type = = ' object ' ) {
out [ ' properties ' ] = < String , dynamic > { } ;
2025-09-07 21:41:13 +05:30
if ( schema [ ' required ' ] is List )
out [ ' required ' ] = List . from ( schema [ ' required ' ] ) ;
2025-08-31 14:02:44 +05:30
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 > { } ;
}
2025-09-07 21:41:13 +05:30
List < Map < String , dynamic > > _convertOpenApiToToolPayload (
Map < String , dynamic > openApi ,
) {
2025-08-31 14:02:44 +05:30
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 ' ] ,
2025-09-07 21:41:13 +05:30
' description ' :
operation [ ' description ' ] ? ?
operation [ ' summary ' ] ? ?
' No description available. ' ,
2025-08-31 14:02:44 +05:30
' 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 ) {
2025-09-07 21:41:13 +05:30
String desc = ( schema [ ' description ' ] ? ? p [ ' description ' ] ? ? ' ' )
. toString ( ) ;
2025-08-31 14:02:44 +05:30
if ( schema [ ' enum ' ] is List ) {
2025-09-07 21:41:13 +05:30
desc =
' $ desc . Possible values: ${ ( schema [ ' enum ' ] as List ) . join ( ' , ' ) } ' ;
2025-08-31 14:02:44 +05:30
}
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 ' ] ;
2025-09-07 21:41:13 +05:30
final resolved = _resolveSchemaSimple (
schema ,
openApi [ ' components ' ] as Map < String , dynamic > ? ,
) ;
2025-08-31 14:02:44 +05:30
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 ;
}