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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user