fix: group gateway skills and clean imports
This commit is contained in:
parent
b439d06892
commit
c9dcb5bcbc
@ -184,9 +184,8 @@ class AppController extends ChangeNotifier {
|
||||
bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList(
|
||||
initialGatewayProviderCatalog ?? const <SingleAgentProvider>[],
|
||||
);
|
||||
bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets(
|
||||
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[],
|
||||
);
|
||||
bridgeAvailableExecutionTargetsInternal =
|
||||
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[];
|
||||
bridgeCapabilitiesRefreshAttemptedInternal =
|
||||
bridgeAgentProviderCatalogInternal.isNotEmpty ||
|
||||
bridgeGatewayProviderCatalogInternal.isNotEmpty;
|
||||
@ -503,7 +502,9 @@ class AppController extends ChangeNotifier {
|
||||
!targets.contains(AssistantExecutionTarget.gateway)) {
|
||||
targets.add(AssistantExecutionTarget.gateway);
|
||||
}
|
||||
return compactAssistantExecutionTargets(targets);
|
||||
return AssistantExecutionTarget.values
|
||||
.where(targets.contains)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<SingleAgentProvider> providerCatalogForExecutionTarget(
|
||||
|
||||
@ -67,9 +67,7 @@ Future<void> refreshAcpCapabilitiesRuntimeInternal(
|
||||
controller.bridgeGatewayProviderCatalogInternal =
|
||||
normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog);
|
||||
controller.bridgeAvailableExecutionTargetsInternal =
|
||||
compactAssistantExecutionTargets(
|
||||
capabilities.availableExecutionTargets,
|
||||
);
|
||||
capabilities.availableExecutionTargets;
|
||||
} else if (refreshError != null) {
|
||||
controller.bridgeCapabilitiesRefreshErrorInternal = refreshError
|
||||
.toString()
|
||||
@ -106,20 +104,12 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
|
||||
controller.bridgeGatewayProviderCatalogInternal =
|
||||
normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog);
|
||||
controller.bridgeAvailableExecutionTargetsInternal =
|
||||
compactAssistantExecutionTargets(
|
||||
capabilities.availableExecutionTargets,
|
||||
);
|
||||
capabilities.availableExecutionTargets;
|
||||
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
|
||||
controller.bridgeCapabilitiesRefreshErrorInternal = '';
|
||||
} catch (error) {
|
||||
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
|
||||
controller.bridgeCapabilitiesRefreshErrorInternal = error.toString().trim();
|
||||
controller.bridgeAgentProviderCatalogInternal =
|
||||
const <SingleAgentProvider>[];
|
||||
controller.bridgeGatewayProviderCatalogInternal =
|
||||
const <SingleAgentProvider>[];
|
||||
controller.bridgeAvailableExecutionTargetsInternal =
|
||||
const <AssistantExecutionTarget>[];
|
||||
}
|
||||
if (!controller.disposedInternal) {
|
||||
controller.notifyListeners();
|
||||
|
||||
@ -45,32 +45,6 @@ import 'app_controller_desktop_runtime_helpers.dart';
|
||||
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
|
||||
extension AppControllerDesktopSkillPermissions on AppController {
|
||||
Future<void> replaceSingleAgentThreadSkillsInternal(
|
||||
String sessionKey,
|
||||
List<AssistantThreadSkillEntry> importedSkills,
|
||||
) async {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
);
|
||||
final importedKeys = importedSkills.map((item) => item.key).toSet();
|
||||
final nextSelected =
|
||||
(assistantThreadRecordsInternal[normalizedSessionKey]
|
||||
?.selectedSkillKeys ??
|
||||
const <String>[])
|
||||
.where(importedKeys.contains)
|
||||
.toList(growable: false);
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
importedSkills: importedSkills,
|
||||
selectedSkillKeys: nextSelected,
|
||||
selectedSkillsSource: assistantThreadRecordsInternal[normalizedSessionKey]
|
||||
?.contextState
|
||||
.selectedSkillsSource,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
|
||||
void upsertTaskThreadInternal(
|
||||
String sessionKey, {
|
||||
ThreadOwnerScope? ownerScope,
|
||||
@ -84,7 +58,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
bool? archived,
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
List<AssistantThreadSkillEntry>? importedSkills,
|
||||
List<String>? selectedSkillKeys,
|
||||
String? assistantModelId,
|
||||
SingleAgentProvider? selectedProvider,
|
||||
@ -120,15 +93,19 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
ThreadExecutionMode.gateway => AssistantExecutionTarget.gateway,
|
||||
null => AssistantExecutionTarget.agent,
|
||||
};
|
||||
final nextImportedSkills =
|
||||
importedSkills ??
|
||||
existing?.importedSkills ??
|
||||
const <AssistantThreadSkillEntry>[];
|
||||
final importedKeys = nextImportedSkills.map((item) => item.key).toSet();
|
||||
final nextSelectedSkillKeys =
|
||||
(selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[])
|
||||
.where(importedKeys.contains)
|
||||
.toList(growable: false);
|
||||
final bridgeSkillKeys = skills
|
||||
.map((item) => item.skillKey.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet();
|
||||
final selectedSkillCandidates =
|
||||
selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[];
|
||||
final nextSelectedSkillKeys = selectedSkillCandidates
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.where(
|
||||
(item) => bridgeSkillKeys.isEmpty || bridgeSkillKeys.contains(item),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final nextMessages =
|
||||
messages ??
|
||||
existing?.messages ??
|
||||
@ -213,7 +190,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
nextExecutionTarget,
|
||||
),
|
||||
selectedSkillKeys: const <String>[],
|
||||
importedSkills: const <AssistantThreadSkillEntry>[],
|
||||
permissionLevel: AssistantPermissionLevel.defaultAccess,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
latestResolvedRuntimeModel: '',
|
||||
@ -230,7 +206,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
.copyWith(
|
||||
messages: nextMessages,
|
||||
messageViewMode: messageViewMode,
|
||||
importedSkills: nextImportedSkills,
|
||||
selectedSkillKeys: nextSelectedSkillKeys,
|
||||
selectedModelId:
|
||||
assistantModelId ??
|
||||
|
||||
@ -532,9 +532,8 @@ extension AppControllerDesktopThreadStorage on AppController {
|
||||
archived: record.archived,
|
||||
messageViewMode: record.messageViewMode,
|
||||
selectedSkillKeys: record.selectedSkillKeys
|
||||
.where(
|
||||
(item) => record.importedSkills.any((skill) => skill.key == item),
|
||||
)
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false),
|
||||
assistantModelId: record.assistantModelId.trim().isEmpty
|
||||
? resolvedAssistantModelForTargetInternal(normalizedExecutionTarget)
|
||||
|
||||
@ -45,6 +45,7 @@ ComposerSkillOptionInternal skillOptionFromGatewayInternal(
|
||||
: skill.skillKey.trim();
|
||||
final label = skill.name.trim().isEmpty ? key : skill.name.trim();
|
||||
final sourceLabel = skill.source.trim().isEmpty ? 'Gateway' : skill.source;
|
||||
final group = skillGroupForSourceInternal(skill.source);
|
||||
final description = skill.description.trim().isEmpty
|
||||
? appText('可在当前任务中调用的技能。', 'Skill available in the current task.')
|
||||
: skill.description.trim();
|
||||
@ -54,16 +55,62 @@ ComposerSkillOptionInternal skillOptionFromGatewayInternal(
|
||||
label: label,
|
||||
description: description,
|
||||
sourceLabel: sourceLabel,
|
||||
groupLabel: group.label,
|
||||
groupSortOrder: group.sortOrder,
|
||||
icon: Icons.key_rounded,
|
||||
);
|
||||
}
|
||||
|
||||
ComposerSkillGroupInternal skillGroupForSourceInternal(String source) {
|
||||
final normalized = source.trim().toLowerCase();
|
||||
if (normalized == 'openclaw-workspace') {
|
||||
return const ComposerSkillGroupInternal(
|
||||
label: 'Workspace Skills',
|
||||
sortOrder: 0,
|
||||
);
|
||||
}
|
||||
if (normalized.startsWith('agents-skills-') ||
|
||||
normalized == 'agent' ||
|
||||
normalized.startsWith('agent-') ||
|
||||
normalized.contains('personal')) {
|
||||
return const ComposerSkillGroupInternal(
|
||||
label: 'Agent Skills',
|
||||
sortOrder: 1,
|
||||
);
|
||||
}
|
||||
if (normalized == 'bridge' || normalized == 'gateway') {
|
||||
return const ComposerSkillGroupInternal(
|
||||
label: 'Gateway Skills',
|
||||
sortOrder: 2,
|
||||
);
|
||||
}
|
||||
if (normalized.isEmpty) {
|
||||
return const ComposerSkillGroupInternal(
|
||||
label: 'Gateway Skills',
|
||||
sortOrder: 2,
|
||||
);
|
||||
}
|
||||
return const ComposerSkillGroupInternal(label: 'Other Skills', sortOrder: 3);
|
||||
}
|
||||
|
||||
class ComposerSkillGroupInternal {
|
||||
const ComposerSkillGroupInternal({
|
||||
required this.label,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int sortOrder;
|
||||
}
|
||||
|
||||
class ComposerSkillOptionInternal {
|
||||
const ComposerSkillOptionInternal({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.sourceLabel,
|
||||
required this.groupLabel,
|
||||
required this.groupSortOrder,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@ -71,5 +118,7 @@ class ComposerSkillOptionInternal {
|
||||
final String label;
|
||||
final String description;
|
||||
final String sourceLabel;
|
||||
final String groupLabel;
|
||||
final int groupSortOrder;
|
||||
final IconData icon;
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
final groupedSkills = skillPickerSectionsInternal(filteredSkills);
|
||||
return Material(
|
||||
key: const Key('assistant-skill-picker-popover'),
|
||||
color: Colors.transparent,
|
||||
@ -174,19 +175,32 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
|
||||
itemCount: filteredSkills.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemCount: groupedSkills.length,
|
||||
itemBuilder: (context, index) {
|
||||
final skill = filteredSkills[index];
|
||||
return SkillPickerTileInternal(
|
||||
key: ValueKey<String>(
|
||||
'assistant-skill-option-${skill.key}',
|
||||
final row = groupedSkills[index];
|
||||
if (row.headerLabel != null) {
|
||||
return SkillPickerGroupHeaderInternal(
|
||||
key: ValueKey<String>(
|
||||
'assistant-skill-group-${row.headerLabel}',
|
||||
),
|
||||
label: row.headerLabel!,
|
||||
);
|
||||
}
|
||||
final skill = row.skill!;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: index == groupedSkills.length - 1 ? 0 : 8,
|
||||
),
|
||||
child: SkillPickerTileInternal(
|
||||
key: ValueKey<String>(
|
||||
'assistant-skill-option-${skill.key}',
|
||||
),
|
||||
option: skill,
|
||||
selected: selectedSkillKeys.contains(skill.key),
|
||||
onTap: () => onToggleSkill(skill.key),
|
||||
),
|
||||
option: skill,
|
||||
selected: selectedSkillKeys.contains(skill.key),
|
||||
onTap: () => onToggleSkill(skill.key),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -199,6 +213,68 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
List<SkillPickerRowInternal> skillPickerSectionsInternal(
|
||||
List<ComposerSkillOptionInternal> skills,
|
||||
) {
|
||||
final groupsByLabel = <String, List<ComposerSkillOptionInternal>>{};
|
||||
final groupSortOrders = <String, int>{};
|
||||
for (final skill in skills) {
|
||||
groupsByLabel.putIfAbsent(skill.groupLabel, () => []).add(skill);
|
||||
groupSortOrders[skill.groupLabel] = skill.groupSortOrder;
|
||||
}
|
||||
final labels = groupsByLabel.keys.toList(growable: false)
|
||||
..sort((a, b) {
|
||||
final orderCompare = (groupSortOrders[a] ?? 999).compareTo(
|
||||
groupSortOrders[b] ?? 999,
|
||||
);
|
||||
if (orderCompare != 0) {
|
||||
return orderCompare;
|
||||
}
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
final rows = <SkillPickerRowInternal>[];
|
||||
for (final label in labels) {
|
||||
rows.add(SkillPickerRowInternal.header(label));
|
||||
for (final skill in groupsByLabel[label]!) {
|
||||
rows.add(SkillPickerRowInternal.skill(skill));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
class SkillPickerRowInternal {
|
||||
const SkillPickerRowInternal.header(this.headerLabel) : skill = null;
|
||||
const SkillPickerRowInternal.skill(this.skill) : headerLabel = null;
|
||||
|
||||
final String? headerLabel;
|
||||
final ComposerSkillOptionInternal? skill;
|
||||
}
|
||||
|
||||
class SkillPickerGroupHeaderInternal extends StatelessWidget {
|
||||
const SkillPickerGroupHeaderInternal({super.key, required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 8),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SkillPickerTileInternal extends StatelessWidget {
|
||||
const SkillPickerTileInternal({
|
||||
super.key,
|
||||
|
||||
@ -23,9 +23,8 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final supportedExecutionTargets = compactAssistantExecutionTargets(
|
||||
uiFeatures.availableExecutionTargets,
|
||||
);
|
||||
final supportedExecutionTargets = controller
|
||||
.visibleAssistantExecutionTargets(uiFeatures.availableExecutionTargets);
|
||||
if (supportedExecutionTargets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@ -388,70 +388,6 @@ class GatewayChatMessage {
|
||||
}
|
||||
}
|
||||
|
||||
class AssistantThreadSkillEntry {
|
||||
const AssistantThreadSkillEntry({
|
||||
required this.key,
|
||||
required this.label,
|
||||
required this.description,
|
||||
this.source = '',
|
||||
required this.sourcePath,
|
||||
this.scope = '',
|
||||
required this.sourceLabel,
|
||||
});
|
||||
|
||||
final String key;
|
||||
final String label;
|
||||
final String description;
|
||||
final String source;
|
||||
final String sourcePath;
|
||||
final String scope;
|
||||
final String sourceLabel;
|
||||
|
||||
AssistantThreadSkillEntry copyWith({
|
||||
String? key,
|
||||
String? label,
|
||||
String? description,
|
||||
String? source,
|
||||
String? sourcePath,
|
||||
String? scope,
|
||||
String? sourceLabel,
|
||||
}) {
|
||||
return AssistantThreadSkillEntry(
|
||||
key: key ?? this.key,
|
||||
label: label ?? this.label,
|
||||
description: description ?? this.description,
|
||||
source: source ?? this.source,
|
||||
sourcePath: sourcePath ?? this.sourcePath,
|
||||
scope: scope ?? this.scope,
|
||||
sourceLabel: sourceLabel ?? this.sourceLabel,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'key': key,
|
||||
'label': label,
|
||||
'description': description,
|
||||
'source': source,
|
||||
'sourcePath': sourcePath,
|
||||
'scope': scope,
|
||||
'sourceLabel': sourceLabel,
|
||||
};
|
||||
}
|
||||
|
||||
factory AssistantThreadSkillEntry.fromJson(Map<String, dynamic> json) {
|
||||
return AssistantThreadSkillEntry(
|
||||
key: json['key']?.toString() ?? '',
|
||||
label: json['label']?.toString() ?? '',
|
||||
description: json['description']?.toString() ?? '',
|
||||
source: json['source']?.toString() ?? '',
|
||||
sourcePath: json['sourcePath']?.toString() ?? '',
|
||||
scope: json['scope']?.toString() ?? '',
|
||||
sourceLabel: json['sourceLabel']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const int taskThreadSchemaVersion = 20260411;
|
||||
|
||||
enum ThreadRealm { local, remote }
|
||||
@ -729,7 +665,6 @@ class ThreadContextState {
|
||||
required this.messages,
|
||||
required this.selectedModelId,
|
||||
required this.selectedSkillKeys,
|
||||
required this.importedSkills,
|
||||
required this.permissionLevel,
|
||||
required this.messageViewMode,
|
||||
required this.latestResolvedRuntimeModel,
|
||||
@ -747,7 +682,6 @@ class ThreadContextState {
|
||||
final List<GatewayChatMessage> messages;
|
||||
final String selectedModelId;
|
||||
final List<String> selectedSkillKeys;
|
||||
final List<AssistantThreadSkillEntry> importedSkills;
|
||||
final AssistantPermissionLevel permissionLevel;
|
||||
final AssistantMessageViewMode messageViewMode;
|
||||
final String latestResolvedRuntimeModel;
|
||||
@ -765,7 +699,6 @@ class ThreadContextState {
|
||||
List<GatewayChatMessage>? messages,
|
||||
String? selectedModelId,
|
||||
List<String>? selectedSkillKeys,
|
||||
List<AssistantThreadSkillEntry>? importedSkills,
|
||||
AssistantPermissionLevel? permissionLevel,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
String? latestResolvedRuntimeModel,
|
||||
@ -784,7 +717,6 @@ class ThreadContextState {
|
||||
messages: messages ?? this.messages,
|
||||
selectedModelId: selectedModelId ?? this.selectedModelId,
|
||||
selectedSkillKeys: selectedSkillKeys ?? this.selectedSkillKeys,
|
||||
importedSkills: importedSkills ?? this.importedSkills,
|
||||
permissionLevel: permissionLevel ?? this.permissionLevel,
|
||||
messageViewMode: messageViewMode ?? this.messageViewMode,
|
||||
latestResolvedRuntimeModel:
|
||||
@ -814,9 +746,6 @@ class ThreadContextState {
|
||||
'messages': messages.map((item) => item.toJson()).toList(growable: false),
|
||||
'selectedModelId': selectedModelId,
|
||||
'selectedSkillKeys': selectedSkillKeys,
|
||||
'importedSkills': importedSkills
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
'permissionLevel': permissionLevel.name,
|
||||
'messageViewMode': messageViewMode.name,
|
||||
'latestResolvedRuntimeModel': latestResolvedRuntimeModel,
|
||||
@ -850,18 +779,6 @@ class ThreadContextState {
|
||||
)
|
||||
.toList(growable: false)
|
||||
: const <GatewayChatMessage>[];
|
||||
final rawImportedSkills = json['importedSkills'];
|
||||
final importedSkills = rawImportedSkills is List
|
||||
? rawImportedSkills
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => AssistantThreadSkillEntry.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.where((item) => item.key.trim().isNotEmpty)
|
||||
.toList(growable: false)
|
||||
: const <AssistantThreadSkillEntry>[];
|
||||
final rawSelectedSkillKeys = json['selectedSkillKeys'];
|
||||
final selectedSkillKeys = rawSelectedSkillKeys is List
|
||||
? rawSelectedSkillKeys
|
||||
@ -874,7 +791,6 @@ class ThreadContextState {
|
||||
messages: messages,
|
||||
selectedModelId: json['selectedModelId']?.toString() ?? '',
|
||||
selectedSkillKeys: selectedSkillKeys,
|
||||
importedSkills: importedSkills,
|
||||
permissionLevel: AssistantPermissionLevelCopy.fromJsonValue(
|
||||
json['permissionLevel']?.toString(),
|
||||
),
|
||||
@ -1002,7 +918,6 @@ class TaskThread {
|
||||
List<GatewayChatMessage>? messages,
|
||||
bool? archived,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
List<AssistantThreadSkillEntry>? importedSkills,
|
||||
List<String>? selectedSkillKeys,
|
||||
String? assistantModelId,
|
||||
String? gatewayEntryState,
|
||||
@ -1041,8 +956,6 @@ class TaskThread {
|
||||
messages: messages ?? const <GatewayChatMessage>[],
|
||||
selectedModelId: assistantModelId?.trim() ?? '',
|
||||
selectedSkillKeys: selectedSkillKeys ?? const <String>[],
|
||||
importedSkills:
|
||||
importedSkills ?? const <AssistantThreadSkillEntry>[],
|
||||
permissionLevel:
|
||||
permissionLevel ?? AssistantPermissionLevel.defaultAccess,
|
||||
messageViewMode:
|
||||
@ -1090,8 +1003,6 @@ class TaskThread {
|
||||
|
||||
String get sessionKey => threadId;
|
||||
List<GatewayChatMessage> get messages => contextState.messages;
|
||||
List<AssistantThreadSkillEntry> get importedSkills =>
|
||||
contextState.importedSkills;
|
||||
List<String> get selectedSkillKeys => contextState.selectedSkillKeys;
|
||||
String get assistantModelId => contextState.selectedModelId;
|
||||
AssistantMessageViewMode get messageViewMode => contextState.messageViewMode;
|
||||
@ -1134,7 +1045,6 @@ class TaskThread {
|
||||
List<GatewayChatMessage>? messages,
|
||||
bool? archived,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
List<AssistantThreadSkillEntry>? importedSkills,
|
||||
List<String>? selectedSkillKeys,
|
||||
String? assistantModelId,
|
||||
ThreadSelectionSource? assistantModelSource,
|
||||
@ -1158,7 +1068,6 @@ class TaskThread {
|
||||
contextState: (contextState ?? this.contextState).copyWith(
|
||||
messages: messages,
|
||||
messageViewMode: messageViewMode,
|
||||
importedSkills: importedSkills,
|
||||
selectedSkillKeys: selectedSkillKeys,
|
||||
selectedModelId: assistantModelId,
|
||||
selectedModelSource: assistantModelSource,
|
||||
@ -1276,7 +1185,6 @@ class TaskThread {
|
||||
'messages': json['messages'],
|
||||
'selectedModelId': json['assistantModelId'],
|
||||
'selectedSkillKeys': json['selectedSkillKeys'],
|
||||
'importedSkills': json['importedSkills'],
|
||||
'permissionLevel': json['permissionLevel'],
|
||||
'messageViewMode': json['messageViewMode'],
|
||||
'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'],
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picker.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
@ -320,7 +321,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps task dialog modes selectable when only OpenClaw is live',
|
||||
'does not fabricate agent mode when only OpenClaw gateway is live',
|
||||
(tester) async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
@ -355,13 +356,89 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final agentItem = tester
|
||||
.widget<PopupMenuItem<AssistantExecutionTarget>>(
|
||||
find.byKey(
|
||||
const Key('assistant-execution-target-menu-item-agent'),
|
||||
),
|
||||
);
|
||||
expect(agentItem.enabled, isTrue);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'allows switching from Gateway back to Agent when bridge reports both',
|
||||
(tester) async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
SingleAgentProvider.opencode,
|
||||
],
|
||||
initialGatewayProviderCatalog: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.openclaw,
|
||||
],
|
||||
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.agent,
|
||||
AssistantExecutionTarget.gateway,
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession(
|
||||
'unit-fixture-task-a',
|
||||
);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'unit-fixture-task-a',
|
||||
executionTarget: AssistantExecutionTarget.gateway,
|
||||
messageViewMode: controller.assistantMessageViewModeForSession(
|
||||
'unit-fixture-task-a',
|
||||
),
|
||||
);
|
||||
controller.notifyListeners();
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(child: _buildLowerPane(controller: controller)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.currentAssistantExecutionTarget.isGateway, isTrue);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
|
||||
expect(
|
||||
controller
|
||||
.providerCatalogForExecutionTarget(AssistantExecutionTarget.agent)
|
||||
.map((provider) => provider.providerId),
|
||||
const <String>['codex', 'opencode'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.providerCatalogForExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
)
|
||||
.map((provider) => provider.providerId),
|
||||
const <String>[kCanonicalGatewayProviderId],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -396,6 +473,113 @@ void main() {
|
||||
|
||||
expect(sendCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('groups all visible skills by source', (tester) async {
|
||||
final toggledKeys = <String>[];
|
||||
final searchController = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(searchController.dispose);
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SkillPickerPopoverInternal(
|
||||
maxHeight: 360,
|
||||
searchController: searchController,
|
||||
searchFocusNode: focusNode,
|
||||
selectedSkillKeys: const <String>[],
|
||||
filteredSkills: const <ComposerSkillOptionInternal>[
|
||||
ComposerSkillOptionInternal(
|
||||
key: 'pdf',
|
||||
label: 'PDF',
|
||||
description: 'Create PDF files',
|
||||
sourceLabel: 'openclaw-workspace',
|
||||
groupLabel: 'Workspace Skills',
|
||||
groupSortOrder: 0,
|
||||
icon: Icons.key_rounded,
|
||||
),
|
||||
ComposerSkillOptionInternal(
|
||||
key: 'browser-automation',
|
||||
label: 'Browser Automation',
|
||||
description: 'Automate browsers',
|
||||
sourceLabel: 'agents-skills-personal',
|
||||
groupLabel: 'Agent Skills',
|
||||
groupSortOrder: 1,
|
||||
icon: Icons.key_rounded,
|
||||
),
|
||||
ComposerSkillOptionInternal(
|
||||
key: 'gateway-search',
|
||||
label: 'Gateway Search',
|
||||
description: 'Search through the gateway',
|
||||
sourceLabel: 'gateway',
|
||||
groupLabel: 'Gateway Skills',
|
||||
groupSortOrder: 2,
|
||||
icon: Icons.key_rounded,
|
||||
),
|
||||
],
|
||||
isLoading: false,
|
||||
hasQuery: false,
|
||||
onQueryChanged: (_) {},
|
||||
onToggleSkill: toggledKeys.add,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Workspace Skills'), findsOneWidget);
|
||||
expect(find.text('Agent Skills'), findsOneWidget);
|
||||
expect(find.text('Gateway Skills'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(
|
||||
const ValueKey<String>('assistant-skill-option-browser-automation'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(toggledKeys, const <String>['browser-automation']);
|
||||
});
|
||||
|
||||
testWidgets('search results only keep groups with matching skills', (
|
||||
tester,
|
||||
) async {
|
||||
final searchController = TextEditingController(text: 'gateway');
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(searchController.dispose);
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SkillPickerPopoverInternal(
|
||||
maxHeight: 360,
|
||||
searchController: searchController,
|
||||
searchFocusNode: focusNode,
|
||||
selectedSkillKeys: const <String>[],
|
||||
filteredSkills: const <ComposerSkillOptionInternal>[
|
||||
ComposerSkillOptionInternal(
|
||||
key: 'gateway-search',
|
||||
label: 'Gateway Search',
|
||||
description: 'Search through the gateway',
|
||||
sourceLabel: 'gateway',
|
||||
groupLabel: 'Gateway Skills',
|
||||
groupSortOrder: 2,
|
||||
icon: Icons.key_rounded,
|
||||
),
|
||||
],
|
||||
isLoading: false,
|
||||
hasQuery: true,
|
||||
onQueryChanged: (_) {},
|
||||
onToggleSkill: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Gateway Skills'), findsOneWidget);
|
||||
expect(find.text('Gateway Search'), findsOneWidget);
|
||||
expect(find.text('Workspace Skills'), findsNothing);
|
||||
expect(find.text('Agent Skills'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -476,10 +476,90 @@ void main() {
|
||||
expect(option.key, 'browser-fetch');
|
||||
expect(option.label, 'Browser Fetch');
|
||||
expect(option.description, 'Bridge-managed browser skill');
|
||||
expect(option.groupLabel, 'Gateway Skills');
|
||||
expect(option.icon, Icons.key_rounded);
|
||||
},
|
||||
);
|
||||
|
||||
test('all bridge skill sources remain selectable and routed', () async {
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient();
|
||||
final controller = _connectedGatewayController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
controller.skillsControllerInternal.itemsInternal =
|
||||
const <GatewaySkillSummary>[
|
||||
GatewaySkillSummary(
|
||||
name: 'Workspace PDF',
|
||||
description: 'Write PDF documents',
|
||||
source: 'openclaw-workspace',
|
||||
skillKey: 'pdf',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
GatewaySkillSummary(
|
||||
name: 'Browser Automation',
|
||||
description: 'Use browser automation',
|
||||
source: 'agents-skills-personal',
|
||||
skillKey: 'browser-automation',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
GatewaySkillSummary(
|
||||
name: 'Gateway Search',
|
||||
description: 'Search through the gateway',
|
||||
source: 'gateway',
|
||||
skillKey: 'gateway-search',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
];
|
||||
await _selectGatewaySession(controller, 'unit-skill-source-groups-task');
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-source-groups-task',
|
||||
'browser-automation',
|
||||
);
|
||||
|
||||
final routing = controller.buildExternalAcpRoutingForSessionInternal(
|
||||
'unit-skill-source-groups-task',
|
||||
);
|
||||
expect(routing.availableSkills.map((item) => item.id), const <String>[
|
||||
'pdf',
|
||||
'browser-automation',
|
||||
'gateway-search',
|
||||
]);
|
||||
expect(routing.explicitSkills, const <String>['browser-automation']);
|
||||
expect(
|
||||
controller.assistantSelectedSkillKeysForSession(
|
||||
'unit-skill-source-groups-task',
|
||||
),
|
||||
const <String>['browser-automation'],
|
||||
);
|
||||
|
||||
await controller.sendChatMessage(
|
||||
'打开网页完成检查',
|
||||
selectedSkillLabels: const <String>[
|
||||
'Browser Automation (browser-automation)',
|
||||
],
|
||||
);
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(1));
|
||||
expect(fakeGoTaskService.requests.single.selectedSkills, const <String>[
|
||||
'Browser Automation (browser-automation)',
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'selected bridge skill is passed to task context with stable key',
|
||||
() async {
|
||||
@ -520,7 +600,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('skill selection ignores stale local imported skills', () {
|
||||
test('skill selection ignores stale non-bridge skill keys', () {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
@ -532,16 +612,7 @@ void main() {
|
||||
);
|
||||
controller.upsertTaskThreadInternal(
|
||||
'unit-skill-source-task',
|
||||
importedSkills: const <AssistantThreadSkillEntry>[
|
||||
AssistantThreadSkillEntry(
|
||||
key: '/tmp/local-only/SKILL.md',
|
||||
label: 'Local Only',
|
||||
description: 'stale local skill',
|
||||
sourcePath: '/tmp/local-only/SKILL.md',
|
||||
sourceLabel: 'local',
|
||||
),
|
||||
],
|
||||
selectedSkillKeys: const <String>['/tmp/local-only/SKILL.md'],
|
||||
selectedSkillKeys: const <String>['stale-non-bridge-skill'],
|
||||
selectedSkillsSource: ThreadSelectionSource.explicit,
|
||||
);
|
||||
controller.skillsControllerInternal.itemsInternal =
|
||||
|
||||
@ -59,6 +59,8 @@ void main() {
|
||||
'payload': <String, dynamic>{
|
||||
'workspaceDir': '/home/ubuntu/.openclaw/workspace',
|
||||
'managedSkillsDir': '/home/ubuntu/.openclaw/skills',
|
||||
'agentId': 'main',
|
||||
'agentSkillFilter': <String>['it-infra-continuous-png'],
|
||||
'skills': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'name': 'it-infra-continuous-png',
|
||||
@ -67,6 +69,25 @@ void main() {
|
||||
'skillKey': 'it-infra-continuous-png',
|
||||
'eligible': true,
|
||||
'disabled': false,
|
||||
'blockedByAgentFilter': false,
|
||||
'modelVisible': true,
|
||||
'commandVisible': true,
|
||||
'missing': <String, dynamic>{
|
||||
'bins': <String>[],
|
||||
'env': <String>[],
|
||||
'config': <String>[],
|
||||
},
|
||||
},
|
||||
<String, dynamic>{
|
||||
'name': 'blocked-for-agent',
|
||||
'description': 'Visible in status, hidden from model.',
|
||||
'source': 'openclaw-workspace',
|
||||
'skillKey': 'blocked-for-agent',
|
||||
'eligible': true,
|
||||
'disabled': false,
|
||||
'blockedByAgentFilter': true,
|
||||
'modelVisible': false,
|
||||
'commandVisible': false,
|
||||
'missing': <String, dynamic>{
|
||||
'bins': <String>[],
|
||||
'env': <String>[],
|
||||
@ -133,9 +154,12 @@ void main() {
|
||||
(observedGatewayRequests.single['params'] as Map)['agentId'],
|
||||
'main',
|
||||
);
|
||||
expect(controller.items, hasLength(1));
|
||||
expect(controller.items.single.skillKey, 'it-infra-continuous-png');
|
||||
expect(controller.items.single.eligible, isTrue);
|
||||
expect(controller.items, hasLength(2));
|
||||
expect(controller.items.map((item) => item.skillKey), const <String>[
|
||||
'it-infra-continuous-png',
|
||||
'blocked-for-agent',
|
||||
]);
|
||||
expect(controller.items.first.eligible, isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user