Add Flutter web assistant shell

This commit is contained in:
Haitao Pan 2026-03-21 15:52:25 +08:00
parent fe1a74c478
commit a41ac51103
26 changed files with 7595 additions and 3546 deletions

View File

@ -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
View 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.

View 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

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@ -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';

View 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
View 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),
),
),
],
),
),
);
}
}

View 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() ?? '');
}

View 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',
),
_ => '',
};
}

View 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';
}

View 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
View 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)}';
}
}

View File

@ -304,7 +304,7 @@ packages:
source: hosted
version: "1.0.2"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View File

@ -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

View 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));
}
}

View 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);
});
}

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

49
web/index.html Normal file
View 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
View 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"
}
]
}