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) {