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-09-26 01:38:00 +05:30
import ' package:flutter/foundation.dart ' ;
2025-08-10 01:20:45 +05:30
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-09-26 01:38:00 +05:30
import ' ../../../core/services/socket_service.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 ' ;
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-09-25 22:36:42 +05:30
import ' ../../../core/utils/debug_logger.dart ' ;
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 =
2025-09-21 22:31:44 +05:30
NotifierProvider < ChatMessagesNotifier , List < ChatMessage > > (
ChatMessagesNotifier . new ,
) ;
2025-08-10 01:20:45 +05:30
// Loading state for conversation (used to show chat skeletons during fetch)
2025-09-21 22:31:44 +05:30
final isLoadingConversationProvider =
NotifierProvider < IsLoadingConversationNotifier , bool > (
IsLoadingConversationNotifier . new ,
) ;
2025-08-10 01:20:45 +05:30
2025-08-28 12:59:48 +05:30
// Prefilled input text (e.g., when sharing text from other apps)
2025-09-21 22:31:44 +05:30
final prefilledInputTextProvider =
NotifierProvider < PrefilledInputTextNotifier , String ? > (
PrefilledInputTextNotifier . new ,
) ;
2025-08-28 12:59:48 +05:30
2025-08-28 14:45:46 +05:30
// Trigger to request focus on the chat input (increment to signal)
2025-09-21 22:31:44 +05:30
final inputFocusTriggerProvider =
NotifierProvider < InputFocusTriggerNotifier , int > (
InputFocusTriggerNotifier . new ,
) ;
2025-08-28 14:45:46 +05:30
2025-09-08 01:15:31 +05:30
// Whether the chat composer currently has focus
2025-09-21 22:31:44 +05:30
final composerHasFocusProvider = NotifierProvider < ComposerFocusNotifier , bool > (
ComposerFocusNotifier . new ,
) ;
class IsLoadingConversationNotifier extends Notifier < bool > {
@ override
bool build ( ) = > false ;
void set ( bool value ) = > state = value ;
}
class PrefilledInputTextNotifier extends Notifier < String ? > {
@ override
String ? build ( ) = > null ;
void set ( String ? value ) = > state = value ;
void clear ( ) = > state = null ;
}
class InputFocusTriggerNotifier extends Notifier < int > {
@ override
int build ( ) = > 0 ;
void set ( int value ) = > state = value ;
int increment ( ) {
final next = state + 1 ;
state = next ;
return next ;
}
}
class ComposerFocusNotifier extends Notifier < bool > {
@ override
bool build ( ) = > false ;
2025-09-08 01:15:31 +05:30
2025-09-21 22:31:44 +05:30
void set ( bool value ) = > state = value ;
}
class ChatMessagesNotifier extends Notifier < List < ChatMessage > > {
2025-08-10 01:20:45 +05:30
StreamSubscription ? _messageStream ;
ProviderSubscription ? _conversationListener ;
final List < StreamSubscription > _subscriptions = [ ] ;
2025-09-26 01:38:00 +05:30
final List < SocketEventSubscription > _socketSubscriptions = [ ] ;
VoidCallback ? _socketTeardown ;
2025-09-07 23:17:26 +05:30
// Activity-based watchdog to prevent stuck typing indicator
InactivityWatchdog ? _typingWatchdog ;
2025-09-27 16:34:37 +05:30
DateTime ? _lastStreamingActivity ;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
bool _initialized = false ;
2025-09-07 11:13:05 +05:30
2025-09-21 22:31:44 +05:30
@ override
List < ChatMessage > build ( ) {
if ( ! _initialized ) {
_initialized = true ;
_conversationListener = ref . listen ( activeConversationProvider , (
previous ,
next ,
) {
2025-09-25 23:22:48 +05:30
DebugLogger . log (
' Conversation changed: ${ previous ? . id } -> ${ next ? . id } ' ,
scope: ' chat/providers ' ,
) ;
2025-09-21 22:31:44 +05:30
// Only react when the conversation actually changes
if ( previous ? . id = = next ? . id ) {
// If same conversation but server updated it (e.g., title/content), avoid overwriting
// locally streamed assistant content with an outdated server copy.
if ( previous ? . updatedAt ! = next ? . updatedAt ) {
final serverMessages = next ? . messages ? ? const [ ] ;
// Primary rule: adopt server messages when there are strictly more of them.
if ( serverMessages . length > state . length ) {
2025-09-07 11:13:05 +05:30
state = serverMessages ;
return ;
}
2025-09-21 22:31:44 +05:30
// 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 ' ;
final serverHasMore =
serverText . isNotEmpty & & serverText . length > localText . length ;
final localEmptyButServerHas =
localText . isEmpty & & serverText . isNotEmpty ;
if ( sameLastId & &
isAssistant & &
( serverHasMore | | localEmptyButServerHas ) ) {
state = serverMessages ;
return ;
}
}
2025-08-26 11:21:26 +05:30
}
2025-09-21 22:31:44 +05:30
return ;
2025-08-10 01:20:45 +05:30
}
2025-09-21 22:31:44 +05:30
// Cancel any existing message stream when switching conversations
_cancelMessageStream ( ) ;
// Also cancel typing guard on conversation switch
_cancelTypingGuard ( ) ;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
if ( next ! = null ) {
state = next . messages ;
2025-08-21 14:37:49 +05:30
2025-09-21 22:31:44 +05:30
// Update selected model if conversation has a different model
_updateModelForConversation ( next ) ;
} else {
state = [ ] ;
}
} ) ;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
ref . onDispose ( ( ) {
for ( final subscription in _subscriptions ) {
subscription . cancel ( ) ;
}
_subscriptions . clear ( ) ;
_cancelMessageStream ( ) ;
2025-09-26 01:38:00 +05:30
cancelSocketSubscriptions ( ) ;
2025-09-21 22:31:44 +05:30
_cancelTypingGuard ( ) ;
_conversationListener ? . close ( ) ;
_conversationListener = null ;
} ) ;
}
final activeConversation = ref . read ( activeConversationProvider ) ;
return activeConversation ? . messages ? ? const [ ] ;
2025-08-10 01:20:45 +05:30
}
void _addSubscription ( StreamSubscription subscription ) {
_subscriptions . add ( subscription ) ;
}
void _cancelMessageStream ( ) {
_messageStream ? . cancel ( ) ;
_messageStream = null ;
2025-09-26 01:38:00 +05:30
cancelSocketSubscriptions ( ) ;
2025-08-10 01:20:45 +05:30
}
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 {
2025-09-21 22:31:44 +05:30
final apiSvc = ref . read ( apiServiceProvider ) ;
final activeConv = ref . read ( activeConversationProvider ) ;
2025-09-07 23:17:26 +05:30
final msgId = last . id ;
final chatId = activeConv ? . id ;
if ( apiSvc ! = null & & chatId ! = null & & chatId . isNotEmpty ) {
2025-09-16 18:15:44 +05:30
final resp = await apiSvc . dio . get ( ' /api/v1/chats/ $ chatId ' ) ;
2025-09-07 23:17:26 +05:30
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-13 10:16:58 +05:30
content =
( textItem as Map ) [ ' text ' ] ? . toString ( ) ? ? ' ' ;
2025-09-07 23:17:26 +05:30
}
}
}
}
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 ( ) {
2025-09-27 16:34:37 +05:30
_lastStreamingActivity = DateTime . now ( ) ;
2025-09-05 23:08:23 +05:30
// 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
2025-09-21 22:31:44 +05:30
final globalWebSearch = ref . read ( webSearchEnabledProvider ) ;
final webSearchAvailable = ref . read ( webSearchAvailableProvider ) ;
final globalImageGen = ref . read ( imageGenerationEnabledProvider ) ;
2025-09-05 23:08:23 +05:30
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-27 16:34:37 +05:30
// Enhanced streaming recovery method similar to OpenWebUI's approach
void recoverStreamingIfNeeded ( ) {
if ( state . isEmpty ) return ;
final lastMessage = state . last ;
if ( lastMessage . role ! = ' assistant ' | | ! lastMessage . isStreaming ) return ;
// Check if streaming has been inactive for too long
final now = DateTime . now ( ) ;
if ( _lastStreamingActivity ! = null ) {
final inactiveTime = now . difference ( _lastStreamingActivity ! ) ;
// If inactive for more than 3 minutes, consider recovery
if ( inactiveTime > const Duration ( minutes: 3 ) ) {
DebugLogger . log (
' Streaming inactive for ${ inactiveTime . inSeconds } s, attempting recovery ' ,
scope: ' chat/provider ' ,
) ;
// Try to gracefully finish the streaming state
finishStreaming ( ) ;
}
}
}
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-09-21 22:31:44 +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 {
2025-09-21 22:31:44 +05:30
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
2025-09-21 22:31:44 +05:30
ref . read ( selectedModelProvider . notifier ) . set ( conversationModel ) ;
2025-08-17 00:26:12 +05:30
} 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 ) ;
}
2025-09-26 01:38:00 +05:30
void setSocketSubscriptions (
List < SocketEventSubscription > subscriptions , {
VoidCallback ? onDispose ,
} ) {
cancelSocketSubscriptions ( ) ;
_socketSubscriptions . addAll ( subscriptions ) ;
_socketTeardown = onDispose ;
}
void cancelSocketSubscriptions ( ) {
if ( _socketSubscriptions . isEmpty ) {
_socketTeardown ? . call ( ) ;
_socketTeardown = null ;
return ;
}
for ( final sub in _socketSubscriptions ) {
try {
sub . dispose ( ) ;
} catch ( _ ) { }
}
_socketSubscriptions . clear ( ) ;
_socketTeardown ? . call ( ) ;
_socketTeardown = null ;
}
2025-08-10 01:20:45 +05:30
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
2025-09-25 18:25:39 +05:30
void updateMessageById (
String messageId ,
ChatMessage Function ( ChatMessage current ) updater ,
) {
final index = state . indexWhere ( ( m ) = > m . id = = messageId ) ;
2025-09-27 16:57:42 +05:30
if ( index = = - 1 ) return ;
2025-09-25 18:25:39 +05:30
final original = state [ index ] ;
final updated = updater ( original ) ;
if ( identical ( updated , original ) ) {
return ;
}
final next = [ . . . state ] ;
next [ index ] = updated ;
state = next ;
}
void appendStatusUpdate ( String messageId , ChatStatusUpdate update ) {
updateMessageById ( messageId , ( current ) {
final history = [ . . . current . statusHistory , update ] ;
return current . copyWith ( statusHistory: history ) ;
} ) ;
}
void setFollowUps ( String messageId , List < String > followUps ) {
updateMessageById ( messageId , ( current ) {
return current . copyWith ( followUps: List < String > . from ( followUps ) ) ;
} ) ;
}
void upsertCodeExecution ( String messageId , ChatCodeExecution execution ) {
updateMessageById ( messageId , ( current ) {
final existing = current . codeExecutions ;
final idx = existing . indexWhere ( ( e ) = > e . id = = execution . id ) ;
if ( idx = = - 1 ) {
return current . copyWith ( codeExecutions: [ . . . existing , execution ] ) ;
}
final next = [ . . . existing ] ;
next [ idx ] = execution ;
return current . copyWith ( codeExecutions: next ) ;
} ) ;
}
void appendSourceReference ( String messageId , ChatSourceReference reference ) {
updateMessageById ( messageId , ( current ) {
final existing = current . sources ;
final alreadyPresent = existing . any ( ( source ) {
if ( reference . id ! = null & & reference . id ! . isNotEmpty ) {
return source . id = = reference . id ;
}
if ( reference . url ! = null & & reference . url ! . isNotEmpty ) {
return source . url = = reference . url ;
}
return false ;
} ) ;
if ( alreadyPresent ) {
return current ;
}
return current . copyWith ( sources: [ . . . existing , reference ] ) ;
} ) ;
}
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 ( ) {
2025-09-27 16:57:42 +05:30
if ( state . isEmpty ) return ;
2025-08-10 01:20:45 +05:30
final lastMessage = state . last ;
2025-09-27 16:57:42 +05:30
if ( lastMessage . role ! = ' assistant ' | | ! lastMessage . isStreaming ) return ;
2025-08-10 01:20:45 +05:30
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 {
2025-09-21 22:31:44 +05:30
ref . invalidate ( conversationsProvider ) ;
2025-09-07 18:51:59 +05:30
} catch ( _ ) { }
2025-08-10 01:20:45 +05:30
}
}
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 ,
2025-09-20 18:28:12 +05:30
String ? systemPrompt ,
2025-09-05 11:48:43 +05:30
} ) async {
// 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 ( _ ) { }
}
2025-09-22 23:17:23 +05:30
// Sync conversation state to ensure WebUI can load conversation history
2025-09-05 11:48:43 +05:30
try {
2025-09-22 23:17:23 +05:30
final api = ref . read ( apiServiceProvider ) ;
final activeConv = ref . read ( activeConversationProvider ) ;
2025-09-05 11:48:43 +05:30
if ( api ! = null & & activeConv ! = null ) {
2025-09-21 22:31:44 +05:30
final resolvedSystemPrompt =
( systemPrompt ! = null & & systemPrompt . trim ( ) . isNotEmpty )
2025-09-20 18:28:12 +05:30
? systemPrompt . trim ( )
: activeConv . systemPrompt ;
2025-09-05 11:48:43 +05:30
final current = ref . read ( chatMessagesProvider ) ;
2025-09-22 23:17:23 +05:30
await api . syncConversationMessages (
2025-09-05 11:48:43 +05:30
activeConv . id ,
current ,
model: modelId ,
2025-09-20 18:28:12 +05:30
systemPrompt: resolvedSystemPrompt ,
2025-09-05 11:48:43 +05:30
) ;
}
2025-09-22 23:17:23 +05:30
} catch ( _ ) {
// Non-critical - continue if sync fails
}
2025-09-05 11:48:43 +05:30
return assistantMessageId ;
}
2025-09-20 18:47:38 +05:30
String ? _extractSystemPromptFromSettings ( Map < String , dynamic > ? settings ) {
if ( settings = = null ) return null ;
final rootValue = settings [ ' system ' ] ;
if ( rootValue is String ) {
final trimmed = rootValue . trim ( ) ;
if ( trimmed . isNotEmpty ) return trimmed ;
}
final ui = settings [ ' ui ' ] ;
if ( ui is Map < String , dynamic > ) {
final uiValue = ui [ ' system ' ] ;
if ( uiValue is String ) {
final trimmed = uiValue . trim ( ) ;
if ( trimmed . isNotEmpty ) return trimmed ;
}
}
return null ;
}
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
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . clear ( ) ;
2025-08-10 01:20:45 +05:30
// Clear messages
ref . read ( chatMessagesProvider . notifier ) . clearMessages ( ) ;
}
// Available tools provider
2025-09-21 22:31:44 +05:30
final availableToolsProvider =
NotifierProvider < AvailableToolsNotifier , List < String > > (
AvailableToolsNotifier . new ,
) ;
2025-08-10 01:20:45 +05:30
// Web search enabled state for API-based web search
2025-09-21 22:31:44 +05:30
final webSearchEnabledProvider =
NotifierProvider < WebSearchEnabledNotifier , bool > (
WebSearchEnabledNotifier . new ,
) ;
2025-08-10 01:20:45 +05:30
2025-08-21 14:37:49 +05:30
// Image generation enabled state - behaves like web search
2025-09-21 22:31:44 +05:30
final imageGenerationEnabledProvider =
NotifierProvider < ImageGenerationEnabledNotifier , bool > (
ImageGenerationEnabledNotifier . new ,
) ;
2025-08-21 14:37:49 +05:30
2025-08-10 01:20:45 +05:30
// Vision capable models provider
2025-09-21 22:31:44 +05:30
final visionCapableModelsProvider =
NotifierProvider < VisionCapableModelsNotifier , List < String > > (
VisionCapableModelsNotifier . new ,
) ;
// File upload capable models provider
final fileUploadCapableModelsProvider =
NotifierProvider < FileUploadCapableModelsNotifier , List < String > > (
FileUploadCapableModelsNotifier . new ,
) ;
class AvailableToolsNotifier extends Notifier < List < String > > {
@ override
List < String > build ( ) = > [ ] ;
void set ( List < String > tools ) = > state = List < String > . from ( tools ) ;
}
class WebSearchEnabledNotifier extends Notifier < bool > {
@ override
bool build ( ) = > false ;
void set ( bool value ) = > state = value ;
}
class ImageGenerationEnabledNotifier extends Notifier < bool > {
@ override
bool build ( ) = > false ;
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
void set ( bool value ) = > state = value ;
}
class VisionCapableModelsNotifier extends Notifier < List < String > > {
@ override
List < String > build ( ) {
final selectedModel = ref . watch ( selectedModelProvider ) ;
if ( selectedModel = = null ) {
return [ ] ;
}
if ( selectedModel . isMultimodal = = true ) {
return [ selectedModel . id ] ;
}
// For now, assume all models support vision unless explicitly marked
2025-08-10 01:20:45 +05:30
return [ selectedModel . id ] ;
}
2025-09-21 22:31:44 +05:30
}
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
class FileUploadCapableModelsNotifier extends Notifier < List < String > > {
@ override
List < String > build ( ) {
final selectedModel = ref . watch ( selectedModelProvider ) ;
if ( selectedModel = = null ) {
return [ ] ;
}
2025-08-10 01:20:45 +05:30
2025-09-21 22:31:44 +05:30
// For now, assume all models support file upload
return [ selectedModel . id ] ;
}
}
2025-08-10 01:20:45 +05:30
// 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 ;
}
2025-09-22 23:17:23 +05:30
// Helper function to build files array from attachment IDs
Future < List < Map < String , dynamic > > ? > _buildFilesArrayFromAttachments (
dynamic api ,
List < String > attachmentIds ,
) async {
final filesArray = < Map < String , dynamic > > [ ] ;
for ( final attachmentId in attachmentIds ) {
try {
final fileInfo = await api . getFileInfo ( attachmentId ) ;
final fileName = fileInfo [ ' filename ' ] ? ? fileInfo [ ' name ' ] ? ? ' Unknown ' ;
final fileSize = fileInfo [ ' size ' ] ;
// Check if it's an image
final ext = fileName . toLowerCase ( ) . split ( ' . ' ) . last ;
final isImage = [ ' jpg ' , ' jpeg ' , ' png ' , ' gif ' , ' webp ' ] . contains ( ext ) ;
// Add all files to the files array for WebUI display
// Note: This is for storage/display, not for API message sending
filesArray . add ( {
' type ' : isImage ? ' image ' : ' file ' ,
' id ' : attachmentId , // Required for RAG system to lookup file content
' url ' : ' /api/v1/files/ $ attachmentId /content ' ,
' name ' : fileName ,
if ( fileSize ! = null ) ' size ' : fileSize ,
} ) ;
} catch ( _ ) {
// If we can't get file info, assume it's a non-image file
// Images should be handled in the content array anyway
filesArray . add ( {
' type ' : ' file ' ,
' id ' : attachmentId , // Required for RAG system to lookup file content
' url ' : ' /api/v1/files/ $ attachmentId /content ' ,
' name ' : ' Unknown ' ,
} ) ;
}
}
return filesArray . isNotEmpty ? filesArray : null ;
}
2025-08-10 01:20:45 +05:30
// 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 = [ ] ;
if ( cleanedText . isNotEmpty ) {
contentArray . add ( { ' type ' : ' text ' , ' text ' : cleanedText } ) ;
}
2025-09-22 23:17:23 +05:30
// Collect all files in OpenWebUI format for the files array
final allFiles = < Map < String , dynamic > > [ ] ;
2025-09-07 21:41:13 +05:30
for ( final attachmentId in attachmentIds ) {
try {
2025-09-22 23:17:23 +05:30
final fileInfo = await api . getFileInfo ( attachmentId ) ;
final fileName = fileInfo [ ' filename ' ] ? ? fileInfo [ ' name ' ] ? ? ' Unknown ' ;
final fileSize = fileInfo [ ' size ' ] ;
2025-09-07 21:41:13 +05:30
final base64Data = await _getFileAsBase64 ( api , attachmentId ) ;
if ( base64Data ! = null ) {
2025-09-22 23:17:23 +05:30
// This is an image file - add to content array only
2025-09-07 21:41:13 +05:30
if ( base64Data . startsWith ( ' data: ' ) ) {
contentArray . add ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : base64Data } ,
} ) ;
} else {
2025-09-22 23:17:23 +05:30
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 ' ;
2025-09-07 21:41:13 +05:30
}
2025-09-22 23:17:23 +05:30
final dataUrl = ' data: $ mimeType ;base64, $ base64Data ' ;
contentArray . add ( {
' type ' : ' image_url ' ,
' image_url ' : { ' url ' : dataUrl } ,
} ) ;
2025-09-07 21:41:13 +05:30
}
2025-09-22 23:17:23 +05:30
// Note: Images are handled in content array above, no need to duplicate in files array
// This prevents duplicate display in the WebUI
2025-09-07 21:41:13 +05:30
} else {
2025-09-22 23:17:23 +05:30
// This is a non-image file
allFiles . add ( {
' type ' : ' file ' ,
' id ' : attachmentId , // Required for RAG system to lookup file content
' url ' : ' /api/v1/files/ $ attachmentId /content ' ,
' name ' : fileName ,
if ( fileSize ! = null ) ' size ' : fileSize ,
} ) ;
2025-09-07 21:41:13 +05:30
}
} catch ( _ ) {
// Swallow and continue to keep regeneration robust
}
}
final messageMap = < String , dynamic > {
' role ' : role ,
' content ' : contentArray . isNotEmpty ? contentArray : cleanedText ,
} ;
2025-09-22 23:17:23 +05:30
if ( allFiles . isNotEmpty ) {
messageMap [ ' files ' ] = allFiles ;
2025-09-07 21:41:13 +05:30
}
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 ' ) ;
}
2025-09-20 18:28:12 +05:30
var activeConversation = ref . read ( activeConversationProvider ) ;
2025-08-16 20:27:44 +05:30
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-20 18:28:12 +05:30
Map < String , dynamic > ? userSettingsData ;
String ? userSystemPrompt ;
try {
userSettingsData = await api ! . getUserSettings ( ) ;
2025-09-20 18:47:38 +05:30
userSystemPrompt = _extractSystemPromptFromSettings ( userSettingsData ) ;
2025-09-20 18:28:12 +05:30
} catch ( _ ) { }
if ( ( activeConversation . systemPrompt = = null | |
activeConversation . systemPrompt ! . trim ( ) . isEmpty ) & &
( userSystemPrompt ? . isNotEmpty ? ? false ) ) {
2025-09-21 22:31:44 +05:30
final updated = activeConversation . copyWith (
systemPrompt: userSystemPrompt ,
) ;
ref . read ( activeConversationProvider . notifier ) . set ( updated ) ;
2025-09-20 18:28:12 +05:30
activeConversation = updated ;
}
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
2025-09-13 10:16:58 +05:30
final bool isLastUser =
( i = = messages . length - 1 ) & & msg . role = = ' user ' ;
2025-09-07 21:41:13 +05:30
final List < String > messageAttachments =
( isLastUser & & ( attachments ! = null & & attachments . isNotEmpty ) )
2025-09-13 10:16:58 +05:30
? List < String > . from ( attachments )
: ( msg . attachmentIds ? ? const < String > [ ] ) ;
2025-09-07 21:41:13 +05:30
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-20 18:28:12 +05:30
final conversationSystemPrompt = activeConversation . systemPrompt ? . trim ( ) ;
2025-09-21 22:31:44 +05:30
final effectiveSystemPrompt =
( conversationSystemPrompt ! = null & &
2025-09-20 18:28:12 +05:30
conversationSystemPrompt . isNotEmpty )
? conversationSystemPrompt
: userSystemPrompt ;
if ( effectiveSystemPrompt ! = null & & effectiveSystemPrompt . isNotEmpty ) {
final hasSystemMessage = conversationMessages . any (
( m ) = > ( m [ ' role ' ] ? . toString ( ) . toLowerCase ( ) ? ? ' ' ) = = ' system ' ,
) ;
if ( ! hasSystemMessage ) {
2025-09-21 22:31:44 +05:30
conversationMessages . insert ( 0 , {
' role ' : ' system ' ,
' content ' : effectiveSystemPrompt ,
} ) ;
2025-09-20 18:28:12 +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-09-20 18:28:12 +05:30
systemPrompt: effectiveSystemPrompt ,
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 ;
2025-09-20 18:28:12 +05:30
final uiSettings = userSettingsData ? [ ' ui ' ] as Map < String , dynamic > ? ;
2025-09-21 22:31:44 +05:30
final rawServers = uiSettings ! = null
? ( uiSettings [ ' toolServers ' ] as List ? )
: null ;
2025-09-20 18:28:12 +05:30
if ( rawServers ! = null & & rawServers . isNotEmpty ) {
try {
2025-09-07 21:41:13 +05:30
toolServers = await _resolveToolServers ( rawServers , api ) ;
2025-09-20 18:28:12 +05:30
} catch ( _ ) { }
}
2025-09-07 21:41:13 +05:30
// 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-16 18:15:44 +05:30
final bool isBackgroundFlowPre =
2025-09-13 10:16:58 +05:30
isBackgroundToolsFlowPre | |
isBackgroundWebSearchPre | |
imageGenerationEnabled ;
2025-09-26 13:59:28 +05:30
final bool passSocketSession =
wantSessionBinding & & ( isBackgroundFlowPre | | bgTasks . isNotEmpty ) ;
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 ,
2025-09-16 18:15:44 +05:30
sessionIdOverride: passSocketSession ? socketSessionId : null ,
2025-09-26 01:38:00 +05:30
socketSessionId: socketSessionId ,
2025-09-07 21:41:13 +05:30
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-09-26 01:38:00 +05:30
final effectiveSessionId =
response . socketSessionId ? ? socketSessionId ? ? sessionId ;
2025-08-16 20:27:44 +05:30
2025-09-26 13:59:28 +05:30
final bool isBackgroundFlow = response . isBackgroundFlow ;
2025-09-07 21:41:13 +05:30
try {
ref . read ( chatMessagesProvider . notifier ) . updateLastMessageWithFunction ( (
m ,
) {
final mergedMeta = {
if ( m . metadata ! = null ) . . . m . metadata ! ,
2025-09-16 18:15:44 +05:30
' backgroundFlow ' : isBackgroundFlow ,
2025-09-07 21:41:13 +05:30
if ( isBackgroundWebSearchPre ) ' webSearchFlow ' : true ,
if ( imageGenerationEnabled ) ' imageGenerationFlow ' : true ,
} ;
return m . copyWith ( metadata: mergedMeta ) ;
} ) ;
} catch ( _ ) { }
2025-08-16 20:27:44 +05:30
2025-09-26 01:38:00 +05:30
final activeStream = attachUnifiedChunkedStreaming (
2025-09-07 21:41:13 +05:30
stream: stream ,
webSearchEnabled: webSearchEnabled ,
assistantMessageId: assistantMessageId ,
modelId: selectedModel . id ,
modelItem: modelItem ,
2025-09-26 01:38:00 +05:30
sessionId: effectiveSessionId ,
2025-09-07 21:41:13 +05:30
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 ) ,
2025-09-25 18:25:39 +05:30
appendStatusUpdate: ( messageId , update ) = > ref
. read ( chatMessagesProvider . notifier )
. appendStatusUpdate ( messageId , update ) ,
setFollowUps: ( messageId , followUps ) = > ref
. read ( chatMessagesProvider . notifier )
. setFollowUps ( messageId , followUps ) ,
upsertCodeExecution: ( messageId , execution ) = > ref
. read ( chatMessagesProvider . notifier )
. upsertCodeExecution ( messageId , execution ) ,
appendSourceReference: ( messageId , reference ) = > ref
. read ( chatMessagesProvider . notifier )
. appendSourceReference ( messageId , reference ) ,
updateMessageById: ( messageId , updater ) = > ref
. read ( chatMessagesProvider . notifier )
. updateMessageById ( messageId , updater ) ,
onChatTitleUpdated: ( newTitle ) {
final active = ref . read ( activeConversationProvider ) ;
if ( active ! = null ) {
ref
. read ( activeConversationProvider . notifier )
. set ( active . copyWith ( title: newTitle ) ) ;
}
ref . invalidate ( conversationsProvider ) ;
} ,
onChatTagsUpdated: ( ) {
ref . invalidate ( conversationsProvider ) ;
final active = ref . read ( activeConversationProvider ) ;
final api = ref . read ( apiServiceProvider ) ;
if ( active ! = null & & api ! = null ) {
Future . microtask ( ( ) async {
try {
final refreshed = await api . getConversation ( active . id ) ;
ref . read ( activeConversationProvider . notifier ) . set ( refreshed ) ;
} catch ( _ ) { }
} ) ;
}
} ,
2025-09-07 21:41:13 +05:30
finishStreaming: ( ) = >
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ,
getMessages: ( ) = > ref . read ( chatMessagesProvider ) ,
) ;
2025-09-26 01:38:00 +05:30
ref . read ( chatMessagesProvider . notifier )
. . setMessageStream ( activeStream . streamSubscription )
. . setSocketSubscriptions (
activeStream . socketSubscriptions ,
onDispose: activeStream . disposeWatchdog ,
) ;
2025-09-07 21:41:13 +05:30
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 ' ) ;
}
2025-09-20 18:28:12 +05:30
Map < String , dynamic > ? userSettingsData ;
String ? userSystemPrompt ;
if ( ! reviewerMode & & api ! = null ) {
try {
userSettingsData = await api . getUserSettings ( ) ;
2025-09-20 18:47:38 +05:30
userSystemPrompt = _extractSystemPromptFromSettings ( userSettingsData ) ;
2025-09-20 18:28:12 +05:30
} catch ( _ ) { }
}
2025-08-10 01:20:45 +05:30
// 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-09-22 23:17:23 +05:30
List < Map < String , dynamic > > ? userFiles ;
if ( attachments ! = null & &
attachments . isNotEmpty & &
! reviewerMode & &
api ! = null ) {
userFiles = await _buildFilesArrayFromAttachments ( api , attachments ) ;
}
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-09-22 23:17:23 +05:30
files: userFiles ,
2025-08-12 13:07:10 +05:30
) ;
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-09-20 18:28:12 +05:30
systemPrompt: userSystemPrompt ,
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
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( localConversation ) ;
2025-08-10 01:20:45 +05:30
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 ,
2025-09-20 18:28:12 +05:30
systemPrompt: userSystemPrompt ,
2025-08-10 01:20:45 +05:30
) ;
final updatedConversation = localConversation . copyWith (
id: serverConversation . id ,
2025-09-20 18:28:12 +05:30
systemPrompt: serverConversation . systemPrompt ? ? userSystemPrompt ,
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
) ;
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( updatedConversation ) ;
2025-08-10 01:20:45 +05:30
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
}
2025-09-20 18:28:12 +05:30
if ( activeConversation ! = null & &
( activeConversation . systemPrompt = = null | |
activeConversation . systemPrompt ! . trim ( ) . isEmpty ) & &
( userSystemPrompt ? . isNotEmpty ? ? false ) ) {
final updated = activeConversation . copyWith ( systemPrompt: userSystemPrompt ) ;
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( updated ) ;
2025-09-20 18:28:12 +05:30
activeConversation = updated ;
}
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)
// 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-09-20 18:28:12 +05:30
final conversationSystemPrompt = activeConversation ? . systemPrompt ? . trim ( ) ;
2025-09-21 22:31:44 +05:30
final effectiveSystemPrompt =
( conversationSystemPrompt ! = null & & conversationSystemPrompt . isNotEmpty )
2025-09-20 18:28:12 +05:30
? conversationSystemPrompt
: userSystemPrompt ;
if ( effectiveSystemPrompt ! = null & & effectiveSystemPrompt . isNotEmpty ) {
final hasSystemMessage = conversationMessages . any (
( m ) = > ( m [ ' role ' ] ? . toString ( ) . toLowerCase ( ) ? ? ' ' ) = = ' system ' ,
) ;
if ( ! hasSystemMessage ) {
2025-09-21 22:31:44 +05:30
conversationMessages . insert ( 0 , {
' role ' : ' system ' ,
' content ' : effectiveSystemPrompt ,
} ) ;
2025-09-20 18:28:12 +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 ) ;
2025-09-22 23:17:23 +05:30
// Sync conversation state to ensure WebUI can load conversation history
2025-09-05 11:15:39 +05:30
try {
final activeConvForSeed = ref . read ( activeConversationProvider ) ;
if ( activeConvForSeed ! = null ) {
final msgsForSeed = ref . read ( chatMessagesProvider ) ;
2025-09-22 23:17:23 +05:30
await api . syncConversationMessages (
2025-09-05 11:15:39 +05:30
activeConvForSeed . id ,
msgsForSeed ,
model: selectedModel . id ,
2025-09-20 18:28:12 +05:30
systemPrompt: effectiveSystemPrompt ,
2025-09-05 11:15:39 +05:30
) ;
}
2025-09-22 23:17:23 +05:30
} catch ( _ ) {
// Non-critical - continue if sync fails
}
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 ;
2025-09-20 18:28:12 +05:30
final uiSettings = userSettingsData ? [ ' ui ' ] as Map < String , dynamic > ? ;
2025-09-21 22:31:44 +05:30
final rawServers = uiSettings ! = null
? ( uiSettings [ ' toolServers ' ] as List ? )
: null ;
2025-09-20 18:28:12 +05:30
if ( rawServers ! = null & & rawServers . isNotEmpty ) {
try {
2025-08-31 14:02:44 +05:30
toolServers = await _resolveToolServers ( rawServers , api ) ;
2025-09-20 18:28:12 +05:30
} catch ( _ ) { }
}
2025-08-31 14:02:44 +05:30
// 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-09-26 13:59:28 +05:30
final bool shouldBindSession =
wantSessionBinding & &
( isBackgroundToolsFlowPre | |
isBackgroundWebSearchPre | |
imageGenerationEnabled | |
bgTasks . isNotEmpty ) ;
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).
2025-09-26 13:59:28 +05:30
sessionIdOverride: shouldBindSession ? socketSessionId : null ,
2025-09-26 01:38:00 +05:30
socketSessionId: socketSessionId ,
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-26 01:38:00 +05:30
final effectiveSessionId =
response . socketSessionId ? ? socketSessionId ? ? sessionId ;
2025-08-10 01:20:45 +05:30
2025-09-25 12:28:02 +05:30
// Use unified streaming helper for SSE/WebSocket handling
2025-09-26 13:59:28 +05:30
final bool isBackgroundFlow = response . isBackgroundFlow ;
2025-09-25 12:28:02 +05:30
2025-09-05 23:08:23 +05:30
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-09-26 01:38:00 +05:30
final activeStream = attachUnifiedChunkedStreaming (
2025-09-25 12:28:02 +05:30
stream: stream ,
webSearchEnabled: webSearchEnabled ,
assistantMessageId: assistantMessageId ,
modelId: selectedModel . id ,
modelItem: modelItem ,
2025-09-26 01:38:00 +05:30
sessionId: effectiveSessionId ,
2025-09-25 12:28:02 +05:30
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 ) ,
2025-09-25 18:25:39 +05:30
appendStatusUpdate: ( messageId , update ) = > ref
. read ( chatMessagesProvider . notifier )
. appendStatusUpdate ( messageId , update ) ,
setFollowUps: ( messageId , followUps ) = > ref
. read ( chatMessagesProvider . notifier )
. setFollowUps ( messageId , followUps ) ,
upsertCodeExecution: ( messageId , execution ) = > ref
. read ( chatMessagesProvider . notifier )
. upsertCodeExecution ( messageId , execution ) ,
appendSourceReference: ( messageId , reference ) = > ref
. read ( chatMessagesProvider . notifier )
. appendSourceReference ( messageId , reference ) ,
updateMessageById: ( messageId , updater ) = > ref
. read ( chatMessagesProvider . notifier )
. updateMessageById ( messageId , updater ) ,
onChatTitleUpdated: ( newTitle ) {
final active = ref . read ( activeConversationProvider ) ;
if ( active ! = null ) {
ref
. read ( activeConversationProvider . notifier )
. set ( active . copyWith ( title: newTitle ) ) ;
}
ref . invalidate ( conversationsProvider ) ;
} ,
onChatTagsUpdated: ( ) {
ref . invalidate ( conversationsProvider ) ;
final active = ref . read ( activeConversationProvider ) ;
final api = ref . read ( apiServiceProvider ) ;
if ( active ! = null & & api ! = null ) {
Future . microtask ( ( ) async {
try {
final refreshed = await api . getConversation ( active . id ) ;
ref . read ( activeConversationProvider . notifier ) . set ( refreshed ) ;
} catch ( _ ) { }
} ) ;
}
} ,
2025-09-25 12:28:02 +05:30
finishStreaming: ( ) = >
ref . read ( chatMessagesProvider . notifier ) . finishStreaming ( ) ,
getMessages: ( ) = > ref . read ( chatMessagesProvider ) ,
2025-08-10 01:20:45 +05:30
) ;
2025-09-26 01:38:00 +05:30
ref . read ( chatMessagesProvider . notifier )
. . setMessageStream ( activeStream . streamSubscription )
. . setSocketSubscriptions (
activeStream . socketSubscriptions ,
onDispose: activeStream . disposeWatchdog ,
) ;
2025-09-25 12:28:02 +05:30
return ;
2025-08-10 01:20:45 +05:30
} 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 ) ;
2025-09-25 12:28:02 +05:30
} else if ( e . toString ( ) . contains ( ' 401 ' ) | | e . toString ( ) . contains ( ' 403 ' ) ) {
// Authentication errors - clear auth state and redirect to login
ref . invalidate ( authStateManagerProvider ) ;
2025-08-10 01:20:45 +05:30
} 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 ' ) ) {
2025-09-25 23:22:48 +05:30
DebugLogger . log (
' Model or endpoint not found (404) ' ,
scope: ' chat/providers ' ,
) ;
2025-08-10 01:20:45 +05:30
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 ) ;
}
}
}
// 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 ) ) ;
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( 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 ) {
2025-09-21 22:31:44 +05:30
ref
. read ( activeConversationProvider . notifier )
. set ( activeConversation ! . copyWith ( pinned: pinned ) ) ;
2025-08-10 01:20:45 +05:30
}
} catch ( e ) {
2025-09-25 23:22:48 +05:30
DebugLogger . log (
' Error ${ pinned ? ' pinning ' : ' unpinning ' } conversation: $ e ' ,
scope: ' chat/providers ' ,
) ;
2025-08-10 01:20:45 +05:30
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 ) {
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . clear ( ) ;
2025-08-10 01:20:45 +05:30
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 ) {
2025-09-25 23:22:48 +05:30
DebugLogger . log (
2025-08-10 01:20:45 +05:30
' Error ${ archived ? ' archiving ' : ' unarchiving ' } conversation: $ e ' ,
2025-09-25 23:22:48 +05:30
scope: ' chat/providers ' ,
2025-08-10 01:20:45 +05:30
) ;
// If server operation failed and we archived locally, restore the conversation
if ( activeConversation ? . id = = conversationId & & archived ) {
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( activeConversation ) ;
2025-08-10 01:20:45 +05:30
// 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 ) {
2025-09-25 23:22:48 +05:30
DebugLogger . log ( ' Error sharing conversation: $ e ' , scope: ' chat/providers ' ) ;
2025-08-10 01:20:45 +05:30
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
2025-09-21 22:31:44 +05:30
ref . read ( activeConversationProvider . notifier ) . set ( clonedConversation ) ;
2025-08-10 01:20:45 +05:30
// 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 ) {
2025-09-25 23:22:48 +05:30
DebugLogger . log ( ' Error cloning conversation: $ e ' , scope: ' chat/providers ' ) ;
2025-08-10 01:20:45 +05:30
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-21 22:31:44 +05:30
ref . read ( imageGenerationEnabledProvider . notifier ) . set ( 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
2025-09-21 22:31:44 +05:30
ref . read ( imageGenerationEnabledProvider . notifier ) . set ( 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 ) ;
// 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 ==========
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-16 18:15:44 +05:30
if ( schema is Map < String , dynamic > ) {
2025-09-07 21:41:13 +05:30
return Map < String , dynamic > . from ( schema ) ;
2025-09-16 18:15:44 +05:30
}
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-16 18:15:44 +05:30
if ( schema [ ' description ' ] ! = null ) {
2025-09-07 21:41:13 +05:30
out [ ' description ' ] = schema [ ' description ' ] ;
2025-09-16 18:15:44 +05:30
}
2025-08-31 14:02:44 +05:30
if ( type = = ' object ' ) {
out [ ' properties ' ] = < String , dynamic > { } ;
2025-09-16 18:15:44 +05:30
if ( schema [ ' required ' ] is List ) {
2025-09-07 21:41:13 +05:30
out [ ' required ' ] = List . from ( schema [ ' required ' ] ) ;
2025-09-16 18:15:44 +05:30
}
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 ;
}