Improve assistant task UX
This commit is contained in:
parent
f81c4e8c76
commit
867629900a
@ -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`。
|
||||
|
||||
## 手动验收步骤
|
||||
|
||||
前置条件:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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!,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -159,6 +159,12 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
).connectFromSavedSettingsOrShowDialogInternal,
|
||||
onMessageViewModeChanged:
|
||||
controller.setAssistantMessageViewMode,
|
||||
onRecallUserMessage: AssistantPageStateActionsInternal(
|
||||
this,
|
||||
).recallUserMessageInternal,
|
||||
onEditUserMessage: AssistantPageStateActionsInternal(
|
||||
this,
|
||||
).editUserMessageInternal,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user