Files
social-app/docs/plans/2026-02-28-ag-ui-chat-implementation-plan.md
T

2464 lines
67 KiB
Markdown
Raw Normal View History

# AG-UI 聊天功能实现计划 v1.3
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> **Revision History:**
> - v1.3: 修复 AiDecisionEngine 规则优先级、ChatState.copyWith 空值处理、测试用例对齐
> - v1.2: 修复 AgUiService.onEvent 可变、ChatBloc 初始化、补充测试覆盖
> - v1.1: 修复编译错误、事件映射、职责边界、设计 token、测试覆盖等问题
**Goal:** 实现基于 AG-UI 协议的 AI 聊天功能,包括事件流处理、工具调用、日历卡片渲染
**Architecture:**
- 核心组件:AgUiService 处理事件流、ToolRegistry 管理前端工具
- 数据流:用户消息 → 事件流 → AI 响应 → ToolCall → 执行 → UI 渲染
- Mock 模式使用规则引擎模拟 AI 决策,真实模式对接 SSE
**Tech Stack:** Flutter, Dart, shared_preferences
**Scope:** 本次仅实现 Mock 模式,真实 SSE 对接后续迭代
---
## 阶段 0: 依赖与脚手架
### Task 0: 添加依赖
**Files:**
- Modify: `apps/pubspec.yaml`
**Step 1: 添加依赖**
```yaml
dependencies:
shared_preferences: ^2.2.2
json_annotation: ^4.8.1
dev_dependencies:
json_serializable: ^6.7.1
build_runner: ^2.4.8
```
**Step 2: 安装依赖**
Run: `cd apps && flutter pub get`
Expected: 依赖安装成功
**Step 3: 创建目录结构**
Run: `mkdir -p apps/lib/features/chat/{data/{models,tools,services,ai,repositories},ui/widgets}`
**Step 4: Commit**
```bash
git add apps/pubspec.yaml apps/pubspec.lock
git commit -m "chore(chat): add json_annotation and shared_preferences deps"
```
---
## 阶段 1: 基础框架
### Task 1: 创建 AG-UI 事件模型
**Files:**
- Create: `apps/lib/features/chat/data/models/ag_ui_event.dart`
**Step 1: 写入 AG-UI 事件基类和子类型**
```dart
import 'package:json_annotation/json_annotation.dart';
import 'tool_result.dart'; // 引入 UiCard 定义
part 'ag_ui_event.g.dart';
/// Wire protocol 事件类型字符串(与服务端协议对齐)
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,
}
/// 事件类型映射工具
class AgUiEventTypeMapper {
static AgUiEventType fromWire(String wireType) {
switch (wireType) {
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;
}
}
static String toWire(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 'UNKNOWN';
}
}
}
/// 基类事件
@JsonSerializable()
class AgUiEvent {
final AgUiEventType type;
final String? timestamp;
AgUiEvent({required this.type, this.timestamp});
factory AgUiEvent.fromJson(Map<String, dynamic> json) {
final wireType = json['type'] as String? ?? 'UNKNOWN';
final type = AgUiEventTypeMapper.fromWire(wireType);
switch (type) {
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.runStarted:
return RunStartedEvent.fromJson(json);
case AgUiEventType.runFinished:
return RunFinishedEvent.fromJson(json);
case AgUiEventType.runError:
return RunErrorEvent.fromJson(json);
case AgUiEventType.unknown:
default:
return UnknownAgUiEvent(raw: json);
}
}
Map<String, dynamic> toJson() => _$AgUiEventToJson(this);
}
/// 未知事件(容错处理)
@JsonSerializable()
class UnknownAgUiEvent extends AgUiEvent {
final Map<String, dynamic> raw;
UnknownAgUiEvent({required this.raw})
: super(type: AgUiEventType.unknown);
factory UnknownAgUiEvent.fromJson(Map<String, dynamic> json) =>
_$UnknownAgUiEventFromJson(json);
@override
Map<String, dynamic> toJson() => raw;
}
@JsonSerializable()
class RunStartedEvent extends AgUiEvent {
final String threadId;
final String runId;
RunStartedEvent({
required this.threadId,
required this.runId,
}) : super(type: AgUiEventType.runStarted);
factory RunStartedEvent.fromJson(Map<String, dynamic> json) =>
_$RunStartedEventFromJson(json);
}
@JsonSerializable()
class RunFinishedEvent extends AgUiEvent {
final String threadId;
final String runId;
RunFinishedEvent({
required this.threadId,
required this.runId,
}) : super(type: AgUiEventType.runFinished);
factory RunFinishedEvent.fromJson(Map<String, dynamic> json) =>
_$RunFinishedEventFromJson(json);
}
@JsonSerializable()
class RunErrorEvent extends AgUiEvent {
final String message;
final String? code;
RunErrorEvent({
required this.message,
this.code,
}) : super(type: AgUiEventType.runError);
factory RunErrorEvent.fromJson(Map<String, dynamic> json) =>
_$RunErrorEventFromJson(json);
}
@JsonSerializable()
class TextMessageStartEvent extends AgUiEvent {
final String messageId;
final String role;
TextMessageStartEvent({
required this.messageId,
required this.role,
}) : super(type: AgUiEventType.textMessageStart);
factory TextMessageStartEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageStartEventFromJson(json);
}
@JsonSerializable()
class TextMessageContentEvent extends AgUiEvent {
final String messageId;
final String delta;
TextMessageContentEvent({
required this.messageId,
required this.delta,
}) : super(type: AgUiEventType.textMessageContent);
factory TextMessageContentEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageContentEventFromJson(json);
}
@JsonSerializable()
class TextMessageEndEvent extends AgUiEvent {
final String messageId;
TextMessageEndEvent({required this.messageId})
: super(type: AgUiEventType.textMessageEnd);
factory TextMessageEndEvent.fromJson(Map<String, dynamic> json) =>
_$TextMessageEndEventFromJson(json);
}
@JsonSerializable()
class ToolCallStartEvent extends AgUiEvent {
final String toolCallId;
final String toolCallName;
final String? parentMessageId;
ToolCallStartEvent({
required this.toolCallId,
required this.toolCallName,
this.parentMessageId,
}) : super(type: AgUiEventType.toolCallStart);
factory ToolCallStartEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallStartEventFromJson(json);
}
@JsonSerializable()
class ToolCallArgsEvent extends AgUiEvent {
final String toolCallId;
final String delta;
ToolCallArgsEvent({
required this.toolCallId,
required this.delta,
}) : super(type: AgUiEventType.toolCallArgs);
factory ToolCallArgsEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallArgsEventFromJson(json);
}
@JsonSerializable()
class ToolCallEndEvent extends AgUiEvent {
final String toolCallId;
ToolCallEndEvent({required this.toolCallId})
: super(type: AgUiEventType.toolCallEnd);
factory ToolCallEndEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallEndEventFromJson(json);
}
@JsonSerializable()
class ToolCallResultEvent extends AgUiEvent {
final String messageId;
final String toolCallId;
final Map<String, dynamic> result;
final UiCard? ui;
ToolCallResultEvent({
required this.messageId,
required this.toolCallId,
required this.result,
this.ui,
}) : super(type: AgUiEventType.toolCallResult);
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallResultEventFromJson(json);
}
@JsonSerializable()
class ToolCallErrorEvent extends AgUiEvent {
final String toolCallId;
final String error;
final String? code;
ToolCallErrorEvent({
required this.toolCallId,
required this.error,
this.code,
}) : super(type: AgUiEventType.toolCallError);
factory ToolCallErrorEvent.fromJson(Map<String, dynamic> json) =>
_$ToolCallErrorEventFromJson(json);
}
// UiCard 定义在 tool_result.dart,通过 import 引入
// 此文件顶部需要: import 'tool_result.dart';
```
**Step 2: 生成代码**
Run: `cd apps && dart run build_runner build --delete-conflicting-outputs`
Expected: 生成 `ag_ui_event.g.dart`
**Step 3: Commit**
```bash
git add apps/lib/features/chat/data/models/ag_ui_event.dart
git commit -m "feat(chat): add AG-UI event models with wire protocol mapping"
```
---
### Task 2: 创建 Tool Result Schema 模型
**Files:**
- Create: `apps/lib/features/chat/data/models/tool_result.dart`
**Step 1: 写入 ToolResult 和 UiCard 模型**
```dart
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<String, dynamic> json) =>
_$ToolResultFromJson(json);
Map<String, dynamic> toJson() => _$ToolResultToJson(this);
}
/// UI 卡片 Schema(给 UI 渲染)
@JsonSerializable()
class UiCard {
@JsonKey(name: 'type')
final String cardType;
@JsonKey(name: 'version')
final String? schemaVersion;
final Map<String, dynamic> data;
final List<CardAction>? actions;
UiCard({
required this.cardType,
this.schemaVersion = 'v1',
required this.data,
this.actions,
});
factory UiCard.fromJson(Map<String, dynamic> json) =>
_$UiCardFromJson(json);
Map<String, dynamic> toJson() => _$UiCardToJson(this);
}
/// 卡片操作按钮
@JsonSerializable()
class CardAction {
final String type;
final String label;
final String? target;
final String? action;
CardAction({
required this.type,
required this.label,
this.target,
this.action,
});
factory CardAction.fromJson(Map<String, dynamic> json) =>
_$CardActionFromJson(json);
Map<String, dynamic> toJson() => _$CardActionToJson(this);
}
/// 日历卡片数据
@JsonSerializable()
class CalendarCardData {
final String id;
final String title;
final String? description;
final String startAt;
final String? endAt;
final String? timezone;
final String? location;
final String? color;
final String? sourceType;
CalendarCardData({
required this.id,
required this.title,
this.description,
required this.startAt,
this.endAt,
this.timezone,
this.location,
this.color,
this.sourceType,
});
factory CalendarCardData.fromJson(Map<String, dynamic> json) =>
_$CalendarCardDataFromJson(json);
Map<String, dynamic> toJson() => _$CalendarCardDataToJson(this);
}
```
**Step 2: 生成代码**
Run: `cd apps && dart run build_runner build --delete-conflicting-outputs`
**Step 3: Commit**
```bash
git add apps/lib/features/chat/data/models/tool_result.dart
git commit -m "feat(chat): add ToolResult and UiCard models"
```
---
### Task 3: 创建 ChatListItem 模型(独立于 home
**Files:**
- Create: `apps/lib/features/chat/data/models/chat_list_item.dart`
**Step 1: 写入 ChatListItem 模型**
```dart
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,
}) {
return TextMessageItem(
id: id ?? this.id,
content: content ?? this.content,
timestamp: timestamp ?? this.timestamp,
sender: sender ?? this.sender,
isStreaming: isStreaming ?? this.isStreaming,
);
}
}
/// 工具调用项(pending 状态)
class ToolCallItem extends ChatListItem {
@override
final String id;
final String callId;
final String toolName;
final Map<String, dynamic> args;
final ToolCallStatus status;
final String? errorMessage;
@override
final DateTime timestamp;
@override
final MessageSender sender;
ToolCallItem({
required this.id,
required this.callId,
required this.toolName,
required this.args,
required this.status,
this.errorMessage,
required this.timestamp,
required this.sender,
});
@override
ChatItemType get type => ChatItemType.toolCall;
ToolCallItem copyWith({
String? id,
String? callId,
String? toolName,
Map<String, dynamic>? args,
ToolCallStatus? status,
String? errorMessage,
DateTime? timestamp,
MessageSender? sender,
}) {
return 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;
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/data/models/chat_list_item.dart
git commit -m "feat(chat): add ChatListItem models in chat feature"
```
---
### Task 4: 创建 ToolRegistry
**Files:**
- Create: `apps/lib/features/chat/data/tools/tool_registry.dart`
**Step 1: 写入 ToolRegistry**
```dart
import 'dart:convert';
typedef ToolHandler = Future<Map<String, dynamic>> Function(
Map<String, dynamic> args,
);
class ToolDefinition {
final String name;
final String description;
final Map<String, dynamic> parameters;
final ToolHandler handler;
ToolDefinition({
required this.name,
required this.description,
required this.parameters,
required this.handler,
});
}
class ToolRegistry {
static final Map<String, ToolDefinition> _tools = {};
static bool _initialized = false;
static void initialize() {
if (_initialized) return;
_tools['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<Map<String, dynamic>> _handleCreateCalendarEvent(
Map<String, dynamic> args,
) async {
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
return {
'eventId': eventId,
'ok': true,
'message': '日程已创建',
'title': args['title'],
'description': args['description'],
'startAt': args['startAt'],
'endAt': args['endAt'],
'timezone': args['timezone'] ?? 'Asia/Shanghai',
'location': args['location'],
'color': '#4F46E5',
'sourceType': 'agentGenerated',
};
}
static ToolDefinition? getTool(String name) => _tools[name];
static List<ToolDefinition> getAllTools() => _tools.values.toList();
static Future<Map<String, dynamic>> execute(
String toolName,
Map<String, dynamic> args,
) async {
final tool = _tools[toolName];
if (tool == null) {
throw ToolNotFoundException('Tool not found: $toolName');
}
return tool.handler(args);
}
/// 校验工具参数
static ToolValidationResult validateArgs(
String toolName,
Map<String, dynamic> args,
) {
final tool = _tools[toolName];
if (tool == null) {
return ToolValidationResult(
ok: false,
error: 'Tool not found: $toolName',
);
}
final required = tool.parameters['required'] as List<dynamic>? ?? [];
final missing = <String>[];
for (final field in required) {
if (!args.containsKey(field) || args[field] == null) {
missing.add(field as String);
}
}
if (missing.isNotEmpty) {
return ToolValidationResult(
ok: false,
error: 'Missing required fields: ${missing.join(', ')}',
);
}
return ToolValidationResult(ok: true);
}
}
class ToolNotFoundException implements Exception {
final String message;
ToolNotFoundException(this.message);
@override
String toString() => 'ToolNotFoundException: $message';
}
class ToolValidationResult {
final bool ok;
final String? error;
ToolValidationResult({required this.ok, this.error});
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/data/tools/tool_registry.dart
git commit -m "feat(chat): add ToolRegistry with validation"
```
---
## 阶段 2: Mock 实现
### Task 5: 创建 AiDecisionEngine 规则引擎
**Files:**
- Create: `apps/lib/features/chat/data/ai/ai_decision_engine.dart`
**Step 1: 写入 AiDecisionEngine**
```dart
import 'dart:convert';
enum Intent {
createEvent,
searchEvent,
unknown,
}
class AiDecisionEngine {
/// 意图匹配规则(顺序敏感:searchEvent 优先级高于 createEvent
static final List<(_IntentPattern, 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<String, dynamic>? tryExtractEventArgs(String text) {
// 只有明确的创建意图才提取参数
if (matchIntent(text) != Intent.createEvent) {
return null;
}
final args = <String, dynamic>{};
// 提取标题
final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text);
if (titleMatch != null) {
args['title'] = titleMatch.group(1)?.trim() ?? text;
} else {
// 如果没有"提醒"前缀,使用整段文本作为标题(但至少需要有时间)
if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) {
args['title'] = text.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '').trim();
}
}
// 必须有标题才返回
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';
}
// 必须有 startAt 才返回
if (!args.containsKey('startAt')) {
return null;
}
return args;
}
bool shouldTriggerToolCall(String text) {
final intent = matchIntent(text);
return intent == Intent.createEvent;
}
Map<String, dynamic>? getToolCallArgs(String text) {
if (!shouldTriggerToolCall(text)) return null;
return tryExtractEventArgs(text);
}
/// 检查是否为强制触发(调试用)
/// 格式:#tool:create_calendar_event {"title": "test"}
ForceTriggerResult? tryForceTrigger(String text) {
final match = RegExp(r'#tool:(\w+)\s*(\{.*\})?').firstMatch(text);
if (match == null) return null;
final toolName = match.group(1);
final argsJson = match.group(2);
Map<String, dynamic>? args;
if (argsJson != null) {
try {
args = jsonDecode(argsJson) as Map<String, dynamic>;
} catch (_) {
args = {};
}
}
return ForceTriggerResult(toolName: toolName!, args: args ?? {});
}
}
class ForceTriggerResult {
final String toolName;
final Map<String, dynamic> args;
ForceTriggerResult({required this.toolName, required this.args});
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/data/ai/ai_decision_engine.dart
git commit -m "feat(chat): add AiDecisionEngine with force trigger support"
```
---
### Task 6: 创建 AgUiService Mock 实现
**Files:**
- Create: `apps/lib/features/chat/data/services/ag_ui_service.dart`
**Step 1: 写入 AgUiService**
```dart
import 'dart:async';
import 'dart:convert';
import 'package:social_app/core/config/env.dart';
import '../models/ag_ui_event.dart';
import '../models/tool_result.dart';
import '../tools/tool_registry.dart';
import '../ai/ai_decision_engine.dart';
typedef EventCallback = void Function(AgUiEvent event);
class AgUiService {
EventCallback onEvent; // 非 final,允许后续绑定
final AiDecisionEngine _decisionEngine = AiDecisionEngine();
String? _currentRunId;
AgUiService({required this.onEvent}) {
ToolRegistry.initialize();
}
Future<void> sendMessage(String content) async {
if (Env.isMockApi) {
await _mockEventStream(content);
} else {
// 真实模式暂未实现,降级到 mock 并提示
onEvent(RunErrorEvent(
message: 'Real mode not implemented, please enable MOCK_API=true',
code: 'NOT_IMPLEMENTED',
));
}
}
Future<void> _mockEventStream(String content) async {
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
_currentRunId = runId;
// RunStarted
onEvent(RunStartedEvent(
threadId: 'thread_default',
runId: runId,
));
// 模拟 AI 思考延迟
await Future.delayed(const Duration(milliseconds: 500));
// 检查强制触发
final forceTrigger = _decisionEngine.tryForceTrigger(content);
if (forceTrigger != null) {
await _mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
} else if (_decisionEngine.shouldTriggerToolCall(content)) {
await _mockToolCallFlow(content);
}
// AI 回复文本
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(TextMessageStartEvent(
messageId: messageId,
role: 'assistant',
));
final replies = _generateReplies(content);
for (final chunk in replies) {
await Future.delayed(const Duration(milliseconds: 100));
onEvent(TextMessageContentEvent(
messageId: messageId,
delta: chunk,
));
}
await Future.delayed(const Duration(milliseconds: 100));
onEvent(TextMessageEndEvent(messageId: messageId));
// RunFinished
onEvent(RunFinishedEvent(
threadId: 'thread_default',
runId: runId,
));
}
Future<void> _mockToolCallFlow(String content) async {
final args = _decisionEngine.getToolCallArgs(content) ?? {};
await _mockToolCallFlowWithArgs('create_calendar_event', args);
}
Future<void> _mockToolCallFlowWithArgs(
String toolName,
Map<String, dynamic> args,
) async {
final toolCallId = 'call_${DateTime.now().millisecondsSinceEpoch}';
// ToolCallStart
onEvent(ToolCallStartEvent(
toolCallId: toolCallId,
toolCallName: toolName,
));
// ToolCallArgs (使用标准 JSON)
final argsJson = jsonEncode(args);
await Future.delayed(const Duration(milliseconds: 200));
onEvent(ToolCallArgsEvent(
toolCallId: toolCallId,
delta: argsJson,
));
// ToolCallEnd
await Future.delayed(const Duration(milliseconds: 100));
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;
}
// 执行工具
await Future.delayed(const Duration(milliseconds: 300));
try {
final result = await ToolRegistry.execute(toolName, args);
// 构建 UI Card
final uiCard = _buildUiCard(toolName, result);
// ToolCallResult
onEvent(ToolCallResultEvent(
messageId: _currentRunId ?? '',
toolCallId: toolCallId,
result: result,
ui: uiCard,
));
} catch (e) {
onEvent(ToolCallErrorEvent(
toolCallId: toolCallId,
error: e.toString(),
code: 'EXECUTION_ERROR',
));
}
}
UiCard _buildUiCard(String toolName, Map<String, dynamic> result) {
if (toolName == 'create_calendar_event') {
return UiCard(
cardType: 'calendar_card.v1',
schemaVersion: 'v1',
data: {
'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'],
},
actions: [
CardAction(type: 'open', label: '打开', target: 'calendar/${result['eventId']}'),
CardAction(type: 'edit', label: '编辑', action: 'edit_event'),
],
);
}
// 默认返回通用卡片
return UiCard(
cardType: 'generic_card.v1',
data: result,
);
}
/// 生成回复文本(返回 List<String> 支持流式)
List<String> _generateReplies(String content) {
if (_decisionEngine.shouldTriggerToolCall(content)) {
return ['好的,', '我已为你创建日程。'];
}
return ['收到,', '有什么可以帮你的?'];
}
Future<void> reconnect() async {
// TODO: 实现重连逻辑
}
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/data/services/ag_ui_service.dart
git commit -m "feat(chat): add AgUiService with mock event stream"
```
---
## 阶段 3: UI 渲染
### Task 7: 创建 UiSchemaRenderer
**Files:**
- Create: `apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart`
**Step 1: 写入 UiSchemaRenderer(使用设计 token**
```dart
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(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 4,
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(2),
),
),
),
const SizedBox(height: AppSpacing.md),
// Source type
Row(
children: [
const Icon(Icons.auto_awesome, size: 14, color: AppColors.slate500),
const SizedBox(width: AppSpacing.xs),
Text(
'AI生成',
style: const TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
),
const SizedBox(height: AppSpacing.xs),
// Time
Text(
_formatTime(data.startAt, data.endAt),
style: const TextStyle(fontSize: 12, color: AppColors.slate500),
),
const SizedBox(height: AppSpacing.sm),
// Title
Text(
data.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
if (data.description != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
data.description!,
style: const TextStyle(fontSize: 14, color: AppColors.slate600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (data.location != null) ...[
const SizedBox(height: AppSpacing.sm),
Row(
children: [
const Icon(Icons.location_on_outlined, size: 14, color: AppColors.slate500),
const SizedBox(width: AppSpacing.xs),
Text(
data.location!,
style: const TextStyle(fontSize: 13, color: AppColors.slate600),
),
],
),
],
// Actions
if (card.actions != null && card.actions!.isNotEmpty) ...[
const SizedBox(height: AppSpacing.md),
const Divider(height: 1),
const SizedBox(height: AppSpacing.sm),
Row(
children: card.actions!.map((action) {
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: TextButton(
onPressed: () => _handleAction(action),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
),
child: Text(action.label),
),
);
}).toList(),
),
],
],
),
);
}
static Widget _renderErrorCard(UiCard card) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.red50,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.red200),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: AppColors.red600),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
card.data['message'] ?? '发生错误',
style: const TextStyle(color: AppColors.red600),
),
),
],
),
);
}
static Widget _renderUnknownCard(UiCard card) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.slate100,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Unknown card type: ${card.cardType}',
style: const TextStyle(color: AppColors.slate600),
),
const SizedBox(height: AppSpacing.sm),
SelectableText(
card.data.toString(),
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
],
),
);
}
static String _formatTime(String startAt, String? endAt) {
final start = DateTime.parse(startAt);
final startStr = '${start.month}${start.day}${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}';
if (endAt != null) {
final end = DateTime.parse(endAt);
final endStr = '${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}';
return '$startStr - $endStr';
}
return startStr;
}
static void _handleAction(CardAction action) {
// TODO: 实现 action 处理
}
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart
git commit -m "feat(chat): add UiSchemaRenderer with design tokens"
```
---
## 阶段 4: 集成
### Task 8: 创建 ChatBloc(状态管理)
**Files:**
- Create: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
**Step 1: 写入 ChatBloc**
```dart
import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
import '../../data/models/tool_result.dart';
import '../../data/services/ag_ui_service.dart';
class ChatBloc extends Cubit<ChatState> {
final AgUiService _service;
final Map<String, String> _pendingArgsBuffer = {};
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: false));
break;
case AgUiEventType.runFinished:
emit(state.copyWith(isLoading: false));
break;
case AgUiEventType.runError:
final e = event as RunErrorEvent;
emit(state.copyWith(
isLoading: false,
error: e.message,
));
break;
case AgUiEventType.textMessageStart:
_handleTextMessageStart(event as TextMessageStartEvent);
break;
case AgUiEventType.textMessageContent:
_handleTextMessageContent(event as TextMessageContentEvent);
break;
case AgUiEventType.textMessageEnd:
_handleTextMessageEnd(event as TextMessageEndEvent);
break;
case AgUiEventType.toolCallStart:
_handleToolCallStart(event as ToolCallStartEvent);
break;
case AgUiEventType.toolCallArgs:
_handleToolCallArgs(event as ToolCallArgsEvent);
break;
case AgUiEventType.toolCallEnd:
_handleToolCallEnd(event as ToolCallEndEvent);
break;
case AgUiEventType.toolCallResult:
_handleToolCallResult(event as ToolCallResultEvent);
break;
case AgUiEventType.toolCallError:
_handleToolCallError(event as ToolCallErrorEvent);
break;
default:
break;
}
}
void _handleTextMessageStart(TextMessageStartEvent event) {
final newItems = [...state.items];
newItems.add(TextMessageItem(
id: event.messageId,
content: '',
timestamp: DateTime.now(),
sender: MessageSender.ai,
isStreaming: true,
));
emit(state.copyWith(items: newItems, currentMessageId: event.messageId));
}
void _handleTextMessageContent(TextMessageContentEvent event) {
final index = state.items.indexWhere((item) => item.id == event.messageId);
if (index >= 0) {
final item = state.items[index] as TextMessageItem;
final newItems = [...state.items];
newItems[index] = item.copyWith(
content: item.content + event.delta,
isStreaming: true,
);
emit(state.copyWith(items: newItems));
}
}
void _handleTextMessageEnd(TextMessageEndEvent event) {
final index = state.items.indexWhere((item) => item.id == event.messageId);
if (index >= 0) {
final item = state.items[index] as TextMessageItem;
final newItems = [...state.items];
newItems[index] = item.copyWith(isStreaming: false);
emit(state.copyWith(items: newItems, currentMessageId: null));
}
}
void _handleToolCallStart(ToolCallStartEvent event) {
final newItems = [...state.items];
newItems.add(ToolCallItem(
id: event.toolCallId,
callId: event.toolCallId,
toolName: event.toolCallName,
args: {},
status: ToolCallStatus.pending,
timestamp: DateTime.now(),
sender: MessageSender.ai,
));
_pendingArgsBuffer[event.toolCallId] = '';
emit(state.copyWith(items: newItems));
}
void _handleToolCallArgs(ToolCallArgsEvent event) {
_pendingArgsBuffer[event.toolCallId] =
(_pendingArgsBuffer[event.toolCallId] ?? '') + event.delta;
}
void _handleToolCallEnd(ToolCallEndEvent event) {
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
if (index >= 0) {
final item = state.items[index] as ToolCallItem;
final argsJson = _pendingArgsBuffer[event.toolCallId] ?? '{}';
Map<String, dynamic> args;
try {
args = jsonDecode(argsJson) as Map<String, dynamic>;
} catch (_) {
args = {};
}
final newItems = [...state.items];
newItems[index] = item.copyWith(
args: args,
status: ToolCallStatus.executing,
);
_pendingArgsBuffer.remove(event.toolCallId);
emit(state.copyWith(items: newItems));
}
}
void _handleToolCallResult(ToolCallResultEvent event) {
if (event.ui == null) return;
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
if (index >= 0) {
final newItems = [...state.items];
// 移除 pending tool call,添加 result card
newItems.removeAt(index);
newItems.add(ToolResultItem(
id: 'result_${event.toolCallId}',
callId: event.toolCallId,
uiCard: event.ui!,
timestamp: DateTime.now(),
sender: MessageSender.ai,
));
emit(state.copyWith(items: newItems));
}
}
void _handleToolCallError(ToolCallErrorEvent event) {
final index = state.items.indexWhere((item) => item.id == event.toolCallId);
if (index >= 0) {
final item = state.items[index] as ToolCallItem;
final newItems = [...state.items];
newItems[index] = item.copyWith(
status: ToolCallStatus.error,
errorMessage: event.error,
);
emit(state.copyWith(items: newItems, error: event.error));
}
}
Future<void> sendMessage(String content) async {
if (content.trim().isEmpty) return;
// 添加用户消息
final userMessage = TextMessageItem(
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
content: content,
timestamp: DateTime.now(),
sender: MessageSender.user,
);
emit(state.copyWith(
items: [...state.items, userMessage],
isLoading: true,
error: null,
));
await _service.sendMessage(content);
}
void clearError() {
emit(state.copyWith(error: null));
}
}
class ChatState {
final List<ChatListItem> items;
final bool isLoading;
final String? currentMessageId;
final String? error;
const ChatState({
this.items = const [],
this.isLoading = false,
this.currentMessageId,
this.error,
});
static const _unset = Object();
ChatState copyWith({
List<ChatListItem>? items,
bool? isLoading,
Object? currentMessageId = _unset,
Object? error = _unset,
}) {
return ChatState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
currentMessageId: currentMessageId == _unset
? this.currentMessageId
: currentMessageId as String?,
error: error == _unset
? this.error
: error as String?,
);
}
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/presentation/bloc/chat_bloc.dart
git commit -m "feat(chat): add ChatBloc for state management"
```
---
### Task 9: 更新 HomeScreen 集成 ChatBloc
**Files:**
- Modify: `apps/lib/features/home/ui/screens/home_screen.dart`
**Step 1: 添加 ChatBloc 集成**
在现有 HomeScreen 中添加 BlocProvider 和事件处理逻辑,保持现有 UI 结构。
```dart
// 在文件顶部添加 import
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
import 'package:social_app/shared/widgets/toast.dart';
// 在 build 方法中包裹 BlocProvider
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatBloc(),
child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
},
builder: (context, state) {
return Scaffold(
// ... 现有 UI
);
},
),
);
}
// 更新 _buildChatArea 方法
Widget _buildChatArea(BuildContext context) {
final chatState = context.watch<ChatBloc>().state;
final items = chatState.items;
return ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
itemCount: items.length + (chatState.isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (chatState.isLoading && index == items.length) {
return _buildLoadingIndicator();
}
final item = items[index];
switch (item.type) {
case ChatItemType.message:
final msg = item as TextMessageItem;
return ChatBubble(
sender: msg.sender == MessageSender.user
? MessageSender.user
: MessageSender.ai,
content: msg.content,
timestamp: msg.timestamp,
);
case ChatItemType.toolCall:
final toolCall = item as ToolCallItem;
return _buildToolCallItem(toolCall);
case ChatItemType.toolResult:
final result = item as ToolResultItem;
return ChatBubble(
sender: MessageSender.ai,
content: '',
timestamp: result.timestamp,
extraContent: UiSchemaRenderer.render(result.uiCard),
);
}
},
);
}
Widget _buildToolCallItem(ToolCallItem item) {
IconData icon;
String text;
Color color;
switch (item.status) {
case ToolCallStatus.pending:
icon = Icons.hourglass_empty;
text = '正在创建日程...';
color = AppColors.slate500;
break;
case ToolCallStatus.executing:
icon = Icons.sync;
text = '正在执行...';
color = AppColors.blue500;
break;
case ToolCallStatus.completed:
icon = Icons.check_circle;
text = '已完成';
color = AppColors.green500;
break;
case ToolCallStatus.error:
icon = Icons.error;
text = item.errorMessage ?? '执行失败';
color = AppColors.red500;
break;
}
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: AppSpacing.sm),
Text(text, style: TextStyle(color: color)),
],
),
);
}
// 更新发送消息方法
Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim();
if (content.isEmpty) return;
_messageController.clear();
context.read<ChatBloc>().sendMessage(content);
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/home/ui/screens/home_screen.dart
git commit -m "feat(chat): integrate ChatBloc into HomeScreen"
```
---
## 阶段 5: 持久化
### Task 10: 创建 ChatHistoryRepository
**Files:**
- Create: `apps/lib/features/chat/data/repositories/chat_history_repository.dart`
**Step 1: 写入 ChatHistoryRepository**
```dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ChatHistoryRepository {
static const String _messagesKey = 'chat_messages_';
static const String _lastRunIdKey = 'chat_last_run_id_';
static const String _calendarEventsKey = 'calendar_events';
final String threadId;
ChatHistoryRepository({this.threadId = 'default'});
String get _msgKey => '$_messagesKey$threadId';
String get _runIdKey => '$_lastRunIdKey$threadId';
Future<void> saveMessages(List<Map<String, dynamic>> messages) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_msgKey, jsonEncode(messages));
}
Future<List<Map<String, dynamic>>?> loadMessages() async {
final prefs = await SharedPreferences.getInstance();
final data = prefs.getString(_msgKey);
if (data == null) return null;
final list = jsonDecode(data) as List;
return list.cast<Map<String, dynamic>>();
}
Future<void> saveLastRunId(String runId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_runIdKey, runId);
}
Future<String?> loadLastRunId() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_runIdKey);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_msgKey);
await prefs.remove(_runIdKey);
}
// Calendar events 独立存储
Future<void> saveCalendarEvent(Map<String, dynamic> event) async {
final prefs = await SharedPreferences.getInstance();
final eventsJson = prefs.getString(_calendarEventsKey);
final events = eventsJson != null
? jsonDecode(eventsJson) as Map<String, dynamic>
: <String, dynamic>{};
events[event['id']] = event;
await prefs.setString(_calendarEventsKey, jsonEncode(events));
}
Future<List<Map<String, dynamic>>> loadCalendarEvents() async {
final prefs = await SharedPreferences.getInstance();
final eventsJson = prefs.getString(_calendarEventsKey);
if (eventsJson == null) return [];
final events = jsonDecode(eventsJson) as Map<String, dynamic>;
return events.values.cast<Map<String, dynamic>>().toList();
}
}
```
**Step 2: Commit**
```bash
git add apps/lib/features/chat/data/repositories/chat_history_repository.dart
git commit -m "feat(chat): add ChatHistoryRepository with shared_preferences"
```
---
## 测试验证
### Task 11: 编写单元测试
**Files:**
- Create: `apps/test/features/chat/ai_decision_engine_test.dart`
- Create: `apps/test/features/chat/tool_registry_test.dart`
- Create: `apps/test/features/chat/ag_ui_event_test.dart`
**Step 1: 写入 AiDecisionEngine 测试**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
void main() {
group('AiDecisionEngine', () {
late AiDecisionEngine engine;
setUp(() {
engine = AiDecisionEngine();
});
group('matchIntent', () {
test('should match searchEvent intent for "今天有什么日程"', () {
final intent = engine.matchIntent('今天有什么日程');
expect(intent, Intent.searchEvent);
});
test('should match createEvent intent for "提醒我明天开会"', () {
final intent = engine.matchIntent('提醒我明天开会');
expect(intent, Intent.createEvent);
});
test('should match createEvent intent for "明天10点有约"', () {
final intent = engine.matchIntent('明天10点有约');
expect(intent, Intent.createEvent);
});
test('should return unknown for random text', () {
final intent = engine.matchIntent('你好');
expect(intent, Intent.unknown);
});
});
group('shouldTriggerToolCall', () {
test('should not trigger tool call for random text', () {
final shouldTrigger = engine.shouldTriggerToolCall('你好');
expect(shouldTrigger, false);
});
test('should not trigger tool call for search intent', () {
final shouldTrigger = engine.shouldTriggerToolCall('今天有什么日程');
expect(shouldTrigger, false);
});
test('should trigger tool call for event creation text', () {
final shouldTrigger = engine.shouldTriggerToolCall('提醒我明天开会');
expect(shouldTrigger, true);
});
});
group('tryExtractEventArgs', () {
test('should extract event args from text with time', () {
final args = engine.tryExtractEventArgs('提醒我明天10点开会');
expect(args, isNotNull);
expect(args!['title'], contains('开会'));
expect(args['startAt'], isNotNull);
});
test('should return null for non-event text', () {
final args = engine.tryExtractEventArgs('你好');
expect(args, isNull);
});
test('should return null for search intent text', () {
final args = engine.tryExtractEventArgs('今天有什么日程');
expect(args, isNull);
});
});
group('tryForceTrigger', () {
test('should parse force trigger format', () {
final result = engine.tryForceTrigger('#tool:create_calendar_event {"title": "test"}');
expect(result, isNotNull);
expect(result!.toolName, 'create_calendar_event');
expect(result.args['title'], 'test');
});
test('should return null for normal text', () {
final result = engine.tryForceTrigger('提醒我开会');
expect(result, isNull);
});
});
});
}
```
**Step 2: 写入 ToolRegistry 测试**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
void main() {
group('ToolRegistry', () {
setUp(() {
ToolRegistry.initialize();
});
test('should have create_calendar_event tool registered', () {
final tool = ToolRegistry.getTool('create_calendar_event');
expect(tool, isNotNull);
expect(tool!.name, 'create_calendar_event');
});
test('should validate required args', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {});
expect(result.ok, false);
expect(result.error, contains('title'));
});
test('should pass validation with required args', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {
'title': 'Test Event',
'startAt': '2026-03-01T10:00:00Z',
});
expect(result.ok, true);
});
test('should execute tool and return result', () async {
final result = await ToolRegistry.execute('create_calendar_event', {
'title': 'Test Event',
'startAt': '2026-03-01T10:00:00Z',
});
expect(result['ok'], true);
expect(result['eventId'], isNotNull);
expect(result['title'], 'Test Event');
});
test('should throw for unknown tool', () async {
expect(
() => ToolRegistry.execute('unknown_tool', {}),
throwsA(isA<ToolNotFoundException>()),
);
});
});
}
```
**Step 3: 写入 AgUiEvent 测试**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
void main() {
group('AgUiEventTypeMapper', () {
test('should map wire type to enum correctly', () {
expect(
AgUiEventTypeMapper.fromWire('TEXT_MESSAGE_START'),
AgUiEventType.textMessageStart,
);
expect(
AgUiEventTypeMapper.fromWire('TOOL_CALL_START'),
AgUiEventType.toolCallStart,
);
});
test('should return unknown for unknown wire type', () {
expect(
AgUiEventTypeMapper.fromWire('UNKNOWN_TYPE'),
AgUiEventType.unknown,
);
});
test('should map enum to wire type correctly', () {
expect(
AgUiEventTypeMapper.toWire(AgUiEventType.textMessageStart),
'TEXT_MESSAGE_START',
);
});
});
group('AgUiEvent.fromJson', () {
test('should parse TextMessageStartEvent', () {
final json = {
'type': 'TEXT_MESSAGE_START',
'messageId': 'msg_1',
'role': 'assistant',
};
final event = AgUiEvent.fromJson(json);
expect(event, isA<TextMessageStartEvent>());
final e = event as TextMessageStartEvent;
expect(e.messageId, 'msg_1');
expect(e.role, 'assistant');
});
test('should parse ToolCallResultEvent with ui', () {
final json = {
'type': 'TOOL_CALL_RESULT',
'messageId': 'msg_1',
'toolCallId': 'call_1',
'result': {'ok': true},
'ui': {
'type': 'calendar_card.v1',
'data': {'id': 'evt_1'},
},
};
final event = AgUiEvent.fromJson(json);
expect(event, isA<ToolCallResultEvent>());
final e = event as ToolCallResultEvent;
expect(e.toolCallId, 'call_1');
expect(e.ui, isNotNull);
expect(e.ui!.cardType, 'calendar_card.v1');
});
test('should return UnknownAgUiEvent for unknown type', () {
final json = {
'type': 'FUTURE_EVENT_TYPE',
'someField': 'value',
};
final event = AgUiEvent.fromJson(json);
expect(event, isA<UnknownAgUiEvent>());
expect(event.type, AgUiEventType.unknown);
});
});
}
```
**Step 4: 写入 ChatBloc 测试**
**Files:**
- Create: `apps/test/features/chat/chat_bloc_test.dart`
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
void main() {
group('ChatBloc', () {
late ChatBloc bloc;
late List<AgUiEvent> capturedEvents;
setUp(() {
capturedEvents = [];
bloc = ChatBloc(); // 使用默认 mock service
});
tearDown(() {
bloc.close();
});
test('initial state is empty', () {
expect(bloc.state.items, isEmpty);
expect(bloc.state.isLoading, false);
expect(bloc.state.error, isNull);
});
group('sendMessage', () {
test('should add user message to state', () async {
await bloc.sendMessage('你好');
expect(bloc.state.items.length, greaterThan(0));
final userMsg = bloc.state.items.first;
expect(userMsg, isA<TextMessageItem>());
expect((userMsg as TextMessageItem).sender, MessageSender.user);
expect(userMsg.content, '你好');
});
test('should set isLoading to true during send', () async {
final states = <ChatState>[];
bloc.stream.listen(states.add);
await bloc.sendMessage('你好');
// 至少有一个状态是 loading
expect(states.any((s) => s.isLoading), true);
});
test('should trigger tool call for event creation text', () async {
await bloc.sendMessage('提醒我明天10点开会');
// 应该有 tool result item
await Future.delayed(const Duration(seconds: 2));
final hasToolResult = bloc.state.items.any(
(item) => item.type == ChatItemType.toolResult,
);
expect(hasToolResult, true);
});
test('should handle tool call error gracefully', () async {
// 使用强制触发一个会失败的工具
await bloc.sendMessage('#tool:unknown_tool {}');
await Future.delayed(const Duration(seconds: 1));
// 应该有错误状态或错误消息
expect(
bloc.state.error != null ||
bloc.state.items.any((i) =>
i.type == ChatItemType.toolCall &&
(i as ToolCallItem).status == ToolCallStatus.error
),
true,
);
});
});
group('clearError', () {
test('should clear error state', () async {
await bloc.sendMessage('#tool:unknown_tool {}');
await Future.delayed(const Duration(milliseconds: 500));
if (bloc.state.error != null) {
bloc.clearError();
expect(bloc.state.error, isNull);
}
});
});
});
}
```
**Step 5: 写入 AgUiService 测试**
**Files:**
- Create: `apps/test/features/chat/ag_ui_service_test.dart`
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
void main() {
group('AgUiService', () {
late AgUiService service;
late List<AgUiEvent> capturedEvents;
setUp(() {
capturedEvents = [];
service = AgUiService(onEvent: capturedEvents.add);
});
group('sendMessage', () {
test('should emit RunStarted event first', () async {
await service.sendMessage('你好');
expect(capturedEvents.first, isA<RunStartedEvent>());
});
test('should emit RunFinished event last', () async {
await service.sendMessage('你好');
expect(capturedEvents.last, isA<RunFinishedEvent>());
});
test('should emit text message events for normal text', () async {
await service.sendMessage('你好');
final hasStart = capturedEvents.any((e) => e is TextMessageStartEvent);
final hasContent = capturedEvents.any((e) => e is TextMessageContentEvent);
final hasEnd = capturedEvents.any((e) => e is TextMessageEndEvent);
expect(hasStart, true);
expect(hasContent, true);
expect(hasEnd, true);
});
test('should emit tool call events for event creation text', () async {
await service.sendMessage('提醒我明天开会');
final hasToolStart = capturedEvents.any((e) => e is ToolCallStartEvent);
final hasToolResult = capturedEvents.any((e) => e is ToolCallResultEvent);
expect(hasToolStart, true);
expect(hasToolResult, true);
});
test('should parse force trigger format', () async {
await service.sendMessage('#tool:create_calendar_event {"title":"Test"}');
final toolStart = capturedEvents.whereType<ToolCallStartEvent>().firstOrNull;
expect(toolStart, isNotNull);
expect(toolStart!.toolCallName, 'create_calendar_event');
});
test('should emit ToolCallErrorEvent for validation failure', () async {
// 缺少必填字段
await service.sendMessage('#tool:create_calendar_event {}');
final hasError = capturedEvents.any((e) => e is ToolCallErrorEvent);
expect(hasError, true);
});
});
});
}
```
**Step 6: 写入 UiSchemaRenderer 测试**
**Files:**
- Create: `apps/test/features/chat/ui_schema_renderer_test.dart`
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/ui/widgets/ui_schema_renderer.dart';
import 'package:social_app/features/chat/data/models/tool_result.dart';
void main() {
group('UiSchemaRenderer', () {
group('render', () {
testWidgets('should render calendar_card.v1', (tester) async {
final card = UiCard(
cardType: 'calendar_card.v1',
data: {
'id': 'evt_1',
'title': 'Test Event',
'startAt': '2026-03-01T10:00:00Z',
'color': '#4F46E5',
},
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UiSchemaRenderer.render(card),
),
),
);
expect(find.text('Test Event'), findsOneWidget);
expect(find.text('AI生成'), findsOneWidget);
});
testWidgets('should render error_card.v1', (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('should render unknown card with fallback', (tester) async {
final card = UiCard(
cardType: 'future_card_type.v99',
data: {'foo': 'bar'},
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UiSchemaRenderer.render(card),
),
),
);
expect(find.textContaining('Unknown card type'), findsOneWidget);
expect(find.textContaining('foo'), findsOneWidget);
});
testWidgets('should render calendar card with actions', (tester) async {
final card = UiCard(
cardType: 'calendar_card.v1',
data: {
'id': 'evt_1',
'title': 'Meeting',
'startAt': '2026-03-01T10:00:00Z',
},
actions: [
CardAction(type: 'open', label: '打开'),
CardAction(type: 'edit', label: '编辑'),
],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: UiSchemaRenderer.render(card),
),
),
);
expect(find.text('打开'), findsOneWidget);
expect(find.text('编辑'), findsOneWidget);
});
});
});
}
```
**Step 7: 运行所有测试**
Run: `cd apps && flutter test test/features/chat/`
Expected: All tests PASS
**Step 8: Commit**
```bash
git add apps/test/features/chat/
git commit -m "test(chat): add comprehensive unit tests for bloc, service and renderer"
```
---
## 完成验证
**验证命令:**
```bash
# 运行所有测试
cd apps && flutter test
# Lint
cd apps && flutter analyze
# 构建
cd apps && flutter build apk --debug
```
---
## 文件清单
```
apps/lib/features/chat/
├── data/
│ ├── models/
│ │ ├── ag_ui_event.dart
│ │ ├── tool_result.dart
│ │ └── chat_list_item.dart
│ ├── tools/
│ │ └── tool_registry.dart
│ ├── services/
│ │ └── ag_ui_service.dart
│ ├── ai/
│ │ └── ai_decision_engine.dart
│ └── repositories/
│ └── chat_history_repository.dart
└── presentation/
├── bloc/
│ └── chat_bloc.dart
└── ui/
└── widgets/
└── ui_schema_renderer.dart
apps/test/features/chat/
├── ai_decision_engine_test.dart
├── tool_registry_test.dart
├── ag_ui_event_test.dart
├── chat_bloc_test.dart
├── ag_ui_service_test.dart
└── ui_schema_renderer_test.dart
```
---
**Plan v1.1 complete.** 可选择执行方式:
1. **Subagent-Driven(本会话)** - 每个任务由 subagent 执行,快速迭代
2. **Parallel Session(单独会话)** - 在新会话中使用 executing-plans,分批执行
需要我开始复审这个修订版计划吗?