Files
social-app/docs/plans/2026-02-28-ag-ui-chat-implementation-plan.md
T
qzl c3192a2431 feat(chat): add ChatBubble widget and mock data for home screen
- Add ChatBubble reusable widget for chat messages
- Add HomeMockData for chat list mock data
- Add HomeScreen widget tests
- Add AG-UI chat design and implementation plan docs
- Add friendship design docs
- Ignore backend/logs directory
2026-02-28 14:47:33 +08:00

2464 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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,分批执行
需要我开始复审这个修订版计划吗?