feat(doctor,shell): ollama host-service suggestion + hub-update hint (0.80.0)
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
351c5a82bc
commit
14f824b8ef
13 changed files with 703 additions and 135 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue