Merge branch 'release/v1.1.4' into main

This commit is contained in:
Cowork 3P 2026-06-04 12:57:57 +08:00
commit db956dba2f
29 changed files with 358 additions and 378 deletions

View File

@ -232,9 +232,17 @@ extension AppControllerDesktopGateway on AppController {
await refreshGatewayHealth();
await refreshAgents();
await refreshSessions();
await modelsControllerInternal.refresh();
await cronJobsControllerInternal.refresh();
await devicesControllerInternal.refresh(quiet: true);
// Refresh independent controllers concurrently.
await Future.wait(<Future<void>>[
skillsControllerInternal.refresh(
agentId: agentsControllerInternal.selectedAgentId.isEmpty
? null
: agentsControllerInternal.selectedAgentId,
),
modelsControllerInternal.refresh(),
cronJobsControllerInternal.refresh(),
devicesControllerInternal.refresh(quiet: true),
]);
await settingsControllerInternal.refreshDerivedState();
try {
await refreshAcpCapabilitiesInternal(

View File

@ -677,7 +677,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
agentsControllerInternal.addListener(relayChildChangeInternal);
sessionsControllerInternal.addListener(relayChildChangeInternal);
chatControllerInternal.addListener(relayChildChangeInternal);
skillsControllerInternal.addListener(relayChildChangeInternal);
modelsControllerInternal.addListener(relayChildChangeInternal);
cronJobsControllerInternal.addListener(relayChildChangeInternal);
devicesControllerInternal.addListener(relayChildChangeInternal);
@ -693,7 +692,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
agentsControllerInternal.removeListener(relayChildChangeInternal);
sessionsControllerInternal.removeListener(relayChildChangeInternal);
chatControllerInternal.removeListener(relayChildChangeInternal);
skillsControllerInternal.removeListener(relayChildChangeInternal);
modelsControllerInternal.removeListener(relayChildChangeInternal);
cronJobsControllerInternal.removeListener(relayChildChangeInternal);
devicesControllerInternal.removeListener(relayChildChangeInternal);

View File

@ -179,6 +179,11 @@ extension AppControllerDesktopThreadActions on AppController {
if (isAppOwnedAssistantSessionKeyInternal(sessionKey)) {
await chatControllerInternal.loadSession(sessionKey);
}
await skillsControllerInternal.refresh(
agentId: agentsControllerInternal.selectedAgentId.isEmpty
? null
: agentsControllerInternal.selectedAgentId,
);
recomputeTasksInternal();
}

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';
@ -79,6 +78,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
required this.hasQuery,
required this.onQueryChanged,
required this.onToggleSkill,
this.onRetry,
});
final double maxHeight;
@ -91,6 +91,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
final bool hasQuery;
final ValueChanged<String> onQueryChanged;
final ValueChanged<String> onToggleSkill;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
@ -191,6 +192,31 @@ class SkillPickerPopoverInternal extends StatelessWidget {
),
),
],
if (!hasError && !isLoading && !hasQuery) ...[
const SizedBox(height: 8),
Text(
appText(
'技能来源于 Gateway 工作区。请确认 OpenClaw'
' Gateway 已连接且安装了技能包。',
'Skills come from the Gateway workspace.'
' Make sure OpenClaw Gateway is connected'
' and skills are installed.',
),
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
if ((hasError || (!isLoading && !hasQuery)) &&
onRetry != null) ...[
const SizedBox(height: 12),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 16),
label: Text(appText('重试', 'Retry')),
),
],
],
),
),
@ -349,3 +375,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';
@ -99,6 +98,7 @@ Widget buildSkillPickerOverlayForInternal(
hasQuery: state.skillPickerQueryInternal.trim().isNotEmpty,
onQueryChanged: state.setSkillPickerQueryInternal,
onToggleSkill: (skillKey) => state.widget.onToggleSkill(skillKey),
onRetry: () => state.widget.controller.skillsController.refresh(),
),
),
],

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

