import 'dart:async'; import 'package:flutter/material.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'; class AppController extends ChangeNotifier { AppController() { _runtime = GatewayRuntime( store: _store, identityStore: DeviceIdentityStore(_store), ); _settingsController = SettingsController(_store); _agentsController = GatewayAgentsController(_runtime); _sessionsController = GatewaySessionsController(_runtime); _chatController = GatewayChatController(_runtime); _instancesController = InstancesController(_runtime); _skillsController = SkillsController(_runtime); _connectorsController = ConnectorsController(_runtime); _modelsController = ModelsController(_runtime, _settingsController); _cronJobsController = CronJobsController(_runtime); _devicesController = DevicesController(_runtime); _tasksController = DerivedTasksController(); _attachChildListeners(); unawaited(_initialize()); } final SecureConfigStore _store = SecureConfigStore(); late final GatewayRuntime _runtime; 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? _runtimeEventsSubscription; WorkspaceDestination get destination => _destination; ThemeMode get themeMode => _themeMode; AppSidebarState get sidebarState => _sidebarState; DetailPanelData? get detailPanel => _detailPanel; bool get initializing => _initializing; String? get bootstrapError => _bootstrapError; GatewayRuntime get runtime => _runtime; 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 get agents => _agentsController.agents; List get sessions => _sessionsController.sessions; List get instances => _instancesController.items; List get skills => _skillsController.items; List get connectors => _connectorsController.items; List get models => _modelsController.items; List 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']; List 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 get secretReferences => _settingsController.buildSecretReferences(); List get secretAuditTrail => _settingsController.auditTrail; List get runtimeLogs => _runtime.logs; List get chatMessages { final items = List.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 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 toggleAppLanguage() async { await setAppLanguage( settings.appLanguage == AppLanguage.zh ? AppLanguage.en : AppLanguage.zh, ); } Future 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 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 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 disconnectGateway() async { 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 connectSavedGateway() async { await _connectProfile(settings.gateway); } Future clearStoredGatewayToken() async { await _settingsController.clearGatewaySecrets(token: true); } Future refreshGatewayHealth() async { if (!_runtime.isConnected) { return; } try { await _runtime.health(); } catch (_) {} try { await _runtime.status(); } catch (_) {} notifyListeners(); } Future refreshDevices({bool quiet = false}) async { await _devicesController.refresh(quiet: quiet); } Future approveDevicePairing(String requestId) async { await _devicesController.approve(requestId); await _settingsController.refreshDerivedState(); } Future rejectDevicePairing(String requestId) async { await _devicesController.reject(requestId); } Future removePairedDevice(String deviceId) async { await _devicesController.remove(deviceId); await _settingsController.refreshDerivedState(); } Future rotateDeviceRoleToken({ required String deviceId, required String role, List scopes = const [], }) async { final token = await _devicesController.rotateToken( deviceId: deviceId, role: role, scopes: scopes, ); await _settingsController.refreshDerivedState(); return token; } Future revokeDeviceRoleToken({ required String deviceId, required String role, }) async { await _devicesController.revokeToken(deviceId: deviceId, role: role); await _settingsController.refreshDerivedState(); } Future refreshAgents() async { await _agentsController.refresh(); _sessionsController.configure( mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', selectedAgentId: _agentsController.selectedAgentId, defaultAgentId: '', ); _recomputeTasks(); } Future 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 refreshSessions() async { _sessionsController.configure( mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', selectedAgentId: _agentsController.selectedAgentId, defaultAgentId: '', ); await _sessionsController.refresh(); await _chatController.loadSession(_sessionsController.currentSessionKey); _recomputeTasks(); } Future switchSession(String sessionKey) async { await _sessionsController.switchSession(sessionKey); await _chatController.loadSession(_sessionsController.currentSessionKey); _recomputeTasks(); } Future sendChatMessage( String message, { String thinking = 'off', List attachments = const [], }) async { await _chatController.sendMessage( sessionKey: _sessionsController.currentSessionKey, message: message, thinking: thinking, attachments: attachments, ); _recomputeTasks(); } Future abortRun() async { await _chatController.abortRun(); } Future setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { if (settings.assistantExecutionTarget == target) { return; } await saveSettings( settings.copyWith(assistantExecutionTarget: target), refreshAfterSave: false, ); } Future setAssistantPermissionLevel( AssistantPermissionLevel level, ) async { if (settings.assistantPermissionLevel == level) { return; } await saveSettings( settings.copyWith(assistantPermissionLevel: level), refreshAfterSave: false, ); } Future selectDefaultModel(String modelId) async { final trimmed = modelId.trim(); if (trimmed.isEmpty || settings.defaultModel == trimmed) { return; } await saveSettings( settings.copyWith(defaultModel: trimmed), refreshAfterSave: false, ); } Future updateAiGatewaySelection(List 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 ? [available.first] : const []; 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 syncAiGatewayCatalog( AiGatewayProfile profile, { String apiKeyOverride = '', }) async { final synced = await _settingsController.syncAiGatewayCatalog( profile, apiKeyOverride: apiKeyOverride, ); _modelsController.restoreFromSettings( _settingsController.snapshot.aiGateway, ); _recomputeTasks(); return synced; } Future saveSettings( SettingsSnapshot snapshot, { bool refreshAfterSave = true, }) async { setActiveAppLanguage(snapshot.appLanguage); await _settingsController.saveSnapshot(snapshot); _agentsController.restoreSelection(snapshot.gateway.selectedAgentId); _modelsController.restoreFromSettings(snapshot.aiGateway); if (refreshAfterSave) { _recomputeTasks(); } } Future testOllamaConnection({required bool cloud}) { return _settingsController.testOllamaConnection(cloud: cloud); } Future testVaultConnection() { return _settingsController.testVaultConnection(); } void clearRuntimeLogs() { _runtime.clearLogs(); } List taskItemsForTab(String tab) => switch (tab) { 'Queue' => _tasksController.queue, 'Running' => _tasksController.running, 'History' => _tasksController.history, 'Failed' => _tasksController.failed, 'Scheduled' => _tasksController.scheduled, _ => _tasksController.queue, }; @override void dispose() { _runtimeEventsSubscription?.cancel(); _detachChildListeners(); _runtime.dispose(); _settingsController.dispose(); _agentsController.dispose(); _sessionsController.dispose(); _chatController.dispose(); _instancesController.dispose(); _skillsController.dispose(); _connectorsController.dispose(); _modelsController.dispose(); _cronJobsController.dispose(); _devicesController.dispose(); _tasksController.dispose(); super.dispose(); } Future _initialize() async { try { await _settingsController.initialize(); final bootstrap = await RuntimeBootstrapConfig.load( workspacePathHint: settings.workspacePath, cliPathHint: settings.cliPath, ); final seeded = bootstrap.mergeIntoSettings(settings); if (seeded.toJsonString() != settings.toJsonString()) { await _settingsController.saveSnapshot(seeded); } _modelsController.restoreFromSettings(settings.aiGateway); setActiveAppLanguage(settings.appLanguage); await _runtime.initialize(); _agentsController.restoreSelection(settings.gateway.selectedAgentId); _sessionsController.configure( mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', selectedAgentId: _agentsController.selectedAgentId, defaultAgentId: '', ); _runtimeEventsSubscription = _runtime.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) { _bootstrapError = error.toString(); } finally { _initializing = false; notifyListeners(); } } Future _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(); _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)); } } void _recomputeTasks() { _tasksController.recompute( sessions: _sessionsController.sessions, cronJobs: _cronJobsController.items, currentSessionKey: _sessionsController.currentSessionKey, hasPendingRun: _chatController.hasPendingRun, activeAgentName: _agentsController.activeAgentName, ); } void _attachChildListeners() { _runtime.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() { _runtime.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() { notifyListeners(); } RuntimeConnectionMode _modeFromHost(String host) { final trimmed = host.trim().toLowerCase(); if (trimmed == '127.0.0.1' || trimmed == 'localhost') { return RuntimeConnectionMode.local; } return RuntimeConnectionMode.remote; } }