Skip to content
Open
Binary file added assets/icons/hermes_agent.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions lib/core/persistence/persistence_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ final class PreferenceKeys {
static const String notificationChannelEnabled =
'notification_channel_enabled';

// Hermes Agent (direct second backend) — non-secret config. The API key and
// long-term memory session key are secrets and live in SecureCredentialStorage.
static const String hermesEnabled = 'hermes_enabled_v1';
static const String hermesBaseUrl = 'hermes_base_url_v1';
static const String hermesShowJobs = 'hermes_show_jobs_v1';

/// Which backend onboarding completed against ('owui' | 'hermes' | unset).
/// Read synchronously by the router for boot-deterministic routing.
static const String preferredBackend = 'preferred_backend_v1';

// Drawer section collapsed states
static const String drawerShowPinned = 'drawer_show_pinned';
static const String drawerShowFolders = 'drawer_show_folders';
Expand Down
46 changes: 41 additions & 5 deletions lib/core/providers/app_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import '../services/worker_manager.dart';
import '../../shared/theme/tweakcn_themes.dart';
import '../../shared/theme/app_theme.dart';
import '../../features/tools/providers/tools_providers.dart';
import '../../features/hermes/models/hermes_model.dart';
import '../../features/hermes/providers/hermes_providers.dart';
import '../models/socket_transport_availability.dart';
import 'storage_providers.dart';
import 'package:drift/drift.dart' show Value;
Expand Down Expand Up @@ -652,11 +654,20 @@ class Models extends _$Models {
}

if (!ref.watch(isAuthenticatedProvider2)) {
// Hermes-only mode: no OpenWebUI auth, but a usable Hermes agent — surface
// just the synthetic Hermes model so the picker/composer work.
if (ref.watch(hermesConfigProvider).isUsable) {
return _withHermes(const <Model>[]);
}
DebugLogger.log('skip-unauthed', scope: 'models');
_persistModelsAsync(const <Model>[]);
return const [];
}

// Re-run when the Hermes toggle flips so the synthetic model appears or
// disappears from the picker.
ref.watch(hermesEnabledProvider);

final storage = ref.watch(optimizedStorageServiceProvider);
try {
final cached = await storage.getLocalModels();
Expand Down Expand Up @@ -685,7 +696,7 @@ class Models extends _$Models {
);
}
});
return visibleCached;
return _withHermes(visibleCached);
}
} catch (error, stackTrace) {
DebugLogger.error(
Expand All @@ -704,7 +715,16 @@ class Models extends _$Models {
}

final fresh = await _load(api);
return fresh;
return _withHermes(fresh);
}

/// Appends the synthetic Hermes agent model when the feature is enabled.
/// Called after OpenWebUI models are persisted so Hermes never lands in the
/// model cache, and guarded against duplicates on rebuild.
List<Model> _withHermes(List<Model> models) {
if (!ref.read(hermesEnabledProvider)) return models;
if (models.any(isHermesModel)) return models;
return [...models, hermesSyntheticModel()];
}

Future<void> refresh() async {
Expand All @@ -725,12 +745,13 @@ class Models extends _$Models {
}
final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return;
state = result;
final withHermes = result.whenData(_withHermes);
state = withHermes;

// Update selected model with fresh data (e.g., filters) if it exists
// in the new models list
if (result.hasValue) {
final freshModels = result.value!;
if (withHermes.hasValue) {
final freshModels = withHermes.value!;
final currentSelected = ref.read(selectedModelProvider);
if (currentSelected != null) {
if (currentSelected.isHidden) {
Expand Down Expand Up @@ -1870,6 +1891,21 @@ Future<Model?> defaultModel(Ref ref) async {

final api = ref.watch(apiServiceProvider);
if (api == null) {
// Hermes-only mode: auto-select the synthetic Hermes agent model.
if (ref.read(hermesConfigProvider).isUsable) {
final models = await ref.read(modelsProvider.future);
Model? hermes;
for (final model in models) {
if (isHermesModel(model)) {
hermes = model;
break;
}
}
if (hermes != null && !ref.read(isManualModelSelectionProvider)) {
ref.read(selectedModelProvider.notifier).set(hermes);
}
return hermes;
}
DebugLogger.warning('no-api', scope: 'models/default');
return null;
}
Expand Down
40 changes: 40 additions & 0 deletions lib/core/providers/backend_mode_providers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../persistence/persistence_keys.dart';
import '../persistence/preferences_store.dart';

/// Which backend the user has onboarded against. Drives boot-deterministic
/// routing so a Hermes-only install never bounces to the OpenWebUI server
/// connection screen while async state (active server / Hermes secrets) loads.
enum PreferredBackend { unset, owui, hermes }

/// Synchronous, persisted preferred-backend signal. Read by the router.
///
/// Unlike the derived `hermesOnlyModeProvider`, this resolves synchronously at
/// boot from shared preferences (mirroring `reviewerModeProvider`'s role) so the
/// first `redirect()` is correct without waiting on async providers.
class PreferredBackendController extends Notifier<PreferredBackend> {
@override
PreferredBackend build() => _parse(
PreferencesStore.getString(PreferenceKeys.preferredBackend),
);

Future<void> set(PreferredBackend backend) async {
state = backend;
await PreferencesStore.put(
PreferenceKeys.preferredBackend,
backend.name,
);
}

static PreferredBackend _parse(String? raw) => switch (raw) {
'owui' => PreferredBackend.owui,
'hermes' => PreferredBackend.hermes,
_ => PreferredBackend.unset,
};
}

final preferredBackendProvider =
NotifierProvider<PreferredBackendController, PreferredBackend>(
PreferredBackendController.new,
);
69 changes: 66 additions & 3 deletions lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import 'package:go_router/go_router.dart';

import '../auth/auth_state_manager.dart';
import '../providers/app_providers.dart';
import '../providers/backend_mode_providers.dart';
import '../../features/hermes/models/hermes_config.dart';
import '../../features/hermes/providers/hermes_providers.dart';
import '../services/navigation_service.dart';
import '../services/performance_profiler.dart';
import '../utils/debug_logger.dart';
import '../../features/auth/providers/unified_auth_providers.dart';
import '../../features/auth/views/authentication_page.dart';
import '../../features/auth/views/backend_chooser_page.dart';
import '../../features/auth/views/connect_signin_page.dart';
import '../../features/auth/views/connection_issue_page.dart';
import '../../features/auth/views/proxy_auth_page.dart';
Expand All @@ -29,6 +33,8 @@ import '../../features/profile/views/about_page.dart';
import '../../features/profile/views/account_settings_page.dart';
import '../../features/profile/views/app_customization_page.dart';
import '../../features/profile/views/audio_settings_page.dart';
import '../../features/hermes/views/hermes_settings_page.dart';
import '../../features/hermes/views/hermes_jobs_page.dart';
import '../../features/profile/views/personalization_page.dart';
import '../../features/profile/views/profile_page.dart';
import '../../features/notifications/views/notification_settings_page.dart';
Expand All @@ -47,6 +53,10 @@ class RouterNotifier extends ChangeNotifier {
authNavigationStateProvider,
_onStateChanged,
),
// Hermes-only routing: re-evaluate when the preferred backend changes or
// the Hermes config becomes usable (secrets finish loading).
ref.listen<PreferredBackend>(preferredBackendProvider, _onStateChanged),
ref.listen<HermesConfig>(hermesConfigProvider, _onStateChanged),
];
}

Expand Down Expand Up @@ -85,9 +95,23 @@ class RouterNotifier extends ChangeNotifier {
return Routes.chat;
}

// Onboarding screens (backend chooser + Hermes setup) always render.
if (location == Routes.backendChooser ||
location == Routes.hermesSettings) {
return null;
}

final preferredBackend = ref.read(preferredBackendProvider);
final hermesUsable = ref.read(hermesConfigProvider).isUsable;
final prefersHermes = preferredBackend == PreferredBackend.hermes;

if (activeServerAsync.isLoading) {
// Avoid redirect loops: do not override explicit auth routes while loading
if (_isAuthLocation(location)) return null;
// Hermes-only user: don't flash the OWUI splash→serverConnection path.
if (prefersHermes && hermesUsable) {
return location == Routes.chat ? null : Routes.chat;
}
// Keep splash during server loading otherwise
return location == Routes.splash ? null : Routes.splash;
}
Expand All @@ -98,19 +122,38 @@ class RouterNotifier extends ChangeNotifier {

final activeServer = activeServerAsync.asData?.value;
final hasActiveServer = activeServer != null;

// Hermes-only mode: onboarded to Hermes with no OWUI server → straight to
// chat, bypassing OWUI server/auth entirely (mirrors reviewer mode).
if (prefersHermes && !hasActiveServer) {
// Let a Hermes-only user reach the OWUI connect/auth flow so they can add
// an Open WebUI server (bidirectional switching). Once connected,
// preferredBackend flips to owui and this branch no longer applies.
if (_isAuthLocation(location)) return null;
if (hermesUsable) {
return location == Routes.chat ? null : Routes.chat;
}
// Not usable yet. If Hermes is still enabled, the API key secret is just
// loading → hold on splash (the hermesConfigProvider subscription re-runs
// this once it resolves). If disabled (config cleared), fall through to
// the chooser so the user is never stranded.
if (ref.read(hermesConfigProvider).enabled) {
return location == Routes.splash ? null : Routes.splash;
}
}

if (!hasActiveServer) {
// No server configured - redirect to server connection
// No server configured - redirect to onboarding chooser.
// Exception: allow staying on server connection, authentication,
// proxy auth, and SSO pages during the connection/auth flow.
// But always redirect away from connection issue page (user logged out)
if (location == Routes.serverConnection ||
location == Routes.authentication ||
location == Routes.proxyAuth ||
location == Routes.ssoAuth ||
location == Routes.login) {
return null;
}
return Routes.serverConnection;
return Routes.backendChooser;
}

final authState = ref.read(authNavigationStateProvider);
Expand Down Expand Up @@ -258,6 +301,12 @@ final goRouterProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) =>
_buildPlatformPage(state: state, child: const ConnectAndSignInPage()),
),
GoRoute(
path: Routes.backendChooser,
name: RouteNames.backendChooser,
pageBuilder: (context, state) =>
_buildPlatformPage(state: state, child: const BackendChooserPage()),
),
GoRoute(
path: Routes.serverConnection,
name: RouteNames.serverConnection,
Expand Down Expand Up @@ -362,6 +411,20 @@ final goRouterProvider = Provider<GoRouter>((ref) {
child: const NotificationSettingsPage(),
),
),
GoRoute(
path: Routes.hermesSettings,
name: RouteNames.hermesSettings,
pageBuilder: (context, state) => _buildPlatformPage(
state: state,
child: HermesSettingsPage(isOnboarding: state.extra == true),
),
),
GoRoute(
path: Routes.hermesJobs,
name: RouteNames.hermesJobs,
pageBuilder: (context, state) =>
_buildPlatformPage(state: state, child: const HermesJobsPage()),
),
GoRoute(
path: Routes.about,
name: RouteNames.about,
Expand Down
1 change: 1 addition & 0 deletions lib/core/services/native_sheet_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class NativeSheetRoutes {
static const voice = 'voice';
static const aiMemory = 'ai-memory';
static const dataConnection = 'data-connection';
static const hermes = 'hermes';
static const helpAbout = 'help-about';
static const about = 'about';
static const notificationSettings = 'notification-settings';
Expand Down
Loading