Merge pull request #1 from x-evor/codex/web-homepage-app-layout

Codex/web homepage app layout
This commit is contained in:
Haitao Pan 2026-03-24 10:03:49 +00:00 committed by GitHub
commit 1ea4e2fad4
21 changed files with 5141 additions and 974 deletions

View File

@ -420,22 +420,22 @@ web:
description: Web relay gateway assistant mode
ui_surface: web_assistant_page
file_attachments:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose file attachments in assistant composer
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web file attachment action in assistant composer
ui_surface: web_assistant_page
multi_agent:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose multi-agent assistant toggle
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web multi-agent toggle in assistant composer
ui_surface: web_assistant_page
local_gateway:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose local gateway assistant mode
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web local gateway assistant mode
ui_surface: web_assistant_page
local_runtime:
enabled: false

View File

@ -30,6 +30,8 @@
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>XWorkmate uses your local network only when you explicitly connect to a user-configured OpenClaw Gateway on the same network.</string>
<key>NSCameraUsageDescription</key>
<string>XWorkmate uses the camera only when you explicitly scan a gateway pairing QR code.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
import '../web/web_assistant_page.dart';
import '../web/web_settings_page.dart';
import '../widgets/app_brand_logo.dart';
@ -32,6 +31,7 @@ class AppShell extends StatelessWidget {
: (availableDestinations.isEmpty
? WorkspaceDestination.assistant
: availableDestinations.first);
return Scaffold(
body: SafeArea(
bottom: false,
@ -79,68 +79,43 @@ class AppShell extends StatelessWidget {
return Row(
children: [
Container(
width: currentDestination == WorkspaceDestination.settings
? 248
: 236,
margin: const EdgeInsets.fromLTRB(4, 4, 4, 0),
width: 76,
margin: const EdgeInsets.fromLTRB(4, 4, 0, 4),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.9),
palette.chromeHighlight.withValues(alpha: 0.94),
palette.chromeSurface.withValues(alpha: 0.92),
],
),
borderRadius: BorderRadius.circular(AppRadius.sidebar),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: palette.chromeStroke),
boxShadow: [palette.chromeShadowAmbient],
),
child: Padding(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.fromLTRB(10, 12, 10, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const AppBrandLogo(size: 32, borderRadius: 10),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'XWorkmate',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
appText(
'Web Workspace',
'Web Workspace',
),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
],
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: const Center(
child: AppBrandLogo(size: 28, borderRadius: 8),
),
),
const SizedBox(height: 18),
...availableDestinations.map(
(destination) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _WebNavItem(
child: _WebNavRailButton(
key: Key('web-shell-nav-${destination.name}'),
destination: destination,
selected: currentDestination == destination,
onTap: () =>
@ -149,34 +124,25 @@ class AppShell extends StatelessWidget {
),
),
const Spacer(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('平台', 'Platform'),
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(color: palette.textMuted),
),
const SizedBox(height: 6),
Text(
appText(
'Web 仅保留 Assistant / Settings',
'Web keeps only Assistant / Settings',
),
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
_WebUtilityButton(
key: const Key('web-shell-language-toggle'),
tooltip: controller.appLanguage == AppLanguage.zh
? '中文'
: 'English',
icon: Icons.translate_rounded,
onTap: controller.toggleAppLanguage,
),
const SizedBox(height: 8),
_WebUtilityButton(
key: const Key('web-shell-theme-toggle'),
tooltip: _themeLabel(controller.themeMode),
icon: controller.themeMode == ThemeMode.dark
? Icons.dark_mode_rounded
: Icons.light_mode_rounded,
onTap: () => controller.setThemeMode(
controller.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark,
),
),
],
@ -210,8 +176,9 @@ class AppShell extends StatelessWidget {
}
}
class _WebNavItem extends StatelessWidget {
const _WebNavItem({
class _WebNavRailButton extends StatelessWidget {
const _WebNavRailButton({
super.key,
required this.destination,
required this.selected,
required this.onTap,
@ -224,35 +191,76 @@ class _WebNavItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = context.palette;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: selected ? palette.accentMuted : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? palette.accent.withValues(alpha: 0.26)
: palette.strokeSoft,
),
),
child: Row(
children: [
Icon(destination.icon, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
destination.label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
return Tooltip(
message: destination.label,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: 52,
height: 52,
decoration: BoxDecoration(
color: selected ? palette.accentMuted : palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? palette.accent.withValues(alpha: 0.28)
: palette.strokeSoft,
),
],
boxShadow: selected ? [palette.chromeShadowLift] : null,
),
child: Icon(
destination.icon,
size: 22,
color: selected ? palette.accent : palette.textSecondary,
),
),
),
);
}
}
class _WebUtilityButton extends StatelessWidget {
const _WebUtilityButton({
super.key,
required this.tooltip,
required this.icon,
required this.onTap,
});
final String tooltip;
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Container(
width: 52,
height: 44,
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(icon, size: 20, color: palette.textSecondary),
),
),
);
}
}
String _themeLabel(ThemeMode mode) {
return switch (mode) {
ThemeMode.dark => appText('深色', 'Dark'),
ThemeMode.system => appText('跟随系统', 'System'),
ThemeMode.light => appText('浅色', 'Light'),
};
}

View File

@ -270,7 +270,7 @@ mobile:
description: Mobile Vault server integration section
ui_surface: settings_page
gateway_setup_code:
enabled: false
enabled: true
release_tier: experimental
build_modes: [debug, profile, release]
description: Mobile gateway setup code editor
@ -543,22 +543,22 @@ web:
description: Web relay gateway assistant mode
ui_surface: web_assistant_page
file_attachments:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose file attachments in assistant composer
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web file attachment action in assistant composer
ui_surface: web_assistant_page
multi_agent:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose multi-agent assistant toggle
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web multi-agent toggle in assistant composer
ui_surface: web_assistant_page
local_gateway:
enabled: false
release_tier: experimental
build_modes: []
description: Web does not expose local gateway assistant mode
enabled: true
release_tier: stable
build_modes: [debug, profile, release]
description: Web local gateway assistant mode
ui_surface: web_assistant_page
local_runtime:
enabled: false

View File

@ -0,0 +1,516 @@
import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../i18n/app_language.dart';
import '../../runtime/gateway_runtime.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
class MobileGatewayPairingGuidePage extends StatelessWidget {
const MobileGatewayPairingGuidePage({
super.key,
required this.supportsQrScan,
required this.onManualInput,
required this.onScannedSetupCode,
});
final bool supportsQrScan;
final VoidCallback onManualInput;
final Future<void> Function(String setupCode) onScannedSetupCode;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Scaffold(
backgroundColor: const Color(0xFFF3F1EF),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: Row(
children: [
_HeaderCircleButton(
key: const ValueKey('pairing-guide-close-button'),
icon: Icons.close_rounded,
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Center(
child: Text(
'配对网关',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(width: 56),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 118,
height: 118,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
border: Border.all(
color: Colors.black.withValues(alpha: 0.08),
),
),
alignment: Alignment.center,
child: Icon(
Icons.hub_outlined,
size: 56,
color: palette.textPrimary,
),
),
const SizedBox(height: 26),
Text(
'配对你的 OpenClaw 主机',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
Text(
'在 Mac、Windows 或云端部署的 OpenClaw 主机上安装 xworkmate然后生成配对二维码或配置码。',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: palette.textSecondary,
height: 1.35,
),
),
const SizedBox(height: 24),
_GuideCard(
key: const ValueKey('pairing-guide-install-card'),
title: '自主安装',
subtitle: '按下面两步在主机上安装 XWorkmate CLI然后生成配对码。',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'1. 安装',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
_CommandBlock(
key: const ValueKey(
'pairing-guide-install-command',
),
command: 'npm install -g xworkmate',
),
const SizedBox(height: 16),
Text(
'2. 配对',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
_CommandBlock(
key: const ValueKey('pairing-guide-pair-command'),
command: 'xworkmate pair',
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('pairing-guide-scan-button'),
onPressed: () async {
if (!supportsQrScan) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'Android 扫码即将支持,当前请先使用手动输入代码。',
'Android QR scanning is coming soon. Use manual code entry for now.',
),
),
),
);
return;
}
final result = await Navigator.of(context)
.push<String>(
MaterialPageRoute<String>(
fullscreenDialog: true,
builder: (_) =>
const MobileGatewayQrScannerPage(),
),
);
if (result == null || !context.mounted) {
return;
}
Navigator.of(context).pop();
await onScannedSetupCode(result);
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: const Color(0xFF151517),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
child: Text(
'扫描二维码',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
key: const ValueKey('pairing-guide-manual-button'),
onPressed: () {
Navigator.of(context).pop();
onManualInput();
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: Colors.white,
foregroundColor: palette.textPrimary,
side: BorderSide(
color: Colors.black.withValues(alpha: 0.08),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppRadius.button,
),
),
),
child: Text(
'手动输入代码',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
],
),
),
);
}
}
class MobileGatewayQrScannerPage extends StatefulWidget {
const MobileGatewayQrScannerPage({super.key});
@override
State<MobileGatewayQrScannerPage> createState() =>
_MobileGatewayQrScannerPageState();
}
class _MobileGatewayQrScannerPageState
extends State<MobileGatewayQrScannerPage> {
bool _hasHandledDetection = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: _QrScannerSurface(onCodeDetected: _handleDetectedCode),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HeaderCircleButton(
key: const ValueKey('pairing-scanner-close-button'),
icon: Icons.close_rounded,
onPressed: () => Navigator.of(context).pop(),
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.28),
),
const Spacer(),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(AppRadius.dialog),
border: Border.all(
color: Colors.white.withValues(alpha: 0.12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'扫描配对二维码',
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'将二维码放入取景框内。扫描成功后会自动把配置码带入 Gateway 设置页。',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.82),
height: 1.35,
),
),
],
),
),
],
),
),
),
],
),
);
}
void _handleDetectedCode(String raw) {
if (_hasHandledDetection) {
return;
}
final setupCode = resolveGatewaySetupCodeFromScan(raw);
if (setupCode == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText('未识别到有效配置码,请重试。', 'No valid setup code found. Try again.'),
),
),
);
return;
}
_hasHandledDetection = true;
Navigator.of(context).pop(setupCode);
}
}
String? resolveGatewaySetupCodeFromScan(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = _extractSetupCodeFromJsonPayload(trimmed) ?? trimmed;
return decodeGatewaySetupCode(candidate) != null ? candidate : null;
}
String? _extractSetupCodeFromJsonPayload(String raw) {
final normalized = raw.trim();
if (!normalized.startsWith('{')) {
return null;
}
try {
final dynamic decoded = jsonDecode(normalized);
if (decoded is! Map<String, dynamic>) {
return null;
}
final setupCode = decoded['setupCode'];
if (setupCode is! String || setupCode.trim().isEmpty) {
return null;
}
return setupCode.trim();
} catch (_) {
return null;
}
}
class _GuideCard extends StatelessWidget {
const _GuideCard({
super.key,
required this.title,
required this.subtitle,
required this.child,
});
final String title;
final String subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(subtitle, style: theme.textTheme.bodyLarge),
const SizedBox(height: 18),
child,
],
),
);
}
}
class _CommandBlock extends StatelessWidget {
const _CommandBlock({super.key, required this.command});
final String command;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
padding: const EdgeInsets.fromLTRB(18, 14, 14, 14),
decoration: BoxDecoration(
color: const Color(0xFFF8F6F4),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
),
child: Row(
children: [
Expanded(
child: SelectableText(
command,
style: theme.textTheme.titleMedium?.copyWith(
color: palette.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
IconButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: command));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(appText('已复制命令。', 'Command copied.'))),
);
},
icon: const Icon(Icons.content_copy_rounded),
tooltip: appText('复制命令', 'Copy command'),
),
],
),
);
}
}
class _HeaderCircleButton extends StatelessWidget {
const _HeaderCircleButton({
super.key,
required this.icon,
required this.onPressed,
this.foregroundColor,
this.backgroundColor,
});
final IconData icon;
final VoidCallback onPressed;
final Color? foregroundColor;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return SizedBox(
width: 56,
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white.withValues(alpha: 0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon),
color: foregroundColor ?? palette.textPrimary,
),
),
);
}
}
class _QrScannerSurface extends StatelessWidget {
const _QrScannerSurface({required this.onCodeDetected});
final ValueChanged<String> onCodeDetected;
@override
Widget build(BuildContext context) {
return MobileScanner(
key: const ValueKey('pairing-guide-ios-scanner'),
onDetect: (capture) {
final code = capture.barcodes
.map((item) => item.rawValue?.trim() ?? '')
.firstWhere((item) => item.isNotEmpty, orElse: () => '');
if (code.isEmpty) {
return;
}
onCodeDetected(code);
},
);
}
}

