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

67 KiB
Raw Blame History

AG-UI 聊天功能实现计划 v1.3

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Revision History:

  • v1.3: 修复 AiDecisionEngine 规则优先级、ChatState.copyWith 空值处理、测试用例对齐
  • v1.2: 修复 AgUiService.onEvent 可变、ChatBloc 初始化、补充测试覆盖
  • v1.1: 修复编译错误、事件映射、职责边界、设计 token、测试覆盖等问题

Goal: 实现基于 AG-UI 协议的 AI 聊天功能,包括事件流处理、工具调用、日历卡片渲染

Architecture:

  • 核心组件:AgUiService 处理事件流、ToolRegistry 管理前端工具
  • 数据流:用户消息 → 事件流 → AI 响应 → ToolCall → 执行 → UI 渲染
  • Mock 模式使用规则引擎模拟 AI 决策,真实模式对接 SSE

Tech Stack: Flutter, Dart, shared_preferences

Scope: 本次仅实现 Mock 模式,真实 SSE 对接后续迭代


阶段 0: 依赖与脚手架

Task 0: 添加依赖

Files:

  • Modify: apps/pubspec.yaml

Step 1: 添加依赖

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

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 事件基类和子类型

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

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 模型

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

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 模型

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

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

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

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

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

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

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

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

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

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

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

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 结构。

// 在文件顶部添加 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

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

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

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 测试

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 测试

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 测试

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
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
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
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

git add apps/test/features/chat/
git commit -m "test(chat): add comprehensive unit tests for bloc, service and renderer"

完成验证

验证命令:

# 运行所有测试
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,分批执行

需要我开始复审这个修订版计划吗?