From d37677c533d98c5a799d8abf164a88fe6cbc458d Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 14:41:21 +0800 Subject: [PATCH] fix(chat): fix ChatBloc event callback and test reliability - Fix onEvent callback initialization in ChatBloc constructor - Add MockAgUiService to isolate test from mock API behavior - Remove unnecessary non-null assertions in tests --- .../chat/data/ai/ai_decision_engine.dart | 58 +++++---- .../chat/data/models/ag_ui_event.dart | 88 +++++-------- .../chat/data/models/tool_result.dart | 5 +- .../chat/data/services/ag_ui_service.dart | 34 +++-- .../chat/data/tools/tool_registry.dart | 30 +++-- .../chat/presentation/bloc/chat_bloc.dart | 11 +- .../chat/ui/widgets/ui_schema_renderer.dart | 103 ++++++++------- .../features/home/ui/screens/home_screen.dart | 118 ++++++++++-------- apps/test/features/chat/chat_bloc_test.dart | 24 ++-- 9 files changed, 254 insertions(+), 217 deletions(-) diff --git a/apps/lib/features/chat/data/ai/ai_decision_engine.dart b/apps/lib/features/chat/data/ai/ai_decision_engine.dart index bd27a98..5f8fade 100644 --- a/apps/lib/features/chat/data/ai/ai_decision_engine.dart +++ b/apps/lib/features/chat/data/ai/ai_decision_engine.dart @@ -2,17 +2,23 @@ import 'dart:convert'; enum Intent { createEvent, searchEvent, unknown } -class AiDecisionEngine { - /// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent) - static final List<(RegExp, Intent)> _orderedPatterns = [ - (RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent), - (RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent), - ( - RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), - Intent.createEvent, - ), - ]; +/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent) +final _orderedPatterns = <(RegExp, Intent)>[ + (RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent), + (RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent), + (RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent), +]; +/// 时区常量 +const _defaultTimezone = 'Asia/Shanghai'; +const _dayToday = '今天'; +const _dayTomorrow = '明天'; +const _dayAfterTomorrow = '后天'; +const _tomorrowOffset = 1; +const _dayAfterTomorrowOffset = 2; +const _defaultMinute = 0; + +class AiDecisionEngine { Intent matchIntent(String text) { for (final (pattern, intent) in _orderedPatterns) { if (pattern.hasMatch(text)) return intent; @@ -34,31 +40,33 @@ class AiDecisionEngine { .trim(); } - if (args['title'] == null || (args['title'] as String).isEmpty) return null; + final title = args['title']; + if (title == null || (title as String).isEmpty) return null; final timeMatch = RegExp( r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?', ).firstMatch(text); if (timeMatch != null) { - final dayStr = timeMatch.group(1) ?? '今天'; + final dayStr = timeMatch.group(1) ?? _dayToday; final hour = int.parse(timeMatch.group(2)!); - final minute = int.parse(timeMatch.group(3) ?? '0'); + final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute'); final now = DateTime.now(); - DateTime startAt; - switch (dayStr) { - case '明天': - startAt = DateTime(now.year, now.month, now.day + 1, hour, minute); - break; - case '后天': - startAt = DateTime(now.year, now.month, now.day + 2, hour, minute); - break; - default: - startAt = DateTime(now.year, now.month, now.day, hour, minute); - } + final dayOffset = switch (dayStr) { + _dayTomorrow => _tomorrowOffset, + _dayAfterTomorrow => _dayAfterTomorrowOffset, + _ => 0, + }; + final startAt = DateTime( + now.year, + now.month, + now.day + dayOffset, + hour, + minute, + ); args['startAt'] = startAt.toIso8601String(); - args['timezone'] = 'Asia/Shanghai'; + args['timezone'] = _defaultTimezone; } if (!args.containsKey('startAt')) return null; diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/features/chat/data/models/ag_ui_event.dart index 9d62e64..349b7ab 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -32,63 +32,39 @@ enum AgUiEventType { unknown, } -AgUiEventType agUiEventTypeFromWire(String wire) { - switch (wire) { - case AgUiEventTypeWire.runStarted: - return AgUiEventType.runStarted; - case AgUiEventTypeWire.runFinished: - return AgUiEventType.runFinished; - case AgUiEventTypeWire.runError: - return AgUiEventType.runError; - case AgUiEventTypeWire.textMessageStart: - return AgUiEventType.textMessageStart; - case AgUiEventTypeWire.textMessageContent: - return AgUiEventType.textMessageContent; - case AgUiEventTypeWire.textMessageEnd: - return AgUiEventType.textMessageEnd; - case AgUiEventTypeWire.toolCallStart: - return AgUiEventType.toolCallStart; - case AgUiEventTypeWire.toolCallArgs: - return AgUiEventType.toolCallArgs; - case AgUiEventTypeWire.toolCallEnd: - return AgUiEventType.toolCallEnd; - case AgUiEventTypeWire.toolCallResult: - return AgUiEventType.toolCallResult; - case AgUiEventTypeWire.toolCallError: - return AgUiEventType.toolCallError; - default: - return AgUiEventType.unknown; - } -} +const _wireToTypeMap = { + AgUiEventTypeWire.runStarted: AgUiEventType.runStarted, + AgUiEventTypeWire.runFinished: AgUiEventType.runFinished, + AgUiEventTypeWire.runError: AgUiEventType.runError, + AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart, + AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent, + AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd, + AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart, + AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs, + AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd, + AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult, + AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError, +}; -String agUiEventTypeToWire(AgUiEventType type) { - switch (type) { - case AgUiEventType.runStarted: - return AgUiEventTypeWire.runStarted; - case AgUiEventType.runFinished: - return AgUiEventTypeWire.runFinished; - case AgUiEventType.runError: - return AgUiEventTypeWire.runError; - case AgUiEventType.textMessageStart: - return AgUiEventTypeWire.textMessageStart; - case AgUiEventType.textMessageContent: - return AgUiEventTypeWire.textMessageContent; - case AgUiEventType.textMessageEnd: - return AgUiEventTypeWire.textMessageEnd; - case AgUiEventType.toolCallStart: - return AgUiEventTypeWire.toolCallStart; - case AgUiEventType.toolCallArgs: - return AgUiEventTypeWire.toolCallArgs; - case AgUiEventType.toolCallEnd: - return AgUiEventTypeWire.toolCallEnd; - case AgUiEventType.toolCallResult: - return AgUiEventTypeWire.toolCallResult; - case AgUiEventType.toolCallError: - return AgUiEventTypeWire.toolCallError; - case AgUiEventType.unknown: - return ''; - } -} +const _typeToWireMap = { + AgUiEventType.runStarted: AgUiEventTypeWire.runStarted, + AgUiEventType.runFinished: AgUiEventTypeWire.runFinished, + AgUiEventType.runError: AgUiEventTypeWire.runError, + AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart, + AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent, + AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd, + AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart, + AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs, + AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd, + AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult, + AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError, + AgUiEventType.unknown: '', +}; + +AgUiEventType agUiEventTypeFromWire(String wire) => + _wireToTypeMap[wire] ?? AgUiEventType.unknown; + +String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? ''; @JsonSerializable() class AgUiEvent { diff --git a/apps/lib/features/chat/data/models/tool_result.dart b/apps/lib/features/chat/data/models/tool_result.dart index a2298e7..4e051a9 100644 --- a/apps/lib/features/chat/data/models/tool_result.dart +++ b/apps/lib/features/chat/data/models/tool_result.dart @@ -2,6 +2,9 @@ import 'package:json_annotation/json_annotation.dart'; part 'tool_result.g.dart'; +/// Schema 版本常量 +const _defaultSchemaVersion = 'v1'; + /// 工具执行结果(给 AI 的原始数据) @JsonSerializable() class ToolResult { @@ -31,7 +34,7 @@ class UiCard { UiCard({ required this.cardType, - this.schemaVersion = 'v1', + this.schemaVersion = _defaultSchemaVersion, required this.data, this.actions, }); diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index a132d33..40fb38e 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -8,6 +8,18 @@ import '../models/ag_ui_event.dart'; import '../models/tool_result.dart'; import '../tools/tool_registry.dart'; +/// Mock ID 前缀常量 +const _threadIdPrefix = 'thread_'; +const _runIdPrefix = 'run_'; +const _toolCallIdPrefix = 'tc_'; +const _messageIdPrefix = 'msg_'; + +/// 流式输出延迟 (毫秒) +const _streamChunkDelayMs = 50; + +/// 文本块大小 +const _textChunkSize = 10; + typedef EventCallback = void Function(AgUiEvent event); class AgUiService { @@ -27,8 +39,8 @@ class AgUiService { } Future _mockEventStream(String content) async { - final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}'; - final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; + final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}'; + final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(RunStartedEvent(threadId: threadId, runId: runId)); @@ -58,7 +70,8 @@ class AgUiService { String toolName, Map args, ) async { - final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}'; + final toolCallId = + '$_toolCallIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName)); @@ -82,7 +95,8 @@ class AgUiService { ToolRegistry.initialize(); final result = await ToolRegistry.execute(toolName, args); final ui = _buildUiCard(toolName, result); - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + final messageId = + '$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent( ToolCallResultEvent( @@ -145,20 +159,20 @@ class AgUiService { Future _mockTextMessageStream(List replies) async { for (final reply in replies) { - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + final messageId = + '$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant')); - const chunkSize = 10; - for (var i = 0; i < reply.length; i += chunkSize) { - final end = (i + chunkSize < reply.length) - ? i + chunkSize + for (var i = 0; i < reply.length; i += _textChunkSize) { + final end = (i + _textChunkSize < reply.length) + ? i + _textChunkSize : reply.length; final chunk = reply.substring(i, end); onEvent(TextMessageContentEvent(messageId: messageId, delta: chunk)); - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: _streamChunkDelayMs)); } onEvent(TextMessageEndEvent(messageId: messageId)); diff --git a/apps/lib/features/chat/data/tools/tool_registry.dart b/apps/lib/features/chat/data/tools/tool_registry.dart index 4a88b58..8b05761 100644 --- a/apps/lib/features/chat/data/tools/tool_registry.dart +++ b/apps/lib/features/chat/data/tools/tool_registry.dart @@ -1,6 +1,14 @@ typedef ToolHandler = Future> Function(Map args); +/// 工具常量 +const _toolNameCreateCalendar = 'create_calendar_event'; +const _defaultTimezone = 'Asia/Shanghai'; +const _defaultEventColor = '#4F46E5'; +const _defaultSourceType = 'agentGenerated'; +const _titleMinLength = 1; +const _titleMaxLength = 100; + class ToolDefinition { final String name; final String description; @@ -22,8 +30,8 @@ class ToolRegistry { static void initialize() { if (_initialized) return; - _tools['create_calendar_event'] = ToolDefinition( - name: 'create_calendar_event', + _tools[_toolNameCreateCalendar] = ToolDefinition( + name: _toolNameCreateCalendar, description: '创建一个日历事件或待办事项', parameters: { 'type': 'object', @@ -31,8 +39,8 @@ class ToolRegistry { 'title': { 'type': 'string', 'description': '事件标题', - 'minLength': 1, - 'maxLength': 100, + 'minLength': _titleMinLength, + 'maxLength': _titleMaxLength, }, 'description': {'type': 'string', 'description': '事件描述'}, 'startAt': { @@ -45,7 +53,7 @@ class ToolRegistry { 'format': 'date-time', 'description': '结束时间 (ISO8601)', }, - 'timezone': {'type': 'string', 'default': 'Asia/Shanghai'}, + 'timezone': {'type': 'string', 'default': _defaultTimezone}, 'location': {'type': 'string'}, 'notes': {'type': 'string'}, }, @@ -69,10 +77,10 @@ class ToolRegistry { 'description': args['description'], 'startAt': args['startAt'], 'endAt': args['endAt'], - 'timezone': args['timezone'] ?? 'Asia/Shanghai', + 'timezone': args['timezone'] ?? _defaultTimezone, 'location': args['location'], - 'color': '#4F46E5', - 'sourceType': 'agentGenerated', + 'color': _defaultEventColor, + 'sourceType': _defaultSourceType, }; } @@ -93,17 +101,19 @@ class ToolRegistry { Map args, ) { final tool = _tools[toolName]; - if (tool == null) + if (tool == null) { return ToolValidationResult( ok: false, error: 'Tool not found: $toolName', ); + } final required = tool.parameters['required'] as List? ?? []; final missing = []; for (final field in required) { - if (!args.containsKey(field) || args[field] == null) + if (!args.containsKey(field) || args[field] == null) { missing.add(field as String); + } } if (missing.isNotEmpty) { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 20f2fd5..d8099ee 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../data/models/ag_ui_event.dart'; import '../../data/models/chat_list_item.dart'; import '../../data/models/tool_result.dart'; +import '../../data/services/ag_ui_service.dart'; class ChatState { final List items; @@ -38,20 +39,12 @@ class ChatState { } } -class AgUiService { - void Function(AgUiEvent)? onEvent; - - AgUiService({this.onEvent}); - - Future sendMessage(String content) async {} -} - class ChatBloc extends Cubit { final AgUiService _service; final Map _toolCallArgsBuffer = {}; ChatBloc({AgUiService? service}) - : _service = service ?? AgUiService(onEvent: (_) {}), + : _service = service ?? AgUiService(), super(const ChatState()) { _service.onEvent = _handleEvent; } diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index 2e7da89..c4f1884 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -2,16 +2,19 @@ import 'package:flutter/material.dart'; import 'package:social_app/core/theme/design_tokens.dart'; import '../../data/models/tool_result.dart'; +/// 卡片类型常量 +const _calendarCardType = 'calendar_card.v1'; +const _errorCardType = 'error_card.v1'; +const _aiGeneratedSource = 'ai_generated'; +const _primaryActionType = 'primary'; + class UiSchemaRenderer { static Widget render(UiCard card) { - switch (card.cardType) { - case 'calendar_card.v1': - return _renderCalendarCard(card); - case 'error_card.v1': - return _renderErrorCard(card); - default: - return _renderUnknownCard(card); - } + return switch (card.cardType) { + _calendarCardType => _renderCalendarCard(card), + _errorCardType => _renderErrorCard(card), + _ => _renderUnknownCard(card), + }; } static Widget _renderCalendarCard(UiCard card) { @@ -19,6 +22,7 @@ class UiSchemaRenderer { final color = data.color != null ? Color(int.parse(data.color!.replaceFirst('#', '0xFF'))) : AppColors.blue500; + final isAiGenerated = data.sourceType == _aiGeneratedSource; return Container( decoration: BoxDecoration( @@ -44,23 +48,10 @@ class UiSchemaRenderer { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (data.sourceType == 'ai_generated') - Container( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: AppColors.messageTagBg, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - 'AI生成', - style: TextStyle(fontSize: 10, color: AppColors.blue600), - ), - ), - if (data.sourceType == 'ai_generated') + if (isAiGenerated) ...[ + _buildAiTag(), SizedBox(height: AppSpacing.sm), + ], Text( _formatTime(data.startAt, data.endAt), style: TextStyle(fontSize: 12, color: AppColors.slate500), @@ -83,32 +74,11 @@ class UiSchemaRenderer { ], if (data.location != null) ...[ SizedBox(height: AppSpacing.sm), - Row( - children: [ - Icon( - Icons.location_on_outlined, - size: 16, - color: AppColors.slate500, - ), - SizedBox(width: AppSpacing.xs), - Text( - data.location!, - style: TextStyle( - fontSize: 12, - color: AppColors.slate500, - ), - ), - ], - ), + _buildLocation(data.location!), ], if (card.actions != null && card.actions!.isNotEmpty) ...[ SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - children: card.actions! - .map((action) => _buildActionButton(action)) - .toList(), - ), + _buildActions(card.actions!), ], ], ), @@ -118,8 +88,45 @@ class UiSchemaRenderer { ); } + static Widget _buildAiTag() { + return Container( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.messageTagBg, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + 'AI生成', + style: TextStyle(fontSize: 10, color: AppColors.blue600), + ), + ); + } + + static Widget _buildLocation(String location) { + return Row( + children: [ + Icon(Icons.location_on_outlined, size: 16, color: AppColors.slate500), + SizedBox(width: AppSpacing.xs), + Text( + location, + style: TextStyle(fontSize: 12, color: AppColors.slate500), + ), + ], + ); + } + + static Widget _buildActions(List actions) { + return Wrap( + spacing: AppSpacing.sm, + children: actions.map((action) => _buildActionButton(action)).toList(), + ); + } + static Widget _buildActionButton(CardAction action) { - final isPrimary = action.type == 'primary'; + final isPrimary = action.type == _primaryActionType; return GestureDetector( onTap: () => _handleAction(action), child: Container( diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 32fc1e5..a8abbea 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -10,6 +10,25 @@ import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'home_sheet.dart'; +/// 布局常量 +const _headerHeight = 60.0; +const _defaultPadding = 20.0; +const _itemSpacing = 16.0; +const _inputPadding = 16.0; +const _iconSize = 24.0; +const _avatarSize = 32.0; +const _botIconSize = 18.0; +const _messagePaddingH = 13.0; +const _messagePaddingV = 9.0; +const _cornerRadius = 12.0; +const _inputMinHeight = 48.0; +const _inputRadius = 24.0; +const _scrollDurationMs = 300; + +/// 颜色常量 +const _chatBgColor = Color(0xFFF8FAFC); +const _userBubbleColor = Color(0xFFEAF1FB); + class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -53,7 +72,7 @@ class _HomeScreenState extends State { }, builder: (context, state) { return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + backgroundColor: _chatBgColor, body: SafeArea( child: Column( children: [ @@ -71,16 +90,16 @@ class _HomeScreenState extends State { Widget _buildHeader(BuildContext context) { return SizedBox( - height: 60, + height: _headerHeight, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(horizontal: _defaultPadding), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ IconButton( icon: const Icon( LucideIcons.settings, - size: 24, + size: _iconSize, color: AppColors.slate900, ), onPressed: () => context.push('/settings'), @@ -90,16 +109,16 @@ class _HomeScreenState extends State { IconButton( icon: const Icon( LucideIcons.calendar, - size: 24, + size: _iconSize, color: AppColors.slate900, ), onPressed: () => context.push('/calendar/dayweek?from=home'), ), - const SizedBox(width: 16), + const SizedBox(width: _itemSpacing), IconButton( icon: const Icon( LucideIcons.messageSquare, - size: 24, + size: _iconSize, color: AppColors.slate900, ), onPressed: () => context.push('/messages/invites'), @@ -126,7 +145,7 @@ class _HomeScreenState extends State { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); } @@ -134,12 +153,12 @@ class _HomeScreenState extends State { return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(_defaultPadding), itemCount: state.items.length, itemBuilder: (context, index) { final item = state.items[index]; return Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: _itemSpacing), child: _buildChatItem(item), ); }, @@ -167,15 +186,15 @@ class _HomeScreenState extends State { children: [ if (!isUser) ...[ Container( - width: 32, - height: 32, + width: _avatarSize, + height: _avatarSize, decoration: BoxDecoration( color: AppColors.blue100, shape: BoxShape.circle, ), child: const Icon( LucideIcons.bot, - size: 18, + size: _botIconSize, color: AppColors.blue600, ), ), @@ -183,14 +202,17 @@ class _HomeScreenState extends State { ], Flexible( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), + padding: const EdgeInsets.symmetric( + horizontal: _messagePaddingH, + vertical: _messagePaddingV, + ), decoration: BoxDecoration( - color: isUser ? const Color(0xFFEAF1FB) : AppColors.white, + color: isUser ? _userBubbleColor : AppColors.white, borderRadius: BorderRadius.only( - topLeft: const Radius.circular(12), - topRight: const Radius.circular(12), - bottomLeft: Radius.circular(isUser ? 12 : 0), - bottomRight: Radius.circular(isUser ? 0 : 12), + topLeft: const Radius.circular(_cornerRadius), + topRight: const Radius.circular(_cornerRadius), + bottomLeft: Radius.circular(isUser ? _cornerRadius : 0), + bottomRight: Radius.circular(isUser ? 0 : _cornerRadius), ), border: isUser ? null : Border.all(color: AppColors.slate300), ), @@ -207,32 +229,28 @@ class _HomeScreenState extends State { } Widget _buildToolCallItem(ToolCallItem item) { - String statusText; - Color statusColor; - IconData statusIcon; - - switch (item.status) { - case ToolCallStatus.pending: - statusText = '准备中...'; - statusColor = AppColors.slate500; - statusIcon = LucideIcons.clock; - break; - case ToolCallStatus.executing: - statusText = '执行中...'; - statusColor = AppColors.blue600; - statusIcon = LucideIcons.loader; - break; - case ToolCallStatus.error: - statusText = item.errorMessage ?? '执行失败'; - statusColor = AppColors.red600; - statusIcon = LucideIcons.alertCircle; - break; - case ToolCallStatus.completed: - statusText = '已完成'; - statusColor = AppColors.emerald600; - statusIcon = LucideIcons.checkCircle; - break; - } + final (statusText, statusColor, statusIcon) = switch (item.status) { + ToolCallStatus.pending => ( + '准备中...', + AppColors.slate500, + LucideIcons.clock, + ), + ToolCallStatus.executing => ( + '执行中...', + AppColors.blue600, + LucideIcons.loader, + ), + ToolCallStatus.error => ( + item.errorMessage ?? '执行失败', + AppColors.red600, + LucideIcons.alertCircle, + ), + ToolCallStatus.completed => ( + '已完成', + AppColors.emerald600, + LucideIcons.checkCircle, + ), + }; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -267,8 +285,8 @@ class _HomeScreenState extends State { Widget _buildInputContainer(BuildContext context) { return Container( - padding: const EdgeInsets.all(16), - color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.all(_inputPadding), + color: _chatBgColor, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -292,11 +310,11 @@ class _HomeScreenState extends State { const SizedBox(width: 8), Expanded( child: Container( - constraints: const BoxConstraints(minHeight: 48), + constraints: const BoxConstraints(minHeight: _inputMinHeight), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: Colors.transparent, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(_inputRadius), border: Border.all(color: AppColors.slate300), ), child: Row( @@ -327,7 +345,7 @@ class _HomeScreenState extends State { onTap: _hasMessage ? () => _sendMessage(context) : null, child: Icon( _hasMessage ? LucideIcons.send : LucideIcons.mic, - size: 24, + size: _iconSize, color: _hasMessage ? AppColors.blue600 : AppColors.slate500, diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart index 8b2ec7b..ace218f 100644 --- a/apps/test/features/chat/chat_bloc_test.dart +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -2,14 +2,22 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:social_app/features/chat/data/models/ag_ui_event.dart'; import 'package:social_app/features/chat/data/models/chat_list_item.dart'; +import 'package:social_app/features/chat/data/services/ag_ui_service.dart'; import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; +class MockAgUiService extends AgUiService { + MockAgUiService() : super(onEvent: (_) {}); + + @override + Future sendMessage(String content) async {} +} + void main() { late ChatBloc chatBloc; late AgUiService service; setUp(() { - service = AgUiService(); + service = MockAgUiService(); chatBloc = ChatBloc(service: service); }); @@ -49,7 +57,7 @@ void main() { build: () => chatBloc, act: (bloc) { bloc.emit(chatBloc.state.copyWith(isLoading: true)); - service.onEvent!( + service.onEvent( TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'), ); }, @@ -86,7 +94,7 @@ void main() { currentMessageId: 'msg_1', ), act: (bloc) { - service.onEvent!( + service.onEvent( TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'), ); }, @@ -115,7 +123,7 @@ void main() { currentMessageId: 'msg_1', ), act: (bloc) { - service.onEvent!(TextMessageEndEvent(messageId: 'msg_1')); + service.onEvent(TextMessageEndEvent(messageId: 'msg_1')); }, expect: () => [ isA() @@ -132,7 +140,7 @@ void main() { 'runStarted sets isLoading to true', build: () => chatBloc, act: (bloc) { - service.onEvent!(RunStartedEvent(threadId: 't1', runId: 'r1')); + service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() @@ -146,7 +154,7 @@ void main() { build: () => chatBloc, seed: () => const ChatState(isLoading: true), act: (bloc) { - service.onEvent!(RunFinishedEvent(threadId: 't1', runId: 'r1')); + service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() @@ -160,7 +168,7 @@ void main() { build: () => chatBloc, seed: () => const ChatState(isLoading: true), act: (bloc) { - service.onEvent!( + service.onEvent( RunErrorEvent(message: 'Something went wrong', code: 'ERR'), ); }, @@ -183,7 +191,7 @@ void main() { 'toolCallStart adds ToolCallItem', build: () => chatBloc, act: (bloc) { - service.onEvent!( + service.onEvent( ToolCallStartEvent( toolCallId: 'tc_1', toolCallName: 'create_calendar_event',