xworkmate-app/lib/app/app_controller.dart

1148 lines
37 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
enum CodexCooperationState { notStarted, bridgeOnly, registered }
class AppController extends ChangeNotifier {
AppController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
}) {
_store = store ?? SecureConfigStore();
final resolvedRuntimeCoordinator =
runtimeCoordinator ??
RuntimeCoordinator(
gateway: GatewayRuntime(
store: _store,
identityStore: DeviceIdentityStore(_store),
),
codex: CodexRuntime(),
configBridge: CodexConfigBridge(),
);
_runtimeCoordinator = resolvedRuntimeCoordinator;
_codeAgentNodeOrchestrator = CodeAgentNodeOrchestrator(_runtimeCoordinator);
_codeAgentBridgeRegistry = AgentRegistry(_runtimeCoordinator.gateway);
_settingsController = SettingsController(_store);
_agentsController = GatewayAgentsController(_runtimeCoordinator.gateway);
_sessionsController = GatewaySessionsController(
_runtimeCoordinator.gateway,
);
_chatController = GatewayChatController(_runtimeCoordinator.gateway);
_instancesController = InstancesController(_runtimeCoordinator.gateway);
_skillsController = SkillsController(_runtimeCoordinator.gateway);
_connectorsController = ConnectorsController(_runtimeCoordinator.gateway);
_modelsController = ModelsController(
_runtimeCoordinator.gateway,
_settingsController,
);
_cronJobsController = CronJobsController(_runtimeCoordinator.gateway);
_devicesController = DevicesController(_runtimeCoordinator.gateway);
_tasksController = DerivedTasksController();
_attachChildListeners();
unawaited(_initialize());
}
late final SecureConfigStore _store;
late final RuntimeCoordinator _runtimeCoordinator;
late final CodeAgentNodeOrchestrator _codeAgentNodeOrchestrator;
late final AgentRegistry _codeAgentBridgeRegistry;
late final SettingsController _settingsController;
late final GatewayAgentsController _agentsController;
late final GatewaySessionsController _sessionsController;
late final GatewayChatController _chatController;
late final InstancesController _instancesController;
late final SkillsController _skillsController;
late final ConnectorsController _connectorsController;
late final ModelsController _modelsController;
late final CronJobsController _cronJobsController;
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
ThemeMode _themeMode = ThemeMode.light;
AppSidebarState _sidebarState = AppSidebarState.expanded;
DetailPanelData? _detailPanel;
bool _initializing = true;
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false;
WorkspaceDestination get destination => _destination;
ThemeMode get themeMode => _themeMode;
AppSidebarState get sidebarState => _sidebarState;
DetailPanelData? get detailPanel => _detailPanel;
bool get initializing => _initializing;
String? get bootstrapError => _bootstrapError;
RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator;
GatewayRuntime get _runtime => _runtimeCoordinator.gateway;
GatewayRuntime get runtime => _runtime;
/// Whether Codex bridge is enabled and configured
bool get isCodexBridgeEnabled => _isCodexBridgeEnabled;
bool _isCodexBridgeEnabled = false;
bool _isCodexBridgeBusy = false;
String? _codexBridgeError;
String? _codexRuntimeWarning;
String? _resolvedCodexCliPath;
CodexCooperationState _codexCooperationState =
CodexCooperationState.notStarted;
SettingsController get settingsController => _settingsController;
GatewayAgentsController get agentsController => _agentsController;
GatewaySessionsController get sessionsController => _sessionsController;
GatewayChatController get chatController => _chatController;
InstancesController get instancesController => _instancesController;
SkillsController get skillsController => _skillsController;
ConnectorsController get connectorsController => _connectorsController;
ModelsController get modelsController => _modelsController;
CronJobsController get cronJobsController => _cronJobsController;
DevicesController get devicesController => _devicesController;
DerivedTasksController get tasksController => _tasksController;
GatewayConnectionSnapshot get connection => _runtime.snapshot;
SettingsSnapshot get settings => _settingsController.snapshot;
List<GatewayAgentSummary> get agents => _agentsController.agents;
List<GatewaySessionSummary> get sessions => _sessionsController.sessions;
List<GatewayInstanceSummary> get instances => _instancesController.items;
List<GatewaySkillSummary> get skills => _skillsController.items;
List<GatewayConnectorSummary> get connectors => _connectorsController.items;
List<GatewayModelSummary> get models => _modelsController.items;
List<GatewayCronJobSummary> get cronJobs => _cronJobsController.items;
GatewayDevicePairingList get devices => _devicesController.items;
String get selectedAgentId => _agentsController.selectedAgentId;
String get activeAgentName => _agentsController.activeAgentName;
String get currentSessionKey => _sessionsController.currentSessionKey;
String? get activeRunId => _chatController.activeRunId;
AppLanguage get appLanguage => settings.appLanguage;
AssistantExecutionTarget get assistantExecutionTarget =>
settings.assistantExecutionTarget;
AssistantPermissionLevel get assistantPermissionLevel =>
settings.assistantPermissionLevel;
bool get hasStoredGatewayCredential =>
_settingsController.secureRefs.containsKey('gateway_token') ||
_settingsController.secureRefs.containsKey('gateway_password') ||
_settingsController.secureRefs.containsKey(
'gateway_device_token_operator',
);
bool get hasStoredGatewayToken =>
_settingsController.secureRefs.containsKey('gateway_token');
String? get storedGatewayTokenMask =>
_settingsController.secureRefs['gateway_token'];
String get aiGatewayUrl => settings.aiGateway.baseUrl.trim();
bool get isCodexBridgeBusy => _isCodexBridgeBusy;
String? get codexBridgeError => _codexBridgeError;
String? get codexRuntimeWarning => _codexRuntimeWarning;
String? get resolvedCodexCliPath => _resolvedCodexCliPath;
bool get hasDetectedCodexCli => _resolvedCodexCliPath != null;
String get configuredCodexCliPath => settings.codexCliPath.trim();
CodeAgentRuntimeMode get configuredCodeAgentRuntimeMode =>
settings.codeAgentRuntimeMode;
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
configuredCodeAgentRuntimeMode;
CodexCooperationState get codexCooperationState => _codexCooperationState;
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> openOnlineWorkspace() async {
const url = 'https://www.svc.plus/Xworkmate';
try {
if (Platform.isMacOS) {
await Process.run('open', [url]);
return;
}
if (Platform.isWindows) {
await Process.run('cmd', ['/c', 'start', '', url]);
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
}
} catch (_) {
// Best effort only. Do not surface a blocking error from a convenience link.
}
}
List<String> get aiGatewayModelChoices {
final selected = settings.aiGateway.selectedModels
.where(settings.aiGateway.availableModels.contains)
.toList(growable: false);
if (selected.isNotEmpty) {
return selected;
}
final available = settings.aiGateway.availableModels
.take(5)
.toList(growable: false);
if (available.isNotEmpty) {
return available;
}
return _modelsController.items
.map((item) => item.id)
.toList(growable: false);
}
String get resolvedDefaultModel {
final current = settings.defaultModel.trim();
final choices = aiGatewayModelChoices;
if (choices.contains(current)) {
return current;
}
if (choices.isNotEmpty) {
return choices.first;
}
return current;
}
bool get canQuickConnectGateway {
final profile = settings.gateway;
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
return true;
}
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return false;
}
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = GatewayConnectionProfile.defaults();
return hasStoredGatewayCredential ||
host != defaults.host ||
profile.port != defaults.port ||
profile.tls != defaults.tls ||
profile.mode != defaults.mode;
}
List<SecretReferenceEntry> get secretReferences =>
_settingsController.buildSecretReferences();
List<SecretAuditEntry> get secretAuditTrail => _settingsController.auditTrail;
List<RuntimeLogEntry> get runtimeLogs => _runtime.logs;
List<WorkspaceDestination> get assistantNavigationDestinations =>
normalizeAssistantNavigationDestinations(
settings.assistantNavigationDestinations,
);
List<GatewayChatMessage> get chatMessages {
final items = List<GatewayChatMessage>.from(_chatController.messages);
final streaming = _chatController.streamingAssistantText?.trim() ?? '';
if (streaming.isNotEmpty) {
items.add(
GatewayChatMessage(
id: 'streaming',
role: 'assistant',
text: streaming,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: true,
error: false,
),
);
}
return items;
}
void navigateTo(WorkspaceDestination destination) {
if (_destination == destination) {
return;
}
_destination = destination;
_detailPanel = null;
notifyListeners();
}
void navigateHome() {
final mainSessionKey =
_runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true
? _runtime.snapshot.mainSessionKey!.trim()
: 'main';
final destinationChanged = _destination != WorkspaceDestination.assistant;
final detailChanged = _detailPanel != null;
_destination = WorkspaceDestination.assistant;
_detailPanel = null;
if (destinationChanged || detailChanged) {
notifyListeners();
}
if (_sessionsController.currentSessionKey != mainSessionKey) {
unawaited(switchSession(mainSessionKey));
}
}
void cycleSidebarState() {
_sidebarState = switch (_sidebarState) {
AppSidebarState.expanded => AppSidebarState.collapsed,
AppSidebarState.collapsed => AppSidebarState.hidden,
AppSidebarState.hidden => AppSidebarState.expanded,
};
notifyListeners();
}
void setSidebarState(AppSidebarState state) {
if (_sidebarState == state) {
return;
}
_sidebarState = state;
notifyListeners();
}
void setThemeMode(ThemeMode mode) {
if (_themeMode == mode) {
return;
}
_themeMode = mode;
notifyListeners();
}
Future<void> toggleAppLanguage() async {
await setAppLanguage(
settings.appLanguage == AppLanguage.zh ? AppLanguage.en : AppLanguage.zh,
);
}
Future<void> setAppLanguage(AppLanguage language) async {
if (settings.appLanguage == language) {
return;
}
setActiveAppLanguage(language);
await saveSettings(
settings.copyWith(appLanguage: language),
refreshAfterSave: false,
);
}
void openDetail(DetailPanelData detailPanel) {
_detailPanel = detailPanel;
notifyListeners();
}
void closeDetail() {
if (_detailPanel == null) {
return;
}
_detailPanel = null;
notifyListeners();
}
Future<void> connectWithSetupCode({
required String setupCode,
String token = '',
String password = '',
}) async {
final decoded = decodeGatewaySetupCode(setupCode);
final resolvedToken = token.trim().isNotEmpty
? token.trim()
: (decoded?.token.trim() ?? '');
final resolvedPassword = password.trim().isNotEmpty
? password.trim()
: (decoded?.password.trim() ?? '');
await _settingsController.saveGatewaySecrets(
token: resolvedToken,
password: resolvedPassword,
);
final nextProfile = settings.gateway.copyWith(
useSetupCode: true,
setupCode: setupCode.trim(),
host: decoded?.host ?? settings.gateway.host,
port: decoded?.port ?? settings.gateway.port,
tls: decoded?.tls ?? settings.gateway.tls,
mode: _modeFromHost(decoded?.host ?? settings.gateway.host),
);
await saveSettings(
settings.copyWith(gateway: nextProfile),
refreshAfterSave: false,
);
await _connectProfile(
nextProfile,
authTokenOverride: resolvedToken,
authPasswordOverride: resolvedPassword,
);
}
Future<void> connectManual({
required String host,
required int port,
required bool tls,
required RuntimeConnectionMode mode,
String token = '',
String password = '',
}) async {
await _settingsController.saveGatewaySecrets(
token: token.trim(),
password: password.trim(),
);
final resolvedHost =
host.trim().isEmpty && mode == RuntimeConnectionMode.local
? '127.0.0.1'
: host.trim();
final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0
? 18789
: port;
final nextProfile = settings.gateway.copyWith(
mode: mode,
useSetupCode: false,
setupCode: '',
host: resolvedHost,
port: resolvedPort <= 0 ? 443 : resolvedPort,
tls: mode == RuntimeConnectionMode.local ? false : tls,
);
await saveSettings(
settings.copyWith(gateway: nextProfile),
refreshAfterSave: false,
);
await _connectProfile(
nextProfile,
authTokenOverride: token.trim(),
authPasswordOverride: password.trim(),
);
}
Future<void> disconnectGateway() async {
_clearCodexGatewayRegistration();
await _runtime.disconnect(clearDesiredProfile: false);
await _settingsController.refreshDerivedState();
await _agentsController.refresh();
await _sessionsController.refresh();
_chatController.clear();
await _instancesController.refresh();
await _skillsController.refresh();
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
_devicesController.clear();
_recomputeTasks();
}
Future<void> connectSavedGateway() async {
await _connectProfile(settings.gateway);
}
Future<void> clearStoredGatewayToken() async {
await _settingsController.clearGatewaySecrets(token: true);
}
Future<void> refreshGatewayHealth() async {
if (!_runtime.isConnected) {
return;
}
try {
await _runtime.health();
} catch (_) {}
try {
await _runtime.status();
} catch (_) {}
notifyListeners();
}
Future<void> refreshDevices({bool quiet = false}) async {
await _devicesController.refresh(quiet: quiet);
}
Future<void> approveDevicePairing(String requestId) async {
await _devicesController.approve(requestId);
await _settingsController.refreshDerivedState();
}
Future<void> rejectDevicePairing(String requestId) async {
await _devicesController.reject(requestId);
}
Future<void> removePairedDevice(String deviceId) async {
await _devicesController.remove(deviceId);
await _settingsController.refreshDerivedState();
}
Future<String?> rotateDeviceRoleToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
final token = await _devicesController.rotateToken(
deviceId: deviceId,
role: role,
scopes: scopes,
);
await _settingsController.refreshDerivedState();
return token;
}
Future<void> revokeDeviceRoleToken({
required String deviceId,
required String role,
}) async {
await _devicesController.revokeToken(deviceId: deviceId, role: role);
await _settingsController.refreshDerivedState();
}
Future<void> refreshAgents() async {
await _agentsController.refresh();
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
_recomputeTasks();
}
Future<void> selectAgent(String? agentId) async {
_agentsController.selectAgent(agentId);
final nextProfile = settings.gateway.copyWith(
selectedAgentId: _agentsController.selectedAgentId,
);
await saveSettings(
settings.copyWith(gateway: nextProfile),
refreshAfterSave: false,
);
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
await _chatController.loadSession(_sessionsController.currentSessionKey);
await _skillsController.refresh(
agentId: _agentsController.selectedAgentId.isEmpty
? null
: _agentsController.selectedAgentId,
);
_recomputeTasks();
}
Future<void> refreshSessions() async {
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
await _sessionsController.refresh();
await _chatController.loadSession(_sessionsController.currentSessionKey);
_recomputeTasks();
}
Future<void> switchSession(String sessionKey) async {
await _sessionsController.switchSession(sessionKey);
await _chatController.loadSession(_sessionsController.currentSessionKey);
_recomputeTasks();
}
Future<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
}) async {
final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch(
_buildCodeAgentNodeState(),
);
await _chatController.sendMessage(
sessionKey: _sessionsController.currentSessionKey,
message: message,
thinking: thinking,
attachments: attachments,
agentId: dispatch.agentId,
metadata: dispatch.metadata,
);
_recomputeTasks();
}
Future<void> abortRun() async {
await _chatController.abortRun();
}
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {
if (settings.assistantExecutionTarget == target) {
return;
}
await saveSettings(
settings.copyWith(assistantExecutionTarget: target),
refreshAfterSave: false,
);
}
Future<void> setAssistantPermissionLevel(
AssistantPermissionLevel level,
) async {
if (settings.assistantPermissionLevel == level) {
return;
}
await saveSettings(
settings.copyWith(assistantPermissionLevel: level),
refreshAfterSave: false,
);
}
Future<void> selectDefaultModel(String modelId) async {
final trimmed = modelId.trim();
if (trimmed.isEmpty || settings.defaultModel == trimmed) {
return;
}
await saveSettings(
settings.copyWith(defaultModel: trimmed),
refreshAfterSave: false,
);
}
Future<void> updateAiGatewaySelection(List<String> selectedModels) async {
final available = settings.aiGateway.availableModels;
final normalized = selectedModels
.map((item) => item.trim())
.where((item) => item.isNotEmpty && available.contains(item))
.toList(growable: false);
final fallbackSelection = normalized.isNotEmpty
? normalized
: available.isNotEmpty
? <String>[available.first]
: const <String>[];
final currentDefaultModel = settings.defaultModel.trim();
final resolvedDefaultModel = fallbackSelection.contains(currentDefaultModel)
? currentDefaultModel
: fallbackSelection.isNotEmpty
? fallbackSelection.first
: '';
await saveSettings(
settings.copyWith(
aiGateway: settings.aiGateway.copyWith(
selectedModels: fallbackSelection,
),
defaultModel: resolvedDefaultModel,
),
refreshAfterSave: false,
);
}
Future<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final synced = await _settingsController.syncAiGatewayCatalog(
profile,
apiKeyOverride: apiKeyOverride,
);
_modelsController.restoreFromSettings(
_settingsController.snapshot.aiGateway,
);
_recomputeTasks();
return synced;
}
Future<void> saveSettings(
SettingsSnapshot snapshot, {
bool refreshAfterSave = true,
}) async {
final current = settings;
final sanitized = _sanitizeCodeAgentSettings(snapshot);
setActiveAppLanguage(sanitized.appLanguage);
await _settingsController.saveSnapshot(sanitized);
_agentsController.restoreSelection(sanitized.gateway.selectedAgentId);
_modelsController.restoreFromSettings(sanitized.aiGateway);
if (current.codexCliPath != sanitized.codexCliPath ||
current.codeAgentRuntimeMode != sanitized.codeAgentRuntimeMode) {
_registerCodexExternalProvider(codexPath: sanitized.codexCliPath);
await _refreshCodexCliAvailability();
}
if (refreshAfterSave) {
_recomputeTasks();
}
}
Future<void> toggleAssistantNavigationDestination(
WorkspaceDestination destination,
) async {
if (!kAssistantNavigationDestinationCandidates.contains(destination)) {
return;
}
final current = assistantNavigationDestinations;
final next = current.contains(destination)
? current.where((item) => item != destination).toList(growable: false)
: <WorkspaceDestination>[...current, destination];
await saveSettings(
settings.copyWith(assistantNavigationDestinations: next),
refreshAfterSave: false,
);
}
Future<String> testOllamaConnection({required bool cloud}) {
return _settingsController.testOllamaConnection(cloud: cloud);
}
Future<String> testVaultConnection() {
return _settingsController.testVaultConnection();
}
void clearRuntimeLogs() {
_runtimeCoordinator.gateway.clearLogs();
_notifyIfActive();
}
List<DerivedTaskItem> taskItemsForTab(String tab) => switch (tab) {
'Queue' => _tasksController.queue,
'Running' => _tasksController.running,
'History' => _tasksController.history,
'Failed' => _tasksController.failed,
'Scheduled' => _tasksController.scheduled,
_ => _tasksController.queue,
};
/// Enable Codex ↔ Gateway bridge
Future<void> enableCodexBridge() async {
if (_isCodexBridgeEnabled || _isCodexBridgeBusy) return;
_isCodexBridgeBusy = true;
_codexBridgeError = null;
try {
final gatewayUrl = aiGatewayUrl;
final apiKey = await loadAiGatewayApiKey();
if (gatewayUrl.isEmpty) {
throw StateError(
appText('AI Gateway URL 未配置', 'AI Gateway URL not configured'),
);
}
final runtimeMode = effectiveCodeAgentRuntimeMode;
String? codexPath;
if (runtimeMode == CodeAgentRuntimeMode.externalCli) {
codexPath = await _resolveCodexCliPath();
if (codexPath == null) {
throw StateError(
appText(
'未找到 Codex CLI。请先安装或填写可执行文件路径。',
'Codex CLI not found. Install it or set a manual binary path.',
),
);
}
}
await _runtimeCoordinator.configureCodexForGateway(
gatewayUrl: gatewayUrl,
apiKey: apiKey,
);
await _runtimeCoordinator.startCodeAgentRuntime(
runtimeMode: runtimeMode,
codexPath: codexPath,
workingDirectory: _resolveCodexWorkingDirectory(),
);
_registerCodexExternalProvider(codexPath: codexPath);
_isCodexBridgeEnabled = true;
_codexCooperationState = CodexCooperationState.bridgeOnly;
await _ensureCodexGatewayRegistration();
notifyListeners();
} catch (e) {
_codexBridgeError = e.toString();
notifyListeners();
rethrow;
} finally {
_isCodexBridgeBusy = false;
notifyListeners();
}
}
/// Disable Codex ↔ Gateway bridge
Future<void> disableCodexBridge() async {
if (!_isCodexBridgeEnabled || _isCodexBridgeBusy) return;
_isCodexBridgeBusy = true;
try {
if (_runtime.isConnected && _codeAgentBridgeRegistry.isRegistered) {
await _codeAgentBridgeRegistry.unregister();
} else {
_codeAgentBridgeRegistry.clearRegistration();
}
await _runtimeCoordinator.stopCodeAgentRuntime();
_isCodexBridgeEnabled = false;
_codexCooperationState = CodexCooperationState.notStarted;
_codexBridgeError = null;
notifyListeners();
} catch (e) {
_codexBridgeError = e.toString();
notifyListeners();
rethrow;
} finally {
_isCodexBridgeBusy = false;
notifyListeners();
}
}
@override
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
_runtimeEventsSubscription?.cancel();
_detachChildListeners();
_runtimeCoordinator.dispose();
_settingsController.dispose();
_agentsController.dispose();
_sessionsController.dispose();
_chatController.dispose();
_instancesController.dispose();
_skillsController.dispose();
_connectorsController.dispose();
_modelsController.dispose();
_cronJobsController.dispose();
_devicesController.dispose();
_tasksController.dispose();
_store.dispose();
super.dispose();
}
Future<void> _initialize() async {
try {
await _settingsController.initialize();
if (_disposed) {
return;
}
final bootstrap = await RuntimeBootstrapConfig.load(
workspacePathHint: settings.workspacePath,
cliPathHint: settings.cliPath,
);
if (_disposed) {
return;
}
final seeded = bootstrap.mergeIntoSettings(settings);
if (seeded.toJsonString() != settings.toJsonString()) {
await _settingsController.saveSnapshot(seeded);
if (_disposed) {
return;
}
}
final normalized = _sanitizeCodeAgentSettings(
_settingsController.snapshot,
);
if (normalized.toJsonString() !=
_settingsController.snapshot.toJsonString()) {
await _settingsController.saveSnapshot(normalized);
if (_disposed) {
return;
}
}
_modelsController.restoreFromSettings(settings.aiGateway);
setActiveAppLanguage(settings.appLanguage);
_registerCodexExternalProvider();
await _refreshCodexCliAvailability();
if (_disposed) {
return;
}
_agentsController.restoreSelection(settings.gateway.selectedAgentId);
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
_runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen(
_handleRuntimeEvent,
);
final shouldAutoConnect =
settings.gateway.useSetupCode &&
settings.gateway.setupCode.trim().isNotEmpty;
if (shouldAutoConnect) {
try {
await _connectProfile(settings.gateway);
} catch (_) {
// Keep the shell usable when auto-connect fails.
}
}
} catch (error) {
if (_disposed) {
return;
}
_bootstrapError = error.toString();
} finally {
if (!_disposed) {
_initializing = false;
_notifyIfActive();
}
}
}
Future<void> _connectProfile(
GatewayConnectionProfile profile, {
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
await _runtime.connectProfile(
profile,
authTokenOverride: authTokenOverride,
authPasswordOverride: authPasswordOverride,
);
await refreshGatewayHealth();
await refreshAgents();
await refreshSessions();
await _instancesController.refresh();
await _skillsController.refresh(
agentId: _agentsController.selectedAgentId.isEmpty
? null
: _agentsController.selectedAgentId,
);
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
await _devicesController.refresh(quiet: true);
await _settingsController.refreshDerivedState();
await _ensureCodexGatewayRegistration();
_recomputeTasks();
}
void _handleRuntimeEvent(GatewayPushEvent event) {
_chatController.handleEvent(event);
if (event.event == 'chat') {
final payload = asMap(event.payload);
final state = stringValue(payload['state']);
if (state == 'final' || state == 'aborted' || state == 'error') {
unawaited(refreshSessions());
}
}
if (event.event == 'seqGap') {
unawaited(refreshSessions());
}
if (event.event == 'device.pair.requested' ||
event.event == 'device.pair.resolved') {
unawaited(refreshDevices(quiet: true));
}
}
SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) {
_codexRuntimeWarning =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? appText(
'内置 Codex 仍处于实验阶段;建议优先使用 External Codex CLI。',
'Built-in Codex is still experimental; External Codex CLI is recommended.',
)
: null;
final normalizedPath = snapshot.codexCliPath.trim();
if (normalizedPath == snapshot.codexCliPath) {
return snapshot;
}
return snapshot.copyWith(codexCliPath: normalizedPath);
}
Future<void> _refreshCodexCliAvailability() async {
_resolvedCodexCliPath = await _runtimeCoordinator.resolveCodexPath(
codexPath: settings.codexCliPath,
);
_notifyIfActive();
}
Future<String?> _resolveCodexCliPath() async {
if (_resolvedCodexCliPath != null) {
return _resolvedCodexCliPath;
}
await _refreshCodexCliAvailability();
return _resolvedCodexCliPath;
}
String? _resolveCodexWorkingDirectory() {
final candidate = settings.workspacePath.trim();
if (candidate.isEmpty) {
return null;
}
final directory = Directory(candidate);
return directory.existsSync() ? directory.path : null;
}
void _registerCodexExternalProvider({String? codexPath}) {
_runtimeCoordinator.registerExternalCodeAgent(
ExternalCodeAgentProvider(
id: 'codex',
name: 'Codex CLI',
command: (codexPath?.trim().isNotEmpty ?? false)
? codexPath!.trim()
: 'codex',
defaultArgs: const <String>['app-server', '--listen', 'stdio://'],
capabilities: const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
],
),
);
}
CodeAgentNodeState _buildCodeAgentNodeState() {
return CodeAgentNodeState(
selectedAgentId: _agentsController.selectedAgentId,
gatewayConnected: _runtime.isConnected,
executionTarget: settings.assistantExecutionTarget,
runtimeMode: effectiveCodeAgentRuntimeMode,
bridgeEnabled: _isCodexBridgeEnabled,
bridgeState: _codexCooperationState.name,
preferredProviderId: 'codex',
resolvedCodexCliPath: _resolvedCodexCliPath,
configuredCodexCliPath: configuredCodexCliPath,
);
}
GatewayMode _bridgeGatewayMode() {
return switch (settings.gateway.mode) {
RuntimeConnectionMode.local => GatewayMode.local,
RuntimeConnectionMode.remote => GatewayMode.remote,
RuntimeConnectionMode.unconfigured => GatewayMode.offline,
};
}
Future<void> _ensureCodexGatewayRegistration() async {
if (!_isCodexBridgeEnabled) {
return;
}
if (!_runtime.isConnected) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
_codeAgentBridgeRegistry.clearRegistration();
notifyListeners();
return;
}
if (_codeAgentBridgeRegistry.isRegistered) {
_codexCooperationState = CodexCooperationState.registered;
notifyListeners();
return;
}
try {
final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch(
_buildCodeAgentNodeState(),
);
await _codeAgentBridgeRegistry.register(
agentType: 'code-agent-bridge',
name: 'XWorkmate Codex Bridge',
version: kAppVersion,
transport: 'stdio-bridge',
capabilities: const <AgentCapability>[
AgentCapability(
name: 'chat',
description: 'Bridge external Codex CLI chat turns.',
),
AgentCapability(
name: 'code-edit',
description: 'Bridge code editing tasks through Codex CLI.',
),
AgentCapability(
name: 'memory-sync',
description: 'Coordinate memory sync through OpenClaw Gateway.',
),
],
metadata: <String, dynamic>{
...dispatch.metadata,
'providerId': 'codex',
'runtimeMode': effectiveCodeAgentRuntimeMode.name,
'gatewayMode': _bridgeGatewayMode().name,
'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath)
.trim()
.isNotEmpty,
'capabilities': const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
],
},
);
_codexCooperationState = CodexCooperationState.registered;
_codexBridgeError = null;
} catch (error) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
_codexBridgeError = error.toString();
}
notifyListeners();
}
void _clearCodexGatewayRegistration() {
_codeAgentBridgeRegistry.clearRegistration();
if (_isCodexBridgeEnabled) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
} else {
_codexCooperationState = CodexCooperationState.notStarted;
}
notifyListeners();
}
void _recomputeTasks() {
_tasksController.recompute(
sessions: _sessionsController.sessions,
cronJobs: _cronJobsController.items,
currentSessionKey: _sessionsController.currentSessionKey,
hasPendingRun: _chatController.hasPendingRun,
activeAgentName: _agentsController.activeAgentName,
);
}
void _attachChildListeners() {
_runtimeCoordinator.addListener(_relayChildChange);
_settingsController.addListener(_relayChildChange);
_agentsController.addListener(_relayChildChange);
_sessionsController.addListener(_relayChildChange);
_chatController.addListener(_relayChildChange);
_instancesController.addListener(_relayChildChange);
_skillsController.addListener(_relayChildChange);
_connectorsController.addListener(_relayChildChange);
_modelsController.addListener(_relayChildChange);
_cronJobsController.addListener(_relayChildChange);
_devicesController.addListener(_relayChildChange);
_tasksController.addListener(_relayChildChange);
}
void _detachChildListeners() {
_runtimeCoordinator.removeListener(_relayChildChange);
_settingsController.removeListener(_relayChildChange);
_agentsController.removeListener(_relayChildChange);
_sessionsController.removeListener(_relayChildChange);
_chatController.removeListener(_relayChildChange);
_instancesController.removeListener(_relayChildChange);
_skillsController.removeListener(_relayChildChange);
_connectorsController.removeListener(_relayChildChange);
_modelsController.removeListener(_relayChildChange);
_cronJobsController.removeListener(_relayChildChange);
_devicesController.removeListener(_relayChildChange);
_tasksController.removeListener(_relayChildChange);
}
void _relayChildChange() {
_notifyIfActive();
}
void _notifyIfActive() {
if (_disposed) {
return;
}
notifyListeners();
}
RuntimeConnectionMode _modeFromHost(String host) {
final trimmed = host.trim().toLowerCase();
if (trimmed == '127.0.0.1' || trimmed == 'localhost') {
return RuntimeConnectionMode.local;
}
return RuntimeConnectionMode.remote;
}
}