feat(doctor,shell): ollama host-service suggestion + hub-update hint (0.80.0)
Some checks failed
Security / Security check (push) Failing after 2s

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-23 00:03:42 +02:00
parent 351c5a82bc
commit 14f824b8ef
13 changed files with 703 additions and 135 deletions

View file

@ -6,6 +6,23 @@ lockstep.
## Unreleased ## 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) ### Added (0.79.0)
- **Maintainers in the store.** The detail sheet always answers "who - **Maintainers in the store.** The detail sheet always answers "who

View file

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the /// Studio's own build version. Bump on every UI release so the
/// running app self-identifies. /// running app self-identifies.
const String kStudioVersion = '0.79.0'; const String kStudioVersion = '0.80.0';
const String kProductName = 'Ch∆In Studio'; const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)'; const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -1221,6 +1221,42 @@ class HubService {
} }
/// Composite "doctor" snapshot. One round-trip per piece, run /// 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<bool> declareService({
required String name,
required String endpoint,
String healthPath = '',
List<String> 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<UpdateStatus?> 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 /// Version string of the RUNNING hub (from CheckUpdate's
/// local_version a local RPC field; the manifest fetch result /// local_version a local RPC field; the manifest fetch result
/// is ignored). Empty when the hub is unreachable. Used by /// is ignored). Empty when the hub is unreachable. Used by

View file

@ -1191,6 +1191,14 @@
} }
}, },
"doctorSummaryDeclared": "deklariert", "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", "svcExposureLoopback": "nur lokal",
"svcExposurePrivate": "privates Netz", "svcExposurePrivate": "privates Netz",
"svcExposurePublic": "öffentlich erreichbar", "svcExposurePublic": "öffentlich erreichbar",

View file

@ -1215,6 +1215,14 @@
} }
}, },
"doctorSummaryDeclared": "declared", "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", "svcExposureLoopback": "local only",
"svcExposurePrivate": "private network", "svcExposurePrivate": "private network",
"svcExposurePublic": "publicly reachable", "svcExposurePublic": "publicly reachable",

View file