View File

@ -11,6 +11,7 @@ import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/detail_drawer.dart';
import 'mobile_gateway_pairing_guide_page.dart';
enum MobileShellTab { assistant, tasks, workspace, secrets, settings }
@ -152,6 +153,54 @@ class _MobileShellState extends State<MobileShell> {
rootLabel: appText('移动端', 'Mobile'),
destination: WorkspaceDestination.settings,
sectionLabel: appText('集成', 'Integrations'),
gatewayProfileIndex: kGatewayRemoteProfileIndex,
prefersGatewaySetupCode: false,
),
);
}
Future<void> _openGatewaySetupCodeEntry({String? prefilledSetupCode}) async {
final setupCode = prefilledSetupCode?.trim() ?? '';
if (setupCode.isNotEmpty) {
final current = widget
.controller
.settingsDraft
.gatewayProfiles[kGatewayRemoteProfileIndex];
await widget.controller.saveSettingsDraft(
widget.controller.settingsDraft.copyWithGatewayProfileAt(
kGatewayRemoteProfileIndex,
current.copyWith(useSetupCode: true, setupCode: setupCode),
),
);
}
widget.controller.openSettings(
detail: SettingsDetailPage.gatewayConnection,
navigationContext: SettingsNavigationContext(
rootLabel: appText('移动端', 'Mobile'),
destination: WorkspaceDestination.settings,
sectionLabel: appText('集成', 'Integrations'),
gatewayProfileIndex: kGatewayRemoteProfileIndex,
prefersGatewaySetupCode: true,
),
);
}
void _showPairingGuidePage() {
unawaited(_showPairingGuidePageFlow());
}
Future<void> _showPairingGuidePageFlow() async {
final supportsQrScan = Theme.of(context).platform == TargetPlatform.iOS;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) => MobileGatewayPairingGuidePage(
supportsQrScan: supportsQrScan,
onManualInput: () => unawaited(_openGatewaySetupCodeEntry()),
onScannedSetupCode: (setupCode) async {
await _openGatewaySetupCodeEntry(prefilledSetupCode: setupCode);
},
),
),
);
}
@ -172,7 +221,7 @@ class _MobileShellState extends State<MobileShell> {
Navigator.of(sheetContext).pop();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_showConnectSheet();
_showPairingGuidePage();
}
});
},
@ -267,7 +316,7 @@ class _MobileShellState extends State<MobileShell> {
_MobileSafeStrip(
controller: widget.controller,
onOpenSafeSheet: _showMobileSafeSheet,
onOpenGatewayConnect: _showConnectSheet,
onOpenGatewayConnect: _showPairingGuidePage,
),
const SizedBox(height: 10),
Expanded(
@ -491,11 +540,11 @@ class _MobileSafeStrip extends StatelessWidget {
else
FilledButton(
key: const ValueKey('mobile-safe-connect-button'),
onPressed: handlePrimaryConnect,
onPressed: () => unawaited(handlePrimaryConnect()),
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText('连接 Gateway', 'Connect Gateway'),
: appText('配对网关', 'Pair Gateway'),
),
),
if (hasPendingRun)
@ -678,14 +727,11 @@ class _MobileSafeSheet extends StatelessWidget {
key: const ValueKey(
'mobile-safe-sheet-connect-button',
),
onPressed: handleConnect,
onPressed: () => unawaited(handleConnect()),
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText(
'打开集成设置',
'Open Integrations',
),
: appText('配对网关', 'Pair Gateway'),
),
),
if (hasPendingRun)

View File

@ -133,6 +133,32 @@ class _SettingsPageState extends State<SettingsPage> {
if (widget.navigationContext != _navigationContext) {
_navigationContext = widget.navigationContext;
}
_applyGatewayNavigationHints();
}
void _applyGatewayNavigationHints() {
final detail = _detail;
final navigationContext = _navigationContext;
if (detail != SettingsDetailPage.gatewayConnection ||
navigationContext == null) {
return;
}
final gatewayProfileIndex = navigationContext.gatewayProfileIndex;
if (gatewayProfileIndex == null) {
return;
}
_selectedGatewayProfileIndex = gatewayProfileIndex.clamp(
0,
kGatewayProfileListLength - 1,
);
}
bool _prefersGatewaySetupCodeForCurrentContext(BuildContext context) {
return resolveUiFeaturePlatformFromContext(context) ==
UiFeaturePlatform.mobile &&
_detail == SettingsDetailPage.gatewayConnection &&
_navigationContext?.prefersGatewaySetupCode == true &&
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
}
@override
@ -169,6 +195,7 @@ class _SettingsPageState extends State<SettingsPage> {
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
_detail = controller.settingsDetail;
_navigationContext = controller.settingsNavigationContext;
_applyGatewayNavigationHints();
final settings = controller.settingsDraft;
final showingDetail = _detail != null;
final showGlobalApplyBar =
@ -1160,9 +1187,13 @@ class _SettingsPageState extends State<SettingsPage> {
resolveUiFeaturePlatformFromContext(context),
);
final setupCodeFeatureEnabled = uiFeatures.supportsGatewaySetupCode;
final forceSetupCodeMode = _prefersGatewaySetupCodeForCurrentContext(
context,
);
final useSetupCode = selectedProfileIndex == kGatewayLocalProfileIndex
? false
: setupCodeFeatureEnabled && gatewayProfile.useSetupCode;
: forceSetupCodeMode ||
(setupCodeFeatureEnabled && gatewayProfile.useSetupCode);
final gatewayTls = gatewayMode == RuntimeConnectionMode.local
? false
: gatewayProfile.tls;
@ -1220,6 +1251,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
const SizedBox(height: 12),
if (selectedProfileIndex != kGatewayLocalProfileIndex &&
!forceSetupCodeMode &&
setupCodeFeatureEnabled) ...[
SectionTabs(
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
@ -1245,6 +1277,7 @@ class _SettingsPageState extends State<SettingsPage> {
TextField(
key: const ValueKey('gateway-setup-code-field'),
controller: _gatewaySetupCodeController,
autofocus: forceSetupCodeMode,
minLines: 4,
maxLines: 6,
decoration: InputDecoration(
@ -3162,9 +3195,13 @@ XWorkmate Privacy Policy
_selectedGatewayProfileIndex,
current,
);
final forceSetupCodeMode =
_navigationContext?.prefersGatewaySetupCode == true &&
_detail == SettingsDetailPage.gatewayConnection &&
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
final useSetupCode = mode == RuntimeConnectionMode.local
? false
: current.useSetupCode;
: forceSetupCodeMode || current.useSetupCode;
final tls = mode == RuntimeConnectionMode.local ? false : current.tls;
final parsedPort = int.tryParse(_gatewayPortController.text.trim());
final decoded = useSetupCode

View File

@ -278,6 +278,8 @@ class SettingsNavigationContext {
this.secretsTab,
this.aiGatewayTab,
this.settingsTab,
this.gatewayProfileIndex,
this.prefersGatewaySetupCode,
});
final String rootLabel;
@ -287,6 +289,8 @@ class SettingsNavigationContext {
final SecretsTab? secretsTab;
final AiGatewayTab? aiGatewayTab;
final SettingsTab? settingsTab;
final int? gatewayProfileIndex;
final bool? prefersGatewaySetupCode;
}
enum AccountTab { profile, workspace, sessions }

251
lib/web/web_acp_client.dart Normal file
View File

@ -0,0 +1,251 @@
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../runtime/runtime_models.dart';
class WebAcpException implements Exception {
const WebAcpException(this.message, {this.code, this.details});
final String message;
final String? code;
final Object? details;
@override
String toString() => code == null ? message : '$code: $message';
}
class WebAcpCapabilities {
const WebAcpCapabilities({
required this.singleAgent,
required this.multiAgent,
required this.providers,
required this.raw,
});
const WebAcpCapabilities.empty()
: singleAgent = false,
multiAgent = false,
providers = const <SingleAgentProvider>{},
raw = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final Set<SingleAgentProvider> providers;
final Map<String, dynamic> raw;
}
class WebAcpClient {
const WebAcpClient();
static const Duration _defaultTimeout = Duration(seconds: 120);
Future<WebAcpCapabilities> loadCapabilities({
required Uri endpoint,
}) async {
final response = await request(
endpoint: endpoint,
method: 'acp.capabilities',
params: const <String, dynamic>{},
);
final result = _asMap(response['result']);
final caps = _asMap(result['capabilities']);
final providers = <SingleAgentProvider>{};
for (final raw in <Object?>[
..._asList(result['providers']),
..._asList(caps['providers']),
]) {
if (raw == null) {
continue;
}
final provider = SingleAgentProviderCopy.fromJsonValue(
raw.toString().trim().toLowerCase(),
);
if (provider != SingleAgentProvider.auto) {
providers.add(provider);
}
}
final singleAgent =
_boolValue(result['singleAgent']) ??
_boolValue(caps['single_agent']) ??
providers.isNotEmpty;
final multiAgent =
_boolValue(result['multiAgent']) ??
_boolValue(caps['multi_agent']) ??
false;
return WebAcpCapabilities(
singleAgent: singleAgent,
multiAgent: multiAgent,
providers: providers,
raw: result,
);
}
Future<void> cancelSession({
required Uri endpoint,
required String sessionId,
required String threadId,
}) async {
await request(
endpoint: endpoint,
method: 'session.cancel',
params: <String, dynamic>{'sessionId': sessionId, 'threadId': threadId},
);
}
Future<Map<String, dynamic>> request({
required Uri endpoint,
required String method,
required Map<String, dynamic> params,
void Function(Map<String, dynamic> notification)? onNotification,
Duration timeout = _defaultTimeout,
}) async {
final requestId = '${DateTime.now().microsecondsSinceEpoch}-$method';
final wsEndpoint = _resolveWebSocketEndpoint(endpoint);
if (wsEndpoint == null) {
throw const WebAcpException(
'Missing ACP endpoint',
code: 'ACP_ENDPOINT_MISSING',
);
}
final socket = WebSocketChannel.connect(wsEndpoint);
final completer = Completer<Map<String, dynamic>>();
late final StreamSubscription<dynamic> subscription;
subscription = socket.stream.listen(
(raw) {
final json = _decodeMap(raw);
final id = _stringValue(json['id']);
final methodName = _stringValue(json['method']) ?? '';
if (id == requestId &&
(json.containsKey('result') || json.containsKey('error'))) {
if (!completer.isCompleted) {
completer.complete(json);
}
return;
}
if (methodName.isNotEmpty && onNotification != null) {
onNotification(json);
}
},
onError: (Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(
WebAcpException(error.toString(), code: 'ACP_WS_RUNTIME_ERROR'),
);
}
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(
const WebAcpException(
'ACP websocket closed before response',
code: 'ACP_WS_EARLY_CLOSE',
),
);
}
},
cancelOnError: true,
);
try {
await socket.ready;
socket.sink.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': requestId,
'method': method,
'params': params,
}),
);
final response = await completer.future.timeout(timeout);
_throwIfJsonRpcError(response);
return response;
} finally {
await subscription.cancel();
await socket.sink.close();
}
}
static Uri? _resolveWebSocketEndpoint(Uri? endpoint) {
if (endpoint == null || endpoint.host.trim().isEmpty) {
return null;
}
final scheme = endpoint.scheme.trim().toLowerCase();
final wsScheme = switch (scheme) {
'https' || 'wss' => 'wss',
_ => 'ws',
};
return endpoint.replace(path: '/acp', query: null, fragment: null, scheme: wsScheme);
}
void _throwIfJsonRpcError(Map<String, dynamic> response) {
final error = _asMap(response['error']);
if (error.isEmpty) {
return;
}
throw WebAcpException(
_stringValue(error['message']) ?? 'ACP request failed',
code: _stringValue(error['code']),
details: error['data'],
);
}
static Map<String, dynamic> _decodeMap(Object? raw) {
if (raw is Map<String, dynamic>) {
return raw;
}
if (raw is Map) {
return raw.cast<String, dynamic>();
}
if (raw is String) {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.cast<String, dynamic>();
}
}
return const <String, dynamic>{};
}
static Map<String, dynamic> _asMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
static List<dynamic> _asList(Object? value) {
if (value is List<dynamic>) {
return value;
}
if (value is List) {
return value.cast<dynamic>();
}
return const <dynamic>[];
}
static String? _stringValue(Object? value) {
final text = value?.toString().trim();
return (text == null || text.isEmpty) ? null : text;
}
static bool? _boolValue(Object? value) {
if (value is bool) {
return value;
}
final text = value?.toString().trim().toLowerCase();
if (text == 'true') {
return true;
}
if (text == 'false') {
return false;
}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -37,7 +37,7 @@ class WebRelayGatewayClient {
StreamSubscription<dynamic>? _subscription;
int _requestCounter = 0;
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
mode: RuntimeConnectionMode.unconfigured,
);
Stream<GatewayPushEvent> get events => _events.stream;
@ -51,11 +51,14 @@ class WebRelayGatewayClient {
required String authPassword,
}) async {
await disconnect();
final targetMode = profile.mode == RuntimeConnectionMode.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final endpoint = _resolveEndpoint(profile);
if (endpoint == null) {
_snapshot =
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
mode: targetMode,
).copyWith(
status: RuntimeConnectionStatus.error,
statusText: 'Missing relay endpoint',
@ -68,7 +71,7 @@ class WebRelayGatewayClient {
final identity = await _identityManager.loadOrCreate(_store);
_snapshot =
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
mode: targetMode,
).copyWith(
status: RuntimeConnectionStatus.connecting,
statusText: 'Connecting…',
@ -136,6 +139,7 @@ class WebRelayGatewayClient {
);
try {
await channel.ready;
final nonce = await challenge.future.timeout(
const Duration(seconds: 5),
onTimeout: () =>
@ -159,6 +163,7 @@ class WebRelayGatewayClient {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
mode: targetMode,
serverName: _stringValue(server['host']),
remoteAddress: '${endpoint.host}:${endpoint.port}',
mainSessionKey:
@ -173,6 +178,7 @@ class WebRelayGatewayClient {
} catch (error) {
await disconnect();
_snapshot = _snapshot.copyWith(
mode: targetMode,
status: RuntimeConnectionStatus.error,
statusText: 'Connection failed',
lastError: error.toString(),
@ -195,6 +201,13 @@ class WebRelayGatewayClient {
_subscription = null;
await _channel?.sink.close();
_channel = null;
if (_snapshot.status != RuntimeConnectionStatus.offline) {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
clearRemoteAddress: true,
);
}
}
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
@ -275,8 +288,15 @@ class WebRelayGatewayClient {
required String sessionKey,
required String message,
required String thinking,
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
Map<String, dynamic> metadata = const <String, dynamic>{},
}) async {
final runId = _randomId();
final normalizedMetadata = <String, dynamic>{
for (final entry in metadata.entries)
if (entry.key.trim().isNotEmpty) entry.key: entry.value,
};
final payload = _asMap(
await request(
'chat.send',
@ -284,6 +304,11 @@ class WebRelayGatewayClient {
'sessionKey': sessionKey,
'message': message,
'thinking': thinking,
if (attachments.isNotEmpty)
'attachments': attachments
.map((item) => item.toJson())
.toList(growable: false),
if (normalizedMetadata.isNotEmpty) 'metadata': normalizedMetadata,
'timeoutMs': 30000,
'idempotencyKey': runId,
},

View File

@ -26,16 +26,22 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
late final TextEditingController _directBaseUrlController;
late final TextEditingController _directProviderController;
late final TextEditingController _directApiKeyController;
late final TextEditingController _relayHostController;
late final TextEditingController _relayPortController;
late final TextEditingController _relayTokenController;
late final TextEditingController _relayPasswordController;
late final TextEditingController _localHostController;
late final TextEditingController _localPortController;
late final TextEditingController _localTokenController;
late final TextEditingController _localPasswordController;
late final TextEditingController _remoteHostController;
late final TextEditingController _remotePortController;
late final TextEditingController _remoteTokenController;
late final TextEditingController _remotePasswordController;
late final TextEditingController _sessionRemoteBaseUrlController;
late final TextEditingController _sessionApiTokenController;
late WebSessionPersistenceMode _sessionPersistenceMode;
bool _remoteTls = true;
String _directMessage = '';
String _relayMessage = '';
String _localGatewayMessage = '';
String _remoteGatewayMessage = '';
String _sessionPersistenceMessage = '';
@override
@ -45,10 +51,14 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
_directBaseUrlController = TextEditingController();
_directProviderController = TextEditingController();
_directApiKeyController = TextEditingController();
_relayHostController = TextEditingController();
_relayPortController = TextEditingController();
_relayTokenController = TextEditingController();
_relayPasswordController = TextEditingController();
_localHostController = TextEditingController();
_localPortController = TextEditingController();
_localTokenController = TextEditingController();
_localPasswordController = TextEditingController();
_remoteHostController = TextEditingController();
_remotePortController = TextEditingController();
_remoteTokenController = TextEditingController();
_remotePasswordController = TextEditingController();
_sessionRemoteBaseUrlController = TextEditingController();
_sessionApiTokenController = TextEditingController();
_sessionPersistenceMode = widget.controller.webSessionPersistence.mode;
@ -67,10 +77,14 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
_directBaseUrlController.dispose();
_directProviderController.dispose();
_directApiKeyController.dispose();
_relayHostController.dispose();
_relayPortController.dispose();
_relayTokenController.dispose();
_relayPasswordController.dispose();
_localHostController.dispose();
_localPortController.dispose();
_localTokenController.dispose();
_localPasswordController.dispose();
_remoteHostController.dispose();
_remotePortController.dispose();
_remoteTokenController.dispose();
_remotePasswordController.dispose();
_sessionRemoteBaseUrlController.dispose();
_sessionApiTokenController.dispose();
super.dispose();
@ -78,7 +92,8 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
void _syncControllers() {
final settings = widget.controller.settings;
final relayProfile = settings.primaryRemoteGatewayProfile;
final localProfile = settings.primaryLocalGatewayProfile;
final remoteProfile = settings.primaryRemoteGatewayProfile;
_setIfDifferent(_directNameController, settings.aiGateway.name);
_setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl);
_setIfDifferent(_directProviderController, settings.defaultProvider);
@ -88,19 +103,46 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
? ''
: _directApiKeyController.text,
);
_setIfDifferent(_relayHostController, relayProfile.host);
_setIfDifferent(_relayPortController, '${relayProfile.port}');
_setIfDifferent(_localHostController, localProfile.host);
_setIfDifferent(_localPortController, '${localProfile.port}');
_setIfDifferent(_remoteHostController, remoteProfile.host);
_setIfDifferent(_remotePortController, '${remoteProfile.port}');
_remoteTls = remoteProfile.tls;
_setIfDifferent(
_relayTokenController,
widget.controller.storedRelayTokenMask == null
_localTokenController,
widget.controller.storedRelayTokenMaskForProfile(
kGatewayLocalProfileIndex,
) ==
null
? ''
: _relayTokenController.text,
: _localTokenController.text,
);
_setIfDifferent(
_relayPasswordController,
widget.controller.storedRelayPasswordMask == null
_localPasswordController,
widget.controller.storedRelayPasswordMaskForProfile(
kGatewayLocalProfileIndex,
) ==
null
? ''
: _relayPasswordController.text,
: _localPasswordController.text,
);
_setIfDifferent(
_remoteTokenController,
widget.controller.storedRelayTokenMaskForProfile(
kGatewayRemoteProfileIndex,
) ==
null
? ''
: _remoteTokenController.text,
);
_setIfDifferent(
_remotePasswordController,
widget.controller.storedRelayPasswordMaskForProfile(
kGatewayRemoteProfileIndex,
) ==
null
? ''
: _remotePasswordController.text,
);
_sessionPersistenceMode = settings.webSessionPersistence.mode;
_setIfDifferent(
@ -225,11 +267,6 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
final targets = controller
.featuresFor(UiFeaturePlatform.web)
.availableExecutionTargets
.where(
(target) =>
target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.remote,
)
.toList(growable: false);
return [
SurfaceCard(
@ -271,7 +308,6 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
SettingsSnapshot settings,
) {
final palette = context.palette;
final relayProfile = settings.primaryRemoteGatewayProfile;
return [
SurfaceCard(
child: Row(
@ -290,6 +326,217 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
),
),
const SizedBox(height: 12),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('单机智能体', 'Single Agent'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: _directNameController,
decoration: InputDecoration(labelText: appText('名称', 'Name')),
),
const SizedBox(height: 10),
TextField(
controller: _directProviderController,
decoration: InputDecoration(
labelText: appText('Provider 标识', 'Provider label'),
),
),
const SizedBox(height: 10),
TextField(
controller: _directBaseUrlController,
decoration: InputDecoration(
labelText: appText('LLM API Endpoint', 'LLM API Endpoint'),
hintText: 'https://api.example.com/v1',
),
),
const SizedBox(height: 10),
TextField(
controller: _directApiKeyController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('LLM API Token', 'LLM API Token'),
helperText: controller.storedAiGatewayApiKeyMask == null
? null
: '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}',
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: controller.resolvedAiGatewayModel.isEmpty
? null
: controller.resolvedAiGatewayModel,
items: settings.aiGateway.availableModels
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
),
)
.toList(growable: false),
onChanged: (value) {
if (value != null) {
controller.selectDirectModel(value);
}
},
decoration: InputDecoration(
labelText: appText('默认模型', 'Default model'),
hintText: appText('先同步模型目录', 'Sync model catalog first'),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
onPressed: controller.aiGatewayBusy
? null
: () async {
final result = await controller.testAiGatewayConnection(
baseUrl: _directBaseUrlController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() => _directMessage = result.message);
},
child: Text(appText('Test', 'Test')),
),
FilledButton(
onPressed: controller.aiGatewayBusy
? null
: () async {
await controller.saveAiGatewayConfiguration(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
defaultModel: controller.resolvedAiGatewayModel,
);
if (!mounted) {
return;
}
setState(() {
_directMessage = appText(
'配置已保存,尚未同步模型目录。',
'Configuration saved; model catalog not synced yet.',
);
});
},
child: Text(appText('Save', 'Save')),
),
FilledButton.icon(
onPressed: controller.aiGatewayBusy
? null
: () async {
await controller.saveAiGatewayConfiguration(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
defaultModel: controller.resolvedAiGatewayModel,
);
try {
await controller.syncAiGatewayModels(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() {
_directMessage =
controller.settings.aiGateway.syncMessage;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() => _directMessage = '$error');
}
},
icon: controller.aiGatewayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_circle_outline_rounded),
label: Text(appText('Apply', 'Apply')),
),
],
),
if (_directMessage.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
_directMessage,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
],
],
),
),
const SizedBox(height: 12),
_buildGatewayCard(
context,
controller: controller,
title: appText('Local Gateway', 'Local Gateway'),
executionTarget: AssistantExecutionTarget.local,
profileIndex: kGatewayLocalProfileIndex,
hostController: _localHostController,
portController: _localPortController,
tokenController: _localTokenController,
passwordController: _localPasswordController,
tokenMask: controller.storedRelayTokenMaskForProfile(
kGatewayLocalProfileIndex,
),
passwordMask: controller.storedRelayPasswordMaskForProfile(
kGatewayLocalProfileIndex,
),
tls: false,
onTlsChanged: null,
message: _localGatewayMessage,
onMessageChanged: (value) {
setState(() => _localGatewayMessage = value);
},
),
const SizedBox(height: 12),
_buildGatewayCard(
context,
controller: controller,
title: appText('Remote Gateway', 'Remote Gateway'),
executionTarget: AssistantExecutionTarget.remote,
profileIndex: kGatewayRemoteProfileIndex,
hostController: _remoteHostController,
portController: _remotePortController,
tokenController: _remoteTokenController,
passwordController: _remotePasswordController,
tokenMask: controller.storedRelayTokenMaskForProfile(
kGatewayRemoteProfileIndex,
),
passwordMask: controller.storedRelayPasswordMaskForProfile(
kGatewayRemoteProfileIndex,
),
tls: _remoteTls,
onTlsChanged: (value) {
setState(() => _remoteTls = value);
},
message: _remoteGatewayMessage,
onMessageChanged: (value) {
setState(() => _remoteGatewayMessage = value);
},
),
const SizedBox(height: 12),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -376,7 +623,26 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
controller.sessionPersistenceStatusMessage;
});
},
child: Text(appText('保存会话存储', 'Save session store')),
child: Text(appText('Save', 'Save')),
),
FilledButton.tonal(
onPressed: () async {
await controller.saveWebSessionPersistenceConfiguration(
mode: _sessionPersistenceMode,
remoteBaseUrl: _sessionRemoteBaseUrlController.text,
apiToken: _sessionApiTokenController.text,
);
if (!mounted) {
return;
}
setState(() {
_sessionPersistenceMessage = appText(
'会话存储配置已应用到当前浏览器会话。',
'Session persistence settings are now applied to this browser session.',
);
});
},
child: Text(appText('Apply', 'Apply')),
),
],
),
@ -395,299 +661,233 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
],
),
),
const SizedBox(height: 12),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('单机智能体', 'Single Agent'),
style: Theme.of(context).textTheme.titleMedium,
];
}
Widget _buildGatewayCard(
BuildContext context, {
required AppController controller,
required String title,
required AssistantExecutionTarget executionTarget,
required int profileIndex,
required TextEditingController hostController,
required TextEditingController portController,
required TextEditingController tokenController,
required TextEditingController passwordController,
required String? tokenMask,
required String? passwordMask,
required bool tls,
required ValueChanged<bool>? onTlsChanged,
required String message,
required ValueChanged<String> onMessageChanged,
}) {
final expectedMode = executionTarget == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final matchesTarget = controller.connection.mode == expectedMode;
final status = matchesTarget
? controller.connection.status.label
: RuntimeConnectionStatus.offline.label;
final endpoint = '${hostController.text.trim()}:${_parsePort(portController.text, fallback: 443)}';
final statusEndpoint = matchesTarget
? (controller.connection.remoteAddress?.trim().isNotEmpty == true
? controller.connection.remoteAddress!.trim()
: endpoint)
: endpoint;
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: hostController,
decoration: InputDecoration(
labelText: appText('主机或 URL', 'Host or URL'),
),
const SizedBox(height: 12),
TextField(
controller: _directNameController,
decoration: InputDecoration(labelText: appText('名称', 'Name')),
),
const SizedBox(height: 10),
TextField(
controller: _directProviderController,
decoration: InputDecoration(
labelText: appText('Provider 标识', 'Provider label'),
),
),
const SizedBox(height: 10),
TextField(
controller: _directBaseUrlController,
decoration: InputDecoration(
labelText: appText('LLM API Endpoint', 'LLM API Endpoint'),
hintText: 'https://api.example.com/v1',
),
),
const SizedBox(height: 10),
TextField(
controller: _directApiKeyController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('LLM API Token', 'LLM API Token'),
helperText: controller.storedAiGatewayApiKeyMask == null
? null
: '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}',
),
),
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: controller.resolvedAiGatewayModel.isEmpty
),
const SizedBox(height: 10),
TextField(
controller: portController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: appText('端口', 'Port')),
),
const SizedBox(height: 10),
TextField(
controller: tokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Gateway Token', 'Gateway token'),
helperText: tokenMask == null
? null
: controller.resolvedAiGatewayModel,
items: settings.aiGateway.availableModels
.map(
(item) => DropdownMenuItem<String>(
value: item,
child: Text(item),
),
)
.toList(growable: false),
onChanged: (value) {
if (value != null) {
controller.selectDirectModel(value);
}
},
decoration: InputDecoration(
labelText: appText('默认模型', 'Default model'),
hintText: appText('先同步模型目录', 'Sync model catalog first'),
),
: '${appText('已保存', 'Stored')}: $tokenMask',
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
onPressed: controller.aiGatewayBusy
? null
: () async {
final result = await controller
.testAiGatewayConnection(
baseUrl: _directBaseUrlController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() => _directMessage = result.message);
},
child: Text(appText('测试连接', 'Test connection')),
),
const SizedBox(height: 10),
TextField(
controller: passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Gateway Password', 'Gateway password'),
helperText: passwordMask == null
? null
: '${appText('已保存', 'Stored')}: $passwordMask',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
'${appText('状态', 'Status')}: $status · $statusEndpoint',
),
FilledButton.icon(
onPressed: controller.aiGatewayBusy
? null
: () async {
await controller.saveAiGatewayConfiguration(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
defaultModel: controller.resolvedAiGatewayModel,
);
try {
await controller.syncAiGatewayModels(
name: _directNameController.text,
baseUrl: _directBaseUrlController.text,
provider: _directProviderController.text,
apiKey: _directApiKeyController.text,
);
if (!mounted) {
return;
}
setState(() {
_directMessage =
controller.settings.aiGateway.syncMessage;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() => _directMessage = '$error');
}
},
icon: controller.aiGatewayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check_circle_outline_rounded),
label: Text(appText('保存/应用', 'Save / Apply')),
),
],
),
if (_directMessage.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
_directMessage,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
],
],
),
),
const SizedBox(height: 12),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('Relay OpenClaw Gateway', 'Relay OpenClaw Gateway'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: _relayHostController,
decoration: InputDecoration(
labelText: appText('主机或 URL', 'Host or URL'),
),
),
const SizedBox(height: 10),
TextField(
controller: _relayPortController,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: appText('端口', 'Port')),
),
const SizedBox(height: 10),
TextField(
controller: _relayTokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Relay Token', 'Relay token'),
helperText: controller.storedRelayTokenMask == null
? null
: '${appText('已保存', 'Stored')}: ${controller.storedRelayTokenMask}',
),
),
const SizedBox(height: 10),
TextField(
controller: _relayPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('Relay Password', 'Relay password'),
helperText: controller.storedRelayPasswordMask == null
? null
: '${appText('已保存', 'Stored')}: ${controller.storedRelayPasswordMask}',
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
'${appText('状态', 'Status')}: ${controller.connection.status.label} · ${controller.connection.remoteAddress ?? appText('未连接', 'Offline')}',
),
),
Switch(
value: relayProfile.tls,
onChanged: (value) => controller.saveRelayConfiguration(
host: _relayHostController.text,
port: int.tryParse(_relayPortController.text.trim()) ?? 443,
tls: value,
token: _relayTokenController.text,
password: _relayPasswordController.text,
),
),
if (onTlsChanged != null) ...[
Switch(value: tls, onChanged: onTlsChanged),
Text(appText('TLS', 'TLS')),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton(
onPressed: () => controller.saveRelayConfiguration(
host: _relayHostController.text,
port: int.tryParse(_relayPortController.text.trim()) ?? 443,
tls: relayProfile.tls,
token: _relayTokenController.text,
password: _relayPasswordController.text,
),
child: Text(appText('保存', 'Save')),
),
OutlinedButton.icon(
onPressed: controller.relayBusy
? null
: () async {
try {
await controller.saveRelayConfiguration(
host: _relayHostController.text,
port:
int.tryParse(
_relayPortController.text.trim(),
) ??
443,
tls: relayProfile.tls,
token: _relayTokenController.text,
password: _relayPasswordController.text,
);
await controller.connectRelay();
if (!mounted) {
return;
}
setState(() {
_relayMessage = appText(
'Relay 已连接',
'Relay connected',
);
});
} catch (error) {
if (!mounted) {
return;
}
setState(() => _relayMessage = '$error');
}
},
icon: controller.relayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.link_rounded),
label: Text(appText('连接 Relay', 'Connect relay')),
),
OutlinedButton(
onPressed: controller.relayBusy
? null
: () async {
await controller.disconnectRelay();
],
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
OutlinedButton(
onPressed: controller.relayBusy
? null
: () async {
final profile = _gatewayProfileDraft(
executionTarget: executionTarget,
host: hostController.text,
portText: portController.text,
tls: tls,
);
final result = await controller.testGatewayConnectionDraft(
profile: profile,
executionTarget: executionTarget,
tokenOverride: tokenController.text,
passwordOverride: passwordController.text,
);
if (!mounted) {
return;
}
onMessageChanged(
'${result.state.toUpperCase()} · ${result.message}',
);
},
child: Text(appText('Test', 'Test')),
),
FilledButton(
onPressed: controller.relayBusy
? null
: () async {
await controller.saveRelayConfiguration(
profileIndex: profileIndex,
host: hostController.text,
port: _parsePort(portController.text, fallback: 443),
tls: tls,
token: tokenController.text,
password: passwordController.text,
);
if (!mounted) {
return;
}
onMessageChanged(
appText(
'配置已保存,尚未应用到当前线程连接。',
'Configuration saved but not applied to active thread connections yet.',
),
);
},
child: Text(appText('Save', 'Save')),
),
FilledButton.icon(
onPressed: controller.relayBusy
? null
: () async {
try {
await controller.applyRelayConfiguration(
profileIndex: profileIndex,
host: hostController.text,
port: _parsePort(portController.text, fallback: 443),
tls: tls,
token: tokenController.text,
password: passwordController.text,
);
if (!mounted) {
return;
}
setState(() {
_relayMessage = appText(
'Relay 已断开',
'Relay disconnected',
);
});
},
child: Text(appText('断开', 'Disconnect')),
),
],
),
if (_relayMessage.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
_relayMessage,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
onMessageChanged(
appText(
'配置已应用;当前线程目标匹配时将使用新连接。',
'Configuration applied. Threads targeting this gateway now use the updated connection.',
),
);
} catch (error) {
if (!mounted) {
return;
}
onMessageChanged('$error');
}
},
icon: controller.relayBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_circle_outline_rounded),
label: Text(appText('Apply', 'Apply')),
),
],
),
if (message.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Text(
message,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: context.palette.textSecondary),
),
],
),
],
),
];
);
}
GatewayConnectionProfile _gatewayProfileDraft({
required AssistantExecutionTarget executionTarget,
required String host,
required String portText,
required bool tls,
}) {
final mode = executionTarget == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final defaults = executionTarget == AssistantExecutionTarget.local
? GatewayConnectionProfile.defaultsLocal()
: GatewayConnectionProfile.defaultsRemote();
return defaults.copyWith(
mode: mode,
host: host.trim(),
port: _parsePort(portText, fallback: defaults.port),
tls: mode == RuntimeConnectionMode.local ? false : tls,
useSetupCode: false,
setupCode: '',
);
}
int _parsePort(String value, {required int fallback}) {
final parsed = int.tryParse(value.trim());
if (parsed == null || parsed <= 0) {
return fallback;
}
return parsed;
}
List<Widget> _buildAppearance(
@ -786,10 +986,13 @@ String _targetLabel(AssistantExecutionTarget target) {
'Single Agent',
'Single Agent',
),
AssistantExecutionTarget.remote => appText(
'Relay OpenClaw Gateway',
'Relay OpenClaw Gateway',
AssistantExecutionTarget.local => appText(
'Local Gateway',
'Local Gateway',
),
AssistantExecutionTarget.remote => appText(
'Remote Gateway',
'Remote Gateway',
),
_ => '',
};
}

