feat(studio): Flows + Module sheet + System-AI editor i18n (v0.28.0)

- Localize Flows page: app-bar title and reload tooltip,
  hub-unreachable / no-saved-flows empty states, run-flow input
  dialog (title with flow name, description, hint, Cancel /
  Run), running dialog (title, "Flow running…" status, no-output
  message, Close button), flow-card Run button.
- Localize the module-sheet bottom sheet: failed-to-load text,
  Capabilities and "Declared permissions" section headers,
  no-permissions placeholder copy.
- Localize the System-AI configuration dialog: title, intro
  paragraph, provider dropdown label, endpoint label, API-key
  env-var label (required vs optional) and disclaimer, privacy
  mode header and three options (Off / Redacted / Full) plus
  their descriptions, test-result panel ("Connection ok" /
  "Connection failed", "Reply: …" prefix), model picker (label,
  hint fallback, helper text with Ollama variant, Refresh /
  Pull / Pulling… buttons, list errors and empty states),
  hardware banner ("Detected: …" + " · curation reviewed …"
  suffix), suitability legend (recommended / balanced / small /
  large / huge / unknown), cache row with pluralized count and
  Clear button, cache cleared / clear-failed toasts, pull-empty
  / pull-failed errors. Suitability label moved off a getter
  onto a `labelFor(BuildContext)` method so it can read the
  current locale.

Provider preset descriptions and modelHint strings stay in
English in `_ProviderPreset.all` — they're tightly coupled to
the wire-protocol identifiers and would require a runtime
factory rebuild rather than a const list.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 02:59:08 +02:00
parent e2b2639a86
commit b5a4c74c4b
10 changed files with 1120 additions and 91 deletions

View file

