mirror of
https://github.com/foss42/apidash.git
synced 2025-12-02 02:39:19 +08:00
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:
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user