View File

@ -10,8 +10,11 @@ class WebStore {
static const settingsKey = 'xworkmate.web.settings.snapshot';
static const threadsKey = 'xworkmate.web.assistant.threads';
static const aiGatewayApiKeyKey = 'xworkmate.web.ai_gateway.api_key';
// Legacy remote-only keys (kept for migration fallback).
static const relayTokenKey = 'xworkmate.web.relay.token';
static const relayPasswordKey = 'xworkmate.web.relay.password';
static const relayTokenProfilePrefix = 'xworkmate.web.relay.token.';
static const relayPasswordProfilePrefix = 'xworkmate.web.relay.password.';
static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity';
static const sessionClientIdKey = 'xworkmate.web.session.client_id';
static const themeModeKey = 'xworkmate.web.theme_mode';
@ -72,24 +75,50 @@ class WebStore {
await _prefs!.setString(aiGatewayApiKeyKey, value.trim());
}
Future<String> loadRelayToken() async {
Future<String> loadRelayToken({int? profileIndex}) async {
await initialize();
return (_prefs!.getString(relayTokenKey) ?? '').trim();
final scopedKey = _relayTokenScopedKey(profileIndex);
final scoped = (_prefs!.getString(scopedKey) ?? '').trim();
if (scoped.isNotEmpty) {
return scoped;
}
// Backward compatibility: old builds persisted a single remote token.
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
return (_prefs!.getString(relayTokenKey) ?? '').trim();
}
return '';
}
Future<void> saveRelayToken(String value) async {
Future<void> saveRelayToken(String value, {int? profileIndex}) async {
await initialize();
await _prefs!.setString(relayTokenKey, value.trim());
final trimmed = value.trim();
await _prefs!.setString(_relayTokenScopedKey(profileIndex), trimmed);
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
await _prefs!.setString(relayTokenKey, trimmed);
}
}
Future<String> loadRelayPassword() async {
Future<String> loadRelayPassword({int? profileIndex}) async {
await initialize();
return (_prefs!.getString(relayPasswordKey) ?? '').trim();
final scopedKey = _relayPasswordScopedKey(profileIndex);
final scoped = (_prefs!.getString(scopedKey) ?? '').trim();
if (scoped.isNotEmpty) {
return scoped;
}
// Backward compatibility: old builds persisted a single remote password.
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
return (_prefs!.getString(relayPasswordKey) ?? '').trim();
}
return '';
}
Future<void> saveRelayPassword(String value) async {
Future<void> saveRelayPassword(String value, {int? profileIndex}) async {
await initialize();
await _prefs!.setString(relayPasswordKey, value.trim());
final trimmed = value.trim();
await _prefs!.setString(_relayPasswordScopedKey(profileIndex), trimmed);
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
await _prefs!.setString(relayPasswordKey, trimmed);
}
}
Future<String> loadOrCreateWebSessionClientId() async {
@ -161,4 +190,14 @@ class WebStore {
).join();
return 'web-$timestamp-$suffix';
}
static String _relayTokenScopedKey(int? profileIndex) {
final resolved = profileIndex ?? kGatewayRemoteProfileIndex;
return '$relayTokenProfilePrefix$resolved';
}
static String _relayPasswordScopedKey(int? profileIndex) {
final resolved = profileIndex ?? kGatewayRemoteProfileIndex;
return '$relayPasswordProfilePrefix$resolved';
}
}

