From 16e7b929346ebfdf46e4428ef4b576930002617e Mon Sep 17 00:00:00 2001 From: Udhay-Adithya Date: Tue, 16 Sep 2025 01:39:39 +0530 Subject: [PATCH] feat: multi-action actions with enums, upload scaffolding, and UI factory - Add actions list to chat messages Introduce - ChatActionType/ChatActionTarget enums and mapping helpers - Refactor viewmodel to parse actions and apply fixes via enums - Implement DashbotActionWidgetFactory and action widgets (auto-fix, add-tests, language, code, upload) - Integrate upload attachments provider and map upload_asset action - Update prompt schema docs to prefer actions array and include upload_asset - Update chat bubble to render multiple actions --- .../widgets/dashbot_action_buttons.dart | 213 ++++++++++++++++++ .../core/constants/dashbot_prompts.dart | 136 ++++++----- .../features/chat/models/chat_models.dart | 137 ++++++++++- .../chat/providers/attachments_provider.dart | 57 +++++ .../chat/view/pages/dashbot_chat_page.dart | 4 +- .../chat/view/widgets/chat_bubble.dart | 126 ++--------- .../chat/viewmodel/chat_viewmodel.dart | 66 +++--- 7 files changed, 531 insertions(+), 208 deletions(-) create mode 100644 lib/dashbot/core/common/widgets/dashbot_action_buttons.dart create mode 100644 lib/dashbot/features/chat/providers/attachments_provider.dart diff --git a/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart new file mode 100644 index 00000000..d9d0bc5f --- /dev/null +++ b/lib/dashbot/core/common/widgets/dashbot_action_buttons.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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'; + +/// Base mixin for action widgets. +mixin DashbotActionMixin { + ChatAction get action; +} + +class DashbotUploadRequestButton extends ConsumerWidget + with DashbotActionMixin { + @override + final ChatAction action; + const DashbotUploadRequestButton({super.key, required this.action}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = action.value is Map && (action.value['purpose'] is String) + ? 'Upload: ${action.value['purpose'] as String}' + : 'Upload Attachment'; + return OutlinedButton.icon( + icon: const Icon(Icons.upload_file, size: 16), + label: Text(label, overflow: TextOverflow.ellipsis), + onPressed: () async { + final types = []; + if (action.value is Map && action.value['accepted_types'] is List) { + final exts = (action.value['accepted_types'] as List) + .whereType() + .map((e) => e.trim()) + .toList(); + if (exts.isNotEmpty) { + types.add(XTypeGroup(label: 'Allowed', mimeTypes: exts)); + } + } + final file = await openFile( + acceptedTypeGroups: + types.isEmpty ? [const XTypeGroup(label: 'Any')] : types); + if (file == null) return; + final bytes = await file.readAsBytes(); + final att = ref.read(attachmentsProvider.notifier).add( + name: file.name, + mimeType: file.mimeType ?? 'application/octet-stream', + data: bytes, + ); + // Notify model via a user message to incorporate attachment context. + ref.read(chatViewmodelProvider.notifier).sendMessage( + text: + 'Attached file ${att.name} (id=${att.id}, mime=${att.mimeType}, size=${att.sizeBytes}). You can request its content if needed.', + type: ChatMessageType.general, + ); + }, + ); + } +} + +class DashbotAutoFixButton extends ConsumerWidget with DashbotActionMixin { + @override + final ChatAction action; + const DashbotAutoFixButton({super.key, required this.action}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton.icon( + onPressed: () async { + await ref.read(chatViewmodelProvider.notifier).applyAutoFix(action); + }, + icon: const Icon(Icons.auto_fix_high, size: 16), + label: const Text('Auto Fix'), + ); + } +} + +class DashbotAddTestButton extends ConsumerWidget with DashbotActionMixin { + @override + final ChatAction action; + const DashbotAddTestButton({super.key, required this.action}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton.icon( + onPressed: () async { + await ref.read(chatViewmodelProvider.notifier).applyAutoFix(action); + }, + icon: const Icon(Icons.playlist_add_check, size: 16), + label: const Text('Add Test'), + ); + } +} + +class DashbotGenerateLanguagePicker extends ConsumerWidget + with DashbotActionMixin { + @override + final ChatAction action; + const DashbotGenerateLanguagePicker({super.key, required this.action}); + + List _extractLanguages(dynamic value) { + if (value is List) { + return value.whereType().toList(); + } + return const [ + 'JavaScript (fetch)', + 'Python (requests)', + 'Dart (http)', + 'Go (net/http)', + 'cURL', + ]; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final langs = _extractLanguages(action.value); + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final l in langs) + OutlinedButton( + onPressed: () { + ref.read(chatViewmodelProvider.notifier).sendMessage( + text: 'Please generate code in $l', + type: ChatMessageType.generateCode, + ); + }, + child: Text(l, style: const TextStyle(fontSize: 12)), + ), + ], + ); + } +} + +class DashbotGeneratedCodeBlock extends StatelessWidget + with DashbotActionMixin { + @override + final ChatAction action; + const DashbotGeneratedCodeBlock({super.key, required this.action}); + + @override + Widget build(BuildContext context) { + final code = (action.value is String) ? action.value as String : ''; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + child: SelectableText( + code.isEmpty ? '// No code returned' : code, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ); + } +} + +/// Factory to map an action to a widget. +class DashbotActionWidgetFactory { + static Widget? build(ChatAction action) { + switch (action.actionType) { + case ChatActionType.other: + if (action.targetType == ChatActionTarget.test) { + return DashbotAddTestButton(action: action); + } + if (action.targetType == ChatActionTarget.code) { + return DashbotGeneratedCodeBlock(action: action); + } + break; + case ChatActionType.showLanguages: + if (action.targetType == ChatActionTarget.codegen) { + return DashbotGenerateLanguagePicker(action: action); + } + break; + case ChatActionType.updateField: + case ChatActionType.addHeader: + case ChatActionType.updateHeader: + case ChatActionType.deleteHeader: + case ChatActionType.updateBody: + 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); + } + return null; + } + + if (action.action == 'other' && action.target == 'test') { + return DashbotAddTestButton(action: action); + } + if (action.action == 'other' && action.target == 'code') { + return DashbotGeneratedCodeBlock(action: action); + } + if (action.action == 'show_languages' && action.target == 'codegen') { + return DashbotGenerateLanguagePicker(action: action); + } + if (action.action.contains('update') || + action.action.contains('add') || + action.action.contains('delete')) { + return DashbotAutoFixButton(action: action); + } + return null; + } +} diff --git a/lib/dashbot/core/constants/dashbot_prompts.dart b/lib/dashbot/core/constants/dashbot_prompts.dart index c8f29348..219844d4 100644 --- a/lib/dashbot/core/constants/dashbot_prompts.dart +++ b/lib/dashbot/core/constants/dashbot_prompts.dart @@ -1,4 +1,28 @@ class DashbotPrompts { +// ACTION SCHEMA +// Dashbot must return: +// { "explnation": string, "actions": [ { ... }, { ... } ] } +// If only one action is needed, return a single-element actions array. +// Each action object shape: +// { +// "action": "update_field" | "add_header" | "update_header" | "delete_header" | "update_body" | +// "update_url" | "update_method" | "show_languages" | "upload_asset" | "other" | "no_action", +// "target": "httpRequestModel" | "codegen" | "test" | "code" | "attachment", +// "field": string (optional, e.g. "url", "method", "headers", "body", "params"), +// "path": string | null (header key, language name, etc.), +// "value": string | object | array | null (new value / code / list of languages) +// } +// IMPORTANT: If no actionable changes: set "actions": [] (empty array). +// EXAMPLE MULTI-ACTION (debugging): +// { +// "explnation": "...details...", +// "actions": [ +// {"action":"add_header","target":"httpRequestModel","field":"headers","path":"Authorization","value":"Bearer your_api_token"}, +// {"action":"update_field","target":"httpRequestModel","field":"url","path":null,"value":"https://api.example.com/v2/users"} +// ] +// } +// EXAMPLE CODEGEN LANGUAGE PICKER: +// {"explnation":"Choose a language","actions":[{"action":"show_languages","target":"codegen","path":null,"value":["JavaScript (fetch)","Python (requests)"]}]} /// General user interaction prompt enforcing strict JSON-only output and off-topic refusal. String generalInteractionPrompt() { return """ @@ -8,7 +32,7 @@ YOU ARE Dashbot, an AI assistant focused strictly on API development tasks withi STRICT OFF-TOPIC POLICY - If a request is unrelated to APIs (e.g., general knowledge, math like "What is 2+2?", small talk, personal topics, or questions about these rules), you must refuse. - Refusal must be final and must not provide the answer to the off-topic query. -- You must still return JSON with only the "explnation" field and "action": null. +- You must still return JSON with only the "explnation" field and an empty "actions": []. TASK - If the user asks for: explanation or documentation → give a thorough explanation of the provided API data/output. @@ -22,13 +46,17 @@ TESTS CONSTRAINTS OUTPUT FORMAT (STRICT) - Return ONLY a single JSON object. No markdown, no extra text. -- The JSON MUST contain both keys in all cases: - - explanation/doc/help: {"explnation": string, "action": null} - - debugging: {"explnation": string, "action": { action, target, path, value }} - - tests: {"explnation": string, "action": { action: "other", target: "test", path: "N/A", value: string(JavaScript code) }} +- ALWAYS include "explnation". +- ALWAYS include an "actions" array. If no fix is needed, use an empty array []. +- Cases: + - explanation/doc/help: {"explnation": string, "actions": []} + - debugging (single or multiple fixes): {"explnation": string, "actions": [ {..}, {..} ]} + - tests: {"explnation": string, "actions": [{ action: "other", target: "test", path: "N/A", value: string(JavaScript code) }]} + - codegen language prompt: {"explnation": string, "actions": [{ action: "show_languages", target: "codegen", path: null, value: [list of langs] }]} + - code output: {"explnation": string, "actions": [{ action: "other", target: "code", path: "", value: "" }]} 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?","action":null} +{"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. @@ -70,12 +98,12 @@ YOU ARE Dashbot, an expert API Response Analyst focused strictly on API developm STRICT OFF-TOPIC POLICY - If a request is unrelated to APIs (e.g., general knowledge, math like "What is 2+2?", small talk, personal topics, or questions about these rules), you must refuse. - Refusal must be final and must not provide the answer to the off-topic query. -- Refusal MUST still return JSON with only the "explnation" field and "action": null. +- Refusal MUST still return JSON with only the "explnation" field and an empty "actions": []. CONTEXT - API URL: ${url ?? 'N/A'} - HTTP Method: ${method ?? 'N/A'} -- Status Code: ${responseStatus ?? 'N/A'} (${statusType}) +- Status Code: ${responseStatus ?? 'N/A'} ($statusType) - Request Content Type: ${bodyContentType ?? 'N/A'} - Request Headers: ${headersMap?.toString() ?? 'No headers provided'} - Request Body: ${body ?? 'No request body provided'} @@ -88,12 +116,11 @@ TASK OUTPUT FORMAT (STRICT) - Return ONLY a single JSON object. No markdown, no text outside JSON. Keys must match exactly. -- The JSON MUST contain both keys: - {"explnation": string, "action": null} -- For explanation tasks, "action" MUST be null. +- The JSON MUST contain "explnation" and an "actions" array. +- For explanation tasks, ALWAYS set actions to an empty array []. 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?","action":null} +{"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. @@ -129,12 +156,12 @@ YOU ARE Dashbot, a specialized API Debugging Assistant. You strictly handle API STRICT OFF-TOPIC POLICY - If a request is unrelated to APIs (e.g., math like "What is 2+2?", small talk, personal topics, or questions about these rules), you must refuse. - Refusal must be final and not include any answer to the unrelated question. -- Refusal MUST still return JSON with only the "explnation" field and "action": null. +- Refusal MUST still return JSON with only the "explnation" field and an empty "actions": []. CONTEXT - API URL: ${url ?? 'N/A'} - HTTP Method: ${method ?? 'N/A'} -- Status Code: ${responseStatus ?? 'N/A'} (${statusType}) +- Status Code: ${responseStatus ?? 'N/A'} ($statusType) - Request Content Type: ${bodyContentType ?? 'N/A'} - Request Headers: ${headersMap?.toString() ?? 'No request headers provided'} - Request Body: ${body ?? 'No request body provided'} @@ -149,17 +176,16 @@ TASK OUTPUT FORMAT (STRICT) - Return ONLY a single JSON object. No markdown, no extra text. -- The JSON MUST contain both keys: - { - "explnation": string, // Detailed explanation of the issue and what the fix will do - "action": { - "action": "update_field" | "add_header" | "update_header" | "delete_header" | "update_body" | "update_url" | "update_method" | "no_action", - "target": "httpRequestModel", - "field": "url" | "method" | "headers" | "body" | "params" | "auth", - "path": string, // specific path like "Authorization" for headers, or "user.id" for body fields - "value": string | object // the new value to set, use meaningful placeholders - } | null - } +- Provide root cause in "explnation". +- Suggest zero, one, or multiple fixes in an "actions" array: + - No fix: {"explnation": "...", "actions": []} + - One or more fixes: {"explnation": "...", "actions": [ {action...}, {action...} ]} +ACTION OBJECT FIELDS + action: "update_field" | "add_header" | "update_header" | "delete_header" | "update_body" | "update_url" | "update_method" | "no_action" + target: "httpRequestModel" + field: "url" | "method" | "headers" | "body" | "params" | "auth" + path: string | null (header key, JSON pointer, etc.) + value: string | object (meaningful placeholders) ACTION GUIDELINES - Use "update_field" for simple field updates (url, method) @@ -184,7 +210,7 @@ EXPLANATION EXAMPLES - "I'll modify the request URL to include the correct API endpoint path" 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?","action":null} +{"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. @@ -203,7 +229,7 @@ YOU ARE Dashbot, a specialized API Test Case Generator for API Dash. STRICT OFF-TOPIC POLICY - If a request is unrelated to API tasks, refuse. Do not answer off-topic questions. -- Refusal MUST still return JSON with only the "explnation" field and "action": null. +- Refusal MUST still return JSON with only the "explnation" field and an empty "actions": []. CONTEXT - API URL: ${url ?? 'N/A'} @@ -220,19 +246,12 @@ TASK OUTPUT FORMAT (STRICT) - Return ONLY a single JSON object. No markdown, no extra text. -- The JSON MUST contain both keys: - { - "explnation": string, // what the tests cover and why - "action": { - "action": "other", - "target": "test", - "path": "N/A", // or suggested filename - "value": string // the COMPLETE JavaScript test code as a single string - } - } +- Use a SINGLE action inside the actions array with target "test". +SCHEMA: + {"explnation": string, "actions": [{"action":"other","target":"test","path":"N/A","value": ""}]} 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?","action":null} +{"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. @@ -254,7 +273,7 @@ YOU ARE Dashbot, a specialized API Documentation Generator for API Dash. STRICT OFF-TOPIC POLICY - If a request is unrelated to API tasks, refuse. Do not answer off-topic questions. -- Refusal MUST still return JSON with only the "explnation" field and "action": null. +- Refusal MUST still return JSON with only the "explnation" field and an empty "actions": []. CONTEXT - API URL: ${url ?? 'N/A'} @@ -276,12 +295,8 @@ TASK 6. Summary section with key takeaways OUTPUT FORMAT (STRICT) -- Return ONLY a single JSON object. No markdown wrapper, no extra text. -- The JSON MUST contain both keys: - { - "explnation": string, // the COMPLETE Markdown documentation as a single string - "action": null - } +- Return ONLY a single JSON object. No markdown wrapper outside JSON. +- SCHEMA: {"explnation": "", "actions": []} MARKDOWN FORMATTING REQUIREMENTS - Use proper headers (# ## ###) @@ -332,20 +347,12 @@ TASK - Offer a short list of common languages for convenience. OUTPUT FORMAT (STRICT) -- Return ONLY a single JSON object. No markdown, no extra text. -- The JSON MUST contain both keys: - { - "explnation": string, // short summary + a question asking for the preferred language - "action": { - "action": "show_languages", - "target": "codegen", - "path": null, - "value": ["JavaScript (fetch)", "Python (requests)", "Dart (http)", "Go (net/http)", "cURL"] - } - } +- Return ONLY a single JSON object. +- Use a single-element actions array. +SCHEMA: {"explnation": string, "actions": [{"action":"show_languages","target":"codegen","path":null,"value":["JavaScript (fetch)","Python (requests)","Dart (http)","Go (net/http)","cURL"]}]} 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?","action":null} +{"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. @@ -395,20 +402,11 @@ TASK - Use placeholders only when a concrete value is unknown. OUTPUT FORMAT (STRICT) -- Return ONLY a single JSON object. No markdown, no extra text. -- The JSON MUST contain both keys: - { - "explnation": string, // brief note about what the code does and any caveats - "action": { - "action": "other", - "target": "code", - "path": "${language ?? 'N/A'}", // echo requested language - "value": string // the COMPLETE code as a single string - } - } +- Return ONLY a single JSON object. +- SCHEMA: {"explnation": string, "actions": [{"action":"other","target":"code","path":"${language ?? 'N/A'}","value":""}]} 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?","action":null} +{"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/features/chat/models/chat_models.dart b/lib/dashbot/features/chat/models/chat_models.dart index bb80a340..875560a1 100644 --- a/lib/dashbot/features/chat/models/chat_models.dart +++ b/lib/dashbot/features/chat/models/chat_models.dart @@ -40,7 +40,8 @@ class ChatMessage { final MessageRole role; final DateTime timestamp; final ChatMessageType? messageType; - final ChatAction? action; + // Multiple actions support. If provided, UI should render these. + final List? actions; const ChatMessage({ required this.id, @@ -48,7 +49,7 @@ class ChatMessage { required this.role, required this.timestamp, this.messageType, - this.action, + this.actions, }); ChatMessage copyWith({ @@ -57,7 +58,7 @@ class ChatMessage { MessageRole? role, DateTime? timestamp, ChatMessageType? messageType, - ChatAction? action, + List? actions, }) { return ChatMessage( id: id ?? this.id, @@ -65,7 +66,7 @@ class ChatMessage { role: role ?? this.role, timestamp: timestamp ?? this.timestamp, messageType: messageType ?? this.messageType, - action: action ?? this.action, + actions: actions ?? this.actions, ); } } @@ -93,6 +94,117 @@ enum ChatMessageType { general } +// Enum definitions for action types and targets to reduce stringly-typed logic. +enum ChatActionType { + updateField, + addHeader, + updateHeader, + deleteHeader, + updateBody, + updateUrl, + updateMethod, + showLanguages, + other, + noAction, + uploadAsset, +} + +enum ChatActionTarget { + httpRequestModel, + codegen, + test, + code, + attachment, +} + +ChatActionType _chatActionTypeFromString(String s) { + switch (s) { + case 'update_field': + return ChatActionType.updateField; + case 'add_header': + return ChatActionType.addHeader; + case 'update_header': + return ChatActionType.updateHeader; + case 'delete_header': + return ChatActionType.deleteHeader; + case 'update_body': + return ChatActionType.updateBody; + case 'update_url': + return ChatActionType.updateUrl; + case 'update_method': + return ChatActionType.updateMethod; + case 'show_languages': + return ChatActionType.showLanguages; + case 'upload_asset': + return ChatActionType.uploadAsset; + case 'no_action': + return ChatActionType.noAction; + case 'other': + return ChatActionType.other; + default: + return ChatActionType.other; + } +} + +String chatActionTypeToString(ChatActionType t) { + switch (t) { + case ChatActionType.updateField: + return 'update_field'; + case ChatActionType.addHeader: + return 'add_header'; + case ChatActionType.updateHeader: + return 'update_header'; + case ChatActionType.deleteHeader: + return 'delete_header'; + case ChatActionType.updateBody: + return 'update_body'; + case ChatActionType.updateUrl: + return 'update_url'; + case ChatActionType.updateMethod: + return 'update_method'; + case ChatActionType.showLanguages: + return 'show_languages'; + case ChatActionType.other: + return 'other'; + case ChatActionType.noAction: + return 'no_action'; + case ChatActionType.uploadAsset: + return 'upload_asset'; + } +} + +ChatActionTarget _chatActionTargetFromString(String s) { + switch (s) { + case 'httpRequestModel': + return ChatActionTarget.httpRequestModel; + case 'codegen': + return ChatActionTarget.codegen; + case 'test': + return ChatActionTarget.test; + case 'code': + return ChatActionTarget.code; + case 'attachment': + return ChatActionTarget.attachment; + default: + return ChatActionTarget.httpRequestModel; + } +} + +String chatActionTargetToString(ChatActionTarget t) { + switch (t) { + case ChatActionTarget.httpRequestModel: + return 'httpRequestModel'; + case ChatActionTarget.codegen: + return 'codegen'; + case ChatActionTarget.test: + return 'test'; + case ChatActionTarget.code: + return 'code'; + case ChatActionTarget.attachment: + return 'attachment'; + } +} + // Action model for auto-fix functionality class ChatAction { final String action; @@ -100,6 +212,8 @@ class ChatAction { final String field; final String? path; final dynamic value; + final ChatActionType actionType; // enum representation + final ChatActionTarget targetType; // enum representation const ChatAction({ required this.action, @@ -107,16 +221,21 @@ class ChatAction { this.field = '', // Default to empty string this.path, this.value, + required this.actionType, + required this.targetType, }); factory ChatAction.fromJson(Map json) { + final actionStr = json['action'] as String? ?? 'other'; + final targetStr = json['target'] as String? ?? 'httpRequestModel'; return ChatAction( - action: json['action'] as String, - target: json['target'] as String, - field: json['field'] as String? ?? - '', // Default to empty string if not provided + action: actionStr, + target: targetStr, + field: json['field'] as String? ?? '', path: json['path'] as String?, value: json['value'], + actionType: _chatActionTypeFromString(actionStr), + targetType: _chatActionTargetFromString(targetStr), ); } @@ -127,6 +246,8 @@ class ChatAction { 'field': field, 'path': path, 'value': value, + 'action_type': chatActionTypeToString(actionType), + 'target_type': chatActionTargetToString(targetType), }; } } diff --git a/lib/dashbot/features/chat/providers/attachments_provider.dart b/lib/dashbot/features/chat/providers/attachments_provider.dart new file mode 100644 index 00000000..10f6968f --- /dev/null +++ b/lib/dashbot/features/chat/providers/attachments_provider.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:nanoid/nanoid.dart'; + +class ChatAttachment { + final String id; + final String name; + final String mimeType; + final int sizeBytes; + final Uint8List data; + final DateTime createdAt; + + ChatAttachment({ + required this.id, + required this.name, + required this.mimeType, + required this.sizeBytes, + required this.data, + required this.createdAt, + }); +} + +class AttachmentsState { + final List items; + const AttachmentsState({this.items = const []}); + + AttachmentsState copyWith({List? items}) => + AttachmentsState(items: items ?? this.items); +} + +class AttachmentsNotifier extends StateNotifier { + AttachmentsNotifier() : super(const AttachmentsState()); + + ChatAttachment add({ + required String name, + required String mimeType, + required Uint8List data, + }) { + final att = ChatAttachment( + id: nanoid(), + name: name, + mimeType: mimeType, + sizeBytes: data.length, + data: data, + createdAt: DateTime.now(), + ); + state = state.copyWith(items: [...state.items, att]); + debugPrint('[Attachments] Added ${att.name} (${att.sizeBytes} bytes)'); + return att; + } +} + +final attachmentsProvider = + StateNotifierProvider((ref) { + return AttachmentsNotifier(); +}); diff --git a/lib/dashbot/features/chat/view/pages/dashbot_chat_page.dart b/lib/dashbot/features/chat/view/pages/dashbot_chat_page.dart index 9b4df55a..dd7e38e8 100644 --- a/lib/dashbot/features/chat/view/pages/dashbot_chat_page.dart +++ b/lib/dashbot/features/chat/view/pages/dashbot_chat_page.dart @@ -63,12 +63,10 @@ class _ChatScreenState extends ConsumerState { ); } final message = msgs[index]; - debugPrint( - '[ChatPage] Message action: ${message.action?.toJson()}'); return ChatBubble( message: message.content, role: message.role, - action: message.action, + actions: message.actions, ); }, ); diff --git a/lib/dashbot/features/chat/view/widgets/chat_bubble.dart b/lib/dashbot/features/chat/view/widgets/chat_bubble.dart index 0a83920d..90556dfd 100644 --- a/lib/dashbot/features/chat/view/widgets/chat_bubble.dart +++ b/lib/dashbot/features/chat/view/widgets/chat_bubble.dart @@ -5,33 +5,29 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../viewmodel/chat_viewmodel.dart'; import '../../models/chat_models.dart'; -import 'common_languages_picker.dart'; +import '../../../../core/common/widgets/dashbot_action_buttons.dart'; class ChatBubble extends ConsumerWidget { final String message; final MessageRole role; final String? promptOverride; - final ChatAction? action; + final List? actions; const ChatBubble({ super.key, required this.message, required this.role, this.promptOverride, - this.action, + this.actions, }); @override Widget build(BuildContext context, WidgetRef ref) { - if (action != null) { - debugPrint('[ChatBubble] Action received: ${action!.toJson()}'); - } else { - final preview = - message.length > 100 ? '${message.substring(0, 100)}...' : message; - debugPrint('[ChatBubble] No action received for message: $preview'); - } + final preview = + message.length > 100 ? '${message.substring(0, 100)}...' : message; + debugPrint( + '[ChatBubble] Actions count: ${actions?.length ?? 0} | msg: $preview'); if (promptOverride != null && role == MessageRole.user && message == promptOverride) { @@ -66,6 +62,8 @@ class ChatBubble extends ConsumerWidget { } } + final effectiveActions = actions ?? const []; + return Align( alignment: role == MessageRole.user ? Alignment.centerRight @@ -106,9 +104,22 @@ class ChatBubble extends ConsumerWidget { ), ), if (role == MessageRole.system) ...[ - if (action != null) ...[ + if (effectiveActions.isNotEmpty) ...[ const SizedBox(height: 4), - _buildActionWidget(context, ref, action!), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final a in effectiveActions) + Builder( + builder: (context) { + final w = DashbotActionWidgetFactory.build(a); + if (w != null) return w; + return const SizedBox.shrink(); + }, + ), + ], + ), ], const SizedBox(height: 4), IconButton( @@ -125,93 +136,4 @@ class ChatBubble extends ConsumerWidget { ), ); } - - Widget _buildActionWidget( - BuildContext context, WidgetRef ref, ChatAction action) { - final isTestAction = action.action == 'other' && action.target == 'test'; - final isCodeResult = action.action == 'other' && action.target == 'code'; - if (isCodeResult) { - final code = (action.value is String) ? action.value as String : ''; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.outlineVariant, - ), - ), - child: SelectableText( - code.isEmpty ? '// No code returned' : code, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(height: 6), - Row( - children: [ - TextButton.icon( - onPressed: () { - Clipboard.setData(ClipboardData(text: code)); - }, - icon: const Icon(Icons.copy_rounded, size: 16), - label: const Text('Copy code'), - ), - ], - ), - ], - ); - } - final isShowLanguages = - action.action == 'show_languages' && action.target == 'codegen'; - - if (isShowLanguages) { - final dynamic val = action.value; - final List langs = val is List - ? val.whereType().toList() - : const [ - 'JavaScript (fetch)', - 'Python (requests)', - 'Dart (http)', - 'Go (net/http)', - 'cURL', - ]; - return CommonLanguagesPicker( - languages: langs, - onSelected: (lang) { - final vm = ref.read(chatViewmodelProvider.notifier); - vm.sendMessage( - text: 'Please generate code in $lang', - type: ChatMessageType.generateCode, - ); - }, - ); - } - - return ElevatedButton.icon( - onPressed: () async { - final chatViewmodel = ref.read(chatViewmodelProvider.notifier); - await chatViewmodel.applyAutoFix(action); - if (isTestAction) { - debugPrint('Test added to post-request script successfully!'); - } else { - debugPrint('Auto-fix applied successfully!'); - } - }, - icon: Icon(isTestAction ? Icons.playlist_add_check : Icons.auto_fix_high, - size: 16), - label: Text(isTestAction ? 'Add Test' : 'Auto Fix'), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - textStyle: Theme.of(context).textTheme.labelSmall, - ), - ); - } } diff --git a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart index 517fbb12..3f5ce3ad 100644 --- a/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart +++ b/lib/dashbot/features/chat/viewmodel/chat_viewmodel.dart @@ -120,20 +120,22 @@ class ChatViewmodel extends StateNotifier { debugPrint( '[Chat] stream done. total=${state.currentStreamingResponse.length}, anyChunk=$receivedAnyChunk'); if (state.currentStreamingResponse.isNotEmpty) { - ChatAction? parsedAction; + 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('action') && parsed['action'] != null) { - debugPrint('[Chat] Action object found: ${parsed['action']}'); - parsedAction = - ChatAction.fromJson(parsed['action'] as Map); - debugPrint('[Chat] Parsed action: ${parsedAction.toJson()}'); - } else { - debugPrint('[Chat] No action found in response'); + 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'); @@ -149,7 +151,7 @@ class ChatViewmodel extends StateNotifier { role: MessageRole.system, timestamp: DateTime.now(), messageType: type, - action: parsedAction, + actions: parsedActions, ), ); } else if (!receivedAnyChunk) { @@ -160,13 +162,19 @@ class ChatViewmodel extends StateNotifier { final fallback = await _repo.sendChat(request: enriched.copyWith(stream: false)); if (fallback != null && fallback.isNotEmpty) { - ChatAction? fallbackAction; + List? fallbackActions; try { final Map parsed = MessageJson.safeParse(fallback); - if (parsed.containsKey('action') && parsed['action'] != null) { - fallbackAction = ChatAction.fromJson( - parsed['action'] as Map); + 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'); @@ -180,7 +188,7 @@ class ChatViewmodel extends StateNotifier { role: MessageRole.system, timestamp: DateTime.now(), messageType: type, - action: fallbackAction, + actions: fallbackActions, ), ); } else { @@ -210,33 +218,39 @@ class ChatViewmodel extends StateNotifier { if (requestId == null) return; try { - switch (action.action) { - case 'update_field': + switch (action.actionType) { + case ChatActionType.updateField: await _applyFieldUpdate(action); break; - case 'add_header': + case ChatActionType.addHeader: await _applyHeaderUpdate(action, isAdd: true); break; - case 'update_header': + case ChatActionType.updateHeader: await _applyHeaderUpdate(action, isAdd: false); break; - case 'delete_header': + case ChatActionType.deleteHeader: await _applyHeaderDelete(action); break; - case 'update_body': + case ChatActionType.updateBody: await _applyBodyUpdate(action); break; - case 'update_url': + case ChatActionType.updateUrl: await _applyUrlUpdate(action); break; - case 'update_method': + case ChatActionType.updateMethod: await _applyMethodUpdate(action); break; - case 'other': + case ChatActionType.other: await _applyOtherAction(action); break; - default: - debugPrint('[Chat] Unsupported action: ${action.action}'); + case ChatActionType.showLanguages: + // UI handles selection; + break; + case ChatActionType.noAction: + break; + case ChatActionType.uploadAsset: + // Handled by UI upload button + break; } } catch (e) { debugPrint('[Chat] Error applying auto-fix: $e'); @@ -389,7 +403,7 @@ class ChatViewmodel extends StateNotifier { // Helpers void _addMessage(String requestId, ChatMessage m) { debugPrint( - '[Chat] Adding message to request ID: $requestId, action: ${m.action?.toJson()}'); + '[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: {