Add Flutter web assistant shell
This commit is contained in:
parent
fe1a74c478
commit
a41ac51103
25
README.md
25
README.md
@ -1,7 +1,7 @@
|
||||
# XWorkmate
|
||||
|
||||
XWorkmate is a desktop-first AI workspace shell built with Flutter.
|
||||
`v0.5` ships persistent assistant task threads, optional ARIS-powered multi-agent collaboration, and a bundled Go bridge runtime that travels with the app.
|
||||
XWorkmate is an AI workspace shell built with Flutter.
|
||||
`v0.5` ships persistent assistant task threads, optional ARIS-powered multi-agent collaboration, and a bundled Go bridge runtime that travels with the macOS app.
|
||||
|
||||
## v0.5 Highlights
|
||||
|
||||
@ -19,6 +19,7 @@ XWorkmate is a desktop-first AI workspace shell built with Flutter.
|
||||
- Multi-Agent orchestration with optional ARIS preset
|
||||
- Bundled ARIS skills, Go bridge helper, `llm-chat` reviewer, and `claude-review`
|
||||
- Ollama Cloud settings, task grouping, and macOS packaged delivery
|
||||
- Flutter Web shell with `Assistant` + `Settings` only, supporting `Direct AI Gateway` and `Relay OpenClaw Gateway`
|
||||
|
||||
### Not Yet Implemented
|
||||
- Built-in Codex runtime through Rust FFI
|
||||
@ -38,9 +39,29 @@ XWorkmate is a desktop-first AI workspace shell built with Flutter.
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter test --platform chrome test/widget_test.dart test/web
|
||||
flutter run -d macos
|
||||
```
|
||||
|
||||
## Flutter Web
|
||||
|
||||
Web keeps the Assistant-first entry flow, but only exposes:
|
||||
|
||||
- `Assistant`
|
||||
- `Settings`
|
||||
- `Direct AI Gateway`
|
||||
- `Relay OpenClaw Gateway`
|
||||
|
||||
Web does not expose local CLI, workspace file access, native runtime orchestration, or desktop-only diagnostics.
|
||||
|
||||
Build the root-site bundle with:
|
||||
|
||||
```bash
|
||||
flutter build web --release --base-href /
|
||||
```
|
||||
|
||||
Deployment notes for `https://xworkmate.svc.plus/` are in [docs/web-deployment.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/web-deployment.md).
|
||||
|
||||
## macOS Packaging
|
||||
|
||||
```bash
|
||||
|
||||
61
docs/web-deployment.md
Normal file
61
docs/web-deployment.md
Normal file
@ -0,0 +1,61 @@
|
||||
# XWorkmate Web Deployment
|
||||
|
||||
This repo now ships a browser-safe Flutter Web variant intended to be deployed at the root site:
|
||||
|
||||
- `https://xworkmate.svc.plus/`
|
||||
|
||||
## Product Scope
|
||||
|
||||
The Web app keeps only:
|
||||
|
||||
- `Assistant`
|
||||
- `Settings`
|
||||
- `Direct AI Gateway`
|
||||
- `Relay OpenClaw Gateway`
|
||||
|
||||
The following remain desktop-only:
|
||||
|
||||
- local OpenClaw gateway mode
|
||||
- local CLI orchestration
|
||||
- workspace file and attachment access
|
||||
- native desktop integrations
|
||||
- desktop diagnostics/runtime surfaces
|
||||
|
||||
## Build Commands
|
||||
|
||||
Use a root-site build:
|
||||
|
||||
```bash
|
||||
flutter build web --release --base-href /
|
||||
```
|
||||
|
||||
Recommended validation before deployment:
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test
|
||||
flutter test --platform chrome test/widget_test.dart test/web
|
||||
flutter build web --release --base-href /
|
||||
```
|
||||
|
||||
## Static Hosting Notes
|
||||
|
||||
- Deploy the contents of `build/web/` at the site root.
|
||||
- Keep `index.html` served from `/`.
|
||||
- Flutter emits fingerprinted assets; publish the full directory together so `flutter_service_worker.js` and asset hashes stay aligned.
|
||||
- Cache `index.html` conservatively or with revalidation so new asset manifests are picked up quickly after each release.
|
||||
- Static assets under `build/web/assets/` and hashed JS files can be cached aggressively.
|
||||
|
||||
## Network Requirements
|
||||
|
||||
- `Direct AI Gateway` must be browser-reachable from the end user device.
|
||||
- Direct gateway endpoints must allow the Web origin with correct CORS headers.
|
||||
- If a provider cannot satisfy browser reachability or CORS constraints, users must use `Relay OpenClaw Gateway` instead.
|
||||
- Relay endpoints should stay on TLS in production and must not silently downgrade to insecure transport for remote usage.
|
||||
|
||||
## Persistence and Secrets
|
||||
|
||||
- Web configuration is stored in browser-local persistent storage on the current device.
|
||||
- This includes the selected execution target, direct gateway settings, relay settings, and Web conversation metadata.
|
||||
- Web persistence is less secure than desktop secure storage; use trusted devices only.
|
||||
- `.env` remains desktop/development prefill-only and is not auto-imported into Web runtime behavior.
|
||||
56
lib/app/app_capabilities.dart
Normal file
56
lib/app/app_capabilities.dart
Normal file
@ -0,0 +1,56 @@
|
||||
import '../models/app_models.dart';
|
||||
|
||||
class AppCapabilities {
|
||||
const AppCapabilities({
|
||||
required this.allowedDestinations,
|
||||
required this.supportsFileAttachments,
|
||||
required this.supportsLocalGateway,
|
||||
required this.supportsRelayGateway,
|
||||
required this.supportsDesktopRuntime,
|
||||
required this.supportsDiagnostics,
|
||||
});
|
||||
|
||||
final Set<WorkspaceDestination> allowedDestinations;
|
||||
final bool supportsFileAttachments;
|
||||
final bool supportsLocalGateway;
|
||||
final bool supportsRelayGateway;
|
||||
final bool supportsDesktopRuntime;
|
||||
final bool supportsDiagnostics;
|
||||
|
||||
bool supportsDestination(WorkspaceDestination destination) {
|
||||
return allowedDestinations.contains(destination);
|
||||
}
|
||||
|
||||
static const desktop = AppCapabilities(
|
||||
allowedDestinations: <WorkspaceDestination>{
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.nodes,
|
||||
WorkspaceDestination.agents,
|
||||
WorkspaceDestination.mcpServer,
|
||||
WorkspaceDestination.clawHub,
|
||||
WorkspaceDestination.secrets,
|
||||
WorkspaceDestination.aiGateway,
|
||||
WorkspaceDestination.settings,
|
||||
WorkspaceDestination.account,
|
||||
},
|
||||
supportsFileAttachments: true,
|
||||
supportsLocalGateway: true,
|
||||
supportsRelayGateway: true,
|
||||
supportsDesktopRuntime: true,
|
||||
supportsDiagnostics: true,
|
||||
);
|
||||
|
||||
static const web = AppCapabilities(
|
||||
allowedDestinations: <WorkspaceDestination>{
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.settings,
|
||||
},
|
||||
supportsFileAttachments: false,
|
||||
supportsLocalGateway: false,
|
||||
supportsRelayGateway: true,
|
||||
supportsDesktopRuntime: false,
|
||||
supportsDiagnostics: false,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
3081
lib/app/app_controller_desktop.dart
Normal file
3081
lib/app/app_controller_desktop.dart
Normal file
File diff suppressed because it is too large
Load Diff
894
lib/app/app_controller_web.dart
Normal file
894
lib/app/app_controller_web.dart
Normal file
@ -0,0 +1,894 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import '../web/web_ai_gateway_client.dart';
|
||||
import '../web/web_relay_gateway_client.dart';
|
||||
import '../web/web_store.dart';
|
||||
import 'app_capabilities.dart';
|
||||
|
||||
class AppController extends ChangeNotifier {
|
||||
AppController({
|
||||
WebStore? store,
|
||||
WebAiGatewayClient? aiGatewayClient,
|
||||
WebRelayGatewayClient? relayClient,
|
||||
}) : _store = store ?? WebStore(),
|
||||
_aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient() {
|
||||
_relayClient = relayClient ?? WebRelayGatewayClient(_store);
|
||||
_relayEventsSubscription = _relayClient.events.listen(_handleRelayEvent);
|
||||
unawaited(_initialize());
|
||||
}
|
||||
|
||||
final WebStore _store;
|
||||
final WebAiGatewayClient _aiGatewayClient;
|
||||
late final WebRelayGatewayClient _relayClient;
|
||||
|
||||
late final StreamSubscription<GatewayPushEvent> _relayEventsSubscription;
|
||||
|
||||
SettingsSnapshot _settings = SettingsSnapshot.defaults();
|
||||
ThemeMode _themeMode = ThemeMode.light;
|
||||
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
||||
SettingsTab _settingsTab = SettingsTab.general;
|
||||
bool _initializing = true;
|
||||
String? _bootstrapError;
|
||||
bool _relayBusy = false;
|
||||
bool _aiGatewayBusy = false;
|
||||
final Map<String, AssistantThreadRecord> _threadRecords =
|
||||
<String, AssistantThreadRecord>{};
|
||||
final Set<String> _pendingSessionKeys = <String>{};
|
||||
final Map<String, String> _streamingTextBySession = <String, String>{};
|
||||
String _currentSessionKey = '';
|
||||
String? _lastAssistantError;
|
||||
|
||||
AppCapabilities get capabilities => AppCapabilities.web;
|
||||
WorkspaceDestination get destination => _destination;
|
||||
SettingsTab get settingsTab => _settingsTab;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
bool get initializing => _initializing;
|
||||
String? get bootstrapError => _bootstrapError;
|
||||
SettingsSnapshot get settings => _settings;
|
||||
AppLanguage get appLanguage => _settings.appLanguage;
|
||||
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
|
||||
bool get relayBusy => _relayBusy;
|
||||
bool get aiGatewayBusy => _aiGatewayBusy;
|
||||
String? get lastAssistantError => _lastAssistantError;
|
||||
String get currentSessionKey => _currentSessionKey;
|
||||
bool get supportsDesktopIntegration => false;
|
||||
bool get hasStoredGatewayToken => storedRelayTokenMask != null;
|
||||
bool get hasStoredAiGatewayApiKey => storedAiGatewayApiKeyMask != null;
|
||||
String? get storedGatewayTokenMask => storedRelayTokenMask;
|
||||
String? get storedRelayTokenMask => WebStore.maskValue(
|
||||
_relayTokenCache.trim().isEmpty ? '' : _relayTokenCache,
|
||||
);
|
||||
String? get storedRelayPasswordMask => WebStore.maskValue(
|
||||
_relayPasswordCache.trim().isEmpty ? '' : _relayPasswordCache,
|
||||
);
|
||||
String? get storedAiGatewayApiKeyMask => WebStore.maskValue(
|
||||
_aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache,
|
||||
);
|
||||
|
||||
String _relayTokenCache = '';
|
||||
String _relayPasswordCache = '';
|
||||
String _aiGatewayApiKeyCache = '';
|
||||
|
||||
AssistantExecutionTarget get assistantExecutionTarget =>
|
||||
_currentRecord.executionTarget ?? _settings.assistantExecutionTarget;
|
||||
AssistantExecutionTarget get currentAssistantExecutionTarget =>
|
||||
assistantExecutionTarget;
|
||||
bool get isAiGatewayOnlyMode =>
|
||||
assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly;
|
||||
List<GatewayChatMessage> get chatMessages {
|
||||
final base = List<GatewayChatMessage>.from(_currentRecord.messages);
|
||||
final streaming = _streamingTextBySession[_currentSessionKey]?.trim() ?? '';
|
||||
if (streaming.isNotEmpty) {
|
||||
base.add(
|
||||
GatewayChatMessage(
|
||||
id: 'streaming',
|
||||
role: 'assistant',
|
||||
text: streaming,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: true,
|
||||
error: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
List<WebConversationSummary> get conversations {
|
||||
final entries =
|
||||
_threadRecords.values
|
||||
.map(
|
||||
(record) => WebConversationSummary(
|
||||
sessionKey: record.sessionKey,
|
||||
title: _titleForRecord(record),
|
||||
preview: _previewForRecord(record),
|
||||
updatedAtMs:
|
||||
record.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
executionTarget:
|
||||
_sanitizeTarget(record.executionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
pending: _pendingSessionKeys.contains(record.sessionKey),
|
||||
current: record.sessionKey == _currentSessionKey,
|
||||
),
|
||||
)
|
||||
.toList(growable: true)
|
||||
..sort((left, right) {
|
||||
if (left.current != right.current) {
|
||||
return left.current ? -1 : 1;
|
||||
}
|
||||
return right.updatedAtMs.compareTo(left.updatedAtMs);
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
List<WebConversationSummary> conversationsForTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) {
|
||||
return conversations
|
||||
.where((item) => item.executionTarget == target)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String get aiGatewayUrl => _settings.aiGateway.baseUrl.trim();
|
||||
String get resolvedAiGatewayModel {
|
||||
final current = _settings.defaultModel.trim();
|
||||
final choices = aiGatewayConversationModelChoices;
|
||||
if (choices.contains(current)) {
|
||||
return current;
|
||||
}
|
||||
if (choices.isNotEmpty) {
|
||||
return choices.first;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
List<String> get aiGatewayConversationModelChoices {
|
||||
final selected = _settings.aiGateway.selectedModels
|
||||
.map((item) => item.trim())
|
||||
.where(
|
||||
(item) =>
|
||||
item.isNotEmpty &&
|
||||
_settings.aiGateway.availableModels.contains(item),
|
||||
)
|
||||
.toList(growable: false);
|
||||
if (selected.isNotEmpty) {
|
||||
return selected;
|
||||
}
|
||||
return _settings.aiGateway.availableModels
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
bool get canUseAiGatewayConversation =>
|
||||
aiGatewayUrl.isNotEmpty &&
|
||||
_aiGatewayApiKeyCache.trim().isNotEmpty &&
|
||||
resolvedAiGatewayModel.isNotEmpty;
|
||||
|
||||
String get assistantConnectionStatusLabel => isAiGatewayOnlyMode
|
||||
? (canUseAiGatewayConversation
|
||||
? appText('可用', 'Ready')
|
||||
: appText('未配置', 'Not configured'))
|
||||
: connection.status.label;
|
||||
|
||||
String get assistantConnectionTargetLabel {
|
||||
if (!isAiGatewayOnlyMode) {
|
||||
return connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline');
|
||||
}
|
||||
final host = _hostLabel(_settings.aiGateway.baseUrl);
|
||||
final model = resolvedAiGatewayModel;
|
||||
if (host.isEmpty && model.isEmpty) {
|
||||
return appText('Direct AI 未配置', 'Direct AI not configured');
|
||||
}
|
||||
if (host.isNotEmpty && model.isNotEmpty) {
|
||||
return '$model · $host';
|
||||
}
|
||||
return host.isNotEmpty ? host : model;
|
||||
}
|
||||
|
||||
String get currentConversationTitle => _titleForRecord(_currentRecord);
|
||||
|
||||
AssistantThreadRecord get _currentRecord {
|
||||
final existing = _threadRecords[_currentSessionKey];
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
final target =
|
||||
_sanitizeTarget(_settings.assistantExecutionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
final record = _newRecord(target: target);
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
_currentSessionKey = record.sessionKey;
|
||||
return record;
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
await _store.initialize();
|
||||
_themeMode = await _store.loadThemeMode();
|
||||
_settings = _sanitizeSettings(await _store.loadSettingsSnapshot());
|
||||
_aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey();
|
||||
_relayTokenCache = await _store.loadRelayToken();
|
||||
_relayPasswordCache = await _store.loadRelayPassword();
|
||||
final records = await _store.loadAssistantThreadRecords();
|
||||
for (final record in records) {
|
||||
final sanitized = _sanitizeRecord(record);
|
||||
_threadRecords[sanitized.sessionKey] = sanitized;
|
||||
}
|
||||
if (_threadRecords.isEmpty) {
|
||||
final record = _newRecord(
|
||||
target: _settings.assistantExecutionTarget,
|
||||
title: appText('新对话', 'New conversation'),
|
||||
);
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
}
|
||||
_currentSessionKey = conversations.first.sessionKey;
|
||||
} catch (error) {
|
||||
_bootstrapError = '$error';
|
||||
} finally {
|
||||
_initializing = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void navigateTo(WorkspaceDestination destination) {
|
||||
if (!capabilities.supportsDestination(destination)) {
|
||||
return;
|
||||
}
|
||||
_destination = destination;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void navigateHome() {
|
||||
navigateTo(WorkspaceDestination.assistant);
|
||||
}
|
||||
|
||||
void openSettings({SettingsTab tab = SettingsTab.general}) {
|
||||
_destination = WorkspaceDestination.settings;
|
||||
_settingsTab = _sanitizeSettingsTab(tab);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setSettingsTab(SettingsTab tab) {
|
||||
_settingsTab = _sanitizeSettingsTab(tab);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
if (_themeMode == mode) {
|
||||
return;
|
||||
}
|
||||
_themeMode = mode;
|
||||
await _store.saveThemeMode(mode);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleAppLanguage() async {
|
||||
final next = _settings.appLanguage == AppLanguage.zh
|
||||
? AppLanguage.en
|
||||
: AppLanguage.zh;
|
||||
_settings = _settings.copyWith(appLanguage: next);
|
||||
await _persistSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> createConversation({AssistantExecutionTarget? target}) async {
|
||||
final resolvedTarget =
|
||||
_sanitizeTarget(target) ?? _settings.assistantExecutionTarget;
|
||||
final record = _newRecord(target: resolvedTarget);
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
_currentSessionKey = record.sessionKey;
|
||||
_lastAssistantError = null;
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> switchConversation(String sessionKey) async {
|
||||
if (!_threadRecords.containsKey(sessionKey)) {
|
||||
return;
|
||||
}
|
||||
_currentSessionKey = sessionKey;
|
||||
_lastAssistantError = null;
|
||||
notifyListeners();
|
||||
final record = _threadRecords[sessionKey]!;
|
||||
if (_sanitizeTarget(record.executionTarget) ==
|
||||
AssistantExecutionTarget.remote &&
|
||||
connection.status == RuntimeConnectionStatus.connected) {
|
||||
await refreshRelayHistory(sessionKey: sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) async {
|
||||
final resolvedTarget =
|
||||
_sanitizeTarget(target) ?? AssistantExecutionTarget.aiGatewayOnly;
|
||||
_settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget);
|
||||
_replaceCurrentRecord(
|
||||
_currentRecord.copyWith(executionTarget: resolvedTarget),
|
||||
);
|
||||
await _persistSettings();
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> saveAiGatewayConfiguration({
|
||||
required String name,
|
||||
required String baseUrl,
|
||||
required String provider,
|
||||
required String apiKey,
|
||||
required String defaultModel,
|
||||
}) async {
|
||||
final normalizedBaseUrl = _aiGatewayClient.normalizeBaseUrl(baseUrl);
|
||||
_settings = _settings.copyWith(
|
||||
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
|
||||
defaultModel: defaultModel.trim(),
|
||||
aiGateway: _settings.aiGateway.copyWith(
|
||||
name: name.trim().isEmpty ? 'Direct AI' : name.trim(),
|
||||
baseUrl: normalizedBaseUrl?.toString() ?? baseUrl.trim(),
|
||||
),
|
||||
);
|
||||
_aiGatewayApiKeyCache = apiKey.trim();
|
||||
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
|
||||
await _persistSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<AiGatewayConnectionCheck> testAiGatewayConnection({
|
||||
required String baseUrl,
|
||||
required String apiKey,
|
||||
}) async {
|
||||
_aiGatewayBusy = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
return await _aiGatewayClient.testConnection(
|
||||
baseUrl: baseUrl,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
} finally {
|
||||
_aiGatewayBusy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncAiGatewayModels({
|
||||
required String name,
|
||||
required String baseUrl,
|
||||
required String provider,
|
||||
required String apiKey,
|
||||
}) async {
|
||||
_aiGatewayBusy = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final models = await _aiGatewayClient.loadModels(
|
||||
baseUrl: baseUrl,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
final availableModels = models
|
||||
.map((item) => item.id)
|
||||
.toList(growable: false);
|
||||
final selectedModels = availableModels.take(5).toList(growable: false);
|
||||
final resolvedDefaultModel =
|
||||
_settings.defaultModel.trim().isNotEmpty &&
|
||||
availableModels.contains(_settings.defaultModel.trim())
|
||||
? _settings.defaultModel.trim()
|
||||
: selectedModels.isNotEmpty
|
||||
? selectedModels.first
|
||||
: '';
|
||||
_settings = _settings.copyWith(
|
||||
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
|
||||
defaultModel: resolvedDefaultModel,
|
||||
aiGateway: _settings.aiGateway.copyWith(
|
||||
name: name.trim().isEmpty ? 'Direct AI' : name.trim(),
|
||||
baseUrl:
|
||||
_aiGatewayClient.normalizeBaseUrl(baseUrl)?.toString() ??
|
||||
baseUrl.trim(),
|
||||
availableModels: availableModels,
|
||||
selectedModels: selectedModels,
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Loaded ${availableModels.length} model(s)',
|
||||
),
|
||||
);
|
||||
_aiGatewayApiKeyCache = apiKey.trim();
|
||||
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
|
||||
await _persistSettings();
|
||||
} catch (error) {
|
||||
_settings = _settings.copyWith(
|
||||
aiGateway: _settings.aiGateway.copyWith(
|
||||
syncState: 'error',
|
||||
syncMessage: _aiGatewayClient.networkErrorLabel(error),
|
||||
),
|
||||
);
|
||||
await _persistSettings();
|
||||
rethrow;
|
||||
} finally {
|
||||
_aiGatewayBusy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRelayConfiguration({
|
||||
required String host,
|
||||
required int port,
|
||||
required bool tls,
|
||||
required String token,
|
||||
required String password,
|
||||
}) async {
|
||||
_settings = _settings.copyWith(
|
||||
gateway: _settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
useSetupCode: false,
|
||||
host: host.trim(),
|
||||
port: port,
|
||||
tls: tls,
|
||||
),
|
||||
);
|
||||
_relayTokenCache = token.trim();
|
||||
_relayPasswordCache = password.trim();
|
||||
await _store.saveRelayToken(_relayTokenCache);
|
||||
await _store.saveRelayPassword(_relayPasswordCache);
|
||||
await _persistSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> connectRelay() async {
|
||||
_relayBusy = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _relayClient.connect(
|
||||
profile: _settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
useSetupCode: false,
|
||||
),
|
||||
authToken: _relayTokenCache,
|
||||
authPassword: _relayPasswordCache,
|
||||
);
|
||||
await refreshRelaySessions();
|
||||
await refreshRelayModels();
|
||||
if (_sanitizeTarget(_currentRecord.executionTarget) ==
|
||||
AssistantExecutionTarget.remote) {
|
||||
await refreshRelayHistory(sessionKey: _currentSessionKey);
|
||||
}
|
||||
} finally {
|
||||
_relayBusy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnectRelay() async {
|
||||
_relayBusy = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _relayClient.disconnect();
|
||||
} finally {
|
||||
_relayBusy = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshRelaySessions() async {
|
||||
if (connection.status != RuntimeConnectionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
final sessions = await _relayClient.listSessions(limit: 50);
|
||||
for (final session in sessions) {
|
||||
final existing = _threadRecords[session.key];
|
||||
final next = AssistantThreadRecord(
|
||||
sessionKey: session.key,
|
||||
messages: existing?.messages ?? const <GatewayChatMessage>[],
|
||||
updatedAtMs:
|
||||
session.updatedAtMs ??
|
||||
existing?.updatedAtMs ??
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
title: (session.derivedTitle ?? session.displayName ?? session.key)
|
||||
.trim(),
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
messageViewMode:
|
||||
existing?.messageViewMode ?? AssistantMessageViewMode.rendered,
|
||||
);
|
||||
_threadRecords[session.key] = next;
|
||||
}
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshRelayModels() async {
|
||||
if (connection.status != RuntimeConnectionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
final models = await _relayClient.listModels();
|
||||
final availableModels = models
|
||||
.map((item) => item.id.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (availableModels.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final defaultModel = _settings.defaultModel.trim().isNotEmpty
|
||||
? _settings.defaultModel.trim()
|
||||
: availableModels.first;
|
||||
_settings = _settings.copyWith(
|
||||
defaultModel: defaultModel,
|
||||
aiGateway: _settings.aiGateway.copyWith(
|
||||
availableModels: _settings.aiGateway.availableModels.isEmpty
|
||||
? availableModels
|
||||
: _settings.aiGateway.availableModels,
|
||||
),
|
||||
);
|
||||
await _persistSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshRelayHistory({String? sessionKey}) async {
|
||||
final resolvedKey = (sessionKey ?? _currentSessionKey).trim();
|
||||
if (resolvedKey.isEmpty ||
|
||||
connection.status != RuntimeConnectionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
final messages = await _relayClient.loadHistory(resolvedKey, limit: 120);
|
||||
final existing = _threadRecords[resolvedKey];
|
||||
final next =
|
||||
(existing ?? _newRecord(target: AssistantExecutionTarget.remote))
|
||||
.copyWith(
|
||||
sessionKey: resolvedKey,
|
||||
messages: messages,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
title: _deriveThreadTitle(
|
||||
existing?.title ?? '',
|
||||
messages,
|
||||
fallback: resolvedKey,
|
||||
),
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
);
|
||||
_threadRecords[resolvedKey] = next;
|
||||
_streamingTextBySession.remove(resolvedKey);
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String rawMessage) async {
|
||||
final trimmed = rawMessage.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_lastAssistantError = null;
|
||||
final target = assistantExecutionTarget;
|
||||
final current = _currentRecord;
|
||||
final updatedMessages = <GatewayChatMessage>[
|
||||
...current.messages,
|
||||
GatewayChatMessage(
|
||||
id: _messageId(),
|
||||
role: 'user',
|
||||
text: trimmed,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
];
|
||||
_replaceCurrentRecord(
|
||||
current.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
title: _deriveThreadTitle(current.title, updatedMessages),
|
||||
executionTarget: target,
|
||||
),
|
||||
);
|
||||
_pendingSessionKeys.add(_currentSessionKey);
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
if (!canUseAiGatewayConversation) {
|
||||
throw Exception(
|
||||
appText(
|
||||
'请先在 Settings 配置 Direct AI 的地址、API Key 和默认模型。',
|
||||
'Configure Direct AI endpoint, API key, and default model first.',
|
||||
),
|
||||
);
|
||||
}
|
||||
final reply = await _aiGatewayClient.completeChat(
|
||||
baseUrl: _settings.aiGateway.baseUrl,
|
||||
apiKey: _aiGatewayApiKeyCache,
|
||||
model: resolvedAiGatewayModel,
|
||||
history: updatedMessages,
|
||||
);
|
||||
_appendAssistantMessage(
|
||||
sessionKey: _currentSessionKey,
|
||||
text: reply,
|
||||
error: false,
|
||||
);
|
||||
} else {
|
||||
if (connection.status != RuntimeConnectionStatus.connected) {
|
||||
throw Exception(
|
||||
appText(
|
||||
'Relay OpenClaw Gateway 尚未连接。',
|
||||
'Relay OpenClaw Gateway is not connected.',
|
||||
),
|
||||
);
|
||||
}
|
||||
await _relayClient.sendChat(
|
||||
sessionKey: _currentSessionKey,
|
||||
message: trimmed,
|
||||
thinking: 'medium',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
_appendAssistantMessage(
|
||||
sessionKey: _currentSessionKey,
|
||||
text: error.toString(),
|
||||
error: true,
|
||||
);
|
||||
_lastAssistantError = error.toString();
|
||||
_pendingSessionKeys.remove(_currentSessionKey);
|
||||
_streamingTextBySession.remove(_currentSessionKey);
|
||||
await _persistThreads();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectDirectModel(String model) async {
|
||||
final trimmed = model.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_settings = _settings.copyWith(defaultModel: trimmed);
|
||||
await _persistSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_relayEventsSubscription.cancel());
|
||||
unawaited(_relayClient.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
|
||||
return switch (tab) {
|
||||
SettingsTab.workspace ||
|
||||
SettingsTab.agents ||
|
||||
SettingsTab.diagnostics ||
|
||||
SettingsTab.experimental => SettingsTab.gateway,
|
||||
_ => tab,
|
||||
};
|
||||
}
|
||||
|
||||
SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) {
|
||||
final target =
|
||||
_sanitizeTarget(snapshot.assistantExecutionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
return snapshot.copyWith(
|
||||
assistantExecutionTarget: target,
|
||||
gateway: snapshot.gateway.copyWith(
|
||||
mode: target == AssistantExecutionTarget.remote
|
||||
? RuntimeConnectionMode.remote
|
||||
: RuntimeConnectionMode.unconfigured,
|
||||
useSetupCode: false,
|
||||
),
|
||||
assistantNavigationDestinations: const <WorkspaceDestination>[],
|
||||
);
|
||||
}
|
||||
|
||||
AssistantThreadRecord _sanitizeRecord(AssistantThreadRecord record) {
|
||||
final target =
|
||||
_sanitizeTarget(record.executionTarget) ??
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
return record.copyWith(
|
||||
executionTarget: target,
|
||||
title: record.title.trim().isEmpty
|
||||
? appText('新对话', 'New conversation')
|
||||
: record.title.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.remote => AssistantExecutionTarget.remote,
|
||||
AssistantExecutionTarget.aiGatewayOnly =>
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
_ => AssistantExecutionTarget.aiGatewayOnly,
|
||||
};
|
||||
}
|
||||
|
||||
AssistantThreadRecord _newRecord({
|
||||
required AssistantExecutionTarget target,
|
||||
String? title,
|
||||
}) {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final prefix = target == AssistantExecutionTarget.remote
|
||||
? 'relay'
|
||||
: 'direct';
|
||||
return AssistantThreadRecord(
|
||||
sessionKey: '$prefix:$timestamp',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
updatedAtMs: timestamp.toDouble(),
|
||||
title: title ?? appText('新对话', 'New conversation'),
|
||||
archived: false,
|
||||
executionTarget: target,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
);
|
||||
}
|
||||
|
||||
void _replaceCurrentRecord(AssistantThreadRecord record) {
|
||||
_threadRecords[record.sessionKey] = record;
|
||||
_currentSessionKey = record.sessionKey;
|
||||
}
|
||||
|
||||
void _appendAssistantMessage({
|
||||
required String sessionKey,
|
||||
required String text,
|
||||
required bool error,
|
||||
}) {
|
||||
final existing =
|
||||
_threadRecords[sessionKey] ??
|
||||
_newRecord(target: assistantExecutionTarget);
|
||||
final messages = <GatewayChatMessage>[
|
||||
...existing.messages,
|
||||
GatewayChatMessage(
|
||||
id: _messageId(),
|
||||
role: 'assistant',
|
||||
text: text,
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: error ? 'error' : null,
|
||||
pending: false,
|
||||
error: error,
|
||||
),
|
||||
];
|
||||
_threadRecords[sessionKey] = existing.copyWith(
|
||||
messages: messages,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
title: _deriveThreadTitle(existing.title, messages, fallback: sessionKey),
|
||||
);
|
||||
_pendingSessionKeys.remove(sessionKey);
|
||||
_streamingTextBySession.remove(sessionKey);
|
||||
}
|
||||
|
||||
void _handleRelayEvent(GatewayPushEvent event) {
|
||||
if (event.event != 'chat') {
|
||||
return;
|
||||
}
|
||||
final payload = _castMap(event.payload);
|
||||
final sessionKey = (payload['sessionKey']?.toString().trim() ?? '').trim();
|
||||
if (sessionKey.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final state = payload['state']?.toString().trim() ?? '';
|
||||
final message = _castMap(payload['message']);
|
||||
final text = _extractMessageText(message);
|
||||
if (text.isNotEmpty && (state == 'delta' || state == 'final')) {
|
||||
_streamingTextBySession[sessionKey] = text;
|
||||
}
|
||||
if (state == 'final' || state == 'aborted' || state == 'error') {
|
||||
_pendingSessionKeys.remove(sessionKey);
|
||||
unawaited(refreshRelaySessions());
|
||||
unawaited(refreshRelayHistory(sessionKey: sessionKey));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _persistSettings() async {
|
||||
await _store.saveSettingsSnapshot(_settings);
|
||||
}
|
||||
|
||||
Future<void> _persistThreads() async {
|
||||
await _store.saveAssistantThreadRecords(
|
||||
_threadRecords.values.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
String _titleForRecord(AssistantThreadRecord record) {
|
||||
final title = record.title.trim();
|
||||
if (title.isNotEmpty) {
|
||||
return title;
|
||||
}
|
||||
return _deriveThreadTitle('', record.messages, fallback: record.sessionKey);
|
||||
}
|
||||
|
||||
String _previewForRecord(AssistantThreadRecord record) {
|
||||
for (final message in record.messages.reversed) {
|
||||
final text = message.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return appText(
|
||||
'等待描述这个任务的第一条消息',
|
||||
'Waiting for the first message of this task',
|
||||
);
|
||||
}
|
||||
|
||||
String _deriveThreadTitle(
|
||||
String currentTitle,
|
||||
List<GatewayChatMessage> messages, {
|
||||
String fallback = '',
|
||||
}) {
|
||||
final trimmedCurrent = currentTitle.trim();
|
||||
if (trimmedCurrent.isNotEmpty &&
|
||||
trimmedCurrent != appText('新对话', 'New conversation')) {
|
||||
return trimmedCurrent;
|
||||
}
|
||||
for (final message in messages) {
|
||||
if (message.role.trim().toLowerCase() != 'user') {
|
||||
continue;
|
||||
}
|
||||
final text = message.text.trim();
|
||||
if (text.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
return text.length <= 32 ? text : '${text.substring(0, 32)}...';
|
||||
}
|
||||
return fallback.isEmpty ? appText('新对话', 'New conversation') : fallback;
|
||||
}
|
||||
|
||||
String _hostLabel(String rawUrl) {
|
||||
final normalized = _aiGatewayClient.normalizeBaseUrl(rawUrl);
|
||||
return normalized?.host.trim() ?? '';
|
||||
}
|
||||
|
||||
String _messageId() {
|
||||
return DateTime.now().microsecondsSinceEpoch.toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _castMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
String _extractMessageText(Map<String, dynamic> message) {
|
||||
final directContent = message['content'];
|
||||
if (directContent is String) {
|
||||
return directContent;
|
||||
}
|
||||
final parts = <String>[];
|
||||
if (directContent is List) {
|
||||
for (final part in directContent) {
|
||||
final map = _castMap(part);
|
||||
final text = map['text']?.toString().trim();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
parts.add(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
}
|
||||
|
||||
class WebConversationSummary {
|
||||
const WebConversationSummary({
|
||||
required this.sessionKey,
|
||||
required this.title,
|
||||
required this.preview,
|
||||
required this.updatedAtMs,
|
||||
required this.executionTarget,
|
||||
required this.pending,
|
||||
required this.current,
|
||||
});
|
||||
|
||||
final String sessionKey;
|
||||
final String title;
|
||||
final String preview;
|
||||
final double updatedAtMs;
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final bool pending;
|
||||
final bool current;
|
||||
}
|
||||
@ -1,463 +1 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/account/account_page.dart';
|
||||
import '../features/mobile/mobile_shell.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/detail_drawer.dart';
|
||||
import '../widgets/pane_resize_handle.dart';
|
||||
import '../widgets/sidebar_navigation.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'workspace_page_registry.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
static const _sidebarMinWidth = 56.0;
|
||||
static const _sidebarViewportPadding = 72.0;
|
||||
static const _mainContentMinWidth = 640.0;
|
||||
double? _sidebarExpandedWidth;
|
||||
|
||||
static const _mobileDestinations = [
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.secrets,
|
||||
WorkspaceDestination.settings,
|
||||
];
|
||||
|
||||
double _clampSidebarWidth(double value, double viewportWidth) {
|
||||
final responsiveMax =
|
||||
(viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp(
|
||||
_sidebarMinWidth,
|
||||
viewportWidth - _sidebarViewportPadding,
|
||||
);
|
||||
return value.clamp(_sidebarMinWidth, responsiveMax).toDouble();
|
||||
}
|
||||
|
||||
double _defaultSidebarWidth(AppLanguage language, double viewportWidth) {
|
||||
final baseWidth = language == AppLanguage.zh
|
||||
? AppSizes.sidebarExpandedWidthZh
|
||||
: AppSizes.sidebarExpandedWidthEn;
|
||||
return _clampSidebarWidth(baseWidth, viewportWidth);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final controller = widget.controller;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final palette = context.palette;
|
||||
final platform = Theme.of(context).platform;
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final isCompactMobile =
|
||||
(platform == TargetPlatform.iOS ||
|
||||
platform == TargetPlatform.android) &&
|
||||
constraints.maxWidth < 900;
|
||||
final isMobile = constraints.maxWidth < 900;
|
||||
final sidebarState = controller.sidebarState;
|
||||
final showSidebar = sidebarState != AppSidebarState.hidden;
|
||||
final embedSidebarIntoAssistant =
|
||||
controller.destination == WorkspaceDestination.assistant &&
|
||||
showSidebar;
|
||||
final expandedSidebarWidth = _clampSidebarWidth(
|
||||
_sidebarExpandedWidth ??
|
||||
_defaultSidebarWidth(
|
||||
controller.appLanguage,
|
||||
constraints.maxWidth,
|
||||
),
|
||||
constraints.maxWidth,
|
||||
);
|
||||
final showPinnedDetail =
|
||||
controller.detailPanel != null &&
|
||||
constraints.maxWidth > 1280;
|
||||
final mobileDestination =
|
||||
controller.destination == WorkspaceDestination.account
|
||||
? WorkspaceDestination.assistant
|
||||
: controller.destination;
|
||||
|
||||
void openMobileDetail(DetailPanelData detail) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: DetailSheet(
|
||||
data: detail,
|
||||
onClose: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void openAccountSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return Container(
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
12,
|
||||
MediaQuery.of(sheetContext).padding.top + 12,
|
||||
12,
|
||||
12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: AccountPage(controller: controller),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompactMobile) {
|
||||
return MobileShell(controller: controller);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
color: palette.canvas.withValues(alpha: 0.18),
|
||||
child: _pageForDestination(
|
||||
mobileDestination,
|
||||
openMobileDetail,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _mobileDestinations.indexOf(
|
||||
mobileDestination,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
controller.navigateTo(
|
||||
_mobileDestinations[index],
|
||||
);
|
||||
},
|
||||
destinations: _mobileDestinations
|
||||
.map(
|
||||
(destination) => NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
label: destination.label,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 96,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: openAccountSheet,
|
||||
child: const Icon(Icons.account_circle_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (showSidebar && !embedSidebarIntoAssistant)
|
||||
SidebarNavigation(
|
||||
currentSection: controller.destination,
|
||||
sidebarState: sidebarState,
|
||||
appLanguage: controller.appLanguage,
|
||||
themeMode: controller.themeMode,
|
||||
onSectionChanged: controller.navigateTo,
|
||||
onToggleLanguage: controller.toggleAppLanguage,
|
||||
onCycleSidebarState: controller.cycleSidebarState,
|
||||
onExpandFromCollapsed: () => controller
|
||||
.setSidebarState(AppSidebarState.expanded),
|
||||
onOpenAccount: () => controller.navigateTo(
|
||||
WorkspaceDestination.account,
|
||||
),
|
||||
onOpenThemeToggle: () => controller.setThemeMode(
|
||||
controller.themeMode == ThemeMode.dark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
),
|
||||
accountName:
|
||||
controller.settings.accountUsername
|
||||
.trim()
|
||||
.isEmpty
|
||||
? appText('本地操作员', 'Local Operator')
|
||||
: controller.settings.accountUsername,
|
||||
accountSubtitle:
|
||||
controller.settings.accountWorkspace
|
||||
.trim()
|
||||
.isEmpty
|
||||
? appText('账号', 'Account')
|
||||
: controller.settings.accountWorkspace,
|
||||
onOpenOnlineWorkspace:
|
||||
controller.openOnlineWorkspace,
|
||||
expandedWidthOverride:
|
||||
sidebarState == AppSidebarState.expanded
|
||||
? expandedSidebarWidth
|
||||
: null,
|
||||
favoriteDestinations: controller
|
||||
.assistantNavigationDestinations
|
||||
.toSet(),
|
||||
onToggleFavorite:
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
PaneResizeHandle(
|
||||
axis: Axis.horizontal,
|
||||
onDelta: (delta) {
|
||||
setState(() {
|
||||
_sidebarExpandedWidth = _clampSidebarWidth(
|
||||
expandedSidebarWidth + delta,
|
||||
constraints.maxWidth,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4, right: 4),
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(
|
||||
right: showPinnedDetail ? 336 : 0,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeBackground,
|
||||
palette.canvas,
|
||||
],
|
||||
stops: const [0.0, 0.68],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -180,
|
||||
right: -80,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 420,
|
||||
height: 420,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
palette.chromeHighlight
|
||||
.withValues(
|
||||
alpha:
|
||||
brightness ==
|
||||
Brightness.dark
|
||||
? 0.14
|
||||
: 0.42,
|
||||
),
|
||||
palette.chromeHighlight
|
||||
.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -220,
|
||||
left: -140,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 360,
|
||||
height: 360,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
palette.chromeInset.withValues(
|
||||
alpha:
|
||||
brightness ==
|
||||
Brightness.dark
|
||||
? 0.14
|
||||
: 0.24,
|
||||
),
|
||||
palette.chromeInset.withValues(
|
||||
alpha: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildCurrentPage(controller.openDetail),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (controller.detailPanel != null && !showPinnedDetail)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: controller.closeDetail,
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.detailPanel != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: DetailDrawer(
|
||||
data: controller.detailPanel!,
|
||||
onClose: controller.closeDetail,
|
||||
),
|
||||
),
|
||||
if (!showSidebar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 8,
|
||||
bottom: 0,
|
||||
child: _SidebarRevealRail(
|
||||
onExpand: () => controller.setSidebarState(
|
||||
AppSidebarState.expanded,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(ValueChanged<DetailPanelData> onOpenDetail) {
|
||||
return IndexedStack(
|
||||
index: widget.controller.destination.index,
|
||||
children: WorkspaceDestination.values
|
||||
.map((destination) => _pageForDestination(destination, onOpenDetail))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pageForDestination(
|
||||
WorkspaceDestination destination,
|
||||
ValueChanged<DetailPanelData> onOpenDetail,
|
||||
) {
|
||||
return buildWorkspacePage(
|
||||
destination: destination,
|
||||
controller: widget.controller,
|
||||
onOpenDetail: onOpenDetail,
|
||||
surface: WorkspacePageSurface.desktop,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarRevealRail extends StatefulWidget {
|
||||
const _SidebarRevealRail({required this.onExpand});
|
||||
|
||||
final VoidCallback onExpand;
|
||||
|
||||
@override
|
||||
State<_SidebarRevealRail> createState() => _SidebarRevealRailState();
|
||||
}
|
||||
|
||||
class _SidebarRevealRailState extends State<_SidebarRevealRail> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Tooltip(
|
||||
message: appText('展开导航', 'Expand sidebar'),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onExpand,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: _hovered ? 22 : 10,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _hovered
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeHighlight.withValues(alpha: 0.92),
|
||||
palette.chromeSurface,
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: _hovered ? null : Colors.transparent,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(14),
|
||||
),
|
||||
border: Border.all(
|
||||
color: _hovered ? palette.chromeStroke : Colors.transparent,
|
||||
),
|
||||
boxShadow: _hovered ? [palette.chromeShadowLift] : const [],
|
||||
),
|
||||
child: _hovered
|
||||
? Icon(
|
||||
Icons.keyboard_double_arrow_right_rounded,
|
||||
size: 16,
|
||||
color: palette.textMuted,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'app_shell_desktop.dart' if (dart.library.html) 'app_shell_web.dart';
|
||||
|
||||
463
lib/app/app_shell_desktop.dart
Normal file
463
lib/app/app_shell_desktop.dart
Normal file
@ -0,0 +1,463 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/account/account_page.dart';
|
||||
import '../features/mobile/mobile_shell.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/detail_drawer.dart';
|
||||
import '../widgets/pane_resize_handle.dart';
|
||||
import '../widgets/sidebar_navigation.dart';
|
||||
import 'app_controller.dart';
|
||||
import 'workspace_page_registry.dart';
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
static const _sidebarMinWidth = 56.0;
|
||||
static const _sidebarViewportPadding = 72.0;
|
||||
static const _mainContentMinWidth = 640.0;
|
||||
double? _sidebarExpandedWidth;
|
||||
|
||||
static const _mobileDestinations = [
|
||||
WorkspaceDestination.assistant,
|
||||
WorkspaceDestination.tasks,
|
||||
WorkspaceDestination.skills,
|
||||
WorkspaceDestination.secrets,
|
||||
WorkspaceDestination.settings,
|
||||
];
|
||||
|
||||
double _clampSidebarWidth(double value, double viewportWidth) {
|
||||
final responsiveMax =
|
||||
(viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp(
|
||||
_sidebarMinWidth,
|
||||
viewportWidth - _sidebarViewportPadding,
|
||||
);
|
||||
return value.clamp(_sidebarMinWidth, responsiveMax).toDouble();
|
||||
}
|
||||
|
||||
double _defaultSidebarWidth(AppLanguage language, double viewportWidth) {
|
||||
final baseWidth = language == AppLanguage.zh
|
||||
? AppSizes.sidebarExpandedWidthZh
|
||||
: AppSizes.sidebarExpandedWidthEn;
|
||||
return _clampSidebarWidth(baseWidth, viewportWidth);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: widget.controller,
|
||||
builder: (context, _) {
|
||||
final controller = widget.controller;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final palette = context.palette;
|
||||
final platform = Theme.of(context).platform;
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final isCompactMobile =
|
||||
(platform == TargetPlatform.iOS ||
|
||||
platform == TargetPlatform.android) &&
|
||||
constraints.maxWidth < 900;
|
||||
final isMobile = constraints.maxWidth < 900;
|
||||
final sidebarState = controller.sidebarState;
|
||||
final showSidebar = sidebarState != AppSidebarState.hidden;
|
||||
final embedSidebarIntoAssistant =
|
||||
controller.destination == WorkspaceDestination.assistant &&
|
||||
showSidebar;
|
||||
final expandedSidebarWidth = _clampSidebarWidth(
|
||||
_sidebarExpandedWidth ??
|
||||
_defaultSidebarWidth(
|
||||
controller.appLanguage,
|
||||
constraints.maxWidth,
|
||||
),
|
||||
constraints.maxWidth,
|
||||
);
|
||||
final showPinnedDetail =
|
||||
controller.detailPanel != null &&
|
||||
constraints.maxWidth > 1280;
|
||||
final mobileDestination =
|
||||
controller.destination == WorkspaceDestination.account
|
||||
? WorkspaceDestination.assistant
|
||||
: controller.destination;
|
||||
|
||||
void openMobileDetail(DetailPanelData detail) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: DetailSheet(
|
||||
data: detail,
|
||||
onClose: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void openAccountSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return Container(
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
12,
|
||||
MediaQuery.of(sheetContext).padding.top + 12,
|
||||
12,
|
||||
12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: AccountPage(controller: controller),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompactMobile) {
|
||||
return MobileShell(controller: controller);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
color: palette.canvas.withValues(alpha: 0.18),
|
||||
child: _pageForDestination(
|
||||
mobileDestination,
|
||||
openMobileDetail,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _mobileDestinations.indexOf(
|
||||
mobileDestination,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
controller.navigateTo(
|
||||
_mobileDestinations[index],
|
||||
);
|
||||
},
|
||||
destinations: _mobileDestinations
|
||||
.map(
|
||||
(destination) => NavigationDestination(
|
||||
icon: Icon(destination.icon),
|
||||
label: destination.label,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 24,
|
||||
bottom: 96,
|
||||
child: FloatingActionButton.small(
|
||||
onPressed: openAccountSheet,
|
||||
child: const Icon(Icons.account_circle_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (showSidebar && !embedSidebarIntoAssistant)
|
||||
SidebarNavigation(
|
||||
currentSection: controller.destination,
|
||||
sidebarState: sidebarState,
|
||||
appLanguage: controller.appLanguage,
|
||||
themeMode: controller.themeMode,
|
||||
onSectionChanged: controller.navigateTo,
|
||||
onToggleLanguage: controller.toggleAppLanguage,
|
||||
onCycleSidebarState: controller.cycleSidebarState,
|
||||
onExpandFromCollapsed: () => controller
|
||||
.setSidebarState(AppSidebarState.expanded),
|
||||
onOpenAccount: () => controller.navigateTo(
|
||||
WorkspaceDestination.account,
|
||||
),
|
||||
onOpenThemeToggle: () => controller.setThemeMode(
|
||||
controller.themeMode == ThemeMode.dark
|
||||
? ThemeMode.light
|
||||
: ThemeMode.dark,
|
||||
),
|
||||
accountName:
|
||||
controller.settings.accountUsername
|
||||
.trim()
|
||||
.isEmpty
|
||||
? appText('本地操作员', 'Local Operator')
|
||||
: controller.settings.accountUsername,
|
||||
accountSubtitle:
|
||||
controller.settings.accountWorkspace
|
||||
.trim()
|
||||
.isEmpty
|
||||
? appText('账号', 'Account')
|
||||
: controller.settings.accountWorkspace,
|
||||
onOpenOnlineWorkspace:
|
||||
controller.openOnlineWorkspace,
|
||||
expandedWidthOverride:
|
||||
sidebarState == AppSidebarState.expanded
|
||||
? expandedSidebarWidth
|
||||
: null,
|
||||
favoriteDestinations: controller
|
||||
.assistantNavigationDestinations
|
||||
.toSet(),
|
||||
onToggleFavorite:
|
||||
controller.toggleAssistantNavigationDestination,
|
||||
),
|
||||
if (sidebarState == AppSidebarState.expanded &&
|
||||
!embedSidebarIntoAssistant)
|
||||
PaneResizeHandle(
|
||||
axis: Axis.horizontal,
|
||||
onDelta: (delta) {
|
||||
setState(() {
|
||||
_sidebarExpandedWidth = _clampSidebarWidth(
|
||||
expandedSidebarWidth + delta,
|
||||
constraints.maxWidth,
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4, right: 4),
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(
|
||||
right: showPinnedDetail ? 336 : 0,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeBackground,
|
||||
palette.canvas,
|
||||
],
|
||||
stops: const [0.0, 0.68],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -180,
|
||||
right: -80,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 420,
|
||||
height: 420,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
palette.chromeHighlight
|
||||
.withValues(
|
||||
alpha:
|
||||
brightness ==
|
||||
Brightness.dark
|
||||
? 0.14
|
||||
: 0.42,
|
||||
),
|
||||
palette.chromeHighlight
|
||||
.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -220,
|
||||
left: -140,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 360,
|
||||
height: 360,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
palette.chromeInset.withValues(
|
||||
alpha:
|
||||
brightness ==
|
||||
Brightness.dark
|
||||
? 0.14
|
||||
: 0.24,
|
||||
),
|
||||
palette.chromeInset.withValues(
|
||||
alpha: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildCurrentPage(controller.openDetail),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (controller.detailPanel != null && !showPinnedDetail)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: controller.closeDetail,
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.detailPanel != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: DetailDrawer(
|
||||
data: controller.detailPanel!,
|
||||
onClose: controller.closeDetail,
|
||||
),
|
||||
),
|
||||
if (!showSidebar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 8,
|
||||
bottom: 0,
|
||||
child: _SidebarRevealRail(
|
||||
onExpand: () => controller.setSidebarState(
|
||||
AppSidebarState.expanded,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(ValueChanged<DetailPanelData> onOpenDetail) {
|
||||
return IndexedStack(
|
||||
index: widget.controller.destination.index,
|
||||
children: WorkspaceDestination.values
|
||||
.map((destination) => _pageForDestination(destination, onOpenDetail))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pageForDestination(
|
||||
WorkspaceDestination destination,
|
||||
ValueChanged<DetailPanelData> onOpenDetail,
|
||||
) {
|
||||
return buildWorkspacePage(
|
||||
destination: destination,
|
||||
controller: widget.controller,
|
||||
onOpenDetail: onOpenDetail,
|
||||
surface: WorkspacePageSurface.desktop,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarRevealRail extends StatefulWidget {
|
||||
const _SidebarRevealRail({required this.onExpand});
|
||||
|
||||
final VoidCallback onExpand;
|
||||
|
||||
@override
|
||||
State<_SidebarRevealRail> createState() => _SidebarRevealRailState();
|
||||
}
|
||||
|
||||
class _SidebarRevealRailState extends State<_SidebarRevealRail> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Tooltip(
|
||||
message: appText('展开导航', 'Expand sidebar'),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onExpand,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: _hovered ? 22 : 10,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _hovered
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeHighlight.withValues(alpha: 0.92),
|
||||
palette.chromeSurface,
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: _hovered ? null : Colors.transparent,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
right: Radius.circular(14),
|
||||
),
|
||||
border: Border.all(
|
||||
color: _hovered ? palette.chromeStroke : Colors.transparent,
|
||||
),
|
||||
boxShadow: _hovered ? [palette.chromeShadowLift] : const [],
|
||||
),
|
||||
child: _hovered
|
||||
? Icon(
|
||||
Icons.keyboard_double_arrow_right_rounded,
|
||||
size: 16,
|
||||
color: palette.textMuted,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
258
lib/app/app_shell_web.dart
Normal file
258
lib/app/app_shell_web.dart
Normal file
@ -0,0 +1,258 @@
|
||||
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 'app_controller_web.dart';
|
||||
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final mobile = constraints.maxWidth < 900;
|
||||
if (mobile) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: _buildPage(controller)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: NavigationBar(
|
||||
selectedIndex:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings
|
||||
? 1
|
||||
: 0,
|
||||
onDestinationSelected: (index) {
|
||||
controller.navigateTo(
|
||||
index == 0
|
||||
? WorkspaceDestination.assistant
|
||||
: WorkspaceDestination.settings,
|
||||
);
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline_rounded),
|
||||
label: 'Assistant',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.tune_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final palette = context.palette;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings
|
||||
? 248
|
||||
: 236,
|
||||
margin: const EdgeInsets.fromLTRB(4, 4, 4, 0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
palette.chromeHighlight.withValues(alpha: 0.9),
|
||||
palette.chromeSurface.withValues(alpha: 0.92),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.sidebar),
|
||||
border: Border.all(color: palette.chromeStroke),
|
||||
boxShadow: [palette.chromeShadowAmbient],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: palette.accentMuted,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.crop_square_rounded,
|
||||
color: palette.accent,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_WebNavItem(
|
||||
destination: WorkspaceDestination.assistant,
|
||||
selected:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.assistant,
|
||||
onTap: () => controller.navigateTo(
|
||||
WorkspaceDestination.assistant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_WebNavItem(
|
||||
destination: WorkspaceDestination.settings,
|
||||
selected:
|
||||
controller.destination ==
|
||||
WorkspaceDestination.settings,
|
||||
onTap: () => controller.navigateTo(
|
||||
WorkspaceDestination.settings,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildPage(controller)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPage(AppController controller) {
|
||||
return switch (controller.destination) {
|
||||
WorkspaceDestination.settings => WebSettingsPage(controller: controller),
|
||||
_ => WebAssistantPage(controller: controller),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _WebNavItem extends StatelessWidget {
|
||||
const _WebNavItem({
|
||||
required this.destination,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final WorkspaceDestination destination;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
376
lib/web/web_ai_gateway_client.dart
Normal file
376
lib/web/web_ai_gateway_client.dart
Normal file
@ -0,0 +1,376 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../runtime/runtime_models.dart';
|
||||
|
||||
class WebAiGatewayClient {
|
||||
const WebAiGatewayClient();
|
||||
|
||||
Uri? normalizeBaseUrl(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
|
||||
final uri = Uri.tryParse(candidate);
|
||||
if (uri == null || uri.host.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty);
|
||||
return uri.replace(
|
||||
pathSegments: pathSegments.isEmpty ? const <String>['v1'] : pathSegments,
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AiGatewayConnectionCheck> testConnection({
|
||||
required String baseUrl,
|
||||
required String apiKey,
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (normalizedBaseUrl == null) {
|
||||
return const AiGatewayConnectionCheck(
|
||||
state: 'invalid',
|
||||
message: 'Missing AI Gateway URL',
|
||||
endpoint: '',
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
final trimmedApiKey = apiKey.trim();
|
||||
final endpoint = _modelsUri(normalizedBaseUrl).toString();
|
||||
if (trimmedApiKey.isEmpty) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'invalid',
|
||||
message: 'Missing AI Gateway API key',
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
try {
|
||||
final models = await loadModels(
|
||||
baseUrl: normalizedBaseUrl.toString(),
|
||||
apiKey: trimmedApiKey,
|
||||
);
|
||||
if (models.isEmpty) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'empty',
|
||||
message: 'Authenticated but no models were returned',
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'ready',
|
||||
message: 'Authenticated · ${models.length} model(s) available',
|
||||
endpoint: endpoint,
|
||||
modelCount: models.length,
|
||||
);
|
||||
} catch (error) {
|
||||
return AiGatewayConnectionCheck(
|
||||
state: 'error',
|
||||
message: networkErrorLabel(error),
|
||||
endpoint: endpoint,
|
||||
modelCount: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<GatewayModelSummary>> loadModels({
|
||||
required String baseUrl,
|
||||
required String apiKey,
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (normalizedBaseUrl == null || apiKey.trim().isEmpty) {
|
||||
return const <GatewayModelSummary>[];
|
||||
}
|
||||
final response = await http.get(
|
||||
_modelsUri(normalizedBaseUrl),
|
||||
headers: _headers(apiKey),
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw WebAiGatewayException(
|
||||
message: _httpErrorLabel(
|
||||
response.statusCode,
|
||||
_extractErrorDetail(response.body),
|
||||
),
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(_extractFirstJsonDocument(response.body));
|
||||
final payload = decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
final rawModels = <Object?>[
|
||||
..._asList(payload['data']),
|
||||
if (_asList(payload['data']).isEmpty) ..._asList(payload['models']),
|
||||
];
|
||||
final seen = <String>{};
|
||||
final items = <GatewayModelSummary>[];
|
||||
for (final item in rawModels) {
|
||||
final map = _asMap(item);
|
||||
final modelId =
|
||||
_stringValue(map['id']) ?? _stringValue(map['name']) ?? '';
|
||||
if (modelId.isEmpty || !seen.add(modelId)) {
|
||||
continue;
|
||||
}
|
||||
items.add(
|
||||
GatewayModelSummary(
|
||||
id: modelId,
|
||||
name: _stringValue(map['name']) ?? modelId,
|
||||
provider:
|
||||
_stringValue(map['provider']) ??
|
||||
_stringValue(map['owned_by']) ??
|
||||
'Direct AI',
|
||||
contextWindow:
|
||||
_intValue(map['contextWindow']) ??
|
||||
_intValue(map['context_window']),
|
||||
maxOutputTokens:
|
||||
_intValue(map['maxOutputTokens']) ??
|
||||
_intValue(map['max_output_tokens']),
|
||||
),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Future<String> completeChat({
|
||||
required String baseUrl,
|
||||
required String apiKey,
|
||||
required String model,
|
||||
required List<GatewayChatMessage> history,
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (normalizedBaseUrl == null) {
|
||||
throw const WebAiGatewayException(message: 'Missing AI Gateway URL');
|
||||
}
|
||||
final response = await http.post(
|
||||
_chatUri(normalizedBaseUrl),
|
||||
headers: <String, String>{
|
||||
..._headers(apiKey),
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'model': model,
|
||||
'stream': false,
|
||||
'messages': history
|
||||
.where((message) {
|
||||
final role = message.role.trim().toLowerCase();
|
||||
return (role == 'user' || role == 'assistant') &&
|
||||
message.text.trim().isNotEmpty;
|
||||
})
|
||||
.map(
|
||||
(message) => <String, String>{
|
||||
'role': message.role.trim().toLowerCase() == 'assistant'
|
||||
? 'assistant'
|
||||
: 'user',
|
||||
'content': message.text.trim(),
|
||||
},
|
||||
)
|
||||
.toList(growable: false),
|
||||
}),
|
||||
);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw WebAiGatewayException(
|
||||
message: _httpErrorLabel(
|
||||
response.statusCode,
|
||||
_extractErrorDetail(response.body),
|
||||
),
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(_extractFirstJsonDocument(response.body));
|
||||
final payload = decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
final choices = _asList(payload['choices']);
|
||||
final firstChoice = choices.isEmpty
|
||||
? const <String, dynamic>{}
|
||||
: _asMap(choices.first);
|
||||
final message = _asMap(firstChoice['message']);
|
||||
final content = _stringValue(message['content']) ?? '';
|
||||
if (content.trim().isNotEmpty) {
|
||||
return content.trim();
|
||||
}
|
||||
final delta = _asMap(firstChoice['delta']);
|
||||
final deltaContent = _stringValue(delta['content']) ?? '';
|
||||
if (deltaContent.trim().isNotEmpty) {
|
||||
return deltaContent.trim();
|
||||
}
|
||||
throw const FormatException('Missing assistant content');
|
||||
}
|
||||
|
||||
String networkErrorLabel(Object error) {
|
||||
if (error is WebAiGatewayException) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Failed: $error';
|
||||
}
|
||||
|
||||
Uri _modelsUri(Uri baseUrl) {
|
||||
final pathSegments = baseUrl.pathSegments
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: true);
|
||||
if (pathSegments.isEmpty) {
|
||||
pathSegments.add('v1');
|
||||
}
|
||||
if (pathSegments.last != 'models') {
|
||||
pathSegments.add('models');
|
||||
}
|
||||
return baseUrl.replace(
|
||||
pathSegments: pathSegments,
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
Uri _chatUri(Uri baseUrl) {
|
||||
final pathSegments = baseUrl.pathSegments
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: true);
|
||||
if (pathSegments.isEmpty) {
|
||||
pathSegments.add('v1');
|
||||
}
|
||||
if (pathSegments.last == 'models') {
|
||||
pathSegments.removeLast();
|
||||
}
|
||||
if (pathSegments.length >= 2 &&
|
||||
pathSegments[pathSegments.length - 2] == 'chat' &&
|
||||
pathSegments.last == 'completions') {
|
||||
return baseUrl.replace(pathSegments: pathSegments);
|
||||
}
|
||||
pathSegments.addAll(const <String>['chat', 'completions']);
|
||||
return baseUrl.replace(
|
||||
pathSegments: pathSegments,
|
||||
query: null,
|
||||
fragment: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> _headers(String apiKey) {
|
||||
final trimmedApiKey = apiKey.trim();
|
||||
return <String, String>{
|
||||
'accept': 'application/json',
|
||||
if (trimmedApiKey.isNotEmpty) 'authorization': 'Bearer $trimmedApiKey',
|
||||
if (trimmedApiKey.isNotEmpty) 'x-api-key': trimmedApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
String _httpErrorLabel(int statusCode, String detail) {
|
||||
final base = switch (statusCode) {
|
||||
400 => 'Bad request (400)',
|
||||
401 => 'Authentication failed (401)',
|
||||
403 => 'Access denied (403)',
|
||||
404 => 'Endpoint not found (404)',
|
||||
429 => 'Rate limited by AI endpoint (429)',
|
||||
>= 500 => 'AI endpoint unavailable ($statusCode)',
|
||||
_ => 'AI endpoint responded $statusCode',
|
||||
};
|
||||
return detail.isEmpty ? base : '$base · $detail';
|
||||
}
|
||||
|
||||
String _extractErrorDetail(String body) {
|
||||
if (body.trim().isEmpty) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(_extractFirstJsonDocument(body));
|
||||
final map = decoded is Map<String, dynamic>
|
||||
? decoded
|
||||
: <String, dynamic>{};
|
||||
final error = _asMap(map['error']);
|
||||
return (_stringValue(error['message']) ??
|
||||
_stringValue(map['message']) ??
|
||||
_stringValue(map['detail']) ??
|
||||
'')
|
||||
.trim();
|
||||
} on FormatException {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String _extractFirstJsonDocument(String body) {
|
||||
final trimmed = body.trimLeft();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('Empty response body');
|
||||
}
|
||||
final start = trimmed.indexOf(RegExp(r'[\{\[]'));
|
||||
if (start < 0) {
|
||||
throw const FormatException('Missing JSON document');
|
||||
}
|
||||
var depth = 0;
|
||||
var inString = false;
|
||||
var escaped = false;
|
||||
for (var index = start; index < trimmed.length; index++) {
|
||||
final char = trimmed[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char == r'\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char == '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
if (char == '{' || char == '[') {
|
||||
depth += 1;
|
||||
} else if (char == '}' || char == ']') {
|
||||
depth -= 1;
|
||||
if (depth == 0) {
|
||||
return trimmed.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw const FormatException('Unterminated JSON document');
|
||||
}
|
||||
}
|
||||
|
||||
class WebAiGatewayException implements Exception {
|
||||
const WebAiGatewayException({required this.message, this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
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>{};
|
||||
}
|
||||
|
||||
List<Object?> _asList(Object? value) {
|
||||
if (value is List<Object?>) {
|
||||
return value;
|
||||
}
|
||||
if (value is List) {
|
||||
return value.cast<Object?>();
|
||||
}
|
||||
return const <Object?>[];
|
||||
}
|
||||
|
||||
String? _stringValue(Object? value) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
int? _intValue(Object? value) {
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
631
lib/web/web_assistant_page.dart
Normal file
631
lib/web/web_assistant_page.dart
Normal file
@ -0,0 +1,631 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller_web.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../widgets/desktop_workspace_scaffold.dart';
|
||||
import '../widgets/status_badge.dart';
|
||||
import '../widgets/surface_card.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
|
||||
class WebAssistantPage extends StatefulWidget {
|
||||
const WebAssistantPage({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
|
||||
@override
|
||||
State<WebAssistantPage> createState() => _WebAssistantPageState();
|
||||
}
|
||||
|
||||
class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
String _query = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputController.dispose();
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final allDirect = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
final allRelay = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
final direct = _filterConversations(allDirect);
|
||||
final relay = _filterConversations(allRelay);
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final connected =
|
||||
currentTarget == AssistantExecutionTarget.aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
: controller.connection.status == RuntimeConnectionStatus.connected;
|
||||
final currentMessages = controller.chatMessages;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return DesktopWorkspaceScaffold(
|
||||
breadcrumbs: <AppBreadcrumbItem>[
|
||||
AppBreadcrumbItem(
|
||||
label: appText('主页', 'Home'),
|
||||
icon: Icons.home_rounded,
|
||||
onTap: controller.navigateHome,
|
||||
),
|
||||
AppBreadcrumbItem(label: WorkspaceDestination.assistant.label),
|
||||
],
|
||||
eyebrow: appText('Web Workspace', 'Web Workspace'),
|
||||
title: appText('助手', 'Assistant'),
|
||||
subtitle: appText(
|
||||
'Direct AI 与 Relay Gateway 共用一个入口,左侧保留会话/任务历史。',
|
||||
'Use one Assistant surface for Direct AI and Relay Gateway, with embedded conversation history on the left.',
|
||||
),
|
||||
toolbar: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => controller.createConversation(
|
||||
target: controller.assistantExecutionTarget,
|
||||
),
|
||||
icon: const Icon(Icons.edit_square),
|
||||
label: Text(appText('新对话', 'New conversation')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
controller.openSettings(tab: SettingsTab.gateway),
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
label: Text(appText('连接设置', 'Connection settings')),
|
||||
),
|
||||
_TargetChip(
|
||||
value: currentTarget,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setAssistantExecutionTarget(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final vertical = constraints.maxWidth < 980;
|
||||
final rail = _ConversationRail(
|
||||
controller: controller,
|
||||
query: _query,
|
||||
searchController: _searchController,
|
||||
onQueryChanged: (value) {
|
||||
setState(() => _query = value.trim().toLowerCase());
|
||||
},
|
||||
onClearQuery: () {
|
||||
_searchController.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
direct: direct,
|
||||
relay: relay,
|
||||
);
|
||||
final panel = _ConversationPanel(
|
||||
controller: controller,
|
||||
inputController: _inputController,
|
||||
scrollController: _scrollController,
|
||||
connected: connected,
|
||||
currentMessages: currentMessages,
|
||||
);
|
||||
|
||||
if (vertical) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 300, child: rail),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: panel),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 320, child: rail),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: panel),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<WebConversationSummary> _filterConversations(
|
||||
List<WebConversationSummary> items,
|
||||
) {
|
||||
if (_query.isEmpty) {
|
||||
return items;
|
||||
}
|
||||
return items
|
||||
.where((item) {
|
||||
final haystack = '${item.title}\n${item.preview}'.toLowerCase();
|
||||
return haystack.contains(_query);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConversationRail extends StatelessWidget {
|
||||
const _ConversationRail({
|
||||
required this.controller,
|
||||
required this.query,
|
||||
required this.searchController,
|
||||
required this.onQueryChanged,
|
||||
required this.onClearQuery,
|
||||
required this.direct,
|
||||
required this.relay,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final String query;
|
||||
final TextEditingController searchController;
|
||||
final ValueChanged<String> onQueryChanged;
|
||||
final VoidCallback onClearQuery;
|
||||
final List<WebConversationSummary> direct;
|
||||
final List<WebConversationSummary> relay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SurfaceCard(
|
||||
borderRadius: 10,
|
||||
tone: SurfaceCardTone.chrome,
|
||||
child: Column(
|
||||
key: const Key('assistant-task-rail'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchController,
|
||||
onChanged: onQueryChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText('搜索会话', 'Search conversations'),
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: query.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: onClearQuery,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
_ConversationGroup(
|
||||
title: appText('Direct AI Gateway', 'Direct AI Gateway'),
|
||||
icon: Icons.hub_rounded,
|
||||
items: direct,
|
||||
emptyLabel: appText(
|
||||
'还没有 Direct AI 对话',
|
||||
'No Direct AI conversations yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ConversationGroup(
|
||||
title: appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
items: relay,
|
||||
emptyLabel: appText(
|
||||
'还没有 Relay 对话',
|
||||
'No Relay conversations yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConversationGroup extends StatelessWidget {
|
||||
const _ConversationGroup({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.items,
|
||||
required this.emptyLabel,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final List<WebConversationSummary> items;
|
||||
final String emptyLabel;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: palette.accent),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Text(
|
||||
emptyLabel,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SurfaceCard(
|
||||
onTap: () => onSelect(item.sessionKey),
|
||||
borderRadius: 10,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: item.current ? palette.accentMuted : null,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.preview,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.pending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8, top: 2),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConversationPanel extends StatelessWidget {
|
||||
const _ConversationPanel({
|
||||
required this.controller,
|
||||
required this.inputController,
|
||||
required this.scrollController,
|
||||
required this.connected,
|
||||
required this.currentMessages,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final TextEditingController inputController;
|
||||
final ScrollController scrollController;
|
||||
final bool connected;
|
||||
final List<GatewayChatMessage> currentMessages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final targetReady = currentTarget == AssistantExecutionTarget.aiGatewayOnly
|
||||
? controller.canUseAiGatewayConversation
|
||||
: controller.connection.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SurfaceCard(
|
||||
borderRadius: 10,
|
||||
tone: SurfaceCardTone.chrome,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.currentConversationTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: StatusInfo(
|
||||
controller.assistantConnectionStatusLabel,
|
||||
targetReady ? StatusTone.success : StatusTone.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!connected)
|
||||
SurfaceCard(
|
||||
borderRadius: 10,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentTarget == AssistantExecutionTarget.aiGatewayOnly
|
||||
? appText(
|
||||
'当前 Direct AI 配置还不完整,请先在 Settings 中保存地址、API Key 和默认模型。',
|
||||
'Direct AI is not ready yet. Save the endpoint, API key, and default model in Settings first.',
|
||||
)
|
||||
: appText(
|
||||
'当前 Relay Gateway 尚未连接,请先在 Settings 中保存配置并连接。',
|
||||
'Relay Gateway is offline. Save the relay config and connect from Settings first.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
controller.openSettings(tab: SettingsTab.gateway),
|
||||
child: Text(appText('打开设置', 'Open settings')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!connected) const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: SurfaceCard(
|
||||
borderRadius: 10,
|
||||
padding: EdgeInsets.zero,
|
||||
tone: SurfaceCardTone.chrome,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: currentMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = currentMessages[index];
|
||||
return _MessageBubble(message: message);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(height: 1, color: palette.strokeSoft),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText(
|
||||
'输入需求、补充上下文、继续追问',
|
||||
'Describe the task, add context, or continue the conversation',
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) {
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
final value = inputController.text;
|
||||
inputController.clear();
|
||||
controller.sendMessage(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentTarget ==
|
||||
AssistantExecutionTarget.aiGatewayOnly
|
||||
? appText(
|
||||
'Web 端 Direct AI 只保留纯网络能力,不提供本地文件和 CLI。',
|
||||
'Direct AI on web keeps network-only capabilities and does not expose local files or CLI.',
|
||||
)
|
||||
: appText(
|
||||
'Web 端 Relay 模式使用远程 OpenClaw Gateway,不区分 local / remote。',
|
||||
'Relay mode on web uses the remote OpenClaw Gateway and does not expose local / remote splits.',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: connected
|
||||
? () {
|
||||
final value = inputController.text;
|
||||
inputController.clear();
|
||||
controller.sendMessage(value);
|
||||
}
|
||||
: () => controller.openSettings(
|
||||
tab: SettingsTab.gateway,
|
||||
),
|
||||
icon: Icon(
|
||||
connected
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.settings_rounded,
|
||||
),
|
||||
label: Text(
|
||||
connected
|
||||
? appText('提交', 'Submit')
|
||||
: appText('配置', 'Configure'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({required this.message});
|
||||
|
||||
final GatewayChatMessage message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final assistant = message.role.trim().toLowerCase() == 'assistant';
|
||||
final color = message.error
|
||||
? palette.danger.withValues(alpha: 0.14)
|
||||
: assistant
|
||||
? palette.surfacePrimary
|
||||
: palette.accentMuted;
|
||||
|
||||
return Align(
|
||||
alignment: assistant ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
assistant ? 'Assistant' : 'You',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(message.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TargetChip extends StatelessWidget {
|
||||
const _TargetChip({required this.value, required this.onChanged});
|
||||
|
||||
final AssistantExecutionTarget value;
|
||||
final ValueChanged<AssistantExecutionTarget?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<AssistantExecutionTarget>(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
items:
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
]
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _targetLabel(AssistantExecutionTarget target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.aiGatewayOnly => appText(
|
||||
'Direct AI Gateway',
|
||||
'Direct AI Gateway',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
734
lib/web/web_relay_gateway_client.dart
Normal file
734
lib/web/web_relay_gateway_client.dart
Normal file
@ -0,0 +1,734 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../app/app_metadata.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import 'web_store.dart';
|
||||
|
||||
class GatewayPushEvent {
|
||||
const GatewayPushEvent({
|
||||
required this.event,
|
||||
required this.payload,
|
||||
this.sequence,
|
||||
});
|
||||
|
||||
final String event;
|
||||
final dynamic payload;
|
||||
final int? sequence;
|
||||
}
|
||||
|
||||
class WebRelayGatewayClient {
|
||||
WebRelayGatewayClient(this._store);
|
||||
|
||||
final WebStore _store;
|
||||
final StreamController<GatewayPushEvent> _events =
|
||||
StreamController<GatewayPushEvent>.broadcast();
|
||||
final Map<String, Completer<_RelayRpcResponse>> _pending =
|
||||
<String, Completer<_RelayRpcResponse>>{};
|
||||
final _WebRelayIdentityManager _identityManager = _WebRelayIdentityManager();
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
int _requestCounter = 0;
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
);
|
||||
|
||||
Stream<GatewayPushEvent> get events => _events.stream;
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
String get mainSessionKey => _snapshot.mainSessionKey ?? 'main';
|
||||
|
||||
Future<void> connect({
|
||||
required GatewayConnectionProfile profile,
|
||||
required String authToken,
|
||||
required String authPassword,
|
||||
}) async {
|
||||
await disconnect();
|
||||
final endpoint = _resolveEndpoint(profile);
|
||||
if (endpoint == null) {
|
||||
_snapshot =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
).copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Missing relay endpoint',
|
||||
lastError: 'Configure relay host / port first.',
|
||||
lastErrorCode: 'MISSING_ENDPOINT',
|
||||
);
|
||||
throw const WebRelayGatewayException('Missing relay endpoint');
|
||||
}
|
||||
|
||||
final identity = await _identityManager.loadOrCreate(_store);
|
||||
_snapshot =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
).copyWith(
|
||||
status: RuntimeConnectionStatus.connecting,
|
||||
statusText: 'Connecting…',
|
||||
remoteAddress: '${endpoint.host}:${endpoint.port}',
|
||||
deviceId: identity.deviceId,
|
||||
authRole: 'operator',
|
||||
authScopes: const <String>[
|
||||
'operator.admin',
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
],
|
||||
connectAuthMode: authToken.trim().isNotEmpty
|
||||
? 'shared-token'
|
||||
: authPassword.trim().isNotEmpty
|
||||
? 'password'
|
||||
: 'none',
|
||||
connectAuthFields: <String>[
|
||||
if (authToken.trim().isNotEmpty) 'token',
|
||||
if (authPassword.trim().isNotEmpty) 'password',
|
||||
],
|
||||
connectAuthSources: <String>[
|
||||
if (authToken.trim().isNotEmpty) 'browser-store',
|
||||
if (authPassword.trim().isNotEmpty) 'browser-store',
|
||||
],
|
||||
hasSharedAuth:
|
||||
authToken.trim().isNotEmpty || authPassword.trim().isNotEmpty,
|
||||
hasDeviceToken: false,
|
||||
clearLastError: true,
|
||||
clearLastErrorCode: true,
|
||||
clearLastErrorDetailCode: true,
|
||||
);
|
||||
|
||||
final uri = Uri(
|
||||
scheme: endpoint.tls ? 'wss' : 'ws',
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
);
|
||||
final channel = WebSocketChannel.connect(uri);
|
||||
final challenge = Completer<String>();
|
||||
|
||||
_channel = channel;
|
||||
_subscription = channel.stream.listen(
|
||||
(dynamic raw) => _handleIncoming(raw, challenge),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Relay error',
|
||||
lastError: error.toString(),
|
||||
lastErrorCode: 'SOCKET_FAILURE',
|
||||
);
|
||||
},
|
||||
onDone: () {
|
||||
if (_snapshot.status == RuntimeConnectionStatus.connected) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Disconnected',
|
||||
lastError: 'Relay connection closed',
|
||||
lastErrorCode: 'SOCKET_CLOSED',
|
||||
);
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
final nonce = await challenge.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () =>
|
||||
throw const WebRelayGatewayException('Relay challenge timeout'),
|
||||
);
|
||||
final result = await _requestRaw(
|
||||
'connect',
|
||||
params: await _buildConnectParams(
|
||||
identity: identity,
|
||||
nonce: nonce,
|
||||
authToken: authToken.trim(),
|
||||
authPassword: authPassword.trim(),
|
||||
),
|
||||
timeout: const Duration(seconds: 12),
|
||||
);
|
||||
final payload = _asMap(result.payload);
|
||||
final auth = _asMap(payload['auth']);
|
||||
final snapshot = _asMap(payload['snapshot']);
|
||||
final sessionDefaults = _asMap(snapshot['sessionDefaults']);
|
||||
final server = _asMap(payload['server']);
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
serverName: _stringValue(server['host']),
|
||||
remoteAddress: '${endpoint.host}:${endpoint.port}',
|
||||
mainSessionKey:
|
||||
_stringValue(sessionDefaults['mainSessionKey']) ?? 'main',
|
||||
lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
authRole: _stringValue(auth['role']) ?? 'operator',
|
||||
authScopes: _stringList(auth['scopes']),
|
||||
clearLastError: true,
|
||||
clearLastErrorCode: true,
|
||||
clearLastErrorDetailCode: true,
|
||||
);
|
||||
} catch (error) {
|
||||
await disconnect();
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: error.toString(),
|
||||
lastErrorCode: 'CONNECT_FAILED',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
for (final pending in _pending.values) {
|
||||
if (!pending.isCompleted) {
|
||||
pending.completeError(
|
||||
const WebRelayGatewayException('Relay request cancelled'),
|
||||
);
|
||||
}
|
||||
}
|
||||
_pending.clear();
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
}
|
||||
|
||||
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
|
||||
final payload = _asMap(
|
||||
await request(
|
||||
'sessions.list',
|
||||
params: <String, dynamic>{
|
||||
'includeGlobal': true,
|
||||
'includeUnknown': false,
|
||||
'includeDerivedTitles': true,
|
||||
'includeLastMessage': true,
|
||||
'limit': limit,
|
||||
},
|
||||
),
|
||||
);
|
||||
return _asList(payload['sessions'])
|
||||
.map((item) {
|
||||
final map = _asMap(item);
|
||||
return GatewaySessionSummary(
|
||||
key: _stringValue(map['key']) ?? 'main',
|
||||
kind: _stringValue(map['kind']),
|
||||
displayName:
|
||||
_stringValue(map['displayName']) ?? _stringValue(map['label']),
|
||||
surface: _stringValue(map['surface']),
|
||||
subject: _stringValue(map['subject']),
|
||||
room: _stringValue(map['room']),
|
||||
space: _stringValue(map['space']),
|
||||
updatedAtMs: _doubleValue(map['updatedAt']),
|
||||
sessionId: _stringValue(map['sessionId']),
|
||||
systemSent: _boolValue(map['systemSent']),
|
||||
abortedLastRun: _boolValue(map['abortedLastRun']),
|
||||
thinkingLevel: _stringValue(map['thinkingLevel']),
|
||||
verboseLevel: _stringValue(map['verboseLevel']),
|
||||
inputTokens: _intValue(map['inputTokens']),
|
||||
outputTokens: _intValue(map['outputTokens']),
|
||||
totalTokens: _intValue(map['totalTokens']),
|
||||
model: _stringValue(map['model']),
|
||||
contextTokens: _intValue(map['contextTokens']),
|
||||
derivedTitle: _stringValue(map['derivedTitle']),
|
||||
lastMessagePreview: _stringValue(map['lastMessagePreview']),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<GatewayChatMessage>> loadHistory(
|
||||
String sessionKey, {
|
||||
int limit = 120,
|
||||
}) async {
|
||||
final payload = _asMap(
|
||||
await request(
|
||||
'chat.history',
|
||||
params: <String, dynamic>{'sessionKey': sessionKey, 'limit': limit},
|
||||
),
|
||||
);
|
||||
return _asList(payload['messages'])
|
||||
.map((item) {
|
||||
final map = _asMap(item);
|
||||
return GatewayChatMessage(
|
||||
id: _randomId(),
|
||||
role: _stringValue(map['role']) ?? 'assistant',
|
||||
text: _extractMessageText(map),
|
||||
timestampMs: _doubleValue(map['timestamp']),
|
||||
toolCallId:
|
||||
_stringValue(map['toolCallId']) ??
|
||||
_stringValue(map['tool_call_id']),
|
||||
toolName:
|
||||
_stringValue(map['toolName']) ?? _stringValue(map['tool_name']),
|
||||
stopReason: _stringValue(map['stopReason']),
|
||||
pending: false,
|
||||
error: false,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<String> sendChat({
|
||||
required String sessionKey,
|
||||
required String message,
|
||||
required String thinking,
|
||||
}) async {
|
||||
final runId = _randomId();
|
||||
final payload = _asMap(
|
||||
await request(
|
||||
'chat.send',
|
||||
params: <String, dynamic>{
|
||||
'sessionKey': sessionKey,
|
||||
'message': message,
|
||||
'thinking': thinking,
|
||||
'timeoutMs': 30000,
|
||||
'idempotencyKey': runId,
|
||||
},
|
||||
timeout: const Duration(seconds: 35),
|
||||
),
|
||||
);
|
||||
return _stringValue(payload['runId']) ?? runId;
|
||||
}
|
||||
|
||||
Future<List<GatewayModelSummary>> listModels() async {
|
||||
final payload = _asMap(await request('models.list'));
|
||||
return _asList(payload['models'])
|
||||
.map((item) {
|
||||
final map = _asMap(item);
|
||||
return GatewayModelSummary(
|
||||
id: _stringValue(map['id']) ?? 'unknown',
|
||||
name:
|
||||
_stringValue(map['name']) ??
|
||||
_stringValue(map['id']) ??
|
||||
'unknown',
|
||||
provider: _stringValue(map['provider']) ?? 'relay',
|
||||
contextWindow: _intValue(map['contextWindow']),
|
||||
maxOutputTokens: _intValue(map['maxOutputTokens']),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<dynamic> request(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
if (_channel == null || !isConnected) {
|
||||
throw const WebRelayGatewayException('Relay not connected');
|
||||
}
|
||||
final result = await _requestRaw(method, params: params, timeout: timeout);
|
||||
return result.payload;
|
||||
}
|
||||
|
||||
Future<_RelayRpcResponse> _requestRaw(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
final channel = _channel;
|
||||
if (channel == null) {
|
||||
throw const WebRelayGatewayException('Relay not connected');
|
||||
}
|
||||
final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}';
|
||||
final completer = Completer<_RelayRpcResponse>();
|
||||
_pending[id] = completer;
|
||||
channel.sink.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'type': 'req',
|
||||
'id': id,
|
||||
'method': method,
|
||||
if (params != null && params.isNotEmpty) 'params': params,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
return await completer.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () =>
|
||||
throw WebRelayGatewayException('$method request timeout'),
|
||||
);
|
||||
} finally {
|
||||
_pending.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _buildConnectParams({
|
||||
required LocalDeviceIdentity identity,
|
||||
required String nonce,
|
||||
required String authToken,
|
||||
required String authPassword,
|
||||
}) async {
|
||||
const scopes = <String>[
|
||||
'operator.admin',
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
];
|
||||
const clientId = 'xworkmate-web';
|
||||
const clientMode = 'ui';
|
||||
final signedAtMs = DateTime.now().millisecondsSinceEpoch;
|
||||
final signaturePayload = _identityManager.buildDeviceAuthPayloadV3(
|
||||
deviceId: identity.deviceId,
|
||||
clientId: clientId,
|
||||
clientMode: clientMode,
|
||||
role: 'operator',
|
||||
scopes: scopes,
|
||||
signedAtMs: signedAtMs,
|
||||
token: authToken,
|
||||
nonce: nonce,
|
||||
platform: 'web',
|
||||
deviceFamily: 'Browser',
|
||||
);
|
||||
final signature = await _identityManager.signPayload(
|
||||
identity: identity,
|
||||
payload: signaturePayload,
|
||||
);
|
||||
|
||||
return <String, dynamic>{
|
||||
'minProtocol': 3,
|
||||
'maxProtocol': 3,
|
||||
'client': <String, dynamic>{
|
||||
'id': clientId,
|
||||
'displayName': '$kSystemAppName Browser',
|
||||
'version': kAppVersion,
|
||||
'platform': 'web',
|
||||
'deviceFamily': 'Browser',
|
||||
'modelIdentifier': 'browser',
|
||||
'mode': clientMode,
|
||||
'instanceId':
|
||||
'$clientId-${identity.deviceId.substring(0, min(8, identity.deviceId.length))}',
|
||||
},
|
||||
'caps': const <String>['tool-events'],
|
||||
'commands': const <String>[],
|
||||
'permissions': const <String, bool>{},
|
||||
'role': 'operator',
|
||||
'scopes': scopes,
|
||||
if (authToken.isNotEmpty || authPassword.isNotEmpty)
|
||||
'auth': <String, dynamic>{
|
||||
if (authToken.isNotEmpty) 'token': authToken,
|
||||
if (authPassword.isNotEmpty) 'password': authPassword,
|
||||
},
|
||||
'locale': 'web',
|
||||
'userAgent': '$kSystemAppName/$kAppVersion web',
|
||||
'device': <String, dynamic>{
|
||||
'id': identity.deviceId,
|
||||
'publicKey': identity.publicKeyBase64Url,
|
||||
'signature': signature,
|
||||
'signedAt': signedAtMs,
|
||||
'nonce': nonce,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _handleIncoming(dynamic raw, Completer<String> challenge) {
|
||||
final text = raw is String ? raw : utf8.decode(raw as List<int>);
|
||||
final decoded = jsonDecode(text) as Map<String, dynamic>;
|
||||
final type = _stringValue(decoded['type']);
|
||||
if (type == 'event') {
|
||||
final event = _stringValue(decoded['event']) ?? '';
|
||||
final payload = decoded['payload'];
|
||||
if (event == 'connect.challenge') {
|
||||
final nonce = _stringValue(_asMap(payload)['nonce']);
|
||||
if (nonce != null && !challenge.isCompleted) {
|
||||
challenge.complete(nonce);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_events.add(
|
||||
GatewayPushEvent(
|
||||
event: event,
|
||||
payload: payload,
|
||||
sequence: _intValue(decoded['seq']),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (type != 'res') {
|
||||
return;
|
||||
}
|
||||
final id = _stringValue(decoded['id']);
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
final completer = _pending.remove(id);
|
||||
if (completer == null || completer.isCompleted) {
|
||||
return;
|
||||
}
|
||||
final ok = _boolValue(decoded['ok']) ?? false;
|
||||
if (!ok) {
|
||||
final error = _asMap(decoded['error']);
|
||||
completer.completeError(
|
||||
WebRelayGatewayException(
|
||||
_stringValue(error['message']) ?? 'Relay request failed',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
completer.complete(
|
||||
_RelayRpcResponse(
|
||||
ok: true,
|
||||
payload: decoded['payload'],
|
||||
error: _asMap(decoded['error']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_ResolvedRelayEndpoint? _resolveEndpoint(GatewayConnectionProfile profile) {
|
||||
final rawHost = profile.host.trim();
|
||||
if (rawHost.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final candidate = rawHost.contains('://')
|
||||
? rawHost
|
||||
: '${profile.tls ? 'https' : 'http'}://$rawHost:${profile.port}';
|
||||
final uri = Uri.tryParse(candidate);
|
||||
if (uri == null || uri.host.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final tls = switch (uri.scheme.trim().toLowerCase()) {
|
||||
'http' || 'ws' => false,
|
||||
_ => true,
|
||||
};
|
||||
return _ResolvedRelayEndpoint(
|
||||
host: uri.host.trim(),
|
||||
port: uri.hasPort ? uri.port : (tls ? 443 : 80),
|
||||
tls: tls,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await disconnect();
|
||||
await _events.close();
|
||||
}
|
||||
}
|
||||
|
||||
class WebRelayGatewayException implements Exception {
|
||||
const WebRelayGatewayException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class _ResolvedRelayEndpoint {
|
||||
const _ResolvedRelayEndpoint({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.tls,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final bool tls;
|
||||
}
|
||||
|
||||
class _RelayRpcResponse {
|
||||
const _RelayRpcResponse({
|
||||
required this.ok,
|
||||
required this.payload,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
final bool ok;
|
||||
final dynamic payload;
|
||||
final Map<String, dynamic> error;
|
||||
}
|
||||
|
||||
class _WebRelayIdentityManager {
|
||||
final Ed25519 _algorithm = Ed25519();
|
||||
|
||||
Future<LocalDeviceIdentity> loadOrCreate(WebStore store) async {
|
||||
final existing = await store.loadRelayDeviceIdentity();
|
||||
if (existing != null &&
|
||||
existing.deviceId.isNotEmpty &&
|
||||
existing.publicKeyBase64Url.isNotEmpty &&
|
||||
existing.privateKeyBase64Url.isNotEmpty) {
|
||||
return existing;
|
||||
}
|
||||
final keyPair = await _algorithm.newKeyPair();
|
||||
final publicKey = await keyPair.extractPublicKey();
|
||||
final privateKeyBytes = await keyPair.extractPrivateKeyBytes();
|
||||
final publicKeyBytes = publicKey.bytes;
|
||||
final identity = LocalDeviceIdentity(
|
||||
deviceId: _deriveDeviceId(publicKeyBytes),
|
||||
publicKeyBase64Url: _base64UrlEncode(publicKeyBytes),
|
||||
privateKeyBase64Url: _base64UrlEncode(privateKeyBytes),
|
||||
createdAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
await store.saveRelayDeviceIdentity(identity);
|
||||
return identity;
|
||||
}
|
||||
|
||||
Future<String> signPayload({
|
||||
required LocalDeviceIdentity identity,
|
||||
required String payload,
|
||||
}) async {
|
||||
final publicKeyBytes = _base64UrlDecode(identity.publicKeyBase64Url);
|
||||
final privateKeyBytes = _base64UrlDecode(identity.privateKeyBase64Url);
|
||||
final keyPair = SimpleKeyPairData(
|
||||
privateKeyBytes,
|
||||
publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.ed25519),
|
||||
type: KeyPairType.ed25519,
|
||||
);
|
||||
final signature = await _algorithm.sign(
|
||||
utf8.encode(payload),
|
||||
keyPair: keyPair,
|
||||
);
|
||||
return _base64UrlEncode(signature.bytes);
|
||||
}
|
||||
|
||||
String buildDeviceAuthPayloadV3({
|
||||
required String deviceId,
|
||||
required String clientId,
|
||||
required String clientMode,
|
||||
required String role,
|
||||
required List<String> scopes,
|
||||
required int signedAtMs,
|
||||
required String token,
|
||||
required String nonce,
|
||||
required String platform,
|
||||
required String deviceFamily,
|
||||
}) {
|
||||
return <String>[
|
||||
'v3',
|
||||
deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes.join(','),
|
||||
'$signedAtMs',
|
||||
token,
|
||||
nonce,
|
||||
_normalizeMetadata(platform),
|
||||
_normalizeMetadata(deviceFamily),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
String _normalizeMetadata(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in trimmed.runes) {
|
||||
if (rune >= 65 && rune <= 90) {
|
||||
buffer.writeCharCode(rune + 32);
|
||||
} else {
|
||||
buffer.writeCharCode(rune);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _deriveDeviceId(List<int> publicKeyBytes) {
|
||||
return crypto.sha256.convert(publicKeyBytes).toString();
|
||||
}
|
||||
|
||||
String _base64UrlEncode(List<int> value) {
|
||||
return base64Url.encode(value).replaceAll('=', '');
|
||||
}
|
||||
|
||||
Uint8List _base64UrlDecode(String value) {
|
||||
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
|
||||
final padded = normalized + '=' * ((4 - normalized.length % 4) % 4);
|
||||
return Uint8List.fromList(base64.decode(padded));
|
||||
}
|
||||
}
|
||||
|
||||
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>{};
|
||||
}
|
||||
|
||||
List<Object?> _asList(Object? value) {
|
||||
if (value is List<Object?>) {
|
||||
return value;
|
||||
}
|
||||
if (value is List) {
|
||||
return value.cast<Object?>();
|
||||
}
|
||||
return const <Object?>[];
|
||||
}
|
||||
|
||||
String? _stringValue(Object? value) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
int? _intValue(Object? value) {
|
||||
if (value is num) {
|
||||
return value.toInt();
|
||||
}
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
double? _doubleValue(Object? value) {
|
||||
if (value is num) {
|
||||
return value.toDouble();
|
||||
}
|
||||
return double.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
bool? _boolValue(Object? value) {
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
if (value is num) {
|
||||
return value != 0;
|
||||
}
|
||||
final normalized = value?.toString().trim().toLowerCase();
|
||||
if (normalized == 'true') {
|
||||
return true;
|
||||
}
|
||||
if (normalized == 'false') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? value) {
|
||||
return _asList(
|
||||
value,
|
||||
).map(_stringValue).whereType<String>().toList(growable: false);
|
||||
}
|
||||
|
||||
String _extractMessageText(Map<String, dynamic> message) {
|
||||
final directContent = message['content'];
|
||||
if (directContent is String) {
|
||||
return directContent;
|
||||
}
|
||||
final parts = <String>[];
|
||||
for (final part in _asList(directContent)) {
|
||||
final map = _asMap(part);
|
||||
final text = _stringValue(map['text']) ?? _stringValue(map['thinking']);
|
||||
if (text != null && text.isNotEmpty) {
|
||||
parts.add(text);
|
||||
continue;
|
||||
}
|
||||
final nestedContent = map['content'];
|
||||
if (nestedContent is String && nestedContent.trim().isNotEmpty) {
|
||||
parts.add(nestedContent.trim());
|
||||
}
|
||||
}
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
String _randomId() {
|
||||
final random = Random.secure();
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch.toRadixString(16);
|
||||
final suffix = List<int>.generate(
|
||||
6,
|
||||
(_) => random.nextInt(256),
|
||||
).map((value) => value.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '$timestamp-$suffix';
|
||||
}
|
||||
670
lib/web/web_settings_page.dart
Normal file
670
lib/web/web_settings_page.dart
Normal file
@ -0,0 +1,670 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller_web.dart';
|
||||
import '../app/app_metadata.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
import '../runtime/runtime_models.dart';
|
||||
import '../theme/app_palette.dart';
|
||||
import '../widgets/desktop_workspace_scaffold.dart';
|
||||
import '../widgets/section_tabs.dart';
|
||||
import '../widgets/surface_card.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
|
||||
class WebSettingsPage extends StatefulWidget {
|
||||
const WebSettingsPage({super.key, required this.controller});
|
||||
|
||||
final AppController controller;
|
||||
|
||||
@override
|
||||
State<WebSettingsPage> createState() => _WebSettingsPageState();
|
||||
}
|
||||
|
||||
class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
late final TextEditingController _directNameController;
|
||||
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;
|
||||
|
||||
String _directMessage = '';
|
||||
String _relayMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_directNameController = TextEditingController();
|
||||
_directBaseUrlController = TextEditingController();
|
||||
_directProviderController = TextEditingController();
|
||||
_directApiKeyController = TextEditingController();
|
||||
_relayHostController = TextEditingController();
|
||||
_relayPortController = TextEditingController();
|
||||
_relayTokenController = TextEditingController();
|
||||
_relayPasswordController = TextEditingController();
|
||||
_syncControllers();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant WebSettingsPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_syncControllers();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_directNameController.dispose();
|
||||
_directBaseUrlController.dispose();
|
||||
_directProviderController.dispose();
|
||||
_directApiKeyController.dispose();
|
||||
_relayHostController.dispose();
|
||||
_relayPortController.dispose();
|
||||
_relayTokenController.dispose();
|
||||
_relayPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncControllers() {
|
||||
final settings = widget.controller.settings;
|
||||
_setIfDifferent(_directNameController, settings.aiGateway.name);
|
||||
_setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl);
|
||||
_setIfDifferent(_directProviderController, settings.defaultProvider);
|
||||
_setIfDifferent(
|
||||
_directApiKeyController,
|
||||
widget.controller.storedAiGatewayApiKeyMask == null
|
||||
? ''
|
||||
: _directApiKeyController.text,
|
||||
);
|
||||
_setIfDifferent(_relayHostController, settings.gateway.host);
|
||||
_setIfDifferent(_relayPortController, '${settings.gateway.port}');
|
||||
_setIfDifferent(
|
||||
_relayTokenController,
|
||||
widget.controller.storedRelayTokenMask == null
|
||||
? ''
|
||||
: _relayTokenController.text,
|
||||
);
|
||||
_setIfDifferent(
|
||||
_relayPasswordController,
|
||||
widget.controller.storedRelayPasswordMask == null
|
||||
? ''
|
||||
: _relayPasswordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = widget.controller;
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final settings = controller.settings;
|
||||
final currentTab = controller.settingsTab;
|
||||
return DesktopWorkspaceScaffold(
|
||||
breadcrumbs: <AppBreadcrumbItem>[
|
||||
AppBreadcrumbItem(
|
||||
label: appText('主页', 'Home'),
|
||||
icon: Icons.home_rounded,
|
||||
onTap: controller.navigateHome,
|
||||
),
|
||||
AppBreadcrumbItem(
|
||||
label: appText('设置', 'Settings'),
|
||||
onTap: () => controller.openSettings(tab: currentTab),
|
||||
),
|
||||
AppBreadcrumbItem(label: currentTab.label),
|
||||
],
|
||||
eyebrow: appText('Web Preferences', 'Web Preferences'),
|
||||
title: appText('设置', 'Settings'),
|
||||
subtitle: appText(
|
||||
'Web 版只保留 Direct AI / Relay Gateway、界面偏好和基础信息。',
|
||||
'The web app keeps only Direct AI, Relay Gateway, appearance preferences, and basic product info.',
|
||||
),
|
||||
toolbar: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => controller.navigateHome(),
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded),
|
||||
label: Text(appText('回到助手', 'Back to assistant')),
|
||||
),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<ThemeMode>(
|
||||
value: controller.themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setThemeMode(value);
|
||||
}
|
||||
},
|
||||
items: ThemeMode.values
|
||||
.map(
|
||||
(mode) => DropdownMenuItem<ThemeMode>(
|
||||
value: mode,
|
||||
child: Text(_themeLabel(mode)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.toggleAppLanguage,
|
||||
icon: const Icon(Icons.translate_rounded),
|
||||
label: Text(
|
||||
controller.appLanguage == AppLanguage.zh ? '中文' : 'English',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SectionTabs(
|
||||
items: const <SettingsTab>[
|
||||
SettingsTab.general,
|
||||
SettingsTab.gateway,
|
||||
SettingsTab.appearance,
|
||||
SettingsTab.about,
|
||||
].map((item) => item.label).toList(),
|
||||
value: currentTab.label,
|
||||
onChanged: (label) {
|
||||
final tab = SettingsTab.values.firstWhere(
|
||||
(item) => item.label == label,
|
||||
);
|
||||
controller.setSettingsTab(tab);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: switch (currentTab) {
|
||||
SettingsTab.general => _buildGeneral(context, controller),
|
||||
SettingsTab.gateway => _buildGateway(
|
||||
context,
|
||||
controller,
|
||||
settings,
|
||||
),
|
||||
SettingsTab.appearance => _buildAppearance(
|
||||
context,
|
||||
controller,
|
||||
),
|
||||
_ => _buildAbout(context),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildGeneral(BuildContext context, AppController controller) {
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('默认工作模式', 'Default work mode'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<AssistantExecutionTarget>(
|
||||
initialValue: controller.assistantExecutionTarget,
|
||||
items:
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
AssistantExecutionTarget.remote,
|
||||
]
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setAssistantExecutionTarget(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
appText(
|
||||
'当前会话列表会在浏览器本地保存,刷新后仍可恢复 Direct AI / Relay 的历史入口。',
|
||||
'Conversation history is stored in this browser so Direct AI and Relay entries remain available after reload.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildGateway(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
final palette = context.palette;
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: palette.warning),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
appText(
|
||||
'Web 版凭证会保存在当前浏览器本地存储中,安全性低于桌面端安全存储。请仅在可信设备上使用。',
|
||||
'Web credentials are persisted in this browser and are less secure than desktop secure storage. Use only on trusted devices.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('Direct AI', 'Direct AI'),
|
||||
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('Base URL', 'Base URL'),
|
||||
hintText: 'https://api.example.com/v1',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directApiKeyController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('API Key', 'API Key'),
|
||||
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: [
|
||||
FilledButton(
|
||||
onPressed: () => controller.saveAiGatewayConfiguration(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
defaultModel: controller.resolvedAiGatewayModel,
|
||||
),
|
||||
child: Text(appText('保存', 'Save')),
|
||||
),
|
||||
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')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
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.sync_rounded),
|
||||
label: Text(appText('同步模型', 'Sync models')),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: settings.gateway.tls,
|
||||
onChanged: (value) => controller.saveRelayConfiguration(
|
||||
host: _relayHostController.text,
|
||||
port: int.tryParse(_relayPortController.text.trim()) ?? 443,
|
||||
tls: value,
|
||||
token: _relayTokenController.text,
|
||||
password: _relayPasswordController.text,
|
||||
),
|
||||
),
|
||||
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: settings.gateway.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: settings.gateway.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();
|
||||
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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildAppearance(
|
||||
BuildContext context,
|
||||
AppController controller,
|
||||
) {
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('界面偏好', 'Appearance'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<ThemeMode>(
|
||||
initialValue: controller.themeMode,
|
||||
items: ThemeMode.values
|
||||
.map(
|
||||
(mode) => DropdownMenuItem<ThemeMode>(
|
||||
value: mode,
|
||||
child: Text(_themeLabel(mode)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setThemeMode(value);
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(labelText: appText('主题', 'Theme')),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.toggleAppLanguage,
|
||||
icon: const Icon(Icons.translate_rounded),
|
||||
label: Text(
|
||||
controller.appLanguage == AppLanguage.zh ? '中文' : 'English',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildAbout(BuildContext context) {
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'XWorkmate Web',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(kAppVersionLabel),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'Root SPA 目标部署到 https://xworkmate.svc.plus/ 。Direct AI 需要浏览器可达且支持 CORS;否则请使用 Relay 模式。',
|
||||
'The root SPA targets https://xworkmate.svc.plus/ . Direct AI endpoints must be browser-reachable and CORS-compatible; otherwise use relay mode.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
void _setIfDifferent(TextEditingController controller, String value) {
|
||||
if (controller.text == value) {
|
||||
return;
|
||||
}
|
||||
controller.value = controller.value.copyWith(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
composing: TextRange.empty,
|
||||
);
|
||||
}
|
||||
|
||||
String _themeLabel(ThemeMode mode) {
|
||||
return switch (mode) {
|
||||
ThemeMode.light => appText('浅色', 'Light'),
|
||||
ThemeMode.dark => appText('深色', 'Dark'),
|
||||
ThemeMode.system => appText('跟随系统', 'System'),
|
||||
};
|
||||
}
|
||||
|
||||
String _targetLabel(AssistantExecutionTarget target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.aiGatewayOnly => appText(
|
||||
'Direct AI Gateway',
|
||||
'Direct AI Gateway',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
140
lib/web/web_store.dart
Normal file
140
lib/web/web_store.dart
Normal file
@ -0,0 +1,140 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../runtime/runtime_models.dart';
|
||||
|
||||
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';
|
||||
static const relayTokenKey = 'xworkmate.web.relay.token';
|
||||
static const relayPasswordKey = 'xworkmate.web.relay.password';
|
||||
static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity';
|
||||
static const themeModeKey = 'xworkmate.web.theme_mode';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
Future<void> initialize() async {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
Future<SettingsSnapshot> loadSettingsSnapshot() async {
|
||||
await initialize();
|
||||
return SettingsSnapshot.fromJsonString(_prefs!.getString(settingsKey));
|
||||
}
|
||||
|
||||
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(settingsKey, snapshot.toJsonString());
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
|
||||
await initialize();
|
||||
final raw = _prefs!.getString(threadsKey);
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return const <AssistantThreadRecord>[];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
|
||||
)
|
||||
.toList(growable: false);
|
||||
} catch (_) {
|
||||
return const <AssistantThreadRecord>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveAssistantThreadRecords(
|
||||
List<AssistantThreadRecord> records,
|
||||
) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(
|
||||
threadsKey,
|
||||
jsonEncode(records.map((item) => item.toJson()).toList(growable: false)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> loadAiGatewayApiKey() async {
|
||||
await initialize();
|
||||
return (_prefs!.getString(aiGatewayApiKeyKey) ?? '').trim();
|
||||
}
|
||||
|
||||
Future<void> saveAiGatewayApiKey(String value) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(aiGatewayApiKeyKey, value.trim());
|
||||
}
|
||||
|
||||
Future<String> loadRelayToken() async {
|
||||
await initialize();
|
||||
return (_prefs!.getString(relayTokenKey) ?? '').trim();
|
||||
}
|
||||
|
||||
Future<void> saveRelayToken(String value) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(relayTokenKey, value.trim());
|
||||
}
|
||||
|
||||
Future<String> loadRelayPassword() async {
|
||||
await initialize();
|
||||
return (_prefs!.getString(relayPasswordKey) ?? '').trim();
|
||||
}
|
||||
|
||||
Future<void> saveRelayPassword(String value) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(relayPasswordKey, value.trim());
|
||||
}
|
||||
|
||||
Future<LocalDeviceIdentity?> loadRelayDeviceIdentity() async {
|
||||
await initialize();
|
||||
final raw = _prefs!.getString(relayDeviceIdentityKey);
|
||||
if (raw == null || raw.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDeviceIdentity.fromJson(
|
||||
(jsonDecode(raw) as Map).cast<String, dynamic>(),
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRelayDeviceIdentity(LocalDeviceIdentity identity) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(
|
||||
relayDeviceIdentityKey,
|
||||
jsonEncode(identity.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ThemeMode> loadThemeMode() async {
|
||||
await initialize();
|
||||
return switch ((_prefs!.getString(themeModeKey) ?? '').trim()) {
|
||||
'dark' => ThemeMode.dark,
|
||||
'system' => ThemeMode.system,
|
||||
_ => ThemeMode.light,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> saveThemeMode(ThemeMode mode) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(themeModeKey, mode.name);
|
||||
}
|
||||
|
||||
static String? maskValue(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed.length <= 4) {
|
||||
return '*' * trimmed.length;
|
||||
}
|
||||
return '${trimmed.substring(0, 2)}${'*' * (trimmed.length - 4)}${trimmed.substring(trimmed.length - 2)}';
|
||||
}
|
||||
}
|
||||
@ -304,7 +304,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
||||
@ -22,6 +22,7 @@ dependencies:
|
||||
file_selector: ^1.0.3
|
||||
flutter_markdown: ^0.7.7+1
|
||||
flutter_secure_storage: ^9.2.4
|
||||
http: ^1.5.0
|
||||
markdown: ^7.3.0
|
||||
package_info_plus: ^8.3.1
|
||||
path_provider: ^2.1.5
|
||||
|
||||
72
test/web/web_settings_persistence_browser_test.dart
Normal file
72
test/web/web_settings_persistence_browser_test.dart
Normal file
@ -0,0 +1,72 @@
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
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_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('web controller persists direct and relay configuration', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
|
||||
final controller = AppController(store: WebStore());
|
||||
await _waitForReady(controller);
|
||||
|
||||
await controller.saveAiGatewayConfiguration(
|
||||
name: 'Direct AI',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
provider: 'openai-compatible',
|
||||
apiKey: 'sk-test-web',
|
||||
defaultModel: '',
|
||||
);
|
||||
await controller.saveRelayConfiguration(
|
||||
host: 'relay.example.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: 'relay-token',
|
||||
password: 'relay-password',
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
await controller.createConversation(
|
||||
target: AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
|
||||
final reloaded = AppController(store: WebStore());
|
||||
await _waitForReady(reloaded);
|
||||
|
||||
expect(reloaded.settings.aiGateway.baseUrl, 'https://api.example.com/v1');
|
||||
expect(reloaded.settings.defaultProvider, 'openai-compatible');
|
||||
expect(reloaded.settings.gateway.host, 'relay.example.com');
|
||||
expect(reloaded.settings.gateway.port, 443);
|
||||
expect(
|
||||
reloaded.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(reloaded.storedAiGatewayApiKeyMask, isNotNull);
|
||||
expect(reloaded.storedRelayTokenMask, isNotNull);
|
||||
expect(reloaded.conversations, isNotEmpty);
|
||||
|
||||
controller.dispose();
|
||||
reloaded.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
38
test/web/web_ui_browser_test.dart
Normal file
38
test/web/web_ui_browser_test.dart
Normal file
@ -0,0 +1,38 @@
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:xworkmate/app/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('web shell exposes only assistant and settings surfaces', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(const XWorkmateApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('助手'), findsWidgets);
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(find.text('Tasks'), findsNothing);
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-attachment-menu-button')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('连接设置'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(find.textContaining('浏览器本地存储'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:xworkmate/app/app.dart';
|
||||
@ -17,7 +18,14 @@ void main() {
|
||||
|
||||
expect(find.text('新对话'), findsWidgets);
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
expect(find.text('幻灯片'), findsNothing);
|
||||
expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget);
|
||||
|
||||
if (kIsWeb) {
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(find.text('Tasks'), findsNothing);
|
||||
expect(find.text('AI Gateway'), findsNothing);
|
||||
} else {
|
||||
expect(find.text('幻灯片'), findsNothing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
BIN
web/icons/Icon-192.png
Normal file
BIN
web/icons/Icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/icons/Icon-512.png
Normal file
BIN
web/icons/Icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
BIN
web/icons/Icon-maskable-192.png
Normal file
BIN
web/icons/Icon-maskable-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web/icons/Icon-maskable-512.png
Normal file
BIN
web/icons/Icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
49
web/index.html
Normal file
49
web/index.html
Normal file
@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta
|
||||
name="description"
|
||||
content="XWorkmate Web keeps the Assistant-first workflow with Direct AI Gateway and Relay OpenClaw Gateway."
|
||||
>
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="XWorkmate">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>XWorkmate</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
You can customize the "flutter_bootstrap.js" script.
|
||||
This is useful to provide a custom configuration to the Flutter loader
|
||||
or to give the user feedback during the initialization process.
|
||||
|
||||
For more details:
|
||||
* https://docs.flutter.dev/platform-integration/web/initialization
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
35
web/manifest.json
Normal file
35
web/manifest.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "XWorkmate",
|
||||
"short_name": "XWorkmate",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "Assistant-first Flutter Web shell for Direct AI Gateway and Relay OpenClaw Gateway.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user