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:
parent
e2b2639a86
commit
b5a4c74c4b
10 changed files with 1120 additions and 91 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue