From 14f824b8ef77a13c5123a7883fee44f6d3d6c598 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 23 Jul 2026 00:03:42 +0200 Subject: [PATCH] feat(doctor,shell): ollama host-service suggestion + hub-update hint (0.80.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor: when the system AI uses an Ollama endpoint that no declared host service covers (host:port match, /v1 suffix stripped), the services panel says so in one sentence with a one-click 'declare as host service' via the new DeclareService RPC — and states honestly that it takes effect after a daemon restart (the restart button sits on the same page). Pure suggestOllamaServiceEndpoint pins every branch. Shell: one slim, dismissible banner after connecting when the release manifest offers a newer hub version; dismissal is persisted per version so each release hints exactly once (pure shouldShowUpdateHint + a widget test through the fake hub). Deliberately manifest-based — Studio and hub versions are independent counters, so a direct comparison would be wrong; unreleased dev skew stays with the per-page classified states. The probe stays inert under the test probe override: its timeout timer leaked into hub_banner_test (the hermeticity class again). Signed-off-by: flemming-it --- CHANGELOG.md | 17 ++ lib/data/about_info.dart | 2 +- lib/data/hub.dart | 36 +++ lib/l10n/app_de.arb | 8 + lib/l10n/app_en.arb | 8 + lib/l10n/app_localizations.dart | 36 +++ lib/l10n/app_localizations_de.dart | 23 ++ lib/l10n/app_localizations_en.dart | 23 ++ lib/main.dart | 166 +++++++++-- lib/pages/doctor.dart | 354 ++++++++++++++++------- pubspec.yaml | 2 +- test/ollama_service_suggestion_test.dart | 149 ++++++++++ test/support/fake_hub.dart | 14 + 13 files changed, 703 insertions(+), 135 deletions(-) create mode 100644 test/ollama_service_suggestion_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d95a3c..85798e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ lockstep. ## Unreleased +### Added (0.80.0) + +- **Ollama joins the host services.** Ollama-backed store entries + now declare `requires_services: [ollama]` hub-side, and the + doctor's services panel notices when the system AI uses an Ollama + endpoint that is not declared as a host service: one plain + sentence plus a one-click "declare as host service" (via the new + DeclareService RPC; states honestly that a daemon restart is + required). +- **The shell says when a hub update exists.** One slim, dismissible + banner after connecting when the release manifest offers a newer + hub version — dismissal is remembered per version, the doctor + page keeps the apply button. (Deliberately manifest-based: + Studio and hub version numbers are independent counters, so a + direct comparison would be wrong; unreleased dev skew stays with + the per-page "needs a newer hub" states.) + ### Added (0.79.0) - **Maintainers in the store.** The detail sheet always answers "who diff --git a/lib/data/about_info.dart b/lib/data/about_info.dart index 71f20cb..bcbb3ff 100644 --- a/lib/data/about_info.dart +++ b/lib/data/about_info.dart @@ -4,7 +4,7 @@ /// Studio's own build version. Bump on every UI release so the /// running app self-identifies. -const String kStudioVersion = '0.79.0'; +const String kStudioVersion = '0.80.0'; const String kProductName = 'Ch∆In Studio'; const String kVendorName = 'Flemming.AI (F∆I)'; diff --git a/lib/data/hub.dart b/lib/data/hub.dart index aa04481..a53c766 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -1221,6 +1221,42 @@ class HubService { } /// Composite "doctor" snapshot. One round-trip per piece, run + /// Declare a host service in the operator config. Returns true + /// when a daemon restart is required for it to take effect + /// (always, today — services are read at start). + Future declareService({ + required String name, + required String endpoint, + String healthPath = '', + List tags = const [], + }) => _client.declareService( + name: name, + endpoint: endpoint, + healthPath: healthPath, + tags: tags, + ); + + /// Lightweight update probe for the shell's one-time hint: + /// null when the manifest is unreachable / no update / any + /// error — the hint simply stays away then. + Future checkHubUpdate() async { + try { + final r = await _client.checkUpdate().timeout( + const Duration(seconds: 3), + ); + if (!r.updateAvailable) return null; + return UpdateStatus( + channel: r.channel, + localVersion: r.localVersion, + latestVersion: r.latestVersion, + updateAvailable: r.updateAvailable, + manifestReachable: r.manifestReachable, + ); + } catch (_) { + return null; + } + } + /// Version string of the RUNNING hub (from CheckUpdate's /// local_version — a local RPC field; the manifest fetch result /// is ignored). Empty when the hub is unreachable. Used by diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 858dbdb..6826847 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1191,6 +1191,14 @@ } }, "doctorSummaryDeclared": "deklariert", + "doctorOllamaSuggest": "Die System-KI nutzt Ollama auf {endpoint} — als Host-Dienst deklarieren, damit Module und Diagnose ihn kennen?", + "@doctorOllamaSuggest": {"placeholders": {"endpoint": {"type": "String"}}}, + "doctorOllamaDeclareButton": "Als Host-Dienst deklarieren", + "doctorOllamaDeclared": "Als Host-Dienst „ollama“ deklariert — wirksam nach einem Daemon-Neustart (Knopf unten auf dieser Seite).", + "updateHintBanner": "Für den Hub ({local}) ist Version {latest} verfügbar.", + "@updateHintBanner": {"placeholders": {"local": {"type": "String"}, "latest": {"type": "String"}}}, + "updateHintOpenDoctor": "Diagnose öffnen", + "updateHintDismiss": "Später", "svcExposureLoopback": "nur lokal", "svcExposurePrivate": "privates Netz", "svcExposurePublic": "öffentlich erreichbar", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c2c3a96..7ff858d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1215,6 +1215,14 @@ } }, "doctorSummaryDeclared": "declared", + "doctorOllamaSuggest": "The system AI uses Ollama at {endpoint} — declare it as a host service so modules and the doctor know it?", + "@doctorOllamaSuggest": {"placeholders": {"endpoint": {"type": "String"}}}, + "doctorOllamaDeclareButton": "Declare as host service", + "doctorOllamaDeclared": "Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).", + "updateHintBanner": "Version {latest} is available for the hub ({local}).", + "@updateHintBanner": {"placeholders": {"local": {"type": "String"}, "latest": {"type": "String"}}}, + "updateHintOpenDoctor": "Open Doctor", + "updateHintDismiss": "Later", "svcExposureLoopback": "local only", "svcExposurePrivate": "private network", "svcExposurePublic": "publicly reachable", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ff78b39..caff8a4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3704,6 +3704,42 @@ abstract class AppLocalizations { /// **'declared'** String get doctorSummaryDeclared; + /// No description provided for @doctorOllamaSuggest. + /// + /// In en, this message translates to: + /// **'The system AI uses Ollama at {endpoint} — declare it as a host service so modules and the doctor know it?'** + String doctorOllamaSuggest(String endpoint); + + /// No description provided for @doctorOllamaDeclareButton. + /// + /// In en, this message translates to: + /// **'Declare as host service'** + String get doctorOllamaDeclareButton; + + /// No description provided for @doctorOllamaDeclared. + /// + /// In en, this message translates to: + /// **'Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).'** + String get doctorOllamaDeclared; + + /// No description provided for @updateHintBanner. + /// + /// In en, this message translates to: + /// **'Version {latest} is available for the hub ({local}).'** + String updateHintBanner(String local, String latest); + + /// No description provided for @updateHintOpenDoctor. + /// + /// In en, this message translates to: + /// **'Open Doctor'** + String get updateHintOpenDoctor; + + /// No description provided for @updateHintDismiss. + /// + /// In en, this message translates to: + /// **'Later'** + String get updateHintDismiss; + /// No description provided for @svcExposureLoopback. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8c792fe..ce214cf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2157,6 +2157,29 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorSummaryDeclared => 'deklariert'; + @override + String doctorOllamaSuggest(String endpoint) { + return 'Die System-KI nutzt Ollama auf $endpoint — als Host-Dienst deklarieren, damit Module und Diagnose ihn kennen?'; + } + + @override + String get doctorOllamaDeclareButton => 'Als Host-Dienst deklarieren'; + + @override + String get doctorOllamaDeclared => + 'Als Host-Dienst „ollama“ deklariert — wirksam nach einem Daemon-Neustart (Knopf unten auf dieser Seite).'; + + @override + String updateHintBanner(String local, String latest) { + return 'Für den Hub ($local) ist Version $latest verfügbar.'; + } + + @override + String get updateHintOpenDoctor => 'Diagnose öffnen'; + + @override + String get updateHintDismiss => 'Später'; + @override String get svcExposureLoopback => 'nur lokal'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e9a515f..6bc4405 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2160,6 +2160,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorSummaryDeclared => 'declared'; + @override + String doctorOllamaSuggest(String endpoint) { + return 'The system AI uses Ollama at $endpoint — declare it as a host service so modules and the doctor know it?'; + } + + @override + String get doctorOllamaDeclareButton => 'Declare as host service'; + + @override + String get doctorOllamaDeclared => + 'Declared as host service \"ollama\" — takes effect after a daemon restart (button further down this page).'; + + @override + String updateHintBanner(String local, String latest) { + return 'Version $latest is available for the hub ($local).'; + } + + @override + String get updateHintOpenDoctor => 'Open Doctor'; + + @override + String get updateHintDismiss => 'Later'; + @override String get svcExposureLoopback => 'local only'; diff --git a/lib/main.dart b/lib/main.dart index 8586937..5e1d1b7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ // Tokens in lib/theme/, primitives in lib/widgets/. import 'dart:async'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform; @@ -285,8 +286,7 @@ class StudioAppState extends State { startSidebarExpanded: widget.startSidebarExpanded, ) : SetupGateScreen( - onDone: () => - setState(() => _setupGateDone = true), + onDone: () => setState(() => _setupGateDone = true), ), ); }, @@ -375,9 +375,9 @@ class StudioShellState extends State { final override = debugReloadTokenOverride; if (override != null) return override(); if (debugProbeOverride != null) return Future.value(false); - return HubService.instance - .reloadAuthTokenIfChanged() - .catchError((_) => false); + return HubService.instance.reloadAuthTokenIfChanged().catchError( + (_) => false, + ); } /// Current connection state, for descendants (e.g. WelcomePage) @@ -392,9 +392,9 @@ class StudioShellState extends State { final r = await SystemActions.chainDaemon(['start']); if (!mounted) return; if (r.ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.daemonStartRequested)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.daemonStartRequested))); Future.delayed(const Duration(seconds: 1), _checkHealth); return; } @@ -515,6 +515,43 @@ class StudioShellState extends State { ); } + /// One-time hub-update hint (zero-learning-curve: the shell says + /// actively that a newer hub release exists instead of hiding it + /// on the doctor page). Null = nothing to show. Dismissal is + /// persisted per latest-version, so each release hints once. + UpdateStatus? _updateHint; + bool _updateProbed = false; + + Future _probeUpdateOnce() async { + if (_updateProbed) return; + _updateProbed = true; + // Inert under a test probe override (same rule as + // _reloadTokenIfChanged): those suites drive the shell against + // the REAL HubService, and the update probe's timeout timer + // would leak past the test body. Banner tests inject the fake + // hub instead, which answers without timers. + if (debugProbeOverride != null) return; + final st = await HubService.instance.checkHubUpdate(); + if (!mounted || st == null) return; + final prefs = await SharedPreferences.getInstance(); + final dismissed = prefs.getString('update.hint.dismissed'); + if (!mounted) return; + if (shouldShowUpdateHint( + latestVersion: st.latestVersion, + dismissedVersion: dismissed, + )) { + setState(() => _updateHint = st); + } + } + + Future _dismissUpdateHint() async { + final latest = _updateHint?.latestVersion; + setState(() => _updateHint = null); + if (latest == null) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('update.hint.dismissed', latest); + } + Future _checkHealth({bool retriedAfterTokenReload = false}) async { final probe = debugProbeOverride != null ? await debugProbeOverride!() @@ -528,6 +565,10 @@ class StudioShellState extends State { final endpointChanged = endpoint != _polledEndpoint; _polledEndpoint = endpoint; final ok = probe == HubProbeResult.serving; + if (ok) { + // Fire-and-forget; guarded by _updateProbed. + unawaited(_probeUpdateOnce()); + } final authRejected = probe == HubProbeResult.authRejected; final wasUnreachable = _hubUnreachable; final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1); @@ -561,7 +602,9 @@ class StudioShellState extends State { if (_activeChannel != snap.active) { setState(() => _activeChannel = snap.active); } - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } try { // The badge counts the active workspace's pending approvals // (empty slug = all projects), matching the Approvals page. @@ -572,7 +615,9 @@ class StudioShellState extends State { if (_pendingApprovals != pending.length) { setState(() => _pendingApprovals = pending.length); } - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } } @@ -696,11 +741,21 @@ class StudioShellState extends State { child: Column( children: [ const ChainSealedIdentityBar(), + if (_updateHint != null) + _UpdateHintBanner( + status: _updateHint!, + onOpenDoctor: () { + navigateTo('doctor'); + unawaited(_dismissUpdateHint()); + }, + onDismiss: _dismissUpdateHint, + ), if (_hubUnreachable) _HubUnreachableBanner( endpoint: HubService.instance.endpointLabel, authRejected: _authRejected, - onOpenSettings: () => ChainSettingsDialog.show(context), + onOpenSettings: () => + ChainSettingsDialog.show(context), ), Expanded( child: AnimatedSwitcher( @@ -916,9 +971,9 @@ class _SidebarState extends State<_Sidebar> final r = await SystemActions.chainDaemon(['start']); if (!context.mounted) return; if (r.ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.daemonStartRequested)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.daemonStartRequested))); return; } // The daemon may already be running (port in use) — probe before @@ -1064,14 +1119,17 @@ class _SidebarState extends State<_Sidebar> t: t, labelsInteractive: labelsInteractive, iconColumnWidth: _collapsedWidth, - badge: widget.pages[i].id == 'approvals' && + badge: + widget.pages[i].id == 'approvals' && widget.pendingApprovals > 0 ? widget.pendingApprovals : null, // Cmd+1..9 jump to the destination; the // hint rides in tooltip + expanded label // so the shortcut is discoverable. - shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null, + shortcutHint: i < 9 + ? _metaShortcut('${i + 1}') + : null, onTap: () => widget.onSelect(i), ), ], @@ -1308,7 +1366,9 @@ Future _showChannelSwitchMenu(BuildContext anchor, String current) async { HubEndpoint(host: '127.0.0.1', port: target.port), persist: false, ); - } catch (_) {/* health poll will retry */} + } catch (_) { + /* health poll will retry */ + } if (anchor.mounted) { messenger.showSnackBar( SnackBar(content: Text(l.channelSwitchOk(selected))), @@ -1549,7 +1609,9 @@ class _ConnectionLabel extends StatelessWidget { // which reports the *active* channel's daemon. final ch = HubService.instance.connectedChannelName; final caption = connected == true - ? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected) + ? (ch != null + ? '${l.connectionConnected} · $ch' + : l.connectionConnected) : connected == false ? l.connectionTapToStart : l.connectionConnecting; @@ -1653,10 +1715,7 @@ class _SidebarItemState extends State<_SidebarItem> { right: -6, top: -4, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 1, - ), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), constraints: const BoxConstraints(minWidth: 16, minHeight: 14), decoration: BoxDecoration( color: theme.colorScheme.primary, @@ -2187,3 +2246,66 @@ class _LanguageToggle extends StatelessWidget { // the editor directly with the full runDriver bridge. There // is one Flows destination; the standalone editor route is // gone. + +/// Whether the one-time update hint should show for [latestVersion] +/// given the persisted [dismissedVersion]. Pure so the unit test +/// drives it: each release hints exactly once. +bool shouldShowUpdateHint({ + required String latestVersion, + required String? dismissedVersion, +}) { + if (latestVersion.isEmpty) return false; + return latestVersion != dismissedVersion; +} + +/// Slim, dismissible banner: a newer hub release exists. Neutral +/// tone (informational, not an error); the doctor page holds the +/// apply button. +class _UpdateHintBanner extends StatelessWidget { + final UpdateStatus status; + final VoidCallback onOpenDoctor; + final VoidCallback onDismiss; + + const _UpdateHintBanner({ + required this.status, + required this.onOpenDoctor, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Material( + color: theme.colorScheme.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.lg, + vertical: ChainSpace.xs, + ), + child: Row( + children: [ + Icon( + Icons.system_update_alt_outlined, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Text( + l.updateHintBanner(status.localVersion, status.latestVersion), + style: theme.textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: onOpenDoctor, + child: Text(l.updateHintOpenDoctor), + ), + TextButton(onPressed: onDismiss, child: Text(l.updateHintDismiss)), + ], + ), + ), + ); + } +} diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 431c71c..f4fe89d 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -392,8 +392,11 @@ class _EventLogPanel extends StatelessWidget { child: InkWell( onTap: onOpenAudit, borderRadius: BorderRadius.circular(ChainRadius.sm), - child: Semantics(button: true, label: l.doctorLinkAudit, - child: headline), + child: Semantics( + button: true, + label: l.doctorLinkAudit, + child: headline, + ), ), ), ); @@ -539,7 +542,8 @@ class _PathRow extends StatelessWidget { // Text config files get an in-Studio viewer too (read top-down, // no log colouring) so the operator can read config.yaml // without leaving Studio or hunting for an external editor. - final isConfig = !isDirectory && + final isConfig = + !isDirectory && (lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.toml')); @@ -575,9 +579,9 @@ class _PathRow extends StatelessWidget { onPressed: () async { await Clipboard.setData(ClipboardData(text: path)); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.aboutCopiedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.aboutCopiedToast))); }, ), const SizedBox(width: ChainSpace.xs), @@ -917,9 +921,7 @@ String _sourceKindLabel(String kind) { case 'system': return 'System'; default: - return kind.isEmpty - ? kind - : kind[0].toUpperCase() + kind.substring(1); + return kind.isEmpty ? kind : kind[0].toUpperCase() + kind.substring(1); } } @@ -1065,117 +1067,246 @@ class DoctorModulesPanel extends StatelessWidget { } } -class _ServicesPanel extends StatelessWidget { +class _ServicesPanel extends StatefulWidget { final DoctorSnapshot snapshot; const _ServicesPanel({required this.snapshot}); + @override + State<_ServicesPanel> createState() => _ServicesPanelState(); +} + +class _ServicesPanelState extends State<_ServicesPanel> { + DoctorSnapshot get snapshot => widget.snapshot; + + /// Best-effort system-AI probe for the Ollama suggestion; null + /// (no suggestion) on any failure. + SystemAiStatus? _ai; + + /// True once the operator declared the suggested service this + /// session — the hub's services() stays the boot snapshot, so + /// the row flips to the honest "takes effect after restart" + /// note instead of re-suggesting. + bool _declared = false; + bool _declaring = false; + + @override + void initState() { + super.initState(); + HubService.instance + .systemAiStatus() + .then((st) { + if (mounted) setState(() => _ai = st); + }) + .catchError((_) {}); + } + + Future _declare(String endpoint) async { + final l = AppLocalizations.of(context)!; + setState(() => _declaring = true); + try { + await HubService.instance.declareService( + name: 'ollama', + endpoint: endpoint, + healthPath: '/', + tags: const ['llm'], + ); + if (!mounted) return; + setState(() { + _declared = true; + _declaring = false; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.doctorOllamaDeclared))); + } catch (e) { + if (!mounted) return; + setState(() => _declaring = false); + showChainErrorSnack(context, 'doctor.declare-service', e); + } + } + + /// The suggestion strip, or null when there is nothing to say. + Widget? _suggestionRow(AppLocalizations l) { + final ai = _ai; + if (ai == null || _declared) { + return _declared + ? ChainInlineHelp( + icon: Icons.check_circle_outline, + text: l.doctorOllamaDeclared, + ) + : null; + } + final endpoint = suggestOllamaServiceEndpoint( + ai: ai, + services: snapshot.services, + ); + if (endpoint == null) return null; + return ChainInlineHelp( + icon: Icons.dns_outlined, + text: l.doctorOllamaSuggest(endpoint), + onLearnMore: _declaring ? null : () => _declare(endpoint), + learnMoreLabel: l.doctorOllamaDeclareButton, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; + final suggestion = _suggestionRow(l); if (snapshot.services.isEmpty) { // Wrap on narrow windows so the mono-spaced hint // (`add to ~/.chain/config.yaml under services:`) does not // overflow horizontally past the card's right edge. - return ChainCard( - child: Wrap( - spacing: ChainSpace.sm, - runSpacing: ChainSpace.xs, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (suggestion != null) ...[ + suggestion, + const SizedBox(height: ChainSpace.sm), + ], + ChainCard( + child: Wrap( + spacing: ChainSpace.sm, + runSpacing: ChainSpace.xs, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Icon( - Icons.dns_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.dns_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: ChainSpace.sm), + Text( + l.doctorServicesEmpty, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), - const SizedBox(width: ChainSpace.sm), Text( - l.doctorServicesEmpty, - style: theme.textTheme.bodyMedium?.copyWith( + l.doctorServicesEmptyHint, + style: ChainTheme.mono( + size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), + // The hint names config.yaml — put the file one tap away + // instead of making the operator hunt for it. + if (snapshot.paths.configPath.isNotEmpty) + OutlinedButton.icon( + onPressed: () => showFaiConfigViewer( + context, + path: snapshot.paths.configPath, + title: l.doctorPathConfig, + ), + icon: const Icon(Icons.settings_outlined, size: 14), + label: Text(l.doctorLinkConfig), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), ], ), - Text( - l.doctorServicesEmptyHint, - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - // The hint names config.yaml — put the file one tap away - // instead of making the operator hunt for it. - if (snapshot.paths.configPath.isNotEmpty) - OutlinedButton.icon( - onPressed: () => showFaiConfigViewer( - context, - path: snapshot.paths.configPath, - title: l.doctorPathConfig, - ), - icon: const Icon(Icons.settings_outlined, size: 14), - label: Text(l.doctorLinkConfig), - style: OutlinedButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ], - ), + ), + ], ); } - return ChainCard( - padding: EdgeInsets.zero, - child: Column( - children: [ - for (var i = 0; i < snapshot.services.length; i++) ...[ - if (i > 0) - Divider(height: 1, color: theme.colorScheme.outlineVariant), - Padding( - padding: const EdgeInsets.all(ChainSpace.lg), - child: Row( - children: [ - Icon( - Icons.dns_outlined, - size: 16, - color: theme.colorScheme.primary, - ), - const SizedBox(width: ChainSpace.sm), - Text( - snapshot.services[i].name, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: ChainSpace.md), - Text( - snapshot.services[i].endpoint, - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - _ServiceExposurePill( - exposure: snapshot.services[i].exposure, - ), - for (final tag in snapshot.services[i].tags) ...[ - ChainPill(label: tag, tone: ChainPillTone.neutral), - const SizedBox(width: ChainSpace.xs), - ], - ], - ), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (suggestion != null) ...[ + suggestion, + const SizedBox(height: ChainSpace.sm), ], - ), + ChainCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + for (var i = 0; i < snapshot.services.length; i++) ...[ + if (i > 0) + Divider(height: 1, color: theme.colorScheme.outlineVariant), + Padding( + padding: const EdgeInsets.all(ChainSpace.lg), + child: Row( + children: [ + Icon( + Icons.dns_outlined, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: ChainSpace.sm), + Text( + snapshot.services[i].name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: ChainSpace.md), + Text( + snapshot.services[i].endpoint, + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + _ServiceExposurePill( + exposure: snapshot.services[i].exposure, + ), + for (final tag in snapshot.services[i].tags) ...[ + ChainPill(label: tag, tone: ChainPillTone.neutral), + const SizedBox(width: ChainSpace.xs), + ], + ], + ), + ), + ], + ], + ), + ), + ], ); } } +/// The system AI's Ollama endpoint as a host-service suggestion, +/// or null when there is nothing to suggest: AI disabled, another +/// provider, or a declared service already covers the same +/// host:port. Returns the base URL (the /v1 suffix the OpenAI- +/// compatible config carries is stripped — the service entry +/// describes the server, not one API flavour). Top-level so the +/// unit test drives every branch. +String? suggestOllamaServiceEndpoint({ + required SystemAiStatus ai, + required List services, +}) { + if (!ai.enabled || ai.provider != 'ollama' || ai.endpoint.trim().isEmpty) { + return null; + } + var base = ai.endpoint.trim(); + for (final suffix in ['/v1/', '/v1']) { + if (base.endsWith(suffix)) { + base = base.substring(0, base.length - suffix.length); + break; + } + } + final target = Uri.tryParse(base); + if (target == null || target.host.isEmpty) return null; + for (final s in services) { + final u = Uri.tryParse(s.endpoint); + if (u != null && u.host == target.host && u.port == target.port) { + return null; + } + } + return base; +} + /// Network-reach pill per declared service, from the hub's /// endpoint classification (`DeclaredService.exposure`). Public /// endpoints get the warning tone — a host service reachable from @@ -1193,15 +1324,15 @@ class _ServiceExposurePill extends StatelessWidget { 'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''), 'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''), 'public' => ( - l.svcExposurePublic, - ChainPillTone.warning, - l.svcExposurePublicHint, - ), + l.svcExposurePublic, + ChainPillTone.warning, + l.svcExposurePublicHint, + ), 'unknown' => ( - l.svcExposureUnknown, - ChainPillTone.neutral, - l.svcExposureUnknownHint, - ), + l.svcExposureUnknown, + ChainPillTone.neutral, + l.svcExposureUnknownHint, + ), _ => ('', ChainPillTone.neutral, ''), }; if (label.isEmpty) return const SizedBox.shrink(); @@ -1296,21 +1427,22 @@ class _UpdateBannerState extends State<_UpdateBanner> { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => SystemActions.openInOs( - status.releaseNotesUrl!, - ), + onTap: () => + SystemActions.openInOs(status.releaseNotesUrl!), child: Semantics( button: true, label: l.doctorLinkReleaseNotes, child: Text( l.doctorReleaseNotes(status.releaseNotesUrl!), - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.primary, - ).copyWith( - decoration: TextDecoration.underline, - decorationColor: theme.colorScheme.primary, - ), + style: + ChainTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ).copyWith( + decoration: TextDecoration.underline, + decorationColor: + theme.colorScheme.primary, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 2937cb6..7149725 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: chain_studio description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" publish_to: 'none' -version: 0.79.0 +version: 0.80.0 environment: sdk: ^3.11.0-200.1.beta diff --git a/test/ollama_service_suggestion_test.dart b/test/ollama_service_suggestion_test.dart new file mode 100644 index 0000000..8669b48 --- /dev/null +++ b/test/ollama_service_suggestion_test.dart @@ -0,0 +1,149 @@ +// The doctor's "declare the system AI's Ollama as a host service" +// suggestion and the shell's one-time update hint — both are pure +// functions so every branch is pinned here. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart' + show StudioApp, shouldShowUpdateHint; +import 'package:chain_studio/pages/doctor.dart' + show suggestOllamaServiceEndpoint; + +import 'support/fake_hub.dart'; + +SystemAiStatus _ai({ + bool enabled = true, + String provider = 'ollama', + String endpoint = 'http://127.0.0.1:11434/v1', +}) => SystemAiStatus( + enabled: enabled, + provider: provider, + endpoint: endpoint, + model: 'qwen2.5:14b', + privacyMode: 'off', + apiKeyEnv: '', +); + +void main() { + group('suggestOllamaServiceEndpoint', () { + test('suggests the base URL with the /v1 suffix stripped', () { + expect( + suggestOllamaServiceEndpoint(ai: _ai(), services: const []), + 'http://127.0.0.1:11434', + ); + }); + + test('nothing to suggest when AI is off or another provider', () { + expect( + suggestOllamaServiceEndpoint(ai: _ai(enabled: false), services: const []), + isNull, + ); + expect( + suggestOllamaServiceEndpoint( + ai: _ai(provider: 'openai'), + services: const [], + ), + isNull, + ); + expect( + suggestOllamaServiceEndpoint(ai: _ai(endpoint: ''), services: const []), + isNull, + ); + }); + + test('a declared service on the same host:port silences it', () { + expect( + suggestOllamaServiceEndpoint( + ai: _ai(), + services: const [ + ServiceEntry( + name: 'my-llm', + endpoint: 'http://127.0.0.1:11434', + tags: [], + ), + ], + ), + isNull, + ); + }); + + test('a service on another port does not silence it', () { + expect( + suggestOllamaServiceEndpoint( + ai: _ai(), + services: const [ + ServiceEntry( + name: 'judge-ner', + endpoint: 'http://127.0.0.1:8756', + tags: [], + ), + ], + ), + 'http://127.0.0.1:11434', + ); + }); + }); + + testWidgets('the shell hints once and dismisses persistently', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final fake = installFakeHub(); + fake.updateHint = const UpdateStatus( + channel: 'stable', + localVersion: '0.22.0', + latestVersion: '0.23.0', + updateAvailable: true, + manifestReachable: true, + ); + await tester.pumpWidget( + const StudioApp( + initialThemeMode: ThemeModeValue.dark, + initialLocale: Locale('de'), + ), + ); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.textContaining('0.23.0'), findsOneWidget); + await tester.tap(find.text('Später')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.textContaining('0.23.0'), findsNothing); + // Dismissal is persisted per version. + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('update.hint.dismissed'), '0.23.0'); + // Drain shell timers (a11y-suite pattern). + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(minutes: 1)); + }); + + group('shouldShowUpdateHint', () { + test('hints once per release version', () { + expect( + shouldShowUpdateHint(latestVersion: '0.23.0', dismissedVersion: null), + isTrue, + ); + expect( + shouldShowUpdateHint( + latestVersion: '0.23.0', + dismissedVersion: '0.22.0', + ), + isTrue, + ); + expect( + shouldShowUpdateHint( + latestVersion: '0.23.0', + dismissedVersion: '0.23.0', + ), + isFalse, + ); + expect( + shouldShowUpdateHint(latestVersion: '', dismissedVersion: null), + isFalse, + ); + }); + }); +} diff --git a/test/support/fake_hub.dart b/test/support/fake_hub.dart index 08f6903..219d308 100644 --- a/test/support/fake_hub.dart +++ b/test/support/fake_hub.dart @@ -257,6 +257,20 @@ class FakeHubService extends Fake implements HubService { Future> listN8nEndpoints() => _async('listN8nEndpoints', () => const []); + /// Scriptable update hint for the shell banner test. + UpdateStatus? updateHint; + + @override + Future checkHubUpdate() async => updateHint; + + @override + Future declareService({ + required String name, + required String endpoint, + String healthPath = '', + List tags = const [], + }) => _async('declareService', () => true); + @override Future doctor() => _async( 'doctor',