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