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
This commit is contained in:
Udhay-Adithya
2025-09-16 01:39:39 +05:30
parent ca4a7415df
commit 16e7b92934
7 changed files with 531 additions and 208 deletions

View File

@@ -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<ChatAction>? 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<ChatAction>? 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<String, dynamic> 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),
};
}
}

View File

@@ -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<ChatAttachment> items;
const AttachmentsState({this.items = const []});
AttachmentsState copyWith({List<ChatAttachment>? items}) =>
AttachmentsState(items: items ?? this.items);
}
class AttachmentsNotifier extends StateNotifier<AttachmentsState> {
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<AttachmentsNotifier, AttachmentsState>((ref) {
return AttachmentsNotifier();
});

View File

@@ -63,12 +63,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
);
}
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,
);
},
);

View File

@@ -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<ChatAction>? 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<String> langs = val is List
? val.whereType<String>().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,
),
);
}
}

View File

@@ -120,20 +120,22 @@ class ChatViewmodel extends StateNotifier<ChatState> {
debugPrint(
'[Chat] stream done. total=${state.currentStreamingResponse.length}, anyChunk=$receivedAnyChunk');
if (state.currentStreamingResponse.isNotEmpty) {
ChatAction? parsedAction;
List<ChatAction>? parsedActions;
try {
debugPrint(
'[Chat] Attempting to parse response: ${state.currentStreamingResponse}');
final Map<String, dynamic> 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<String, dynamic>);
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<String, dynamic>>()
.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<ChatState> {
role: MessageRole.system,
timestamp: DateTime.now(),
messageType: type,
action: parsedAction,
actions: parsedActions,
),
);
} else if (!receivedAnyChunk) {
@@ -160,13 +162,19 @@ class ChatViewmodel extends StateNotifier<ChatState> {
final fallback =
await _repo.sendChat(request: enriched.copyWith(stream: false));
if (fallback != null && fallback.isNotEmpty) {
ChatAction? fallbackAction;
List<ChatAction>? fallbackActions;
try {
final Map<String, dynamic> parsed =
MessageJson.safeParse(fallback);
if (parsed.containsKey('action') && parsed['action'] != null) {
fallbackAction = ChatAction.fromJson(
parsed['action'] as Map<String, dynamic>);
if (parsed.containsKey('actions') &&
parsed['actions'] is List) {
fallbackActions = (parsed['actions'] as List)
.whereType<Map<String, dynamic>>()
.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<ChatState> {
role: MessageRole.system,
timestamp: DateTime.now(),
messageType: type,
action: fallbackAction,
actions: fallbackActions,
),
);
} else {
@@ -210,33 +218,39 @@ class ChatViewmodel extends StateNotifier<ChatState> {
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<ChatState> {
// 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: {