fix: group bridge skills by source

This commit is contained in:
Haitao Pan 2026-05-23 11:43:09 +08:00
parent b439d06892
commit 7625988cfa
7 changed files with 340 additions and 154 deletions

View File

@ -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 ??

View File

@ -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)

View File

@ -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;
}

View File

@ -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,

View File

@ -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'],

View File

@ -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';
@ -396,6 +397,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);
});
});
}

View File

@ -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 =