View File

@ -8,6 +8,7 @@ import Foundation
import device_info_plus
import file_selector_macos
import irondash_engine_context
import mobile_scanner
import package_info_plus
import shared_preferences_foundation
import super_native_extensions
@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))

View File

@ -380,6 +380,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
native_toolchain_c:
dependency: transitive
description:

View File

@ -23,6 +23,7 @@ dependencies:
flutter_markdown: ^0.7.7+1
http: ^1.5.0
markdown: ^7.3.0
mobile_scanner: ^6.0.7
package_info_plus: ^8.3.1
path_provider: ^2.1.5
shared_preferences: ^2.5.3

View File

@ -3,6 +3,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_shell.dart';
import 'package:xworkmate/app/ui_feature_manifest.dart';
@ -14,6 +15,34 @@ import 'package:xworkmate/theme/app_theme.dart';
import '../../test_support.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const mobileScannerChannel = MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
);
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, (call) async {
return switch (call.method) {
'state' => 1,
'request' => true,
'start' => <Object?, Object?>{
'textureId': 1,
'size': <Object?, Object?>{'width': 1080.0, 'height': 1920.0},
'numberOfCameras': 1,
'currentTorchMode': 0,
},
_ => null,
};
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, null);
});
Future<void> pumpMobileShell(
WidgetTester tester, {
required Widget child,

View File

@ -0,0 +1,97 @@
@TestOn('vm')
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/features/mobile/mobile_gateway_pairing_guide_page.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const mobileScannerChannel = MethodChannel(
'dev.steenbakker.mobile_scanner/scanner/method',
);
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, (call) async => null);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(mobileScannerChannel, null);
});
Future<void> pumpGuide(
WidgetTester tester, {
required bool supportsQrScan,
required VoidCallback onManual,
required Future<void> Function(String setupCode) onScanned,
}) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(430, 1200);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.iOS),
darkTheme: AppTheme.dark(platform: TargetPlatform.iOS),
home: MobileGatewayPairingGuidePage(
supportsQrScan: supportsQrScan,
onManualInput: onManual,
onScannedSetupCode: onScanned,
),
),
);
await tester.pump();
}
testWidgets('guide shows xworkmate commands', (tester) async {
await pumpGuide(
tester,
supportsQrScan: true,
onManual: () {},
onScanned: (_) async {},
);
expect(find.text('配对网关'), findsOneWidget);
expect(find.text('npm install -g xworkmate'), findsOneWidget);
expect(find.text('xworkmate pair'), findsOneWidget);
expect(
find.byKey(const ValueKey('pairing-guide-install-command')),
findsOneWidget,
);
});
testWidgets('manual button triggers callback', (tester) async {
var manualTapped = false;
await pumpGuide(
tester,
supportsQrScan: true,
onManual: () => manualTapped = true,
onScanned: (_) async {},
);
await tester.tap(find.byKey(const ValueKey('pairing-guide-manual-button')));
await tester.pumpAndSettle();
expect(manualTapped, isTrue);
});
testWidgets('android scan button shows placeholder toast', (tester) async {
await pumpGuide(
tester,
supportsQrScan: false,
onManual: () {},
onScanned: (_) async {},
);
await tester.tap(find.byKey(const ValueKey('pairing-guide-scan-button')));
await tester.pump();
expect(find.textContaining('Android 扫码即将支持'), findsOneWidget);
});
}

