diff --git a/apps/AGENTS.md b/apps/AGENTS.md index d188e2d..0a86d52 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -122,6 +122,46 @@ AppBanner(message: '请检查输入', type: ToastType.warning) - DO NOT create custom SnackBar, Dialog, or Banner components - DO NOT use raw `ScaffoldMessenger` +## Agent Chat (AG-UI Protocol) + +**Agent chat functionality MUST follow the AG-UI protocol**, reference `docs/knowledges/ag-ui-llms-full.txt`. + +### Core Requirements + +1. **Event-Driven Architecture**: Implement event-driven streaming responses +2. **Event Types**: Must support the 16 standard event types: + - **Lifecycle**: `RUN_STARTED`, `RUN_FINISHED`, `RUN_ERROR`, `STEP_STARTED`, `STEP_FINISHED` + - **Text Message**: `TEXT_MESSAGE_START`, `TEXT_MESSAGE_CONTENT`, `TEXT_MESSAGE_END` + - **Tool Call**: `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, `TOOL_CALL_RESULT` + - **State Management**: `STATE_SNAPSHOT`, `STATE_DELTA`, `MESSAGES_SNAPSHOT` + - **Special**: `RAW`, `CUSTOM` + +3. **Transport**: Use Server-Sent Events (SSE) for streaming + +4. **Event Flow**: Follow the standard pattern: + - `RUN_STARTED` (required) → [optional events] → `RUN_FINISHED` or `RUN_ERROR` (required) + - Text messages: `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT` (delta) → `TEXT_MESSAGE_END` + +5. **Frontend Integration**: Use AG-UI compatible client libraries + +### Event Reference + +| Event | Description | +|-------|-------------| +| `RUN_STARTED` | Signals the start of an agent run | +| `RUN_FINISHED` | Signals successful completion | +| `RUN_ERROR` | Signals an error during execution | +| `TEXT_MESSAGE_START` | Initializes a new text message with unique messageId | +| `TEXT_MESSAGE_CONTENT` | Delivers incremental text chunks (delta) | +| `TEXT_MESSAGE_END` | Marks message completion | + +### Prohibitions + +- DO NOT return non-streaming responses for agent chat +- DO NOT skip required lifecycle events (RUN_STARTED, RUN_FINISHED/RUN_ERROR) +- DO NOT use custom event formats outside of AG-UI specification + + ## App Debugging **DO NOT automatically start Flutter app debugging.** 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 6dc3379..3874902 100644 --- a/apps/lib/features/chat/data/models/ag_ui_event.dart +++ b/apps/lib/features/chat/data/models/ag_ui_event.dart @@ -61,6 +61,7 @@ const _typeToWireMap = { AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd, AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult, AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError, + AgUiEventType.messagesSnapshot: AgUiEventTypeWire.messagesSnapshot, AgUiEventType.unknown: '', }; @@ -102,6 +103,8 @@ class AgUiEvent { return ToolCallResultEvent.fromJson(json); case AgUiEventType.toolCallError: return ToolCallErrorEvent.fromJson(json); + case AgUiEventType.messagesSnapshot: + return MessagesSnapshotEvent.fromJson(json); case AgUiEventType.unknown: return UnknownAgUiEvent.fromJson(json); } @@ -297,3 +300,39 @@ class ToolCallErrorEvent extends AgUiEvent { @override Map toJson() => _$ToolCallErrorEventToJson(this); } + +@JsonSerializable() +class MessagesSnapshotEvent extends AgUiEvent { + final List messages; + + MessagesSnapshotEvent({required this.messages}) + : super(type: AgUiEventType.messagesSnapshot); + + factory MessagesSnapshotEvent.fromJson(Map json) => + _$MessagesSnapshotEventFromJson(json); + + @override + Map toJson() => _$MessagesSnapshotEventToJson(this); +} + +@JsonSerializable() +class SnapshotMessage { + final String id; + final String role; + final String? content; + final String? toolCallId; + final UiCard? ui; + + SnapshotMessage({ + required this.id, + required this.role, + this.content, + this.toolCallId, + this.ui, + }); + + factory SnapshotMessage.fromJson(Map json) => + _$SnapshotMessageFromJson(json); + + Map toJson() => _$SnapshotMessageToJson(this); +} 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 40fb38e..367ae02 100644 --- a/apps/lib/features/chat/data/services/ag_ui_service.dart +++ b/apps/lib/features/chat/data/services/ag_ui_service.dart @@ -7,6 +7,7 @@ import '../ai/ai_decision_engine.dart'; import '../models/ag_ui_event.dart'; import '../models/tool_result.dart'; import '../tools/tool_registry.dart'; +import 'mock_history_service.dart'; /// Mock ID 前缀常量 const _threadIdPrefix = 'thread_'; @@ -25,10 +26,12 @@ typedef EventCallback = void Function(AgUiEvent event); class AgUiService { EventCallback onEvent; final AiDecisionEngine _decisionEngine; + final MockHistoryService _historyService; AgUiService({EventCallback? onEvent}) : onEvent = onEvent ?? ((_) {}), - _decisionEngine = AiDecisionEngine(); + _decisionEngine = AiDecisionEngine(), + _historyService = MockHistoryService(); Future sendMessage(String content) async { if (Env.isMockApi) { @@ -38,6 +41,43 @@ class AgUiService { } } + Future loadHistory({DateTime? beforeDate}) async { + if (Env.isMockApi) { + await _mockLoadHistory(beforeDate: beforeDate); + } else { + throw UnimplementedError('Real API not implemented'); + } + } + + bool hasEarlierHistory(DateTime fromDate) { + return _historyService.hasEarlierHistory(fromDate); + } + + Future _mockLoadHistory({DateTime? beforeDate}) async { + final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}'; + final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}'; + + onEvent(RunStartedEvent(threadId: threadId, runId: runId)); + + DateTime targetDate; + if (beforeDate != null) { + final prevDate = _historyService.getPreviousDay(beforeDate); + if (prevDate == null) { + onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); + return; + } + targetDate = prevDate; + } else { + targetDate = _historyService.getLatestHistoryDate() ?? DateTime.now(); + } + + final messages = _historyService.getHistoryForDay(targetDate); + + onEvent(MessagesSnapshotEvent(messages: messages)); + + onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); + } + Future _mockEventStream(String content) async { final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}'; final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}'; diff --git a/apps/lib/features/chat/data/services/mock_history_service.dart b/apps/lib/features/chat/data/services/mock_history_service.dart new file mode 100644 index 0000000..061c2e0 --- /dev/null +++ b/apps/lib/features/chat/data/services/mock_history_service.dart @@ -0,0 +1,149 @@ +import '../models/ag_ui_event.dart'; +import '../models/tool_result.dart'; + +class MockHistoryService { + static final MockHistoryService _instance = MockHistoryService._internal(); + factory MockHistoryService() => _instance; + MockHistoryService._internal(); + + List getHistoryForDay(DateTime date) { + final dayStart = DateTime(date.year, date.month, date.day); + final allHistory = _generateAllHistory(); + + return allHistory.where((msg) { + if (msg.ui != null) { + final data = msg.ui!.data; + final startAtStr = data['startAt'] as String?; + if (startAtStr != null) { + try { + final startAt = DateTime.parse(startAtStr); + final msgDate = DateTime(startAt.year, startAt.month, startAt.day); + return msgDate == dayStart; + } catch (_) {} + } + } + return false; + }).toList(); + } + + DateTime? getLatestHistoryDate() { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day); + } + + DateTime? getPreviousDay(DateTime currentDate) { + final allDates = _getAllHistoryDates(); + final sortedDates = allDates.toList()..sort((a, b) => b.compareTo(a)); + + final currentDateOnly = DateTime( + currentDate.year, + currentDate.month, + currentDate.day, + ); + + for (final date in sortedDates) { + if (date.isBefore(currentDateOnly)) { + return date; + } + } + return null; + } + + bool hasEarlierHistory(DateTime fromDate) { + final allDates = _getAllHistoryDates(); + final fromDateOnly = DateTime(fromDate.year, fromDate.month, fromDate.day); + + return allDates.any((date) => date.isBefore(fromDateOnly)); + } + + Set _getAllHistoryDates() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + return {today, yesterday}; + } + + List _generateAllHistory() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + + return [ + SnapshotMessage(id: 'hist-m1', role: 'user', content: '明天提醒我开会'), + SnapshotMessage( + id: 'hist-t1', + role: 'tool', + toolCallId: 'hist-tc1', + ui: UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'hist-s1', + title: '产品评审会议', + description: '讨论Q2产品路线图', + startAt: today + .add(const Duration(days: 1, hours: 10)) + .toIso8601String(), + endAt: today + .add(const Duration(days: 1, hours: 11)) + .toIso8601String(), + timezone: 'Asia/Shanghai', + location: '会议室A / 在线', + color: '#4F46E5', + sourceType: 'ai_generated', + ).toJson(), + actions: [ + CardAction( + type: 'link', + label: '查看详情', + target: '/calendar/hist-s1', + ), + ], + ), + ), + SnapshotMessage( + id: 'hist-m2', + role: 'assistant', + content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。', + ), + SnapshotMessage(id: 'hist-m3', role: 'user', content: '下周一之前提交项目报告'), + SnapshotMessage( + id: 'hist-t2', + role: 'tool', + toolCallId: 'hist-tc2', + ui: UiCard( + cardType: 'calendar_card.v1', + data: CalendarCardData( + id: 'hist-s2', + title: '提交项目报告', + description: '完成并提交Q2项目报告', + startAt: yesterday + .subtract(const Duration(days: 3)) + .toIso8601String(), + endAt: null, + timezone: 'Asia/Shanghai', + location: null, + color: '#F59E0B', + sourceType: 'ai_generated', + ).toJson(), + actions: [ + CardAction( + type: 'link', + label: '查看详情', + target: '/calendar/hist-s2', + ), + ], + ), + ), + SnapshotMessage( + id: 'hist-m4', + role: 'assistant', + content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。', + ), + SnapshotMessage( + id: 'hist-m5', + role: 'assistant', + content: '你好,我有什么可以帮你的?', + ), + ]; + } +} diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index d8099ee..3f8e3fd 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -12,12 +12,16 @@ class ChatState { final bool isLoading; final String? currentMessageId; final String? error; + final DateTime? oldestLoadedDate; + final bool hasEarlierHistory; const ChatState({ this.items = const [], this.isLoading = false, this.currentMessageId, this.error, + this.oldestLoadedDate, + this.hasEarlierHistory = false, }); static const _unset = Object(); @@ -27,6 +31,8 @@ class ChatState { bool? isLoading, Object? currentMessageId = _unset, Object? error = _unset, + Object? oldestLoadedDate = _unset, + bool? hasEarlierHistory, }) { return ChatState( items: items ?? this.items, @@ -35,6 +41,10 @@ class ChatState { ? this.currentMessageId : currentMessageId as String?, error: error == _unset ? this.error : error as String?, + oldestLoadedDate: oldestLoadedDate == _unset + ? this.oldestLoadedDate + : oldestLoadedDate as DateTime?, + hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory, ); } }