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/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:lucide_icons/lucide_icons.dart';
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
import '../../../../core/theme/design_tokens.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';
|
import 'home_sheet.dart';
|
||||||
|
|
||||||
|
/// 布局常量
|
||||||
|
const _headerHeight = 60.0;
|
||||||
|
const _defaultPadding = 20.0;
|
||||||
|
const _itemSpacing = 16.0;
|
||||||
|
const _inputPadding = 16.0;
|
||||||
|
const _iconSize = 24.0;
|
||||||
|
const _avatarSize = 32.0;
|
||||||
|
const _botIconSize = 18.0;
|
||||||
|
const _messagePaddingH = 13.0;
|
||||||
|
const _messagePaddingV = 9.0;
|
||||||
|
const _cornerRadius = 12.0;
|
||||||
|
const _inputMinHeight = 48.0;
|
||||||
|
const _inputRadius = 24.0;
|
||||||
|
const _scrollDurationMs = 300;
|
||||||
|
|
||||||
|
/// 颜色常量
|
||||||
|
const _chatBgColor = Color(0xFFF8FAFC);
|
||||||
|
const _userBubbleColor = Color(0xFFEAF1FB);
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@@ -13,6 +38,7 @@ class HomeScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
final TextEditingController _messageController = TextEditingController();
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
@@ -26,6 +52,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.removeListener(_onMessageChanged);
|
_messageController.removeListener(_onMessageChanged);
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,32 +62,44 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return BlocProvider(
|
||||||
backgroundColor: const Color(0xFFF8FAFC),
|
create: (context) => ChatBloc(),
|
||||||
body: SafeArea(
|
child: BlocConsumer<ChatBloc, ChatState>(
|
||||||
child: Column(
|
listener: (context, state) {
|
||||||
children: [
|
if (state.error != null) {
|
||||||
_buildHeader(context),
|
Toast.show(context, state.error!, type: ToastType.error);
|
||||||
Expanded(child: _buildChatArea()),
|
}
|
||||||
_buildInputContainer(context),
|
},
|
||||||
],
|
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) {
|
Widget _buildHeader(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 60,
|
height: _headerHeight,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: _defaultPadding),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
LucideIcons.settings,
|
LucideIcons.settings,
|
||||||
size: 24,
|
size: _iconSize,
|
||||||
color: AppColors.slate900,
|
color: AppColors.slate900,
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/settings'),
|
onPressed: () => context.push('/settings'),
|
||||||
@@ -70,16 +109,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
LucideIcons.calendar,
|
LucideIcons.calendar,
|
||||||
size: 24,
|
size: _iconSize,
|
||||||
color: AppColors.slate900,
|
color: AppColors.slate900,
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/calendar/dayweek?from=home'),
|
onPressed: () => context.push('/calendar/dayweek?from=home'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: _itemSpacing),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
LucideIcons.messageSquare,
|
LucideIcons.messageSquare,
|
||||||
size: 24,
|
size: _iconSize,
|
||||||
color: AppColors.slate900,
|
color: AppColors.slate900,
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/messages/invites'),
|
onPressed: () => context.push('/messages/invites'),
|
||||||
@@ -92,95 +131,162 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatArea() {
|
Widget _buildChatArea(BuildContext context, ChatState state) {
|
||||||
return Padding(
|
if (state.items.isEmpty) {
|
||||||
padding: const EdgeInsets.all(20),
|
return const Center(
|
||||||
child: Column(
|
child: Text(
|
||||||
children: [
|
'开始对话吧',
|
||||||
_buildUserMessageRow(),
|
style: TextStyle(fontSize: 16, color: AppColors.slate400),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_buildTodoCard(),
|
);
|
||||||
],
|
}
|
||||||
),
|
|
||||||
|
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(
|
return Row(
|
||||||
|
mainAxisAlignment: isUser
|
||||||
|
? MainAxisAlignment.end
|
||||||
|
: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: SizedBox()),
|
if (!isUser) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
width: _avatarSize,
|
||||||
decoration: BoxDecoration(
|
height: _avatarSize,
|
||||||
color: const Color(0xFFEAF1FB),
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.only(
|
color: AppColors.blue100,
|
||||||
topLeft: Radius.circular(12),
|
shape: BoxShape.circle,
|
||||||
topRight: Radius.circular(12),
|
),
|
||||||
bottomLeft: Radius.circular(12),
|
child: const Icon(
|
||||||
bottomRight: Radius.circular(0),
|
LucideIcons.bot,
|
||||||
|
size: _botIconSize,
|
||||||
|
color: AppColors.blue600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
const SizedBox(width: 8),
|
||||||
'明天提醒我开会',
|
],
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.slate900),
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: AppColors.blue50,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.slate300),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Icon(statusIcon, size: 16, color: statusColor),
|
||||||
width: 4,
|
const SizedBox(width: 8),
|
||||||
height: 60,
|
Text(
|
||||||
decoration: const BoxDecoration(
|
item.toolName,
|
||||||
color: AppColors.blue500,
|
style: const TextStyle(
|
||||||
borderRadius: BorderRadius.only(
|
fontSize: 13,
|
||||||
topLeft: Radius.circular(4),
|
fontWeight: FontWeight.w500,
|
||||||
bottomLeft: Radius.circular(4),
|
color: AppColors.slate700,
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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) {
|
Widget _buildInputContainer(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(_inputPadding),
|
||||||
color: const Color(0xFFF8FAFC),
|
color: _chatBgColor,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -204,11 +310,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(minHeight: 48),
|
constraints: const BoxConstraints(minHeight: _inputMinHeight),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(_inputRadius),
|
||||||
border: Border.all(color: AppColors.slate300),
|
border: Border.all(color: AppColors.slate300),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -231,13 +337,19 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
filled: false,
|
filled: false,
|
||||||
),
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(context),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Icon(
|
GestureDetector(
|
||||||
_hasMessage ? LucideIcons.send : LucideIcons.mic,
|
onTap: _hasMessage ? () => _sendMessage(context) : null,
|
||||||
size: 24,
|
child: Icon(
|
||||||
color: _hasMessage ? AppColors.blue600 : AppColors.slate500,
|
_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) {
|
void _showBottomSheet(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dependencies:
|
|||||||
get_it: ^7.7.0
|
get_it: ^7.7.0
|
||||||
lucide_icons: ^0.257.0
|
lucide_icons: ^0.257.0
|
||||||
intl: ^0.19.0
|
intl: ^0.19.0
|
||||||
|
shared_preferences: ^2.2.2
|
||||||
|
json_annotation: ^4.8.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -26,6 +28,8 @@ dev_dependencies:
|
|||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
bloc_test: ^9.1.7
|
bloc_test: ^9.1.7
|
||||||
mocktail: ^1.0.4
|
mocktail: ^1.0.4
|
||||||
|
json_serializable: ^6.7.1
|
||||||
|
build_runner: ^2.4.8
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
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