feat: image generation
This commit is contained in:
@@ -896,6 +896,37 @@ final conversationSuggestionsProvider = FutureProvider<List<String>>((
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Server features and permissions
|
||||||
|
final userPermissionsProvider = FutureProvider<Map<String, dynamic>>((
|
||||||
|
ref,
|
||||||
|
) async {
|
||||||
|
final api = ref.watch(apiServiceProvider);
|
||||||
|
if (api == null) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await api.getUserPermissions();
|
||||||
|
} catch (e) {
|
||||||
|
foundation.debugPrint('DEBUG: Error fetching user permissions: $e');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final imageGenerationAvailableProvider = Provider<bool>((ref) {
|
||||||
|
final perms = ref.watch(userPermissionsProvider);
|
||||||
|
return perms.maybeWhen(
|
||||||
|
data: (data) {
|
||||||
|
final features = data['features'];
|
||||||
|
if (features is Map<String, dynamic>) {
|
||||||
|
final value = features['image_generation'];
|
||||||
|
if (value is bool) return value;
|
||||||
|
if (value is String) return value.toLowerCase() == 'true';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
orElse: () => false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Folders provider
|
// Folders provider
|
||||||
final foldersProvider = FutureProvider<List<Folder>>((ref) async {
|
final foldersProvider = FutureProvider<List<Folder>>((ref) async {
|
||||||
final api = ref.watch(apiServiceProvider);
|
final api = ref.watch(apiServiceProvider);
|
||||||
|
|||||||
@@ -739,10 +739,7 @@ class ApiService {
|
|||||||
userAttachments.add(file['file_id'] as String);
|
userAttachments.add(file['file_id'] as String);
|
||||||
} else if (file['type'] == 'image' && file['url'] != null) {
|
} else if (file['type'] == 'image' && file['url'] != null) {
|
||||||
// Generated image
|
// Generated image
|
||||||
generatedFiles.add({
|
generatedFiles.add({'type': file['type'], 'url': file['url']});
|
||||||
'type': file['type'],
|
|
||||||
'url': file['url'],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1688,9 +1685,8 @@ class ApiService {
|
|||||||
required String text,
|
required String text,
|
||||||
String? voice,
|
String? voice,
|
||||||
}) async {
|
}) async {
|
||||||
debugPrint(
|
final textPreview = text.length > 50 ? text.substring(0, 50) : text;
|
||||||
'DEBUG: Generating speech for text: ${text.substring(0, 50)}...',
|
debugPrint('DEBUG: Generating speech for text: $textPreview...');
|
||||||
);
|
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
'/api/v1/audio/speech',
|
'/api/v1/audio/speech',
|
||||||
data: {'text': text, if (voice != null) 'voice': voice},
|
data: {'text': text, if (voice != null) 'voice': voice},
|
||||||
@@ -1805,7 +1801,7 @@ class ApiService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> generateImage({
|
Future<dynamic> generateImage({
|
||||||
required String prompt,
|
required String prompt,
|
||||||
String? model,
|
String? model,
|
||||||
int? width,
|
int? width,
|
||||||
@@ -1813,9 +1809,9 @@ class ApiService {
|
|||||||
int? steps,
|
int? steps,
|
||||||
double? guidance,
|
double? guidance,
|
||||||
}) async {
|
}) async {
|
||||||
debugPrint(
|
final promptPreview = prompt.length > 50 ? prompt.substring(0, 50) : prompt;
|
||||||
'DEBUG: Generating image with prompt: ${prompt.substring(0, 50)}...',
|
debugPrint('DEBUG: Generating image with prompt: $promptPreview...');
|
||||||
);
|
try {
|
||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
'/api/v1/images/generations',
|
'/api/v1/images/generations',
|
||||||
data: {
|
data: {
|
||||||
@@ -1827,7 +1823,23 @@ class ApiService {
|
|||||||
if (guidance != null) 'guidance': guidance,
|
if (guidance != null) 'guidance': guidance,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return response.data as Map<String, dynamic>;
|
return response.data;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
debugPrint('DEBUG: images/generations failed: ${e.response?.statusCode}');
|
||||||
|
// Fallback to singular path some servers use
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/v1/image/generations',
|
||||||
|
data: {
|
||||||
|
'prompt': prompt,
|
||||||
|
if (model != null) 'model': model,
|
||||||
|
if (width != null) 'width': width,
|
||||||
|
if (height != null) 'height': height,
|
||||||
|
if (steps != null) 'steps': steps,
|
||||||
|
if (guidance != null) 'guidance': guidance,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompts
|
// Prompts
|
||||||
@@ -1841,6 +1853,24 @@ class ApiService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permissions & Features
|
||||||
|
Future<Map<String, dynamic>> getUserPermissions() async {
|
||||||
|
debugPrint('DEBUG: Fetching user permissions');
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/v1/users/permissions');
|
||||||
|
return response.data as Map<String, dynamic>;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('DEBUG: Error fetching user permissions: $e');
|
||||||
|
if (e is DioException) {
|
||||||
|
debugPrint('DEBUG: Permissions error response: ${e.response?.data}');
|
||||||
|
debugPrint(
|
||||||
|
'DEBUG: Permissions error status: ${e.response?.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> createPrompt({
|
Future<Map<String, dynamic>> createPrompt({
|
||||||
required String title,
|
required String title,
|
||||||
required String content,
|
required String content,
|
||||||
@@ -2434,6 +2464,7 @@ class ApiService {
|
|||||||
String? conversationId,
|
String? conversationId,
|
||||||
List<String>? toolIds,
|
List<String>? toolIds,
|
||||||
bool enableWebSearch = false,
|
bool enableWebSearch = false,
|
||||||
|
bool enableImageGeneration = false,
|
||||||
Map<String, dynamic>? modelItem,
|
Map<String, dynamic>? modelItem,
|
||||||
}) {
|
}) {
|
||||||
final streamController = StreamController<String>();
|
final streamController = StreamController<String>();
|
||||||
@@ -2504,17 +2535,25 @@ class ApiService {
|
|||||||
data['chat_id'] = conversationId;
|
data['chat_id'] = conversationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add web search flag if enabled
|
// Add feature flags if enabled
|
||||||
if (enableWebSearch) {
|
if (enableWebSearch) {
|
||||||
data['web_search'] = true;
|
data['web_search'] = true;
|
||||||
// Also add it in features for compatibility
|
debugPrint('DEBUG: Web search enabled in SSE request');
|
||||||
|
}
|
||||||
|
if (enableImageGeneration) {
|
||||||
|
// Mirror web_search behavior for image generation
|
||||||
|
data['image_generation'] = true;
|
||||||
|
debugPrint('DEBUG: Image generation enabled in SSE request');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableWebSearch || enableImageGeneration) {
|
||||||
|
// Include features map for compatibility
|
||||||
data['features'] = {
|
data['features'] = {
|
||||||
'web_search': true,
|
'web_search': enableWebSearch,
|
||||||
'image_generation': false,
|
'image_generation': enableImageGeneration,
|
||||||
'code_interpreter': false,
|
'code_interpreter': false,
|
||||||
'memory': false,
|
'memory': false,
|
||||||
};
|
};
|
||||||
debugPrint('DEBUG: Web search enabled in SSE request');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
|
// Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateModelForConversation(Conversation conversation) async {
|
Future<void> _updateModelForConversation(Conversation conversation) async {
|
||||||
debugPrint('DEBUG: _updateModelForConversation called for conversation ${conversation.id}');
|
debugPrint(
|
||||||
|
'DEBUG: _updateModelForConversation called for conversation ${conversation.id}',
|
||||||
|
);
|
||||||
|
|
||||||
// Check if conversation has a model specified
|
// Check if conversation has a model specified
|
||||||
if (conversation.model == null || conversation.model!.isEmpty) {
|
if (conversation.model == null || conversation.model!.isEmpty) {
|
||||||
@@ -100,14 +102,16 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
debugPrint('DEBUG: Available models count: ${models.length}');
|
debugPrint('DEBUG: Available models count: ${models.length}');
|
||||||
|
|
||||||
if (models.isEmpty) {
|
if (models.isEmpty) {
|
||||||
debugPrint('DEBUG: No models available, cannot update selected model');
|
debugPrint(
|
||||||
|
'DEBUG: No models available, cannot update selected model',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for exact match first
|
// Look for exact match first
|
||||||
final conversationModel = models.where(
|
final conversationModel = models
|
||||||
(model) => model.id == conversation.model,
|
.where((model) => model.id == conversation.model)
|
||||||
).firstOrNull;
|
.firstOrNull;
|
||||||
|
|
||||||
if (conversationModel != null) {
|
if (conversationModel != null) {
|
||||||
// Update the selected model
|
// Update the selected model
|
||||||
@@ -164,16 +168,15 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateLastMessageWithFunction(ChatMessage Function(ChatMessage) updater) {
|
void updateLastMessageWithFunction(
|
||||||
|
ChatMessage Function(ChatMessage) updater,
|
||||||
|
) {
|
||||||
if (state.isEmpty) return;
|
if (state.isEmpty) return;
|
||||||
|
|
||||||
final lastMessage = state.last;
|
final lastMessage = state.last;
|
||||||
if (lastMessage.role != 'assistant') return;
|
if (lastMessage.role != 'assistant') return;
|
||||||
|
|
||||||
state = [
|
state = [...state.sublist(0, state.length - 1), updater(lastMessage)];
|
||||||
...state.sublist(0, state.length - 1),
|
|
||||||
updater(lastMessage),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void appendToLastMessage(String content) {
|
void appendToLastMessage(String content) {
|
||||||
@@ -286,6 +289,9 @@ final availableToolsProvider = StateProvider<List<String>>((ref) => []);
|
|||||||
// Web search enabled state for API-based web search
|
// Web search enabled state for API-based web search
|
||||||
final webSearchEnabledProvider = StateProvider<bool>((ref) => false);
|
final webSearchEnabledProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
|
// Image generation enabled state - behaves like web search
|
||||||
|
final imageGenerationEnabledProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
// Vision capable models provider
|
// Vision capable models provider
|
||||||
final visionCapableModelsProvider = StateProvider<List<String>>((ref) {
|
final visionCapableModelsProvider = StateProvider<List<String>>((ref) {
|
||||||
final selectedModel = ref.watch(selectedModelProvider);
|
final selectedModel = ref.watch(selectedModelProvider);
|
||||||
@@ -383,7 +389,9 @@ Future<void> regenerateMessage(
|
|||||||
String userMessageContent,
|
String userMessageContent,
|
||||||
List<String>? attachments,
|
List<String>? attachments,
|
||||||
) async {
|
) async {
|
||||||
debugPrint('DEBUG: regenerateMessage called with content: $userMessageContent');
|
debugPrint(
|
||||||
|
'DEBUG: regenerateMessage called with content: $userMessageContent',
|
||||||
|
);
|
||||||
|
|
||||||
final reviewerMode = ref.read(reviewerModeProvider);
|
final reviewerMode = ref.read(reviewerModeProvider);
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
@@ -433,7 +441,8 @@ Future<void> regenerateMessage(
|
|||||||
try {
|
try {
|
||||||
// Get conversation history for context (excluding the removed assistant message)
|
// Get conversation history for context (excluding the removed assistant message)
|
||||||
final List<ChatMessage> messages = ref.read(chatMessagesProvider);
|
final List<ChatMessage> messages = ref.read(chatMessagesProvider);
|
||||||
final List<Map<String, dynamic>> conversationMessages = <Map<String, dynamic>>[];
|
final List<Map<String, dynamic>> conversationMessages =
|
||||||
|
<Map<String, dynamic>>[];
|
||||||
|
|
||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
|
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
|
||||||
@@ -452,10 +461,7 @@ Future<void> regenerateMessage(
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Regular text message
|
// Regular text message
|
||||||
conversationMessages.add({
|
conversationMessages.add({'role': msg.role, 'content': msg.content});
|
||||||
'role': msg.role,
|
|
||||||
'content': msg.content,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,7 +502,6 @@ Future<void> regenerateMessage(
|
|||||||
|
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
await _saveConversationLocally(ref);
|
await _saveConversationLocally(ref);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('DEBUG: Error during message regeneration: $e');
|
debugPrint('DEBUG: Error during message regeneration: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -542,10 +547,14 @@ Future<void> _sendMessageInternal(
|
|||||||
// Check if we need to create a new conversation first
|
// Check if we need to create a new conversation first
|
||||||
var activeConversation = ref.read(activeConversationProvider);
|
var activeConversation = ref.read(activeConversationProvider);
|
||||||
|
|
||||||
debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}');
|
debugPrint(
|
||||||
|
'DEBUG: Active conversation before send: ${activeConversation?.id}',
|
||||||
|
);
|
||||||
|
|
||||||
// Create user message first
|
// Create user message first
|
||||||
debugPrint('DEBUG: Creating user message with attachments: $attachments, tools: $toolIds');
|
debugPrint(
|
||||||
|
'DEBUG: Creating user message with attachments: $attachments, tools: $toolIds',
|
||||||
|
);
|
||||||
final userMessage = ChatMessage(
|
final userMessage = ChatMessage(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -790,14 +799,18 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if web search is enabled for API
|
// Check feature toggles for API
|
||||||
final webSearchEnabled = ref.read(webSearchEnabledProvider);
|
final webSearchEnabled = ref.read(webSearchEnabledProvider);
|
||||||
|
final imageGenerationEnabled = ref.read(imageGenerationEnabledProvider);
|
||||||
|
|
||||||
// Debug log to track web search state
|
// Debug log to track feature toggle states
|
||||||
debugPrint('DEBUG: Web search toggle state: $webSearchEnabled');
|
debugPrint('DEBUG: Web search toggle state: $webSearchEnabled');
|
||||||
|
debugPrint('DEBUG: Image generation toggle state: $imageGenerationEnabled');
|
||||||
|
|
||||||
// Prepare tools list - pass tool IDs directly
|
// Prepare tools list - pass tool IDs directly
|
||||||
final List<String>? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) ? toolIds : null;
|
final List<String>? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty)
|
||||||
|
? toolIds
|
||||||
|
: null;
|
||||||
if (toolIdsForApi != null) {
|
if (toolIdsForApi != null) {
|
||||||
debugPrint('DEBUG: Including tool IDs: $toolIdsForApi');
|
debugPrint('DEBUG: Including tool IDs: $toolIdsForApi');
|
||||||
}
|
}
|
||||||
@@ -934,6 +947,7 @@ Future<void> _sendMessageInternal(
|
|||||||
conversationId: activeConversation?.id,
|
conversationId: activeConversation?.id,
|
||||||
toolIds: toolIdsForApi,
|
toolIds: toolIdsForApi,
|
||||||
enableWebSearch: webSearchEnabled,
|
enableWebSearch: webSearchEnabled,
|
||||||
|
enableImageGeneration: imageGenerationEnabled,
|
||||||
modelItem: modelItem,
|
modelItem: modelItem,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1014,7 +1028,9 @@ Future<void> _sendMessageInternal(
|
|||||||
chunk.contains('web search')) {
|
chunk.contains('web search')) {
|
||||||
isSearching = true;
|
isSearching = true;
|
||||||
// Update the message to show search status
|
// Update the message to show search status
|
||||||
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
|
ref
|
||||||
|
.read(chatMessagesProvider.notifier)
|
||||||
|
.updateLastMessageWithFunction(
|
||||||
(message) => message.copyWith(
|
(message) => message.copyWith(
|
||||||
content: '🔍 Searching the web...',
|
content: '🔍 Searching the web...',
|
||||||
metadata: {'webSearchActive': true},
|
metadata: {'webSearchActive': true},
|
||||||
@@ -1025,11 +1041,14 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if web search is complete
|
// Check if web search is complete
|
||||||
if (isSearching && (chunk.contains('[/SEARCHING]') ||
|
if (isSearching &&
|
||||||
|
(chunk.contains('[/SEARCHING]') ||
|
||||||
chunk.contains('Search complete'))) {
|
chunk.contains('Search complete'))) {
|
||||||
isSearching = false;
|
isSearching = false;
|
||||||
// Clear the search status message
|
// Clear the search status message
|
||||||
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
|
ref
|
||||||
|
.read(chatMessagesProvider.notifier)
|
||||||
|
.updateLastMessageWithFunction(
|
||||||
(message) => message.copyWith(
|
(message) => message.copyWith(
|
||||||
content: '',
|
content: '',
|
||||||
metadata: {'webSearchActive': false},
|
metadata: {'webSearchActive': false},
|
||||||
@@ -1110,7 +1129,6 @@ Future<void> _sendMessageInternal(
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}',
|
'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}',
|
||||||
);
|
);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('DEBUG: Chat completed notification failed: $e');
|
debugPrint('DEBUG: Chat completed notification failed: $e');
|
||||||
debugPrint('DEBUG: Error details: $e');
|
debugPrint('DEBUG: Error details: $e');
|
||||||
@@ -1136,8 +1154,15 @@ Future<void> _sendMessageInternal(
|
|||||||
|
|
||||||
// If title is still "New Chat" and this is the first exchange, trigger title generation
|
// If title is still "New Chat" and this is the first exchange, trigger title generation
|
||||||
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
||||||
debugPrint('DEBUG: Triggering title generation for conversation ${activeConversation.id}');
|
debugPrint(
|
||||||
_triggerTitleGeneration(ref, activeConversation.id, formattedMessages, selectedModel.id);
|
'DEBUG: Triggering title generation for conversation ${activeConversation.id}',
|
||||||
|
);
|
||||||
|
_triggerTitleGeneration(
|
||||||
|
ref,
|
||||||
|
activeConversation.id,
|
||||||
|
formattedMessages,
|
||||||
|
selectedModel.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always combine current local messages with updated server content
|
// Always combine current local messages with updated server content
|
||||||
@@ -1292,9 +1317,7 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Streaming already marked as complete when stream ended
|
// Streaming already marked as complete when stream ended
|
||||||
debugPrint(
|
debugPrint('DEBUG: Server content replacement completed');
|
||||||
'DEBUG: Server content replacement completed',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Start background title check for first message exchanges
|
// Start background title check for first message exchanges
|
||||||
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
||||||
@@ -1320,6 +1343,127 @@ Future<void> _sendMessageInternal(
|
|||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
await _saveConversationToServer(ref);
|
await _saveConversationToServer(ref);
|
||||||
debugPrint('DEBUG: Conversation save completed');
|
debugPrint('DEBUG: Conversation save completed');
|
||||||
|
|
||||||
|
// If image generation is enabled, trigger image generation with the user's prompt
|
||||||
|
if (imageGenerationEnabled) {
|
||||||
|
try {
|
||||||
|
debugPrint('DEBUG: Image generation enabled - triggering request');
|
||||||
|
final imageResponse = await api.generateImage(prompt: message);
|
||||||
|
|
||||||
|
// Extract image URLs or base64 data URIs from response
|
||||||
|
List<Map<String, dynamic>> extractGeneratedFiles(dynamic resp) {
|
||||||
|
final results = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
// If it's already a list (e.g., list of URLs or file maps)
|
||||||
|
if (resp is List) {
|
||||||
|
for (final item in resp) {
|
||||||
|
if (item is String && item.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': item});
|
||||||
|
} else if (item is Map) {
|
||||||
|
final url = item['url'];
|
||||||
|
final b64 = item['b64_json'] ?? item['b64'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': url});
|
||||||
|
} else if (b64 is String && b64.isNotEmpty) {
|
||||||
|
results.add({
|
||||||
|
'type': 'image',
|
||||||
|
'url': 'data:image/png;base64,$b64',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp is! Map) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common patterns: { data: [ { url }, { b64_json } ] }
|
||||||
|
final data = resp['data'];
|
||||||
|
if (data is List) {
|
||||||
|
for (final item in data) {
|
||||||
|
if (item is Map) {
|
||||||
|
final url = item['url'];
|
||||||
|
final b64 = item['b64_json'] ?? item['b64'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': url});
|
||||||
|
} else if (b64 is String && b64.isNotEmpty) {
|
||||||
|
// Default to PNG for base64 images
|
||||||
|
results.add({
|
||||||
|
'type': 'image',
|
||||||
|
'url': 'data:image/png;base64,$b64',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (item is String && item.isNotEmpty) {
|
||||||
|
// Some servers may return a list of URLs
|
||||||
|
results.add({'type': 'image', 'url': item});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative patterns
|
||||||
|
final images = resp['images'];
|
||||||
|
if (images is List) {
|
||||||
|
for (final item in images) {
|
||||||
|
if (item is String && item.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': item});
|
||||||
|
} else if (item is Map) {
|
||||||
|
final url = item['url'];
|
||||||
|
final b64 = item['b64_json'] ?? item['b64'];
|
||||||
|
if (url is String && url.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': url});
|
||||||
|
} else if (b64 is String && b64.isNotEmpty) {
|
||||||
|
results.add({
|
||||||
|
'type': 'image',
|
||||||
|
'url': 'data:image/png;base64,$b64',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single fields
|
||||||
|
final singleUrl = resp['url'];
|
||||||
|
if (singleUrl is String && singleUrl.isNotEmpty) {
|
||||||
|
results.add({'type': 'image', 'url': singleUrl});
|
||||||
|
}
|
||||||
|
final singleB64 = resp['b64_json'] ?? resp['b64'];
|
||||||
|
if (singleB64 is String && singleB64.isNotEmpty) {
|
||||||
|
results.add({
|
||||||
|
'type': 'image',
|
||||||
|
'url': 'data:image/png;base64,$singleB64',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
final generatedFiles = extractGeneratedFiles(imageResponse);
|
||||||
|
if (generatedFiles.isNotEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'DEBUG: Image generation returned ${generatedFiles.length} file(s)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach images to the last assistant message
|
||||||
|
ref
|
||||||
|
.read(chatMessagesProvider.notifier)
|
||||||
|
.updateLastMessageWithFunction((ChatMessage m) {
|
||||||
|
final currentFiles = m.files ?? <Map<String, dynamic>>[];
|
||||||
|
return m.copyWith(
|
||||||
|
files: [...currentFiles, ...generatedFiles],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save updated conversation with images
|
||||||
|
await _saveConversationToServer(ref);
|
||||||
|
} else {
|
||||||
|
debugPrint('DEBUG: No images found in generation response');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('DEBUG: Image generation failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
debugPrint('DEBUG: Stream error in chat provider: $error');
|
debugPrint('DEBUG: Stream error in chat provider: $error');
|
||||||
@@ -1354,8 +1498,7 @@ Future<void> _sendMessageInternal(
|
|||||||
final errorMessage = ChatMessage(
|
final errorMessage = ChatMessage(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content:
|
content: '''⚠️ **Message Format Error**
|
||||||
'''⚠️ **Message Format Error**
|
|
||||||
|
|
||||||
This might be because:
|
This might be because:
|
||||||
• Image attachment couldn't be processed
|
• Image attachment couldn't be processed
|
||||||
@@ -1382,8 +1525,7 @@ This might be because:
|
|||||||
final errorMessage = ChatMessage(
|
final errorMessage = ChatMessage(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content:
|
content: '''⚠️ **Server Error**
|
||||||
'''⚠️ **Server Error**
|
|
||||||
|
|
||||||
This usually means:
|
This usually means:
|
||||||
• OpenWebUI server is experiencing issues
|
• OpenWebUI server is experiencing issues
|
||||||
@@ -1407,8 +1549,7 @@ This usually means:
|
|||||||
final errorMessage = ChatMessage(
|
final errorMessage = ChatMessage(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content:
|
content: '''⏱️ **Request Timeout**
|
||||||
'''⏱️ **Request Timeout**
|
|
||||||
|
|
||||||
This might be because:
|
This might be because:
|
||||||
• Server taking too long to respond
|
• Server taking too long to respond
|
||||||
@@ -1512,7 +1653,9 @@ Future<void> _triggerTitleGeneration(
|
|||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api == null) return;
|
if (api == null) return;
|
||||||
|
|
||||||
debugPrint('DEBUG: Requesting title generation for conversation $conversationId');
|
debugPrint(
|
||||||
|
'DEBUG: Requesting title generation for conversation $conversationId',
|
||||||
|
);
|
||||||
|
|
||||||
// Call the title generation endpoint
|
// Call the title generation endpoint
|
||||||
final generatedTitle = await api.generateTitle(
|
final generatedTitle = await api.generateTitle(
|
||||||
@@ -1521,7 +1664,9 @@ Future<void> _triggerTitleGeneration(
|
|||||||
model: model,
|
model: model,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (generatedTitle != null && generatedTitle.isNotEmpty && generatedTitle != 'New Chat') {
|
if (generatedTitle != null &&
|
||||||
|
generatedTitle.isNotEmpty &&
|
||||||
|
generatedTitle != 'New Chat') {
|
||||||
debugPrint('DEBUG: Title generated successfully: $generatedTitle');
|
debugPrint('DEBUG: Title generated successfully: $generatedTitle');
|
||||||
|
|
||||||
// Update the active conversation with the new title
|
// Update the active conversation with the new title
|
||||||
@@ -1535,7 +1680,9 @@ Future<void> _triggerTitleGeneration(
|
|||||||
|
|
||||||
// Save the updated title to the server
|
// Save the updated title to the server
|
||||||
try {
|
try {
|
||||||
debugPrint('DEBUG: Saving generated title to server: $generatedTitle');
|
debugPrint(
|
||||||
|
'DEBUG: Saving generated title to server: $generatedTitle',
|
||||||
|
);
|
||||||
final currentMessages = ref.read(chatMessagesProvider);
|
final currentMessages = ref.read(chatMessagesProvider);
|
||||||
await api.updateConversationWithMessages(
|
await api.updateConversationWithMessages(
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -1564,7 +1711,10 @@ Future<void> _triggerTitleGeneration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Background function to check for title updates without blocking UI
|
// Background function to check for title updates without blocking UI
|
||||||
Future<void> _checkForTitleInBackground(dynamic ref, String conversationId) async {
|
Future<void> _checkForTitleInBackground(
|
||||||
|
dynamic ref,
|
||||||
|
String conversationId,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api == null) return;
|
if (api == null) return;
|
||||||
@@ -1578,7 +1728,9 @@ Future<void> _checkForTitleInBackground(dynamic ref, String conversationId) asyn
|
|||||||
final updatedConv = await api.getConversation(conversationId);
|
final updatedConv = await api.getConversation(conversationId);
|
||||||
|
|
||||||
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
|
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
|
||||||
debugPrint('DEBUG: Background title update found: ${updatedConv.title}');
|
debugPrint(
|
||||||
|
'DEBUG: Background title update found: ${updatedConv.title}',
|
||||||
|
);
|
||||||
|
|
||||||
// Update the active conversation with the new title
|
// Update the active conversation with the new title
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
final activeConversation = ref.read(activeConversationProvider);
|
||||||
@@ -1606,7 +1758,9 @@ Future<void> _checkForTitleInBackground(dynamic ref, String conversationId) asyn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('DEBUG: Background title check completed without finding generated title');
|
debugPrint(
|
||||||
|
'DEBUG: Background title check completed without finding generated title',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('DEBUG: Background title check failed: $e');
|
debugPrint('DEBUG: Background title check failed: $e');
|
||||||
}
|
}
|
||||||
@@ -1646,9 +1800,7 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Conversation ID being updated: ${activeConversation.id}',
|
'DEBUG: Conversation ID being updated: ${activeConversation.id}',
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint('DEBUG: Number of messages to save: ${messages.length}');
|
||||||
'DEBUG: Number of messages to save: ${messages.length}',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.updateConversationWithMessages(
|
await api.updateConversationWithMessages(
|
||||||
@@ -1724,7 +1876,9 @@ Future<void> _saveConversationLocally(dynamic ref) async {
|
|||||||
final List<dynamic> conversations = jsonDecode(conversationsJson);
|
final List<dynamic> conversations = jsonDecode(conversationsJson);
|
||||||
|
|
||||||
// Find and update or add the conversation
|
// Find and update or add the conversation
|
||||||
final existingIndex = conversations.indexWhere((c) => c['id'] == updatedConversation.id);
|
final existingIndex = conversations.indexWhere(
|
||||||
|
(c) => c['id'] == updatedConversation.id,
|
||||||
|
);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
conversations[existingIndex] = updatedConversation.toJson();
|
conversations[existingIndex] = updatedConversation.toJson();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:io' show Platform;
|
|||||||
import '../../../core/models/tool.dart';
|
import '../../../core/models/tool.dart';
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
import '../../chat/providers/chat_providers.dart';
|
import '../../chat/providers/chat_providers.dart';
|
||||||
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../providers/tools_providers.dart';
|
import '../providers/tools_providers.dart';
|
||||||
|
|
||||||
class UnifiedToolsModal extends ConsumerStatefulWidget {
|
class UnifiedToolsModal extends ConsumerStatefulWidget {
|
||||||
@@ -19,6 +20,8 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
|
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
|
||||||
|
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
|
||||||
|
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
|
||||||
final selectedToolIds = ref.watch(selectedToolIdsProvider);
|
final selectedToolIds = ref.watch(selectedToolIdsProvider);
|
||||||
final toolsAsync = ref.watch(toolsListProvider);
|
final toolsAsync = ref.watch(toolsListProvider);
|
||||||
|
|
||||||
@@ -60,6 +63,12 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
_buildWebSearchToggle(webSearchEnabled),
|
_buildWebSearchToggle(webSearchEnabled),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
|
|
||||||
|
// Image Generation Toggle (conditionally shown)
|
||||||
|
if (imageGenAvailable) ...[
|
||||||
|
_buildImageGenerationToggle(imageGenEnabled),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
],
|
||||||
|
|
||||||
// Tools Section
|
// Tools Section
|
||||||
toolsAsync.when(
|
toolsAsync.when(
|
||||||
data: (tools) {
|
data: (tools) {
|
||||||
@@ -95,10 +104,15 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
...tools.map((tool) => Padding(
|
...tools.map(
|
||||||
|
(tool) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
padding: const EdgeInsets.only(bottom: Spacing.sm),
|
||||||
child: _buildToolCard(tool, selectedToolIds.contains(tool.id)),
|
child: _buildToolCard(
|
||||||
)),
|
tool,
|
||||||
|
selectedToolIds.contains(tool.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -221,17 +235,95 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildImageGenerationToggle(bool imageGenEnabled) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
ref.read(imageGenerationEnabledProvider.notifier).state =
|
||||||
|
!imageGenEnabled;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: context.conduitTheme.cardBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: context.conduitTheme.cardBorder,
|
||||||
|
width: BorderWidth.regular,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.photo : Icons.image,
|
||||||
|
size: IconSize.medium,
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.textPrimary.withValues(
|
||||||
|
alpha: Alpha.strong,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Image Generation',
|
||||||
|
style: AppTypography.labelStyle.copyWith(
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
imageGenEnabled
|
||||||
|
? 'I can generate images from your prompt'
|
||||||
|
: 'Enable to generate images with your request',
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimaryText.withValues(
|
||||||
|
alpha: Alpha.strong,
|
||||||
|
)
|
||||||
|
: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
imageGenEnabled ? Icons.toggle_on : Icons.toggle_off,
|
||||||
|
size: IconSize.large,
|
||||||
|
color: imageGenEnabled
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildToolCard(Tool tool, bool isSelected) {
|
Widget _buildToolCard(Tool tool, bool isSelected) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
final currentIds = ref.read(selectedToolIdsProvider);
|
final currentIds = ref.read(selectedToolIdsProvider);
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
ref.read(selectedToolIdsProvider.notifier).state =
|
ref.read(selectedToolIdsProvider.notifier).state = currentIds
|
||||||
currentIds.where((id) => id != tool.id).toList();
|
.where((id) => id != tool.id)
|
||||||
|
.toList();
|
||||||
} else {
|
} else {
|
||||||
ref.read(selectedToolIdsProvider.notifier).state =
|
ref.read(selectedToolIdsProvider.notifier).state = [
|
||||||
[...currentIds, tool.id];
|
...currentIds,
|
||||||
|
tool.id,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -310,7 +402,9 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
if (toolName.contains('image') || toolName.contains('vision')) {
|
if (toolName.contains('image') || toolName.contains('vision')) {
|
||||||
return Platform.isIOS ? CupertinoIcons.photo : Icons.image;
|
return Platform.isIOS ? CupertinoIcons.photo : Icons.image;
|
||||||
} else if (toolName.contains('code') || toolName.contains('python')) {
|
} else if (toolName.contains('code') || toolName.contains('python')) {
|
||||||
return Platform.isIOS ? CupertinoIcons.chevron_left_slash_chevron_right : Icons.code;
|
return Platform.isIOS
|
||||||
|
? CupertinoIcons.chevron_left_slash_chevron_right
|
||||||
|
: Icons.code;
|
||||||
} else if (toolName.contains('calculator') || toolName.contains('math')) {
|
} else if (toolName.contains('calculator') || toolName.contains('math')) {
|
||||||
return Icons.calculate;
|
return Icons.calculate;
|
||||||
} else if (toolName.contains('file') || toolName.contains('document')) {
|
} else if (toolName.contains('file') || toolName.contains('document')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user