refactor(ui): eliminate unowned helper sprawl in assistant skill picker

This commit is contained in:
Haitao Pan 2026-06-04 10:08:02 +08:00
parent 008b97b170
commit f30b1e2eda
23 changed files with 193 additions and 186 deletions

View File

@ -6,7 +6,6 @@ export 'assistant_page_composer_support.dart';
export 'assistant_page_tooltip_labels.dart';
export 'assistant_page_message_widgets.dart';
export 'assistant_page_task_models.dart';
export 'assistant_page_composer_skill_models.dart';
export 'assistant_page_composer_skill_picker.dart';
export 'assistant_page_composer_clipboard.dart';
export 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';

View File

@ -31,7 +31,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_components_core.dart';

View File

@ -1,124 +0,0 @@
// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path_provider/path_provider.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/assistant_artifact_sidebar.dart';
import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
import 'assistant_page_main.dart';
import 'assistant_page_components.dart';
import 'assistant_page_composer_bar.dart';
import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
ComposerSkillOptionInternal skillOptionFromGatewayInternal(
GatewaySkillSummary skill,
) {
final key = skill.skillKey.trim().isEmpty
? skill.name.trim().toLowerCase()
: 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();
return ComposerSkillOptionInternal(
key: key,
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,
});
final String key;
final String label;
final String description;
final String sourceLabel;
final String groupLabel;
final int groupSortOrder;
final IconData icon;
}

View File

@ -33,7 +33,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
@ -349,3 +348,89 @@ class SkillPickerTileInternal extends StatelessWidget {
);
}
}
ComposerSkillOptionInternal skillOptionFromGatewayInternal(
GatewaySkillSummary skill,
) {
final key = skill.skillKey.trim().isEmpty
? skill.name.trim().toLowerCase()
: 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();
return ComposerSkillOptionInternal(
key: key,
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,
});
final String key;
final String label;
final String description;
final String sourceLabel;
final String groupLabel;
final int groupSortOrder;
final IconData icon;
}

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_attachment_payloads.dart';

View File

@ -35,7 +35,6 @@ import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -32,7 +32,6 @@ import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';

View File

