From 867629900a921f5bac363f3ea4102fe0642685b8 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 7 Jun 2026 11:39:23 +0800 Subject: [PATCH] Improve assistant task UX --- .../openclaw-gateway-e2e-regression/README.md | 17 +++ lib/app/app_controller_desktop_core.dart | 15 ++ ...app_controller_desktop_thread_actions.dart | 15 +- ...app_controller_desktop_thread_storage.dart | 141 +++++++++++++++--- .../assistant/assistant_page_main.dart | 6 + .../assistant_page_message_widgets.dart | 30 ++++ .../assistant_page_state_actions.dart | 68 ++++++++- .../assistant_page_state_closure.dart | 6 + 8 files changed, 272 insertions(+), 26 deletions(-) diff --git a/docs/cases/openclaw-gateway-e2e-regression/README.md b/docs/cases/openclaw-gateway-e2e-regression/README.md index 7984b2f3..a9effba2 100644 --- a/docs/cases/openclaw-gateway-e2e-regression/README.md +++ b/docs/cases/openclaw-gateway-e2e-regression/README.md @@ -107,6 +107,23 @@ legacy `sessionKey` compatibility field. - Bridge 和 OpenClaw Gateway 只建立稳定连接,不重复并发握手。 - 不出现 `invalid handshake: first request must be connect`、`SOCKET_CLOSED`、`ACP_HTTP_CONNECTION_CLOSED`。 +### `OPENCLAW-E2E-006` 多文案输出 + +围绕 + +从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 + +1. 输出Markdown格式文件, 微信公众号短图文 400-600字 插入关键词的软文 +2. 输出Markdown格式文件, 小红书风格 600-800字 插入钩子话题的软文 +3. 输出Markdown格式文件, X文案串 小于144字的英语 鲜明的观点 +4. 输出Markdown格式文件, 微信公众号文章 800-1200字左右 +4. 输出Markdown格式文件, 头条号长文 800-1200字左右 + +期望结果: + +- 右侧边栏有输出 +- 不出现 `invalid handshake: first request must be connect`、`SOCKET_CLOSED`、`ACP_HTTP_CONNECTION_CLOSED`。 + ## 手动验收步骤 前置条件: diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 0775915b..2a0465a1 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -588,6 +588,21 @@ class AppController extends ChangeNotifier { selectedSkillLabels: selectedSkillLabels, ); + Future removeAssistantUserMessage( + String sessionKey, + String messageId, + ) async => AppControllerDesktopThreadStorage( + this, + ).removeAssistantUserMessageInternal(sessionKey, messageId); + + Future updateAssistantUserMessage( + String sessionKey, + String messageId, + String text, + ) async => AppControllerDesktopThreadStorage( + this, + ).updateAssistantUserMessageInternal(sessionKey, messageId, text); + double get assistantSkillCount => skills.length.toDouble(); int get currentAssistantSkillCount => skills.length; } diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 3a106919..42c36773 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -185,12 +185,25 @@ extension AppControllerDesktopThreadActions on AppController { recomputeTasksInternal(); } - Future refreshSessions() async { + Future refreshSessions({bool preserveCurrentSelection = false}) async { + final previousSessionKey = normalizedAssistantSessionKeyInternal( + sessionsControllerInternal.currentSessionKey, + ); sessionsControllerInternal.configure( selectedAgentId: agentsControllerInternal.selectedAgentId, defaultAgentId: '', ); await sessionsControllerInternal.refresh(); + if (preserveCurrentSelection && + isAppOwnedAssistantSessionKeyInternal(previousSessionKey) && + !isAssistantTaskArchived(previousSessionKey)) { + await setCurrentAssistantSessionKeyInternal( + previousSessionKey, + persistSelection: false, + ); + recomputeTasksInternal(); + return; + } await ensureActiveAssistantThreadInternal(); final selectedSessionKey = normalizedAssistantSessionKeyInternal( sessionsControllerInternal.currentSessionKey, diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 46462d90..9fded12d 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -77,7 +77,8 @@ extension AppControllerDesktopThreadStorage on AppController { } try { await syncAiGatewayCatalog(snapshot.aiGateway, apiKeyOverride: apiKey); - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); // Keep the saved draft applied even if model sync fails immediately. } } @@ -295,13 +296,15 @@ extension AppControllerDesktopThreadStorage on AppController { final localMessages = localSessionMessagesInternal[key]; if (localMessages != null && localMessages.isNotEmpty) { var changed = false; - final next = localMessages.map((msg) { - if (msg.pending && msg.toolCallId != null) { - changed = true; - return msg.copyWith(pending: false, error: hasError || msg.error); - } - return msg; - }).toList(growable: false); + final next = localMessages + .map((msg) { + if (msg.pending && msg.toolCallId != null) { + changed = true; + return msg.copyWith(pending: false, error: hasError || msg.error); + } + return msg; + }) + .toList(growable: false); if (changed) { localSessionMessagesInternal[key] = next; modified = true; @@ -311,13 +314,15 @@ extension AppControllerDesktopThreadStorage on AppController { final threadMessages = assistantThreadMessagesInternal[key]; if (threadMessages != null && threadMessages.isNotEmpty) { var changed = false; - final next = threadMessages.map((msg) { - if (msg.pending && msg.toolCallId != null) { - changed = true; - return msg.copyWith(pending: false, error: hasError || msg.error); - } - return msg; - }).toList(growable: false); + final next = threadMessages + .map((msg) { + if (msg.pending && msg.toolCallId != null) { + changed = true; + return msg.copyWith(pending: false, error: hasError || msg.error); + } + return msg; + }) + .toList(growable: false); if (changed) { assistantThreadMessagesInternal[key] = next; modified = true; @@ -327,13 +332,15 @@ extension AppControllerDesktopThreadStorage on AppController { final record = assistantThreadRecordsInternal[key]; if (record != null && record.messages.isNotEmpty) { var changed = false; - final next = record.messages.map((msg) { - if (msg.pending && msg.toolCallId != null) { - changed = true; - return msg.copyWith(pending: false, error: hasError || msg.error); - } - return msg; - }).toList(growable: false); + final next = record.messages + .map((msg) { + if (msg.pending && msg.toolCallId != null) { + changed = true; + return msg.copyWith(pending: false, error: hasError || msg.error); + } + return msg; + }) + .toList(growable: false); if (changed) { upsertTaskThreadInternal(key, messages: next); modified = true; @@ -371,6 +378,93 @@ extension AppControllerDesktopThreadStorage on AppController { notifyIfActiveInternal(); } + Future removeAssistantUserMessageInternal( + String sessionKey, + String messageId, + ) async { + return mutateAssistantUserMessageInternal( + sessionKey: sessionKey, + messageId: messageId, + mutate: (_) => null, + ); + } + + Future updateAssistantUserMessageInternal( + String sessionKey, + String messageId, + String text, + ) async { + final normalizedText = text.trim(); + if (normalizedText.isEmpty) { + return false; + } + return mutateAssistantUserMessageInternal( + sessionKey: sessionKey, + messageId: messageId, + mutate: (message) => message.copyWith(text: normalizedText), + ); + } + + Future mutateAssistantUserMessageInternal({ + required String sessionKey, + required String messageId, + required GatewayChatMessage? Function(GatewayChatMessage message) mutate, + }) async { + final key = normalizedAssistantSessionKeyInternal(sessionKey); + final id = messageId.trim(); + if (key.isEmpty || id.isEmpty) { + return false; + } + + var changed = false; + List mutateList(List messages) { + final next = []; + for (final message in messages) { + final isTarget = + message.id == id && + message.role.trim().toLowerCase() == 'user' && + !message.pending; + if (!isTarget) { + next.add(message); + continue; + } + final replacement = mutate(message); + if (replacement != null) { + next.add(replacement); + } + changed = true; + } + return next; + } + + final localMessages = localSessionMessagesInternal[key]; + if (localMessages != null) { + localSessionMessagesInternal[key] = mutateList(localMessages); + } + + final threadMessages = assistantThreadMessagesInternal[key]; + if (threadMessages != null) { + assistantThreadMessagesInternal[key] = mutateList(threadMessages); + } + + final record = assistantThreadRecordsInternal[key]; + if (record != null) { + upsertTaskThreadInternal( + key, + messages: mutateList(record.messages), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } + + if (!changed) { + return false; + } + await flushAssistantThreadPersistenceInternal(); + recomputeTasksInternal(); + notifyIfActiveInternal(); + return true; + } + List assistantSessionSummariesInternal() { final items = []; @@ -564,7 +658,8 @@ extension AppControllerDesktopThreadStorage on AppController { normalizedRecord.workspacePath.trim().isNotEmpty) { try { Directory(normalizedRecord.workspacePath).createSync(recursive: true); - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); // Best effort only. The thread should still restore even when the // directory cannot be recreated immediately. } diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 3eda5f86..87319300 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -708,6 +708,8 @@ class ConversationAreaInternal extends StatelessWidget { required this.onOpenAiGatewaySettings, required this.onReconnectGateway, required this.onMessageViewModeChanged, + required this.onRecallUserMessage, + required this.onEditUserMessage, }); final AppController controller; @@ -724,6 +726,8 @@ class ConversationAreaInternal extends StatelessWidget { final Future Function() onReconnectGateway; final Future Function(AssistantMessageViewMode mode) onMessageViewModeChanged; + final ValueChanged onRecallUserMessage; + final ValueChanged onEditUserMessage; @override Widget build(BuildContext context) { @@ -799,6 +803,8 @@ class ConversationAreaInternal extends StatelessWidget { alignRight: true, tone: BubbleToneInternal.user, messageViewMode: messageViewMode, + onRecall: () => onRecallUserMessage(item), + onEdit: () => onEditUserMessage(item), ), TimelineItemKindInternal.assistant => MessageBubbleInternal( diff --git a/lib/features/assistant/assistant_page_message_widgets.dart b/lib/features/assistant/assistant_page_message_widgets.dart index 9baede59..5b9c2811 100644 --- a/lib/features/assistant/assistant_page_message_widgets.dart +++ b/lib/features/assistant/assistant_page_message_widgets.dart @@ -43,6 +43,8 @@ class MessageBubbleInternal extends StatelessWidget { required this.alignRight, required this.tone, required this.messageViewMode, + this.onRecall, + this.onEdit, }); final String label; @@ -50,6 +52,8 @@ class MessageBubbleInternal extends StatelessWidget { final bool alignRight; final BubbleToneInternal tone; final AssistantMessageViewMode messageViewMode; + final VoidCallback? onRecall; + final VoidCallback? onEdit; @override Widget build(BuildContext context) { @@ -100,6 +104,32 @@ class MessageBubbleInternal extends StatelessWidget { tone != BubbleToneInternal.user, compactUserMetadata: tone == BubbleToneInternal.user, ), + if (tone == BubbleToneInternal.user && + (onRecall != null || onEdit != null)) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + if (onRecall != null) + MessageMetaToggleButtonInternal( + key: const Key('assistant-user-message-recall'), + icon: Icons.undo_rounded, + expanded: false, + tooltip: appText('撤回并放回输入框', 'Recall to composer'), + onTap: onRecall!, + ), + if (onEdit != null) + MessageMetaToggleButtonInternal( + key: const Key('assistant-user-message-edit'), + icon: Icons.edit_rounded, + expanded: false, + tooltip: appText('修改这条消息', 'Edit this message'), + onTap: onEdit!, + ), + ], + ), + ], ], ), ), diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 835265c3..73574a66 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -359,7 +359,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { Future continueCurrentTaskInternal(String sessionKey) async { try { await widget.controller.continueAssistantTaskInternal(sessionKey); - } catch (e, stackTrace) { debugPrint('Error: $e\n$stackTrace'); + } catch (e, stackTrace) { + debugPrint('Error: $e\n$stackTrace'); focusComposerInternal(); } } @@ -421,7 +422,70 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { Future refreshTasksWithRetryInternal() async { await runTaskSessionActionWithRetryInternal( appText('刷新任务列表', 'Refresh task list'), - widget.controller.refreshSessions, + () => widget.controller.refreshSessions(preserveCurrentSelection: true), + ); + } + + Future recallUserMessageInternal(TimelineItemInternal item) async { + final removed = await widget.controller.removeAssistantUserMessage( + widget.controller.currentSessionKey, + item.key, + ); + if (!mounted || !removed) { + return; + } + final text = item.text?.trim() ?? ''; + if (text.isNotEmpty) { + inputControllerInternal.value = TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: text.length), + ); + focusComposerInternal(); + } + } + + Future editUserMessageInternal(TimelineItemInternal item) async { + final original = item.text?.trim() ?? ''; + if (original.isEmpty) { + return; + } + final input = TextEditingController(text: original); + final edited = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(appText('修改消息', 'Edit message')), + content: TextField( + key: const Key('assistant-message-edit-input'), + controller: input, + autofocus: true, + minLines: 3, + maxLines: 8, + decoration: InputDecoration( + hintText: appText('输入修改后的内容', 'Enter the revised message'), + ), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(input.text), + child: Text(appText('保存', 'Save')), + ), + ], + ); + }, + ); + if (!mounted || edited == null) { + return; + } + await widget.controller.updateAssistantUserMessage( + widget.controller.currentSessionKey, + item.key, + edited, ); } diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart index d1340e13..97fe7850 100644 --- a/lib/features/assistant/assistant_page_state_closure.dart +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -159,6 +159,12 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal { ).connectFromSavedSettingsOrShowDialogInternal, onMessageViewModeChanged: controller.setAssistantMessageViewMode, + onRecallUserMessage: AssistantPageStateActionsInternal( + this, + ).recallUserMessageInternal, + onEditUserMessage: AssistantPageStateActionsInternal( + this, + ).editUserMessageInternal, ), ), ),