@ -3704,6 +3704,42 @@ abstract class AppLocalizations {
/// **'declared'** /// **'declared'**
String get doctorSummaryDeclared; 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. /// No description provided for @svcExposureLoopback.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2157,6 +2157,29 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'deklariert'; 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 @override
String get svcExposureLoopback => 'nur lokal'; String get svcExposureLoopback => 'nur lokal';

View file

@ -2160,6 +2160,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'declared'; 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 @override
String get svcExposureLoopback => 'local only'; String get svcExposureLoopback => 'local only';

View file

@ -4,6 +4,7 @@
// Tokens in lib/theme/, primitives in lib/widgets/. // Tokens in lib/theme/, primitives in lib/widgets/.
import 'dart:async'; import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_client_sdk/chain_client_sdk.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/foundation.dart' show defaultTargetPlatform;
@ -285,8 +286,7 @@ class StudioAppState extends State<StudioApp> {
startSidebarExpanded: widget.startSidebarExpanded, startSidebarExpanded: widget.startSidebarExpanded,
) )
: SetupGateScreen( : SetupGateScreen(
onDone: () => onDone: () => setState(() => _setupGateDone = true),
setState(() => _setupGateDone = true),
), ),
); );
}, },
@ -375,9 +375,9 @@ class StudioShellState extends State<StudioShell> {
final override = debugReloadTokenOverride; final override = debugReloadTokenOverride;
if (override != null) return override(); if (override != null) return override();
if (debugProbeOverride != null) return Future.value(false); if (debugProbeOverride != null) return Future.value(false);
return HubService.instance return HubService.instance.reloadAuthTokenIfChanged().catchError(
.reloadAuthTokenIfChanged() (_) => false,
.catchError((_) => false); );
} }
/// Current connection state, for descendants (e.g. WelcomePage) /// Current connection state, for descendants (e.g. WelcomePage)
@ -392,9 +392,9 @@ class StudioShellState extends State<StudioShell> {
final r = await SystemActions.chainDaemon(['start']); final r = await SystemActions.chainDaemon(['start']);
if (!mounted) return; if (!mounted) return;
if (r.ok) { if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.daemonStartRequested)), context,
); ).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
Future.delayed(const Duration(seconds: 1), _checkHealth); Future.delayed(const Duration(seconds: 1), _checkHealth);
return; return;
} }
@ -515,6 +515,43 @@ class StudioShellState extends State<StudioShell> {
); );
} }
/// 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<void> _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<void> _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<void> _checkHealth({bool retriedAfterTokenReload = false}) async { Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
final probe = debugProbeOverride != null final probe = debugProbeOverride != null
? await debugProbeOverride!() ? await debugProbeOverride!()
@ -528,6 +565,10 @@ class StudioShellState extends State<StudioShell> {
final endpointChanged = endpoint != _polledEndpoint; final endpointChanged = endpoint != _polledEndpoint;
_polledEndpoint = endpoint; _polledEndpoint = endpoint;
final ok = probe == HubProbeResult.serving; final ok = probe == HubProbeResult.serving;
if (ok) {
// Fire-and-forget; guarded by _updateProbed.
unawaited(_probeUpdateOnce());
}
final authRejected = probe == HubProbeResult.authRejected; final authRejected = probe == HubProbeResult.authRejected;
final wasUnreachable = _hubUnreachable; final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1); final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
@ -561,7 +602,9 @@ class StudioShellState extends State<StudioShell> {
if (_activeChannel != snap.active) { if (_activeChannel != snap.active) {
setState(() => _activeChannel = snap.active); setState(() => _activeChannel = snap.active);
} }
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
try { try {
// The badge counts the active workspace's pending approvals // The badge counts the active workspace's pending approvals
// (empty slug = all projects), matching the Approvals page. // (empty slug = all projects), matching the Approvals page.
@ -572,7 +615,9 @@ class StudioShellState extends State<StudioShell> {
if (_pendingApprovals != pending.length) { if (_pendingApprovals != pending.length) {
setState(() => _pendingApprovals = pending.length); setState(() => _pendingApprovals = pending.length);
} }
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
} }
} }
@ -696,11 +741,21 @@ class StudioShellState extends State<StudioShell> {
child: Column( child: Column(
children: [ children: [
const ChainSealedIdentityBar(), const ChainSealedIdentityBar(),
if (_updateHint != null)
_UpdateHintBanner(
status: _updateHint!,
onOpenDoctor: () {
navigateTo('doctor');
unawaited(_dismissUpdateHint());
},
onDismiss: _dismissUpdateHint,
),
if (_hubUnreachable) if (_hubUnreachable)
_HubUnreachableBanner( _HubUnreachableBanner(
endpoint: HubService.instance.endpointLabel, endpoint: HubService.instance.endpointLabel,
authRejected: _authRejected, authRejected: _authRejected,
onOpenSettings: () => ChainSettingsDialog.show(context), onOpenSettings: () =>
ChainSettingsDialog.show(context),
), ),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
@ -916,9 +971,9 @@ class _SidebarState extends State<_Sidebar>
final r = await SystemActions.chainDaemon(['start']); final r = await SystemActions.chainDaemon(['start']);
if (!context.mounted) return; if (!context.mounted) return;
if (r.ok) { if (r.ok) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.daemonStartRequested)), context,
); ).showSnackBar(SnackBar(content: Text(l.daemonStartRequested)));
return; return;
} }
// The daemon may already be running (port in use) probe before // The daemon may already be running (port in use) probe before
@ -1064,14 +1119,17 @@ class _SidebarState extends State<_Sidebar>
t: t, t: t,
labelsInteractive: labelsInteractive, labelsInteractive: labelsInteractive,
iconColumnWidth: _collapsedWidth, iconColumnWidth: _collapsedWidth,
badge: widget.pages[i].id == 'approvals' && badge:
widget.pages[i].id == 'approvals' &&
widget.pendingApprovals > 0 widget.pendingApprovals > 0
? widget.pendingApprovals ? widget.pendingApprovals
: null, : null,
// Cmd+1..9 jump to the destination; the // Cmd+1..9 jump to the destination; the
// hint rides in tooltip + expanded label // hint rides in tooltip + expanded label
// so the shortcut is discoverable. // so the shortcut is discoverable.
shortcutHint: i < 9 ? _metaShortcut('${i + 1}') : null, shortcutHint: i < 9
? _metaShortcut('${i + 1}')
: null,
onTap: () => widget.onSelect(i), onTap: () => widget.onSelect(i),
), ),
], ],
@ -1308,7 +1366,9 @@ Future<void> _showChannelSwitchMenu(BuildContext anchor, String current) async {
HubEndpoint(host: '127.0.0.1', port: target.port), HubEndpoint(host: '127.0.0.1', port: target.port),
persist: false, persist: false,
); );
} catch (_) {/* health poll will retry */} } catch (_) {
/* health poll will retry */
}
if (anchor.mounted) { if (anchor.mounted) {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l.channelSwitchOk(selected))), SnackBar(content: Text(l.channelSwitchOk(selected))),
@ -1549,7 +1609,9 @@ class _ConnectionLabel extends StatelessWidget {
// which reports the *active* channel's daemon. // which reports the *active* channel's daemon.
final ch = HubService.instance.connectedChannelName; final ch = HubService.instance.connectedChannelName;
final caption = connected == true final caption = connected == true
? (ch != null ? '${l.connectionConnected} · $ch' : l.connectionConnected) ? (ch != null
? '${l.connectionConnected} · $ch'
: l.connectionConnected)
: connected == false : connected == false
? l.connectionTapToStart ? l.connectionTapToStart
: l.connectionConnecting; : l.connectionConnecting;
@ -1653,10 +1715,7 @@ class _SidebarItemState extends State<_SidebarItem> {
right: -6, right: -6,
top: -4, top: -4,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
horizontal: 5,
vertical: 1,
),
constraints: const BoxConstraints(minWidth: 16, minHeight: 14), constraints: const BoxConstraints(minWidth: 16, minHeight: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
@ -2187,3 +2246,66 @@ class _LanguageToggle extends StatelessWidget {
// the editor directly with the full runDriver bridge. There // the editor directly with the full runDriver bridge. There
// is one Flows destination; the standalone editor route is // is one Flows destination; the standalone editor route is
// gone. // 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)),
],
),
),
);
}
}

