diff --git a/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart index d9d0bc5f..0fa4a3e3 100644 --- a/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart +++ b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart @@ -90,6 +90,34 @@ class DashbotAddTestButton extends ConsumerWidget with DashbotActionMixin { } } +class DashbotApplyCurlButton extends ConsumerWidget with DashbotActionMixin { + @override + final ChatAction action; + const DashbotApplyCurlButton({super.key, required this.action}); + + String _labelForField(String? field) { + switch (field) { + case 'apply_to_selected': + return 'Apply to Selected'; + case 'apply_to_new': + return 'Create New Request'; + default: + return 'Apply'; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = _labelForField(action.field); + return ElevatedButton( + onPressed: () async { + await ref.read(chatViewmodelProvider.notifier).applyAutoFix(action); + }, + child: Text(label), + ); + } +} + class DashbotGenerateLanguagePicker extends ConsumerWidget with DashbotActionMixin { @override @@ -177,6 +205,8 @@ class DashbotActionWidgetFactory { return DashbotGenerateLanguagePicker(action: action); } break; + case ChatActionType.applyCurl: + return DashbotApplyCurlButton(action: action); case ChatActionType.updateField: case ChatActionType.addHeader: case ChatActionType.updateHeader: @@ -203,6 +233,9 @@ class DashbotActionWidgetFactory { if (action.action == 'show_languages' && action.target == 'codegen') { return DashbotGenerateLanguagePicker(action: action); } + if (action.action == 'apply_curl') { + return DashbotApplyCurlButton(action: action); + } if (action.action.contains('update') || action.action.contains('add') || action.action.contains('delete')) { diff --git a/lib/dashbot/core/services/curl_import_service.dart b/lib/dashbot/core/services/curl_import_service.dart new file mode 100644 index 00000000..8d2c5d6f --- /dev/null +++ b/lib/dashbot/core/services/curl_import_service.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; +import 'package:curl_parser/curl_parser.dart'; + +/// Service to parse cURL commands and produce +/// a standard action message map understood by Dashbot. +class CurlImportService { + /// Attempts to parse a cURL string. + /// Returns null if parsing fails. + static Curl? tryParseCurl(String input) { + return Curl.tryParse(input); + } + + /// Convert a parsed Curl into a payload used by Dashbot auto-fix action. + static Map buildActionPayloadFromCurl(Curl curl) { + final headers = + Map.from(curl.headers ?? {}); + bool hasHeader(String key) => + headers.keys.any((k) => k.toLowerCase() == key.toLowerCase()); + void setIfMissing(String key, String? value) { + if (value == null || value.isEmpty) return; + if (!hasHeader(key)) headers[key] = value; + } + + // Map cookie to Cookie header if not present + setIfMissing('Cookie', curl.cookie); + // Map user agent and referer to headers if not present + setIfMissing('User-Agent', curl.userAgent); + setIfMissing('Referer', curl.referer); + // Map -u user:password to Authorization: Basic ... if not already present + if (!hasHeader('Authorization') && (curl.user?.isNotEmpty ?? false)) { + final basic = base64.encode(utf8.encode(curl.user!)); + headers['Authorization'] = 'Basic $basic'; + } + + return { + 'method': curl.method, + 'url': curl.uri.toString(), + 'headers': headers, + 'body': curl.data, + 'form': curl.form, + 'formData': curl.formData + ?.map((f) => { + 'name': f.name, + 'value': f.value, + 'type': f.type.name, + }) + .toList(), + }; + } + + /// Build the message object with two actions: apply to selected or new. + static Map buildActionMessageFromPayload( + Map actionPayload, + {String? note}) { + final explanation = StringBuffer( + 'Parsed the cURL command. Where do you want to apply the changes? Choose one of the options below.'); + if (note != null && note.isNotEmpty) { + explanation.writeln(''); + explanation.write('Note: $note'); + } + return { + 'explnation': explanation.toString(), + 'actions': [ + { + 'action': 'apply_curl', + 'target': 'httpRequestModel', + 'field': 'apply_to_selected', + 'path': null, + 'value': actionPayload, + }, + { + 'action': 'apply_curl', + 'target': 'httpRequestModel', + 'field': 'apply_to_new', + 'path': null, + 'value': actionPayload, + } + ] + }; + } + + /// Convenience: from parsed [Curl] to (json, actions list). + static ({String jsonMessage, List> actions}) + buildResponseFromParsed(Curl curl) { + final payload = buildActionPayloadFromCurl(curl); + // Build a small note for flags that are not represented in the request model + final notes = []; + // if (curl.insecure) notes.add('insecure (-k) is not applied automatically'); + // if (curl.location) { + // notes.add('follow redirects (-L) is not applied automatically'); + // } + final msg = buildActionMessageFromPayload( + payload, + note: notes.isEmpty ? null : notes.join('; '), + ); + final actions = + (msg['actions'] as List).whereType>().toList(); + return (jsonMessage: jsonEncode(msg), actions: actions); + } + + /// High-level helper to process a pasted cURL string. + /// Returns either a built (json, actions) tuple or an error message. + static ({ + String? error, + String? jsonMessage, + List>? actions + }) processPastedCurl(String input) { + try { + final curl = tryParseCurl(input); + if (curl == null) { + return ( + error: + 'Sorry, I could not parse that cURL. Ensure it starts with `curl ` and is complete.', + jsonMessage: null, + actions: null + ); + } + final built = buildResponseFromParsed(curl); + return ( + error: null, + jsonMessage: built.jsonMessage, + actions: built.actions + ); + } catch (e) { + final safe = e.toString().replaceAll('"', "'"); + return (error: 'Parsing failed: $safe', jsonMessage: null, actions: null); + } + } +} diff --git a/lib/dashbot/features/chat/models/chat_models.dart b/lib/dashbot/features/chat/models/chat_models.dart index 875560a1..2293c1bb 100644 --- a/lib/dashbot/features/chat/models/chat_models.dart +++ b/lib/dashbot/features/chat/models/chat_models.dart @@ -91,6 +91,7 @@ enum ChatMessageType { generateTest, generateDoc, generateCode, + importCurl, general } @@ -104,6 +105,7 @@ enum ChatActionType { updateUrl, updateMethod, showLanguages, + applyCurl, other, noAction, uploadAsset, @@ -135,6 +137,8 @@ ChatActionType _chatActionTypeFromString(String s) { return ChatActionType.updateMethod; case 'show_languages': return ChatActionType.showLanguages; + case 'apply_curl': + return ChatActionType.applyCurl; case 'upload_asset': return ChatActionType.uploadAsset; case 'no_action': @@ -164,6 +168,8 @@ String chatActionTypeToString(ChatActionType t) { return 'update_method'; case ChatActionType.showLanguages: return 'show_languages'; + case ChatActionType.applyCurl: + return 'apply_curl'; case ChatActionType.other: return 'other'; case ChatActionType.noAction: diff --git a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart index eaad1b2d..3358c7c8 100644 --- a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart +++ b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:convert'; import 'package:apidash_core/apidash_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:apidash/providers/providers.dart'; import 'package:apidash/models/models.dart'; import 'package:nanoid/nanoid.dart'; +import '../../../core/services/curl_import_service.dart'; import '../../../core/utils/safe_parse_json_message.dart'; import '../../../core/constants/dashbot_prompts.dart' as dash; @@ -47,7 +49,7 @@ class ChatViewmodel extends StateNotifier { '[Chat] sendMessage start: type=$type, countAsUser=$countAsUser'); final ai = _selectedAIModel; if (text.trim().isEmpty && countAsUser) return; - if (ai == null) { + if (ai == null && type != ChatMessageType.importCurl) { debugPrint('[Chat] No AI model configured'); _appendSystem( 'AI model is not configured. Please set one.', @@ -57,6 +59,7 @@ class ChatViewmodel extends StateNotifier { } final requestId = _currentRequest?.id ?? 'global'; + final existingMessages = state.chatSessions[requestId] ?? const []; debugPrint('[Chat] using requestId=$requestId'); if (countAsUser) { @@ -72,25 +75,54 @@ class ChatViewmodel extends StateNotifier { ); } - // Special handling: generateCode flow has two steps - String? systemPrompt; + // If user pasted a cURL in import flow, handle locally without AI + final lastSystemImport = existingMessages.lastWhere( + (m) => + m.role == MessageRole.system && + m.messageType == ChatMessageType.importCurl, + orElse: () => ChatMessage( + id: '', + content: '', + role: MessageRole.system, + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + ), + ); + final importFlowActive = lastSystemImport.id.isNotEmpty; + if (text.trim().startsWith('curl ') && + (type == ChatMessageType.importCurl || importFlowActive)) { + await handlePotentialCurlPaste(text); + return; + } + + String systemPrompt; if (type == ChatMessageType.generateCode) { - // If the user message includes a language (heuristic), go straight to code gen; else show intro + language choices final detectedLang = _detectLanguageFromText(text); systemPrompt = _composeSystemPrompt( _currentRequest, - detectedLang == null - ? ChatMessageType.generateCode - : ChatMessageType.generateCode, + type, overrideLanguage: detectedLang, ); + } else if (type == ChatMessageType.importCurl) { + final rqId = _currentRequest?.id ?? 'global'; + _addMessage( + rqId, + ChatMessage( + id: nanoid(), + content: + '{"explnation":"Let\'s import a cURL request. Paste your complete cURL command below.","actions":[]}', + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: ChatMessageType.importCurl, + ), + ); + return; } else { systemPrompt = _composeSystemPrompt(_currentRequest, type); } final userPrompt = (text.trim().isEmpty && !countAsUser) ? 'Please complete the task based on the provided context.' : text; - final enriched = ai.copyWith( + final enriched = ai!.copyWith( systemPrompt: systemPrompt, userPrompt: userPrompt, stream: true, @@ -240,6 +272,9 @@ class ChatViewmodel extends StateNotifier { case ChatActionType.updateMethod: await _applyMethodUpdate(action); break; + case ChatActionType.applyCurl: + await _applyCurl(action); + break; case ChatActionType.other: await _applyOtherAction(action); break; @@ -375,6 +410,14 @@ class ChatViewmodel extends StateNotifier { case 'test': await _applyTestToPostScript(action); break; + case 'httpRequestModel': + if (action.actionType == ChatActionType.applyCurl) { + await _applyCurl(action); + break; + } + // Unsupported other action + debugPrint('[Chat] Unsupported other action target: ${action.target}'); + break; default: debugPrint('[Chat] Unsupported other action target: ${action.target}'); } @@ -386,14 +429,9 @@ class ChatViewmodel extends StateNotifier { final collectionNotifier = _ref.read(collectionStateNotifierProvider.notifier); - final testCode = action.value as String; - - // Get the current post-request script (if any) - final currentRequest = _currentRequest; - final currentPostScript = currentRequest?.postRequestScript ?? ''; - - // Append the test code to the existing post-request script - final newPostScript = currentPostScript.isEmpty + final testCode = action.value is String ? action.value as String : ''; + final currentPostScript = _currentRequest?.postRequestScript ?? ''; + final newPostScript = currentPostScript.trim().isEmpty ? testCode : '$currentPostScript\n\n// Generated Test\n$testCode'; @@ -405,6 +443,131 @@ class ChatViewmodel extends StateNotifier { ChatMessageType.generateTest); } + // Parse a pasted cURL and present actions to apply to current or new request + Future handlePotentialCurlPaste(String text) async { + // quick check + final trimmed = text.trim(); + if (!trimmed.startsWith('curl ')) return; + try { + debugPrint('[cURL] Original: $trimmed'); + final curl = CurlImportService.tryParseCurl(trimmed); + if (curl == null) { + _appendSystem( + '{"explnation":"Sorry, I couldn\'t parse that cURL command. Please verify it starts with `curl ` and is complete.","actions":[]}', + ChatMessageType.importCurl); + return; + } + final built = CurlImportService.buildResponseFromParsed(curl); + final msg = jsonDecode(built.jsonMessage) as Map; + final rqId = _currentRequest?.id ?? 'global'; + _addMessage( + rqId, + ChatMessage( + id: nanoid(), + content: jsonEncode(msg), + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: ChatMessageType.importCurl, + actions: (msg['actions'] as List) + .whereType>() + .map(ChatAction.fromJson) + .toList(), + ), + ); + } catch (e) { + debugPrint('[cURL] Exception: $e'); + final safe = e.toString().replaceAll('"', "'"); + _appendSystem('{"explnation":"Parsing failed: $safe","actions":[]}', + ChatMessageType.importCurl); + } + } + + Future _applyCurl(ChatAction action) async { + final requestId = _currentRequest?.id; + final collection = _ref.read(collectionStateNotifierProvider.notifier); + final payload = action.value is Map + ? (action.value as Map) + : {}; + + String methodStr = (payload['method'] as String?)?.toLowerCase() ?? 'get'; + final method = HTTPVerb.values.firstWhere( + (m) => m.name == methodStr, + orElse: () => HTTPVerb.get, + ); + final url = payload['url'] as String? ?? ''; + + final headersMap = + (payload['headers'] as Map?)?.cast() ?? {}; + final headers = headersMap.entries + .map((e) => NameValueModel(name: e.key, value: e.value.toString())) + .toList(); + + final body = payload['body'] as String?; + final formFlag = payload['form'] == true; + final formDataListRaw = (payload['formData'] as List?)?.cast(); + final formData = formDataListRaw == null + ? [] + : formDataListRaw + .whereType() + .map((e) => FormDataModel( + name: (e['name'] as String?) ?? '', + value: (e['value'] as String?) ?? '', + type: (() { + final t = (e['type'] as String?) ?? 'text'; + try { + return FormDataType.values + .firstWhere((ft) => ft.name == t); + } catch (_) { + return FormDataType.text; + } + })(), + )) + .toList(); + + ContentType bodyContentType; + if (formFlag || formData.isNotEmpty) { + bodyContentType = ContentType.formdata; + } else if ((body ?? '').trim().isEmpty) { + bodyContentType = ContentType.text; + } else { + // Heuristic JSON detection + try { + jsonDecode(body!); + bodyContentType = ContentType.json; + } catch (_) { + bodyContentType = ContentType.text; + } + } + + if (action.field == 'apply_to_selected') { + if (requestId == null) return; + collection.update( + method: method, + url: url, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + _appendSystem( + 'Applied cURL to the selected request.', ChatMessageType.importCurl); + } else if (action.field == 'apply_to_new') { + final model = HttpRequestModel( + method: method, + url: url, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + collection.addRequestModel(model, name: 'Imported cURL'); + _appendSystem( + 'Created a new request from the cURL.', ChatMessageType.importCurl); + } + } + // Helpers void _addMessage(String requestId, ChatMessage m) { debugPrint( @@ -553,6 +716,9 @@ class ChatViewmodel extends StateNotifier { language: overrideLanguage, ); } + case ChatMessageType.importCurl: + // No AI prompt needed; handled locally. + return null; case ChatMessageType.general: return prompts.generalInteractionPrompt(); } diff --git a/lib/dashbot/features/home/view/pages/home_page.dart b/lib/dashbot/features/home/view/pages/home_page.dart index fac8751e..a5df35bb 100644 --- a/lib/dashbot/features/home/view/pages/home_page.dart +++ b/lib/dashbot/features/home/view/pages/home_page.dart @@ -58,7 +58,16 @@ class _DashbotHomePageState extends ConsumerState { }, ), HomeScreenTaskButton( - label: "🔎 Explain me this response", + label: "📥 Import cURL", + onPressed: () { + Navigator.of(context).pushNamed( + DashbotRoutes.dashbotChat, + arguments: ChatMessageType.importCurl, + ); + }, + ), + HomeScreenTaskButton( + label: "�🔎 Explain me this response", onPressed: () { Navigator.of(context).pushNamed( DashbotRoutes.dashbotChat, diff --git a/pubspec.lock b/pubspec.lock index 0e10a2d7..3071c20e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -375,7 +375,7 @@ packages: source: hosted version: "6.0.0" curl_parser: - dependency: transitive + dependency: "direct main" description: path: "packages/curl_parser" relative: true diff --git a/pubspec.yaml b/pubspec.yaml index b7a53d3e..8e4fb343 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ dependencies: path: packages/json_explorer json_field_editor: path: packages/json_field_editor + curl_parser: + path: packages/curl_parser just_audio: ^0.9.46 just_audio_mpv: ^0.1.7 just_audio_windows: ^0.2.0