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

@ -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<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
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<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
/// 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,
),
),
),
),