@ -162,37 +162,10 @@ Offset? desktopContentPosition(
}) {
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null;
final resolvedContentSize = contentSize;
if (resolvedContentSize == null ||
resolvedContentSize.width <= 0 ||
resolvedContentSize.height <= 0) {
return Offset(
(localPosition.dx / viewportSize.width).clamp(0.0, 1.0),
(localPosition.dy / viewportSize.height).clamp(0.0, 1.0),
);
}
final viewportAspect = viewportSize.width / viewportSize.height;
final contentAspect = resolvedContentSize.width / resolvedContentSize.height;
double drawnWidth;
double drawnHeight;
double offsetX;
double offsetY;
if (viewportAspect > contentAspect) {
drawnHeight = viewportSize.height;
drawnWidth = drawnHeight * contentAspect;
offsetX = (viewportSize.width - drawnWidth) / 2;
offsetY = 0;
} else {
drawnWidth = viewportSize.width;
drawnHeight = drawnWidth / contentAspect;
offsetX = 0;
offsetY = (viewportSize.height - drawnHeight) / 2;
}
// We are using FittedBox(fit: BoxFit.fill) which stretches the video
// to fill the viewport exactly, without any padding/offsets.
return Offset(
((localPosition.dx - offsetX) / drawnWidth).clamp(0.0, 1.0),
((localPosition.dy - offsetY) / drawnHeight).clamp(0.0, 1.0),
(localPosition.dx / viewportSize.width).clamp(0.0, 1.0),
(localPosition.dy / viewportSize.height).clamp(0.0, 1.0),
);
}

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({
@ -48,6 +49,7 @@ class _DesktopViewState extends State<DesktopView> {
bool _useGpu = false;
bool _adaptiveResolution = true;
bool _showAdvancedOptions = false;
bool _showControlPanel = true;
String _connectionState = 'disconnected';
bool _hasStream = false;
bool _isFocused = false;
@ -152,7 +154,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,
),
);
@ -178,12 +182,13 @@ class _DesktopViewState extends State<DesktopView> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Control panel card
SurfaceCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_showControlPanel)
SurfaceCard(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: 16,
runSpacing: 16,
@ -214,10 +219,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),
),
),
@ -261,10 +266,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,
@ -292,19 +297,25 @@ class _DesktopViewState extends State<DesktopView> {
),
label: const Text('高级选项'),
),
// Maximize Toggle
if (widget.onToggleMaximize != null)
IconButton(
onPressed: widget.onToggleMaximize,
icon: Icon(
widget.isMaximized
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
// Maximize Toggle
if (widget.onToggleMaximize != null)
IconButton(
onPressed: widget.onToggleMaximize,
icon: Icon(
widget.isMaximized
? Icons.fullscreen_exit_rounded
: Icons.fullscreen_rounded,
),
tooltip: widget.isMaximized ? '恢复默认大小' : '最大化',
),
tooltip: widget.isMaximized ? '恢复默认大小' : '最大化',
// Collapse Toggle
IconButton(
onPressed: () => setState(() => _showControlPanel = false),
icon: const Icon(Icons.expand_less),
tooltip: '折叠面板',
),
],
),
],
),
if (_showAdvancedOptions) ...[
const SizedBox(height: 16),
Wrap(
@ -398,7 +409,21 @@ class _DesktopViewState extends State<DesktopView> {
),
),
const SizedBox(height: 16),
if (!_showControlPanel)
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: FilledButton.tonalIcon(
onPressed: () => setState(() => _showControlPanel = true),
icon: const Icon(Icons.expand_more, size: 18),
label: const Text('展开控制面板'),
),
),
),
if (_showControlPanel)
const SizedBox(height: 16),
// Stream Viewport Card
Expanded(
@ -484,10 +509,18 @@ class _DesktopViewState extends State<DesktopView> {
_inputHandler!.handleScroll(event);
}
},
child: RTCVideoView(
_localRenderer,
objectFit: RTCVideoViewObjectFit
.RTCVideoViewObjectFitContain,
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.fill,
child: SizedBox(
width: _remoteDesktopSize.width > 0 ? _remoteDesktopSize.width : 1280,
height: _remoteDesktopSize.height > 0 ? _remoteDesktopSize.height : 720,
child: RTCVideoView(
_localRenderer,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
),
),
),
),
),
@ -511,8 +544,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

