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