feat: inline user message editing
This commit is contained in:
@@ -2797,14 +2797,14 @@ class ApiService {
|
|||||||
debugPrint('DEBUG: Message count: ${processedMessages.length}');
|
debugPrint('DEBUG: Message count: ${processedMessages.length}');
|
||||||
|
|
||||||
// Debug the data being sent
|
// Debug the data being sent
|
||||||
debugPrint('DEBUG: SSE request data keys: ${data.keys.toList()}');
|
debugPrint('DEBUG: SSE request data keys (pre-bg): ${data.keys.toList()}');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}',
|
'DEBUG: Has background_tasks (pre-bg): ${data.containsKey('background_tasks')}',
|
||||||
);
|
);
|
||||||
debugPrint('DEBUG: Has session_id: ${data.containsKey('session_id')}');
|
debugPrint('DEBUG: Has session_id (pre-bg): ${data.containsKey('session_id')}');
|
||||||
debugPrint('DEBUG: background_tasks value: ${data['background_tasks']}');
|
debugPrint('DEBUG: background_tasks value (pre-bg): ${data['background_tasks']}');
|
||||||
debugPrint('DEBUG: session_id value: ${data['session_id']}');
|
debugPrint('DEBUG: session_id value (pre-bg): ${data['session_id']}');
|
||||||
debugPrint('DEBUG: id value: ${data['id']}');
|
debugPrint('DEBUG: id value (pre-bg): ${data['id']}');
|
||||||
|
|
||||||
// Decide whether to use background task flow.
|
// Decide whether to use background task flow.
|
||||||
// Only enable background task mode when we actually need socket/dynamic-channel
|
// Only enable background task mode when we actually need socket/dynamic-channel
|
||||||
@@ -2831,6 +2831,13 @@ class ApiService {
|
|||||||
data['background_tasks'] = backgroundTasks;
|
data['background_tasks'] = backgroundTasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extra diagnostics to confirm dynamic-channel payload
|
||||||
|
debugPrint('DEBUG: Background flow payload keys: ${data.keys.toList()}');
|
||||||
|
debugPrint('DEBUG: Using session_id: $sessionId');
|
||||||
|
debugPrint('DEBUG: Using message id: $messageId');
|
||||||
|
debugPrint('DEBUG: Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}');
|
||||||
|
debugPrint('DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}');
|
||||||
|
|
||||||
debugPrint('DEBUG: Initiating background tools flow (task-based)');
|
debugPrint('DEBUG: Initiating background tools flow (task-based)');
|
||||||
debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)');
|
debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)');
|
||||||
|
|
||||||
|
|||||||
@@ -143,4 +143,18 @@ class SocketService {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_socket = null;
|
_socket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: ensure there is an active connection and wait briefly.
|
||||||
|
// Returns true if connected by the end of the timeout.
|
||||||
|
Future<bool> ensureConnected({Duration timeout = const Duration(seconds: 2)}) async {
|
||||||
|
if (isConnected) return true;
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
} catch (_) {}
|
||||||
|
final start = DateTime.now();
|
||||||
|
while (!isConnected && DateTime.now().difference(start) < timeout) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
return isConnected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -886,10 +886,21 @@ Future<void> regenerateMessage(
|
|||||||
|
|
||||||
// Socket binding for background flows
|
// Socket binding for background flows
|
||||||
final socketService = ref.read(socketServiceProvider);
|
final socketService = ref.read(socketServiceProvider);
|
||||||
final socketSessionId = socketService?.sessionId;
|
String? socketSessionId = socketService?.sessionId;
|
||||||
final bool wantSessionBinding =
|
bool wantSessionBinding =
|
||||||
(socketService?.isConnected == true) &&
|
(socketService?.isConnected == true) &&
|
||||||
(socketSessionId != null && socketSessionId.isNotEmpty);
|
(socketSessionId != null && socketSessionId.isNotEmpty);
|
||||||
|
// When regenerating with tools, make a best-effort to ensure a live socket.
|
||||||
|
if (!wantSessionBinding && socketService != null) {
|
||||||
|
try {
|
||||||
|
final ok = await socketService.ensureConnected();
|
||||||
|
if (ok) {
|
||||||
|
socketSessionId = socketService.sessionId;
|
||||||
|
wantSessionBinding =
|
||||||
|
socketSessionId != null && socketSessionId.isNotEmpty;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve tool servers from user settings (if any)
|
// Resolve tool servers from user settings (if any)
|
||||||
List<Map<String, dynamic>>? toolServers;
|
List<Map<String, dynamic>>? toolServers;
|
||||||
|
|||||||
@@ -746,7 +746,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
isStreaming: isStreaming,
|
isStreaming: isStreaming,
|
||||||
modelName: displayModelName,
|
modelName: displayModelName,
|
||||||
onCopy: () => _copyMessage(message.content),
|
onCopy: () => _copyMessage(message.content),
|
||||||
onEdit: () => _editMessage(message),
|
|
||||||
onRegenerate: () => _regenerateMessage(message),
|
onRegenerate: () => _regenerateMessage(message),
|
||||||
onLike: () => _likeMessage(message),
|
onLike: () => _likeMessage(message),
|
||||||
onDislike: () => _dislikeMessage(message),
|
onDislike: () => _dislikeMessage(message),
|
||||||
@@ -834,90 +833,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _editMessage(dynamic message) async {
|
// Inline editing handled by UserMessageBubble. Dialog flow removed.
|
||||||
if (message.role != 'user') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final controller = TextEditingController(text: message.content);
|
|
||||||
final result = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
AppLocalizations.of(context)!.editMessage,
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller,
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
maxLines: null,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: AppLocalizations.of(context)!.messageHintText,
|
|
||||||
hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(color: context.conduitTheme.inputBorder),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(color: context.conduitTheme.inputBorder),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(color: context.conduitTheme.buttonPrimary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(
|
|
||||||
'Cancel',
|
|
||||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
child: Text(AppLocalizations.of(context)!.save),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null && result.isNotEmpty && result != message.content) {
|
|
||||||
try {
|
|
||||||
// Find the message index and remove all messages after it
|
|
||||||
final messages = ref.read(chatMessagesProvider);
|
|
||||||
final messageIndex = messages.indexOf(message);
|
|
||||||
|
|
||||||
if (messageIndex >= 0) {
|
|
||||||
// Remove messages from this point onwards
|
|
||||||
final messagesToKeep = messages.take(messageIndex).toList();
|
|
||||||
ref.read(chatMessagesProvider.notifier).setMessages(messagesToKeep);
|
|
||||||
|
|
||||||
// Send the edited message
|
|
||||||
final selectedModel = ref.read(selectedModelProvider);
|
|
||||||
if (selectedModel != null) {
|
|
||||||
final activeConv = ref.read(activeConversationProvider);
|
|
||||||
await ref.read(taskQueueProvider.notifier).enqueueSendText(
|
|
||||||
conversationId: activeConv?.id,
|
|
||||||
text: result,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _likeMessage(dynamic message) {
|
void _likeMessage(dynamic message) {
|
||||||
// TODO: Implement message liking
|
// TODO: Implement message liking
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
import 'package:conduit/shared/widgets/chat_action_button.dart';
|
||||||
|
import '../../../core/providers/app_providers.dart';
|
||||||
|
import '../providers/chat_providers.dart';
|
||||||
|
import '../../../shared/services/tasks/task_queue.dart';
|
||||||
|
import '../../tools/providers/tools_providers.dart';
|
||||||
|
|
||||||
class UserMessageBubble extends ConsumerStatefulWidget {
|
class UserMessageBubble extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -45,6 +49,10 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
late AnimationController _slideController;
|
late AnimationController _slideController;
|
||||||
// press state handled by shared ChatActionButton
|
// press state handled by shared ChatActionButton
|
||||||
|
|
||||||
|
bool _isEditing = false;
|
||||||
|
late final TextEditingController _editController;
|
||||||
|
final FocusNode _editFocusNode = FocusNode();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -56,6 +64,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
duration: AnimationDuration.messageSlide,
|
duration: AnimationDuration.messageSlide,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
_editController = TextEditingController(text: widget.message?.content ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUserAttachmentImages() {
|
Widget _buildUserAttachmentImages() {
|
||||||
@@ -391,6 +400,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_fadeController.dispose();
|
_fadeController.dispose();
|
||||||
_slideController.dispose();
|
_slideController.dispose();
|
||||||
|
_editController.dispose();
|
||||||
|
_editFocusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +434,9 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
(widget.message.files as List).any(
|
(widget.message.files as List).any(
|
||||||
(f) => f is Map && f['type'] == 'image' && f['url'] != null,
|
(f) => f is Map && f['type'] == 'image' && f['url'] != null,
|
||||||
);
|
);
|
||||||
|
// Prefer input/textPrimary colors during inline editing to avoid low contrast
|
||||||
|
final inlineEditTextColor = context.conduitTheme.textPrimary;
|
||||||
|
final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(alpha: 0.92);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: () => _toggleActions(),
|
onLongPress: () => _toggleActions(),
|
||||||
@@ -483,14 +497,74 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Text(
|
child: _isEditing
|
||||||
|
? Focus(
|
||||||
|
focusNode: _editFocusNode,
|
||||||
|
autofocus: true,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: inlineEditFill,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6),
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.xs,
|
||||||
|
vertical: Spacing.xxs,
|
||||||
|
),
|
||||||
|
child: Platform.isIOS
|
||||||
|
? CupertinoTextField(
|
||||||
|
controller: _editController,
|
||||||
|
maxLines: null,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
style: AppTypography
|
||||||
|
.chatMessageStyle
|
||||||
|
.copyWith(
|
||||||
|
color: inlineEditTextColor,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(),
|
||||||
|
cursorColor: context
|
||||||
|
.conduitTheme.buttonPrimary,
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_saveInlineEdit(),
|
||||||
|
)
|
||||||
|
: TextField(
|
||||||
|
controller: _editController,
|
||||||
|
maxLines: null,
|
||||||
|
style: AppTypography
|
||||||
|
.chatMessageStyle
|
||||||
|
.copyWith(
|
||||||
|
color: inlineEditTextColor,
|
||||||
|
),
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(
|
||||||
|
isCollapsed: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding:
|
||||||
|
EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
cursorColor: context
|
||||||
|
.conduitTheme.buttonPrimary,
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_saveInlineEdit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
widget.message.content,
|
widget.message.content,
|
||||||
style: AppTypography.chatMessageStyle.copyWith(
|
style: AppTypography.chatMessageStyle
|
||||||
color: context.conduitTheme.chatBubbleUserText,
|
.copyWith(
|
||||||
|
color: context
|
||||||
|
.conduitTheme.chatBubbleUserText,
|
||||||
),
|
),
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
textHeightBehavior: const TextHeightBehavior(
|
textHeightBehavior:
|
||||||
|
const TextHeightBehavior(
|
||||||
applyHeightToFirstAscent: false,
|
applyHeightToFirstAscent: false,
|
||||||
applyHeightToLastDescent: false,
|
applyHeightToLastDescent: false,
|
||||||
leadingDistribution:
|
leadingDistribution:
|
||||||
@@ -542,10 +616,23 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
runSpacing: Spacing.sm,
|
runSpacing: Spacing.sm,
|
||||||
children: [
|
children: [
|
||||||
|
if (_isEditing) ...[
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
icon: Platform.isIOS ? CupertinoIcons.check_mark : Icons.check,
|
||||||
|
label: AppLocalizations.of(context)!.save,
|
||||||
|
onTap: _saveInlineEdit,
|
||||||
|
),
|
||||||
|
_buildActionButton(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||||
|
label: AppLocalizations.of(context)!.cancel,
|
||||||
|
onTap: _cancelInlineEdit,
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
_buildActionButton(
|
||||||
|
icon:
|
||||||
|
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
||||||
label: AppLocalizations.of(context)!.edit,
|
label: AppLocalizations.of(context)!.edit,
|
||||||
onTap: widget.onEdit,
|
onTap: widget.onEdit ?? _startInlineEdit,
|
||||||
),
|
),
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
@@ -555,6 +642,76 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
onTap: widget.onCopy,
|
onTap: widget.onCopy,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startInlineEdit() {
|
||||||
|
if (_isEditing) return;
|
||||||
|
setState(() {
|
||||||
|
_isEditing = true;
|
||||||
|
_showActions = true; // ensure actions visible for Save/Cancel
|
||||||
|
_editController.text = widget.message.content ?? '';
|
||||||
|
});
|
||||||
|
// Request focus after frame to show keyboard
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_editFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelInlineEdit() {
|
||||||
|
if (!_isEditing) return;
|
||||||
|
setState(() {
|
||||||
|
_isEditing = false;
|
||||||
|
// keep actions panel open; user can close with long-press
|
||||||
|
_editController.text = widget.message.content ?? '';
|
||||||
|
});
|
||||||
|
_editFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveInlineEdit() async {
|
||||||
|
final newText = _editController.text.trim();
|
||||||
|
final oldText = (widget.message.content ?? '').toString();
|
||||||
|
if (newText.isEmpty || newText == oldText) {
|
||||||
|
_cancelInlineEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove messages after this one
|
||||||
|
final messages = ref.read(chatMessagesProvider);
|
||||||
|
final idx = messages.indexOf(widget.message);
|
||||||
|
if (idx >= 0) {
|
||||||
|
final keep = messages.take(idx).toList(growable: false);
|
||||||
|
ref.read(chatMessagesProvider.notifier).setMessages(keep);
|
||||||
|
|
||||||
|
// Enqueue edited text as a new message
|
||||||
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
|
final List<String>? attachments = (widget.message.attachmentIds != null &&
|
||||||
|
(widget.message.attachmentIds as List).isNotEmpty)
|
||||||
|
? List<String>.from(widget.message.attachmentIds as List)
|
||||||
|
: null;
|
||||||
|
final toolIds = ref.read(selectedToolIdsProvider);
|
||||||
|
await ref
|
||||||
|
.read(taskQueueProvider.notifier)
|
||||||
|
.enqueueSendText(
|
||||||
|
conversationId: activeConv?.id,
|
||||||
|
text: newText,
|
||||||
|
attachments: attachments,
|
||||||
|
toolIds: toolIds.isNotEmpty ? toolIds : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Swallow errors; upstream error handling will surface if needed
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isEditing = false;
|
||||||
|
});
|
||||||
|
_editFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user