@ -203,18 +203,62 @@ extension GatewayRuntimeApiInternal on GatewayRuntime {
if (agentId != null && agentId.trim().isNotEmpty)
'agentId': agentId.trim(),
};
if (sessionClientInternal == null) {
throw GatewayRuntimeException(
'skills.status requires bridge session (ACP transport)',
code: 'BRIDGE_NOT_CONFIGURED',
);
}
// Use allowErrorPayload so the bridge can return cached skills even when
// the upstream OpenClaw gateway is temporarily offline (ok:false + payload).
final payload = asMap(
sessionClientInternal == null
? await request('skills.status', params: params)
: await sessionClientInternal!.request(
runtimeId: runtimeIdInternal,
method: 'skills.status',
params: params,
allowErrorPayload: true,
),
await sessionClientInternal!.request(
runtimeId: runtimeIdInternal,
method: 'skills.status',
params: params,
allowErrorPayload: true,
),
);
final statusPayload = skillsStatusPayloadInternal(payload);
return asList(statusPayload['skills'])
final skillsList = asList(payload['skills']);
// When the skills key is entirely absent (not just an empty list), the
// gateway may have returned a stub or error payload. Distinguish between
// "genuinely no skills" and "gateway responded without skills data".
if (!payload.containsKey('skills')) {
final hasWorkspaceMeta = payload.containsKey('workspaceDir') ||
payload.containsKey('managedSkillsDir');
if (!hasWorkspaceMeta) {
appendLogInternal(
this,
'warn',
'skills',
'skills.status returned payload without skills key and without'
' workspace metadata — likely a gateway error or unimplemented method',
);
throw GatewayRuntimeException(
'OpenClaw gateway did not return skills data.'
' The gateway may not have skills.status implemented.',
code: 'SKILLS_STATUS_MISSING',
);
}
// Gateway responded with workspace metadata but no skills key
// genuinely no skills installed. Return empty list.
appendLogInternal(
this,
'debug',
'skills',
'skills.status returned workspace metadata with zero skills',
);
return const <GatewaySkillSummary>[];
}
if (skillsList.isEmpty) {
appendLogInternal(
this,
'debug',
'skills',
'skills.status returned empty skills list (${payload.length} payload keys)',
);
}
return skillsList
.map((item) {
final map = asMap(item);
return GatewaySkillSummary(
@ -561,24 +605,6 @@ extension GatewayRuntimeApiInternal on GatewayRuntime {
}
}
Map<String, dynamic> skillsStatusPayloadInternal(Map<String, dynamic> payload) {
if (asList(payload['skills']).isNotEmpty) {
return payload;
}
for (final key in const <String>[
'status',
'skillStatus',
'data',
'payload',
]) {
final nested = asMap(payload[key]);
if (asList(nested['skills']).isNotEmpty) {
return nested;
}
}
return payload;
}
List<String> skillMissingListInternal(
Map<String, dynamic> skill,
String nestedKey,

View File

@ -12,39 +12,86 @@ import 'runtime_controllers_gateway.dart';
import 'runtime_controllers_derived_tasks.dart';
class SkillsController extends ChangeNotifier {
SkillsController(this.runtimeInternal);
SkillsController(this.runtimeInternal) {
_runtimeListener = () {
if (!runtimeInternal.isConnected) {
// Reset auto-refresh flag on disconnect so a subsequent reconnect
// will trigger a fresh load.
_hasAutoRefreshed = false;
return;
}
// Auto-refresh on first gateway connect only when skills are empty,
// not already loading, and haven't auto-refreshed this session.
if (loadingInternal || itemsInternal.isNotEmpty || _hasAutoRefreshed) {
return;
}
_hasAutoRefreshed = true;
refresh();
};
runtimeInternal.addListener(_runtimeListener!);
}
final GatewayRuntime runtimeInternal;
List<GatewaySkillSummary> itemsInternal = const <GatewaySkillSummary>[];
bool loadingInternal = false;
String? errorInternal;
int _retryCount = 0;
static const int _maxRetries = 2;
VoidCallback? _runtimeListener;
bool _hasAutoRefreshed = false;
List<GatewaySkillSummary> get items => itemsInternal;
bool get loading => loadingInternal;
String? get error => errorInternal;
/// Whether the user can manually retry (non-empty error + not loading).
bool get canRetry =>
(errorInternal?.isNotEmpty ?? false) && !loadingInternal;
Future<void> refresh({String? agentId}) async {
if (!runtimeInternal.isConnected) {
if (!runtimeInternal.canConnectBridgeSession) {
itemsInternal = const <GatewaySkillSummary>[];
errorInternal = null;
notifyListeners();
return;
}
errorInternal = 'Gateway 未连接,无法加载技能列表。';
notifyListeners();
return;
}
loadingInternal = true;
errorInternal = null;
_retryCount = 0;
notifyListeners();
await _doRefresh(agentId: agentId);
}
Future<void> _doRefresh({String? agentId}) async {
try {
itemsInternal = await runtimeInternal.listSkills(agentId: agentId);
errorInternal = null;
_retryCount = 0;
} catch (error) {
if (_retryCount < _maxRetries && runtimeInternal.isConnected) {
_retryCount++;
final delay = Duration(seconds: _retryCount * 2);
await Future<void>.delayed(delay);
if (runtimeInternal.isConnected) {
await _doRefresh(agentId: agentId);
return;
}
}
errorInternal = error.toString();
} finally {
loadingInternal = false;
notifyListeners();
}
}
@override
void dispose() {
if (_runtimeListener != null) {
runtimeInternal.removeListener(_runtimeListener!);
_runtimeListener = null;
}
super.dispose();
}
}
class ModelsController extends ChangeNotifier {

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'top_bar.dart';
class SettingsPageBodyShell extends StatelessWidget {
class SettingsPageBodyShell extends StatefulWidget {
const SettingsPageBodyShell({
super.key,
required this.padding,
@ -22,25 +22,59 @@ class SettingsPageBodyShell extends StatelessWidget {
final Widget? globalApplyBar;
final List<Widget> bodyChildren;
@override
State<SettingsPageBodyShell> createState() => _SettingsPageBodyShellState();
}
class _SettingsPageBodyShellState extends State<SettingsPageBodyShell> {
bool _isHeaderCollapsed = false;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: padding,
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: breadcrumbs,
title: title,
subtitle: subtitle,
trailing: trailing,
),
const SizedBox(height: 24),
if (globalApplyBar != null) ...[
globalApplyBar!,
if (!_isHeaderCollapsed) ...[
TopBar(
breadcrumbs: widget.breadcrumbs,
title: widget.title,
subtitle: widget.subtitle,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
widget.trailing,
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: () => setState(() => _isHeaderCollapsed = true),
icon: const Icon(Icons.expand_less),
tooltip: '折叠顶部面板',
),
],
),
),
const SizedBox(height: 24),
if (widget.globalApplyBar != null) ...[
widget.globalApplyBar!,
const SizedBox(height: 16),
],
] else ...[
Row(
children: [
Expanded(
child: AppBreadcrumbs(items: widget.breadcrumbs),
),
IconButton.filledTonal(
onPressed: () => setState(() => _isHeaderCollapsed = false),
icon: const Icon(Icons.expand_more),
tooltip: '展开顶部面板',
),
],
),
const SizedBox(height: 16),
],
...bodyChildren,
...widget.bodyChildren,
],
),
);

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';
@ -4384,21 +4384,7 @@ Future<void> _waitForThreadLastResultCode(
);
}
Future<void> _waitForOpenClawActiveTaskCount(
AppController controller,
int expectedCount,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (DateTime.now().isBefore(deadline)) {
if (controller.openClawGatewayActiveTasksInternal == expectedCount) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
throw StateError(
'Timed out waiting for OpenClaw active task count $expectedCount. Current count: ${controller.openClawGatewayActiveTasksInternal}.',
);
}
class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
int executeCount = 0;

View File

@ -236,109 +236,4 @@ void main() {
expect(controller.items.single.eligible, isFalse);
},
);
test(
'GatewayRuntime loads skills from nested bridge status payload',
() async {
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>;
final method = rpc['method']?.toString().trim() ?? '';
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'},
'returnedDeviceToken': '',
},
}),
);
await request.response.close();
return;
}
if (method == 'xworkmate.gateway.request') {
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': rpc['id'],
'result': <String, dynamic>{
'ok': true,
'payload': <String, dynamic>{
'status': <String, dynamic>{
'workspaceDir': '/home/ubuntu/.openclaw/workspace',
'managedSkillsDir': '/home/ubuntu/.openclaw/skills',
'skills': <Map<String, dynamic>>[
<String, dynamic>{
'name': 'Browser Automation',
'description': 'Drive browser workflows.',
'source': 'agent',
'id': 'browser-automation',
'eligible': true,
'disabled': false,
'missingBins': <String>[],
'missingEnv': <String>[],
'missingConfig': <String>[],
},
],
},
},
},
}),
);
await request.response.close();
return;
}
request.response.statusCode = HttpStatus.badRequest;
await request.response.close();
});
final tempDir = await Directory.systemTemp.createTemp(
'xworkmate-bridge-skills-nested-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 identityStore = DeviceIdentityStore(store);
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
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(controller.error, isNull);
expect(controller.items, hasLength(1));
expect(controller.items.single.skillKey, 'browser-automation');
expect(controller.items.single.missingBins, isEmpty);
},
);
}