diff --git a/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart b/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart index 19426b5c..7c62e2a9 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart @@ -114,6 +114,7 @@ class ChatHistoryApiService { 'nodeName', 'traceId', 'source', + 'inputGrammarFix', ]; void dispose() { @@ -206,6 +207,9 @@ class ChatHistoryApiService { router: chatRouterFromApi(item['router'] as String?), nodeId: item['nodeId'] as String?, instructions: item['instructions'] as String?, + outputTone: chatOutputToneFromApi(item['outputTone']), + inputGrammarFixerEnabled: + item['inputGrammarFixerEnabled'] as bool? ?? false, resolvedTargetNodeId: item['resolvedTargetNodeId'] as String?, resolvedTargetNodeName: item['resolvedTargetNodeName'] as String?, resolvedTargetPluginId: item['resolvedTargetPluginId'] as String?, @@ -438,6 +442,8 @@ class ChatHistoryApiService { required ChatRouter? router, String? nodeId, String? instructions, + ChatOutputToneSetting? outputTone, + bool? inputGrammarFixerEnabled, }) async { final response = await _apiClient.put( _scopeSettingsUri, @@ -451,6 +457,9 @@ class ChatHistoryApiService { 'router': router?.apiValue, if (nodeId != null) 'nodeId': nodeId, 'instructions': instructions, + if (outputTone != null) 'outputTone': outputTone.toApiMap(), + if (inputGrammarFixerEnabled != null) + 'inputGrammarFixerEnabled': inputGrammarFixerEnabled, }), ); if (response.statusCode != 200) { diff --git a/apps/mobile_chat_app/lib/features/chat/chat_message.dart b/apps/mobile_chat_app/lib/features/chat/chat_message.dart index c0754a7a..477c5741 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_message.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_message.dart @@ -84,6 +84,37 @@ class ChatInvalidation { } } +class InputGrammarFixResult { + const InputGrammarFixResult({ + required this.status, + this.suggestion, + }); + + final String status; + final String? suggestion; + + bool get isAccepted => status == 'accepted'; + bool get hasSuggestion => + status == 'suggested' && suggestion != null && suggestion!.isNotEmpty; + + Map toMap() => { + 'status': status, + 'suggestion': suggestion, + }; + + static InputGrammarFixResult? fromMap(Object? value) { + if (value is! Map) return null; + final map = Map.from(value); + final status = map['status']; + if (status != 'accepted' && status != 'suggested') return null; + final suggestion = map['suggestion']; + return InputGrammarFixResult( + status: status as String, + suggestion: suggestion is String ? suggestion : null, + ); + } +} + /// A chat message displayed in the [MessageList]. /// /// This is a thin view-model for the chat UI, distinct from @@ -124,6 +155,7 @@ class ChatMessage { this.isRecovered = false, this.agentLoopPhase, this.agentLoopTool, + this.inputGrammarFix, this.invalidations = const [], }) : timestamp = timestamp ?? DateTime.now(); @@ -173,6 +205,7 @@ class ChatMessage { /// The tool name associated with a `tool_call_start` phase message. /// Extracted from `agentLoop.toolName` in server metadata. final String? agentLoopTool; + final InputGrammarFixResult? inputGrammarFix; final List invalidations; ChatMessage copyWith({ @@ -210,6 +243,7 @@ class ChatMessage { bool? isRecovered, String? agentLoopPhase, String? agentLoopTool, + InputGrammarFixResult? inputGrammarFix, List? invalidations, }) { return ChatMessage( @@ -248,6 +282,7 @@ class ChatMessage { isRecovered: isRecovered ?? this.isRecovered, agentLoopPhase: agentLoopPhase ?? this.agentLoopPhase, agentLoopTool: agentLoopTool ?? this.agentLoopTool, + inputGrammarFix: inputGrammarFix ?? this.inputGrammarFix, invalidations: invalidations ?? this.invalidations, ); } @@ -288,6 +323,7 @@ class ChatMessage { 'isRecovered': isRecovered, 'agentLoopPhase': agentLoopPhase, 'agentLoopTool': agentLoopTool, + if (inputGrammarFix != null) 'inputGrammarFix': inputGrammarFix!.toMap(), if (invalidations.isNotEmpty) 'invalidations': invalidations.map((item) => item.toMap()).toList(), }; @@ -359,6 +395,7 @@ class ChatMessage { isRecovered: map['isRecovered'] as bool? ?? false, agentLoopPhase: map['agentLoopPhase'] as String?, agentLoopTool: map['agentLoopTool'] as String?, + inputGrammarFix: InputGrammarFixResult.fromMap(map['inputGrammarFix']), invalidations: parseInvalidations(map['invalidations']), ); } diff --git a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart index 0f9b34d7..2aa09578 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart @@ -115,6 +115,8 @@ class _ChatScreenState extends State { final Map _threadNodeIds = {}; final Map _channelInstructions = {}; final Map _threadInstructions = {}; + final Map _channelOutputTones = {}; + final Map _channelInputGrammarFixers = {}; final Set _topologyRefreshedTaskIds = {}; int _respondGeneration = 0; int _idCounter = 0; @@ -267,6 +269,10 @@ class _ChatScreenState extends State { _hydrateChannelInstructions(scopeSettings); final restoredThreadInstructions = _hydrateThreadInstructions(scopeSettings); + final restoredChannelOutputTones = + _hydrateChannelOutputTones(scopeSettings); + final restoredChannelInputGrammarFixers = + _hydrateChannelInputGrammarFixers(scopeSettings); final resolvedActiveChannel = _topologyResolver.resolveChannelId( channels: restoredChannels, requestedChannelId: _activeChannelId, @@ -317,6 +323,12 @@ class _ChatScreenState extends State { _threadInstructions ..clear() ..addAll(restoredThreadInstructions); + _channelOutputTones + ..clear() + ..addAll(restoredChannelOutputTones); + _channelInputGrammarFixers + ..clear() + ..addAll(restoredChannelInputGrammarFixers); _platformNodes = platformNodes; _activeChannelId = resolvedActiveChannel; _activeSubSection = restoredActiveSubSection; @@ -871,6 +883,28 @@ class _ChatScreenState extends State { return instructions; } + Map _hydrateChannelOutputTones( + List settings, + ) { + final tones = {}; + for (final setting in settings) { + if (setting.scopeType != ChatScopeType.channel) continue; + tones[setting.channelId] = setting.outputTone; + } + return tones; + } + + Map _hydrateChannelInputGrammarFixers( + List settings, + ) { + final fixers = {}; + for (final setting in settings) { + if (setting.scopeType != ChatScopeType.channel) continue; + fixers[setting.channelId] = setting.inputGrammarFixerEnabled; + } + return fixers; + } + void _createChannel() { final existingNames = _channels.map((item) => item.name.trim().toLowerCase()).toSet(); @@ -1834,16 +1868,24 @@ class _ChatScreenState extends State { } } - Future _saveChannelInstructions(String? instructions) async { + Future _saveChannelSettings({ + required String? instructions, + required ChatOutputToneSetting outputTone, + required bool inputGrammarFixerEnabled, + }) async { final channelId = _activeChannelId; final normalized = instructions?.trim(); - final previous = _channelInstructions[channelId]; + final previousInstructions = _channelInstructions[channelId]; + final previousOutputTone = _channelOutputTones[channelId]; + final previousInputGrammarFixer = _channelInputGrammarFixers[channelId]; setState(() { if (normalized == null || normalized.isEmpty) { _channelInstructions.remove(channelId); } else { _channelInstructions[channelId] = normalized; } + _channelOutputTones[channelId] = outputTone; + _channelInputGrammarFixers[channelId] = inputGrammarFixerEnabled; }); final effectiveRouter = _channelRouters[channelId] ?? ChatRouter.local; @@ -1857,19 +1899,31 @@ class _ChatScreenState extends State { ? _channelNodeIds[channelId] : null, instructions: normalized?.isEmpty ?? true ? null : normalized, + outputTone: outputTone, + inputGrammarFixerEnabled: inputGrammarFixerEnabled, ); if (!mounted) return; } catch (error) { if (!mounted) return; setState(() { - if (previous == null || previous.isEmpty) { + if (previousInstructions == null || previousInstructions.isEmpty) { _channelInstructions.remove(channelId); } else { - _channelInstructions[channelId] = previous; + _channelInstructions[channelId] = previousInstructions; + } + if (previousOutputTone == null) { + _channelOutputTones.remove(channelId); + } else { + _channelOutputTones[channelId] = previousOutputTone; + } + if (previousInputGrammarFixer == null) { + _channelInputGrammarFixers.remove(channelId); + } else { + _channelInputGrammarFixers[channelId] = previousInputGrammarFixer; } }); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save channel instructions: $error')), + SnackBar(content: Text('Failed to save channel settings: $error')), ); } } @@ -1929,6 +1983,10 @@ class _ChatScreenState extends State { final threadController = TextEditingController( text: isSubSection ? (_threadInstructions[threadKey] ?? '') : '', ); + final outputTone = + _channelOutputTones[channelId] ?? ChatOutputToneSetting.direct; + final inputGrammarFixerEnabled = + _channelInputGrammarFixers[channelId] ?? false; await showDialog( context: context, @@ -1936,9 +1994,15 @@ class _ChatScreenState extends State { isSubSection: isSubSection, channelController: channelController, threadController: threadController, + initialOutputTone: outputTone, + initialInputGrammarFixerEnabled: inputGrammarFixerEnabled, onSaveChannel: (value) async { Navigator.of(dialogContext).pop(); - await _saveChannelInstructions(value); + await _saveChannelSettings( + instructions: value.instructions, + outputTone: value.outputTone, + inputGrammarFixerEnabled: value.inputGrammarFixerEnabled, + ); }, onSaveThread: (value) async { Navigator.of(dialogContext).pop(); @@ -2326,6 +2390,12 @@ class _ChatScreenState extends State { _threadInstructions ..clear() ..addAll(_hydrateThreadInstructions(settings)); + _channelOutputTones + ..clear() + ..addAll(_hydrateChannelOutputTones(settings)); + _channelInputGrammarFixers + ..clear() + ..addAll(_hydrateChannelInputGrammarFixers(settings)); } }); } catch (error) { @@ -3333,12 +3403,26 @@ class _ChatScreenState extends State { } } -/// A two-tab dialog for configuring channel and thread instructions. +class _ChannelConfigValue { + const _ChannelConfigValue({ + required this.instructions, + required this.outputTone, + required this.inputGrammarFixerEnabled, + }); + + final String? instructions; + final ChatOutputToneSetting outputTone; + final bool inputGrammarFixerEnabled; +} + +/// A two-tab dialog for configuring channel and thread settings. class _ScopeConfigDialog extends StatefulWidget { const _ScopeConfigDialog({ required this.isSubSection, required this.channelController, required this.threadController, + required this.initialOutputTone, + required this.initialInputGrammarFixerEnabled, required this.onSaveChannel, required this.onSaveThread, }); @@ -3346,7 +3430,9 @@ class _ScopeConfigDialog extends StatefulWidget { final bool isSubSection; final TextEditingController channelController; final TextEditingController threadController; - final Future Function(String? value) onSaveChannel; + final ChatOutputToneSetting initialOutputTone; + final bool initialInputGrammarFixerEnabled; + final Future Function(_ChannelConfigValue value) onSaveChannel; final Future Function(String? value) onSaveThread; @override @@ -3356,6 +3442,9 @@ class _ScopeConfigDialog extends StatefulWidget { class _ScopeConfigDialogState extends State<_ScopeConfigDialog> with SingleTickerProviderStateMixin { late final TabController _tabController; + late final TextEditingController _customToneController; + late String _toneSelection; + late bool _inputGrammarFixerEnabled; bool _isSaving = false; @override @@ -3363,6 +3452,14 @@ class _ScopeConfigDialogState extends State<_ScopeConfigDialog> super.initState(); _tabController = TabController(length: 2, vsync: this); _tabController.addListener(_onTabChange); + _customToneController = TextEditingController( + text: widget.initialOutputTone.customInstruction ?? '', + ); + _toneSelection = widget.initialOutputTone.isCustom + ? 'custom' + : (widget.initialOutputTone.preset ?? ChatOutputTonePreset.direct) + .apiValue; + _inputGrammarFixerEnabled = widget.initialInputGrammarFixerEnabled; } void _onTabChange() { @@ -3373,6 +3470,7 @@ class _ScopeConfigDialogState extends State<_ScopeConfigDialog> void dispose() { _tabController.removeListener(_onTabChange); _tabController.dispose(); + _customToneController.dispose(); super.dispose(); } @@ -3381,7 +3479,13 @@ class _ScopeConfigDialogState extends State<_ScopeConfigDialog> setState(() => _isSaving = true); try { if (_tabController.index == 0) { - await widget.onSaveChannel(widget.channelController.text); + await widget.onSaveChannel( + _ChannelConfigValue( + instructions: widget.channelController.text, + outputTone: _selectedOutputTone(), + inputGrammarFixerEnabled: _inputGrammarFixerEnabled, + ), + ); } else { await widget.onSaveThread(widget.threadController.text); } @@ -3390,6 +3494,15 @@ class _ScopeConfigDialogState extends State<_ScopeConfigDialog> } } + ChatOutputToneSetting _selectedOutputTone() { + if (_toneSelection == 'custom') { + return ChatOutputToneSetting.custom(_customToneController.text.trim()); + } + return ChatOutputToneSetting.preset( + chatOutputTonePresetFromApi(_toneSelection), + ); + } + @override Widget build(BuildContext context) { return AlertDialog( @@ -3409,24 +3522,81 @@ class _ScopeConfigDialogState extends State<_ScopeConfigDialog> ), const SizedBox(height: BricksSpacing.sm), SizedBox( - height: 180, + height: 300, child: TabBarView( controller: _tabController, children: [ // Channel tab - Padding( + SingleChildScrollView( padding: const EdgeInsets.only(top: BricksSpacing.sm), - child: TextField( - controller: widget.channelController, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - decoration: const InputDecoration( - labelText: 'Instructions', - hintText: - 'Describe the broad context or topic for this channel.', - border: OutlineInputBorder(), - ), + child: Column( + children: [ + SizedBox( + height: 120, + child: TextField( + controller: widget.channelController, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration( + labelText: 'Instructions', + hintText: + 'Describe the broad context or topic for this channel.', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: BricksSpacing.md), + DropdownButtonFormField( + initialValue: _toneSelection, + decoration: const InputDecoration( + labelText: 'Output style', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem( + value: 'direct', + child: Text('Direct'), + ), + DropdownMenuItem( + value: 'socratic', + child: Text('Socratic'), + ), + DropdownMenuItem( + value: 'rhetorical', + child: Text('Rhetorical'), + ), + DropdownMenuItem( + value: 'custom', + child: Text('Custom'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _toneSelection = value); + }, + ), + if (_toneSelection == 'custom') ...[ + const SizedBox(height: BricksSpacing.md), + TextField( + controller: _customToneController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Custom style', + border: OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: BricksSpacing.sm), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Grammar suggestions'), + value: _inputGrammarFixerEnabled, + onChanged: (value) { + setState(() => _inputGrammarFixerEnabled = value); + }, + ), + ], ), ), // Thread tab diff --git a/apps/mobile_chat_app/lib/features/chat/chat_topology.dart b/apps/mobile_chat_app/lib/features/chat/chat_topology.dart index 0d7d523a..14b93bef 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_topology.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_topology.dart @@ -2,6 +2,61 @@ enum ChatRouter { local, plugin } enum ChatScopeType { channel, thread } +enum ChatOutputTonePreset { direct, socratic, rhetorical } + +extension ChatOutputTonePresetApi on ChatOutputTonePreset { + String get apiValue { + switch (this) { + case ChatOutputTonePreset.direct: + return 'direct'; + case ChatOutputTonePreset.socratic: + return 'socratic'; + case ChatOutputTonePreset.rhetorical: + return 'rhetorical'; + } + } + + String get label { + switch (this) { + case ChatOutputTonePreset.direct: + return 'Direct'; + case ChatOutputTonePreset.socratic: + return 'Socratic'; + case ChatOutputTonePreset.rhetorical: + return 'Rhetorical'; + } + } +} + +class ChatOutputToneSetting { + const ChatOutputToneSetting.preset(this.preset) : customInstruction = null; + + const ChatOutputToneSetting.custom(this.customInstruction) : preset = null; + + static const direct = ChatOutputToneSetting.preset( + ChatOutputTonePreset.direct, + ); + + final ChatOutputTonePreset? preset; + final String? customInstruction; + + bool get isCustom => customInstruction != null; + + Map toApiMap() { + final custom = customInstruction?.trim(); + if (custom != null && custom.isNotEmpty) { + return { + 'type': 'custom', + 'instruction': custom, + }; + } + return { + 'type': 'preset', + 'preset': (preset ?? ChatOutputTonePreset.direct).apiValue, + }; + } +} + extension ChatRouterApi on ChatRouter { String get apiValue { switch (this) { @@ -47,6 +102,34 @@ ChatScopeType? chatScopeTypeFromApi(String? value) { } } +ChatOutputTonePreset chatOutputTonePresetFromApi(String? value) { + switch (value) { + case 'socratic': + return ChatOutputTonePreset.socratic; + case 'rhetorical': + return ChatOutputTonePreset.rhetorical; + case 'direct': + default: + return ChatOutputTonePreset.direct; + } +} + +ChatOutputToneSetting chatOutputToneFromApi(Object? value) { + if (value is Map) { + final map = Map.from(value); + if (map['type'] == 'custom') { + final instruction = map['instruction']; + if (instruction is String && instruction.trim().isNotEmpty) { + return ChatOutputToneSetting.custom(instruction.trim()); + } + } + return ChatOutputToneSetting.preset( + chatOutputTonePresetFromApi(map['preset'] as String?), + ); + } + return ChatOutputToneSetting.direct; +} + class ChatChannel { const ChatChannel({ required this.id, @@ -104,6 +187,8 @@ class ChatScopeSetting { this.nodeId, this.threadId, this.instructions, + this.outputTone = ChatOutputToneSetting.direct, + this.inputGrammarFixerEnabled = false, this.resolvedTargetNodeId, this.resolvedTargetNodeName, this.resolvedTargetPluginId, @@ -116,6 +201,8 @@ class ChatScopeSetting { final ChatRouter router; final String? nodeId; final String? instructions; + final ChatOutputToneSetting outputTone; + final bool inputGrammarFixerEnabled; final String? resolvedTargetNodeId; final String? resolvedTargetNodeName; final String? resolvedTargetPluginId; diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart b/apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart index 96026475..12a3aa15 100644 --- a/apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart +++ b/apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart @@ -947,15 +947,38 @@ class _MessageListState extends State { foregroundColor: chatColors.onMessageUser, ), ], + if (msg.inputGrammarFix?.isAccepted ?? false) ...[ + const SizedBox(width: BricksSpacing.xs), + Tooltip( + message: 'English looks good', + child: Icon( + Icons.spellcheck, + size: 14, + color: chatColors.onMessageUser + .withValues(alpha: 0.78), + ), + ), + ], ], ), ), + if (isUser && (msg.inputGrammarFix?.hasSuggestion ?? false)) + Padding( + padding: const EdgeInsets.only(top: BricksSpacing.xs), + child: Text( + msg.inputGrammarFix!.suggestion!, + style: + Theme.of(context).textTheme.labelSmall?.copyWith( + color: chatColors.onMessageUser + .withValues(alpha: 0.78), + ), + ), + ), ], ), ), ), - if (!isUser) - _buildAssistantMetaRow(context, msg, chatColors), + if (!isUser) _buildAssistantMetaRow(context, msg, chatColors), ], ), ); @@ -2763,8 +2786,10 @@ class _AssistantMessageActionMenu extends StatelessWidget { @override Widget build(BuildContext context) { final items = [ - if (showArchiveRound) const _MenuItem(label: 'Archive Round', value: 'archive_round'), - if (showArchiveReply) const _MenuItem(label: 'Archive Reply', value: 'archive_reply'), + if (showArchiveRound) + const _MenuItem(label: 'Archive Round', value: 'archive_round'), + if (showArchiveReply) + const _MenuItem(label: 'Archive Reply', value: 'archive_reply'), if (showFork) const _MenuItem(label: 'Fork', value: 'fork'), ]; final menuHeight = _itemHeight * items.length; diff --git a/apps/mobile_chat_app/test/chat_history_api_service_test.dart b/apps/mobile_chat_app/test/chat_history_api_service_test.dart index d03e3b25..de7ec064 100644 --- a/apps/mobile_chat_app/test/chat_history_api_service_test.dart +++ b/apps/mobile_chat_app/test/chat_history_api_service_test.dart @@ -662,6 +662,61 @@ void main() { ); }); + test('loads and saves channel output tone and grammar fixer', () async { + final client = MockClient((request) async { + if (request.method == 'GET') { + expect(request.url.path.endsWith('/chat/scope-settings'), isTrue); + return http.Response( + jsonEncode({ + 'settings': [ + { + 'scopeType': 'channel', + 'channelId': 'ch-1', + 'router': 'default', + 'outputTone': { + 'type': 'preset', + 'preset': 'socratic', + }, + 'inputGrammarFixerEnabled': true, + }, + ], + }), + 200, + ); + } + + expect(request.method, equals('PUT')); + final decoded = jsonDecode(request.body) as Map; + expect(decoded['scopeType'], equals('channel')); + expect(decoded['channelId'], equals('ch-1')); + expect(decoded['outputTone'], { + 'type': 'custom', + 'instruction': 'Use calm and plain language.', + }); + expect(decoded['inputGrammarFixerEnabled'], isFalse); + return http.Response( + jsonEncode({'setting': {}}), + 200, + ); + }); + + final service = _serviceFor(client); + final settings = await service.loadScopeSettings(); + await service.saveScopeSetting( + scopeType: ChatScopeType.channel, + channelId: 'ch-1', + router: ChatRouter.local, + outputTone: const ChatOutputToneSetting.custom( + 'Use calm and plain language.', + ), + inputGrammarFixerEnabled: false, + ); + + expect(settings, hasLength(1)); + expect(settings.single.outputTone.preset, ChatOutputTonePreset.socratic); + expect(settings.single.inputGrammarFixerEnabled, isTrue); + }); + test('parses agentLoop metadata into agentLoopPhase and agentLoopTool', () async { final client = MockClient((request) async { diff --git a/apps/mobile_chat_app/test/message_list_test.dart b/apps/mobile_chat_app/test/message_list_test.dart index 22082e72..4fcc247d 100644 --- a/apps/mobile_chat_app/test/message_list_test.dart +++ b/apps/mobile_chat_app/test/message_list_test.dart @@ -484,6 +484,34 @@ void main() { const EdgeInsets.only(bottom: BricksSpacing.md), ); }); + + testWidgets('shows grammar accepted icon and suggestion text for users', + (tester) async { + final accepted = ChatMessage( + messageId: 'u-grammar-ok', + role: 'user', + content: 'This sentence is clean.', + timestamp: DateTime.utc(2026, 1, 1), + inputGrammarFix: const InputGrammarFixResult(status: 'accepted'), + ); + final suggested = ChatMessage( + messageId: 'u-grammar-fix', + role: 'user', + content: 'What does this sentence mean?', + timestamp: DateTime.utc(2026, 1, 1, 0, 1), + inputGrammarFix: const InputGrammarFixResult( + status: 'suggested', + suggestion: 'What does this sentence mean?', + ), + ); + + await tester.pumpWidget(_build([accepted, suggested])); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.spellcheck), findsOneWidget); + expect(find.byTooltip('English looks good'), findsOneWidget); + expect(find.text('What does this sentence mean?'), findsNWidgets(2)); + }); }); group('Assistant markdown rendering', () { @@ -1134,8 +1162,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Copy'), findsOneWidget); - expect(find.text('Branch (coming soon)'), findsOneWidget); - expect(find.text('Resend (coming soon)'), findsOneWidget); + expect(find.text('Branch'), findsOneWidget); + expect(find.text('Resend'), findsOneWidget); expect(find.text('message id: u-menu'), findsOneWidget); expect(find.text('task id: task-menu'), findsOneWidget); }); @@ -1590,8 +1618,8 @@ void main() { await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); - expect(find.text('归档此轮'), findsNothing); - expect(find.text('归档此回复'), findsNothing); + expect(find.text('Archive Round'), findsNothing); + expect(find.text('Archive Reply'), findsNothing); expect(find.text('移入Thread'), findsNothing); }); @@ -1609,8 +1637,8 @@ void main() { await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); - expect(find.text('归档此轮'), findsOneWidget); - await tester.tap(find.text('归档此轮')); + expect(find.text('Archive Round'), findsOneWidget); + await tester.tap(find.text('Archive Round')); await tester.pumpAndSettle(); expect(received?.messageId, 'a-action'); @@ -1630,15 +1658,14 @@ void main() { await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); - expect(find.text('归档此回复'), findsOneWidget); - await tester.tap(find.text('归档此回复')); + expect(find.text('Archive Reply'), findsOneWidget); + await tester.tap(find.text('Archive Reply')); await tester.pumpAndSettle(); expect(received?.messageId, 'a-action'); }); - testWidgets('tapping fork calls onFork with message', - (tester) async { + testWidgets('tapping fork calls onFork with message', (tester) async { ChatMessage? received; await tester.pumpWidget( _build( @@ -1670,8 +1697,8 @@ void main() { await tester.tap(find.byIcon(Icons.more_horiz)); await tester.pumpAndSettle(); - expect(find.text('归档此轮'), findsOneWidget); - expect(find.text('归档此回复'), findsNothing); + expect(find.text('Archive Round'), findsOneWidget); + expect(find.text('Archive Reply'), findsNothing); expect(find.text('移入Thread'), findsNothing); }); }); diff --git a/apps/node_backend/src/db/migrations/025_add_channel_output_tone_and_grammar_fixer.sql b/apps/node_backend/src/db/migrations/025_add_channel_output_tone_and_grammar_fixer.sql new file mode 100644 index 00000000..c4ae2ecf --- /dev/null +++ b/apps/node_backend/src/db/migrations/025_add_channel_output_tone_and_grammar_fixer.sql @@ -0,0 +1,16 @@ +-- Migration: Add channel output tone and grammar fixer settings +-- Description: Store channel-scoped output style and input grammar helper flags. +-- Version: 025 +-- Date: 2026-06-17 + +ALTER TABLE chat_scope_settings + ADD COLUMN IF NOT EXISTS output_tone_type VARCHAR(16); + +ALTER TABLE chat_scope_settings + ADD COLUMN IF NOT EXISTS output_tone_preset VARCHAR(32); + +ALTER TABLE chat_scope_settings + ADD COLUMN IF NOT EXISTS output_tone_custom TEXT; + +ALTER TABLE chat_scope_settings + ADD COLUMN IF NOT EXISTS input_grammar_fixer_enabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/apps/node_backend/src/routes/chat.ts b/apps/node_backend/src/routes/chat.ts index f532b77b..18bb1cb9 100644 --- a/apps/node_backend/src/routes/chat.ts +++ b/apps/node_backend/src/routes/chat.ts @@ -20,8 +20,12 @@ import { deleteChatScopeSetting, listChatScopeSettings, normalizeChatRouterValue, + normalizeChatOutputTonePreset, resolveChatScopeRouting, resolveScopeInstructions, + setChannelInputGrammarFixer, + setChannelOutputTone, + type ChatOutputTone, type ChatRouter, type ChatScopeType, upsertChatScopeSetting, @@ -134,6 +138,9 @@ function invalidationsForToolResult( }, ]; case "chat_channel_instruction_set": + case "chat_channel_output_tone_set": + case "chat_channel_custom_output_tone_set": + case "chat_channel_input_grammar_fix_set": return [ { kind: "chat.scopeSettings", @@ -567,6 +574,23 @@ function parseChatRouter(value: unknown): ChatRouter | null { return typeof value === "string" ? normalizeChatRouterValue(value) : null; } +function parseOutputTone(value: unknown): ChatOutputTone | null { + if (!isRecord(value)) return null; + if (value.type === "custom" && typeof value.instruction === "string") { + const instruction = value.instruction.trim(); + return instruction ? { type: "custom", instruction } : null; + } + + if (value.type === "preset") { + const preset = normalizeChatOutputTonePreset( + typeof value.preset === "string" ? value.preset : null, + ); + return preset ? { type: "preset", preset } : null; + } + + return null; +} + function parseScopeType(value: unknown): ChatScopeType | null { if (value === "channel" || value === "thread") return value; return null; @@ -640,6 +664,7 @@ function buildComposedSystemPrompt(params: { systemPrompt: string | null; channelInstructions: string | null; threadInstructions: string | null; + channelOutputTone?: ChatOutputTone | null; channelId: string; threadId: string | null; }): string | null { @@ -659,12 +684,140 @@ function buildComposedSystemPrompt(params: { const ci = params.channelInstructions?.trim(); if (ci) parts.push(`Channel context:\n${ci}`); + parts.push(`Channel output style:\n${renderOutputTonePrompt(params.channelOutputTone)}`); + const ti = params.threadInstructions?.trim(); if (ti) parts.push(`Section context:\n${ti}`); return parts.length > 0 ? parts.join('\n\n') : null; } +function renderOutputTonePrompt(outputTone: ChatOutputTone | null | undefined): string { + const tone: ChatOutputTone = outputTone ?? { type: "preset", preset: "direct" }; + if (tone.type === 'custom') { + return `Use this custom output style for replies in this channel:\n${tone.instruction}`; + } + + switch (tone.preset) { + case 'socratic': + return ( + 'Use the Socratic style: guide the user with focused questions and reflective prompts. ' + + 'Prefer helping the user reason through tradeoffs before giving firm conclusions.' + ); + case 'rhetorical': + return ( + 'Use the Rhetorical style: use richer language, stronger rhythm, vivid phrasing, ' + + 'and expressive structure while preserving accuracy.' + ); + case 'direct': + default: + return ( + 'Use the Direct style: be rigorous, efficient, and concise. ' + + 'Avoid exaggeration, avoid decorative rhetoric, and preserve accuracy.' + ); + } +} + +function shouldRunInputGrammarFixer(text: string): boolean { + const latinWords = text.match(/[A-Za-z][A-Za-z'-]*/g) ?? []; + return latinWords.length >= 3; +} + +function parseGrammarFixerResult(text: string): { + status: "accepted" | "suggested"; + suggestion: string | null; +} | null { + try { + const parsed = JSON.parse(text.trim()); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const status = (parsed as { status?: unknown }).status; + if (status === "accepted") { + return { status: "accepted", suggestion: null }; + } + + const suggestion = (parsed as { suggestion?: unknown }).suggestion; + if (status === "suggested" && typeof suggestion === "string" && suggestion.trim()) { + return { status: "suggested", suggestion: suggestion.trim() }; + } + } catch { + return null; + } + + return null; +} + +async function runInputGrammarFixerAsync(params: { + userId: string; + taskId: string; + sessionId: string; + userMessageId: string; + channelId: string; + threadId: string | null; + userMessage: string; + createdAt: string | null; + body: Record; + baseMetadata: Record; +}) { + if (!shouldRunInputGrammarFixer(params.userMessage)) return; + + try { + const result = await generateWithUserConfig(params.userId, { + model: typeof params.body.model === "string" ? params.body.model : undefined, + configId: typeof params.body.configId === "string" ? params.body.configId : undefined, + maxTokens: 256, + temperature: 0, + messages: [ + { + role: "system", + content: + "You are an English grammar checker for chat input.\n" + + "Return JSON only.\n" + + "If the user's English is acceptable for a normal chat, return " + + '{"status":"accepted","suggestion":null}.\n' + + "If there are meaningful grammar, spelling, or naturalness issues, return " + + '{"status":"suggested","suggestion":""}.\n' + + "Preserve the user's meaning. Do not improve the idea. Do not explain.", + }, + { + role: "user", + content: params.userMessage, + }, + ], + }, parseProvider(params.body.provider)); + + const parsed = parseGrammarFixerResult(result.text); + if (!parsed) return; + + await upsertMessages(params.userId, [ + { + messageId: params.userMessageId, + taskId: params.taskId, + channelId: params.channelId, + sessionId: params.sessionId, + threadId: params.threadId, + role: "user", + content: params.userMessage, + taskState: "accepted", + checkpointCursor: null, + metadata: { + ...params.baseMetadata, + inputGrammarFix: { + status: parsed.status, + suggestion: parsed.suggestion, + checkedAt: new Date().toISOString(), + }, + }, + createdAt: params.createdAt, + }, + ]); + } catch (error) { + console.error("Input grammar fixer error:", error); + } +} + async function runDefaultRouterRespondAsync(params: { userId: string; acceptedTaskId: string; @@ -680,6 +833,8 @@ async function runDefaultRouterRespondAsync(params: { systemPrompt: string | null; channelInstructions: string | null; threadInstructions: string | null; + channelOutputTone?: ChatOutputTone | null; + inputGrammarFixerEnabled: boolean; }) { const { userId, @@ -696,6 +851,8 @@ async function runDefaultRouterRespondAsync(params: { systemPrompt, channelInstructions, threadInstructions, + channelOutputTone, + inputGrammarFixerEnabled, } = params; // NOTE: This runs after the HTTP response has been sent. On Vercel Serverless @@ -741,6 +898,7 @@ async function runDefaultRouterRespondAsync(params: { systemPrompt, channelInstructions, threadInstructions, + channelOutputTone, channelId, threadId, }); @@ -1387,6 +1545,11 @@ router.post( isPluginRoute ? assistantMessageId : undefined, }; + const scopeInstructions = await resolveScopeInstructions(userId, { + channelId, + threadId, + }); + if (resolvedRouter === CHAT_ROUTER_PLUGIN) { const persisted = await upsertMessages(userId, [ { @@ -1405,6 +1568,21 @@ router.post( }, ]); + if (scopeInstructions.inputGrammarFixerEnabled) { + void runInputGrammarFixerAsync({ + userId, + taskId: acceptedTaskId, + sessionId: acceptedSessionId, + userMessageId, + channelId, + threadId: input.threadId, + userMessage, + createdAt: typeof body.createdAt === "string" ? body.createdAt : null, + body, + baseMetadata: userMessageMetadata, + }); + } + try { await emitAssistantDispatchPlaceholder({ userId, @@ -1452,10 +1630,21 @@ router.post( }, ]); - const scopeInstructions = await resolveScopeInstructions(userId, { - channelId, - threadId, - }); + if (scopeInstructions.inputGrammarFixerEnabled) { + void runInputGrammarFixerAsync({ + userId, + taskId: acceptedTaskId, + sessionId: acceptedSessionId, + userMessageId, + channelId, + threadId: input.threadId, + userMessage, + createdAt: typeof body.createdAt === "string" ? body.createdAt : null, + body, + baseMetadata: userMessageMetadata, + }); + } + const systemPrompt = typeof body.systemPrompt === "string" && body.systemPrompt.trim() ? body.systemPrompt.trim() @@ -1476,6 +1665,8 @@ router.post( systemPrompt, channelInstructions: scopeInstructions.channelInstructions, threadInstructions: scopeInstructions.threadInstructions, + channelOutputTone: scopeInstructions.channelOutputTone, + inputGrammarFixerEnabled: scopeInstructions.inputGrammarFixerEnabled, }); res.json({ @@ -1911,6 +2102,16 @@ router.put("/scope-settings", async (req: AuthRequest, res: Response) => { const channelId = parseSessionId(body.channelId); const threadId = parseSessionId(body.threadId); const nodeId = parseSessionId(body.nodeId); + const outputTone = + body.outputTone === null || body.outputTone === undefined + ? undefined + : parseOutputTone(body.outputTone); + const inputGrammarFixerEnabled = + body.inputGrammarFixerEnabled === null || body.inputGrammarFixerEnabled === undefined + ? undefined + : typeof body.inputGrammarFixerEnabled === "boolean" + ? body.inputGrammarFixerEnabled + : null; let instructionsRaw: string | null | undefined; if (body.instructions === null || body.instructions === undefined) { instructionsRaw = undefined; @@ -1946,6 +2147,25 @@ router.put("/scope-settings", async (req: AuthRequest, res: Response) => { return; } + if (body.outputTone !== null && body.outputTone !== undefined && !outputTone) { + res.status(400).json({ + error: + 'Invalid payload: outputTone must be a preset direct/socratic/rhetorical or a non-empty custom instruction', + }); + return; + } + + if ( + body.inputGrammarFixerEnabled !== null && + body.inputGrammarFixerEnabled !== undefined && + inputGrammarFixerEnabled === null + ) { + res.status(400).json({ + error: "Invalid payload: inputGrammarFixerEnabled must be a boolean", + }); + return; + } + if (routerValue === CHAT_ROUTER_PLUGIN) { if (!nodeId) { res.status(400).json({ @@ -1972,7 +2192,7 @@ router.put("/scope-settings", async (req: AuthRequest, res: Response) => { return; } - const setting = await upsertChatScopeSetting(userId, { + let setting = await upsertChatScopeSetting(userId, { scopeType, channelId, threadId, @@ -1980,6 +2200,16 @@ router.put("/scope-settings", async (req: AuthRequest, res: Response) => { nodeId: routerValue === CHAT_ROUTER_PLUGIN ? nodeId : null, instructions: instructionsRaw, }); + + if (scopeType === "channel" && outputTone) { + setting = await setChannelOutputTone(userId, { channelId, outputTone }); + } + if (scopeType === "channel" && inputGrammarFixerEnabled !== undefined) { + setting = await setChannelInputGrammarFixer(userId, { + channelId, + enabled: inputGrammarFixerEnabled, + }); + } res.json({ setting }); } catch (error) { console.error("Upsert chat scope setting error:", error); diff --git a/apps/node_backend/src/services/chatRouterService.test.ts b/apps/node_backend/src/services/chatRouterService.test.ts index f525d04a..d57c2915 100644 --- a/apps/node_backend/src/services/chatRouterService.test.ts +++ b/apps/node_backend/src/services/chatRouterService.test.ts @@ -70,6 +70,8 @@ describe('chatRouterService', () => { router: 'plugin', nodeId: 'node_default', instructions: null, + outputTone: { type: 'preset', preset: 'direct' }, + inputGrammarFixerEnabled: false, createdAt: '2026-04-17T07:00:00.000Z', updatedAt: '2026-04-17T07:05:00.000Z', }, @@ -172,6 +174,33 @@ describe('chatRouterService', () => { expect(result.threadInstructions).toBe('section-specific context'); }); + it('resolves channel output tone and grammar fixer settings', async () => { + queryMock.mockResolvedValueOnce({ + rows: [ + { + scope_type: 'channel', + instructions: 'channel context', + output_tone_type: 'custom', + output_tone_preset: null, + output_tone_custom: 'Use plain technical language.', + input_grammar_fixer_enabled: true, + }, + ], + rowCount: 1, + }); + + const result = await resolveScopeInstructions('u-1', { + channelId: 'channel-a', + threadId: 'main', + }); + + expect(result.channelOutputTone).toEqual({ + type: 'custom', + instruction: 'Use plain technical language.', + }); + expect(result.inputGrammarFixerEnabled).toBe(true); + }); + it('returns null thread instructions when in main section', async () => { queryMock.mockResolvedValueOnce({ rows: [ diff --git a/apps/node_backend/src/services/chatRouterService.ts b/apps/node_backend/src/services/chatRouterService.ts index f06fa159..9d8772bd 100644 --- a/apps/node_backend/src/services/chatRouterService.ts +++ b/apps/node_backend/src/services/chatRouterService.ts @@ -9,6 +9,10 @@ export const BUILTIN_DEFAULT_NODE_NAME = 'Bricks Default'; export type ChatRouter = typeof CHAT_ROUTER_LOCAL | typeof CHAT_ROUTER_PLUGIN; export type ChatScopeType = 'channel' | 'thread'; +export type ChatOutputTonePreset = 'direct' | 'socratic' | 'rhetorical'; +export type ChatOutputTone = + | { type: 'preset'; preset: ChatOutputTonePreset } + | { type: 'custom'; instruction: string }; interface ChatScopeSettingRow { scope_type: ChatScopeType; @@ -17,6 +21,10 @@ interface ChatScopeSettingRow { router: ChatRouter; node_id: string | null; instructions: string | null; + output_tone_type: string | null; + output_tone_preset: string | null; + output_tone_custom: string | null; + input_grammar_fixer_enabled: boolean | number | null; created_at: string; updated_at: string; } @@ -28,6 +36,8 @@ export interface ChatScopeSetting { router: ChatRouter; nodeId: string | null; instructions: string | null; + outputTone: ChatOutputTone; + inputGrammarFixerEnabled: boolean; createdAt: string; updatedAt: string; } @@ -44,6 +54,43 @@ export interface ChatScopeSettingInput extends ChatScopeSelector { instructions?: string | null; } +export const DEFAULT_CHAT_OUTPUT_TONE_PRESET: ChatOutputTonePreset = 'direct'; +export const DEFAULT_CHAT_OUTPUT_TONE: ChatOutputTone = { + type: 'preset', + preset: DEFAULT_CHAT_OUTPUT_TONE_PRESET, +}; + +export function normalizeChatOutputTonePreset( + value: string | null | undefined, +): ChatOutputTonePreset | null { + switch (value) { + case 'direct': + case 'socratic': + case 'rhetorical': + return value; + default: + return null; + } +} + +function normalizeOutputTone(row: { + output_tone_type?: string | null; + output_tone_preset?: string | null; + output_tone_custom?: string | null; +}): ChatOutputTone { + if (row.output_tone_type === 'custom') { + const instruction = row.output_tone_custom?.trim(); + if (instruction) return { type: 'custom', instruction }; + } + + const preset = normalizeChatOutputTonePreset(row.output_tone_preset); + return { type: 'preset', preset: preset ?? DEFAULT_CHAT_OUTPUT_TONE_PRESET }; +} + +function toBoolean(value: boolean | number | null | undefined): boolean { + return value === true || value === 1; +} + export interface ResolvedChatScopeRouting { router: ChatRouter; nodeId: string | null; @@ -92,6 +139,10 @@ function toDto(row: ChatScopeSettingRow): ChatScopeSetting { router: normalizeChatRouterValue(row.router) ?? CHAT_ROUTER_LOCAL, nodeId: row.node_id, instructions: row.instructions ?? null, + outputTone: + row.scope_type === 'channel' ? normalizeOutputTone(row) : DEFAULT_CHAT_OUTPUT_TONE, + inputGrammarFixerEnabled: + row.scope_type === 'channel' ? toBoolean(row.input_grammar_fixer_enabled) : false, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -103,7 +154,9 @@ export function buildChatSessionId(channelId: string, threadId?: string | null): export async function listChatScopeSettings(userId: string): Promise { const result = await pool.query( - `SELECT scope_type, channel_id, thread_id, router, node_id, instructions, created_at, updated_at + `SELECT scope_type, channel_id, thread_id, router, node_id, instructions, + output_tone_type, output_tone_preset, output_tone_custom, + input_grammar_fixer_enabled, created_at, updated_at FROM chat_scope_settings WHERE user_id = $1 ORDER BY channel_id ASC, scope_type ASC, thread_id ASC`, @@ -136,13 +189,78 @@ export async function upsertChatScopeSetting( node_id = EXCLUDED.node_id, instructions = EXCLUDED.instructions, updated_at = CURRENT_TIMESTAMP - RETURNING scope_type, channel_id, thread_id, router, node_id, instructions, created_at, updated_at`, + RETURNING scope_type, channel_id, thread_id, router, node_id, instructions, + output_tone_type, output_tone_preset, output_tone_custom, + input_grammar_fixer_enabled, created_at, updated_at`, [userId, input.scopeType, input.channelId, threadId, router, nodeId, instructions], ); return toDto(result.rows[0]); } +export async function setChannelOutputTone( + userId: string, + input: { channelId: string; outputTone: ChatOutputTone }, +): Promise { + const type = input.outputTone.type; + const preset = type === 'preset' ? input.outputTone.preset : null; + const custom = type === 'custom' ? input.outputTone.instruction.trim() : null; + const result = await pool.query( + `INSERT INTO chat_scope_settings ( + user_id, + scope_type, + channel_id, + thread_id, + router, + node_id, + instructions, + output_tone_type, + output_tone_preset, + output_tone_custom + ) VALUES ($1, 'channel', $2, '', $3, NULL, NULL, $4, $5, $6) + ON CONFLICT (user_id, scope_type, channel_id, thread_id) + DO UPDATE SET + output_tone_type = EXCLUDED.output_tone_type, + output_tone_preset = EXCLUDED.output_tone_preset, + output_tone_custom = EXCLUDED.output_tone_custom, + updated_at = CURRENT_TIMESTAMP + RETURNING scope_type, channel_id, thread_id, router, node_id, instructions, + output_tone_type, output_tone_preset, output_tone_custom, + input_grammar_fixer_enabled, created_at, updated_at`, + [userId, input.channelId, CHAT_ROUTER_LOCAL, type, preset, custom], + ); + + return toDto(result.rows[0]); +} + +export async function setChannelInputGrammarFixer( + userId: string, + input: { channelId: string; enabled: boolean }, +): Promise { + const result = await pool.query( + `INSERT INTO chat_scope_settings ( + user_id, + scope_type, + channel_id, + thread_id, + router, + node_id, + instructions, + input_grammar_fixer_enabled + ) VALUES ($1, 'channel', $2, '', $3, NULL, NULL, $4) + ON CONFLICT (user_id, scope_type, channel_id, thread_id) + DO UPDATE SET + input_grammar_fixer_enabled = EXCLUDED.input_grammar_fixer_enabled, + updated_at = CURRENT_TIMESTAMP + RETURNING scope_type, channel_id, thread_id, router, node_id, instructions, + output_tone_type, output_tone_preset, output_tone_custom, + input_grammar_fixer_enabled, created_at, updated_at`, + [userId, input.channelId, CHAT_ROUTER_LOCAL, input.enabled], + ); + + return toDto(result.rows[0]); +} + export async function deleteChatScopeSetting( userId: string, input: Pick, @@ -197,6 +315,8 @@ export async function resolveChatScopeRouting( export interface ResolvedScopeInstructions { channelInstructions: string | null; threadInstructions: string | null; + channelOutputTone: ChatOutputTone; + inputGrammarFixerEnabled: boolean; } /** @@ -213,8 +333,13 @@ export async function resolveScopeInstructions( const result = await pool.query<{ scope_type: ChatScopeType; instructions: string | null; + output_tone_type: string | null; + output_tone_preset: string | null; + output_tone_custom: string | null; + input_grammar_fixer_enabled: boolean | number | null; }>( - `SELECT scope_type, instructions + `SELECT scope_type, instructions, output_tone_type, output_tone_preset, + output_tone_custom, input_grammar_fixer_enabled FROM chat_scope_settings WHERE user_id = $1 AND channel_id = $2 @@ -228,15 +353,19 @@ export async function resolveScopeInstructions( let channelInstructions: string | null = null; let threadInstructions: string | null = null; + let channelOutputTone: ChatOutputTone = DEFAULT_CHAT_OUTPUT_TONE; + let inputGrammarFixerEnabled = false; for (const row of result.rows) { const value = row.instructions?.trim() || null; if (row.scope_type === 'channel') { channelInstructions = value; + channelOutputTone = normalizeOutputTone(row); + inputGrammarFixerEnabled = toBoolean(row.input_grammar_fixer_enabled); } else if (row.scope_type === 'thread' && threadId !== 'main') { threadInstructions = value; } } - return { channelInstructions, threadInstructions }; + return { channelInstructions, threadInstructions, channelOutputTone, inputGrammarFixerEnabled }; } diff --git a/apps/node_backend/src/services/localAgentLoopService.test.ts b/apps/node_backend/src/services/localAgentLoopService.test.ts index 5c2fe294..57d66e12 100644 --- a/apps/node_backend/src/services/localAgentLoopService.test.ts +++ b/apps/node_backend/src/services/localAgentLoopService.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const upsertChatScopeSettingMock = vi.fn(); +const setChannelOutputToneMock = vi.fn(); +const setChannelInputGrammarFixerMock = vi.fn(); const upsertChatChannelNameMock = vi.fn(); const listChatScopeSettingsMock = vi.fn().mockResolvedValue([]); const listChatChannelNamesMock = vi.fn().mockResolvedValue([]); @@ -13,6 +15,12 @@ vi.mock('./chatRouterService.js', () => ({ const t = threadId?.trim(); return t && t.length > 0 ? t : 'main'; }, + normalizeChatOutputTonePreset: (value?: string | null) => + value === 'direct' || value === 'socratic' || value === 'rhetorical' + ? value + : null, + setChannelOutputTone: setChannelOutputToneMock, + setChannelInputGrammarFixer: setChannelInputGrammarFixerMock, upsertChatScopeSetting: upsertChatScopeSettingMock, listChatScopeSettings: listChatScopeSettingsMock, })); @@ -114,6 +122,8 @@ vi.mock('./scheduledActionService.js', () => ({ describe('localAgentLoopService', () => { beforeEach(() => { upsertChatScopeSettingMock.mockReset(); + setChannelOutputToneMock.mockReset(); + setChannelInputGrammarFixerMock.mockReset(); upsertChatChannelNameMock.mockReset(); }); @@ -162,6 +172,71 @@ describe('localAgentLoopService', () => { }); }); + it('persists channel output tone preset tool', async () => { + setChannelOutputToneMock.mockResolvedValue({ + scopeType: 'channel', + channelId: 'default', + threadId: null, + outputTone: { type: 'preset', preset: 'socratic' }, + updatedAt: '2026-06-17T00:00:00.000Z', + }); + + const { + executeInternalTool, + INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET, + } = await import('./localAgentLoopService.js'); + + const result = await executeInternalTool({ + userId: 'u-1', + toolName: INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET, + args: { + channelId: 'default', + preset: 'socratic', + }, + }); + + expect(result.ok).toBe(true); + expect(setChannelOutputToneMock).toHaveBeenCalledWith('u-1', { + channelId: 'default', + outputTone: { type: 'preset', preset: 'socratic' }, + }); + expect(result.data?.outputTone).toEqual({ + type: 'preset', + preset: 'socratic', + }); + }); + + it('persists channel grammar fixer setting tool', async () => { + setChannelInputGrammarFixerMock.mockResolvedValue({ + scopeType: 'channel', + channelId: 'default', + threadId: null, + inputGrammarFixerEnabled: true, + updatedAt: '2026-06-17T00:00:00.000Z', + }); + + const { + executeInternalTool, + INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET, + } = await import('./localAgentLoopService.js'); + + const result = await executeInternalTool({ + userId: 'u-1', + toolName: INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET, + args: { + channelId: 'default', + enabled: true, + }, + }); + + expect(result.ok).toBe(true); + expect(setChannelInputGrammarFixerMock).toHaveBeenCalledWith('u-1', { + channelId: 'default', + enabled: true, + }); + expect(result.data?.inputGrammarFixerEnabled).toBe(true); + }); + it('creates channel scope for create tool', async () => { upsertChatScopeSettingMock.mockResolvedValue({}); diff --git a/apps/node_backend/src/services/localAgentLoopService.ts b/apps/node_backend/src/services/localAgentLoopService.ts index 1e3f0667..02ab0dd3 100644 --- a/apps/node_backend/src/services/localAgentLoopService.ts +++ b/apps/node_backend/src/services/localAgentLoopService.ts @@ -3,7 +3,11 @@ import { type ChatRouter, CHAT_ROUTER_LOCAL, type ChatScopeType, + type ChatOutputTonePreset, + normalizeChatOutputTonePreset, normalizeChatThreadId, + setChannelInputGrammarFixer, + setChannelOutputTone, upsertChatScopeSetting, listChatScopeSettings, } from './chatRouterService.js'; @@ -63,6 +67,9 @@ import type { AgentTool } from '../llm/types.js'; export const INTERNAL_TOOL_CHAT_CHANNEL_INSTRUCTION_SET = 'chat.channel.instruction.set'; export const INTERNAL_TOOL_CHAT_THREAD_INSTRUCTION_SET = 'chat.thread.instruction.set'; +export const INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET = 'chat.channel.output_tone.set'; +export const INTERNAL_TOOL_CHAT_CHANNEL_CUSTOM_OUTPUT_TONE_SET = 'chat.channel.output_tone.custom.set'; +export const INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET = 'chat.channel.input_grammar_fix.set'; export const INTERNAL_TOOL_CHAT_CHANNEL_CREATE = 'chat.channel.create'; export const INTERNAL_TOOL_CHAT_THREAD_CREATE = 'chat.thread.create'; export const INTERNAL_TOOL_CHAT_CHANNEL_RENAME = 'chat.channel.rename'; @@ -123,6 +130,9 @@ export const INTERNAL_TOOL_SCHEDULED_ACTION_DELETE = 'scheduled_action.delete'; export const INTERNAL_TOOLS = [ INTERNAL_TOOL_CHAT_CHANNEL_INSTRUCTION_SET, INTERNAL_TOOL_CHAT_THREAD_INSTRUCTION_SET, + INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET, + INTERNAL_TOOL_CHAT_CHANNEL_CUSTOM_OUTPUT_TONE_SET, + INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET, INTERNAL_TOOL_CHAT_CHANNEL_CREATE, INTERNAL_TOOL_CHAT_THREAD_CREATE, INTERNAL_TOOL_CHAT_CHANNEL_RENAME, @@ -293,6 +303,11 @@ function readPositiveIntegerArg(args: Record, key: string): num return value > 0 ? value : null; } +function readBooleanArg(args: Record, key: string): boolean | null { + const raw = args[key]; + return typeof raw === 'boolean' ? raw : null; +} + function invalidNoteMutation(toolName: string, error: unknown): ExecuteInternalToolResult { return { ok: false, @@ -338,6 +353,75 @@ async function persistInstruction(params: { }; } +async function persistChannelOutputTone(params: { + userId: string; + channelId: string; + preset: ChatOutputTonePreset; +}): Promise { + const setting = await setChannelOutputTone(params.userId, { + channelId: params.channelId, + outputTone: { type: 'preset', preset: params.preset }, + }); + + return { + ok: true, + toolName: INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET, + data: { + scopeType: setting.scopeType, + channelId: setting.channelId, + outputTone: setting.outputTone, + updatedAt: setting.updatedAt, + }, + error: null, + }; +} + +async function persistChannelCustomOutputTone(params: { + userId: string; + channelId: string; + instruction: string; +}): Promise { + const setting = await setChannelOutputTone(params.userId, { + channelId: params.channelId, + outputTone: { type: 'custom', instruction: params.instruction }, + }); + + return { + ok: true, + toolName: INTERNAL_TOOL_CHAT_CHANNEL_CUSTOM_OUTPUT_TONE_SET, + data: { + scopeType: setting.scopeType, + channelId: setting.channelId, + outputTone: setting.outputTone, + updatedAt: setting.updatedAt, + }, + error: null, + }; +} + +async function persistChannelInputGrammarFix(params: { + userId: string; + channelId: string; + enabled: boolean; +}): Promise { + const setting = await setChannelInputGrammarFixer(params.userId, { + channelId: params.channelId, + enabled: params.enabled, + }); + + return { + ok: true, + toolName: INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET, + data: { + scopeType: setting.scopeType, + channelId: setting.channelId, + inputGrammarFixerEnabled: setting.inputGrammarFixerEnabled, + updatedAt: setting.updatedAt, + }, + error: null, + }; +} + async function createChannelScope(params: { userId: string; channelId: string; @@ -654,6 +738,54 @@ export async function executeInternalTool( instructions: instruction, }); } + case INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET: { + const channelId = readStringArg(args, 'channelId', MAX_IDENTIFIER_LENGTH); + const preset = normalizeChatOutputTonePreset(readStringArg(args, 'preset')); + if (!channelId || !preset) { + return { + ok: false, + toolName, + data: null, + error: { + code: 'invalid_args', + message: 'channelId and preset are required; preset must be direct, socratic, or rhetorical', + }, + }; + } + return persistChannelOutputTone({ userId, channelId, preset }); + } + case INTERNAL_TOOL_CHAT_CHANNEL_CUSTOM_OUTPUT_TONE_SET: { + const channelId = readStringArg(args, 'channelId', MAX_IDENTIFIER_LENGTH); + const instruction = readStringArg(args, 'instruction', 4000); + if (!channelId || !instruction) { + return { + ok: false, + toolName, + data: null, + error: { + code: 'invalid_args', + message: 'channelId and instruction are required string arguments', + }, + }; + } + return persistChannelCustomOutputTone({ userId, channelId, instruction }); + } + case INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET: { + const channelId = readStringArg(args, 'channelId', MAX_IDENTIFIER_LENGTH); + const enabled = readBooleanArg(args, 'enabled'); + if (!channelId || enabled === null) { + return { + ok: false, + toolName, + data: null, + error: { + code: 'invalid_args', + message: 'channelId and enabled are required arguments', + }, + }; + } + return persistChannelInputGrammarFix({ userId, channelId, enabled }); + } case INTERNAL_TOOL_CHAT_CHANNEL_CREATE: { const channelId = readStringArg(args, 'channelId', MAX_IDENTIFIER_LENGTH) ?? readStringArg(args, 'name', MAX_IDENTIFIER_LENGTH); if (!channelId) { @@ -1441,6 +1573,74 @@ export function buildAgentTools(userId: string): Record { execute: (args) => runTool(INTERNAL_TOOL_CHAT_THREAD_INSTRUCTION_SET, args), }, + chat_channel_output_tone_set: { + description: + 'Set the output tone preset for a chat channel. ' + + 'Use this when the user asks future replies in the current channel to be direct, Socratic, or rhetorically rich. ' + + 'Direct means rigorous, efficient, concise, not exaggerated, and low on decorative rhetoric.', + parametersSchema: { + type: 'object', + properties: { + channelId: { + type: 'string', + description: 'The channel identifier.', + }, + preset: { + type: 'string', + enum: ['direct', 'socratic', 'rhetorical'], + description: 'The channel output tone preset.', + }, + }, + required: ['channelId', 'preset'], + additionalProperties: false, + }, + execute: (args) => runTool(INTERNAL_TOOL_CHAT_CHANNEL_OUTPUT_TONE_SET, args), + }, + + chat_channel_custom_output_tone_set: { + description: + 'Set a custom output tone instruction for future replies in a chat channel. ' + + 'Use this when the user asks for a channel-specific style that does not fit Direct, Socratic, or Rhetorical.', + parametersSchema: { + type: 'object', + properties: { + channelId: { + type: 'string', + description: 'The channel identifier.', + }, + instruction: { + type: 'string', + description: 'The custom output style instruction to inject into future replies for the channel.', + }, + }, + required: ['channelId', 'instruction'], + additionalProperties: false, + }, + execute: (args) => runTool(INTERNAL_TOOL_CHAT_CHANNEL_CUSTOM_OUTPUT_TONE_SET, args), + }, + + chat_channel_input_grammar_fix_set: { + description: + 'Enable or disable display-only English grammar suggestions for user input in a chat channel. ' + + 'This setting does not rewrite the user message before the main model answers.', + parametersSchema: { + type: 'object', + properties: { + channelId: { + type: 'string', + description: 'The channel identifier.', + }, + enabled: { + type: 'boolean', + description: 'Whether display-only grammar suggestions should be enabled for this channel.', + }, + }, + required: ['channelId', 'enabled'], + additionalProperties: false, + }, + execute: (args) => runTool(INTERNAL_TOOL_CHAT_CHANNEL_INPUT_GRAMMAR_FIX_SET, args), + }, + chat_channel_create: { description: 'Create a new chat channel. Use when the user requests a new channel by name.', diff --git a/docs/backlog/2026-06-17-reply-action-skill-bindings.md b/docs/backlog/2026-06-17-reply-action-skill-bindings.md new file mode 100644 index 00000000..ca121cba --- /dev/null +++ b/docs/backlog/2026-06-17-reply-action-skill-bindings.md @@ -0,0 +1,16 @@ +# Reply Action Skill Bindings + +## Confirmed Need + +Bricks should eventually support reply action buttons that invoke reusable skills against an existing assistant reply. + +## Example Use Cases + +- Rewrite a single reply with a different expression style. +- Generate a derived reply without changing the channel output tone setting. +- Offer an explicit action to promote a reply style into the channel output tone later. + +## Notes + +- This is not part of the current output tone and input grammar fixer scope. +- Reply actions should reuse the standard skill layer through a reply-level binding. diff --git a/docs/code_maps/feature_map.yaml b/docs/code_maps/feature_map.yaml index 4fdb1def..6bf79ee8 100644 --- a/docs/code_maps/feature_map.yaml +++ b/docs/code_maps/feature_map.yaml @@ -1,7 +1,7 @@ version: 1 map_type: feature_map owner: bricks -last_updated: 2026-06-16 +last_updated: 2026-06-17 purpose: > 为人类测试员和 AI 测试员提供产品功能清单与进入路径,作为回归测试与变更影响评估的入口索引。 @@ -136,6 +136,31 @@ products: - 清空指令后保存,刷新页面后该范围不应再贡献任何提示上下文。 - plugin/OpenClaw 路由下保存或发送消息,OpenClaw dispatch 行为不变。 + - feature_id: channel_output_tone_and_grammar_fixer + name: 频道输出风格与输入语法建议 + user_value: 用户可在频道配置中设置默认回复风格,并开启轻量英文语法建议;AI 可通过工具修改这些频道级设置。 + entry_paths: + - screen: ChatScreen._openScopeConfigDialog + route_hint: apps/mobile_chat_app/lib/features/chat/chat_screen.dart + - widget: _ScopeConfigDialog Channel tab + route_hint: apps/mobile_chat_app/lib/features/chat/chat_screen.dart + - service: ChatHistoryApiService.saveScopeSetting (outputTone/inputGrammarFixerEnabled) + route_hint: apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart + - route: PUT /api/chat/scope-settings (output tone / grammar fixer fields) + route_hint: apps/node_backend/src/routes/chat.ts + - route: POST /api/chat/respond (tone prompt injection / grammar metadata) + route_hint: apps/node_backend/src/routes/chat.ts + - tool: chat_channel_output_tone_set / chat_channel_custom_output_tone_set / chat_channel_input_grammar_fix_set + route_hint: apps/node_backend/src/services/localAgentLoopService.ts + smoke_checks: + - Channel 配置中可选择 Direct / Socratic / Rhetorical / Custom,默认应为 Direct。 + - 保存频道输出风格后刷新页面,Channel 配置应恢复已保存风格。 + - 选择 Custom 时必须能输入自定义风格文本并保存。 + - local/Bricks Default 回复应把频道输出风格作为系统提示的一部分;OpenClaw plugin dispatch 行为不变。 + - AI tool 修改输出风格或语法建议开关后,客户端应通过 typed invalidation 刷新 scope settings。 + - 开启 grammar suggestions 后发送英文问题,主回复仍使用用户原始输入;语法检查结果稍后更新在 user 气泡上。 + - 语法检查 accepted 时 user 气泡显示 spellcheck 图标;suggested 时在 user 气泡下显示建议文本。 + - feature_id: model_settings name: 模型与提供商配置 user_value: 用户可选择可用模型并保存默认配置,并可快速复制 API URL 与 API Key。 diff --git a/docs/code_maps/logic_map.yaml b/docs/code_maps/logic_map.yaml index 16e7ae0c..9ee02cef 100644 --- a/docs/code_maps/logic_map.yaml +++ b/docs/code_maps/logic_map.yaml @@ -1,7 +1,7 @@ version: 1 map_type: logic_map owner: bricks -last_updated: 2026-06-16 +last_updated: 2026-06-17 purpose: > 为人类测试员、AI 测试员与 AI 工程师提供“功能 -> 代码/文档/关键词”映射, 用于影响面分析、回归测试设计与遗留逻辑清理。 @@ -310,38 +310,51 @@ index: - 指令字段在 DB 中允许 NULL;若 DTO 层遗漏 null 转换,可能导致 undefined 被序列化为 undefined 字符串。 - - feature_id: scope_instructions_config - capability: 频道与分区级别的会话指令配置及提示词组合 + - feature_id: channel_output_tone_and_grammar_fixer + capability: 频道级输出风格、AI 可改设置工具、英文输入语法建议 code_index: - apps/mobile_chat_app/lib/features/chat/chat_screen.dart - apps/mobile_chat_app/lib/features/chat/chat_history_api_service.dart + - apps/mobile_chat_app/lib/features/chat/chat_message.dart - apps/mobile_chat_app/lib/features/chat/chat_topology.dart + - apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart - apps/node_backend/src/routes/chat.ts - apps/node_backend/src/services/chatRouterService.ts - - apps/node_backend/src/db/migrations/013_add_instructions_to_chat_scope_settings.sql + - apps/node_backend/src/services/localAgentLoopService.ts + - apps/node_backend/src/db/migrations/025_add_channel_output_tone_and_grammar_fixer.sql doc_index: - - docs/plans/2026-04-30-17-51-+08-scope-instructions-config.md + - docs/tasks/doing/2026-06-17-14-41-CST-channel-output-tone-and-grammar-fixer.md + - docs/backlog/2026-06-17-reply-action-skill-bindings.md test_index: - apps/mobile_chat_app/test/chat_history_api_service_test.dart + - apps/mobile_chat_app/test/message_list_test.dart - apps/node_backend/src/services/chatRouterService.test.ts + - apps/node_backend/src/services/localAgentLoopService.test.ts + - apps/node_backend/src/routes/chat.test.ts keywords: - - scope instructions - - channel instructions - - thread instructions - - system prompt - - prompt composition - - conversation config - - scope config dialog - - tune icon - - instructions field - - resolveScopeInstructions + - output tone + - response style + - direct tone + - socratic tone + - rhetorical tone + - custom tone + - input grammar fixer + - grammar suggestion + - channel settings + - scope settings invalidation + - chat_channel_output_tone_set + - chat_channel_custom_output_tone_set + - chat_channel_input_grammar_fix_set + - buildComposedSystemPrompt + - runInputGrammarFixerAsync change_risks: - - 若 resolveScopeInstructions 查询中 thread_id 的判断逻辑与 normalizeChatThreadId 不一致,主区消息可能携带错误的子区指令。 - - 若 buildComposedSystemPrompt 未对空字符串做 trim/null 检查,发给模型的 system 消息可能含空段落噪音。 - - 若 _hydrateChannelInstructions/_hydrateThreadInstructions 在应用启动时未正确调用,保存值不会在刷新后恢复。 - - _saveChannelInstructions 的乐观更新+失败回滚若 state key 命名与读取不一致,会导致静默数据错位。 - - plugin/OpenClaw 路由走的是 dispatch placeholder 分支,若在该分支误加 system prompt 拼接逻辑,会干扰 OpenClaw dispatch 行为。 - - 指令字段在 DB 中允许 NULL;若 DTO 层遗漏 null 转换,可能导致 undefined 被序列化为 undefined 字符串。 + - 若输出风格被当作普通对话内容而不是 system prompt 片段注入,用户更改风格后可能在历史中产生难以回滚的语义污染。 + - Direct 默认风格必须覆盖缺省值;若前后端默认不一致,刷新前后同一频道回复风格会漂移。 + - Custom 风格需要保存稳定文本而不是 display label,否则后续模型请求无法复用用户定义。 + - grammar fixer 只能更新 user message metadata;若改写并替换主请求输入,会违背当前“模型看到原始问题”的产品约束。 + - grammar fixer 是异步轻量请求;若失败不应阻塞主回复,也不应把错误 UI 暴露为主会话失败。 + - AI tool 修改频道设置后必须返回 chat.scopeSettings invalidation,否则前端会继续显示旧输出风格或旧 grammar 开关。 + - 数据库新增字段需兼容既有 chat_scope_settings 行;缺省值应保持 Direct / grammar disabled。 - feature_id: model_settings capability: 模型配置读写 diff --git a/docs/tasks/doing/2026-06-17-14-41-CST-channel-output-tone-and-grammar-fixer.md b/docs/tasks/doing/2026-06-17-14-41-CST-channel-output-tone-and-grammar-fixer.md new file mode 100644 index 00000000..53735e76 --- /dev/null +++ b/docs/tasks/doing/2026-06-17-14-41-CST-channel-output-tone-and-grammar-fixer.md @@ -0,0 +1,190 @@ +# Channel Output Tone and Input Grammar Fixer + +## Background + +Bricks should not copy Claude Code lifecycle hooks as a product concept. The +current design direction is to keep a standard skill layer and let Bricks bind +those skills to conversation settings, system tools, and UI surfaces. + +For this task, the product scope is intentionally narrow: + +- Channel output tone. +- Custom channel output tone. +- Channel input grammar fixer. + +Reply action buttons are related, but they are not part of the current scope. +They are tracked separately in +`docs/backlog/2026-06-17-reply-action-skill-bindings.md`. + +## Goals + +- Add channel-scoped output tone as a first-class setting. +- Support three built-in output tone presets: Direct, Socratic, and Rhetorical. +- Support custom output tone as a user-defined setting type. +- Programmatically inject the current channel output tone into the assembled + system prompt for future assistant replies. +- Add a channel-scoped input grammar fixer setting. +- Let model/tool calls update these settings through explicit system tools. +- Keep grammar suggestions display-only; they must not replace the user's input + or affect the main model request. +- Allow settings to change after a conversation has started, with changes + affecting future messages only. + +## Architecture Decisions + +### Skill and Binding Model + +Skills describe reusable capability. Bricks-specific bindings decide where a +skill is used and what state it changes. + +For this task: + +- Output tone is not a per-response model invocation skill. +- Output tone tools update channel settings. +- Prompt assembly reads channel settings and injects them into the system + prompt. +- Input grammar fixer is a channel setting that controls whether Bricks runs a + lightweight grammar-check request beside the main conversation flow. + +### Output Tone Setting + +The channel stores output tone as structured state: + +```text +channel.outputTone = + preset: direct | socratic | rhetorical + or + custom: +``` + +Preset meanings: + +- Direct: rigorous, efficient, concise, not exaggerated, and low on decorative + rhetoric. +- Socratic: guide the user through focused questions and reflective prompts. +- Rhetorical: use richer language, stronger rhythm, and more expressive + phrasing while preserving accuracy. + +Direct is the default. Concise, no-hype, and similar requests should converge on +Direct rather than becoming separate presets. + +### Output Tone Tools + +The system should expose tools that can update channel tone settings: + +```text +set_channel_output_tone +- preset: direct | socratic | rhetorical + +set_channel_custom_output_tone +- instruction: string +``` + +These tools write channel state. They do not generate normal assistant content +by themselves. + +### Prompt Assembly + +Every future assistant request in the channel should be assembled from: + +```text +base system prompt ++ channel instruction ++ current channel output tone +``` + +Only the latest channel output tone should control behavior. Historical tone +changes may remain visible in conversation history as events, but prompt +assembly should not replay every historical tone change. + +### Input Grammar Fixer Setting + +The channel stores a simple boolean: + +```text +channel.inputGrammarFixerEnabled = true | false +``` + +The system should expose a tool: + +```text +set_channel_input_grammar_fix +- enabled: boolean +``` + +When enabled, Bricks may run one lightweight grammar request for English user +input. The main assistant reply should continue using the original user input. + +Grammar fixer results should be compact: + +```json +{ "status": "accepted", "suggestion": null } +``` + +or: + +```json +{ "status": "suggested", "suggestion": "Corrected user-facing text." } +``` + +Accepted inputs should show a small positive signal, such as an icon. Suggested +inputs should display the suggestion string. Grammar fixer failures should stay +quiet and should not pollute the main chat. + +### Update Semantics + +Settings are mutable channel state. Users may change them after a conversation +has started. + +- New output tone settings affect future assistant replies only. +- Existing assistant replies are not rewritten. +- New grammar fixer settings affect future user inputs only. +- Existing grammar suggestions or accepted signals may remain as historical UI + results. +- Setting changes are persisted as channel state and should refresh the visible + settings UI through the existing scope-settings invalidation path. + +## Implementation Plan + +1. Inspect the existing channel instruction setting model, persistence path, and + prompt assembly path. +2. Add structured channel output tone storage beside the existing channel + instruction setting. +3. Add the three preset tone definitions and custom tone representation. +4. Add system tools for setting preset output tone and custom output tone. +5. Render the current channel output tone into the system prompt during prompt + assembly. +6. Add channel input grammar fixer storage. +7. Add the system tool for enabling or disabling input grammar fixer. +8. Add the lightweight grammar-check request path and compact accepted/suggested + result contract. +9. Add UI rendering for accepted grammar signal and suggested correction text on + user input. +10. Update code maps if feature entry points, business logic, tests, or docs + indexes change during implementation. + +## Acceptance Criteria + +- A channel can store Direct, Socratic, Rhetorical, or custom output tone. +- Direct is the default channel output tone. +- Users can ask the assistant to change the channel output tone, and the model + can call a system tool that updates the channel setting. +- Future assistant replies in the channel receive the current output tone through + programmatic system prompt assembly. +- Tone changes after a conversation starts affect only future replies. +- A channel can enable or disable input grammar fixer through a system tool. +- When grammar fixer is enabled, English user input can receive either an + accepted signal or a displayed suggestion. +- Grammar suggestions do not replace the user's original input and do not change + the main model request. +- Grammar fixer failures do not create visible chat errors. +- Reply action buttons remain out of current scope. + +## Validation Commands + +- `./tools/init_dev_env.sh` +- `cd apps/mobile_chat_app && flutter analyze` +- `cd apps/mobile_chat_app && flutter test` +- `cd apps/node_backend && npm run type-check` +- `cd apps/node_backend && npm test -- src/services/chatRouterService.test.ts src/services/localAgentLoopService.test.ts src/routes/chat.test.ts` +- `cd apps/node_backend && npm test` when a test database URL is configured.