docs: 添加 AG-UI 协议规则约束到 apps/AGENTS.md
This commit is contained in:
@@ -122,6 +122,46 @@ AppBanner(message: '请检查输入', type: ToastType.warning)
|
|||||||
- DO NOT create custom SnackBar, Dialog, or Banner components
|
- DO NOT create custom SnackBar, Dialog, or Banner components
|
||||||
- DO NOT use raw `ScaffoldMessenger`
|
- 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
|
## App Debugging
|
||||||
|
|
||||||
**DO NOT automatically start Flutter app debugging.**
|
**DO NOT automatically start Flutter app debugging.**
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const _typeToWireMap = {
|
|||||||
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
|
AgUiEventType.toolCallEnd: AgUiEventTypeWire.toolCallEnd,
|
||||||
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
AgUiEventType.toolCallResult: AgUiEventTypeWire.toolCallResult,
|
||||||
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
|
AgUiEventType.toolCallError: AgUiEventTypeWire.toolCallError,
|
||||||
|
AgUiEventType.messagesSnapshot: AgUiEventTypeWire.messagesSnapshot,
|
||||||
AgUiEventType.unknown: '',
|
AgUiEventType.unknown: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +103,8 @@ class AgUiEvent {
|
|||||||
return ToolCallResultEvent.fromJson(json);
|
return ToolCallResultEvent.fromJson(json);
|
||||||
case AgUiEventType.toolCallError:
|
case AgUiEventType.toolCallError:
|
||||||
return ToolCallErrorEvent.fromJson(json);
|
return ToolCallErrorEvent.fromJson(json);
|
||||||
|
case AgUiEventType.messagesSnapshot:
|
||||||
|
return MessagesSnapshotEvent.fromJson(json);
|
||||||
case AgUiEventType.unknown:
|
case AgUiEventType.unknown:
|
||||||
return UnknownAgUiEvent.fromJson(json);
|
return UnknownAgUiEvent.fromJson(json);
|
||||||
}
|
}
|
||||||
@@ -297,3 +300,39 @@ class ToolCallErrorEvent extends AgUiEvent {
|
|||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => _$ToolCallErrorEventToJson(this);
|
Map<String, dynamic> toJson() => _$ToolCallErrorEventToJson(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class MessagesSnapshotEvent extends AgUiEvent {
|
||||||
|
final List<SnapshotMessage> messages;
|
||||||
|
|
||||||
|
MessagesSnapshotEvent({required this.messages})
|
||||||
|
: super(type: AgUiEventType.messagesSnapshot);
|
||||||
|
|
||||||
|
factory MessagesSnapshotEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$MessagesSnapshotEventFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
_$SnapshotMessageFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$SnapshotMessageToJson(this);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../ai/ai_decision_engine.dart';
|
|||||||
import '../models/ag_ui_event.dart';
|
import '../models/ag_ui_event.dart';
|
||||||
import '../models/tool_result.dart';
|
import '../models/tool_result.dart';
|
||||||
import '../tools/tool_registry.dart';
|
import '../tools/tool_registry.dart';
|
||||||
|
import 'mock_history_service.dart';
|
||||||
|
|
||||||
/// Mock ID 前缀常量
|
/// Mock ID 前缀常量
|
||||||
const _threadIdPrefix = 'thread_';
|
const _threadIdPrefix = 'thread_';
|
||||||
@@ -25,10 +26,12 @@ typedef EventCallback = void Function(AgUiEvent event);
|
|||||||
class AgUiService {
|
class AgUiService {
|
||||||
EventCallback onEvent;
|
EventCallback onEvent;
|
||||||
final AiDecisionEngine _decisionEngine;
|
final AiDecisionEngine _decisionEngine;
|
||||||
|
final MockHistoryService _historyService;
|
||||||
|
|
||||||
AgUiService({EventCallback? onEvent})
|
AgUiService({EventCallback? onEvent})
|
||||||
: onEvent = onEvent ?? ((_) {}),
|
: onEvent = onEvent ?? ((_) {}),
|
||||||
_decisionEngine = AiDecisionEngine();
|
_decisionEngine = AiDecisionEngine(),
|
||||||
|
_historyService = MockHistoryService();
|
||||||
|
|
||||||
Future<void> sendMessage(String content) async {
|
Future<void> sendMessage(String content) async {
|
||||||
if (Env.isMockApi) {
|
if (Env.isMockApi) {
|
||||||
@@ -38,6 +41,43 @@ class AgUiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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<void> _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<void> _mockEventStream(String content) async {
|
Future<void> _mockEventStream(String content) async {
|
||||||
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
final threadId = '$_threadIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
final runId = '$_runIdPrefix${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|||||||
@@ -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<SnapshotMessage> 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<DateTime> _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<SnapshotMessage> _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: '你好,我有什么可以帮你的?',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,16 @@ class ChatState {
|
|||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? currentMessageId;
|
final String? currentMessageId;
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final DateTime? oldestLoadedDate;
|
||||||
|
final bool hasEarlierHistory;
|
||||||
|
|
||||||
const ChatState({
|
const ChatState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
this.currentMessageId,
|
this.currentMessageId,
|
||||||
this.error,
|
this.error,
|
||||||
|
this.oldestLoadedDate,
|
||||||
|
this.hasEarlierHistory = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _unset = Object();
|
static const _unset = Object();
|
||||||
@@ -27,6 +31,8 @@ class ChatState {
|
|||||||
bool? isLoading,
|
bool? isLoading,
|
||||||
Object? currentMessageId = _unset,
|
Object? currentMessageId = _unset,
|
||||||
Object? error = _unset,
|
Object? error = _unset,
|
||||||
|
Object? oldestLoadedDate = _unset,
|
||||||
|
bool? hasEarlierHistory,
|
||||||
}) {
|
}) {
|
||||||
return ChatState(
|
return ChatState(
|
||||||
items: items ?? this.items,
|
items: items ?? this.items,
|
||||||
@@ -35,6 +41,10 @@ class ChatState {
|
|||||||
? this.currentMessageId
|
? this.currentMessageId
|
||||||
: currentMessageId as String?,
|
: currentMessageId as String?,
|
||||||
error: error == _unset ? this.error : error as String?,
|
error: error == _unset ? this.error : error as String?,
|
||||||
|
oldestLoadedDate: oldestLoadedDate == _unset
|
||||||
|
? this.oldestLoadedDate
|
||||||
|
: oldestLoadedDate as DateTime?,
|
||||||
|
hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user