Merge pull request #1 from x-evor/codex/web-homepage-app-layout
Codex/web homepage app layout
This commit is contained in:
commit
1ea4e2fad4
@ -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
|
||||
|
||||
@ -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
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
516
lib/features/mobile/mobile_gateway_pairing_guide_page.dart
Normal file
516
lib/features/mobile/mobile_gateway_pairing_guide_page.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
251
lib/web/web_acp_client.dart
Normal 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
@ -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,
|
||||
},
|
||||
|
||||
@ -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',
|
||||
),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
97
test/features/mobile/mobile_pairing_guide_suite.dart
Normal file
97
test/features/mobile/mobile_pairing_guide_suite.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
306
test/web/web_assistant_controller_parity_browser_test.dart
Normal file
306
test/web/web_assistant_controller_parity_browser_test.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user