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/services/openapi_import_service.dart'; import '../../../core/utils/safe_parse_json_message.dart'; 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; ChatRemoteRepository get _repo => _ref.read(chatRepositoryProvider); RequestModel? get _currentRequest => _ref.read(selectedRequestModelProvider); AIRequestModel? get _selectedAIModel { final json = _ref.read(settingsProvider).defaultAIModel; if (json == null) return null; try { return AIRequestModel.fromJson(json); } catch (_) { return null; } } List get currentMessages { final id = _currentRequest?.id ?? 'global'; debugPrint('[Chat] Getting messages for request ID: $id'); final messages = state.chatSessions[id] ?? const []; debugPrint('[Chat] Found ${messages.length} messages'); return messages; } Future sendMessage({ required String text, ChatMessageType type = ChatMessageType.general, bool countAsUser = true, }) async { debugPrint( '[Chat] sendMessage start: type=$type, countAsUser=$countAsUser'); final ai = _selectedAIModel; if (text.trim().isEmpty && countAsUser) return; if (ai == null && type != ChatMessageType.importCurl && type != ChatMessageType.importOpenApi) { debugPrint('[Chat] No AI model configured'); _appendSystem( 'AI model is not configured. Please set one.', type, ); return; } final requestId = _currentRequest?.id ?? 'global'; final existingMessages = state.chatSessions[requestId] ?? const []; debugPrint('[Chat] using requestId=$requestId'); if (countAsUser) { _addMessage( requestId, ChatMessage( id: nanoid(), content: text, role: MessageRole.user, timestamp: DateTime.now(), messageType: type, ), ); } 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; } final promptBuilder = _ref.read(promptBuilderProvider); String systemPrompt; if (type == ChatMessageType.generateCode) { final detectedLang = promptBuilder.detectLanguage(text); systemPrompt = promptBuilder.buildSystemPrompt( _currentRequest, type, overrideLanguage: detectedLang, history: currentMessages, ); } 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 if (type == ChatMessageType.importOpenApi) { final rqId = _currentRequest?.id ?? 'global'; final uploadAction = ChatAction.fromJson({ 'action': 'upload_asset', 'target': 'attachment', 'field': 'openapi_spec', 'path': null, 'value': { 'purpose': 'OpenAPI specification', 'accepted_types': [ 'application/json', 'application/yaml', 'application/x-yaml', 'text/yaml', 'text/x-yaml' ] }, }); _addMessage( rqId, ChatMessage( id: nanoid(), content: '{"explnation":"Upload your OpenAPI (JSON or YAML) specification or paste it here.","actions":[${jsonEncode(uploadAction.toJson())}]}', role: MessageRole.system, timestamp: DateTime.now(), messageType: ChatMessageType.importOpenApi, actions: [uploadAction], ), ); if (_looksLikeOpenApi(text)) { await handlePotentialOpenApiPaste(text); } return; } else { systemPrompt = promptBuilder.buildSystemPrompt( _currentRequest, type, history: currentMessages, ); } final userPrompt = (text.trim().isEmpty && !countAsUser) ? 'Please complete the task based on the provided context.' : text; final enriched = ai!.copyWith( systemPrompt: systemPrompt, userPrompt: userPrompt, stream: false, ); debugPrint( '[Chat] prompts prepared: system=${systemPrompt.length} chars, user=${userPrompt.length} chars'); state = state.copyWith(isGenerating: true, currentStreamingResponse: ''); 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'); } _addMessage( requestId, ChatMessage( id: nanoid(), content: response, role: MessageRole.system, timestamp: DateTime.now(), messageType: type, actions: actions, ), ); } 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() { state = state.copyWith(isGenerating: false); } void clearCurrentChat() { final id = _currentRequest?.id ?? 'global'; final newSessions = {...state.chatSessions}; newSessions[id] = []; state = state.copyWith( chatSessions: newSessions, isGenerating: false, currentStreamingResponse: '', ); } Future applyAutoFix(ChatAction action) async { try { 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'); _appendSystem('Failed to apply auto-fix: $e', ChatMessageType.general); } } // Field/URL/Method/Body updates are handled by AutoFixService // Header updates are now handled by AutoFixService // Body/URL/Method updates handled by AutoFixService Future _applyOtherAction(ChatAction action) async { final requestId = _currentRequest?.id; if (requestId == null) return; switch (action.target) { case 'test': await _applyTestToPostScript(action); break; case 'httpRequestModel': if (action.actionType == ChatActionType.applyCurl) { await _applyCurl(action); break; } if (action.actionType == ChatActionType.applyOpenApi || action.field == 'select_operation') { await _applyOpenApi(action); break; } // Unsupported other action debugPrint('[Chat] Unsupported other action target: ${action.target}'); break; default: debugPrint('[Chat] Unsupported other action target: ${action.target}'); } } Future _applyOpenApi(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 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() ?? {}; 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 _maybeSubstituteBaseUrl(url, baseUrl); if (action.field == 'apply_to_selected') { if (requestId == null) return; collection.update( method: method, url: withEnvUrl, headers: headers, isHeaderEnabledList: List.filled(headers.length, true), body: body, bodyContentType: bodyContentType, formData: formData.isEmpty ? null : formData, ); _appendSystem('Applied OpenAPI operation to the selected request.', ChatMessageType.importOpenApi); } else if (action.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'; collection.addRequestModel(model, name: displayName); _appendSystem('Created a new request from the OpenAPI operation.', ChatMessageType.importOpenApi); } else if (action.field == 'select_operation') { // Present apply options for the selected operation final applyMsg = OpenApiImportService.buildActionMessageFromPayload( payload, title: 'Selected ${action.path}. Where should I apply it?', ); final rqId = _currentRequest?.id ?? 'global'; _addMessage( rqId, ChatMessage( id: nanoid(), content: jsonEncode(applyMsg), role: MessageRole.system, timestamp: DateTime.now(), messageType: ChatMessageType.importOpenApi, actions: (applyMsg['actions'] as List) .whereType>() .map(ChatAction.fromJson) .toList(), ), ); } } Future _applyTestToPostScript(ChatAction action) async { final requestId = _currentRequest?.id; if (requestId == null) return; final collectionNotifier = _ref.read(collectionStateNotifierProvider.notifier); 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'; collectionNotifier.update(postRequestScript: newPostScript, id: requestId); debugPrint('[Chat] Test code added to post-request script'); _appendSystem( 'Test code has been successfully added to the post-request script.', 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 handleOpenApiAttachment(ChatAttachment att) async { try { final content = utf8.decode(att.data); await handlePotentialOpenApiPaste(content); } catch (e) { final safe = e.toString().replaceAll('"', "'"); _appendSystem( '{"explnation":"Failed to read attachment: $safe","actions":[]}', ChatMessageType.importOpenApi); } } Future handlePotentialOpenApiPaste(String text) async { final trimmed = text.trim(); if (!_looksLikeOpenApi(trimmed)) return; try { debugPrint('[OpenAPI] Original length: ${trimmed.length}'); final spec = OpenApiImportService.tryParseSpec(trimmed); if (spec == null) { _appendSystem( '{"explnation":"Sorry, I couldn\'t parse that OpenAPI spec. Ensure it\'s valid JSON or YAML.","actions":[]}', ChatMessageType.importOpenApi); return; } // Build a short summary + structured meta for the insights prompt final summary = OpenApiImportService.summaryForSpec(spec); String? insights; try { final ai = _selectedAIModel; if (ai != null) { final meta = OpenApiImportService.extractSpecMeta(spec); final sys = dash.DashbotPrompts() .openApiInsightsPrompt(specSummary: summary, specMeta: meta); 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) { // Ensure we only pass the explnation string to embed into explanation try { final map = MessageJson.safeParse(res); if (map['explnation'] is String) insights = map['explnation']; } catch (_) { insights = res; // fallback raw text } } } } catch (e) { debugPrint('[OpenAPI] insights error: $e'); } final picker = OpenApiImportService.buildOperationPicker( spec, insights: insights, ); final rqId = _currentRequest?.id ?? 'global'; _addMessage( rqId, ChatMessage( id: nanoid(), content: jsonEncode(picker), role: MessageRole.system, timestamp: DateTime.now(), messageType: ChatMessageType.importOpenApi, actions: (picker['actions'] as List) .whereType>() .map(ChatAction.fromJson) .toList(), ), ); // Do not generate a separate insights prompt; summary is inline now. } catch (e) { debugPrint('[OpenAPI] Exception: $e'); final safe = e.toString().replaceAll('"', "'"); _appendSystem('{"explnation":"Parsing failed: $safe","actions":[]}', ChatMessageType.importOpenApi); } } 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 baseUrl = _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 { // Heuristic JSON detection try { jsonDecode(body!); bodyContentType = ContentType.json; } catch (_) { bodyContentType = ContentType.text; } } final withEnvUrl = await _maybeSubstituteBaseUrl(url, baseUrl); if (action.field == 'apply_to_selected') { if (requestId == null) return; collection.update( method: method, url: withEnvUrl, 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: withEnvUrl, 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( '[Chat] Adding message to request ID: $requestId, actions: ${m.actions?.map((e) => e.toJson()).toList()}'); final msgs = state.chatSessions[requestId] ?? const []; state = state.copyWith( chatSessions: { ...state.chatSessions, requestId: [...msgs, m], }, ); debugPrint( '[Chat] Message added, total messages for $requestId: ${(state.chatSessions[requestId]?.length ?? 0)}'); } void _appendSystem(String text, ChatMessageType type) { final id = _currentRequest?.id ?? 'global'; _addMessage( id, ChatMessage( id: nanoid(), content: text, role: MessageRole.system, timestamp: DateTime.now(), messageType: type, ), ); } // Prompt helper methods moved to PromptBuilder service. bool _looksLikeOpenApi(String text) { final t = text.trim(); if (t.isEmpty) return false; if (t.startsWith('{')) { try { final m = jsonDecode(t); if (m is Map && (m.containsKey('openapi') || m.containsKey('swagger'))) { return true; } } catch (_) {} } return t.contains('openapi:') || t.contains('swagger:'); } String _inferBaseUrl(String url) => _ref.read(urlEnvServiceProvider).inferBaseUrl(url); Future _ensureBaseUrlEnv(String baseUrl) async { 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 { final svc = _ref.read(urlEnvServiceProvider); return svc.maybeSubstituteBaseUrl( url, baseUrl, ensure: (b) => _ensureBaseUrlEnv(b), ); } } final chatViewmodelProvider = StateNotifierProvider(( ref, ) { return ChatViewmodel(ref); });