View File

@ -0,0 +1,306 @@
@TestOn('browser')
library;
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller_web.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/web/web_acp_client.dart';
import 'package:xworkmate/web/web_relay_gateway_client.dart';
import 'package:xworkmate/web/web_store.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('thread-scoped assistant context persists across reload on web', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final fakeRelay = _FakeRelayGatewayClient(WebStore());
final fakeAcp = _FakeAcpClient();
final controller = AppController(
store: WebStore(),
relayClient: fakeRelay,
acpClient: fakeAcp,
);
await _waitForReady(controller);
await controller.saveRelayConfiguration(
profileIndex: kGatewayLocalProfileIndex,
host: '',
port: 18789,
tls: false,
token: '',
password: '',
);
await controller.saveRelayConfiguration(
profileIndex: kGatewayRemoteProfileIndex,
host: '',
port: 443,
tls: true,
token: '',
password: '',
);
final threadSingle = controller.currentSessionKey;
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw);
await controller.selectAssistantModelForSession(threadSingle, 'single-model');
await controller.saveAssistantTaskTitle(threadSingle, 'Thread Single');
await controller.createConversation(target: AssistantExecutionTarget.local);
final threadLocal = controller.currentSessionKey;
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.local);
await controller.selectAssistantModelForSession(threadLocal, 'local-model');
await controller.saveAssistantTaskTitle(threadLocal, 'Thread Local');
await controller.createConversation(target: AssistantExecutionTarget.remote);
final threadRemote = controller.currentSessionKey;
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote);
await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw);
await controller.selectAssistantModelForSession(threadRemote, 'remote-model');
await controller.saveAssistantTaskTitle(threadRemote, 'Thread Remote');
await controller.saveAssistantTaskArchived(threadRemote, true);
expect(
controller.assistantExecutionTargetForSession(threadSingle),
AssistantExecutionTarget.singleAgent,
);
expect(
controller.singleAgentProviderForSession(threadSingle),
SingleAgentProvider.codex,
);
expect(
controller.assistantMessageViewModeForSession(threadSingle),
AssistantMessageViewMode.raw,
);
expect(controller.assistantModelForSession(threadSingle), 'single-model');
expect(controller.assistantModelForSession(threadLocal), 'local-model');
expect(
controller.isAssistantTaskArchived(threadRemote),
isTrue,
);
expect(
controller.conversations.where((item) => item.sessionKey == threadRemote),
isEmpty,
);
controller.dispose();
final reloaded = AppController(
store: WebStore(),
relayClient: _FakeRelayGatewayClient(WebStore()),
acpClient: fakeAcp,
);
await _waitForReady(reloaded);
expect(
reloaded.assistantExecutionTargetForSession(threadSingle),
AssistantExecutionTarget.singleAgent,
);
expect(
reloaded.singleAgentProviderForSession(threadSingle),
SingleAgentProvider.codex,
);
expect(
reloaded.assistantMessageViewModeForSession(threadSingle),
AssistantMessageViewMode.raw,
);
expect(reloaded.assistantModelForSession(threadSingle), 'single-model');
expect(reloaded.assistantModelForSession(threadLocal), 'local-model');
expect(reloaded.isAssistantTaskArchived(threadRemote), isTrue);
reloaded.dispose();
});
test('gateway Save does not connect but Apply connects current target profile',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final fakeRelay = _FakeRelayGatewayClient(WebStore());
final controller = AppController(
store: WebStore(),
relayClient: fakeRelay,
acpClient: _FakeAcpClient(),
);
await _waitForReady(controller);
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote);
fakeRelay.connectCalls = 0;
await controller.saveRelayConfiguration(
profileIndex: kGatewayRemoteProfileIndex,
host: 'remote.example.com',
port: 443,
tls: true,
token: 'remote-token',
password: '',
);
expect(fakeRelay.connectCalls, 0);
await controller.applyRelayConfiguration(
profileIndex: kGatewayRemoteProfileIndex,
host: 'remote.example.com',
port: 443,
tls: true,
token: 'remote-token',
password: '',
);
expect(fakeRelay.connectCalls, greaterThanOrEqualTo(1));
expect(fakeRelay.lastConnectMode, RuntimeConnectionMode.remote);
controller.dispose();
});
}
class _FakeRelayGatewayClient extends WebRelayGatewayClient {
_FakeRelayGatewayClient(
super.store, {
GatewayConnectionSnapshot? initialSnapshot,
}) : _snapshot =
initialSnapshot ??
GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote);
final StreamController<GatewayPushEvent> _eventsController =
StreamController<GatewayPushEvent>.broadcast();
GatewayConnectionSnapshot _snapshot;
int connectCalls = 0;
RuntimeConnectionMode? lastConnectMode;
@override
Stream<GatewayPushEvent> get events => _eventsController.stream;
@override
GatewayConnectionSnapshot get snapshot => _snapshot;
@override
Future<void> connect({
required GatewayConnectionProfile profile,
required String authToken,
required String authPassword,
}) async {
connectCalls += 1;
lastConnectMode = profile.mode;
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
remoteAddress: '${profile.host}:${profile.port}',
);
}
@override
Future<void> disconnect() async {
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.offline,
statusText: 'Offline',
clearRemoteAddress: true,
);
}
@override
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
return const <GatewaySessionSummary>[];
}
@override
Future<List<GatewayChatMessage>> loadHistory(
String sessionKey, {
int limit = 120,
}) async {
return const <GatewayChatMessage>[];
}
@override
Future<String> sendChat({
required String sessionKey,
required String message,
required String thinking,
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
Map<String, dynamic> metadata = const <String, dynamic>{},
}) async {
return 'fake-run';
}
@override
Future<List<GatewayModelSummary>> listModels() async {
return const <GatewayModelSummary>[];
}
@override
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
Duration timeout = const Duration(seconds: 15),
}) async {
if (method == 'skills.status') {
return const <String, dynamic>{'skills': <dynamic>[]};
}
return const <String, dynamic>{};
}
@override
Future<void> dispose() async {
await _eventsController.close();
}
}
class _FakeAcpClient extends WebAcpClient {
@override
Future<WebAcpCapabilities> loadCapabilities({required Uri endpoint}) async {
return WebAcpCapabilities(
singleAgent: true,
multiAgent: true,
providers: <SingleAgentProvider>{
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.claude,
SingleAgentProvider.gemini,
},
raw: <String, dynamic>{},
);
}
@override
Future<void> cancelSession({
required Uri endpoint,
required String sessionId,
required String threadId,
}) async {}
@override
Future<Map<String, dynamic>> request({
required Uri endpoint,
required String method,
required Map<String, dynamic> params,
void Function(Map<String, dynamic> notification)? onNotification,
Duration timeout = const Duration(seconds: 120),
}) async {
return <String, dynamic>{
'result': <String, dynamic>{
'output': 'ok',
'summary': 'ok',
'model': params['model']?.toString() ?? 'fake-model',
},
};
}
}
Future<void> _waitForReady(
AppController controller, {
Duration timeout = const Duration(seconds: 5),
}) async {
final deadline = DateTime.now().add(timeout);
while (controller.initializing) {
if (DateTime.now().isAfter(deadline)) {
fail('controller did not initialize before timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}

View File

@ -21,12 +21,13 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('助手'), findsWidgets);
expect(find.text('设置'), findsWidgets);
expect(find.byKey(const Key('web-shell-nav-assistant')), findsOneWidget);
expect(find.byKey(const Key('web-shell-nav-settings')), findsOneWidget);
expect(find.text('Tasks'), findsNothing);
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
expect(
find.byKey(const Key('assistant-attachment-menu-button')),
findsNothing,
findsOneWidget,
);
await tester.tap(find.text('连接设置'));
@ -34,5 +35,7 @@ void main() {
expect(find.text('设置'), findsWidgets);
expect(find.textContaining('浏览器本地存储'), findsOneWidget);
expect(find.textContaining('Local Gateway'), findsWidgets);
expect(find.textContaining('Remote Gateway'), findsWidgets);
});
}