# 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 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 toJson() => _$AgUiEventToJson(this); } /// 未知事件(容错处理) @JsonSerializable() class UnknownAgUiEvent extends AgUiEvent { final Map raw; UnknownAgUiEvent({required this.raw}) : super(type: AgUiEventType.unknown); factory UnknownAgUiEvent.fromJson(Map json) => _$UnknownAgUiEventFromJson(json); @override Map 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 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 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 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 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 json) => _$TextMessageContentEventFromJson(json); } @JsonSerializable() class TextMessageEndEvent extends AgUiEvent { final String messageId; TextMessageEndEvent({required this.messageId}) : super(type: AgUiEventType.textMessageEnd); factory TextMessageEndEvent.fromJson(Map 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 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 json) => _$ToolCallArgsEventFromJson(json); } @JsonSerializable() class ToolCallEndEvent extends AgUiEvent { final String toolCallId; ToolCallEndEvent({required this.toolCallId}) : super(type: AgUiEventType.toolCallEnd); factory ToolCallEndEvent.fromJson(Map json) => _$ToolCallEndEventFromJson(json); } @JsonSerializable() class ToolCallResultEvent extends AgUiEvent { final String messageId; final String toolCallId; final Map result; final UiCard? ui; ToolCallResultEvent({ required this.messageId, required this.toolCallId, required this.result, this.ui, }) : super(type: AgUiEventType.toolCallResult); factory ToolCallResultEvent.fromJson(Map json) => _$ToolCallResultEventFromJson(json); } @JsonSerializable() class ToolCallErrorEvent extends AgUiEvent { final String toolCallId; final String error; final String? code; ToolCallErrorEvent({ required this.toolCallId, required this.error, this.code, }) : super(type: AgUiEventType.toolCallError); factory ToolCallErrorEvent.fromJson(Map json) => _$ToolCallErrorEventFromJson(json); } // 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 json) => _$ToolResultFromJson(json); Map toJson() => _$ToolResultToJson(this); } /// UI 卡片 Schema(给 UI 渲染) @JsonSerializable() class UiCard { @JsonKey(name: 'type') final String cardType; @JsonKey(name: 'version') final String? schemaVersion; final Map data; final List? actions; UiCard({ required this.cardType, this.schemaVersion = 'v1', required this.data, this.actions, }); factory UiCard.fromJson(Map json) => _$UiCardFromJson(json); Map toJson() => _$UiCardToJson(this); } /// 卡片操作按钮 @JsonSerializable() class CardAction { final String type; final String label; final String? target; final String? action; CardAction({ required this.type, required this.label, this.target, this.action, }); factory CardAction.fromJson(Map json) => _$CardActionFromJson(json); Map toJson() => _$CardActionToJson(this); } /// 日历卡片数据 @JsonSerializable() class CalendarCardData { final String id; final String title; final String? description; final String startAt; final String? endAt; final String? timezone; final String? location; final String? color; final String? sourceType; CalendarCardData({ required this.id, required this.title, this.description, required this.startAt, this.endAt, this.timezone, this.location, this.color, this.sourceType, }); factory CalendarCardData.fromJson(Map json) => _$CalendarCardDataFromJson(json); Map toJson() => _$CalendarCardDataToJson(this); } ``` **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 args; final ToolCallStatus status; final String? errorMessage; @override final DateTime timestamp; @override final MessageSender sender; ToolCallItem({ required this.id, required this.callId, required this.toolName, required this.args, required this.status, this.errorMessage, required this.timestamp, required this.sender, }); @override ChatItemType get type => ChatItemType.toolCall; ToolCallItem copyWith({ String? id, String? callId, String? toolName, Map? args, ToolCallStatus? status, String? errorMessage, DateTime? timestamp, MessageSender? sender, }) { 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> Function( Map args, ); class ToolDefinition { final String name; final String description; final Map parameters; final ToolHandler handler; ToolDefinition({ required this.name, required this.description, required this.parameters, required this.handler, }); } class ToolRegistry { static final Map _tools = {}; static bool _initialized = false; static void initialize() { if (_initialized) return; _tools['create_calendar_event'] = ToolDefinition( name: 'create_calendar_event', description: '创建一个日历事件或待办事项', parameters: { 'type': 'object', 'properties': { 'title': { 'type': 'string', 'description': '事件标题', 'minLength': 1, 'maxLength': 100, }, 'description': {'type': 'string', 'description': '事件描述'}, 'startAt': { 'type': 'string', 'format': 'date-time', 'description': '开始时间 (ISO8601)', }, 'endAt': { 'type': 'string', 'format': 'date-time', 'description': '结束时间 (ISO8601)', }, 'timezone': { 'type': 'string', 'default': 'Asia/Shanghai', }, 'location': {'type': 'string'}, 'notes': {'type': 'string'}, }, 'required': ['title', 'startAt'], }, handler: _handleCreateCalendarEvent, ); _initialized = true; } static Future> _handleCreateCalendarEvent( Map args, ) async { final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}'; return { 'eventId': eventId, 'ok': true, 'message': '日程已创建', 'title': args['title'], 'description': args['description'], 'startAt': args['startAt'], 'endAt': args['endAt'], 'timezone': args['timezone'] ?? 'Asia/Shanghai', 'location': args['location'], 'color': '#4F46E5', 'sourceType': 'agentGenerated', }; } static ToolDefinition? getTool(String name) => _tools[name]; static List getAllTools() => _tools.values.toList(); static Future> execute( String toolName, Map args, ) async { final tool = _tools[toolName]; if (tool == null) { throw ToolNotFoundException('Tool not found: $toolName'); } return tool.handler(args); } /// 校验工具参数 static ToolValidationResult validateArgs( String toolName, Map args, ) { final tool = _tools[toolName]; if (tool == null) { return ToolValidationResult( ok: false, error: 'Tool not found: $toolName', ); } final required = tool.parameters['required'] as List? ?? []; final missing = []; for (final field in required) { if (!args.containsKey(field) || args[field] == null) { missing.add(field as String); } } if (missing.isNotEmpty) { return ToolValidationResult( ok: false, error: 'Missing required fields: ${missing.join(', ')}', ); } return ToolValidationResult(ok: true); } } class ToolNotFoundException implements Exception { final String message; ToolNotFoundException(this.message); @override String toString() => 'ToolNotFoundException: $message'; } class ToolValidationResult { final bool ok; final String? error; ToolValidationResult({required this.ok, this.error}); } ``` **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? tryExtractEventArgs(String text) { // 只有明确的创建意图才提取参数 if (matchIntent(text) != Intent.createEvent) { return null; } final args = {}; // 提取标题 final titleMatch = RegExp(r'提醒(.+?)(?:明天|今天|几点|$)').firstMatch(text); if (titleMatch != null) { args['title'] = titleMatch.group(1)?.trim() ?? text; } else { // 如果没有"提醒"前缀,使用整段文本作为标题(但至少需要有时间) if (RegExp(r'\d{1,2}[:点]|\d{1,2}点').hasMatch(text)) { args['title'] = text.replaceAll(RegExp(r'\d{1,2}[:点]\d{0,2}|明天|今天|后天'), '').trim(); } } // 必须有标题才返回 if (args['title'] == null || (args['title'] as String).isEmpty) { return null; } // 提取时间 final timeMatch = RegExp(r'(明天|今天|后天)?\s*(\d{1,2})[:点](\d{2})?').firstMatch(text); if (timeMatch != null) { final dayStr = timeMatch.group(1) ?? '今天'; final hour = int.parse(timeMatch.group(2)!); final minute = int.parse(timeMatch.group(3) ?? '0'); final now = DateTime.now(); DateTime startAt; switch (dayStr) { case '明天': startAt = DateTime(now.year, now.month, now.day + 1, hour, minute); break; case '后天': startAt = DateTime(now.year, now.month, now.day + 2, hour, minute); break; default: startAt = DateTime(now.year, now.month, now.day, hour, minute); } args['startAt'] = startAt.toIso8601String(); args['timezone'] = 'Asia/Shanghai'; } // 必须有 startAt 才返回 if (!args.containsKey('startAt')) { return null; } return args; } bool shouldTriggerToolCall(String text) { final intent = matchIntent(text); return intent == Intent.createEvent; } Map? 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? args; if (argsJson != null) { try { args = jsonDecode(argsJson) as Map; } catch (_) { args = {}; } } return ForceTriggerResult(toolName: toolName!, args: args ?? {}); } } class ForceTriggerResult { final String toolName; final Map args; ForceTriggerResult({required this.toolName, required this.args}); } ``` **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 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 _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 _mockToolCallFlow(String content) async { final args = _decisionEngine.getToolCallArgs(content) ?? {}; await _mockToolCallFlowWithArgs('create_calendar_event', args); } Future _mockToolCallFlowWithArgs( String toolName, Map 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 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 支持流式) List _generateReplies(String content) { if (_decisionEngine.shouldTriggerToolCall(content)) { return ['好的,', '我已为你创建日程。']; } return ['收到,', '有什么可以帮你的?']; } Future 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 { final AgUiService _service; final Map _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 args; try { args = jsonDecode(argsJson) as Map; } 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 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 items; final bool isLoading; final String? currentMessageId; final String? error; const ChatState({ this.items = const [], this.isLoading = false, this.currentMessageId, this.error, }); static const _unset = Object(); ChatState copyWith({ List? items, bool? isLoading, Object? currentMessageId = _unset, Object? error = _unset, }) { return ChatState( items: items ?? this.items, isLoading: isLoading ?? this.isLoading, currentMessageId: currentMessageId == _unset ? this.currentMessageId : currentMessageId as String?, error: error == _unset ? this.error : error as String?, ); } } ``` **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( 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().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 _sendMessage(BuildContext context) async { final content = _messageController.text.trim(); if (content.isEmpty) return; _messageController.clear(); context.read().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 saveMessages(List> messages) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_msgKey, jsonEncode(messages)); } Future>?> loadMessages() async { final prefs = await SharedPreferences.getInstance(); final data = prefs.getString(_msgKey); if (data == null) return null; final list = jsonDecode(data) as List; return list.cast>(); } Future saveLastRunId(String runId) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_runIdKey, runId); } Future loadLastRunId() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_runIdKey); } Future clear() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_msgKey); await prefs.remove(_runIdKey); } // Calendar events 独立存储 Future saveCalendarEvent(Map event) async { final prefs = await SharedPreferences.getInstance(); final eventsJson = prefs.getString(_calendarEventsKey); final events = eventsJson != null ? jsonDecode(eventsJson) as Map : {}; events[event['id']] = event; await prefs.setString(_calendarEventsKey, jsonEncode(events)); } Future>> loadCalendarEvents() async { final prefs = await SharedPreferences.getInstance(); final eventsJson = prefs.getString(_calendarEventsKey); if (eventsJson == null) return []; final events = jsonDecode(eventsJson) as Map; return events.values.cast>().toList(); } } ``` **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()), ); }); }); } ``` **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()); 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()); 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()); 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 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()); expect((userMsg as TextMessageItem).sender, MessageSender.user); expect(userMsg.content, '你好'); }); test('should set isLoading to true during send', () async { final states = []; 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 capturedEvents; setUp(() { capturedEvents = []; service = AgUiService(onEvent: capturedEvents.add); }); group('sendMessage', () { test('should emit RunStarted event first', () async { await service.sendMessage('你好'); expect(capturedEvents.first, isA()); }); test('should emit RunFinished event last', () async { await service.sendMessage('你好'); expect(capturedEvents.last, isA()); }); 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().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,分批执行 需要我开始复审这个修订版计划吗?