feat(studio): MCP suggestions + Approvals/Audit/Settings i18n (v0.26.0)
- Add curated catalogue of 9 official Anthropic MCP servers as click-to-prefill chips in the Add-MCP-server dialog. Operator still confirms via "Add + discover"; nothing is auto-spawned, preserving the regulated-environment trust model. - Localize Approvals page: pending/history empty states, status pills, payload preview, approve/reject toasts, reject dialog, history detail dialog, expires-in pill. - Localize Audit page: filter chips, clear-log dialog and toasts, no-events / hub-unreachable empty states, live-status bar with pluralized event count, hash-chain-verified badge, System AI panel (cached pill, latency, regenerate tooltip, asking state), event-detail dialog actions. - Localize Settings dialog: hub-endpoint section, channel blurb + popup-menu items, System AI panel headers and off-blurb, MCP/N8N panel headers + pluralized counts + refresh/add/remove tooltips, Add-MCP and Add-n8n dialogs (intro, suggestions label, all field labels and hints, save buttons, toasts). Field labels that map directly to JSON keys (event_id, flow, step, module, provider, endpoint, model) stay English by design — they're technical identifiers, not UI copy. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
349619d68e
commit
d25b4c87ae
9 changed files with 2022 additions and 209 deletions
|
|
@ -59,24 +59,26 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
}
|
||||
|
||||
Future<void> _approve(ApprovalRecord a) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await HubService.instance.approve(a.id, _reviewer);
|
||||
_toast('approved · ${a.flowName} › ${a.stepId}');
|
||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
_toast('approve failed: $e');
|
||||
_toast(l.approvalsApproveFailed(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reject(ApprovalRecord a) async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final reason = await _promptReason(context);
|
||||
if (reason == null || reason.isEmpty) return;
|
||||
try {
|
||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
||||
_toast('rejected · ${a.flowName} › ${a.stepId}');
|
||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
_toast('reject failed: $e');
|
||||
_toast(l.approvalsRejectFailed(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,22 +89,23 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
|
||||
Future<String?> _promptReason(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Reject approval'),
|
||||
title: Text(l.approvalsRejectDialogTitle),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reason (recorded in audit log)',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.approvalsRejectReasonLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
||||
|
|
@ -110,7 +113,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Reject'),
|
||||
child: Text(l.approvalsRejectButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -134,7 +137,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Reload',
|
||||
tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip,
|
||||
onPressed: _refresh,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
|
|
@ -175,6 +178,7 @@ class _PendingList extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FutureBuilder<List<ApprovalRecord>>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -185,21 +189,20 @@ class _PendingList extends StatelessWidget {
|
|||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
final pending = snapshot.data ?? [];
|
||||
if (pending.isEmpty) {
|
||||
return const FaiEmptyState(
|
||||
return FaiEmptyState(
|
||||
icon: Icons.task_alt_outlined,
|
||||
title: 'Inbox zero',
|
||||
hint:
|
||||
'All caught up. New `system.approval@^0` steps land here automatically.',
|
||||
title: l.approvalsInboxZero,
|
||||
hint: l.approvalsInboxHint,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
|
|
@ -229,6 +232,7 @@ class _HistoryList extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FutureBuilder<List<ApprovalRecord>>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
|
|
@ -239,21 +243,20 @@ class _HistoryList extends StatelessWidget {
|
|||
return FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: l.hubUnreachable,
|
||||
hint: l.hubUnreachableHint,
|
||||
action: FilledButton.tonal(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Retry'),
|
||||
child: Text(l.buttonRetry),
|
||||
),
|
||||
);
|
||||
}
|
||||
final decided = snapshot.data ?? [];
|
||||
if (decided.isEmpty) {
|
||||
return const FaiEmptyState(
|
||||
return FaiEmptyState(
|
||||
icon: Icons.history,
|
||||
title: 'No history yet',
|
||||
hint:
|
||||
'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.',
|
||||
title: l.approvalsHistoryEmpty,
|
||||
hint: l.approvalsHistoryEmptyHint,
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
|
|
@ -284,6 +287,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return FaiCard(
|
||||
accentTop: true,
|
||||
child: Column(
|
||||
|
|
@ -291,8 +295,8 @@ class _ApprovalCard extends StatelessWidget {
|
|||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const FaiPill(
|
||||
label: 'pending',
|
||||
FaiPill(
|
||||
label: l.approvalsPillPending,
|
||||
tone: FaiPillTone.warning,
|
||||
icon: Icons.pending_outlined,
|
||||
),
|
||||
|
|
@ -307,7 +311,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
),
|
||||
if (approval.expiresAt != null)
|
||||
FaiPill(
|
||||
label: _expiresIn(approval.expiresAt!),
|
||||
label: _expiresInLabel(context, approval.expiresAt!),
|
||||
tone: FaiPillTone.neutral,
|
||||
icon: Icons.schedule,
|
||||
),
|
||||
|
|
@ -323,7 +327,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'PAYLOAD PREVIEW',
|
||||
l.approvalsPayloadPreview,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -362,7 +366,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
OutlinedButton.icon(
|
||||
onPressed: onReject,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
label: const Text('Reject'),
|
||||
label: Text(l.approvalsRejectButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
side: BorderSide(
|
||||
|
|
@ -374,7 +378,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
FilledButton.icon(
|
||||
onPressed: onApprove,
|
||||
icon: const Icon(Icons.check, size: 16),
|
||||
label: const Text('Approve'),
|
||||
label: Text(l.approvalsApproveButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -393,13 +397,13 @@ class _ApprovalCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
String _expiresIn(DateTime t) {
|
||||
String _expiresInLabel(BuildContext context, DateTime t) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final remaining = t.difference(DateTime.now());
|
||||
if (remaining.isNegative) return 'expired';
|
||||
if (remaining.isNegative) return l.approvalsPillExpired;
|
||||
final minutes = remaining.inMinutes;
|
||||
if (minutes < 60) return '${minutes}m';
|
||||
final hours = remaining.inHours;
|
||||
return '${hours}h';
|
||||
if (minutes < 60) return l.approvalsExpiresIn(minutes);
|
||||
return l.approvalsExpiresInHours(remaining.inHours);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +418,7 @@ class _HistoryRow extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) = _statusPresentation(record.status);
|
||||
final (tone, icon, label) = _statusPresentation(context, record.status);
|
||||
final decided = record.decidedAt;
|
||||
final timeStr =
|
||||
decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||||
|
|
@ -467,14 +471,22 @@ class _HistoryRow extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
static (FaiPillTone, IconData, String) _statusPresentation(String status) {
|
||||
static (FaiPillTone, IconData, String) _statusPresentation(
|
||||
BuildContext context,
|
||||
String status,
|
||||
) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return (FaiPillTone.success, Icons.check, 'approved');
|
||||
return (FaiPillTone.success, Icons.check, l.approvalsPillApproved);
|
||||
case 'rejected':
|
||||
return (FaiPillTone.danger, Icons.close, 'rejected');
|
||||
return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected);
|
||||
case 'expired':
|
||||
return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired');
|
||||
return (
|
||||
FaiPillTone.neutral,
|
||||
Icons.timer_off_outlined,
|
||||
l.approvalsPillExpired,
|
||||
);
|
||||
default:
|
||||
return (FaiPillTone.neutral, Icons.help_outline, status);
|
||||
}
|
||||
|
|
@ -488,7 +500,8 @@ class _HistoryDialog extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) = _HistoryRow._statusPresentation(record.status);
|
||||
final (tone, icon, label) =
|
||||
_HistoryRow._statusPresentation(context, record.status);
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
|
|
@ -511,16 +524,17 @@ class _HistoryDialog extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (record.decidedAt != null)
|
||||
_kv(theme, 'decided',
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||||
_kv(theme, 'created',
|
||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||
_formatTimestamp(record.createdAt.toLocal())),
|
||||
if (record.reason.isNotEmpty)
|
||||
_kv(theme, 'reason', record.reason),
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason,
|
||||
record.reason),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
'PROMPT',
|
||||
AppLocalizations.of(context)!.approvalsDialogPrompt,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -534,7 +548,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
if (record.payloadPreview != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
'PAYLOAD PREVIEW',
|
||||
AppLocalizations.of(context)!.approvalsPayloadPreview,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -570,7 +584,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(AppLocalizations.of(context)!.buttonClose),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class _AuditPageState extends State<AuditPage> {
|
|||
}
|
||||
|
||||
Future<void> _onClearPressed() async {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final outcome = await _ClearAuditDialog.show(context);
|
||||
if (outcome == null || !mounted) return;
|
||||
try {
|
||||
|
|
@ -50,18 +51,13 @@ class _AuditPageState extends State<AuditPage> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Cleared ${r.purged} events on channel "${r.channel}". '
|
||||
'Chain reseeded with marker.',
|
||||
),
|
||||
),
|
||||
SnackBar(content: Text(l.auditClearedToast(r.purged, r.channel))),
|
||||
);
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Clear failed: ${_friendly(e)}')),
|
||||
SnackBar(content: Text(l.auditClearFailed(_friendly(e)))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -115,7 +111,7 @@ class _AuditPageState extends State<AuditPage> {
|
|||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep_outlined, size: 18),
|
||||
tooltip: 'Clear log (local/dev channels only)',
|
||||
tooltip: AppLocalizations.of(context)!.auditClearLogTooltip,
|
||||
onPressed: _onClearPressed,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
|
|
@ -131,15 +127,15 @@ class _AuditPageState extends State<AuditPage> {
|
|||
? FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: 'Hub unreachable',
|
||||
hint: 'Start the hub with `fai serve`.',
|
||||
title: AppLocalizations.of(context)!.hubUnreachable,
|
||||
hint: AppLocalizations.of(context)!.hubUnreachableHint,
|
||||
)
|
||||
: filtered.isEmpty
|
||||
? const FaiEmptyState(
|
||||
? FaiEmptyState(
|
||||
icon: Icons.timeline_outlined,
|
||||
title: 'No events yet',
|
||||
title: AppLocalizations.of(context)!.auditNoEvents,
|
||||
hint:
|
||||
'Run a flow to populate the audit stream.\nEvents appear here within seconds.',
|
||||
AppLocalizations.of(context)!.auditNoEventsHint,
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
|
|
@ -220,11 +216,12 @@ class _FilterChips extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const items = [
|
||||
('all', 'all'),
|
||||
('flow.', 'flow'),
|
||||
('step.', 'step'),
|
||||
('module.', 'module'),
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final items = [
|
||||
('all', l.auditFilterAll),
|
||||
('flow.', l.auditFilterFlow),
|
||||
('step.', l.auditFilterStep),
|
||||
('module.', l.auditFilterModule),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
|
|
@ -317,6 +314,7 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final live = error == null;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
|
|
@ -339,8 +337,8 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
live
|
||||
? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}'
|
||||
: 'disconnected · $error',
|
||||
? l.auditLiveStatus(eventCount)
|
||||
: l.auditDisconnected(error!),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -356,7 +354,7 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'hash chain verified',
|
||||
l.auditHashChainVerified,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -445,6 +443,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
String prettyDetail() {
|
||||
final raw = event.detail;
|
||||
if (raw == null || raw.isEmpty) return '';
|
||||
|
|
@ -511,7 +510,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
if (detail.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
'DETAIL',
|
||||
l.auditDetailHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -556,24 +555,24 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||
label: Text(
|
||||
_explaining
|
||||
? 'Asking…'
|
||||
? l.auditAsking
|
||||
: _explanation == null
|
||||
? 'Explain'
|
||||
: 'Re-ask',
|
||||
? l.auditExplain
|
||||
: l.auditReask,
|
||||
),
|
||||
)
|
||||
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null))
|
||||
Tooltip(
|
||||
message: 'Configure System AI in Settings (Cmd+,)',
|
||||
message: l.auditConfigureSystemAi,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
|
||||
label: const Text('Explain'),
|
||||
label: Text(l.auditExplain),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(l.buttonClose),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -648,6 +647,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final ok = result?.isSuccess ?? false;
|
||||
final color = explaining
|
||||
? theme.colorScheme.primary
|
||||
|
|
@ -670,7 +670,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
Icon(Icons.auto_awesome, size: 14, color: color),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Text(
|
||||
'SYSTEM AI',
|
||||
l.auditSystemAi,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -686,12 +686,16 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
if (result != null && result!.isSuccess && result!.cached) ...[
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Tooltip(
|
||||
message:
|
||||
'Served from the local cache. Originally generated '
|
||||
'${result!.cachedAt.isEmpty ? "at unknown time" : "on ${result!.cachedAt}"}'
|
||||
'${result!.cacheHits > 1 ? " · ${result!.cacheHits} hits" : ""}.',
|
||||
child: const FaiPill(
|
||||
label: 'cached',
|
||||
message: () {
|
||||
final hits = result!.cacheHits > 1
|
||||
? l.auditCachedTooltipHits(result!.cacheHits)
|
||||
: '';
|
||||
return result!.cachedAt.isEmpty
|
||||
? l.auditCachedTooltipUnknown(hits)
|
||||
: l.auditCachedTooltipKnown(result!.cachedAt, hits);
|
||||
}(),
|
||||
child: FaiPill(
|
||||
label: l.auditCachedPill,
|
||||
tone: FaiPillTone.success,
|
||||
icon: Icons.bolt,
|
||||
),
|
||||
|
|
@ -701,8 +705,8 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
if (result != null && result!.isSuccess && result!.latencyMs > 0)
|
||||
Text(
|
||||
result!.cached
|
||||
? 'orig ${result!.latencyMs} ms'
|
||||
: '${result!.latencyMs} ms',
|
||||
? l.auditOriginalLatency(result!.latencyMs)
|
||||
: l.auditLatency(result!.latencyMs),
|
||||
style: FaiTheme.mono(
|
||||
size: 10,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
|
|
@ -711,7 +715,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
if (result != null && result!.isSuccess && onRegenerate != null) ...[
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Tooltip(
|
||||
message: 'Regenerate — skip cache, ask the model again',
|
||||
message: l.auditRegenerateTooltip,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 14),
|
||||
visualDensity: VisualDensity.compact,
|
||||
|
|
@ -732,7 +736,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'Asking the configured System AI…',
|
||||
l.auditAskingFull,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -749,7 +753,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
if (result!.fixHint.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
'FIX',
|
||||
l.auditFixLabel,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -820,8 +824,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: const Text('Clear audit log?'),
|
||||
title: Text(l.auditClearDialogTitle),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Column(
|
||||
|
|
@ -829,9 +834,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Wipes every audit event on the active channel and seeds a '
|
||||
'fresh chain.reset marker carrying reviewer + reason. '
|
||||
'Refused on beta / production. Irreversible.',
|
||||
l.auditClearDialogBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -839,9 +842,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _reviewer,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Reviewer',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.auditClearReviewerLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -852,9 +855,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
controller: _reason,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Reason (recorded in chain.reset marker)',
|
||||
labelText: l.auditClearReasonLabel,
|
||||
helperText: _reason.text.trim().isEmpty
|
||||
? 'Required — auditors will read this.'
|
||||
? l.auditClearReasonHelper
|
||||
: null,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
|
|
@ -867,7 +870,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _reason,
|
||||
|
|
@ -885,7 +888,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
),
|
||||
child: const Text('Clear log'),
|
||||
child: Text(l.auditClearLogButton),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue