From b90d1587c5e1afa30905d192da718ca39af2e323 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:29:47 +0800 Subject: [PATCH 01/13] chore(chat): add json_annotation and shared_preferences deps --- apps/pubspec.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 040f5d1..0b0826d 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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 From 4044b50cf96389dce5f95b5a4dd39cdbd544fee1 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:33:08 +0800 Subject: [PATCH 02/13] feat(chat): add ToolResult and UiCard models --- .../chat/data/models/tool_result.dart | 94 +++++++++++++++++++ .../chat/data/models/tool_result.g.dart | 77 +++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 apps/lib/features/chat/data/models/tool_result.dart create mode 100644 apps/lib/features/chat/data/models/tool_result.g.dart diff --git a/apps/lib/features/chat/data/models/tool_result.dart b/apps/lib/features/chat/data/models/tool_result.dart new file mode 100644 index 0000000..a2298e7 --- /dev/null +++ b/apps/lib/features/chat/data/models/tool_result.dart @@ -0,0 +1,94 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'tool_result.g.dart'; + +/// 工具执行结果(给 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 json) => + _$ToolResultFromJson(json); + + Map toJson() => _$ToolResultToJson(this); +} + +/// UI 卡片 Schema(给 UI 渲染) +@JsonSerializable() +class UiCard { + @JsonKey(name: 'type') + final String cardType; + + @JsonKey(name: 'version') + final String? schemaVersion; + + final Map data; + final List? actions; + + UiCard({ + required this.cardType, + this.schemaVersion = 'v1', + required this.data, + this.actions, + }); + + factory UiCard.fromJson(Map json) => _$UiCardFromJson(json); + + Map 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 json) => + _$CardActionFromJson(json); + + Map 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 json) => + _$CalendarCardDataFromJson(json); + + Map toJson() => _$CalendarCardDataToJson(this); +} diff --git a/apps/lib/features/chat/data/models/tool_result.g.dart b/apps/lib/features/chat/data/models/tool_result.g.dart new file mode 100644 index 0000000..c5f4e03 --- /dev/null +++ b/apps/lib/features/chat/data/models/tool_result.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tool_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ToolResult _$ToolResultFromJson(Map json) => ToolResult( + eventId: json['eventId'] as String?, + ok: json['ok'] as bool? ?? true, + message: json['message'] as String?, +); + +Map _$ToolResultToJson(ToolResult instance) => + { + 'eventId': instance.eventId, + 'ok': instance.ok, + 'message': instance.message, + }; + +UiCard _$UiCardFromJson(Map json) => UiCard( + cardType: json['type'] as String, + schemaVersion: json['version'] as String? ?? 'v1', + data: json['data'] as Map, + actions: (json['actions'] as List?) + ?.map((e) => CardAction.fromJson(e as Map)) + .toList(), +); + +Map _$UiCardToJson(UiCard instance) => { + 'type': instance.cardType, + 'version': instance.schemaVersion, + 'data': instance.data, + 'actions': instance.actions, +}; + +CardAction _$CardActionFromJson(Map json) => CardAction( + type: json['type'] as String, + label: json['label'] as String, + target: json['target'] as String?, + action: json['action'] as String?, +); + +Map _$CardActionToJson(CardAction instance) => + { + 'type': instance.type, + 'label': instance.label, + 'target': instance.target, + 'action': instance.action, + }; + +CalendarCardData _$CalendarCardDataFromJson(Map 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 _$CalendarCardDataToJson(CalendarCardData instance) => + { + '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, + }; From e20b5e0b4ff102b979cdfb35140e76aaa7f3456e Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:34:55 +0800 Subject: [PATCH 03/13] feat(chat): add AG-UI event models with wire protocol mapping --- .../chat/data/models/ag_ui_event.dart | 320 ++++++++++++++++++ .../chat/data/models/ag_ui_event.g.dart | 159 +++++++++ 2 files changed, 479 insertions(+) create mode 100644 apps/lib/features/chat/data/models/ag_ui_event.dart create mode 100644 apps/lib/features/chat/data/models/ag_ui_event.g.dart diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/features/chat/data/models/ag_ui_event.dart new file mode 100644 index 0000000..9d62e64 --- /dev/null +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -0,0 +1,320 @@ +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, +} + +AgUiEventType agUiEventTypeFromWire(String wire) { + switch (wire) { + case AgUiEventTypeWire.runStarted: + return AgUiEventType.runStarted; + case AgUiEventTypeWire.runFinished: + return AgUiEventType.runFinished; + case AgUiEventTypeWire.runError: + return AgUiEventType.runError; + case AgUiEventTypeWire.textMessageStart: + return AgUiEventType.textMessageStart; + case AgUiEventTypeWire.textMessageContent: + return AgUiEventType.textMessageContent; + case AgUiEventTypeWire.textMessageEnd: + return AgUiEventType.textMessageEnd; + case AgUiEventTypeWire.toolCallStart: + return AgUiEventType.toolCallStart; + case AgUiEventTypeWire.toolCallArgs: + return AgUiEventType.toolCallArgs; + case AgUiEventTypeWire.toolCallEnd: + return AgUiEventType.toolCallEnd; + case AgUiEventTypeWire.toolCallResult: + return AgUiEventType.toolCallResult; + case AgUiEventTypeWire.toolCallError: + return AgUiEventType.toolCallError; + default: + return AgUiEventType.unknown; + } +} + +String agUiEventTypeToWire(AgUiEventType type) { + switch (type) { + case AgUiEventType.runStarted: + return AgUiEventTypeWire.runStarted; + case AgUiEventType.runFinished: + return AgUiEventTypeWire.runFinished; + case AgUiEventType.runError: + return AgUiEventTypeWire.runError; + case AgUiEventType.textMessageStart: + return AgUiEventTypeWire.textMessageStart; + case AgUiEventType.textMessageContent: + return AgUiEventTypeWire.textMessageContent; + case AgUiEventType.textMessageEnd: + return AgUiEventTypeWire.textMessageEnd; + case AgUiEventType.toolCallStart: + return AgUiEventTypeWire.toolCallStart; + case AgUiEventType.toolCallArgs: + return AgUiEventTypeWire.toolCallArgs; + case AgUiEventType.toolCallEnd: + return AgUiEventTypeWire.toolCallEnd; + case AgUiEventType.toolCallResult: + return AgUiEventTypeWire.toolCallResult; + case AgUiEventType.toolCallError: + return AgUiEventTypeWire.toolCallError; + case AgUiEventType.unknown: + return ''; + } +} + +@JsonSerializable() +class AgUiEvent { + final AgUiEventType type; + + AgUiEvent({required this.type}); + + factory AgUiEvent.fromJson(Map 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 toJson() => _$AgUiEventToJson(this); +} + +@JsonSerializable() +class UnknownAgUiEvent extends AgUiEvent { + final Map rawJson; + + UnknownAgUiEvent({required this.rawJson}) + : super(type: AgUiEventType.unknown); + + factory UnknownAgUiEvent.fromJson(Map json) => + UnknownAgUiEvent(rawJson: json); + + @override + Map 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 json) => + _$RunStartedEventFromJson(json); + + @override + Map 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 json) => + _$RunFinishedEventFromJson(json); + + @override + Map 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 json) => + _$RunErrorEventFromJson(json); + + @override + Map 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 json) => + _$TextMessageStartEventFromJson(json); + + @override + Map 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 json) => + _$TextMessageContentEventFromJson(json); + + @override + Map toJson() => _$TextMessageContentEventToJson(this); +} + +@JsonSerializable() +class TextMessageEndEvent extends AgUiEvent { + final String messageId; + + TextMessageEndEvent({required this.messageId}) + : super(type: AgUiEventType.textMessageEnd); + + factory TextMessageEndEvent.fromJson(Map json) => + _$TextMessageEndEventFromJson(json); + + @override + Map 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 json) => + _$ToolCallStartEventFromJson(json); + + @override + Map 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 json) => + _$ToolCallArgsEventFromJson(json); + + @override + Map toJson() => _$ToolCallArgsEventToJson(this); +} + +@JsonSerializable() +class ToolCallEndEvent extends AgUiEvent { + final String toolCallId; + + ToolCallEndEvent({required this.toolCallId}) + : super(type: AgUiEventType.toolCallEnd); + + factory ToolCallEndEvent.fromJson(Map json) => + _$ToolCallEndEventFromJson(json); + + @override + Map toJson() => _$ToolCallEndEventToJson(this); +} + +@JsonSerializable() +class ToolCallResultEvent extends AgUiEvent { + final String messageId; + final String toolCallId; + final Map result; + final UiCard? ui; + + ToolCallResultEvent({ + required this.messageId, + required this.toolCallId, + required this.result, + this.ui, + }) : super(type: AgUiEventType.toolCallResult); + + factory ToolCallResultEvent.fromJson(Map json) => + _$ToolCallResultEventFromJson(json); + + @override + Map 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 json) => + _$ToolCallErrorEventFromJson(json); + + @override + Map toJson() => _$ToolCallErrorEventToJson(this); +} diff --git a/apps/lib/features/chat/data/models/ag_ui_event.g.dart b/apps/lib/features/chat/data/models/ag_ui_event.g.dart new file mode 100644 index 0000000..09a764b --- /dev/null +++ b/apps/lib/features/chat/data/models/ag_ui_event.g.dart @@ -0,0 +1,159 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ag_ui_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AgUiEvent _$AgUiEventFromJson(Map json) => + AgUiEvent(type: $enumDecode(_$AgUiEventTypeEnumMap, json['type'])); + +Map _$AgUiEventToJson(AgUiEvent instance) => { + '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 json) => + UnknownAgUiEvent(rawJson: json['rawJson'] as Map); + +Map _$UnknownAgUiEventToJson(UnknownAgUiEvent instance) => + {'rawJson': instance.rawJson}; + +RunStartedEvent _$RunStartedEventFromJson(Map json) => + RunStartedEvent( + threadId: json['threadId'] as String, + runId: json['runId'] as String, + ); + +Map _$RunStartedEventToJson(RunStartedEvent instance) => + {'threadId': instance.threadId, 'runId': instance.runId}; + +RunFinishedEvent _$RunFinishedEventFromJson(Map json) => + RunFinishedEvent( + threadId: json['threadId'] as String, + runId: json['runId'] as String, + ); + +Map _$RunFinishedEventToJson(RunFinishedEvent instance) => + {'threadId': instance.threadId, 'runId': instance.runId}; + +RunErrorEvent _$RunErrorEventFromJson(Map json) => + RunErrorEvent( + message: json['message'] as String, + code: json['code'] as String?, + ); + +Map _$RunErrorEventToJson(RunErrorEvent instance) => + {'message': instance.message, 'code': instance.code}; + +TextMessageStartEvent _$TextMessageStartEventFromJson( + Map json, +) => TextMessageStartEvent( + messageId: json['messageId'] as String, + role: json['role'] as String, +); + +Map _$TextMessageStartEventToJson( + TextMessageStartEvent instance, +) => {'messageId': instance.messageId, 'role': instance.role}; + +TextMessageContentEvent _$TextMessageContentEventFromJson( + Map json, +) => TextMessageContentEvent( + messageId: json['messageId'] as String, + delta: json['delta'] as String, +); + +Map _$TextMessageContentEventToJson( + TextMessageContentEvent instance, +) => { + 'messageId': instance.messageId, + 'delta': instance.delta, +}; + +TextMessageEndEvent _$TextMessageEndEventFromJson(Map json) => + TextMessageEndEvent(messageId: json['messageId'] as String); + +Map _$TextMessageEndEventToJson( + TextMessageEndEvent instance, +) => {'messageId': instance.messageId}; + +ToolCallStartEvent _$ToolCallStartEventFromJson(Map json) => + ToolCallStartEvent( + toolCallId: json['toolCallId'] as String, + toolCallName: json['toolCallName'] as String, + parentMessageId: json['parentMessageId'] as String?, + ); + +Map _$ToolCallStartEventToJson(ToolCallStartEvent instance) => + { + 'toolCallId': instance.toolCallId, + 'toolCallName': instance.toolCallName, + 'parentMessageId': instance.parentMessageId, + }; + +ToolCallArgsEvent _$ToolCallArgsEventFromJson(Map json) => + ToolCallArgsEvent( + toolCallId: json['toolCallId'] as String, + delta: json['delta'] as String, + ); + +Map _$ToolCallArgsEventToJson(ToolCallArgsEvent instance) => + { + 'toolCallId': instance.toolCallId, + 'delta': instance.delta, + }; + +ToolCallEndEvent _$ToolCallEndEventFromJson(Map json) => + ToolCallEndEvent(toolCallId: json['toolCallId'] as String); + +Map _$ToolCallEndEventToJson(ToolCallEndEvent instance) => + {'toolCallId': instance.toolCallId}; + +ToolCallResultEvent _$ToolCallResultEventFromJson(Map json) => + ToolCallResultEvent( + messageId: json['messageId'] as String, + toolCallId: json['toolCallId'] as String, + result: json['result'] as Map, + ui: json['ui'] == null + ? null + : UiCard.fromJson(json['ui'] as Map), + ); + +Map _$ToolCallResultEventToJson( + ToolCallResultEvent instance, +) => { + 'messageId': instance.messageId, + 'toolCallId': instance.toolCallId, + 'result': instance.result, + 'ui': instance.ui, +}; + +ToolCallErrorEvent _$ToolCallErrorEventFromJson(Map json) => + ToolCallErrorEvent( + toolCallId: json['toolCallId'] as String, + error: json['error'] as String, + code: json['code'] as String?, + ); + +Map _$ToolCallErrorEventToJson(ToolCallErrorEvent instance) => + { + 'toolCallId': instance.toolCallId, + 'error': instance.error, + 'code': instance.code, + }; From bc7bfa0692f0c7a1c99607f4112eecb6a60b34d5 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:36:08 +0800 Subject: [PATCH 04/13] feat(chat): add ChatListItem models in chat feature --- .../chat/data/models/chat_list_item.dart | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 apps/lib/features/chat/data/models/chat_list_item.dart diff --git a/apps/lib/features/chat/data/models/chat_list_item.dart b/apps/lib/features/chat/data/models/chat_list_item.dart new file mode 100644 index 0000000..48d92ff --- /dev/null +++ b/apps/lib/features/chat/data/models/chat_list_item.dart @@ -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 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? 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; +} From f0ae3b31e2d63792316fcf6c490422601780cc90 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:36:30 +0800 Subject: [PATCH 05/13] feat(chat): add ToolRegistry with validation --- .../chat/data/tools/tool_registry.dart | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 apps/lib/features/chat/data/tools/tool_registry.dart diff --git a/apps/lib/features/chat/data/tools/tool_registry.dart b/apps/lib/features/chat/data/tools/tool_registry.dart new file mode 100644 index 0000000..4a88b58 --- /dev/null +++ b/apps/lib/features/chat/data/tools/tool_registry.dart @@ -0,0 +1,130 @@ +typedef ToolHandler = + Future> Function(Map args); + +class ToolDefinition { + final String name; + final String description; + final Map parameters; + final ToolHandler handler; + + ToolDefinition({ + required this.name, + required this.description, + required this.parameters, + required this.handler, + }); +} + +class ToolRegistry { + static final Map _tools = {}; + static bool _initialized = false; + + static void initialize() { + if (_initialized) return; + + _tools['create_calendar_event'] = ToolDefinition( + name: 'create_calendar_event', + description: '创建一个日历事件或待办事项', + parameters: { + 'type': 'object', + 'properties': { + 'title': { + 'type': 'string', + 'description': '事件标题', + 'minLength': 1, + 'maxLength': 100, + }, + 'description': {'type': 'string', 'description': '事件描述'}, + 'startAt': { + 'type': 'string', + 'format': 'date-time', + 'description': '开始时间 (ISO8601)', + }, + 'endAt': { + 'type': 'string', + 'format': 'date-time', + 'description': '结束时间 (ISO8601)', + }, + 'timezone': {'type': 'string', 'default': 'Asia/Shanghai'}, + 'location': {'type': 'string'}, + 'notes': {'type': 'string'}, + }, + 'required': ['title', 'startAt'], + }, + handler: _handleCreateCalendarEvent, + ); + + _initialized = true; + } + + static Future> _handleCreateCalendarEvent( + Map 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'] ?? 'Asia/Shanghai', + 'location': args['location'], + 'color': '#4F46E5', + 'sourceType': 'agentGenerated', + }; + } + + static ToolDefinition? getTool(String name) => _tools[name]; + static List getAllTools() => _tools.values.toList(); + + static Future> execute( + String toolName, + Map 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 args, + ) { + final tool = _tools[toolName]; + if (tool == null) + return ToolValidationResult( + ok: false, + error: 'Tool not found: $toolName', + ); + + final required = tool.parameters['required'] as List? ?? []; + final missing = []; + 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}); +} From 7e0e6457d70c7236b2e2c0e288c43027d67c792a Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:36:59 +0800 Subject: [PATCH 06/13] feat(chat): add AiDecisionEngine with force trigger support --- .../chat/data/ai/ai_decision_engine.dart | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 apps/lib/features/chat/data/ai/ai_decision_engine.dart diff --git a/apps/lib/features/chat/data/ai/ai_decision_engine.dart b/apps/lib/features/chat/data/ai/ai_decision_engine.dart new file mode 100644 index 0000000..bd27a98 --- /dev/null +++ b/apps/lib/features/chat/data/ai/ai_decision_engine.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +enum Intent { createEvent, searchEvent, unknown } + +class AiDecisionEngine { + /// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent) + static final List<(RegExp, Intent)> _orderedPatterns = [ + (RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent), + (RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent), + ( + RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), + Intent.createEvent, + ), + ]; + + Intent matchIntent(String text) { + for (final (pattern, intent) in _orderedPatterns) { + if (pattern.hasMatch(text)) return intent; + } + return Intent.unknown; + } + + Map? tryExtractEventArgs(String text) { + if (matchIntent(text) != Intent.createEvent) return null; + + final args = {}; + + 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(); + } + + if (args['title'] == null || (args['title'] as String).isEmpty) return null; + + final timeMatch = RegExp( + r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?', + ).firstMatch(text); + if (timeMatch != null) { + final dayStr = timeMatch.group(1) ?? '今天'; + final hour = int.parse(timeMatch.group(2)!); + final minute = int.parse(timeMatch.group(3) ?? '0'); + + final now = DateTime.now(); + DateTime startAt; + switch (dayStr) { + case '明天': + startAt = DateTime(now.year, now.month, now.day + 1, hour, minute); + break; + case '后天': + startAt = DateTime(now.year, now.month, now.day + 2, hour, minute); + break; + default: + startAt = DateTime(now.year, now.month, now.day, hour, minute); + } + + args['startAt'] = startAt.toIso8601String(); + args['timezone'] = 'Asia/Shanghai'; + } + + if (!args.containsKey('startAt')) return null; + return args; + } + + bool shouldTriggerToolCall(String text) => + matchIntent(text) == Intent.createEvent; + + Map? 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? args; + if (argsJson != null) { + try { + args = jsonDecode(argsJson) as Map; + } catch (_) { + args = {}; + } + } + + return ForceTriggerResult(toolName: toolName, args: args ?? {}); + } +} + +class ForceTriggerResult { + final String toolName; + final Map args; + ForceTriggerResult({required this.toolName, required this.args}); +} From 1fd33c57a77b43fcfcf0cadc2b579e17743e1555 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:38:26 +0800 Subject: [PATCH 07/13] feat(chat): add AgUiService with mock event stream --- .../chat/data/services/ag_ui_service.dart | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 apps/lib/features/chat/data/services/ag_ui_service.dart diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart new file mode 100644 index 0000000..a132d33 --- /dev/null +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -0,0 +1,167 @@ +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'; + +typedef EventCallback = void Function(AgUiEvent event); + +class AgUiService { + EventCallback onEvent; + final AiDecisionEngine _decisionEngine; + + AgUiService({EventCallback? onEvent}) + : onEvent = onEvent ?? ((_) {}), + _decisionEngine = AiDecisionEngine(); + + Future sendMessage(String content) async { + if (Env.isMockApi) { + await _mockEventStream(content); + } else { + throw UnimplementedError('Real API not implemented'); + } + } + + Future _mockEventStream(String content) async { + final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}'; + final runId = 'run_${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 _mockToolCallFlow(String content) async { + final args = _decisionEngine.getToolCallArgs(content); + if (args == null) return; + + await _mockToolCallFlowWithArgs('create_calendar_event', args); + } + + Future _mockToolCallFlowWithArgs( + String toolName, + Map args, + ) async { + final toolCallId = 'tc_${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 = 'msg_${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 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 _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 _mockTextMessageStream(List replies) async { + for (final reply in replies) { + final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + + onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant')); + + const chunkSize = 10; + for (var i = 0; i < reply.length; i += chunkSize) { + final end = (i + chunkSize < reply.length) + ? i + chunkSize + : reply.length; + final chunk = reply.substring(i, end); + + onEvent(TextMessageContentEvent(messageId: messageId, delta: chunk)); + + await Future.delayed(const Duration(milliseconds: 50)); + } + + onEvent(TextMessageEndEvent(messageId: messageId)); + } + } +} From d12f846cc0b0ef85828ea8457bde0bec2331dc88 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:38:58 +0800 Subject: [PATCH 08/13] feat(chat): add UiSchemaRenderer with design tokens --- .../chat/ui/widgets/ui_schema_renderer.dart | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart new file mode 100644 index 0000000..2e7da89 --- /dev/null +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; +import '../../data/models/tool_result.dart'; + +class UiSchemaRenderer { + static Widget render(UiCard card) { + switch (card.cardType) { + case 'calendar_card.v1': + return _renderCalendarCard(card); + case 'error_card.v1': + return _renderErrorCard(card); + default: + return _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; + + 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 (data.sourceType == 'ai_generated') + 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), + ), + ), + if (data.sourceType == 'ai_generated') + 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), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 16, + color: AppColors.slate500, + ), + SizedBox(width: AppSpacing.xs), + Text( + data.location!, + style: TextStyle( + fontSize: 12, + color: AppColors.slate500, + ), + ), + ], + ), + ], + if (card.actions != null && card.actions!.isNotEmpty) ...[ + SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + children: card.actions! + .map((action) => _buildActionButton(action)) + .toList(), + ), + ], + ], + ), + ), + ], + ), + ); + } + + static Widget _buildActionButton(CardAction action) { + final isPrimary = action.type == 'primary'; + 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 处理 + } +} From e1973a986864e5337f38f5f686f8fbf192628bef Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:40:46 +0800 Subject: [PATCH 09/13] feat(chat): add ChatBloc for state management --- .../chat/presentation/bloc/chat_bloc.dart | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 apps/lib/features/chat/presentation/bloc/chat_bloc.dart diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart new file mode 100644 index 0000000..20f2fd5 --- /dev/null +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -0,0 +1,197 @@ +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'; + +class ChatState { + final List 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? 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 AgUiService { + void Function(AgUiEvent)? onEvent; + + AgUiService({this.onEvent}); + + Future sendMessage(String content) async {} +} + +class ChatBloc extends Cubit { + final AgUiService _service; + final Map _toolCallArgsBuffer = {}; + + ChatBloc({AgUiService? service}) + : _service = service ?? AgUiService(onEvent: (_) {}), + 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 parsedArgs = {}; + if (argsBuffer.isNotEmpty) { + try { + parsedArgs = jsonDecode(argsBuffer) as Map; + } 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 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)); + } +} From f82a8072a2f8f56ae99fa16dde7616a56669de92 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:41:58 +0800 Subject: [PATCH 10/13] feat(chat): add ChatHistoryRepository with shared_preferences --- .../repositories/chat_history_repository.dart | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 apps/lib/features/chat/data/repositories/chat_history_repository.dart diff --git a/apps/lib/features/chat/data/repositories/chat_history_repository.dart b/apps/lib/features/chat/data/repositories/chat_history_repository.dart new file mode 100644 index 0000000..c694f1a --- /dev/null +++ b/apps/lib/features/chat/data/repositories/chat_history_repository.dart @@ -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 saveMessages(List> messages) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_msgKey, jsonEncode(messages)); + } + + Future>?> 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>(); + } + + Future saveLastRunId(String runId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_runIdKey, runId); + } + + Future loadLastRunId() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_runIdKey); + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_msgKey); + await prefs.remove(_runIdKey); + } + + Future saveCalendarEvent(Map event) async { + final prefs = await SharedPreferences.getInstance(); + final eventsJson = prefs.getString(_calendarEventsKey); + final events = eventsJson != null + ? jsonDecode(eventsJson) as Map + : {}; + events[event['id']] = event; + await prefs.setString(_calendarEventsKey, jsonEncode(events)); + } + + Future>> loadCalendarEvents() async { + final prefs = await SharedPreferences.getInstance(); + final eventsJson = prefs.getString(_calendarEventsKey); + if (eventsJson == null) return []; + final events = jsonDecode(eventsJson) as Map; + return events.values.cast>().toList(); + } +} From dd90f48c6f725f90d949d25b3c18e7a6cb7c565e Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:43:22 +0800 Subject: [PATCH 11/13] feat(chat): integrate ChatBloc into HomeScreen --- .../features/home/ui/screens/home_screen.dart | 245 +++++++++++++----- 1 file changed, 173 insertions(+), 72 deletions(-) diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 6a5d2cc..32fc1e5 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -1,7 +1,13 @@ 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'; class HomeScreen extends StatefulWidget { @@ -13,6 +19,7 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { final TextEditingController _messageController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); bool get _hasMessage => _messageController.text.trim().isNotEmpty; @@ -26,6 +33,7 @@ class _HomeScreenState extends State { void dispose() { _messageController.removeListener(_onMessageChanged); _messageController.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -35,16 +43,28 @@ class _HomeScreenState extends State { @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( + listener: (context, state) { + if (state.error != null) { + Toast.show(context, state.error!, type: ToastType.error); + } + }, + builder: (context, state) { + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + body: SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded(child: _buildChatArea(context, state)), + _buildInputContainer(context), + ], + ), + ), + ); + }, ), ); } @@ -92,91 +112,159 @@ class _HomeScreenState extends State { ); } - Widget _buildChatArea() { - return Padding( + 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: 300), + curve: Curves.easeOut, + ); + } + }); + + return ListView.builder( + controller: _scrollController, padding: const EdgeInsets.all(20), - child: Column( - children: [ - _buildUserMessageRow(), - const SizedBox(height: 16), - _buildTodoCard(), - ], - ), + itemCount: state.items.length, + itemBuilder: (context, index) { + final item = state.items[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + 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: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.blue100, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.bot, + size: 18, + 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: 13, vertical: 9), + decoration: BoxDecoration( + color: isUser ? const Color(0xFFEAF1FB) : AppColors.white, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(12), + topRight: const Radius.circular(12), + bottomLeft: Radius.circular(isUser ? 12 : 0), + bottomRight: Radius.circular(isUser ? 0 : 12), + ), + 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) { + String statusText; + Color statusColor; + IconData statusIcon; + + switch (item.status) { + case ToolCallStatus.pending: + statusText = '准备中...'; + statusColor = AppColors.slate500; + statusIcon = LucideIcons.clock; + break; + case ToolCallStatus.executing: + statusText = '执行中...'; + statusColor = AppColors.blue600; + statusIcon = LucideIcons.loader; + break; + case ToolCallStatus.error: + statusText = item.errorMessage ?? '执行失败'; + statusColor = AppColors.red600; + statusIcon = LucideIcons.alertCircle; + break; + case ToolCallStatus.completed: + statusText = '已完成'; + statusColor = AppColors.emerald600; + statusIcon = LucideIcons.checkCircle; + break; + } + 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), @@ -231,13 +319,19 @@ class _HomeScreenState extends State { 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: 24, + color: _hasMessage + ? AppColors.blue600 + : AppColors.slate500, + ), ), ], ), @@ -248,6 +342,13 @@ class _HomeScreenState extends State { ); } + Future _sendMessage(BuildContext context) async { + final content = _messageController.text.trim(); + if (content.isEmpty) return; + _messageController.clear(); + context.read().sendMessage(content); + } + void _showBottomSheet(BuildContext context) { showModalBottomSheet( context: context, From 92781ddbbe582126f50940981efbf3463029fd01 Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 13:49:51 +0800 Subject: [PATCH 12/13] test(chat): add comprehensive unit tests --- apps/test/features/chat/ag_ui_event_test.dart | 307 ++++++++++++++++++ .../features/chat/ag_ui_service_test.dart | 224 +++++++++++++ .../chat/ai_decision_engine_test.dart | 168 ++++++++++ apps/test/features/chat/chat_bloc_test.dart | 207 ++++++++++++ .../features/chat/tool_registry_test.dart | 100 ++++++ .../chat/ui_schema_renderer_test.dart | 171 ++++++++++ 6 files changed, 1177 insertions(+) create mode 100644 apps/test/features/chat/ag_ui_event_test.dart create mode 100644 apps/test/features/chat/ag_ui_service_test.dart create mode 100644 apps/test/features/chat/ai_decision_engine_test.dart create mode 100644 apps/test/features/chat/chat_bloc_test.dart create mode 100644 apps/test/features/chat/tool_registry_test.dart create mode 100644 apps/test/features/chat/ui_schema_renderer_test.dart diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart new file mode 100644 index 0000000..988956c --- /dev/null +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + 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()); + }); + + 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()); + 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()); + 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()); + 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()); + }); + }); + + 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'); + }); + }); +} diff --git a/apps/test/features/chat/ag_ui_service_test.dart b/apps/test/features/chat/ag_ui_service_test.dart new file mode 100644 index 0000000..61e1bce --- /dev/null +++ b/apps/test/features/chat/ag_ui_service_test.dart @@ -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 sendMessage(String content) async { + await mockEventStream(content); + } + + Future 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 mockToolCallFlow(String content, AiDecisionEngine engine) async { + final args = engine.getToolCallArgs(content); + if (args == null) return; + + await mockToolCallFlowWithArgs('create_calendar_event', args); + } + + Future mockToolCallFlowWithArgs( + String toolName, + Map 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 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 mockTextMessageStream(List 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 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()); + }); + + test('sendMessage last emits RunFinishedEvent', () async { + await service.sendMessage('你好'); + + expect(capturedEvents.last, isA()); + }); + + test('sendMessage emits events in correct order', () async { + await service.sendMessage('你好'); + + expect(capturedEvents.first, isA()); + expect(capturedEvents.last, isA()); + + 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() + .toList(); + final toolCallEnds = capturedEvents + .whereType() + .toList(); + final toolCallResults = capturedEvents + .whereType() + .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() + .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() + .toList(); + final textContents = capturedEvents + .whereType() + .toList(); + final textEnds = capturedEvents.whereType().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() + .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() + .toList(); + + expect(toolCallErrors.isNotEmpty, true); + expect(toolCallErrors.first.error, contains('Missing required fields')); + }); + }); +} diff --git a/apps/test/features/chat/ai_decision_engine_test.dart b/apps/test/features/chat/ai_decision_engine_test.dart new file mode 100644 index 0000000..79b25b9 --- /dev/null +++ b/apps/test/features/chat/ai_decision_engine_test.dart @@ -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); + }); + }); +} diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart new file mode 100644 index 0000000..8b2ec7b --- /dev/null +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -0,0 +1,207 @@ +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/presentation/bloc/chat_bloc.dart'; + +void main() { + late ChatBloc chatBloc; + late AgUiService service; + + setUp(() { + service = AgUiService(); + 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( + 'sendMessage adds user message to items', + build: () => chatBloc, + act: (bloc) => bloc.sendMessage('Hello'), + expect: () => [ + isA() + .having((state) => state.items.length, 'items length', 1) + .having( + (state) => state.items.first, + 'first item', + isA().having( + (item) => item.content, + 'content', + 'Hello', + ), + ), + ], + ); + + blocTest( + '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() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.items.length, 'items length', 1) + .having((s) => s.currentMessageId, 'currentMessageId', 'msg_1') + .having( + (s) => s.items.first, + 'first item', + isA() + .having((item) => item.isStreaming, 'isStreaming', true) + .having((item) => item.sender, 'sender', MessageSender.ai), + ), + ], + ); + + blocTest( + '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().having( + (s) => (s.items.first as TextMessageItem).content, + 'content', + 'Hello', + ), + ], + ); + + blocTest( + '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() + .having((s) => s.currentMessageId, 'currentMessageId', isNull) + .having( + (s) => (s.items.first as TextMessageItem).isStreaming, + 'isStreaming', + false, + ), + ], + ); + + blocTest( + 'runStarted sets isLoading to true', + build: () => chatBloc, + act: (bloc) { + service.onEvent!(RunStartedEvent(threadId: 't1', runId: 'r1')); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.error, 'error', isNull), + ], + ); + + blocTest( + 'runFinished sets isLoading to false', + build: () => chatBloc, + seed: () => const ChatState(isLoading: true), + act: (bloc) { + service.onEvent!(RunFinishedEvent(threadId: 't1', runId: 'r1')); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.currentMessageId, 'currentMessageId', isNull), + ], + ); + + blocTest( + 'runError sets error message', + build: () => chatBloc, + seed: () => const ChatState(isLoading: true), + act: (bloc) { + service.onEvent!( + RunErrorEvent(message: 'Something went wrong', code: 'ERR'), + ); + }, + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.error, 'error', 'Something went wrong'), + ], + ); + + blocTest( + 'clearError removes error', + build: () => chatBloc, + seed: () => const ChatState(error: 'Some error'), + act: (bloc) => bloc.clearError(), + expect: () => [isA().having((s) => s.error, 'error', isNull)], + ); + + blocTest( + 'toolCallStart adds ToolCallItem', + build: () => chatBloc, + act: (bloc) { + service.onEvent!( + ToolCallStartEvent( + toolCallId: 'tc_1', + toolCallName: 'create_calendar_event', + ), + ); + }, + expect: () => [ + isA().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, + ), + ], + ); + }); +} diff --git a/apps/test/features/chat/tool_registry_test.dart b/apps/test/features/chat/tool_registry_test.dart new file mode 100644 index 0000000..5369e5e --- /dev/null +++ b/apps/test/features/chat/tool_registry_test.dart @@ -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()), + ); + }); + + 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); + }); + }); +} diff --git a/apps/test/features/chat/ui_schema_renderer_test.dart b/apps/test/features/chat/ui_schema_renderer_test.dart new file mode 100644 index 0000000..bb01e0e --- /dev/null +++ b/apps/test/features/chat/ui_schema_renderer_test.dart @@ -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); + }); + }); +} From d37677c533d98c5a799d8abf164a88fe6cbc458d Mon Sep 17 00:00:00 2001 From: qzl Date: Sat, 28 Feb 2026 14:41:21 +0800 Subject: [PATCH 13/13] fix(chat): fix ChatBloc event callback and test reliability - Fix onEvent callback initialization in ChatBloc constructor - Add MockAgUiService to isolate test from mock API behavior - Remove unnecessary non-null assertions in tests --- .../chat/data/ai/ai_decision_engine.dart | 58 +++++---- .../chat/data/models/ag_ui_event.dart | 88 +++++-------- .../chat/data/models/tool_result.dart | 5 +- .../chat/data/services/ag_ui_service.dart | 34 +++-- .../chat/data/tools/tool_registry.dart | 30 +++-- .../chat/presentation/bloc/chat_bloc.dart | 11 +- .../chat/ui/widgets/ui_schema_renderer.dart | 103 ++++++++------- .../features/home/ui/screens/home_screen.dart | 118 ++++++++++-------- apps/test/features/chat/chat_bloc_test.dart | 24 ++-- 9 files changed, 254 insertions(+), 217 deletions(-) diff --git a/apps/lib/features/chat/data/ai/ai_decision_engine.dart b/apps/lib/features/chat/data/ai/ai_decision_engine.dart index bd27a98..5f8fade 100644 --- a/apps/lib/features/chat/data/ai/ai_decision_engine.dart +++ b/apps/lib/features/chat/data/ai/ai_decision_engine.dart @@ -2,17 +2,23 @@ import 'dart:convert'; enum Intent { createEvent, searchEvent, unknown } -class AiDecisionEngine { - /// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent) - static final List<(RegExp, Intent)> _orderedPatterns = [ - (RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent), - (RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent), - ( - RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), - Intent.createEvent, - ), - ]; +/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent) +final _orderedPatterns = <(RegExp, Intent)>[ + (RegExp(r'^查看|^有什么|^今天.*日程|^明天.*安排|^查询'), Intent.searchEvent), + (RegExp(r'提醒|开会|预约|安排.*时间|创建.*日程'), Intent.createEvent), + (RegExp(r'明天.*\d|今天.*\d|后天.*\d|\d{1,2}点|\d{1,2}:\d{2}'), Intent.createEvent), +]; +/// 时区常量 +const _defaultTimezone = 'Asia/Shanghai'; +const _dayToday = '今天'; +const _dayTomorrow = '明天'; +const _dayAfterTomorrow = '后天'; +const _tomorrowOffset = 1; +const _dayAfterTomorrowOffset = 2; +const _defaultMinute = 0; + +class AiDecisionEngine { Intent matchIntent(String text) { for (final (pattern, intent) in _orderedPatterns) { if (pattern.hasMatch(text)) return intent; @@ -34,31 +40,33 @@ class AiDecisionEngine { .trim(); } - if (args['title'] == null || (args['title'] as String).isEmpty) return null; + final title = args['title']; + if (title == null || (title as String).isEmpty) return null; final timeMatch = RegExp( r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?', ).firstMatch(text); if (timeMatch != null) { - final dayStr = timeMatch.group(1) ?? '今天'; + final dayStr = timeMatch.group(1) ?? _dayToday; final hour = int.parse(timeMatch.group(2)!); - final minute = int.parse(timeMatch.group(3) ?? '0'); + final minute = int.parse(timeMatch.group(3) ?? '$_defaultMinute'); final now = DateTime.now(); - DateTime startAt; - switch (dayStr) { - case '明天': - startAt = DateTime(now.year, now.month, now.day + 1, hour, minute); - break; - case '后天': - startAt = DateTime(now.year, now.month, now.day + 2, hour, minute); - break; - default: - startAt = DateTime(now.year, now.month, now.day, hour, minute); - } + final dayOffset = switch (dayStr) { + _dayTomorrow => _tomorrowOffset, + _dayAfterTomorrow => _dayAfterTomorrowOffset, + _ => 0, + }; + final startAt = DateTime( + now.year, + now.month, + now.day + dayOffset, + hour, + minute, + ); args['startAt'] = startAt.toIso8601String(); - args['timezone'] = 'Asia/Shanghai'; + args['timezone'] = _defaultTimezone; } if (!args.containsKey('startAt')) return null; diff --git a/apps/lib/features/chat/data/models/ag_ui_event.dart b/apps/lib/features/chat/data/models/ag_ui_event.dart index 9d62e64..349b7ab 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -32,63 +32,39 @@ enum AgUiEventType { unknown, } -AgUiEventType agUiEventTypeFromWire(String wire) { - switch (wire) { - case AgUiEventTypeWire.runStarted: - return AgUiEventType.runStarted; - case AgUiEventTypeWire.runFinished: - return AgUiEventType.runFinished; - case AgUiEventTypeWire.runError: - return AgUiEventType.runError; - case AgUiEventTypeWire.textMessageStart: - return AgUiEventType.textMessageStart; - case AgUiEventTypeWire.textMessageContent: - return AgUiEventType.textMessageContent; - case AgUiEventTypeWire.textMessageEnd: - return AgUiEventType.textMessageEnd; - case AgUiEventTypeWire.toolCallStart: - return AgUiEventType.toolCallStart; - case AgUiEventTypeWire.toolCallArgs: - return AgUiEventType.toolCallArgs; - case AgUiEventTypeWire.toolCallEnd: - return AgUiEventType.toolCallEnd; - case AgUiEventTypeWire.toolCallResult: - return AgUiEventType.toolCallResult; - case AgUiEventTypeWire.toolCallError: - return AgUiEventType.toolCallError; - default: - return AgUiEventType.unknown; - } -} +const _wireToTypeMap = { + AgUiEventTypeWire.runStarted: AgUiEventType.runStarted, + AgUiEventTypeWire.runFinished: AgUiEventType.runFinished, + AgUiEventTypeWire.runError: AgUiEventType.runError, + AgUiEventTypeWire.textMessageStart: AgUiEventType.textMessageStart, + AgUiEventTypeWire.textMessageContent: AgUiEventType.textMessageContent, + AgUiEventTypeWire.textMessageEnd: AgUiEventType.textMessageEnd, + AgUiEventTypeWire.toolCallStart: AgUiEventType.toolCallStart, + AgUiEventTypeWire.toolCallArgs: AgUiEventType.toolCallArgs, + AgUiEventTypeWire.toolCallEnd: AgUiEventType.toolCallEnd, + AgUiEventTypeWire.toolCallResult: AgUiEventType.toolCallResult, + AgUiEventTypeWire.toolCallError: AgUiEventType.toolCallError, +}; -String agUiEventTypeToWire(AgUiEventType type) { - switch (type) { - case AgUiEventType.runStarted: - return AgUiEventTypeWire.runStarted; - case AgUiEventType.runFinished: - return AgUiEventTypeWire.runFinished; - case AgUiEventType.runError: - return AgUiEventTypeWire.runError; - case AgUiEventType.textMessageStart: - return AgUiEventTypeWire.textMessageStart; - case AgUiEventType.textMessageContent: - return AgUiEventTypeWire.textMessageContent; - case AgUiEventType.textMessageEnd: - return AgUiEventTypeWire.textMessageEnd; - case AgUiEventType.toolCallStart: - return AgUiEventTypeWire.toolCallStart; - case AgUiEventType.toolCallArgs: - return AgUiEventTypeWire.toolCallArgs; - case AgUiEventType.toolCallEnd: - return AgUiEventTypeWire.toolCallEnd; - case AgUiEventType.toolCallResult: - return AgUiEventTypeWire.toolCallResult; - case AgUiEventType.toolCallError: - return AgUiEventTypeWire.toolCallError; - case AgUiEventType.unknown: - return ''; - } -} +const _typeToWireMap = { + AgUiEventType.runStarted: AgUiEventTypeWire.runStarted, + AgUiEventType.runFinished: AgUiEventTypeWire.runFinished, + AgUiEventType.runError: AgUiEventTypeWire.runError, + AgUiEventType.textMessageStart: AgUiEventTypeWire.textMessageStart, + AgUiEventType.textMessageContent: AgUiEventTypeWire.textMessageContent, + AgUiEventType.textMessageEnd: AgUiEventTypeWire.textMessageEnd, + AgUiEventType.toolCallStart: AgUiEventTypeWire.toolCallStart, + AgUiEventType.toolCallArgs: AgUiEventTypeWire.toolCallArgs, + AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd, + AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult, + AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError, + AgUiEventType.unknown: '', +}; + +AgUiEventType agUiEventTypeFromWire(String wire) => + _wireToTypeMap[wire] ?? AgUiEventType.unknown; + +String agUiEventTypeToWire(AgUiEventType type) => _typeToWireMap[type] ?? ''; @JsonSerializable() class AgUiEvent { diff --git a/apps/lib/features/chat/data/models/tool_result.dart b/apps/lib/features/chat/data/models/tool_result.dart index a2298e7..4e051a9 100644 --- a/apps/lib/features/chat/data/models/tool_result.dart +++ b/apps/lib/features/chat/data/models/tool_result.dart @@ -2,6 +2,9 @@ import 'package:json_annotation/json_annotation.dart'; part 'tool_result.g.dart'; +/// Schema 版本常量 +const _defaultSchemaVersion = 'v1'; + /// 工具执行结果(给 AI 的原始数据) @JsonSerializable() class ToolResult { @@ -31,7 +34,7 @@ class UiCard { UiCard({ required this.cardType, - this.schemaVersion = 'v1', + this.schemaVersion = _defaultSchemaVersion, required this.data, this.actions, }); diff --git a/apps/lib/features/chat/data/services/ag_ui_service.dart b/apps/lib/features/chat/data/services/ag_ui_service.dart index a132d33..40fb38e 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -8,6 +8,18 @@ import '../models/ag_ui_event.dart'; import '../models/tool_result.dart'; import '../tools/tool_registry.dart'; +/// Mock ID 前缀常量 +const _threadIdPrefix = 'thread_'; +const _runIdPrefix = 'run_'; +const _toolCallIdPrefix = 'tc_'; +const _messageIdPrefix = 'msg_'; + +/// 流式输出延迟 (毫秒) +const _streamChunkDelayMs = 50; + +/// 文本块大小 +const _textChunkSize = 10; + typedef EventCallback = void Function(AgUiEvent event); class AgUiService { @@ -27,8 +39,8 @@ class AgUiService { } Future _mockEventStream(String content) async { - final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}'; - final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; + final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}'; + final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(RunStartedEvent(threadId: threadId, runId: runId)); @@ -58,7 +70,8 @@ class AgUiService { String toolName, Map args, ) async { - final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}'; + final toolCallId = + '$_toolCallIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName)); @@ -82,7 +95,8 @@ class AgUiService { ToolRegistry.initialize(); final result = await ToolRegistry.execute(toolName, args); final ui = _buildUiCard(toolName, result); - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + final messageId = + '$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent( ToolCallResultEvent( @@ -145,20 +159,20 @@ class AgUiService { Future _mockTextMessageStream(List replies) async { for (final reply in replies) { - final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; + final messageId = + '$_messageIdPrefix${DateTime.now().millisecondsSinceEpoch}'; onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant')); - const chunkSize = 10; - for (var i = 0; i < reply.length; i += chunkSize) { - final end = (i + chunkSize < reply.length) - ? i + chunkSize + for (var i = 0; i < reply.length; i += _textChunkSize) { + final end = (i + _textChunkSize < reply.length) + ? i + _textChunkSize : reply.length; final chunk = reply.substring(i, end); onEvent(TextMessageContentEvent(messageId: messageId, delta: chunk)); - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: _streamChunkDelayMs)); } onEvent(TextMessageEndEvent(messageId: messageId)); diff --git a/apps/lib/features/chat/data/tools/tool_registry.dart b/apps/lib/features/chat/data/tools/tool_registry.dart index 4a88b58..8b05761 100644 --- a/apps/lib/features/chat/data/tools/tool_registry.dart +++ b/apps/lib/features/chat/data/tools/tool_registry.dart @@ -1,6 +1,14 @@ typedef ToolHandler = Future> Function(Map args); +/// 工具常量 +const _toolNameCreateCalendar = 'create_calendar_event'; +const _defaultTimezone = 'Asia/Shanghai'; +const _defaultEventColor = '#4F46E5'; +const _defaultSourceType = 'agentGenerated'; +const _titleMinLength = 1; +const _titleMaxLength = 100; + class ToolDefinition { final String name; final String description; @@ -22,8 +30,8 @@ class ToolRegistry { static void initialize() { if (_initialized) return; - _tools['create_calendar_event'] = ToolDefinition( - name: 'create_calendar_event', + _tools[_toolNameCreateCalendar] = ToolDefinition( + name: _toolNameCreateCalendar, description: '创建一个日历事件或待办事项', parameters: { 'type': 'object', @@ -31,8 +39,8 @@ class ToolRegistry { 'title': { 'type': 'string', 'description': '事件标题', - 'minLength': 1, - 'maxLength': 100, + 'minLength': _titleMinLength, + 'maxLength': _titleMaxLength, }, 'description': {'type': 'string', 'description': '事件描述'}, 'startAt': { @@ -45,7 +53,7 @@ class ToolRegistry { 'format': 'date-time', 'description': '结束时间 (ISO8601)', }, - 'timezone': {'type': 'string', 'default': 'Asia/Shanghai'}, + 'timezone': {'type': 'string', 'default': _defaultTimezone}, 'location': {'type': 'string'}, 'notes': {'type': 'string'}, }, @@ -69,10 +77,10 @@ class ToolRegistry { 'description': args['description'], 'startAt': args['startAt'], 'endAt': args['endAt'], - 'timezone': args['timezone'] ?? 'Asia/Shanghai', + 'timezone': args['timezone'] ?? _defaultTimezone, 'location': args['location'], - 'color': '#4F46E5', - 'sourceType': 'agentGenerated', + 'color': _defaultEventColor, + 'sourceType': _defaultSourceType, }; } @@ -93,17 +101,19 @@ class ToolRegistry { Map args, ) { final tool = _tools[toolName]; - if (tool == null) + if (tool == null) { return ToolValidationResult( ok: false, error: 'Tool not found: $toolName', ); + } final required = tool.parameters['required'] as List? ?? []; final missing = []; for (final field in required) { - if (!args.containsKey(field) || args[field] == null) + if (!args.containsKey(field) || args[field] == null) { missing.add(field as String); + } } if (missing.isNotEmpty) { diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 20f2fd5..d8099ee 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -5,6 +5,7 @@ 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 items; @@ -38,20 +39,12 @@ class ChatState { } } -class AgUiService { - void Function(AgUiEvent)? onEvent; - - AgUiService({this.onEvent}); - - Future sendMessage(String content) async {} -} - class ChatBloc extends Cubit { final AgUiService _service; final Map _toolCallArgsBuffer = {}; ChatBloc({AgUiService? service}) - : _service = service ?? AgUiService(onEvent: (_) {}), + : _service = service ?? AgUiService(), super(const ChatState()) { _service.onEvent = _handleEvent; } diff --git a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart index 2e7da89..c4f1884 100644 --- a/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart +++ b/apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart @@ -2,16 +2,19 @@ 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) { - switch (card.cardType) { - case 'calendar_card.v1': - return _renderCalendarCard(card); - case 'error_card.v1': - return _renderErrorCard(card); - default: - return _renderUnknownCard(card); - } + return switch (card.cardType) { + _calendarCardType => _renderCalendarCard(card), + _errorCardType => _renderErrorCard(card), + _ => _renderUnknownCard(card), + }; } static Widget _renderCalendarCard(UiCard card) { @@ -19,6 +22,7 @@ class UiSchemaRenderer { final color = data.color != null ? Color(int.parse(data.color!.replaceFirst('#', '0xFF'))) : AppColors.blue500; + final isAiGenerated = data.sourceType == _aiGeneratedSource; return Container( decoration: BoxDecoration( @@ -44,23 +48,10 @@ class UiSchemaRenderer { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (data.sourceType == 'ai_generated') - 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), - ), - ), - if (data.sourceType == 'ai_generated') + if (isAiGenerated) ...[ + _buildAiTag(), SizedBox(height: AppSpacing.sm), + ], Text( _formatTime(data.startAt, data.endAt), style: TextStyle(fontSize: 12, color: AppColors.slate500), @@ -83,32 +74,11 @@ class UiSchemaRenderer { ], if (data.location != null) ...[ SizedBox(height: AppSpacing.sm), - Row( - children: [ - Icon( - Icons.location_on_outlined, - size: 16, - color: AppColors.slate500, - ), - SizedBox(width: AppSpacing.xs), - Text( - data.location!, - style: TextStyle( - fontSize: 12, - color: AppColors.slate500, - ), - ), - ], - ), + _buildLocation(data.location!), ], if (card.actions != null && card.actions!.isNotEmpty) ...[ SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - children: card.actions! - .map((action) => _buildActionButton(action)) - .toList(), - ), + _buildActions(card.actions!), ], ], ), @@ -118,8 +88,45 @@ class UiSchemaRenderer { ); } + 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 actions) { + return Wrap( + spacing: AppSpacing.sm, + children: actions.map((action) => _buildActionButton(action)).toList(), + ); + } + static Widget _buildActionButton(CardAction action) { - final isPrimary = action.type == 'primary'; + final isPrimary = action.type == _primaryActionType; return GestureDetector( onTap: () => _handleAction(action), child: Container( diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 32fc1e5..a8abbea 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -10,6 +10,25 @@ 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}); @@ -53,7 +72,7 @@ class _HomeScreenState extends State { }, builder: (context, state) { return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + backgroundColor: _chatBgColor, body: SafeArea( child: Column( children: [ @@ -71,16 +90,16 @@ class _HomeScreenState extends State { 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'), @@ -90,16 +109,16 @@ class _HomeScreenState extends State { 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'), @@ -126,7 +145,7 @@ class _HomeScreenState extends State { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); } @@ -134,12 +153,12 @@ class _HomeScreenState extends State { return ListView.builder( controller: _scrollController, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(_defaultPadding), itemCount: state.items.length, itemBuilder: (context, index) { final item = state.items[index]; return Padding( - padding: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.only(bottom: _itemSpacing), child: _buildChatItem(item), ); }, @@ -167,15 +186,15 @@ class _HomeScreenState extends State { children: [ if (!isUser) ...[ Container( - width: 32, - height: 32, + width: _avatarSize, + height: _avatarSize, decoration: BoxDecoration( color: AppColors.blue100, shape: BoxShape.circle, ), child: const Icon( LucideIcons.bot, - size: 18, + size: _botIconSize, color: AppColors.blue600, ), ), @@ -183,14 +202,17 @@ class _HomeScreenState extends State { ], Flexible( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), + padding: const EdgeInsets.symmetric( + horizontal: _messagePaddingH, + vertical: _messagePaddingV, + ), decoration: BoxDecoration( - color: isUser ? const Color(0xFFEAF1FB) : AppColors.white, + color: isUser ? _userBubbleColor : AppColors.white, borderRadius: BorderRadius.only( - topLeft: const Radius.circular(12), - topRight: const Radius.circular(12), - bottomLeft: Radius.circular(isUser ? 12 : 0), - bottomRight: Radius.circular(isUser ? 0 : 12), + 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), ), @@ -207,32 +229,28 @@ class _HomeScreenState extends State { } Widget _buildToolCallItem(ToolCallItem item) { - String statusText; - Color statusColor; - IconData statusIcon; - - switch (item.status) { - case ToolCallStatus.pending: - statusText = '准备中...'; - statusColor = AppColors.slate500; - statusIcon = LucideIcons.clock; - break; - case ToolCallStatus.executing: - statusText = '执行中...'; - statusColor = AppColors.blue600; - statusIcon = LucideIcons.loader; - break; - case ToolCallStatus.error: - statusText = item.errorMessage ?? '执行失败'; - statusColor = AppColors.red600; - statusIcon = LucideIcons.alertCircle; - break; - case ToolCallStatus.completed: - statusText = '已完成'; - statusColor = AppColors.emerald600; - statusIcon = LucideIcons.checkCircle; - break; - } + 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( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -267,8 +285,8 @@ class _HomeScreenState extends State { 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: [ @@ -292,11 +310,11 @@ class _HomeScreenState extends State { 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( @@ -327,7 +345,7 @@ class _HomeScreenState extends State { onTap: _hasMessage ? () => _sendMessage(context) : null, child: Icon( _hasMessage ? LucideIcons.send : LucideIcons.mic, - size: 24, + size: _iconSize, color: _hasMessage ? AppColors.blue600 : AppColors.slate500, diff --git a/apps/test/features/chat/chat_bloc_test.dart b/apps/test/features/chat/chat_bloc_test.dart index 8b2ec7b..ace218f 100644 --- a/apps/test/features/chat/chat_bloc_test.dart +++ b/apps/test/features/chat/chat_bloc_test.dart @@ -2,14 +2,22 @@ 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 sendMessage(String content) async {} +} + void main() { late ChatBloc chatBloc; late AgUiService service; setUp(() { - service = AgUiService(); + service = MockAgUiService(); chatBloc = ChatBloc(service: service); }); @@ -49,7 +57,7 @@ void main() { build: () => chatBloc, act: (bloc) { bloc.emit(chatBloc.state.copyWith(isLoading: true)); - service.onEvent!( + service.onEvent( TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'), ); }, @@ -86,7 +94,7 @@ void main() { currentMessageId: 'msg_1', ), act: (bloc) { - service.onEvent!( + service.onEvent( TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'), ); }, @@ -115,7 +123,7 @@ void main() { currentMessageId: 'msg_1', ), act: (bloc) { - service.onEvent!(TextMessageEndEvent(messageId: 'msg_1')); + service.onEvent(TextMessageEndEvent(messageId: 'msg_1')); }, expect: () => [ isA() @@ -132,7 +140,7 @@ void main() { 'runStarted sets isLoading to true', build: () => chatBloc, act: (bloc) { - service.onEvent!(RunStartedEvent(threadId: 't1', runId: 'r1')); + service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() @@ -146,7 +154,7 @@ void main() { build: () => chatBloc, seed: () => const ChatState(isLoading: true), act: (bloc) { - service.onEvent!(RunFinishedEvent(threadId: 't1', runId: 'r1')); + service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1')); }, expect: () => [ isA() @@ -160,7 +168,7 @@ void main() { build: () => chatBloc, seed: () => const ChatState(isLoading: true), act: (bloc) { - service.onEvent!( + service.onEvent( RunErrorEvent(message: 'Something went wrong', code: 'ERR'), ); }, @@ -183,7 +191,7 @@ void main() { 'toolCallStart adds ToolCallItem', build: () => chatBloc, act: (bloc) { - service.onEvent!( + service.onEvent( ToolCallStartEvent( toolCallId: 'tc_1', toolCallName: 'create_calendar_event',