diff --git a/lib/dashbot/features/chat/providers/service_providers.dart b/lib/dashbot/features/chat/providers/service_providers.dart new file mode 100644 index 00000000..7cdac0ae --- /dev/null +++ b/lib/dashbot/features/chat/providers/service_providers.dart @@ -0,0 +1,67 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/agent/prompt_builder.dart'; +import '../services/base/url_env_service.dart'; +import '../services/actions/auto_fix_service.dart'; +import '../services/actions/request_apply_service.dart'; +import '../../../../providers/providers.dart'; +import 'package:apidash_core/apidash_core.dart'; + +final promptBuilderProvider = Provider((ref) { + return PromptBuilder(); +}); + +final urlEnvServiceProvider = Provider((ref) { + return UrlEnvService(); +}); + +final requestApplyServiceProvider = Provider((ref) { + return RequestApplyService(urlEnv: ref.read(urlEnvServiceProvider)); +}); + +final autoFixServiceProvider = Provider((ref) { + final collection = ref.read(collectionStateNotifierProvider.notifier); + final urlEnv = ref.read(urlEnvServiceProvider); + return AutoFixService( + requestApply: ref.read(requestApplyServiceProvider), + updateSelected: ({ + required String id, + HTTPVerb? method, + String? url, + List? headers, + List? isHeaderEnabledList, + String? body, + ContentType? bodyContentType, + List? formData, + List? params, + List? isParamEnabledList, + String? postRequestScript, + }) { + collection.update( + id: id, + method: method, + url: url, + headers: headers, + isHeaderEnabledList: isHeaderEnabledList, + body: body, + bodyContentType: bodyContentType, + formData: formData, + params: params, + isParamEnabledList: isParamEnabledList, + postRequestScript: postRequestScript, + ); + }, + addNewRequest: (model, {name}) => + collection.addRequestModel(model, name: name ?? 'New Request'), + readCurrentRequestId: () => ref.read(selectedRequestModelProvider)?.id, + ensureBaseUrl: (baseUrl) => urlEnv.ensureBaseUrlEnv( + baseUrl, + readEnvs: () => ref.read(environmentsStateNotifierProvider), + readActiveEnvId: () => ref.read(activeEnvironmentIdStateProvider), + updateEnv: (id, {values}) => ref + .read(environmentsStateNotifierProvider.notifier) + .updateEnvironment(id, values: values), + ), + readCurrentRequest: () => ref.read(selectedRequestModelProvider), + ); +}); diff --git a/lib/dashbot/features/chat/services/actions/auto_fix_service.dart b/lib/dashbot/features/chat/services/actions/auto_fix_service.dart new file mode 100644 index 00000000..bb085853 --- /dev/null +++ b/lib/dashbot/features/chat/services/actions/auto_fix_service.dart @@ -0,0 +1,183 @@ +import 'package:apidash_core/apidash_core.dart'; +import 'package:apidash/models/models.dart'; + +import '../../models/chat_models.dart'; +import 'request_apply_service.dart'; + +class AutoFixService { + AutoFixService({ + required this.requestApply, + required this.updateSelected, + required this.addNewRequest, + required this.readCurrentRequestId, + required this.ensureBaseUrl, + required this.readCurrentRequest, + }); + + final RequestApplyService requestApply; + final UpdateSelectedFn updateSelected; + final AddNewRequestFn addNewRequest; + final String? Function() readCurrentRequestId; + final Future Function(String baseUrl) ensureBaseUrl; + final RequestModel? Function() readCurrentRequest; + + Future apply(ChatAction action) async { + final requestId = readCurrentRequestId(); + switch (action.actionType) { + case ChatActionType.updateField: + await _applyFieldUpdate(action, requestId); + return null; + case ChatActionType.addHeader: + await _applyHeaderUpdate(action, isAdd: true, requestId: requestId); + return null; + case ChatActionType.updateHeader: + await _applyHeaderUpdate(action, isAdd: false, requestId: requestId); + return null; + case ChatActionType.deleteHeader: + await _applyHeaderDelete(action, requestId); + return null; + case ChatActionType.updateBody: + await _applyBodyUpdate(action, requestId); + return null; + case ChatActionType.updateUrl: + await _applyUrlUpdate(action, requestId); + return null; + case ChatActionType.updateMethod: + await _applyMethodUpdate(action, requestId); + return null; + case ChatActionType.applyCurl: + { + final payload = (action.value is Map) + ? (action.value as Map) + : {}; + final res = await requestApply.applyCurl( + payload: payload, + target: action.field, + requestId: requestId, + updateSelected: updateSelected, + addNewRequest: addNewRequest, + ensureBaseUrl: ensureBaseUrl, + ); + return res?.systemMessage; + } + case ChatActionType.applyOpenApi: + { + final payload = (action.value is Map) + ? (action.value as Map) + : {}; + final res = await requestApply.applyOpenApi( + payload: payload, + field: action.field, + path: action.path, + requestId: requestId, + updateSelected: updateSelected, + addNewRequest: addNewRequest, + ensureBaseUrl: ensureBaseUrl, + ); + return res?.systemMessage; + } + case ChatActionType.other: + // defer to specific target logic if needed + return null; + case ChatActionType.showLanguages: + case ChatActionType.noAction: + case ChatActionType.uploadAsset: + return null; + } + } + + Future _applyFieldUpdate(ChatAction action, String? requestId) async { + if (requestId == null) return; + switch (action.field) { + case 'url': + updateSelected(id: requestId, url: action.value as String); + break; + case 'method': + final method = HTTPVerb.values.firstWhere( + (m) => m.name.toLowerCase() == (action.value as String).toLowerCase(), + orElse: () => HTTPVerb.get, + ); + updateSelected(id: requestId, method: method); + break; + case 'params': + if (action.value is Map) { + final params = (action.value as Map) + .entries + .map((e) => NameValueModel( + name: e.key, + value: e.value.toString(), + )) + .toList(); + final enabled = List.filled(params.length, true); + updateSelected( + id: requestId, + params: params, + isParamEnabledList: enabled, + ); + } + break; + } + } + + Future _applyHeaderUpdate(ChatAction action, + {required bool isAdd, String? requestId}) async { + if (requestId == null || action.path == null) return; + final current = readCurrentRequest(); + final http = current?.httpRequestModel; + if (http == null) return; + + final headers = List.from(http.headers ?? const []); + if (isAdd) { + headers.add( + NameValueModel(name: action.path!, value: action.value as String)); + } else { + final index = headers.indexWhere((h) => h.name == action.path); + if (index != -1) { + headers[index] = headers[index].copyWith(value: action.value as String); + } else { + headers.add( + NameValueModel(name: action.path!, value: action.value as String)); + } + } + + updateSelected( + id: requestId, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + ); + } + + Future _applyHeaderDelete(ChatAction action, String? requestId) async { + if (requestId == null || action.path == null) return; + final current = readCurrentRequest(); + final http = current?.httpRequestModel; + if (http == null) return; + + final headers = List.from(http.headers ?? const []); + headers.removeWhere((h) => h.name == action.path); + updateSelected( + id: requestId, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + ); + } + + Future _applyBodyUpdate(ChatAction action, String? requestId) async { + if (requestId == null) return; + updateSelected(id: requestId, body: action.value as String); + } + + Future _applyUrlUpdate(ChatAction action, String? requestId) async { + if (requestId == null) return; + updateSelected(id: requestId, url: action.value as String); + } + + Future _applyMethodUpdate(ChatAction action, String? requestId) async { + if (requestId == null) return; + final method = HTTPVerb.values.firstWhere( + (m) => m.name.toLowerCase() == (action.value as String).toLowerCase(), + orElse: () => HTTPVerb.get, + ); + updateSelected(id: requestId, method: method); + } +} diff --git a/lib/dashbot/features/chat/services/actions/request_apply_service.dart b/lib/dashbot/features/chat/services/actions/request_apply_service.dart new file mode 100644 index 00000000..1390a6cb --- /dev/null +++ b/lib/dashbot/features/chat/services/actions/request_apply_service.dart @@ -0,0 +1,251 @@ +import 'dart:convert'; + +import 'package:apidash_core/apidash_core.dart'; + +import '../../models/chat_models.dart'; +import '../base/url_env_service.dart'; + +class ApplyResult { + final String? systemMessage; + final ChatMessageType? messageType; + const ApplyResult({this.systemMessage, this.messageType}); +} + +typedef UpdateSelectedFn = void Function({ + required String id, + HTTPVerb? method, + String? url, + List? headers, + List? isHeaderEnabledList, + String? body, + ContentType? bodyContentType, + List? formData, + List? params, + List? isParamEnabledList, + String? postRequestScript, +}); + +typedef AddNewRequestFn = void Function(HttpRequestModel model, {String? name}); + +class RequestApplyService { + RequestApplyService({required this.urlEnv}); + + final UrlEnvService urlEnv; + + Future applyCurl({ + required Map payload, + required String? target, // 'apply_to_selected' | 'apply_to_new' + required String? requestId, + required UpdateSelectedFn updateSelected, + required AddNewRequestFn addNewRequest, + required Future Function(String baseUrl) ensureBaseUrl, + }) async { + 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 baseUrl = urlEnv.inferBaseUrl(url); + + 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 { + try { + jsonDecode(body!); + bodyContentType = ContentType.json; + } catch (_) { + bodyContentType = ContentType.text; + } + } + + final withEnvUrl = await urlEnv.maybeSubstituteBaseUrl( + url, + baseUrl, + ensure: ensureBaseUrl, + ); + + if (target == 'apply_to_selected') { + if (requestId == null) return null; + updateSelected( + id: requestId, + method: method, + url: withEnvUrl, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + return const ApplyResult( + systemMessage: 'Applied cURL to the selected request.', + messageType: ChatMessageType.importCurl, + ); + } else if (target == 'apply_to_new') { + final model = HttpRequestModel( + method: method, + url: withEnvUrl, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + addNewRequest(model, name: 'Imported cURL'); + return const ApplyResult( + systemMessage: 'Created a new request from the cURL.', + messageType: ChatMessageType.importCurl, + ); + } + return null; + } + + Future applyOpenApi({ + required Map payload, + required String? + field, // 'apply_to_selected'|'apply_to_new'|'select_operation' + required String? path, + required String? requestId, + required UpdateSelectedFn updateSelected, + required AddNewRequestFn addNewRequest, + required Future Function(String baseUrl) ensureBaseUrl, + }) async { + 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 baseUrl = payload['baseUrl'] as String? ?? urlEnv.inferBaseUrl(url); + + 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() ?? {}; + 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 { + try { + jsonDecode(body!); + bodyContentType = ContentType.json; + } catch (_) { + bodyContentType = ContentType.text; + } + } + + final withEnvUrl = await urlEnv.maybeSubstituteBaseUrl( + url, + baseUrl, + ensure: ensureBaseUrl, + ); + + if (field == 'apply_to_selected') { + if (requestId == null) return null; + updateSelected( + id: requestId, + method: method, + url: withEnvUrl, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + return const ApplyResult( + systemMessage: 'Applied OpenAPI operation to the selected request.', + messageType: ChatMessageType.importOpenApi, + ); + } else if (field == 'apply_to_new') { + final model = HttpRequestModel( + method: method, + url: withEnvUrl, + headers: headers, + isHeaderEnabledList: List.filled(headers.length, true), + body: body, + bodyContentType: bodyContentType, + formData: formData.isEmpty ? null : formData, + ); + final displayName = '${method.name.toUpperCase()} $routePath'; + addNewRequest(model, name: displayName); + return const ApplyResult( + systemMessage: 'Created a new request from the OpenAPI operation.', + messageType: ChatMessageType.importOpenApi, + ); + } else if (field == 'select_operation') { + // UI presents options elsewhere; no system message here. + return const ApplyResult(); + } + return null; + } +} diff --git a/lib/dashbot/features/chat/services/agent/prompt_builder.dart b/lib/dashbot/features/chat/services/agent/prompt_builder.dart new file mode 100644 index 00000000..bee7d217 --- /dev/null +++ b/lib/dashbot/features/chat/services/agent/prompt_builder.dart @@ -0,0 +1,150 @@ +import 'package:apidash/models/models.dart'; +import 'package:apidash/dashbot/core/constants/dashbot_prompts.dart' as dash; + +import '../../models/chat_models.dart'; + +class PromptBuilder { + String buildSystemPrompt( + RequestModel? req, + ChatMessageType type, { + String? overrideLanguage, + List history = const [], + }) { + final historyBlock = buildHistoryBlock(history); + final contextBlock = buildContextBlock(req); + final task = buildTaskPrompt( + req, + type, + overrideLanguage: overrideLanguage, + ); + return [ + if (task != null) task, + if (contextBlock != null) contextBlock, + if (historyBlock.isNotEmpty) historyBlock, + ].join('\n\n'); + } + + String buildHistoryBlock(List messages, {int maxTurns = 8}) { + if (messages.isEmpty) return ''; + final start = messages.length > maxTurns ? messages.length - maxTurns : 0; + final recent = messages.sublist(start); + final buf = StringBuffer(''' +\tOnly use the following short chat history to maintain continuity. Do not repeat it back. +\t'''); + for (final m in recent) { + final role = m.role == MessageRole.user ? 'user' : 'assistant'; + buf.writeln('- $role: ${m.content}'); + } + buf.writeln(''); + return buf.toString(); + } + + String? buildContextBlock(RequestModel? req) { + final http = req?.httpRequestModel; + if (req == null || http == null) return null; + final headers = http.headersMap.entries + .map((e) => '"${e.key}": "${e.value}"') + .join(', '); + return ''' + Request Name: ${req.name} +\tURL: ${http.url} +\tMethod: ${http.method.name.toUpperCase()} + Status: ${req.responseStatus ?? ''} +\tContent-Type: ${http.bodyContentType.name} +\tHeaders: { $headers } +\tBody: ${http.body ?? ''} + Response: ${req.httpResponseModel?.body ?? ''} +\t'''; + } + + String? buildTaskPrompt( + RequestModel? req, + ChatMessageType type, { + String? overrideLanguage, + }) { + if (req == null) return null; + final http = req.httpRequestModel; + final resp = req.httpResponseModel; + final prompts = dash.DashbotPrompts(); + switch (type) { + case ChatMessageType.explainResponse: + return prompts.explainApiResponsePrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + responseStatus: req.responseStatus, + bodyContentType: http?.bodyContentType.name, + message: resp?.body, + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.debugError: + return prompts.debugApiErrorPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + responseStatus: req.responseStatus, + bodyContentType: http?.bodyContentType.name, + message: resp?.body, + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.generateTest: + return prompts.generateTestCasesPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.generateDoc: + return prompts.generateDocumentationPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + responseStatus: req.responseStatus, + bodyContentType: http?.bodyContentType.name, + message: resp?.body, + headersMap: http?.headersMap, + body: http?.body, + ); + case ChatMessageType.generateCode: + if (overrideLanguage == null || overrideLanguage.isEmpty) { + return prompts.codeGenerationIntroPrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + headersMap: http?.headersMap, + body: http?.body, + bodyContentType: http?.bodyContentType.name, + paramsMap: http?.paramsMap, + authType: http?.authModel?.type.name, + ); + } else { + return prompts.generateCodePrompt( + url: http?.url, + method: http?.method.name.toUpperCase(), + headersMap: http?.headersMap, + body: http?.body, + bodyContentType: http?.bodyContentType.name, + paramsMap: http?.paramsMap, + authType: http?.authModel?.type.name, + language: overrideLanguage, + ); + } + case ChatMessageType.importCurl: + return null; + case ChatMessageType.importOpenApi: + return null; + case ChatMessageType.general: + return prompts.generalInteractionPrompt(); + } + } + + String? detectLanguage(String text) { + final t = text.toLowerCase(); + if (t.contains('python')) return 'Python (requests)'; + if (t.contains('dart')) return 'Dart (http)'; + if (t.contains('golang') || t.contains('go ')) return 'Go (net/http)'; + if (t.contains('javascript') || t.contains('js') || t.contains('fetch')) { + return 'JavaScript (fetch)'; + } + if (t.contains('curl')) return 'cURL'; + return null; + } +} diff --git a/lib/dashbot/features/chat/services/base/url_env_service.dart b/lib/dashbot/features/chat/services/base/url_env_service.dart new file mode 100644 index 00000000..4901dccf --- /dev/null +++ b/lib/dashbot/features/chat/services/base/url_env_service.dart @@ -0,0 +1,68 @@ +import 'package:apidash/consts.dart'; +import 'package:apidash_core/apidash_core.dart'; + +class UrlEnvService { + 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, { + required Map? Function() readEnvs, + required String? Function() readActiveEnvId, + required void Function(String id, {List? values}) + updateEnv, + }) 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 envs = readEnvs(); + String? activeId = readActiveEnvId(); + 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, + )); + updateEnv(activeId, values: values); + } + } + return key; + } + + Future maybeSubstituteBaseUrl( + String url, + String baseUrl, { + required Future Function(String baseUrl) ensure, + }) async { + if (baseUrl.isEmpty || !url.startsWith(baseUrl)) return url; + final key = await ensure(baseUrl); + final path = url.substring(baseUrl.length); + final normalized = path.startsWith('/') ? path : '/$path'; + return '{{$key}}$normalized'; + } +} diff --git a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart index 37385acd..9d32a823 100644 --- a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart +++ b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart @@ -1,10 +1,8 @@ -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/consts.dart'; import 'package:apidash/models/models.dart'; import 'package:nanoid/nanoid.dart'; import '../../../core/services/curl_import_service.dart'; @@ -15,15 +13,14 @@ import '../../../core/constants/dashbot_prompts.dart' as dash; import '../models/chat_models.dart'; import '../repository/chat_remote_repository.dart'; import '../providers/attachments_provider.dart'; +import '../providers/service_providers.dart'; class ChatViewmodel extends StateNotifier { ChatViewmodel(this._ref) : super(const ChatState()); final Ref _ref; - StreamSubscription? _sub; ChatRemoteRepository get _repo => _ref.read(chatRepositoryProvider); - // Currently selected request and AI model are read from app providers RequestModel? get _currentRequest => _ref.read(selectedRequestModelProvider); AIRequestModel? get _selectedAIModel { final json = _ref.read(settingsProvider).defaultAIModel; @@ -80,7 +77,6 @@ class ChatViewmodel extends StateNotifier { ); } - // If user pasted a cURL in import flow, handle locally without AI final lastSystemImport = existingMessages.lastWhere( (m) => m.role == MessageRole.system && @@ -99,13 +95,15 @@ class ChatViewmodel extends StateNotifier { return; } + final promptBuilder = _ref.read(promptBuilderProvider); String systemPrompt; if (type == ChatMessageType.generateCode) { - final detectedLang = _detectLanguageFromText(text); - systemPrompt = _composeSystemPrompt( + final detectedLang = promptBuilder.detectLanguage(text); + systemPrompt = promptBuilder.buildSystemPrompt( _currentRequest, type, overrideLanguage: detectedLang, + history: currentMessages, ); } else if (type == ChatMessageType.importCurl) { final rqId = _currentRequest?.id ?? 'global'; @@ -156,7 +154,11 @@ class ChatViewmodel extends StateNotifier { } return; } else { - systemPrompt = _composeSystemPrompt(_currentRequest, type); + systemPrompt = promptBuilder.buildSystemPrompt( + _currentRequest, + type, + history: currentMessages, + ); } final userPrompt = (text.trim().isEmpty && !countAsUser) ? 'Please complete the task based on the provided context.' @@ -164,129 +166,61 @@ class ChatViewmodel extends StateNotifier { final enriched = ai!.copyWith( systemPrompt: systemPrompt, userPrompt: userPrompt, - stream: true, + stream: false, ); debugPrint( '[Chat] prompts prepared: system=${systemPrompt.length} chars, user=${userPrompt.length} chars'); - // start stream - _sub?.cancel(); state = state.copyWith(isGenerating: true, currentStreamingResponse: ''); - bool receivedAnyChunk = false; - _sub = _repo.streamChat(request: enriched).listen( - (chunk) { - receivedAnyChunk = true; - if (chunk.isEmpty) return; - debugPrint('[Chat] chunk(${chunk.length})'); - state = state.copyWith( - currentStreamingResponse: state.currentStreamingResponse + (chunk), - ); - }, - onError: (e) { - debugPrint('[Chat] stream error: $e'); - state = state.copyWith(isGenerating: false); - _appendSystem('Error: $e', type); - }, - onDone: () async { - debugPrint( - '[Chat] stream done. total=${state.currentStreamingResponse.length}, anyChunk=$receivedAnyChunk'); - if (state.currentStreamingResponse.isNotEmpty) { - List? parsedActions; - try { - debugPrint( - '[Chat] Attempting to parse response: ${state.currentStreamingResponse}'); - final Map parsed = - MessageJson.safeParse(state.currentStreamingResponse); - debugPrint('[Chat] Parsed JSON: $parsed'); - if (parsed.containsKey('actions') && parsed['actions'] is List) { - parsedActions = (parsed['actions'] as List) - .whereType>() - .map(ChatAction.fromJson) - .toList(); - debugPrint('[Chat] Parsed actions list: ${parsedActions.length}'); - } - if (parsedActions == null || parsedActions.isEmpty) { - debugPrint('[Chat] No actions list found in response'); - } - } catch (e) { - debugPrint('[Chat] Error parsing action: $e'); - debugPrint('[Chat] Error details: ${e.toString()}'); - // If parsing fails, continue without action - } - - _addMessage( - requestId, - ChatMessage( - id: nanoid(), - content: state.currentStreamingResponse, - role: MessageRole.system, - timestamp: DateTime.now(), - messageType: type, - actions: parsedActions, - ), - ); - } else if (!receivedAnyChunk) { - // Fallback to non-streaming request - debugPrint( - '[Chat] no streamed content; attempting non-streaming fallback'); - try { - final fallback = - await _repo.sendChat(request: enriched.copyWith(stream: false)); - if (fallback != null && fallback.isNotEmpty) { - List? fallbackActions; - try { - final Map parsed = - MessageJson.safeParse(fallback); - if (parsed.containsKey('actions') && - parsed['actions'] is List) { - fallbackActions = (parsed['actions'] as List) - .whereType>() - .map(ChatAction.fromJson) - .toList(); - } - if ((fallbackActions == null || fallbackActions.isEmpty)) { - // no actions - } - } catch (e) { - debugPrint('[Chat] Fallback error parsing action: $e'); - } - - _addMessage( - requestId, - ChatMessage( - id: nanoid(), - content: fallback, - role: MessageRole.system, - timestamp: DateTime.now(), - messageType: type, - actions: fallbackActions, - ), - ); - } else { - _appendSystem('No response received from the AI.', type); - } - } catch (err) { - debugPrint('[Chat] fallback error: $err'); - _appendSystem('Error: $err', type); + try { + final response = await _repo.sendChat(request: enriched); + if (response != null && response.isNotEmpty) { + List? actions; + try { + debugPrint('[Chat] Parsing non-streaming response'); + final Map parsed = MessageJson.safeParse(response); + if (parsed.containsKey('actions') && parsed['actions'] is List) { + actions = (parsed['actions'] as List) + .whereType>() + .map(ChatAction.fromJson) + .toList(); + debugPrint('[Chat] Parsed actions list: ${actions.length}'); } + } catch (e) { + debugPrint('[Chat] Error parsing action: $e'); } - state = state.copyWith( - isGenerating: false, - currentStreamingResponse: '', + + _addMessage( + requestId, + ChatMessage( + id: nanoid(), + content: response, + role: MessageRole.system, + timestamp: DateTime.now(), + messageType: type, + actions: actions, + ), ); - }, - cancelOnError: true, - ); + } else { + _appendSystem('No response received from the AI.', type); + } + } catch (e) { + debugPrint('[Chat] sendChat error: $e'); + _appendSystem('Error: $e', type); + } finally { + state = state.copyWith( + isGenerating: false, + currentStreamingResponse: '', + ); + } } void cancel() { - _sub?.cancel(); state = state.copyWith(isGenerating: false); } - void clearCurrentChat() { + void clearCurrentChat() { final id = _currentRequest?.id ?? 'global'; - _sub?.cancel(); final newSessions = {...state.chatSessions}; newSessions[id] = []; state = state.copyWith( @@ -297,49 +231,20 @@ class ChatViewmodel extends StateNotifier { } Future applyAutoFix(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null) return; - try { - switch (action.actionType) { - case ChatActionType.updateField: - await _applyFieldUpdate(action); - break; - case ChatActionType.addHeader: - await _applyHeaderUpdate(action, isAdd: true); - break; - case ChatActionType.updateHeader: - await _applyHeaderUpdate(action, isAdd: false); - break; - case ChatActionType.deleteHeader: - await _applyHeaderDelete(action); - break; - case ChatActionType.updateBody: - await _applyBodyUpdate(action); - break; - case ChatActionType.updateUrl: - await _applyUrlUpdate(action); - break; - case ChatActionType.updateMethod: - await _applyMethodUpdate(action); - break; - case ChatActionType.applyCurl: - await _applyCurl(action); - break; - case ChatActionType.applyOpenApi: - await _applyOpenApi(action); - break; - case ChatActionType.other: - await _applyOtherAction(action); - break; - case ChatActionType.showLanguages: - // UI handles selection; - break; - case ChatActionType.noAction: - break; - case ChatActionType.uploadAsset: - // Handled by UI upload button - break; + final msg = await _ref.read(autoFixServiceProvider).apply(action); + if (msg != null && msg.isNotEmpty) { + // Message type depends on action context; choose sensible defaults + final t = (action.actionType == ChatActionType.applyCurl) + ? ChatMessageType.importCurl + : (action.actionType == ChatActionType.applyOpenApi) + ? ChatMessageType.importOpenApi + : ChatMessageType.general; + _appendSystem(msg, t); + } + // Only target-specific 'other' actions remain here + if (action.actionType == ChatActionType.other) { + await _applyOtherAction(action); } } catch (e) { debugPrint('[Chat] Error applying auto-fix: $e'); @@ -347,114 +252,11 @@ class ChatViewmodel extends StateNotifier { } } - Future _applyFieldUpdate(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null) return; + // Field/URL/Method/Body updates are handled by AutoFixService - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); + // Header updates are now handled by AutoFixService - switch (action.field) { - case 'url': - collectionNotifier.update(url: action.value as String, id: requestId); - break; - case 'method': - final method = HTTPVerb.values.firstWhere( - (m) => m.name.toLowerCase() == (action.value as String).toLowerCase(), - orElse: () => HTTPVerb.get, - ); - collectionNotifier.update(method: method, id: requestId); - break; - case 'params': - if (action.value is Map) { - final params = (action.value as Map) - .entries - .map( - (e) => NameValueModel(name: e.key, value: e.value.toString())) - .toList(); - final enabled = List.filled(params.length, true); - collectionNotifier.update( - params: params, - isParamEnabledList: enabled, - id: requestId, - ); - } - break; - } - } - - Future _applyHeaderUpdate(ChatAction action, - {required bool isAdd}) async { - final requestId = _currentRequest?.id; - if (requestId == null || action.path == null) return; - - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); - final currentRequest = _currentRequest; - if (currentRequest?.httpRequestModel == null) return; - - final headers = List.from( - currentRequest!.httpRequestModel!.headers ?? []); - - if (isAdd) { - headers.add( - NameValueModel(name: action.path!, value: action.value as String)); - } else { - final index = headers.indexWhere((h) => h.name == action.path); - if (index != -1) { - headers[index] = headers[index].copyWith(value: action.value as String); - } - } - - collectionNotifier.update(headers: headers, id: requestId); - } - - Future _applyHeaderDelete(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null || action.path == null) return; - - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); - final currentRequest = _currentRequest; - if (currentRequest?.httpRequestModel == null) return; - - final headers = List.from( - currentRequest!.httpRequestModel!.headers ?? []); - headers.removeWhere((h) => h.name == action.path); - - collectionNotifier.update(headers: headers, id: requestId); - } - - Future _applyBodyUpdate(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null) return; - - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); - collectionNotifier.update(body: action.value as String, id: requestId); - } - - Future _applyUrlUpdate(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null) return; - - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); - collectionNotifier.update(url: action.value as String, id: requestId); - } - - Future _applyMethodUpdate(ChatAction action) async { - final requestId = _currentRequest?.id; - if (requestId == null) return; - - final collectionNotifier = - _ref.read(collectionStateNotifierProvider.notifier); - final method = HTTPVerb.values.firstWhere( - (m) => m.name.toLowerCase() == (action.value as String).toLowerCase(), - orElse: () => HTTPVerb.get, - ); - collectionNotifier.update(method: method, id: requestId); - } + // Body/URL/Method updates handled by AutoFixService Future _applyOtherAction(ChatAction action) async { final requestId = _currentRequest?.id; @@ -864,148 +666,7 @@ class ChatViewmodel extends StateNotifier { ); } - String _composeSystemPrompt( - RequestModel? req, - ChatMessageType type, { - String? overrideLanguage, - }) { - final history = _buildHistoryBlock(); - final contextBlock = _buildContextBlock(req); - final task = - _buildTaskPrompt(req, type, overrideLanguage: overrideLanguage); - return [ - if (task != null) task, - if (contextBlock != null) contextBlock, - if (history.isNotEmpty) history, - ].join('\n\n'); - } - - String _buildHistoryBlock({int maxTurns = 8}) { - final id = _currentRequest?.id ?? 'global'; - final messages = state.chatSessions[id] ?? const []; - if (messages.isEmpty) return ''; - final start = messages.length > maxTurns ? messages.length - maxTurns : 0; - final recent = messages.sublist(start); - final buf = StringBuffer(''' - Only use the following short chat history to maintain continuity. Do not repeat it back. - '''); - for (final m in recent) { - final role = m.role == MessageRole.user ? 'user' : 'assistant'; - buf.writeln('- $role: ${m.content}'); - } - buf.writeln(''); - return buf.toString(); - } - - String? _buildContextBlock(RequestModel? req) { - final http = req?.httpRequestModel; - if (req == null || http == null) return null; - final headers = http.headersMap.entries - .map((e) => '"${e.key}": "${e.value}"') - .join(', '); - return ''' - Request Name: ${req.name} - URL: ${http.url} - Method: ${http.method.name.toUpperCase()} - Status: ${req.responseStatus ?? ''} - Content-Type: ${http.bodyContentType.name} - Headers: { $headers } - Body: ${http.body ?? ''} - Response: ${req.httpResponseModel?.body ?? ''} - '''; - } - - String? _buildTaskPrompt(RequestModel? req, ChatMessageType type, - {String? overrideLanguage}) { - if (req == null) return null; - final http = req.httpRequestModel; - final resp = req.httpResponseModel; - final prompts = dash.DashbotPrompts(); - switch (type) { - case ChatMessageType.explainResponse: - return prompts.explainApiResponsePrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - responseStatus: req.responseStatus, - bodyContentType: http?.bodyContentType.name, - message: resp?.body, - headersMap: http?.headersMap, - body: http?.body, - ); - case ChatMessageType.debugError: - return prompts.debugApiErrorPrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - responseStatus: req.responseStatus, - bodyContentType: http?.bodyContentType.name, - message: resp?.body, - headersMap: http?.headersMap, - body: http?.body, - ); - case ChatMessageType.generateTest: - return prompts.generateTestCasesPrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - headersMap: http?.headersMap, - body: http?.body, - ); - case ChatMessageType.generateDoc: - return prompts.generateDocumentationPrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - responseStatus: req.responseStatus, - bodyContentType: http?.bodyContentType.name, - message: resp?.body, - headersMap: http?.headersMap, - body: http?.body, - ); - case ChatMessageType.generateCode: - // If a language is provided, go for code generation; else ask for language first - if (overrideLanguage == null || overrideLanguage.isEmpty) { - return prompts.codeGenerationIntroPrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - headersMap: http?.headersMap, - body: http?.body, - bodyContentType: http?.bodyContentType.name, - paramsMap: http?.paramsMap, - authType: http?.authModel?.type.name, - ); - } else { - return prompts.generateCodePrompt( - url: http?.url, - method: http?.method.name.toUpperCase(), - headersMap: http?.headersMap, - body: http?.body, - bodyContentType: http?.bodyContentType.name, - paramsMap: http?.paramsMap, - authType: http?.authModel?.type.name, - language: overrideLanguage, - ); - } - case ChatMessageType.importCurl: - // No AI prompt needed; handled locally. - return null; - case ChatMessageType.importOpenApi: - // No AI prompt needed; handled locally. - return null; - case ChatMessageType.general: - return prompts.generalInteractionPrompt(); - } - } - - // Very light heuristic to detect language keywords in user text - String? _detectLanguageFromText(String text) { - final t = text.toLowerCase(); - if (t.contains('python')) return 'Python (requests)'; - if (t.contains('dart')) return 'Dart (http)'; - if (t.contains('golang') || t.contains('go ')) return 'Go (net/http)'; - if (t.contains('javascript') || t.contains('js') || t.contains('fetch')) { - return 'JavaScript (fetch)'; - } - if (t.contains('curl')) return 'cURL'; - return null; - } + // Prompt helper methods moved to PromptBuilder service. bool _looksLikeOpenApi(String text) { final t = text.trim(); @@ -1022,56 +683,28 @@ 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) ?? ''; - } + String _inferBaseUrl(String url) => + _ref.read(urlEnvServiceProvider).inferBaseUrl(url); 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; + final svc = _ref.read(urlEnvServiceProvider); + return svc.ensureBaseUrlEnv( + baseUrl, + readEnvs: () => _ref.read(environmentsStateNotifierProvider), + readActiveEnvId: () => _ref.read(activeEnvironmentIdStateProvider), + updateEnv: (id, {values}) => _ref + .read(environmentsStateNotifierProvider.notifier) + .updateEnvironment(id, values: values), + ); } 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'; + final svc = _ref.read(urlEnvServiceProvider); + return svc.maybeSubstituteBaseUrl( + url, + baseUrl, + ensure: (b) => _ensureBaseUrlEnv(b), + ); } }