@ -114,7 +114,8 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
Padding(
padding: const EdgeInsets.all(FaiSpace.xl),
child: Text(
'Failed to load: ${snapshot.error}',
AppLocalizations.of(context)!
.moduleSheetFailedToLoad(snapshot.error.toString()),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
@ -168,6 +169,7 @@ class _Body extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return ListView(
padding: const EdgeInsets.fromLTRB(
FaiSpace.xxl,
@ -202,7 +204,7 @@ class _Body extends StatelessWidget {
),
),
const SizedBox(height: FaiSpace.xl),
_SectionHeader('Capabilities'),
_SectionHeader(l.moduleSheetCapabilities),
const SizedBox(height: FaiSpace.sm),
Wrap(
spacing: FaiSpace.xs,
@ -218,11 +220,11 @@ class _Body extends StatelessWidget {
.toList(),
),
const SizedBox(height: FaiSpace.xl),
_SectionHeader('Declared permissions'),
_SectionHeader(l.moduleSheetPermissions),
const SizedBox(height: FaiSpace.sm),
if (detail.permissions.isEmpty)
Text(
'(none — pure-computation module)',
l.moduleSheetNoPermissions,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,

View file

@ -7,6 +7,7 @@
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_pill.dart';
@ -244,19 +245,18 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
try {
final purged = await HubService.instance.clearSystemLlmCache();
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cleared $purged cached System-AI explanations.')),
SnackBar(content: Text(l.systemAiCacheClearedToast(purged))),
);
// Pop with the same status (since the editor's `initial`
// is now stale w.r.t. cacheCount); caller will re-fetch
// status on next open.
setState(() {
// Force a rebuild that hides the row; cheap.
});
} catch (e) {
if (!mounted) return;
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cache clear failed: $e')),
SnackBar(content: Text(l.systemAiCacheClearFailed(e.toString()))),
);
}
}
@ -315,8 +315,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
Future<void> _pullModel() async {
final wanted = _model.text.trim();
final l = AppLocalizations.of(context)!;
if (wanted.isEmpty) {
setState(() => _error = 'Type a model id (e.g. gemma3:4b) first.');
setState(() => _error = l.systemAiPullEmptyError);
return;
}
setState(() {
@ -332,7 +333,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
setState(() {
_pulling = false;
if (r.errorKind.isNotEmpty) {
_error = 'Pull failed: ${r.text}';
_error = l.systemAiPullFailedError(r.text);
} else {
_error = null;
}
@ -347,8 +348,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return AlertDialog(
title: const Text('System AI'),
title: Text(l.systemAiTitle),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
@ -364,9 +366,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'The hub-internal LLM the platform itself uses for inline failure '
'explanations and operator help. Off by default; configure here. '
'See docs/architecture/system-ai.md for what crosses the wire.',
l.systemAiIntro,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -391,9 +391,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
TextField(
controller: _endpoint,
style: FaiTheme.mono(size: 12),
decoration: const InputDecoration(
labelText: 'Endpoint URL (up to /v1)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l.systemAiEndpointLabel,
border: const OutlineInputBorder(),
isDense: true,
),
),
@ -420,8 +420,8 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
labelText: _preset.wire == 'openai'
? 'API key env var (required)'
: 'API key env var (optional)',
? l.systemAiApiKeyRequired
: l.systemAiApiKeyOptional,
hintText: 'OPENAI_API_KEY',
prefixText: '\$',
border: const OutlineInputBorder(),
@ -430,8 +430,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
),
const SizedBox(height: 4),
Text(
'Studio never reads or stores the key value — only its env-var name. '
'The hub reads `\$<name>` at request time.',
l.systemAiApiKeyDisclaimer('<name>'),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -470,12 +469,12 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
onPressed: _saving || _testing
? null
: () => Navigator.pop(context, null),
child: const Text('Cancel'),
child: Text(l.buttonCancel),
),
OutlinedButton.icon(
onPressed: _saving || _testing ? null : _test,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(_testing ? 'Testing…' : 'Test connection'),
label: Text(_testing ? l.systemAiTesting : l.systemAiTestConnection),
),
FilledButton(
onPressed: _saving || _testing ? null : () => _save(),
@ -485,7 +484,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
: Text(l.systemAiSave),
),
],
);
@ -503,12 +502,13 @@ class _ProviderDropdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return DropdownButtonFormField<String>(
initialValue: value.wire,
isDense: true,
decoration: const InputDecoration(
labelText: 'Provider',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l.systemAiProviderLabel,
border: const OutlineInputBorder(),
isDense: true,
),
items: _ProviderPreset.all
@ -539,24 +539,25 @@ class _PrivacyModeChips extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const modes = <(String, String, String)>[
('off', 'Off', 'Feature disabled. No requests leave the hub.'),
final l = AppLocalizations.of(context)!;
final modes = <(String, String, String)>[
('off', l.systemAiPrivacyOffLabel, l.systemAiPrivacyOffDesc),
(
'redacted',
'Redacted',
'Only event_type / error / module names. KRITIS-friendly default.',
l.systemAiPrivacyRedactedLabel,
l.systemAiPrivacyRedactedDesc,
),
(
'full',
'Full',
'Includes the audit detail JSON (hash-only — no raw payloads).',
l.systemAiPrivacyFullLabel,
l.systemAiPrivacyFullDesc,
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'PRIVACY MODE',
l.systemAiPrivacyHeader,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
@ -611,6 +612,7 @@ class _TestResultPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final ok = result.isSuccess;
final color = ok ? FaiColors.success : theme.colorScheme.error;
return Container(
@ -633,7 +635,7 @@ class _TestResultPanel extends StatelessWidget {
),
const SizedBox(width: FaiSpace.xs),
Text(
ok ? 'Connection ok' : 'Connection failed',
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
),
@ -649,7 +651,7 @@ class _TestResultPanel extends StatelessWidget {
),
const SizedBox(height: 4),
SelectableText(
ok ? 'Reply: ${result.text}' : result.text,
ok ? l.systemAiReplyPrefix(result.text) : result.text,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
@ -707,6 +709,7 @@ class _ModelPicker extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -720,12 +723,14 @@ class _ModelPicker extends StatelessWidget {
controller: controller,
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
labelText: 'Model (required)',
labelText: l.systemAiModelLabel,
hintText: preset.modelHint.isEmpty
? 'model identifier the provider expects'
? l.systemAiModelHintFallback
: preset.modelHint,
helperText: controller.text.trim().isEmpty
? 'Required: pick from the list (Refresh) or type one. ${_isOllama ? "Use Pull to download from Ollama." : ""}'
? (_isOllama
? l.systemAiModelHelperOllama
: l.systemAiModelHelperBase)
: null,
helperMaxLines: 2,
border: const OutlineInputBorder(),
@ -744,7 +749,7 @@ class _ModelPicker extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: const Text('Refresh'),
label: Text(l.systemAiRefresh),
),
if (_isOllama) ...[
const SizedBox(width: FaiSpace.sm),
@ -757,7 +762,7 @@ class _ModelPicker extends StatelessWidget {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 16),
label: Text(pulling ? 'Pulling…' : 'Pull'),
label: Text(pulling ? l.systemAiPulling : l.systemAiPull),
),
],
],
@ -765,7 +770,7 @@ class _ModelPicker extends StatelessWidget {
if (modelsError != null) ...[
const SizedBox(height: 4),
Text(
'Could not list models: $modelsError',
l.systemAiCouldNotListModels(modelsError!),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
@ -776,8 +781,8 @@ class _ModelPicker extends StatelessWidget {
if (models!.isEmpty)
Text(
_isOllama
? 'No models on the Ollama server yet. Type one above and click Pull.'
: 'Provider returned no models.',
? l.systemAiNoModelsOllama
: l.systemAiNoModelsGeneric,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -827,20 +832,21 @@ enum _Suitability {
}
extension _SuitabilityCopy on _Suitability {
String get label {
String labelFor(BuildContext context) {
final l = AppLocalizations.of(context)!;
switch (this) {
case _Suitability.recommended:
return 'recommended';
return l.systemAiSuitabilityRecommended;
case _Suitability.balanced:
return 'balanced';
return l.systemAiSuitabilityBalanced;
case _Suitability.small:
return 'small — quality may be limited';
return l.systemAiSuitabilitySmall;
case _Suitability.large:
return 'large — slower';
return l.systemAiSuitabilityLarge;
case _Suitability.huge:
return 'huge — likely too slow on a laptop';
return l.systemAiSuitabilityHuge;
case _Suitability.unknown:
return 'size unknown';
return l.systemAiSuitabilityUnknown;
}
}
@ -970,8 +976,8 @@ class _ModelChip extends StatelessWidget {
required this.onTap,
});
String _tooltipFor(BuildContext _) {
final lines = <String>[suitability.label];
String _tooltipFor(BuildContext context) {
final lines = <String>[suitability.labelFor(context)];
final c = curated;
if (c != null) {
lines.add(
@ -1023,8 +1029,9 @@ class _HardwareBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final lr = (lastReviewed != null && lastReviewed!.isNotEmpty)
? ' · curation reviewed $lastReviewed'
? l.systemAiHwReviewed(lastReviewed!)
: '';
return Container(
padding: const EdgeInsets.symmetric(
@ -1046,7 +1053,7 @@ class _HardwareBanner extends StatelessWidget {
const SizedBox(width: 6),
Expanded(
child: Text(
'Detected: ${hw.summary}$lr',
'${l.systemAiHwDetected(hw.summary)}$lr',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -1065,6 +1072,7 @@ class _SuitabilityLegend extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
Widget dot(Color c, String label) => Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Row(
@ -1094,7 +1102,7 @@ class _SuitabilityLegend extends StatelessWidget {
Icon(Icons.star, size: 11, color: FaiColors.success),
const SizedBox(width: 4),
Text(
'recommended for this hardware$tierTag',
l.systemAiLegendRecommended(tierTag),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
@ -1103,10 +1111,10 @@ class _SuitabilityLegend extends StatelessWidget {
],
),
),
dot(theme.colorScheme.primary, 'balanced (4-8B)'),
dot(FaiColors.warning, 'small <3B / large 9-15B / above hardware'),
dot(theme.colorScheme.error, 'huge >15B'),
dot(theme.colorScheme.outline, 'size unknown'),
dot(theme.colorScheme.primary, l.systemAiLegendBalanced),
dot(FaiColors.warning, l.systemAiLegendSmallLarge),
dot(theme.colorScheme.error, l.systemAiLegendHuge),
dot(theme.colorScheme.outline, l.systemAiLegendUnknown),
],
);
}
@ -1126,6 +1134,7 @@ class _CacheStatusRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
@ -1146,9 +1155,7 @@ class _CacheStatusRow extends StatelessWidget {
const SizedBox(width: 6),
Expanded(
child: Text(
'Cache: $cacheCount cached explanation${cacheCount == 1 ? "" : "s"}. '
'Identical prompts hit the cache; switching model or privacy '
'mode flushes automatically.',
l.systemAiCacheRow(cacheCount),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -1157,7 +1164,7 @@ class _CacheStatusRow extends StatelessWidget {
TextButton.icon(
onPressed: onClear,
icon: const Icon(Icons.delete_outline, size: 14),
label: const Text('Clear'),
label: Text(l.systemAiCacheClear),
),
],
),