Improve assistant task UX

This commit is contained in:
Haitao Pan 2026-06-07 11:39:23 +08:00
parent f81c4e8c76
commit 867629900a
8 changed files with 272 additions and 26 deletions

View File

@ -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`。
## 手动验收步骤
前置条件:

View File

@ -588,6 +588,21 @@ class AppController extends ChangeNotifier {
selectedSkillLabels: selectedSkillLabels,
);
Future<bool> removeAssistantUserMessage(
String sessionKey,
String messageId,
) async => AppControllerDesktopThreadStorage(
this,
).removeAssistantUserMessageInternal(sessionKey, messageId);
Future<bool> 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;
}

View File

@ -185,12 +185,25 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
}
Future<void> refreshSessions() async {
Future<void> 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,

View File

@ -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<bool> removeAssistantUserMessageInternal(
String sessionKey,
String messageId,
) async {
return mutateAssistantUserMessageInternal(
sessionKey: sessionKey,
messageId: messageId,
mutate: (_) => null,
);
}
Future<bool> 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<bool> 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<GatewayChatMessage> mutateList(List<GatewayChatMessage> messages) {
final next = <GatewayChatMessage>[];
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<GatewaySessionSummary> assistantSessionSummariesInternal() {
final items = <GatewaySessionSummary>[];
@ -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.
}

View File

@ -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<void> Function() onReconnectGateway;
final Future<void> Function(AssistantMessageViewMode mode)
onMessageViewModeChanged;
final ValueChanged<TimelineItemInternal> onRecallUserMessage;
final ValueChanged<TimelineItemInternal> 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(

View File

@ -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!,
),
],
),
],
],
),
),

View File

@ -359,7 +359,8 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
Future<void> 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<void> refreshTasksWithRetryInternal() async {
await runTaskSessionActionWithRetryInternal(
appText('刷新任务列表', 'Refresh task list'),
widget.controller.refreshSessions,
() => widget.controller.refreshSessions(preserveCurrentSelection: true),
);
}
Future<void> 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<void> editUserMessageInternal(TimelineItemInternal item) async {
final original = item.text?.trim() ?? '';
if (original.isEmpty) {
return;
}
final input = TextEditingController(text: original);
final edited = await showDialog<String>(
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,
);
}

View File

@ -159,6 +159,12 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
).connectFromSavedSettingsOrShowDialogInternal,
onMessageViewModeChanged:
controller.setAssistantMessageViewMode,
onRecallUserMessage: AssistantPageStateActionsInternal(
this,
).recallUserMessageInternal,
onEditUserMessage: AssistantPageStateActionsInternal(
this,
).editUserMessageInternal,
),
),
),