@ -6,6 +6,7 @@ import 'desktop_client.dart';
import 'desktop_input_handler.dart';
import '../../app/app_controller.dart';
import '../../widgets/surface_card.dart';
import '../../i18n/app_language.dart';
class DesktopView extends StatefulWidget {
const DesktopView({
@ -141,7 +142,9 @@ class _DesktopViewState extends State<DesktopView> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to connect remote desktop: $e'),
content: Text(
appText('连接AI工作空间失败: $e', 'Failed to connect AI Workspace: $e'),
),
backgroundColor: Colors.redAccent,
),
);
@ -204,10 +207,10 @@ class _DesktopViewState extends State<DesktopView> {
),
label: Text(
_connectionState == 'connected'
? '断开连接'
? appText('断开连接', 'Disconnect')
: (_connectionState == 'connecting'
? '正在连接...'
: '连接桌面'),
? appText('正在连接...', 'Connecting...')
: appText('连接AI工作空间', 'Connect AI Workspace')),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
@ -251,10 +254,10 @@ class _DesktopViewState extends State<DesktopView> {
const SizedBox(width: 8),
Text(
_connectionState == 'connected'
? '已连接'
? appText('已连接', 'Connected')
: (_connectionState == 'connecting'
? '连接中'
: '未连接'),
? appText('连接中', 'Connecting')
: appText('已断开', 'Disconnected')),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@ -508,8 +511,8 @@ class _DesktopViewState extends State<DesktopView> {
const SizedBox(height: 16),
Text(
_connectionState == 'connecting'
? '正在建立 WebRTC 连接,请稍候...'
: '未开启远程桌面流。点击“连接桌面”启动视频流。',
? appText('正在建立 WebRTC 连接,请稍候...', 'Establishing WebRTC connection, please wait...')
: appText('未开启 AI 工作空间流。点击“连接AI工作空间”启动视频流。', 'AI Workspace stream not enabled. Click "Connect AI Workspace" to start the video stream.'),
style: TextStyle(
color: theme.colorScheme.onSurface
.withValues(alpha: 0.6),

View File

@ -59,7 +59,7 @@ class _SettingsRemoteDesktopPanelState extends State<SettingsRemoteDesktopPanel>
const SizedBox(width: 10),
Expanded(
child: Text(
appText('远程桌面', 'Remote Desktop'),
appText('AI工作空间', 'AI Workspace'),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),

View File

@ -155,7 +155,7 @@ extension SettingsTabCopy on SettingsTab {
String get label => switch (this) {
SettingsTab.gateway => appText('集成', 'Integrations'),
SettingsTab.archivedTasks => appText('归档任务', 'Archived tasks'),
SettingsTab.remoteDesktop => appText('远程桌面', 'Remote Desktop'),
SettingsTab.remoteDesktop => appText('AI工作空间', 'AI Workspace'),
SettingsTab.logs => appText('运行日志', 'Runtime Logs'),
};
}

View File

@ -37,9 +37,6 @@ class SkillsController extends ChangeNotifier {
errorInternal = null;
notifyListeners();
try {
await runtimeInternal.ensureBridgeSessionConnected(
selectedAgentId: agentId?.trim() ?? '',
);
itemsInternal = await runtimeInternal.listSkills(agentId: agentId);
} catch (error) {
errorInternal = error.toString();

View File

@ -5,7 +5,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/ui_feature_manifest.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';

View File

@ -29,8 +29,8 @@ void main() {
);
// Verify the panel headers and titles
expect(find.text('远程桌面'), findsOneWidget);
expect(find.text('连接桌面'), findsOneWidget);
expect(find.text('AI工作空间'), findsOneWidget);
expect(find.text('连接AI工作空间'), findsOneWidget);
// Verify advanced options are hidden initially
expect(find.text('GPU 加速'), findsNothing);

View File

@ -8,7 +8,7 @@ import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart';
import 'package:xworkmate/app/app_controller_openclaw_task_queue.dart';
import 'package:xworkmate/app/ui_feature_manifest.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/runtime/gateway_acp_client.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';

View File

@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
'SkillsController lazily connects and loads OpenClaw skills through bridge gateway request',
'SkillsController loads OpenClaw skills through bridge request without legacy gateway connect',
() async {
final observedMethods = <String>[];
final observedGatewayRequests = <Map<String, dynamic>>[];
@ -23,31 +23,6 @@ void main() {
observedMethods.add(method);
request.response.headers.contentType = ContentType.json;
if (method == 'xworkmate.gateway.connect') {
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': rpc['id'],
'result': <String, dynamic>{
'ok': true,
'snapshot': <String, dynamic>{
'status': 'connected',
'mode': 'remote',
'statusText': 'Connected',
'mainSessionKey': 'main',
},
'auth': <String, dynamic>{
'role': 'operator',
'scopes': <String>['operator.read', 'operator.write'],
},
'returnedDeviceToken': '',
},
}),
);
await request.response.close();
return;
}
if (method == 'xworkmate.gateway.request') {
final params = (rpc['params'] as Map).cast<String, dynamic>();
observedGatewayRequests.add(params);
@ -159,10 +134,7 @@ void main() {
final controller = SkillsController(runtime);
await controller.refresh(agentId: 'main');
expect(observedMethods, <String>[
'xworkmate.gateway.connect',
'xworkmate.gateway.request',
]);
expect(observedMethods, const <String>['xworkmate.gateway.request']);
expect(observedGatewayRequests.single['method'], 'skills.status');
expect(
(observedGatewayRequests.single['params'] as Map)['agentId'],
@ -176,4 +148,92 @@ void main() {
expect(controller.items.first.eligible, isTrue);
},
);
test(
'SkillsController keeps bridge skill payload when OpenClaw gateway is offline',
() async {
final observedGatewayRequests = <Map<String, dynamic>>[];
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final subscription = server.listen((request) async {
final body = await utf8.decoder.bind(request).join();
final rpc = jsonDecode(body) as Map<String, dynamic>;
request.response.headers.contentType = ContentType.json;
if (rpc['method'] == 'xworkmate.gateway.request') {
final params = (rpc['params'] as Map).cast<String, dynamic>();
observedGatewayRequests.add(params);
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': rpc['id'],
'result': <String, dynamic>{
'ok': false,
'error': <String, dynamic>{
'code': 'OFFLINE',
'message': 'gateway not connected',
},
'payload': <String, dynamic>{
'skills': <Map<String, dynamic>>[
<String, dynamic>{
'name': 'PDF Writer',
'description': 'Write PDF documents',
'source': 'openclaw-workspace',
'skillKey': 'pdf',
'eligible': false,
'disabled': false,
'missing': <String, dynamic>{
'bins': <String>[],
'env': <String>[],
'config': <String>[],
},
},
],
},
},
}),
);
await request.response.close();
return;
}
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
});
final tempDir = await Directory.systemTemp.createTemp(
'xworkmate-bridge-skills-offline-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3',
secretRootPathResolver: () async => tempDir.path,
);
final acpClient = GatewayAcpClient(
endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'),
authorizationResolver: (_) async => 'bridge-token',
);
final runtime = GatewayRuntime(
store: store,
identityStore: DeviceIdentityStore(store),
sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient),
);
await runtime.initialize();
addTearDown(() async {
runtime.dispose();
await subscription.cancel();
await server.close(force: true);
await tempDir.delete(recursive: true);
});
final controller = SkillsController(runtime);
await controller.refresh(agentId: 'main');
expect(observedGatewayRequests.single['method'], 'skills.status');
expect(controller.error, isNull);
expect(controller.items.map((item) => item.skillKey), const <String>[
'pdf',
]);
expect(controller.items.single.eligible, isFalse);
},
);
}