View file

@ -392,8 +392,11 @@ class _EventLogPanel extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: onOpenAudit, onTap: onOpenAudit,
borderRadius: BorderRadius.circular(ChainRadius.sm), borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Semantics(button: true, label: l.doctorLinkAudit, child: Semantics(
child: headline), 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, // Text config files get an in-Studio viewer too (read top-down,
// no log colouring) so the operator can read config.yaml // no log colouring) so the operator can read config.yaml
// without leaving Studio or hunting for an external editor. // without leaving Studio or hunting for an external editor.
final isConfig = !isDirectory && final isConfig =
!isDirectory &&
(lower.endsWith('.yaml') || (lower.endsWith('.yaml') ||
lower.endsWith('.yml') || lower.endsWith('.yml') ||
lower.endsWith('.toml')); lower.endsWith('.toml'));
@ -575,9 +579,9 @@ class _PathRow extends StatelessWidget {
onPressed: () async { onPressed: () async {
await Clipboard.setData(ClipboardData(text: path)); await Clipboard.setData(ClipboardData(text: path));
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.aboutCopiedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.aboutCopiedToast)));
}, },
), ),
const SizedBox(width: ChainSpace.xs), const SizedBox(width: ChainSpace.xs),
@ -917,9 +921,7 @@ String _sourceKindLabel(String kind) {
case 'system': case 'system':
return 'System'; return 'System';
default: default:
return kind.isEmpty return kind.isEmpty ? kind : kind[0].toUpperCase() + kind.substring(1);
? 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; final DoctorSnapshot snapshot;
const _ServicesPanel({required this.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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final suggestion = _suggestionRow(l);
if (snapshot.services.isEmpty) { if (snapshot.services.isEmpty) {
// Wrap on narrow windows so the mono-spaced hint // Wrap on narrow windows so the mono-spaced hint
// (`add to ~/.chain/config.yaml under services:`) does not // (`add to ~/.chain/config.yaml under services:`) does not
// overflow horizontally past the card's right edge. // overflow horizontally past the card's right edge.
return ChainCard( return Column(
child: Wrap( crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: ChainSpace.sm, children: [
runSpacing: ChainSpace.xs, if (suggestion != null) ...[
crossAxisAlignment: WrapCrossAlignment.center, suggestion,
children: [ const SizedBox(height: ChainSpace.sm),
Row( ],
mainAxisSize: MainAxisSize.min, ChainCard(
child: Wrap(
spacing: ChainSpace.sm,
runSpacing: ChainSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Icon( Row(
Icons.dns_outlined, mainAxisSize: MainAxisSize.min,
size: 18, children: [
color: theme.colorScheme.onSurfaceVariant, 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( Text(
l.doctorServicesEmpty, l.doctorServicesEmptyHint,
style: theme.textTheme.bodyMedium?.copyWith( style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant, 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( return Column(
padding: EdgeInsets.zero, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Column( children: [
children: [ if (suggestion != null) ...[
for (var i = 0; i < snapshot.services.length; i++) ...[ suggestion,
if (i > 0) const SizedBox(height: ChainSpace.sm),
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),
],
],
),
),
],
], ],
), 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<ServiceEntry> 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 /// Network-reach pill per declared service, from the hub's
/// endpoint classification (`DeclaredService.exposure`). Public /// endpoint classification (`DeclaredService.exposure`). Public
/// endpoints get the warning tone a host service reachable from /// endpoints get the warning tone a host service reachable from
@ -1193,15 +1324,15 @@ class _ServiceExposurePill extends StatelessWidget {
'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''), 'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''),
'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''), 'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''),
'public' => ( 'public' => (
l.svcExposurePublic, l.svcExposurePublic,
ChainPillTone.warning, ChainPillTone.warning,
l.svcExposurePublicHint, l.svcExposurePublicHint,
), ),
'unknown' => ( 'unknown' => (
l.svcExposureUnknown, l.svcExposureUnknown,
ChainPillTone.neutral, ChainPillTone.neutral,
l.svcExposureUnknownHint, l.svcExposureUnknownHint,
), ),
_ => ('', ChainPillTone.neutral, ''), _ => ('', ChainPillTone.neutral, ''),
}; };
if (label.isEmpty) return const SizedBox.shrink(); if (label.isEmpty) return const SizedBox.shrink();
@ -1296,21 +1427,22 @@ class _UpdateBannerState extends State<_UpdateBanner> {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () => SystemActions.openInOs( onTap: () =>
status.releaseNotesUrl!, SystemActions.openInOs(status.releaseNotesUrl!),
),
child: Semantics( child: Semantics(
button: true, button: true,
label: l.doctorLinkReleaseNotes, label: l.doctorLinkReleaseNotes,
child: Text( child: Text(
l.doctorReleaseNotes(status.releaseNotesUrl!), l.doctorReleaseNotes(status.releaseNotesUrl!),
style: ChainTheme.mono( style:
size: 11, ChainTheme.mono(
color: theme.colorScheme.primary, size: 11,
).copyWith( color: theme.colorScheme.primary,
decoration: TextDecoration.underline, ).copyWith(
decorationColor: theme.colorScheme.primary, decoration: TextDecoration.underline,
), decorationColor:
theme.colorScheme.primary,
),
), ),
), ),
), ),

View file

@ -1,7 +1,7 @@
name: chain_studio name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none' publish_to: 'none'
version: 0.79.0 version: 0.80.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -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,
);
});
});
}

View file

@ -257,6 +257,20 @@ class FakeHubService extends Fake implements HubService {
Future<List<N8nEndpointInfo>> listN8nEndpoints() => Future<List<N8nEndpointInfo>> listN8nEndpoints() =>
_async('listN8nEndpoints', () => const []); _async('listN8nEndpoints', () => const []);
/// Scriptable update hint for the shell banner test.
UpdateStatus? updateHint;
@override
Future<UpdateStatus?> checkHubUpdate() async => updateHint;
@override
Future<bool> declareService({
required String name,
required String endpoint,
String healthPath = '',
List<String> tags = const [],
}) => _async('declareService', () => true);
@override @override
Future<DoctorSnapshot> doctor() => _async( Future<DoctorSnapshot> doctor() => _async(
'doctor', 'doctor',