1176 lines
38 KiB
Dart
1176 lines
38 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/desktop_platform_service.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,
|
|
DesktopPlatformService? desktopPlatformService,
|
|
}) {
|
|
_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();
|
|
_desktopPlatformService =
|
|
desktopPlatformService ?? createDesktopPlatformService();
|
|
_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;
|
|
late final DesktopPlatformService _desktopPlatformService;
|
|
|
|
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
|
ThemeMode _themeMode = ThemeMode.light;
|
|
AppSidebarState _sidebarState = AppSidebarState.expanded;
|
|
DetailPanelData? _detailPanel;
|
|
bool _initializing = true;
|
|
String? _bootstrapError;
|
|
StreamSubscription<GatewayPushEvent>? _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;
|
|
|
|
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;
|
|
DesktopIntegrationState get desktopIntegration =>
|
|
_desktopPlatformService.state;
|
|
bool get supportsDesktopIntegration => desktopIntegration.isSupported;
|
|
bool get desktopPlatformBusy => _desktopPlatformBusy;
|
|
|
|
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;
|
|
bool _desktopPlatformBusy = false;
|
|
|
|
Future<String> loadAiGatewayApiKey() async {
|
|
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
|
|
}
|
|
|
|
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 (current.linuxDesktop.toJson().toString() !=
|
|
sanitized.linuxDesktop.toJson().toString() ||
|
|
current.launchAtLogin != sanitized.launchAtLogin) {
|
|
await _desktopPlatformService.syncConfig(sanitized.linuxDesktop);
|
|
await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin);
|
|
}
|
|
if (refreshAfterSave) {
|
|
_recomputeTasks();
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> refreshDesktopIntegration() async {
|
|
_desktopPlatformBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await _desktopPlatformService.refresh();
|
|
} finally {
|
|
_desktopPlatformBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> saveLinuxDesktopConfig(LinuxDesktopConfig config) async {
|
|
await saveSettings(settings.copyWith(linuxDesktop: config));
|
|
}
|
|
|
|
Future<void> setDesktopVpnMode(VpnMode mode) async {
|
|
_desktopPlatformBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await saveSettings(
|
|
settings.copyWith(
|
|
linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode),
|
|
),
|
|
refreshAfterSave: false,
|
|
);
|
|
await _desktopPlatformService.setMode(mode);
|
|
} finally {
|
|
_desktopPlatformBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> connectDesktopTunnel() async {
|
|
_desktopPlatformBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await _desktopPlatformService.connectTunnel();
|
|
} finally {
|
|
_desktopPlatformBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> disconnectDesktopTunnel() async {
|
|
_desktopPlatformBusy = true;
|
|
notifyListeners();
|
|
try {
|
|
await _desktopPlatformService.disconnectTunnel();
|
|
} finally {
|
|
_desktopPlatformBusy = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> setLaunchAtLogin(bool enabled) async {
|
|
await saveSettings(
|
|
settings.copyWith(launchAtLogin: enabled),
|
|
refreshAfterSave: false,
|
|
);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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() {
|
|
_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();
|
|
_desktopPlatformService.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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);
|
|
}
|
|
final normalized = _sanitizeCodeAgentSettings(
|
|
_settingsController.snapshot,
|
|
);
|
|
if (normalized.toJsonString() !=
|
|
_settingsController.snapshot.toJsonString()) {
|
|
await _settingsController.saveSnapshot(normalized);
|
|
}
|
|
_modelsController.restoreFromSettings(settings.aiGateway);
|
|
setActiveAppLanguage(settings.appLanguage);
|
|
await _desktopPlatformService.initialize(settings.linuxDesktop);
|
|
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
|
|
_registerCodexExternalProvider();
|
|
await _refreshCodexCliAvailability();
|
|
_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) {
|
|
_bootstrapError = error.toString();
|
|
} finally {
|
|
_initializing = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
notifyListeners();
|
|
}
|
|
|
|
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() {
|
|
notifyListeners();
|
|
}
|
|
|
|
RuntimeConnectionMode _modeFromHost(String host) {
|
|
final trimmed = host.trim().toLowerCase();
|
|
if (trimmed == '127.0.0.1' || trimmed == 'localhost') {
|
|
return RuntimeConnectionMode.local;
|
|
}
|
|
return RuntimeConnectionMode.remote;
|
|
}
|
|
}
|