diff --git a/lib/core/models/tool.dart b/lib/core/models/tool.dart new file mode 100644 index 0000000..09b9db4 --- /dev/null +++ b/lib/core/models/tool.dart @@ -0,0 +1,26 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'tool.freezed.dart'; + +@freezed +sealed class Tool with _$Tool { + const Tool._(); + + const factory Tool({ + required String id, + required String name, + String? description, + String? userId, + Map? meta, + }) = _Tool; + + factory Tool.fromJson(Map json) { + return Tool( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + userId: json['user_id'] as String?, + meta: json['meta'] as Map?, + ); + } +} \ No newline at end of file diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index e68930c..01ebbe2 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -24,6 +24,9 @@ class ApiService { late final ApiAuthInterceptor _authInterceptor; // Removed legacy websocket/socket.io fields + // Public getter for dio instance + Dio get dio => _dio; + // Callback to notify when auth token becomes invalid void Function()? onAuthTokenInvalid; @@ -2415,7 +2418,7 @@ class ApiService { required List> messages, required String model, String? conversationId, - List>? tools, + List? toolIds, bool enableWebSearch = false, Map? modelItem, }) { @@ -2500,6 +2503,12 @@ class ApiService { debugPrint('DEBUG: Web search enabled in SSE request'); } + // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings) + if (toolIds != null && toolIds.isNotEmpty) { + data['tool_ids'] = toolIds; + debugPrint('DEBUG: Including tool_ids in SSE request: $toolIds'); + } + // Don't add session_id or id - they break SSE streaming! // The server falls back to task-based async when these are present diff --git a/lib/core/services/tools_service.dart b/lib/core/services/tools_service.dart new file mode 100644 index 0000000..f66d25b --- /dev/null +++ b/lib/core/services/tools_service.dart @@ -0,0 +1,29 @@ +import 'package:dio/dio.dart'; +import 'package:conduit/core/models/tool.dart'; +import 'package:conduit/core/services/api_service.dart'; +import 'package:conduit/core/error/api_error_handler.dart'; +import 'package:conduit/core/providers/app_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ToolsService { + final ApiService _apiService; + + ToolsService(this._apiService); + + Future> getTools() async { + try { + final response = await _apiService.dio.get('/api/v1/tools/'); + return (response.data as List) + .map((json) => Tool.fromJson(json)) + .toList(); + } on DioException catch (e) { + throw ApiErrorHandler().transformError(e); + } + } +} + +final toolsServiceProvider = Provider((ref) { + final apiService = ref.watch(apiServiceProvider); + if (apiService == null) return null; + return ToolsService(apiService); +}); \ No newline at end of file diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index b1dc75e..fee8c5c 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -507,20 +507,22 @@ Future regenerateMessage( Future sendMessage( WidgetRef ref, String message, - List? attachments, -) async { + List? attachments, [ + List? toolIds, +]) async { debugPrint( - 'DEBUG: sendMessage called with message: $message, attachments: $attachments', + 'DEBUG: sendMessage called with message: $message, attachments: $attachments, tools: $toolIds', ); - await _sendMessageInternal(ref, message, attachments); + await _sendMessageInternal(ref, message, attachments, toolIds); } // Internal send message implementation Future _sendMessageInternal( dynamic ref, String message, - List? attachments, -) async { + List? attachments, [ + List? toolIds, +]) async { debugPrint('DEBUG: _sendMessageInternal called'); debugPrint('DEBUG: Message: $message'); debugPrint('DEBUG: Attachments: $attachments'); @@ -543,7 +545,7 @@ Future _sendMessageInternal( debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}'); // Create user message first - debugPrint('DEBUG: Creating user message with attachments: $attachments'); + debugPrint('DEBUG: Creating user message with attachments: $attachments, tools: $toolIds'); final userMessage = ChatMessage( id: const Uuid().v4(), role: 'user', @@ -794,8 +796,11 @@ Future _sendMessageInternal( // Debug log to track web search state debugPrint('DEBUG: Web search toggle state: $webSearchEnabled'); - // No need for function calling tools since we're using retrieval directly - final tools = >[]; + // Prepare tools list - pass tool IDs directly + final List? toolIdsForApi = (toolIds != null && toolIds.isNotEmpty) ? toolIds : null; + if (toolIdsForApi != null) { + debugPrint('DEBUG: Including tool IDs: $toolIdsForApi'); + } try { // Use the model's actual supported parameters if available @@ -927,7 +932,7 @@ Future _sendMessageInternal( messages: conversationMessages, model: selectedModel.id, conversationId: activeConversation?.id, - tools: tools.isNotEmpty ? tools : null, + toolIds: toolIdsForApi, enableWebSearch: webSearchEnabled, modelItem: modelItem, ); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 5a5d715..76376b0 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -22,6 +22,7 @@ import '../services/file_attachment_service.dart'; import '../../navigation/views/chats_list_page.dart'; import '../../files/views/files_page.dart'; import '../../profile/views/profile_page.dart'; +import '../../tools/providers/tools_providers.dart'; import '../../../shared/widgets/offline_indicator.dart'; import '../../../core/services/connectivity_service.dart'; import '../../../core/models/chat_message.dart'; @@ -289,11 +290,16 @@ class _ChatPageState extends ConsumerState { debugPrint('DEBUG: Uploaded file IDs: $uploadedFileIds'); - // Send message with file attachments using existing provider logic + // Get selected tools + final toolIds = ref.read(selectedToolIdsProvider); + debugPrint('DEBUG: Selected tool IDs: $toolIds'); + + // Send message with file attachments and tools using existing provider logic await sendMessage( ref, text, uploadedFileIds.isNotEmpty ? uploadedFileIds : null, + toolIds.isNotEmpty ? toolIds : null, ); debugPrint('DEBUG: Message sent successfully'); @@ -744,8 +750,6 @@ class _ChatPageState extends ConsumerState { ).push(MaterialPageRoute(builder: (context) => const FilesPage())); } - - void _navigateToProfile() { Navigator.of( context, diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index b83acb0..a517b49 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'dart:io' show Platform; import 'dart:async'; import '../providers/chat_providers.dart'; +import '../../tools/widgets/tool_selector.dart'; +import '../../tools/providers/tools_providers.dart'; import '../../../shared/utils/platform_utils.dart'; @@ -47,6 +49,7 @@ class _ModernChatInputState extends ConsumerState late AnimationController _pulseController; Timer? _blurCollapseTimer; bool _hasAutoFocusedOnce = false; + bool _showToolSelector = false; @override void initState() { @@ -165,6 +168,11 @@ class _ModernChatInputState extends ConsumerState PlatformUtils.lightHaptic(); widget.onSendMessage(text); _controller.clear(); + setState(() { + _showToolSelector = false; + }); + // Clear selected tools after sending + ref.read(selectedToolIdsProvider.notifier).state = []; // Keep input expanded and focused for better UX - don't dismiss keyboard // KeyboardUtils.dismissKeyboard(context); // _setExpanded(false); @@ -346,6 +354,14 @@ class _ModernChatInputState extends ConsumerState // Expanded bottom row with additional options if (_isExpanded) ...[ + // Tool selector + if (_showToolSelector) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: Spacing.inputPadding, + ), + child: ToolSelector(), + ), Container( padding: const EdgeInsets.only( left: Spacing.inputPadding, @@ -364,6 +380,20 @@ class _ModernChatInputState extends ConsumerState tooltip: 'Add attachment', ), const SizedBox(width: Spacing.sm), + // Tools button + _buildRoundButton( + icon: Icons.build, + onTap: widget.enabled + ? () { + setState(() { + _showToolSelector = !_showToolSelector; + }); + } + : null, + tooltip: 'Tools', + isActive: _showToolSelector || ref.watch(selectedToolIdsProvider).isNotEmpty, + ), + const SizedBox(width: Spacing.sm), Flexible( child: Center(child: _buildResearchToggle()), ), diff --git a/lib/features/tools/providers/tools_providers.dart b/lib/features/tools/providers/tools_providers.dart new file mode 100644 index 0000000..afa46ea --- /dev/null +++ b/lib/features/tools/providers/tools_providers.dart @@ -0,0 +1,11 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:conduit/core/models/tool.dart'; +import 'package:conduit/core/services/tools_service.dart'; + +final toolsListProvider = FutureProvider>((ref) async { + final toolsService = ref.watch(toolsServiceProvider); + if (toolsService == null) return []; + return await toolsService.getTools(); +}); + +final selectedToolIdsProvider = StateProvider>((ref) => []); \ No newline at end of file diff --git a/lib/features/tools/widgets/tool_selector.dart b/lib/features/tools/widgets/tool_selector.dart new file mode 100644 index 0000000..a7bfa6f --- /dev/null +++ b/lib/features/tools/widgets/tool_selector.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:conduit/features/tools/providers/tools_providers.dart'; + +class ToolSelector extends ConsumerWidget { + const ToolSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final toolsAsync = ref.watch(toolsListProvider); + final selectedIds = ref.watch(selectedToolIdsProvider); + final theme = Theme.of(context); + + return toolsAsync.when( + data: (tools) { + if (tools.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + height: 40, + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: tools.length, + separatorBuilder: (context, index) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final tool = tools[index]; + final isSelected = selectedIds.contains(tool.id); + + return FilterChip( + label: Text(tool.name), + selected: isSelected, + onSelected: (_) { + final currentIds = ref.read(selectedToolIdsProvider); + if (isSelected) { + ref.read(selectedToolIdsProvider.notifier).state = + currentIds.where((id) => id != tool.id).toList(); + } else { + ref.read(selectedToolIdsProvider.notifier).state = + [...currentIds, tool.id]; + } + }, + avatar: Icon( + Icons.build, + size: 16, + color: isSelected + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ); + } +} \ No newline at end of file