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 }
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;
@@ -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 {
@@ -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,
});
@@ -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<void> _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<String, dynamic> 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<void> _mockTextMessageStream(List<String> 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));
@@ -1,6 +1,14 @@
typedef ToolHandler =
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 {
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<String, dynamic> 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<dynamic>? ?? [];
final missing = <String>[];
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) {
@@ -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<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> {
final AgUiService _service;
final Map<String, String> _toolCallArgsBuffer = {};
ChatBloc({AgUiService? service})
: _service = service ?? AgUiService(onEvent: (_) {}),
: _service = service ?? AgUiService(),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
@@ -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<CardAction> 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(
@@ -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<HomeScreen> {
},
builder: (context, state) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
backgroundColor: _chatBgColor,
body: SafeArea(
child: Column(
children: [
@@ -71,16 +90,16 @@ class _HomeScreenState extends State<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
],
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<HomeScreen> {
}
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<HomeScreen> {
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<HomeScreen> {
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<HomeScreen> {
onTap: _hasMessage ? () => _sendMessage(context) : null,
child: Icon(
_hasMessage ? LucideIcons.send : LucideIcons.mic,
size: 24,
size: _iconSize,
color: _hasMessage
? AppColors.blue600
: 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: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<void> 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<ChatState>()
@@ -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<ChatState>()
@@ -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<ChatState>()
@@ -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',