feat(chat): implement AG-UI protocol AI chat feature
- Add AG-UI event models with wire protocol mapping - Implement ToolRegistry with create_calendar_event tool - Create AiDecisionEngine for intent matching - Add AgUiService with mock event stream support - Implement ChatBloc for state management - Create UiSchemaRenderer with design tokens - Integrate ChatBloc into HomeScreen - Add ChatHistoryRepository with shared_preferences - Add comprehensive unit tests (97 tests) - Fix ChatBloc event callback initialization
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum Intent { createEvent, searchEvent, unknown }
|
||||
|
||||
/// 意图匹配规则(顺序敏感: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;
|
||||
}
|
||||
return Intent.unknown;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? tryExtractEventArgs(String text) {
|
||||
if (matchIntent(text) != Intent.createEvent) return null;
|
||||
|
||||
final args = <String, dynamic>{};
|
||||
|
||||
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
|
||||
if (titleMatch != null) {
|
||||
args['title'] = titleMatch.group(1)?.trim() ?? text;
|
||||
} else if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
|
||||
args['title'] = text
|
||||
.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
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) ?? _dayToday;
|
||||
final hour = int.parse(timeMatch.group(2)!);
|
||||
final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute');
|
||||
|
||||
final now = DateTime.now();
|
||||
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'] = _defaultTimezone;
|
||||
}
|
||||
|
||||
if (!args.containsKey('startAt')) return null;
|
||||
return args;
|
||||
}
|
||||
|
||||
bool shouldTriggerToolCall(String text) =>
|
||||
matchIntent(text) == Intent.createEvent;
|
||||
|
||||
Map<String, dynamic>? getToolCallArgs(String text) {
|
||||
if (!shouldTriggerToolCall(text)) return null;
|
||||
return tryExtractEventArgs(text);
|
||||
}
|
||||
|
||||
ForceTriggerResult? tryForceTrigger(String text) {
|
||||
final match = RegExp(r'#tool:(\w+)\s*(\{.*\})?').firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final toolName = match.group(1)!;
|
||||
final argsJson = match.group(2);
|
||||
|
||||
Map<String, dynamic>? args;
|
||||
if (argsJson != null) {
|
||||
try {
|
||||
args = jsonDecode(argsJson) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
|
||||
return ForceTriggerResult(toolName: toolName, args: args ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
class ForceTriggerResult {
|
||||
final String toolName;
|
||||
final Map<String, dynamic> args;
|
||||
ForceTriggerResult({required this.toolName, required this.args});
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'tool_result.dart';
|
||||
|
||||
part 'ag_ui_event.g.dart';
|
||||
|
||||
class AgUiEventTypeWire {
|
||||
static const runStarted = 'RUN_STARTED';
|
||||
static const runFinished = 'RUN_FINISHED';
|
||||
static const runError = 'RUN_ERROR';
|
||||
static const textMessageStart = 'TEXT_MESSAGE_START';
|
||||
static const textMessageContent = 'TEXT_MESSAGE_CONTENT';
|
||||
static const textMessageEnd = 'TEXT_MESSAGE_END';
|
||||
static const toolCallStart = 'TOOL_CALL_START';
|
||||
static const toolCallArgs = 'TOOL_CALL_ARGS';
|
||||
static const toolCallEnd = 'TOOL_CALL_END';
|
||||
static const toolCallResult = 'TOOL_CALL_RESULT';
|
||||
static const toolCallError = 'TOOL_CALL_ERROR';
|
||||
}
|
||||
|
||||
enum AgUiEventType {
|
||||
runStarted,
|
||||
runFinished,
|
||||
runError,
|
||||
textMessageStart,
|
||||
textMessageContent,
|
||||
textMessageEnd,
|
||||
toolCallStart,
|
||||
toolCallArgs,
|
||||
toolCallEnd,
|
||||
toolCallResult,
|
||||
toolCallError,
|
||||
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,
|
||||
};
|
||||
|
||||
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 {
|
||||
final AgUiEventType type;
|
||||
|
||||
AgUiEvent({required this.type});
|
||||
|
||||
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
|
||||
final typeStr = json['type'] as String? ?? '';
|
||||
final type = agUiEventTypeFromWire(typeStr);
|
||||
|
||||
switch (type) {
|
||||
case AgUiEventType.runStarted:
|
||||
return RunStartedEvent.fromJson(json);
|
||||
case AgUiEventType.runFinished:
|
||||
return RunFinishedEvent.fromJson(json);
|
||||
case AgUiEventType.runError:
|
||||
return RunErrorEvent.fromJson(json);
|
||||
case AgUiEventType.textMessageStart:
|
||||
return TextMessageStartEvent.fromJson(json);
|
||||
case AgUiEventType.textMessageContent:
|
||||
return TextMessageContentEvent.fromJson(json);
|
||||
case AgUiEventType.textMessageEnd:
|
||||
return TextMessageEndEvent.fromJson(json);
|
||||
case AgUiEventType.toolCallStart:
|
||||
return ToolCallStartEvent.fromJson(json);
|
||||
case AgUiEventType.toolCallArgs:
|
||||
return ToolCallArgsEvent.fromJson(json);
|
||||
case AgUiEventType.toolCallEnd:
|
||||
return ToolCallEndEvent.fromJson(json);
|
||||
case AgUiEventType.toolCallResult:
|
||||
return ToolCallResultEvent.fromJson(json);
|
||||
case AgUiEventType.toolCallError:
|
||||
return ToolCallErrorEvent.fromJson(json);
|
||||
case AgUiEventType.unknown:
|
||||
return UnknownAgUiEvent.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class UnknownAgUiEvent extends AgUiEvent {
|
||||
final Map<String, dynamic> rawJson;
|
||||
|
||||
UnknownAgUiEvent({required this.rawJson})
|
||||
: super(type: AgUiEventType.unknown);
|
||||
|
||||
factory UnknownAgUiEvent.fromJson(Map<String, dynamic> json) =>
|
||||
UnknownAgUiEvent(rawJson: json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => rawJson;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunStartedEvent extends AgUiEvent {
|
||||
final String threadId;
|
||||
final String runId;
|
||||
|
||||
RunStartedEvent({required this.threadId, required this.runId})
|
||||
: super(type: AgUiEventType.runStarted);
|
||||
|
||||
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunStartedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunStartedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunFinishedEvent extends AgUiEvent {
|
||||
final String threadId;
|
||||
final String runId;
|
||||
|
||||
RunFinishedEvent({required this.threadId, required this.runId})
|
||||
: super(type: AgUiEventType.runFinished);
|
||||
|
||||
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunFinishedEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunFinishedEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class RunErrorEvent extends AgUiEvent {
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
RunErrorEvent({required this.message, this.code})
|
||||
: super(type: AgUiEventType.runError);
|
||||
|
||||
factory RunErrorEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$RunErrorEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$RunErrorEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageStartEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String role;
|
||||
|
||||
TextMessageStartEvent({required this.messageId, required this.role})
|
||||
: super(type: AgUiEventType.textMessageStart);
|
||||
|
||||
factory TextMessageStartEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageStartEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageStartEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageContentEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String delta;
|
||||
|
||||
TextMessageContentEvent({required this.messageId, required this.delta})
|
||||
: super(type: AgUiEventType.textMessageContent);
|
||||
|
||||
factory TextMessageContentEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageContentEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageContentEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TextMessageEndEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
|
||||
TextMessageEndEvent({required this.messageId})
|
||||
: super(type: AgUiEventType.textMessageEnd);
|
||||
|
||||
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$TextMessageEndEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$TextMessageEndEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallStartEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
final String toolCallName;
|
||||
final String? parentMessageId;
|
||||
|
||||
ToolCallStartEvent({
|
||||
required this.toolCallId,
|
||||
required this.toolCallName,
|
||||
this.parentMessageId,
|
||||
}) : super(type: AgUiEventType.toolCallStart);
|
||||
|
||||
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallStartEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallStartEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallArgsEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
final String delta;
|
||||
|
||||
ToolCallArgsEvent({required this.toolCallId, required this.delta})
|
||||
: super(type: AgUiEventType.toolCallArgs);
|
||||
|
||||
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallArgsEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallArgsEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallEndEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
|
||||
ToolCallEndEvent({required this.toolCallId})
|
||||
: super(type: AgUiEventType.toolCallEnd);
|
||||
|
||||
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallEndEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallEndEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallResultEvent extends AgUiEvent {
|
||||
final String messageId;
|
||||
final String toolCallId;
|
||||
final Map<String, dynamic> result;
|
||||
final UiCard? ui;
|
||||
|
||||
ToolCallResultEvent({
|
||||
required this.messageId,
|
||||
required this.toolCallId,
|
||||
required this.result,
|
||||
this.ui,
|
||||
}) : super(type: AgUiEventType.toolCallResult);
|
||||
|
||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallResultEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallResultEventToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ToolCallErrorEvent extends AgUiEvent {
|
||||
final String toolCallId;
|
||||
final String error;
|
||||
final String? code;
|
||||
|
||||
ToolCallErrorEvent({required this.toolCallId, required this.error, this.code})
|
||||
: super(type: AgUiEventType.toolCallError);
|
||||
|
||||
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolCallErrorEventFromJson(json);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$ToolCallErrorEventToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ag_ui_event.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
AgUiEvent _$AgUiEventFromJson(Map<String, dynamic> json) =>
|
||||
AgUiEvent(type: $enumDecode(_$AgUiEventTypeEnumMap, json['type']));
|
||||
|
||||
Map<String, dynamic> _$AgUiEventToJson(AgUiEvent instance) => <String, dynamic>{
|
||||
'type': _$AgUiEventTypeEnumMap[instance.type]!,
|
||||
};
|
||||
|
||||
const _$AgUiEventTypeEnumMap = {
|
||||
AgUiEventType.runStarted: 'runStarted',
|
||||
AgUiEventType.runFinished: 'runFinished',
|
||||
AgUiEventType.runError: 'runError',
|
||||
AgUiEventType.textMessageStart: 'textMessageStart',
|
||||
AgUiEventType.textMessageContent: 'textMessageContent',
|
||||
AgUiEventType.textMessageEnd: 'textMessageEnd',
|
||||
AgUiEventType.toolCallStart: 'toolCallStart',
|
||||
AgUiEventType.toolCallArgs: 'toolCallArgs',
|
||||
AgUiEventType.toolCallEnd: 'toolCallEnd',
|
||||
AgUiEventType.toolCallResult: 'toolCallResult',
|
||||
AgUiEventType.toolCallError: 'toolCallError',
|
||||
AgUiEventType.unknown: 'unknown',
|
||||
};
|
||||
|
||||
UnknownAgUiEvent _$UnknownAgUiEventFromJson(Map<String, dynamic> json) =>
|
||||
UnknownAgUiEvent(rawJson: json['rawJson'] as Map<String, dynamic>);
|
||||
|
||||
Map<String, dynamic> _$UnknownAgUiEventToJson(UnknownAgUiEvent instance) =>
|
||||
<String, dynamic>{'rawJson': instance.rawJson};
|
||||
|
||||
RunStartedEvent _$RunStartedEventFromJson(Map<String, dynamic> json) =>
|
||||
RunStartedEvent(
|
||||
threadId: json['threadId'] as String,
|
||||
runId: json['runId'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RunStartedEventToJson(RunStartedEvent instance) =>
|
||||
<String, dynamic>{'threadId': instance.threadId, 'runId': instance.runId};
|
||||
|
||||
RunFinishedEvent _$RunFinishedEventFromJson(Map<String, dynamic> json) =>
|
||||
RunFinishedEvent(
|
||||
threadId: json['threadId'] as String,
|
||||
runId: json['runId'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RunFinishedEventToJson(RunFinishedEvent instance) =>
|
||||
<String, dynamic>{'threadId': instance.threadId, 'runId': instance.runId};
|
||||
|
||||
RunErrorEvent _$RunErrorEventFromJson(Map<String, dynamic> json) =>
|
||||
RunErrorEvent(
|
||||
message: json['message'] as String,
|
||||
code: json['code'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RunErrorEventToJson(RunErrorEvent instance) =>
|
||||
<String, dynamic>{'message': instance.message, 'code': instance.code};
|
||||
|
||||
TextMessageStartEvent _$TextMessageStartEventFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => TextMessageStartEvent(
|
||||
messageId: json['messageId'] as String,
|
||||
role: json['role'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TextMessageStartEventToJson(
|
||||
TextMessageStartEvent instance,
|
||||
) => <String, dynamic>{'messageId': instance.messageId, 'role': instance.role};
|
||||
|
||||
TextMessageContentEvent _$TextMessageContentEventFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => TextMessageContentEvent(
|
||||
messageId: json['messageId'] as String,
|
||||
delta: json['delta'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TextMessageContentEventToJson(
|
||||
TextMessageContentEvent instance,
|
||||
) => <String, dynamic>{
|
||||
'messageId': instance.messageId,
|
||||
'delta': instance.delta,
|
||||
};
|
||||
|
||||
TextMessageEndEvent _$TextMessageEndEventFromJson(Map<String, dynamic> json) =>
|
||||
TextMessageEndEvent(messageId: json['messageId'] as String);
|
||||
|
||||
Map<String, dynamic> _$TextMessageEndEventToJson(
|
||||
TextMessageEndEvent instance,
|
||||
) => <String, dynamic>{'messageId': instance.messageId};
|
||||
|
||||
ToolCallStartEvent _$ToolCallStartEventFromJson(Map<String, dynamic> json) =>
|
||||
ToolCallStartEvent(
|
||||
toolCallId: json['toolCallId'] as String,
|
||||
toolCallName: json['toolCallName'] as String,
|
||||
parentMessageId: json['parentMessageId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ToolCallStartEventToJson(ToolCallStartEvent instance) =>
|
||||
<String, dynamic>{
|
||||
'toolCallId': instance.toolCallId,
|
||||
'toolCallName': instance.toolCallName,
|
||||
'parentMessageId': instance.parentMessageId,
|
||||
};
|
||||
|
||||
ToolCallArgsEvent _$ToolCallArgsEventFromJson(Map<String, dynamic> json) =>
|
||||
ToolCallArgsEvent(
|
||||
toolCallId: json['toolCallId'] as String,
|
||||
delta: json['delta'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ToolCallArgsEventToJson(ToolCallArgsEvent instance) =>
|
||||
<String, dynamic>{
|
||||
'toolCallId': instance.toolCallId,
|
||||
'delta': instance.delta,
|
||||
};
|
||||
|
||||
ToolCallEndEvent _$ToolCallEndEventFromJson(Map<String, dynamic> json) =>
|
||||
ToolCallEndEvent(toolCallId: json['toolCallId'] as String);
|
||||
|
||||
Map<String, dynamic> _$ToolCallEndEventToJson(ToolCallEndEvent instance) =>
|
||||
<String, dynamic>{'toolCallId': instance.toolCallId};
|
||||
|
||||
ToolCallResultEvent _$ToolCallResultEventFromJson(Map<String, dynamic> json) =>
|
||||
ToolCallResultEvent(
|
||||
messageId: json['messageId'] as String,
|
||||
toolCallId: json['toolCallId'] as String,
|
||||
result: json['result'] as Map<String, dynamic>,
|
||||
ui: json['ui'] == null
|
||||
? null
|
||||
: UiCard.fromJson(json['ui'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ToolCallResultEventToJson(
|
||||
ToolCallResultEvent instance,
|
||||
) => <String, dynamic>{
|
||||
'messageId': instance.messageId,
|
||||
'toolCallId': instance.toolCallId,
|
||||
'result': instance.result,
|
||||
'ui': instance.ui,
|
||||
};
|
||||
|
||||
ToolCallErrorEvent _$ToolCallErrorEventFromJson(Map<String, dynamic> json) =>
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: json['toolCallId'] as String,
|
||||
error: json['error'] as String,
|
||||
code: json['code'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ToolCallErrorEventToJson(ToolCallErrorEvent instance) =>
|
||||
<String, dynamic>{
|
||||
'toolCallId': instance.toolCallId,
|
||||
'error': instance.error,
|
||||
'code': instance.code,
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'tool_result.dart';
|
||||
|
||||
enum ChatItemType { message, toolCall, toolResult }
|
||||
|
||||
enum MessageSender { user, ai }
|
||||
|
||||
enum ToolCallStatus { pending, executing, completed, error }
|
||||
|
||||
abstract class ChatListItem {
|
||||
String get id;
|
||||
DateTime get timestamp;
|
||||
ChatItemType get type;
|
||||
MessageSender get sender;
|
||||
}
|
||||
|
||||
class TextMessageItem extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final String content;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
final MessageSender sender;
|
||||
final bool isStreaming;
|
||||
|
||||
TextMessageItem({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
this.isStreaming = false,
|
||||
});
|
||||
|
||||
@override
|
||||
ChatItemType get type => ChatItemType.message;
|
||||
|
||||
TextMessageItem copyWith({
|
||||
String? id,
|
||||
String? content,
|
||||
DateTime? timestamp,
|
||||
MessageSender? sender,
|
||||
bool? isStreaming,
|
||||
}) => TextMessageItem(
|
||||
id: id ?? this.id,
|
||||
content: content ?? this.content,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
sender: sender ?? this.sender,
|
||||
isStreaming: isStreaming ?? this.isStreaming,
|
||||
);
|
||||
}
|
||||
|
||||
class ToolCallItem extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final String callId;
|
||||
final String toolName;
|
||||
final Map<String, dynamic> args;
|
||||
final ToolCallStatus status;
|
||||
final String? errorMessage;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
final MessageSender sender;
|
||||
|
||||
ToolCallItem({
|
||||
required this.id,
|
||||
required this.callId,
|
||||
required this.toolName,
|
||||
required this.args,
|
||||
required this.status,
|
||||
this.errorMessage,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
});
|
||||
|
||||
@override
|
||||
ChatItemType get type => ChatItemType.toolCall;
|
||||
|
||||
ToolCallItem copyWith({
|
||||
String? id,
|
||||
String? callId,
|
||||
String? toolName,
|
||||
Map<String, dynamic>? args,
|
||||
ToolCallStatus? status,
|
||||
String? errorMessage,
|
||||
DateTime? timestamp,
|
||||
MessageSender? sender,
|
||||
}) => ToolCallItem(
|
||||
id: id ?? this.id,
|
||||
callId: callId ?? this.callId,
|
||||
toolName: toolName ?? this.toolName,
|
||||
args: args ?? this.args,
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
sender: sender ?? this.sender,
|
||||
);
|
||||
}
|
||||
|
||||
class ToolResultItem extends ChatListItem {
|
||||
@override
|
||||
final String id;
|
||||
final String callId;
|
||||
final UiCard uiCard;
|
||||
@override
|
||||
final DateTime timestamp;
|
||||
@override
|
||||
final MessageSender sender;
|
||||
|
||||
ToolResultItem({
|
||||
required this.id,
|
||||
required this.callId,
|
||||
required this.uiCard,
|
||||
required this.timestamp,
|
||||
required this.sender,
|
||||
});
|
||||
|
||||
@override
|
||||
ChatItemType get type => ChatItemType.toolResult;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'tool_result.g.dart';
|
||||
|
||||
/// Schema 版本常量
|
||||
const _defaultSchemaVersion = 'v1';
|
||||
|
||||
/// 工具执行结果(给 AI 的原始数据)
|
||||
@JsonSerializable()
|
||||
class ToolResult {
|
||||
final String? eventId;
|
||||
final bool ok;
|
||||
final String? message;
|
||||
|
||||
ToolResult({this.eventId, this.ok = true, this.message});
|
||||
|
||||
factory ToolResult.fromJson(Map<String, dynamic> json) =>
|
||||
_$ToolResultFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ToolResultToJson(this);
|
||||
}
|
||||
|
||||
/// UI 卡片 Schema(给 UI 渲染)
|
||||
@JsonSerializable()
|
||||
class UiCard {
|
||||
@JsonKey(name: 'type')
|
||||
final String cardType;
|
||||
|
||||
@JsonKey(name: 'version')
|
||||
final String? schemaVersion;
|
||||
|
||||
final Map<String, dynamic> data;
|
||||
final List<CardAction>? actions;
|
||||
|
||||
UiCard({
|
||||
required this.cardType,
|
||||
this.schemaVersion = _defaultSchemaVersion,
|
||||
required this.data,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
factory UiCard.fromJson(Map<String, dynamic> json) => _$UiCardFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$UiCardToJson(this);
|
||||
}
|
||||
|
||||
/// 卡片操作按钮
|
||||
@JsonSerializable()
|
||||
class CardAction {
|
||||
final String type;
|
||||
final String label;
|
||||
final String? target;
|
||||
final String? action;
|
||||
|
||||
CardAction({
|
||||
required this.type,
|
||||
required this.label,
|
||||
this.target,
|
||||
this.action,
|
||||
});
|
||||
|
||||
factory CardAction.fromJson(Map<String, dynamic> json) =>
|
||||
_$CardActionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CardActionToJson(this);
|
||||
}
|
||||
|
||||
/// 日历卡片数据
|
||||
@JsonSerializable()
|
||||
class CalendarCardData {
|
||||
final String id;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String startAt;
|
||||
final String? endAt;
|
||||
final String? timezone;
|
||||
final String? location;
|
||||
final String? color;
|
||||
final String? sourceType;
|
||||
|
||||
CalendarCardData({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.startAt,
|
||||
this.endAt,
|
||||
this.timezone,
|
||||
this.location,
|
||||
this.color,
|
||||
this.sourceType,
|
||||
});
|
||||
|
||||
factory CalendarCardData.fromJson(Map<String, dynamic> json) =>
|
||||
_$CalendarCardDataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CalendarCardDataToJson(this);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'tool_result.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ToolResult _$ToolResultFromJson(Map<String, dynamic> json) => ToolResult(
|
||||
eventId: json['eventId'] as String?,
|
||||
ok: json['ok'] as bool? ?? true,
|
||||
message: json['message'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ToolResultToJson(ToolResult instance) =>
|
||||
<String, dynamic>{
|
||||
'eventId': instance.eventId,
|
||||
'ok': instance.ok,
|
||||
'message': instance.message,
|
||||
};
|
||||
|
||||
UiCard _$UiCardFromJson(Map<String, dynamic> json) => UiCard(
|
||||
cardType: json['type'] as String,
|
||||
schemaVersion: json['version'] as String? ?? 'v1',
|
||||
data: json['data'] as Map<String, dynamic>,
|
||||
actions: (json['actions'] as List<dynamic>?)
|
||||
?.map((e) => CardAction.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UiCardToJson(UiCard instance) => <String, dynamic>{
|
||||
'type': instance.cardType,
|
||||
'version': instance.schemaVersion,
|
||||
'data': instance.data,
|
||||
'actions': instance.actions,
|
||||
};
|
||||
|
||||
CardAction _$CardActionFromJson(Map<String, dynamic> json) => CardAction(
|
||||
type: json['type'] as String,
|
||||
label: json['label'] as String,
|
||||
target: json['target'] as String?,
|
||||
action: json['action'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CardActionToJson(CardAction instance) =>
|
||||
<String, dynamic>{
|
||||
'type': instance.type,
|
||||
'label': instance.label,
|
||||
'target': instance.target,
|
||||
'action': instance.action,
|
||||
};
|
||||
|
||||
CalendarCardData _$CalendarCardDataFromJson(Map<String, dynamic> json) =>
|
||||
CalendarCardData(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
startAt: json['startAt'] as String,
|
||||
endAt: json['endAt'] as String?,
|
||||
timezone: json['timezone'] as String?,
|
||||
location: json['location'] as String?,
|
||||
color: json['color'] as String?,
|
||||
sourceType: json['sourceType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CalendarCardDataToJson(CalendarCardData instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'description': instance.description,
|
||||
'startAt': instance.startAt,
|
||||
'endAt': instance.endAt,
|
||||
'timezone': instance.timezone,
|
||||
'location': instance.location,
|
||||
'color': instance.color,
|
||||
'sourceType': instance.sourceType,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class ChatHistoryRepository {
|
||||
static const String _messagesKey = 'chat_messages_';
|
||||
static const String _lastRunIdKey = 'chat_last_run_id_';
|
||||
static const String _calendarEventsKey = 'calendar_events';
|
||||
|
||||
final String threadId;
|
||||
|
||||
ChatHistoryRepository({this.threadId = 'default'});
|
||||
|
||||
String get _msgKey => '$_messagesKey$threadId';
|
||||
String get _runIdKey => '$_lastRunIdKey$threadId';
|
||||
|
||||
Future<void> saveMessages(List<Map<String, dynamic>> messages) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_msgKey, jsonEncode(messages));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>?> loadMessages() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final data = prefs.getString(_msgKey);
|
||||
if (data == null) return null;
|
||||
final list = jsonDecode(data) as List;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
Future<void> saveLastRunId(String runId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_runIdKey, runId);
|
||||
}
|
||||
|
||||
Future<String?> loadLastRunId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_runIdKey);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_msgKey);
|
||||
await prefs.remove(_runIdKey);
|
||||
}
|
||||
|
||||
Future<void> saveCalendarEvent(Map<String, dynamic> event) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final eventsJson = prefs.getString(_calendarEventsKey);
|
||||
final events = eventsJson != null
|
||||
? jsonDecode(eventsJson) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
events[event['id']] = event;
|
||||
await prefs.setString(_calendarEventsKey, jsonEncode(events));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadCalendarEvents() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final eventsJson = prefs.getString(_calendarEventsKey);
|
||||
if (eventsJson == null) return [];
|
||||
final events = jsonDecode(eventsJson) as Map<String, dynamic>;
|
||||
return events.values.cast<Map<String, dynamic>>().toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:social_app/core/config/env.dart';
|
||||
|
||||
import '../ai/ai_decision_engine.dart';
|
||||
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 {
|
||||
EventCallback onEvent;
|
||||
final AiDecisionEngine _decisionEngine;
|
||||
|
||||
AgUiService({EventCallback? onEvent})
|
||||
: onEvent = onEvent ?? ((_) {}),
|
||||
_decisionEngine = AiDecisionEngine();
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
if (Env.isMockApi) {
|
||||
await _mockEventStream(content);
|
||||
} else {
|
||||
throw UnimplementedError('Real API not implemented');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mockEventStream(String content) async {
|
||||
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
|
||||
final forceTrigger = _decisionEngine.tryForceTrigger(content);
|
||||
if (forceTrigger != null) {
|
||||
await _mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
||||
} else if (_decisionEngine.shouldTriggerToolCall(content)) {
|
||||
await _mockToolCallFlow(content);
|
||||
}
|
||||
|
||||
final replies = _generateReplies(content);
|
||||
if (replies.isNotEmpty) {
|
||||
await _mockTextMessageStream(replies);
|
||||
}
|
||||
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
}
|
||||
|
||||
Future<void> _mockToolCallFlow(String content) async {
|
||||
final args = _decisionEngine.getToolCallArgs(content);
|
||||
if (args == null) return;
|
||||
|
||||
await _mockToolCallFlowWithArgs('create_calendar_event', args);
|
||||
}
|
||||
|
||||
Future<void> _mockToolCallFlowWithArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final toolCallId =
|
||||
'$_toolCallIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
|
||||
|
||||
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: jsonEncode(args)));
|
||||
|
||||
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
||||
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: validation.error ?? 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ToolRegistry.initialize();
|
||||
final result = await ToolRegistry.execute(toolName, args);
|
||||
final ui = _buildUiCard(toolName, result);
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
result: result,
|
||||
ui: ui,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: e.toString(),
|
||||
code: 'EXECUTION_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
UiCard? _buildUiCard(String toolName, Map<String, dynamic> result) {
|
||||
if (toolName == 'create_calendar_event') {
|
||||
return UiCard(
|
||||
cardType: 'calendar',
|
||||
data: CalendarCardData(
|
||||
id: result['eventId'] ?? '',
|
||||
title: result['title'] ?? '',
|
||||
description: result['description'],
|
||||
startAt: result['startAt'] ?? '',
|
||||
endAt: result['endAt'],
|
||||
timezone: result['timezone'],
|
||||
location: result['location'],
|
||||
color: result['color'],
|
||||
sourceType: result['sourceType'],
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(
|
||||
type: 'link',
|
||||
label: '查看详情',
|
||||
target: '/calendar/${result['eventId']}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _generateReplies(String content) {
|
||||
final intent = _decisionEngine.matchIntent(content);
|
||||
|
||||
switch (intent) {
|
||||
case Intent.createEvent:
|
||||
return ['好的,我已经为您创建了日程安排。'];
|
||||
case Intent.searchEvent:
|
||||
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
|
||||
case Intent.unknown:
|
||||
return ['我理解了您的问题,让我来帮您处理。'];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mockTextMessageStream(List<String> replies) async {
|
||||
for (final reply in replies) {
|
||||
final messageId =
|
||||
'$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
|
||||
|
||||
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: _streamChunkDelayMs));
|
||||
}
|
||||
|
||||
onEvent(TextMessageEndEvent(messageId: messageId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
final Map<String, dynamic> parameters;
|
||||
final ToolHandler handler;
|
||||
|
||||
ToolDefinition({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.parameters,
|
||||
required this.handler,
|
||||
});
|
||||
}
|
||||
|
||||
class ToolRegistry {
|
||||
static final Map<String, ToolDefinition> _tools = {};
|
||||
static bool _initialized = false;
|
||||
|
||||
static void initialize() {
|
||||
if (_initialized) return;
|
||||
|
||||
_tools[_toolNameCreateCalendar] = ToolDefinition(
|
||||
name: _toolNameCreateCalendar,
|
||||
description: '创建一个日历事件或待办事项',
|
||||
parameters: {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'description': '事件标题',
|
||||
'minLength': _titleMinLength,
|
||||
'maxLength': _titleMaxLength,
|
||||
},
|
||||
'description': {'type': 'string', 'description': '事件描述'},
|
||||
'startAt': {
|
||||
'type': 'string',
|
||||
'format': 'date-time',
|
||||
'description': '开始时间 (ISO8601)',
|
||||
},
|
||||
'endAt': {
|
||||
'type': 'string',
|
||||
'format': 'date-time',
|
||||
'description': '结束时间 (ISO8601)',
|
||||
},
|
||||
'timezone': {'type': 'string', 'default': _defaultTimezone},
|
||||
'location': {'type': 'string'},
|
||||
'notes': {'type': 'string'},
|
||||
},
|
||||
'required': ['title', 'startAt'],
|
||||
},
|
||||
handler: _handleCreateCalendarEvent,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _handleCreateCalendarEvent(
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'ok': true,
|
||||
'message': '日程已创建',
|
||||
'title': args['title'],
|
||||
'description': args['description'],
|
||||
'startAt': args['startAt'],
|
||||
'endAt': args['endAt'],
|
||||
'timezone': args['timezone'] ?? _defaultTimezone,
|
||||
'location': args['location'],
|
||||
'color': _defaultEventColor,
|
||||
'sourceType': _defaultSourceType,
|
||||
};
|
||||
}
|
||||
|
||||
static ToolDefinition? getTool(String name) => _tools[name];
|
||||
static List<ToolDefinition> getAllTools() => _tools.values.toList();
|
||||
|
||||
static Future<Map<String, dynamic>> execute(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final tool = _tools[toolName];
|
||||
if (tool == null) throw ToolNotFoundException('Tool not found: $toolName');
|
||||
return tool.handler(args);
|
||||
}
|
||||
|
||||
static ToolValidationResult validateArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) {
|
||||
final tool = _tools[toolName];
|
||||
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) {
|
||||
missing.add(field as String);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
return ToolValidationResult(
|
||||
ok: false,
|
||||
error: 'Missing required fields: ${missing.join(', ')}',
|
||||
);
|
||||
}
|
||||
return ToolValidationResult(ok: true);
|
||||
}
|
||||
}
|
||||
|
||||
class ToolNotFoundException implements Exception {
|
||||
final String message;
|
||||
ToolNotFoundException(this.message);
|
||||
@override
|
||||
String toString() => 'ToolNotFoundException: $message';
|
||||
}
|
||||
|
||||
class ToolValidationResult {
|
||||
final bool ok;
|
||||
final String? error;
|
||||
ToolValidationResult({required this.ok, this.error});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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;
|
||||
final bool isLoading;
|
||||
final String? currentMessageId;
|
||||
final String? error;
|
||||
|
||||
const ChatState({
|
||||
this.items = const [],
|
||||
this.isLoading = false,
|
||||
this.currentMessageId,
|
||||
this.error,
|
||||
});
|
||||
|
||||
static const _unset = Object();
|
||||
|
||||
ChatState copyWith({
|
||||
List<ChatListItem>? items,
|
||||
bool? isLoading,
|
||||
Object? currentMessageId = _unset,
|
||||
Object? error = _unset,
|
||||
}) {
|
||||
return ChatState(
|
||||
items: items ?? this.items,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
currentMessageId: currentMessageId == _unset
|
||||
? this.currentMessageId
|
||||
: currentMessageId as String?,
|
||||
error: error == _unset ? this.error : error as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatBloc extends Cubit<ChatState> {
|
||||
final AgUiService _service;
|
||||
final Map<String, String> _toolCallArgsBuffer = {};
|
||||
|
||||
ChatBloc({AgUiService? service})
|
||||
: _service = service ?? AgUiService(),
|
||||
super(const ChatState()) {
|
||||
_service.onEvent = _handleEvent;
|
||||
}
|
||||
|
||||
void _handleEvent(AgUiEvent event) {
|
||||
switch (event.type) {
|
||||
case AgUiEventType.runStarted:
|
||||
emit(state.copyWith(isLoading: true, error: null));
|
||||
break;
|
||||
case AgUiEventType.runFinished:
|
||||
emit(state.copyWith(isLoading: false, currentMessageId: null));
|
||||
break;
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
emit(state.copyWith(isLoading: false, error: errorEvent.message));
|
||||
break;
|
||||
case AgUiEventType.textMessageStart:
|
||||
final startEvent = event as TextMessageStartEvent;
|
||||
final newMessage = TextMessageItem(
|
||||
id: startEvent.messageId,
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: [...state.items, newMessage],
|
||||
currentMessageId: startEvent.messageId,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case AgUiEventType.textMessageContent:
|
||||
final contentEvent = event as TextMessageContentEvent;
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == contentEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(content: item.content + contentEvent.delta);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
case AgUiEventType.textMessageEnd:
|
||||
final endEvent = event as TextMessageEndEvent;
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.messageId && item is TextMessageItem) {
|
||||
return item.copyWith(isStreaming: false);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems, currentMessageId: null));
|
||||
break;
|
||||
case AgUiEventType.toolCallStart:
|
||||
final startEvent = event as ToolCallStartEvent;
|
||||
_toolCallArgsBuffer[startEvent.toolCallId] = '';
|
||||
final newToolCall = ToolCallItem(
|
||||
id: startEvent.toolCallId,
|
||||
callId: startEvent.toolCallId,
|
||||
toolName: startEvent.toolCallName,
|
||||
args: {},
|
||||
status: ToolCallStatus.pending,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...state.items, newToolCall]));
|
||||
break;
|
||||
case AgUiEventType.toolCallArgs:
|
||||
final argsEvent = event as ToolCallArgsEvent;
|
||||
_toolCallArgsBuffer[argsEvent.toolCallId] =
|
||||
(_toolCallArgsBuffer[argsEvent.toolCallId] ?? '') + argsEvent.delta;
|
||||
break;
|
||||
case AgUiEventType.toolCallEnd:
|
||||
final endEvent = event as ToolCallEndEvent;
|
||||
final argsBuffer = _toolCallArgsBuffer[endEvent.toolCallId] ?? '';
|
||||
Map<String, dynamic> parsedArgs = {};
|
||||
if (argsBuffer.isNotEmpty) {
|
||||
try {
|
||||
parsedArgs = jsonDecode(argsBuffer) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
}
|
||||
_toolCallArgsBuffer.remove(endEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
args: parsedArgs,
|
||||
status: ToolCallStatus.executing,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
case AgUiEventType.toolCallResult:
|
||||
final resultEvent = event as ToolCallResultEvent;
|
||||
final filteredItems = state.items.where((item) {
|
||||
if (item.id == resultEvent.toolCallId && item is ToolCallItem) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
final resultItem = ToolResultItem(
|
||||
id: resultEvent.messageId,
|
||||
callId: resultEvent.toolCallId,
|
||||
uiCard: resultEvent.ui ?? UiCard(cardType: 'empty', data: {}),
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
emit(state.copyWith(items: [...filteredItems, resultItem]));
|
||||
break;
|
||||
case AgUiEventType.toolCallError:
|
||||
final errorEvent = event as ToolCallErrorEvent;
|
||||
_toolCallArgsBuffer.remove(errorEvent.toolCallId);
|
||||
final updatedItems = state.items.map((item) {
|
||||
if (item.id == errorEvent.toolCallId && item is ToolCallItem) {
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: errorEvent.error,
|
||||
);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
emit(state.copyWith(items: updatedItems));
|
||||
break;
|
||||
case AgUiEventType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final userMessage = TextMessageItem(
|
||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.user,
|
||||
);
|
||||
emit(state.copyWith(items: [...state.items, userMessage]));
|
||||
await _service.sendMessage(content);
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
emit(state.copyWith(error: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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) {
|
||||
return switch (card.cardType) {
|
||||
_calendarCardType => _renderCalendarCard(card),
|
||||
_errorCardType => _renderErrorCard(card),
|
||||
_ => _renderUnknownCard(card),
|
||||
};
|
||||
}
|
||||
|
||||
static Widget _renderCalendarCard(UiCard card) {
|
||||
final data = CalendarCardData.fromJson(card.data);
|
||||
final color = data.color != null
|
||||
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
|
||||
: AppColors.blue500;
|
||||
final isAiGenerated = data.sourceType == _aiGeneratedSource;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.messageCardBorder),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: AppSpacing.sm,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(AppRadius.lg),
|
||||
topRight: Radius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isAiGenerated) ...[
|
||||
_buildAiTag(),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
],
|
||||
Text(
|
||||
_formatTime(data.startAt, data.endAt),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
data.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
if (data.description != null) ...[
|
||||
SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
data.description!,
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate600),
|
||||
),
|
||||
],
|
||||
if (data.location != null) ...[
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
_buildLocation(data.location!),
|
||||
],
|
||||
if (card.actions != null && card.actions!.isNotEmpty) ...[
|
||||
SizedBox(height: AppSpacing.md),
|
||||
_buildActions(card.actions!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 == _primaryActionType;
|
||||
return GestureDetector(
|
||||
onTap: () => _handleAction(action),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnWrap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
border: Border.all(
|
||||
color: isPrimary ? AppColors.blue500 : AppColors.messageBtnBorder,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
action.label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isPrimary ? AppColors.white : AppColors.slate600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderErrorCard(UiCard card) {
|
||||
final message = card.data['message'] as String? ?? '发生错误';
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningBackground,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.red400),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 20, color: AppColors.red600),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(fontSize: 14, color: AppColors.red600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _renderUnknownCard(UiCard card) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.messageCardBg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'未知卡片类型: ${card.cardType}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
card.data.toString(),
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatTime(String startAt, String? endAt) {
|
||||
try {
|
||||
final start = DateTime.parse(startAt);
|
||||
final buffer = StringBuffer();
|
||||
|
||||
buffer.write('${start.month}月${start.day}日 ');
|
||||
buffer.write(
|
||||
'${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
|
||||
if (endAt != null) {
|
||||
final end = DateTime.parse(endAt);
|
||||
buffer.write(
|
||||
' - ${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
} catch (e) {
|
||||
return startAt;
|
||||
}
|
||||
}
|
||||
|
||||
static void _handleAction(CardAction action) {
|
||||
// TODO: 实现 action 处理
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
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});
|
||||
|
||||
@@ -13,6 +38,7 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||
|
||||
@@ -26,6 +52,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
void dispose() {
|
||||
_messageController.removeListener(_onMessageChanged);
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -35,32 +62,44 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8FAFC),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildChatArea()),
|
||||
_buildInputContainer(context),
|
||||
],
|
||||
),
|
||||
return BlocProvider(
|
||||
create: (context) => ChatBloc(),
|
||||
child: BlocConsumer<ChatBloc, ChatState>(
|
||||
listener: (context, state) {
|
||||
if (state.error != null) {
|
||||
Toast.show(context, state.error!, type: ToastType.error);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
return Scaffold(
|
||||
backgroundColor: _chatBgColor,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildChatArea(context, state)),
|
||||
_buildInputContainer(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
@@ -70,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'),
|
||||
@@ -92,95 +131,162 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatArea() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildUserMessageRow(),
|
||||
const SizedBox(height: 16),
|
||||
_buildTodoCard(),
|
||||
],
|
||||
),
|
||||
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||
if (state.items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'开始对话吧',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.slate400),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(_defaultPadding),
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _itemSpacing),
|
||||
child: _buildChatItem(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserMessageRow() {
|
||||
Widget _buildChatItem(ChatListItem item) {
|
||||
switch (item.type) {
|
||||
case ChatItemType.message:
|
||||
return _buildMessageItem(item as TextMessageItem);
|
||||
case ChatItemType.toolCall:
|
||||
return _buildToolCallItem(item as ToolCallItem);
|
||||
case ChatItemType.toolResult:
|
||||
return _buildToolResultItem(item as ToolResultItem);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMessageItem(TextMessageItem item) {
|
||||
final isUser = item.sender == MessageSender.user;
|
||||
return Row(
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Expanded(child: SizedBox()),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEAF1FB),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12),
|
||||
topRight: Radius.circular(12),
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(0),
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: _avatarSize,
|
||||
height: _avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue100,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bot,
|
||||
size: _botIconSize,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'明天提醒我开会',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate900),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _messagePaddingH,
|
||||
vertical: _messagePaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? _userBubbleColor : AppColors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
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),
|
||||
),
|
||||
child: Text(
|
||||
item.content,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.slate900),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUser) const SizedBox(width: 40),
|
||||
if (!isUser) const SizedBox(width: 40),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTodoCard() {
|
||||
Widget _buildToolCallItem(ToolCallItem item) {
|
||||
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(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppColors.blue50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 60,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blue500,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
bottomLeft: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'明天 10:00',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'开会',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
],
|
||||
Icon(statusIcon, size: 16, color: statusColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
item.toolName,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.slate700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolResultItem(ToolResultItem item) {
|
||||
return UiSchemaRenderer.render(item.uiCard);
|
||||
}
|
||||
|
||||
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: [
|
||||
@@ -204,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(
|
||||
@@ -231,13 +337,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
||||
size: 24,
|
||||
color: _hasMessage ? AppColors.blue600 : AppColors.slate500,
|
||||
GestureDetector(
|
||||
onTap: _hasMessage ? () => _sendMessage(context) : null,
|
||||
child: Icon(
|
||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
||||
size: _iconSize,
|
||||
color: _hasMessage
|
||||
? AppColors.blue600
|
||||
: AppColors.slate500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -248,6 +360,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(BuildContext context) async {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
_messageController.clear();
|
||||
context.read<ChatBloc>().sendMessage(content);
|
||||
}
|
||||
|
||||
void _showBottomSheet(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
||||
@@ -19,6 +19,8 @@ dependencies:
|
||||
get_it: ^7.7.0
|
||||
lucide_icons: ^0.257.0
|
||||
intl: ^0.19.0
|
||||
shared_preferences: ^2.2.2
|
||||
json_annotation: ^4.8.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -26,6 +28,8 @@ dev_dependencies:
|
||||
flutter_lints: ^6.0.0
|
||||
bloc_test: ^9.1.7
|
||||
mocktail: ^1.0.4
|
||||
json_serializable: ^6.7.1
|
||||
build_runner: ^2.4.8
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
|
||||
void main() {
|
||||
group('agUiEventTypeFromWire', () {
|
||||
test('maps RUN_STARTED correctly', () {
|
||||
expect(agUiEventTypeFromWire('RUN_STARTED'), AgUiEventType.runStarted);
|
||||
});
|
||||
|
||||
test('maps RUN_FINISHED correctly', () {
|
||||
expect(agUiEventTypeFromWire('RUN_FINISHED'), AgUiEventType.runFinished);
|
||||
});
|
||||
|
||||
test('maps RUN_ERROR correctly', () {
|
||||
expect(agUiEventTypeFromWire('RUN_ERROR'), AgUiEventType.runError);
|
||||
});
|
||||
|
||||
test('maps TEXT_MESSAGE_START correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TEXT_MESSAGE_START'),
|
||||
AgUiEventType.textMessageStart,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TEXT_MESSAGE_CONTENT correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TEXT_MESSAGE_CONTENT'),
|
||||
AgUiEventType.textMessageContent,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TEXT_MESSAGE_END correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TEXT_MESSAGE_END'),
|
||||
AgUiEventType.textMessageEnd,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TOOL_CALL_START correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TOOL_CALL_START'),
|
||||
AgUiEventType.toolCallStart,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TOOL_CALL_ARGS correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TOOL_CALL_ARGS'),
|
||||
AgUiEventType.toolCallArgs,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TOOL_CALL_END correctly', () {
|
||||
expect(agUiEventTypeFromWire('TOOL_CALL_END'), AgUiEventType.toolCallEnd);
|
||||
});
|
||||
|
||||
test('maps TOOL_CALL_RESULT correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TOOL_CALL_RESULT'),
|
||||
AgUiEventType.toolCallResult,
|
||||
);
|
||||
});
|
||||
|
||||
test('maps TOOL_CALL_ERROR correctly', () {
|
||||
expect(
|
||||
agUiEventTypeFromWire('TOOL_CALL_ERROR'),
|
||||
AgUiEventType.toolCallError,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns unknown for unknown type', () {
|
||||
expect(agUiEventTypeFromWire('UNKNOWN_TYPE'), AgUiEventType.unknown);
|
||||
});
|
||||
|
||||
test('returns unknown for empty string', () {
|
||||
expect(agUiEventTypeFromWire(''), AgUiEventType.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('agUiEventTypeToWire', () {
|
||||
test('maps runStarted to RUN_STARTED', () {
|
||||
expect(agUiEventTypeToWire(AgUiEventType.runStarted), 'RUN_STARTED');
|
||||
});
|
||||
|
||||
test('maps runFinished to RUN_FINISHED', () {
|
||||
expect(agUiEventTypeToWire(AgUiEventType.runFinished), 'RUN_FINISHED');
|
||||
});
|
||||
|
||||
test('maps textMessageStart to TEXT_MESSAGE_START', () {
|
||||
expect(
|
||||
agUiEventTypeToWire(AgUiEventType.textMessageStart),
|
||||
'TEXT_MESSAGE_START',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps unknown to empty string', () {
|
||||
expect(agUiEventTypeToWire(AgUiEventType.unknown), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('AgUiEvent.fromJson', () {
|
||||
test('parses RunStartedEvent', () {
|
||||
final json = {
|
||||
'type': 'RUN_STARTED',
|
||||
'threadId': 'thread_123',
|
||||
'runId': 'run_456',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<RunStartedEvent>());
|
||||
final runStarted = event as RunStartedEvent;
|
||||
expect(runStarted.threadId, 'thread_123');
|
||||
expect(runStarted.runId, 'run_456');
|
||||
});
|
||||
|
||||
test('parses RunFinishedEvent', () {
|
||||
final json = {
|
||||
'type': 'RUN_FINISHED',
|
||||
'threadId': 'thread_123',
|
||||
'runId': 'run_456',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<RunFinishedEvent>());
|
||||
final runFinished = event as RunFinishedEvent;
|
||||
expect(runFinished.threadId, 'thread_123');
|
||||
});
|
||||
|
||||
test('parses RunErrorEvent', () {
|
||||
final json = {
|
||||
'type': 'RUN_ERROR',
|
||||
'message': 'Something went wrong',
|
||||
'code': 'ERR_001',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<RunErrorEvent>());
|
||||
final runError = event as RunErrorEvent;
|
||||
expect(runError.message, 'Something went wrong');
|
||||
expect(runError.code, 'ERR_001');
|
||||
});
|
||||
|
||||
test('parses TextMessageStartEvent', () {
|
||||
final json = {
|
||||
'type': 'TEXT_MESSAGE_START',
|
||||
'messageId': 'msg_123',
|
||||
'role': 'assistant',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<TextMessageStartEvent>());
|
||||
final textStart = event as TextMessageStartEvent;
|
||||
expect(textStart.messageId, 'msg_123');
|
||||
expect(textStart.role, 'assistant');
|
||||
});
|
||||
|
||||
test('parses TextMessageContentEvent', () {
|
||||
final json = {
|
||||
'type': 'TEXT_MESSAGE_CONTENT',
|
||||
'messageId': 'msg_123',
|
||||
'delta': 'Hello',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<TextMessageContentEvent>());
|
||||
final textContent = event as TextMessageContentEvent;
|
||||
expect(textContent.messageId, 'msg_123');
|
||||
expect(textContent.delta, 'Hello');
|
||||
});
|
||||
|
||||
test('parses TextMessageEndEvent', () {
|
||||
final json = {'type': 'TEXT_MESSAGE_END', 'messageId': 'msg_123'};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<TextMessageEndEvent>());
|
||||
final textEnd = event as TextMessageEndEvent;
|
||||
expect(textEnd.messageId, 'msg_123');
|
||||
});
|
||||
|
||||
test('parses ToolCallStartEvent', () {
|
||||
final json = {
|
||||
'type': 'TOOL_CALL_START',
|
||||
'toolCallId': 'tc_123',
|
||||
'toolCallName': 'create_calendar_event',
|
||||
'parentMessageId': 'msg_001',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<ToolCallStartEvent>());
|
||||
final toolStart = event as ToolCallStartEvent;
|
||||
expect(toolStart.toolCallId, 'tc_123');
|
||||
expect(toolStart.toolCallName, 'create_calendar_event');
|
||||
expect(toolStart.parentMessageId, 'msg_001');
|
||||
});
|
||||
|
||||
test('parses ToolCallArgsEvent', () {
|
||||
final json = {
|
||||
'type': 'TOOL_CALL_ARGS',
|
||||
'toolCallId': 'tc_123',
|
||||
'delta': '{"title": "test"}',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<ToolCallArgsEvent>());
|
||||
final toolArgs = event as ToolCallArgsEvent;
|
||||
expect(toolArgs.toolCallId, 'tc_123');
|
||||
expect(toolArgs.delta, '{"title": "test"}');
|
||||
});
|
||||
|
||||
test('parses ToolCallEndEvent', () {
|
||||
final json = {'type': 'TOOL_CALL_END', 'toolCallId': 'tc_123'};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<ToolCallEndEvent>());
|
||||
});
|
||||
|
||||
test('parses ToolCallResultEvent', () {
|
||||
final json = {
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'msg_123',
|
||||
'toolCallId': 'tc_123',
|
||||
'result': {'ok': true, 'eventId': 'evt_001'},
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<ToolCallResultEvent>());
|
||||
final toolResult = event as ToolCallResultEvent;
|
||||
expect(toolResult.messageId, 'msg_123');
|
||||
expect(toolResult.toolCallId, 'tc_123');
|
||||
expect(toolResult.result['ok'], true);
|
||||
});
|
||||
|
||||
test('parses ToolCallErrorEvent', () {
|
||||
final json = {
|
||||
'type': 'TOOL_CALL_ERROR',
|
||||
'toolCallId': 'tc_123',
|
||||
'error': 'Execution failed',
|
||||
'code': 'EXEC_ERROR',
|
||||
};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<ToolCallErrorEvent>());
|
||||
final toolError = event as ToolCallErrorEvent;
|
||||
expect(toolError.toolCallId, 'tc_123');
|
||||
expect(toolError.error, 'Execution failed');
|
||||
expect(toolError.code, 'EXEC_ERROR');
|
||||
});
|
||||
|
||||
test('returns UnknownAgUiEvent for unknown type', () {
|
||||
final json = {'type': 'UNKNOWN_TYPE', 'someField': 'someValue'};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<UnknownAgUiEvent>());
|
||||
final unknown = event as UnknownAgUiEvent;
|
||||
expect(unknown.rawJson['someField'], 'someValue');
|
||||
});
|
||||
|
||||
test('returns UnknownAgUiEvent for missing type', () {
|
||||
final json = {'someField': 'someValue'};
|
||||
|
||||
final event = AgUiEvent.fromJson(json);
|
||||
|
||||
expect(event, isA<UnknownAgUiEvent>());
|
||||
});
|
||||
});
|
||||
|
||||
group('toJson', () {
|
||||
test('RunStartedEvent serializes with correct fields', () {
|
||||
final event = RunStartedEvent(threadId: 't1', runId: 'r1');
|
||||
final json = event.toJson();
|
||||
|
||||
expect(json['threadId'], 't1');
|
||||
expect(json['runId'], 'r1');
|
||||
});
|
||||
|
||||
test('TextMessageContentEvent serializes with correct fields', () {
|
||||
final event = TextMessageContentEvent(messageId: 'm1', delta: 'hello');
|
||||
final json = event.toJson();
|
||||
|
||||
expect(json['messageId'], 'm1');
|
||||
expect(json['delta'], 'hello');
|
||||
});
|
||||
|
||||
test('ToolCallStartEvent serializes with correct fields', () {
|
||||
final event = ToolCallStartEvent(
|
||||
toolCallId: 'tc1',
|
||||
toolCallName: 'test_tool',
|
||||
);
|
||||
final json = event.toJson();
|
||||
|
||||
expect(json['toolCallId'], 'tc1');
|
||||
expect(json['toolCallName'], 'test_tool');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
|
||||
class TestableAgUiService extends AgUiService {
|
||||
TestableAgUiService({super.onEvent});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content) async {
|
||||
await mockEventStream(content);
|
||||
}
|
||||
|
||||
Future<void> mockEventStream(String content) async {
|
||||
final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
|
||||
final engine = AiDecisionEngine();
|
||||
|
||||
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
|
||||
|
||||
final forceTrigger = engine.tryForceTrigger(content);
|
||||
if (forceTrigger != null) {
|
||||
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
|
||||
} else if (engine.shouldTriggerToolCall(content)) {
|
||||
await mockToolCallFlow(content, engine);
|
||||
}
|
||||
|
||||
final replies = generateReplies(content, engine);
|
||||
if (replies.isNotEmpty) {
|
||||
await mockTextMessageStream(replies);
|
||||
}
|
||||
|
||||
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
|
||||
}
|
||||
|
||||
Future<void> mockToolCallFlow(String content, AiDecisionEngine engine) async {
|
||||
final args = engine.getToolCallArgs(content);
|
||||
if (args == null) return;
|
||||
|
||||
await mockToolCallFlowWithArgs('create_calendar_event', args);
|
||||
}
|
||||
|
||||
Future<void> mockToolCallFlowWithArgs(
|
||||
String toolName,
|
||||
Map<String, dynamic> args,
|
||||
) async {
|
||||
final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
|
||||
|
||||
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}'));
|
||||
|
||||
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
|
||||
|
||||
final validation = ToolRegistry.validateArgs(toolName, args);
|
||||
if (!validation.ok) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: validation.error ?? 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ToolRegistry.initialize();
|
||||
final result = await ToolRegistry.execute(toolName, args);
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
result: result,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
onEvent(
|
||||
ToolCallErrorEvent(
|
||||
toolCallId: toolCallId,
|
||||
error: e.toString(),
|
||||
code: 'EXECUTION_ERROR',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> generateReplies(String content, AiDecisionEngine engine) {
|
||||
final intent = engine.matchIntent(content);
|
||||
|
||||
switch (intent) {
|
||||
case Intent.createEvent:
|
||||
return ['好的,我已经为您创建了日程安排。'];
|
||||
case Intent.searchEvent:
|
||||
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
|
||||
case Intent.unknown:
|
||||
return ['我理解了您的问题,让我来帮您处理。'];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> mockTextMessageStream(List<String> replies) async {
|
||||
for (final reply in replies) {
|
||||
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
|
||||
|
||||
onEvent(TextMessageContentEvent(messageId: messageId, delta: reply));
|
||||
|
||||
onEvent(TextMessageEndEvent(messageId: messageId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late TestableAgUiService service;
|
||||
late List<AgUiEvent> capturedEvents;
|
||||
|
||||
setUp(() {
|
||||
capturedEvents = [];
|
||||
ToolRegistry.initialize();
|
||||
service = TestableAgUiService(
|
||||
onEvent: (event) {
|
||||
capturedEvents.add(event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('AgUiService', () {
|
||||
test('sendMessage first emits RunStartedEvent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.first, isA<RunStartedEvent>());
|
||||
});
|
||||
|
||||
test('sendMessage last emits RunFinishedEvent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.last, isA<RunFinishedEvent>());
|
||||
});
|
||||
|
||||
test('sendMessage emits events in correct order', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
expect(capturedEvents.first, isA<RunStartedEvent>());
|
||||
expect(capturedEvents.last, isA<RunFinishedEvent>());
|
||||
|
||||
final types = capturedEvents.map((e) => e.type).toList();
|
||||
expect(types.first, AgUiEventType.runStarted);
|
||||
expect(types.last, AgUiEventType.runFinished);
|
||||
});
|
||||
|
||||
test('creating schedule text triggers tool call events', () async {
|
||||
await service.sendMessage('提醒我明天10点开会');
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
final toolCallEnds = capturedEvents
|
||||
.whereType<ToolCallEndEvent>()
|
||||
.toList();
|
||||
final toolCallResults = capturedEvents
|
||||
.whereType<ToolCallResultEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isNotEmpty, true);
|
||||
expect(toolCallEnds.isNotEmpty, true);
|
||||
expect(toolCallResults.isNotEmpty, true);
|
||||
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
|
||||
});
|
||||
|
||||
test('force trigger with #tool syntax', () async {
|
||||
await service.sendMessage(
|
||||
'#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}',
|
||||
);
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isNotEmpty, true);
|
||||
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
|
||||
});
|
||||
|
||||
test('text message events are emitted for unknown intent', () async {
|
||||
await service.sendMessage('你好');
|
||||
|
||||
final textStarts = capturedEvents
|
||||
.whereType<TextMessageStartEvent>()
|
||||
.toList();
|
||||
final textContents = capturedEvents
|
||||
.whereType<TextMessageContentEvent>()
|
||||
.toList();
|
||||
final textEnds = capturedEvents.whereType<TextMessageEndEvent>().toList();
|
||||
|
||||
expect(textStarts.isNotEmpty, true);
|
||||
expect(textContents.isNotEmpty, true);
|
||||
expect(textEnds.isNotEmpty, true);
|
||||
});
|
||||
|
||||
test('search intent does not trigger tool calls', () async {
|
||||
await service.sendMessage('今天有什么日程');
|
||||
|
||||
final toolCallStarts = capturedEvents
|
||||
.whereType<ToolCallStartEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallStarts.isEmpty, true);
|
||||
});
|
||||
|
||||
test('tool call with invalid args emits error', () async {
|
||||
await service.sendMessage('#tool:create_calendar_event {}');
|
||||
|
||||
final toolCallErrors = capturedEvents
|
||||
.whereType<ToolCallErrorEvent>()
|
||||
.toList();
|
||||
|
||||
expect(toolCallErrors.isNotEmpty, true);
|
||||
expect(toolCallErrors.first.error, contains('Missing required fields'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
|
||||
|
||||
void main() {
|
||||
late AiDecisionEngine engine;
|
||||
|
||||
setUp(() {
|
||||
engine = AiDecisionEngine();
|
||||
});
|
||||
|
||||
group('matchIntent', () {
|
||||
test('returns searchEvent for "今天有什么日程"', () {
|
||||
expect(engine.matchIntent('今天有什么日程'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns searchEvent for "查看日程"', () {
|
||||
expect(engine.matchIntent('查看日程'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns searchEvent for "查询安排"', () {
|
||||
expect(engine.matchIntent('查询安排'), Intent.searchEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for "提醒我明天开会"', () {
|
||||
expect(engine.matchIntent('提醒我明天开会'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for "安排时间"', () {
|
||||
expect(engine.matchIntent('安排时间'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns createEvent for time pattern "明天10点"', () {
|
||||
expect(engine.matchIntent('明天10点'), Intent.createEvent);
|
||||
});
|
||||
|
||||
test('returns unknown for "你好"', () {
|
||||
expect(engine.matchIntent('你好'), Intent.unknown);
|
||||
});
|
||||
|
||||
test('returns unknown for random text', () {
|
||||
expect(engine.matchIntent('随便说点什么'), Intent.unknown);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldTriggerToolCall', () {
|
||||
test('returns false for "你好"', () {
|
||||
expect(engine.shouldTriggerToolCall('你好'), false);
|
||||
});
|
||||
|
||||
test('returns false for search intent', () {
|
||||
expect(engine.shouldTriggerToolCall('今天有什么日程'), false);
|
||||
});
|
||||
|
||||
test('returns true for create event intent', () {
|
||||
expect(engine.shouldTriggerToolCall('提醒我明天开会'), true);
|
||||
});
|
||||
|
||||
test('returns true for time pattern', () {
|
||||
expect(engine.shouldTriggerToolCall('明天10点开会'), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('tryExtractEventArgs', () {
|
||||
test('returns map with title and startAt for "提醒我明天10点开会"', () {
|
||||
final result = engine.tryExtractEventArgs('提醒我明天10点开会');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], isNotNull);
|
||||
expect(result['startAt'], isNotNull);
|
||||
expect(result['timezone'], 'Asia/Shanghai');
|
||||
});
|
||||
|
||||
test('returns null for "你好"', () {
|
||||
expect(engine.tryExtractEventArgs('你好'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for search intent', () {
|
||||
expect(engine.tryExtractEventArgs('今天有什么日程'), isNull);
|
||||
});
|
||||
|
||||
test('extracts title correctly', () {
|
||||
final result = engine.tryExtractEventArgs('提醒我开会明天10点');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], contains('开会'));
|
||||
});
|
||||
|
||||
test('parses today time correctly', () {
|
||||
final result = engine.tryExtractEventArgs('开会今天14:30');
|
||||
final now = DateTime.now();
|
||||
|
||||
expect(result, isNotNull);
|
||||
final startAt = DateTime.parse(result!['startAt'] as String);
|
||||
expect(startAt.year, now.year);
|
||||
expect(startAt.month, now.month);
|
||||
expect(startAt.day, now.day);
|
||||
expect(startAt.hour, 14);
|
||||
expect(startAt.minute, 30);
|
||||
});
|
||||
|
||||
test('parses tomorrow time correctly', () {
|
||||
final result = engine.tryExtractEventArgs('开会明天9点');
|
||||
final now = DateTime.now();
|
||||
final expectedTomorrow = DateTime(now.year, now.month, now.day + 1);
|
||||
|
||||
expect(result, isNotNull);
|
||||
final startAt = DateTime.parse(result!['startAt'] as String);
|
||||
expect(startAt.day, equals(expectedTomorrow.day));
|
||||
expect(startAt.hour, 9);
|
||||
expect(startAt.minute, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('tryForceTrigger', () {
|
||||
test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () {
|
||||
final result = engine.tryForceTrigger('#tool:create_calendar_event {}');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'create_calendar_event');
|
||||
expect(result.args, isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
|
||||
() {
|
||||
final result = engine.tryForceTrigger('#tool:custom {"key": "value"}');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'custom');
|
||||
expect(result.args['key'], 'value');
|
||||
},
|
||||
);
|
||||
|
||||
test('returns null for normal text', () {
|
||||
expect(engine.tryForceTrigger('普通文本'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for empty string', () {
|
||||
expect(engine.tryForceTrigger(''), isNull);
|
||||
});
|
||||
|
||||
test('handles invalid JSON gracefully', () {
|
||||
final result = engine.tryForceTrigger('#tool:test {invalid json}');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.toolName, 'test');
|
||||
expect(result.args, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('getToolCallArgs', () {
|
||||
test('returns args for create event intent', () {
|
||||
final result = engine.getToolCallArgs('提醒我明天10点开会');
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!['title'], isNotNull);
|
||||
expect(result['startAt'], isNotNull);
|
||||
});
|
||||
|
||||
test('returns null for non-create intent', () {
|
||||
expect(engine.getToolCallArgs('你好'), isNull);
|
||||
});
|
||||
|
||||
test('returns null for search intent', () {
|
||||
expect(engine.getToolCallArgs('今天有什么日程'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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 = MockAgUiService();
|
||||
chatBloc = ChatBloc(service: service);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
chatBloc.close();
|
||||
});
|
||||
|
||||
group('ChatBloc', () {
|
||||
test('initial state is empty', () {
|
||||
expect(chatBloc.state.items, isEmpty);
|
||||
expect(chatBloc.state.isLoading, false);
|
||||
expect(chatBloc.state.currentMessageId, isNull);
|
||||
expect(chatBloc.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage adds user message to items',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) => bloc.sendMessage('Hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((state) => state.items.length, 'items length', 1)
|
||||
.having(
|
||||
(state) => state.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>().having(
|
||||
(item) => item.content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageStart event adds AI message with streaming',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
bloc.emit(chatBloc.state.copyWith(isLoading: true));
|
||||
service.onEvent(
|
||||
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.isLoading, 'isLoading', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.items.length, 'items length', 1)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
|
||||
.having(
|
||||
(s) => s.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>()
|
||||
.having((item) => item.isStreaming, 'isStreaming', true)
|
||||
.having((item) => item.sender, 'sender', MessageSender.ai),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageContent event appends content',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => (s.items.first as TextMessageItem).content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageEnd event sets isStreaming to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: 'Hello World',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having(
|
||||
(s) => (s.items.first as TextMessageItem).isStreaming,
|
||||
'isStreaming',
|
||||
false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runStarted sets isLoading to true',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runFinished sets isLoading to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runError sets error message',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isLoading: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.error, 'error', 'Something went wrong'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'clearError removes error',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(error: 'Some error'),
|
||||
act: (bloc) => bloc.clearError(),
|
||||
expect: () => [isA<ChatState>().having((s) => s.error, 'error', isNull)],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallStart adds ToolCallItem',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tc_1',
|
||||
toolCallName: 'create_calendar_event',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) {
|
||||
final item = s.items.first;
|
||||
return item is ToolCallItem &&
|
||||
item.toolName == 'create_calendar_event' &&
|
||||
item.status == ToolCallStatus.pending;
|
||||
},
|
||||
'has pending tool call',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
ToolRegistry.initialize();
|
||||
});
|
||||
|
||||
group('getTool', () {
|
||||
test('returns tool definition for create_calendar_event', () {
|
||||
final tool = ToolRegistry.getTool('create_calendar_event');
|
||||
|
||||
expect(tool, isNotNull);
|
||||
expect(tool!.name, 'create_calendar_event');
|
||||
expect(tool.description, isNotEmpty);
|
||||
});
|
||||
|
||||
test('returns null for unknown tool', () {
|
||||
expect(ToolRegistry.getTool('unknown_tool'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('validateArgs', () {
|
||||
test('returns error for empty args (missing title)', () {
|
||||
final result = ToolRegistry.validateArgs('create_calendar_event', {});
|
||||
|
||||
expect(result.ok, false);
|
||||
expect(result.error, contains('title'));
|
||||
});
|
||||
|
||||
test('returns error when missing startAt', () {
|
||||
final result = ToolRegistry.validateArgs('create_calendar_event', {
|
||||
'title': 'Test Event',
|
||||
});
|
||||
|
||||
expect(result.ok, false);
|
||||
expect(result.error, contains('startAt'));
|
||||
});
|
||||
|
||||
test('returns ok: true for valid args with title and startAt', () {
|
||||
final result = ToolRegistry.validateArgs('create_calendar_event', {
|
||||
'title': 'x',
|
||||
'startAt': 'x',
|
||||
});
|
||||
|
||||
expect(result.ok, true);
|
||||
expect(result.error, isNull);
|
||||
});
|
||||
|
||||
test('returns error for unknown tool', () {
|
||||
final result = ToolRegistry.validateArgs('unknown_tool', {});
|
||||
|
||||
expect(result.ok, false);
|
||||
expect(result.error, contains('Tool not found'));
|
||||
});
|
||||
});
|
||||
|
||||
group('execute', () {
|
||||
test('returns eventId on success', () async {
|
||||
final result = await ToolRegistry.execute('create_calendar_event', {
|
||||
'title': 'Test Meeting',
|
||||
'startAt': '2026-03-01T10:00:00Z',
|
||||
});
|
||||
|
||||
expect(result['eventId'], isNotNull);
|
||||
expect(result['ok'], true);
|
||||
expect(result['title'], 'Test Meeting');
|
||||
});
|
||||
|
||||
test('throws ToolNotFoundException for unknown tool', () async {
|
||||
expect(
|
||||
() => ToolRegistry.execute('unknown_tool', {}),
|
||||
throwsA(isA<ToolNotFoundException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('includes optional fields in result', () async {
|
||||
final result = await ToolRegistry.execute('create_calendar_event', {
|
||||
'title': 'Test',
|
||||
'startAt': '2026-03-01T10:00:00Z',
|
||||
'description': 'Description',
|
||||
'location': 'Room A',
|
||||
'endAt': '2026-03-01T11:00:00Z',
|
||||
});
|
||||
|
||||
expect(result['description'], 'Description');
|
||||
expect(result['location'], 'Room A');
|
||||
expect(result['endAt'], '2026-03-01T11:00:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
group('getAllTools', () {
|
||||
test('returns list of tool definitions', () {
|
||||
final tools = ToolRegistry.getAllTools();
|
||||
|
||||
expect(tools, isNotEmpty);
|
||||
expect(tools.any((t) => t.name == 'create_calendar_event'), true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/chat/data/models/tool_result.dart';
|
||||
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
|
||||
|
||||
void main() {
|
||||
group('UiSchemaRenderer', () {
|
||||
testWidgets('calendar_card.v1 renders title', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Team Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('Team Meeting'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders time', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
endAt: '2026-03-01T11:30:00Z',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.textContaining('3月1日'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders location', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
location: 'Room 101',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('Room 101'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders description', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
description: 'Quarterly review',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('Quarterly review'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders AI generated tag', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
sourceType: 'ai_generated',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('AI生成'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('error_card.v1 renders error message', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'error_card.v1',
|
||||
data: {'message': 'Something went wrong'},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('Something went wrong'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('error_card.v1 renders default message when missing', (
|
||||
tester,
|
||||
) async {
|
||||
final card = UiCard(cardType: 'error_card.v1', data: {});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('发生错误'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('unknown card type renders fallback', (tester) async {
|
||||
final card = UiCard(cardType: 'unknown_type', data: {'foo': 'bar'});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.textContaining('未知卡片类型'), findsOneWidget);
|
||||
expect(find.textContaining('unknown_type'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders actions', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
).toJson(),
|
||||
actions: [
|
||||
CardAction(type: 'link', label: '查看详情', target: '/calendar/evt_001'),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('查看详情'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calendar_card.v1 renders custom color', (tester) async {
|
||||
final card = UiCard(
|
||||
cardType: 'calendar_card.v1',
|
||||
data: CalendarCardData(
|
||||
id: 'evt_001',
|
||||
title: 'Meeting',
|
||||
startAt: '2026-03-01T10:00:00Z',
|
||||
color: '#FF0000',
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
|
||||
);
|
||||
|
||||
expect(find.text('Meeting'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user