From ce3248dec0828df0245cbf6b2f26933a0b72321b Mon Sep 17 00:00:00 2001 From: Udhay-Adithya Date: Sun, 21 Sep 2025 00:32:08 +0530 Subject: [PATCH] feat: improve ui for openapi specs feature --- .../widgets/dashbot_action_buttons.dart | 74 +++++++++- .../core/constants/dashbot_prompts.dart | 34 +++++ .../core/services/openapi_import_service.dart | 72 ++++++---- .../openapi_operation_picker_dialog.dart | 129 ++++++++++++++++++ .../chat/viewmodel/chat_viewmodel.dart | 114 +++++++++++++++- 5 files changed, 392 insertions(+), 31 deletions(-) create mode 100644 lib/dashbot/features/chat/view/widgets/openapi_operation_picker_dialog.dart diff --git a/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart index 5b3847a2..9c64d7de 100644 --- a/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart +++ b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart @@ -4,6 +4,9 @@ import '../../../features/chat/models/chat_models.dart'; import '../../../features/chat/viewmodel/chat_viewmodel.dart'; import '../../../features/chat/providers/attachments_provider.dart'; import 'package:file_selector/file_selector.dart'; +import '../../services/openapi_import_service.dart'; +import '../../../features/chat/view/widgets/openapi_operation_picker_dialog.dart'; +import 'package:openapi_spec/openapi_spec.dart'; /// Base mixin for action widgets. mixin DashbotActionMixin { @@ -212,6 +215,65 @@ class DashbotSelectOperationButton extends ConsumerWidget } } +class DashbotImportNowButton extends ConsumerWidget with DashbotActionMixin { + @override + final ChatAction action; + const DashbotImportNowButton({super.key, required this.action}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return FilledButton.icon( + icon: const Icon(Icons.playlist_add_check, size: 16), + label: const Text('Import Now'), + onPressed: () async { + try { + OpenApi? spec; + String? sourceName; + if (action.value is Map) { + final map = action.value as Map; + sourceName = map['sourceName'] as String?; + if (map['spec'] is OpenApi) { + spec = map['spec'] as OpenApi; + } else if (map['content'] is String) { + spec = + OpenApiImportService.tryParseSpec(map['content'] as String); + } + } + if (spec == null) return; + + final servers = spec.servers ?? const []; + final baseUrl = servers.isNotEmpty ? (servers.first.url ?? '/') : '/'; + + final selected = await showOpenApiOperationPickerDialog( + context: context, + spec: spec, + sourceName: sourceName, + ); + if (selected == null || selected.isEmpty) return; + + final notifier = ref.read(chatViewmodelProvider.notifier); + for (final s in selected) { + final payload = OpenApiImportService.payloadForOperation( + baseUrl: baseUrl, + path: s.path, + method: s.method, + op: s.op, + ); + await notifier.applyAutoFix(ChatAction.fromJson({ + 'action': 'apply_openapi', + 'actionType': 'apply_openapi', + 'target': 'httpRequestModel', + 'targetType': 'httpRequestModel', + 'field': 'apply_to_new', + 'value': payload, + })); + } + } catch (_) {} + }, + ); + } +} + class DashbotGeneratedCodeBlock extends StatelessWidget with DashbotActionMixin { @override @@ -246,6 +308,9 @@ class DashbotActionWidgetFactory { static Widget? build(ChatAction action) { switch (action.actionType) { case ChatActionType.other: + if (action.action == 'import_now_openapi') { + return DashbotImportNowButton(action: action); + } if (action.field == 'select_operation') { return DashbotSelectOperationButton(action: action); } @@ -265,6 +330,12 @@ class DashbotActionWidgetFactory { return DashbotApplyCurlButton(action: action); case ChatActionType.applyOpenApi: return DashbotApplyOpenApiButton(action: action); + case ChatActionType.noAction: + // If downstream requests, render an Import Now for OpenAPI contexts + if (action.action == 'import_now_openapi') { + return DashbotImportNowButton(action: action); + } + return null; case ChatActionType.updateField: case ChatActionType.addHeader: case ChatActionType.updateHeader: @@ -273,8 +344,7 @@ class DashbotActionWidgetFactory { case ChatActionType.updateUrl: case ChatActionType.updateMethod: return DashbotAutoFixButton(action: action); - case ChatActionType.noAction: - return null; + case ChatActionType.uploadAsset: if (action.targetType == ChatActionTarget.attachment) { return DashbotUploadRequestButton(action: action); diff --git a/lib/dashbot/core/constants/dashbot_prompts.dart b/lib/dashbot/core/constants/dashbot_prompts.dart index 5faaf06d..857e7d2b 100644 --- a/lib/dashbot/core/constants/dashbot_prompts.dart +++ b/lib/dashbot/core/constants/dashbot_prompts.dart @@ -445,6 +445,40 @@ Where "explnation" must include: REFUSAL TEMPLATE (when off-topic), JSON only: {"explnation":"I am Dashbot, an AI assistant focused specifically on API development tasks within API Dash. My capabilities are limited to explaining API responses, debugging requests, generating documentation, creating tests, visualizing API data, and generating integration code. Therefore, I cannot answer questions outside of this scope. How can I assist you with an API-related task?","actions":[]} +RETURN THE JSON ONLY. + +"""; + } + + // Provide insights and suggestions after importing an OpenAPI spec + String openApiInsightsPrompt({ + required String specSummary, + }) { + return """ + +YOU ARE Dashbot, an API Insights Assistant specialized in analyzing OpenAPI specifications within API Dash. + +STRICT OFF-TOPIC POLICY +- If a request is unrelated to APIs, refuse. Return JSON with only "explnation" and an empty "actions": []. + +CONTEXT (OPENAPI SUMMARY) +${specSummary.trim()} + +TASK +- Provide practical, user-friendly insights based on the API spec: + - Identify noteworthy endpoints (e.g., CRUD sets, auth/login, health/status) and common patterns. + - Point out authentication/security requirements (e.g., API keys, OAuth scopes) if present. + - Suggest a few starter calls (e.g., list/search) and a short onboarding path. + - Call out potential pitfalls (rate limits, pagination, required headers, content types). +- Keep it concise and actionable: 1–2 line summary → 4–6 bullets → 2–3 next steps. + +OUTPUT FORMAT (STRICT) +- Return ONLY a single JSON object. +- Keys: {"explnation": string, "actions": []} + +REFUSAL TEMPLATE (when off-topic), JSON only: +{"explnation":"I am Dashbot, an AI assistant focused specifically on API development tasks within API Dash. My capabilities are limited to explaining API responses, debugging requests, generating documentation, creating tests, visualizing API data, and generating integration code. Therefore, I cannot answer questions outside of this scope. How can I assist you with an API-related task?","actions":[]} + RETURN THE JSON ONLY. """; diff --git a/lib/dashbot/core/services/openapi_import_service.dart b/lib/dashbot/core/services/openapi_import_service.dart index 03e75bcf..ea262610 100644 --- a/lib/dashbot/core/services/openapi_import_service.dart +++ b/lib/dashbot/core/services/openapi_import_service.dart @@ -85,6 +85,7 @@ class OpenApiImportService { return { 'method': method.toUpperCase(), 'url': url, + 'baseUrl': baseUrl, 'headers': headers, 'body': body, 'form': isForm, @@ -92,6 +93,20 @@ class OpenApiImportService { }; } + /// Public wrapper to build a request payload for a given operation. + static Map payloadForOperation({ + required String baseUrl, + required String path, + required String method, + required Operation op, + }) => + _payloadForOperation( + baseUrl: baseUrl, + path: path, + method: method, + op: op, + ); + /// Build an action message asking whether to apply to selected/new /// for a single chosen operation. static Map buildActionMessageFromPayload( @@ -121,15 +136,12 @@ class OpenApiImportService { } /// Build a list of operations from the spec, and if multiple are found, - /// return a JSON with explnation and an actions array of type "other" - /// where each action value holds an actionPayload for that operation and - /// path/method in the path field for UI display. The Chat model will emit - /// a follow-up message once the user picks one. + /// return a JSON with a single "Import Now" style action to open + /// a selection dialog in the UI, avoiding rendering dozens of buttons. static Map buildOperationPicker(OpenApi spec) { final servers = spec.servers ?? const []; - final baseUrl = servers.isNotEmpty ? (servers.first.url ?? '/') : '/'; - final actions = >[]; - + int endpointsCount = 0; + final methods = {}; (spec.paths ?? const {}).forEach((path, item) { final ops = { 'GET': item.get, @@ -143,23 +155,12 @@ class OpenApiImportService { }; ops.forEach((method, op) { if (op == null) return; - final payload = _payloadForOperation( - baseUrl: baseUrl, - path: path, - method: method, - op: op, - ); - actions.add({ - 'action': 'other', - 'target': 'httpRequestModel', - 'field': 'select_operation', - 'path': '$method $path', - 'value': payload, - }); + endpointsCount += 1; + methods.add(method); }); }); - if (actions.isEmpty) { + if (endpointsCount == 0) { return { 'explnation': 'No operations found in the OpenAPI spec. Please check the file.', @@ -167,10 +168,33 @@ class OpenApiImportService { }; } + // Build a short spec summary for downstream insights prompt + final title = spec.info.title.isNotEmpty ? spec.info.title : 'Untitled API'; + final version = spec.info.version; + final server = servers.isNotEmpty ? servers.first.url : null; + final summary = StringBuffer() + ..writeln('- Title: $title (v$version)') + ..writeln('- Server: ${server ?? '/'}') + ..writeln('- Endpoints discovered: $endpointsCount') + ..writeln('- Methods: ${methods.join(', ')}'); + return { - 'explnation': - 'OpenAPI parsed. Select an operation to import as a request:', - 'actions': actions, + 'explnation': 'OpenAPI parsed. Click Import Now to choose operations.', + 'actions': [ + { + 'action': 'import_now_openapi', + 'target': 'httpRequestModel', + 'field': '', + 'path': null, + 'value': { + 'spec': spec, + 'sourceName': title, + } + } + ], + 'meta': { + 'openapi_summary': summary.toString(), + } }; } } diff --git a/lib/dashbot/features/chat/view/widgets/openapi_operation_picker_dialog.dart b/lib/dashbot/features/chat/view/widgets/openapi_operation_picker_dialog.dart new file mode 100644 index 00000000..c18b547c --- /dev/null +++ b/lib/dashbot/features/chat/view/widgets/openapi_operation_picker_dialog.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:openapi_spec/openapi_spec.dart'; + + +typedef OpenApiOperationItem = ({String method, String path, Operation op}); + +Future?> showOpenApiOperationPickerDialog({ + required BuildContext context, + required OpenApi spec, + String? sourceName, +}) async { + final title = (spec.info.title.trim().isNotEmpty + ? spec.info.title.trim() + : (sourceName ?? 'OpenAPI')) + .trim(); + + final ops = []; + (spec.paths ?? const {}).forEach((path, item) { + final map = { + 'GET': item.get, + 'POST': item.post, + 'PUT': item.put, + 'DELETE': item.delete, + 'PATCH': item.patch, + 'HEAD': item.head, + 'OPTIONS': item.options, + 'TRACE': item.trace, + }; + map.forEach((method, op) { + if (op != null) { + ops.add((method: method, path: path, op: op)); + } + }); + }); + + if (ops.isEmpty) { + // Nothing to select; return empty selection. + return []; + } + + // Multi-select: default select all + final selected = {for (var i = 0; i < ops.length; i++) i}; + bool selectAll = ops.isNotEmpty; + + return showDialog>( + context: context, + builder: (ctx) { + return StatefulBuilder(builder: (ctx, setState) { + return AlertDialog( + title: Text('Import from $title'), + content: SizedBox( + width: 520, + height: 420, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ExpansionTile( + initiallyExpanded: true, + title: const Text('Available operations'), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CheckboxListTile( + value: selectAll, + onChanged: (v) { + setState(() { + selectAll = v ?? false; + selected + ..clear() + ..addAll(selectAll + ? List.generate(ops.length, (i) => i) + : const {}); + }); + }, + title: const Text('Select all'), + controlAffinity: ListTileControlAffinity.leading, + ), + ), + SizedBox( + height: 300, + child: ListView.builder( + itemCount: ops.length, + itemBuilder: (c, i) { + final o = ops[i]; + final label = '${o.method} ${o.path}'; + final checked = selected.contains(i); + return CheckboxListTile( + value: checked, + onChanged: (v) { + setState(() { + if (v == true) { + selected.add(i); + } else { + selected.remove(i); + } + selectAll = selected.length == ops.length; + }); + }, + title: Text(label), + controlAffinity: ListTileControlAffinity.leading, + ); + }, + ), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(null), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: selected.isEmpty + ? null + : () { + final result = selected.map((i) => ops[i]).toList(); + Navigator.of(ctx).pop(result); + }, + child: const Text('Import'), + ), + ], + ); + }); + }, + ); +} diff --git a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart index 5741d45e..8653491b 100644 --- a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart +++ b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart @@ -4,6 +4,7 @@ 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/consts.dart'; import 'package:apidash/models/models.dart'; import 'package:nanoid/nanoid.dart'; import '../../../core/services/curl_import_service.dart'; @@ -482,6 +483,20 @@ class ChatViewmodel extends StateNotifier { orElse: () => HTTPVerb.get, ); final url = payload['url'] as String? ?? ''; + final baseUrl = payload['baseUrl'] as String? ?? _inferBaseUrl(url); + // Derive a human-readable route path for naming + String routePath; + if (baseUrl.isNotEmpty && url.startsWith(baseUrl)) { + routePath = url.substring(baseUrl.length); + } else { + try { + final u = Uri.parse(url); + routePath = u.path.isEmpty ? '/' : u.path; + } catch (_) { + routePath = url; + } + } + if (!routePath.startsWith('/')) routePath = '/$routePath'; final headersMap = (payload['headers'] as Map?)?.cast() ?? {}; @@ -525,11 +540,12 @@ class ChatViewmodel extends StateNotifier { } } + final withEnvUrl = await _maybeSubstituteBaseUrl(url, baseUrl); if (action.field == 'apply_to_selected') { if (requestId == null) return; collection.update( method: method, - url: url, + url: withEnvUrl, headers: headers, isHeaderEnabledList: List.filled(headers.length, true), body: body, @@ -541,14 +557,15 @@ class ChatViewmodel extends StateNotifier { } else if (action.field == 'apply_to_new') { final model = HttpRequestModel( method: method, - url: url, + url: withEnvUrl, headers: headers, isHeaderEnabledList: List.filled(headers.length, true), body: body, bodyContentType: bodyContentType, formData: formData.isEmpty ? null : formData, ); - collection.addRequestModel(model, name: 'Imported OpenAPI'); + final displayName = '${method.name.toUpperCase()} $routePath'; + collection.addRequestModel(model, name: displayName); _appendSystem('Created a new request from the OpenAPI operation.', ChatMessageType.importOpenApi); } else if (action.field == 'select_operation') { @@ -674,6 +691,16 @@ class ChatViewmodel extends StateNotifier { .toList(), ), ); + // If meta summary is present, generate insights via AI + try { + final meta = picker['meta']; + final summary = (meta is Map && meta['openapi_summary'] is String) + ? meta['openapi_summary'] as String + : ''; + if (summary.isNotEmpty) { + await _generateOpenApiInsights(summary); + } + } catch (_) {} } catch (e) { debugPrint('[OpenAPI] Exception: $e'); final safe = e.toString().replaceAll('"', "'"); @@ -695,6 +722,7 @@ class ChatViewmodel extends StateNotifier { orElse: () => HTTPVerb.get, ); final url = payload['url'] as String? ?? ''; + final baseUrl = _inferBaseUrl(url); final headersMap = (payload['headers'] as Map?)?.cast() ?? {}; @@ -739,11 +767,12 @@ class ChatViewmodel extends StateNotifier { } } + final withEnvUrl = await _maybeSubstituteBaseUrl(url, baseUrl); if (action.field == 'apply_to_selected') { if (requestId == null) return; collection.update( method: method, - url: url, + url: withEnvUrl, headers: headers, isHeaderEnabledList: List.filled(headers.length, true), body: body, @@ -755,7 +784,7 @@ class ChatViewmodel extends StateNotifier { } else if (action.field == 'apply_to_new') { final model = HttpRequestModel( method: method, - url: url, + url: withEnvUrl, headers: headers, isHeaderEnabledList: List.filled(headers.length, true), body: body, @@ -954,6 +983,81 @@ class ChatViewmodel extends StateNotifier { } return t.contains('openapi:') || t.contains('swagger:'); } + + String _inferBaseUrl(String url) { + try { + final u = Uri.parse(url); + if (u.hasScheme && u.host.isNotEmpty) { + final portPart = (u.hasPort && u.port != 0) ? ':${u.port}' : ''; + return '${u.scheme}://${u.host}$portPart'; + } + } catch (_) {} + final m = RegExp(r'^(https?:\/\/[^\/]+)').firstMatch(url); + return m?.group(1) ?? ''; + } + + Future _ensureBaseUrlEnv(String baseUrl) async { + if (baseUrl.isEmpty) return 'BASE_URL'; + String host = 'API'; + try { + final u = Uri.parse(baseUrl); + if (u.hasAuthority && u.host.isNotEmpty) host = u.host; + } catch (_) {} + final slug = host + .replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_') + .replaceAll(RegExp(r'_+'), '_') + .replaceAll(RegExp(r'^_|_$'), '') + .toUpperCase(); + final key = 'BASE_URL_$slug'; + + final envNotifier = _ref.read(environmentsStateNotifierProvider.notifier); + final envs = _ref.read(environmentsStateNotifierProvider); + String? activeId = _ref.read(activeEnvironmentIdStateProvider); + activeId ??= kGlobalEnvironmentId; + final envModel = envs?[activeId]; + + if (envModel != null) { + final exists = envModel.values.any((v) => v.key == key); + if (!exists) { + final values = [...envModel.values]; + values.add( + EnvironmentVariableModel(key: key, value: baseUrl, enabled: true)); + envNotifier.updateEnvironment(activeId, values: values); + } + } + return key; + } + + Future _maybeSubstituteBaseUrl(String url, String baseUrl) async { + if (baseUrl.isEmpty || !url.startsWith(baseUrl)) return url; + final key = await _ensureBaseUrlEnv(baseUrl); + final path = url.substring(baseUrl.length); + final normalized = path.startsWith('/') ? path : '/$path'; + return '{{$key}}$normalized'; + } + + Future _generateOpenApiInsights(String summary) async { + final ai = _selectedAIModel; + if (ai == null) return; + try { + final sys = dash.DashbotPrompts().openApiInsightsPrompt( + specSummary: summary, + ); + final res = await _repo.sendChat( + request: ai.copyWith( + systemPrompt: sys, + userPrompt: + 'Provide concise, actionable insights about these endpoints.', + stream: false, + ), + ); + if (res != null && res.isNotEmpty) { + _appendSystem(res, ChatMessageType.importOpenApi); + } + } catch (e) { + debugPrint('[Chat] Insights error: $e'); + } + } } final chatViewmodelProvider = StateNotifierProvider((