diff --git a/.gitignore b/.gitignore index d473ae3..b593b06 100644 --- a/.gitignore +++ b/.gitignore @@ -287,6 +287,7 @@ infra/cloud/volcano/env/*.env .buildlog/ .history /logs/ +backend/logs/ # Docker volumes (local data) docker/supabase/volumes/db/data/ infra/docker/volumes/db/data/ diff --git a/apps/lib/features/home/data/home_mock_data.dart b/apps/lib/features/home/data/home_mock_data.dart new file mode 100644 index 0000000..8fcc512 --- /dev/null +++ b/apps/lib/features/home/data/home_mock_data.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import '../../../shared/widgets/chat_bubble.dart'; + +enum ChatItemType { message, schedule } + +abstract class ChatListItem { + String get id; + DateTime get timestamp; + ChatItemType get type; + MessageSender get sender; +} + +class HomeMockData { + static List getTodayItems() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + return _getMockItems().where((item) { + final itemDate = DateTime( + item.timestamp.year, + item.timestamp.month, + item.timestamp.day, + ); + return itemDate == today; + }).toList(); + } + + static Future> loadMoreItems(DateTime beforeDate) async { + return _getOlderMockItems(beforeDate); + } + + static List _getMockItems() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final todayStart = DateTime(today.year, today.month, today.day); + + return [ + ChatMessageItem( + id: 'm4', + content: '明天提醒我开会', + timestamp: todayStart.add(const Duration(hours: 14)), + sender: MessageSender.user, + ), + ScheduleItemWrapper( + id: 's1', + scheduleItem: ScheduleItemModel( + id: 's1', + title: '产品评审会议', + description: '讨论Q2产品路线图', + startAt: todayStart.add(const Duration(days: 1, hours: 10)), + endAt: todayStart.add(const Duration(days: 1, hours: 11)), + timezone: 'Asia/Shanghai', + sourceType: ScheduleSourceType.agentGenerated, + status: ScheduleStatus.active, + metadata: ScheduleMetadata( + color: '#4F46E5', + location: '会议室A / 在线', + notes: '需要提前准备Q2数据', + attachments: [ + Attachment( + name: 'Q2路线图.pdf', + type: AttachmentType.document, + url: 'https://example.com/q2.pdf', + ), + ], + ), + createdAt: todayStart.subtract(const Duration(hours: 5)), + ), + timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)), + sender: MessageSender.ai, + ), + ChatMessageItem( + id: 'm5', + content: '已为你创建日程"产品评审会议",明天上午10:00。我还会提前15分钟提醒你。', + timestamp: todayStart.add(const Duration(hours: 14, minutes: 2)), + sender: MessageSender.ai, + ), + ]; + } + + static List _getOlderMockItems(DateTime beforeDate) { + final before = DateTime(beforeDate.year, beforeDate.month, beforeDate.day); + final dayBefore = before.subtract(const Duration(days: 1)); + + return [ + ChatMessageItem( + id: 'm1', + content: '你好,我有什么可以帮你的?', + timestamp: dayBefore.add(const Duration(hours: 10)), + sender: MessageSender.ai, + ), + ChatMessageItem( + id: 'm2', + content: '下周一之前提交项目报告', + timestamp: dayBefore.add(const Duration(hours: 9, minutes: 55)), + sender: MessageSender.user, + ), + ScheduleItemWrapper( + id: 's0', + scheduleItem: ScheduleItemModel( + id: 's0', + title: '提交项目报告', + description: '完成并提交Q2项目报告', + startAt: before.subtract(const Duration(days: 3)), + endAt: null, + timezone: 'Asia/Shanghai', + sourceType: ScheduleSourceType.agentGenerated, + status: ScheduleStatus.active, + metadata: ScheduleMetadata( + color: '#F59E0B', + location: null, + notes: '记得附上数据附件', + attachments: [], + ), + createdAt: dayBefore.add(const Duration(hours: 9, minutes: 50)), + ), + timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)), + sender: MessageSender.ai, + ), + ChatMessageItem( + id: 'm3', + content: '好的,我已帮你创建待办事项"提交项目报告",截止日期为下周一。我还会提醒你完成这项任务。', + timestamp: dayBefore.add(const Duration(hours: 9, minutes: 50)), + sender: MessageSender.ai, + ), + ]; + } +} + +class ChatMessageItem extends ChatListItem { + @override + final String id; + final String content; + @override + final DateTime timestamp; + @override + final MessageSender sender; + + ChatMessageItem({ + required this.id, + required this.content, + required this.timestamp, + required this.sender, + }); + + @override + ChatItemType get type => ChatItemType.message; +} + +class ScheduleItemWrapper extends ChatListItem { + @override + final String id; + final ScheduleItemModel scheduleItem; + @override + final DateTime timestamp; + @override + final MessageSender sender; + + ScheduleItemWrapper({ + required this.id, + required this.scheduleItem, + required this.timestamp, + required this.sender, + }); + + @override + ChatItemType get type => ChatItemType.schedule; +} + +enum ScheduleSourceType { manual, imported, agentGenerated } + +enum ScheduleStatus { active, completed, canceled, archived } + +class ScheduleItemModel { + final String id; + final String title; + final String? description; + final DateTime startAt; + final DateTime? endAt; + final String timezone; + final ScheduleSourceType sourceType; + final ScheduleStatus status; + final ScheduleMetadata? metadata; + final DateTime createdAt; + + ScheduleItemModel({ + required this.id, + required this.title, + this.description, + required this.startAt, + this.endAt, + required this.timezone, + required this.sourceType, + required this.status, + this.metadata, + required this.createdAt, + }); + + factory ScheduleItemModel.fromJson(Map json) { + return ScheduleItemModel( + id: json['id'], + title: json['title'], + description: json['description'], + startAt: DateTime.parse(json['start_at']), + endAt: json['end_at'] != null ? DateTime.parse(json['end_at']) : null, + timezone: json['timezone'] ?? 'UTC', + sourceType: ScheduleSourceType.values.firstWhere( + (e) => e.name == json['source_type'], + orElse: () => ScheduleSourceType.manual, + ), + status: ScheduleStatus.values.firstWhere( + (e) => e.name == json['status'], + orElse: () => ScheduleStatus.active, + ), + metadata: json['metadata'] != null + ? ScheduleMetadata( + color: json['metadata']['color'], + location: json['metadata']['location'], + notes: json['metadata']['notes'], + attachments: + (json['metadata']['attachments'] as List?) + ?.map( + (a) => Attachment( + name: a['name'], + type: a['type'] == 'document' + ? AttachmentType.document + : AttachmentType.reminder, + url: a['url'], + content: a['content'], + note: a['note'], + ), + ) + .toList() ?? + [], + ) + : null, + createdAt: DateTime.parse(json['created_at']), + ); + } +} + +class ScheduleMetadata { + final String? color; + final String? location; + final String? notes; + final List attachments; + + ScheduleMetadata({ + this.color, + this.location, + this.notes, + this.attachments = const [], + }); +} + +enum AttachmentType { document, reminder } + +class Attachment { + final String name; + final AttachmentType type; + final String? url; + final String? content; + final String? note; + + Attachment({ + required this.name, + required this.type, + this.url, + this.content, + this.note, + }); +} + +extension ScheduleSourceTypeExtension on ScheduleSourceType { + String get displayName { + switch (this) { + case ScheduleSourceType.manual: + return '手动创建'; + case ScheduleSourceType.imported: + return '导入'; + case ScheduleSourceType.agentGenerated: + return 'AI生成'; + } + } + + IconData get icon { + switch (this) { + case ScheduleSourceType.manual: + return Icons.edit_calendar; + case ScheduleSourceType.imported: + return Icons.download; + case ScheduleSourceType.agentGenerated: + return Icons.auto_awesome; + } + } +} diff --git a/apps/lib/shared/widgets/chat_bubble.dart b/apps/lib/shared/widgets/chat_bubble.dart new file mode 100644 index 0000000..0ede5c0 --- /dev/null +++ b/apps/lib/shared/widgets/chat_bubble.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/design_tokens.dart'; + +enum MessageSender { user, ai } + +class ChatBubble extends StatelessWidget { + final MessageSender sender; + final String content; + final DateTime timestamp; + final bool showTimestamp; + final Widget? extraContent; + + const ChatBubble({ + super.key, + required this.sender, + required this.content, + required this.timestamp, + this.showTimestamp = true, + this.extraContent, + }); + + @override + Widget build(BuildContext context) { + final isUser = sender == MessageSender.user; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Column( + crossAxisAlignment: isUser + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (showTimestamp) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + _formatTimestamp(timestamp), + style: const TextStyle(fontSize: 11, color: AppColors.slate400), + ), + ), + Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.82, + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isUser ? AppColors.blue500 : AppColors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: content.isNotEmpty + ? Text( + content, + style: TextStyle( + fontSize: 15, + color: isUser ? AppColors.white : AppColors.slate700, + height: 1.45, + ), + ) + : (extraContent ?? const SizedBox.shrink()), + ), + ], + ), + ); + } + + String _formatTimestamp(DateTime time) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final msgDate = DateTime(time.year, time.month, time.day); + + String dateStr; + if (msgDate == today) { + dateStr = '今天'; + } else if (msgDate == today.subtract(const Duration(days: 1))) { + dateStr = '昨天'; + } else { + dateStr = '${time.month}月${time.day}日'; + } + + final timeStr = + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + return '$dateStr $timeStr'; + } +} diff --git a/apps/test/features/home/ui/screens/home_screen_test.dart b/apps/test/features/home/ui/screens/home_screen_test.dart new file mode 100644 index 0000000..51e8bde --- /dev/null +++ b/apps/test/features/home/ui/screens/home_screen_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:social_app/features/home/ui/screens/home_screen.dart'; +import 'package:social_app/shared/widgets/chat_bubble.dart'; + +void main() { + group('HomeScreen Widget Tests', () { + testWidgets('displays chat messages with ChatBubble', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.byType(ChatBubble), findsAtLeastNWidgets(1)); + }); + + testWidgets('displays user request message', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.textContaining('明天提醒我开会'), findsOneWidget); + }); + + testWidgets('displays AI response message', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.textContaining('已为你创建日程'), findsOneWidget); + }); + + testWidgets('displays calendar schedule cards in chat flow', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.byType(ChatBubble), findsAtLeastNWidgets(2)); + }); + + testWidgets('input field is present', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('输入消息...'), findsOneWidget); + }); + + testWidgets('header icons are present', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp(home: HomeScreen())); + + expect(find.byIcon(LucideIcons.settings), findsOneWidget); + expect(find.byIcon(LucideIcons.calendar), findsOneWidget); + expect(find.byIcon(LucideIcons.messageSquare), findsOneWidget); + }); + }); +} diff --git a/docs/plans/2026-02-28-ag-ui-chat-design.md b/docs/plans/2026-02-28-ag-ui-chat-design.md new file mode 100644 index 0000000..ab40467 --- /dev/null +++ b/docs/plans/2026-02-28-ag-ui-chat-design.md @@ -0,0 +1,567 @@ +# AG-UI 聊天功能设计文档 + +## 1. 概述 + +本文档描述如何使用 AG-UI 协议实现 AI 聊天功能,包括: +- 消息的发送与接收(通过 AG-UI 事件流) +- AI 工具调用(Tool Call)机制 +- 日历卡片作为 Tool Result 渲染 +- 前端工具注册与执行 +- 本地持久化 + +## 2. 架构设计 + +### 2.1 整体流程 + +``` +用户输入消息 + ↓ +AgUiService.sendMessage() + ↓ +[Mock Mode] 规则引擎决策 → 事件流模拟 +[Real Mode] POST /api/chat → SSE 监听 + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ AG-UI Event Stream (按序处理) │ +├─────────────────────────────────────────────────────────────┤ +│ TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END │ +│ TOOL_CALL_START → TOOL_CALL_ARGS* → TOOL_CALL_END │ +│ TOOL_CALL_RESULT │ +│ RUN_STARTED → ... → RUN_FINISHED │ +└─────────────────────────────────────────────────────────────┘ + ↓ +ChatListItem 渲染 +``` + +### 2.2 核心组件 + +| 组件 | 职责 | +|------|------| +| `AgUiEvent` | AG-UI 事件数据模型 | +| `AgUiService` | 事件流处理:发送消息、解析事件 | +| `ToolRegistry` | 前端工具注册表:定义工具 + handler | +| `AiDecisionEngine` | Mock 模式:规则引擎决定是否调用工具 | +| `UiSchemaParser` | 解析 tool result 中的 UI Schema | +| `UiSchemaRenderer` | 根据 schema 渲染对应组件 | +| `ChatHistoryRepository` | 本地持久化:IndexedDB/localStorage | + +### 2.3 状态管理 + +``` +ChatState { + messages: ChatListItem[] // 渲染列表 + pendingToolCalls: Map + isLoading: bool + runId: string | null +} +``` + +## 3. 数据模型 + +### 3.1 AG-UI 事件模型 + +```dart +// 基类 +abstract class AgUiEvent { + final String type; + final String? timestamp; +} + +// 生命周期事件 +class RunStartedEvent extends AgUiEvent { + final String threadId; + final String runId; + final String? parentRunId; +} + +class RunFinishedEvent extends AgUiEvent { + final String threadId; + final String runId; + final dynamic result; +} + +// 文本消息事件 +class TextMessageStartEvent extends AgUiEvent { + final String messageId; + final String role; // "user" | "assistant" | "system" +} + +class TextMessageContentEvent extends AgUiEvent { + final String messageId; + final String delta; +} + +class TextMessageEndEvent extends AgUiEvent { + final String messageId; +} + +// 工具调用事件 +class ToolCallStartEvent extends AgUiEvent { + final String toolCallId; + final String toolCallName; + final String? parentMessageId; +} + +class ToolCallArgsEvent extends AgUiEvent { + final String toolCallId; + final String delta; // JSON fragment +} + +class ToolCallEndEvent extends AgUiEvent { + final String toolCallId; +} + +class ToolCallResultEvent extends AgUiEvent { + final String messageId; + final String toolCallId; + final ToolResult result; // 给 AI 的原始结果 + final UiCard? ui; // 给 UI 的渲染数据 +} + +class ToolCallErrorEvent extends AgUiEvent { + final String toolCallId; + final String error; + final String? code; +} +``` + +### 3.2 Tool Result Schema(v1) + +```json +{ + "type": "tool_result", + "version": "v1", + "call_id": "call_abc123", + "tool_name": "create_calendar_event", + "result": { + "eventId": "evt_xxx", + "ok": true, + "message": "日程已创建" + }, + "ui": { + "type": "card", + "cardType": "calendar_card.v1", + "data": { + "id": "evt_xxx", + "title": "产品评审会议", + "description": "讨论Q2路线图", + "startAt": "2026-03-01T10:00:00+08:00", + "endAt": "2026-03-01T11:00:00+08:00", + "timezone": "Asia/Shanghai", + "location": "会议室A", + "color": "#4F46E5", + "sourceType": "agentGenerated" + }, + "actions": [ + {"type": "open", "label": "打开", "target": "calendar/evt_xxx"}, + {"type": "edit", "label": "编辑", "action": "edit_event"}, + {"type": "delete", "label": "删除", "action": "delete_event"} + ] + } +} +``` + +### 3.3 工具定义(前端 Tool Registry) + +```dart +// 工具定义 +class ToolDefinition { + final String name; + final String description; + final Map parameters; + final ToolHandler handler; +} + +// create_calendar_event 工具 +{ + "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"] + } +} +``` + +### 3.4 ChatListItem 模型 + +```dart +enum ChatItemType { + message, // 纯文本消息 + toolCall, // 工具调用中 + toolResult, // 工具结果卡片 + schedule // 日历事件(兼容旧数据) +} + +abstract class ChatListItem { + String get id; + DateTime get timestamp; + ChatItemType get type; + MessageSender get sender; +} + +class TextMessageItem extends ChatListItem { + final String id; + final String content; + final DateTime timestamp; + final MessageSender sender; + final bool isStreaming; // 是否正在流式输出 +} + +class ToolCallItem extends ChatListItem { + final String id; + final String callId; + final String toolName; + final Map args; // 解析后的参数 + final ToolCallStatus status; // pending | executing | completed | error + final ToolResult? result; + final UiCard? uiCard; +} + +class CalendarCardItem extends ChatListItem { + final String id; + final String callId; // 关联的 tool call + final CalendarCardData data; + final List actions; +} +``` + +## 4. 核心流程 + +### 4.1 发送消息 + +```dart +Future sendMessage(String content) async { + // 1. 添加用户消息到列表 + final userMessage = TextMessageItem( + id: generateId(), + content: content, + timestamp: DateTime.now(), + sender: MessageSender.user, + ); + _chatItems.add(userMessage); + + // 2. 发起请求 + if (Env.isMockApi) { + await _mockEventStream(content); + } else { + await _realEventStream(content); + } +} +``` + +### 4.2 Mock 事件流(规则引擎) + +```dart +class AiDecisionEngine { + // 意图关键词映射 + static final Map> _intentPatterns = { + Intent.createEvent: [ + RegExp(r'提醒|开会|预约|日程|安排'), + RegExp(r'明天|今天|后天|下周'), + RegExp(r'\d{1,2}点|\d{1,2}:\d{2}'), + ], + Intent.searchEvent: [ + RegExp(r'查看|有什么|今天.*日程|明天.*安排'), + ], + }; + + Intent? matchIntent(String text) { + for (final entry in _intentPatterns.entries) { + for (final pattern in entry.value) { + if (pattern.hasMatch(text)) { + return entry.key; + } + } + } + return null; + } + + // 支持强制触发:#tool:create_calendar_event {"title": "test"} + bool tryForceTrigger(String text) {...} +} +``` + +### 4.3 事件解析与处理 + +```dart +Future _processEvent(AgUiEvent event) async { + switch (event.type) { + case 'TEXT_MESSAGE_START': + _handleTextMessageStart(event); + break; + case 'TEXT_MESSAGE_CONTENT': + _handleTextMessageContent(event); + break; + case 'TEXT_MESSAGE_END': + _handleTextMessageEnd(event); + break; + case 'TOOL_CALL_START': + _handleToolCallStart(event); + break; + case 'TOOL_CALL_ARGS': + _handleToolCallArgs(event); + break; + case 'TOOL_CALL_END': + await _handleToolCallEnd(event); + break; + case 'TOOL_CALL_RESULT': + _handleToolCallResult(event); + break; + case 'TOOL_CALL_ERROR': + _handleToolCallError(event); + break; + } +} + +void _handleToolCallStart(ToolCallStartEvent event) { + // 创建 pending 状态的 tool call item + final item = ToolCallItem( + id: event.toolCallId, + callId: event.toolCallId, + toolName: event.toolCallName, + args: {}, + status: ToolCallStatus.pending, + ); + _chatItems.add(item); +} + +Future _handleToolCallEnd(ToolCallEndEvent event) async { + // 1. 找到对应的 pending tool call + final toolCall = _findPendingToolCall(event.toolCallId); + if (toolCall == null) return; + + // 2. 校验参数 JSON Schema + final validation = validateToolArgs(toolCall.toolName, toolCall.args); + if (!validation.ok) { + _emitToolCallError(event.toolCallId, validation.error); + return; + } + + // 3. 执行工具 handler + toolCall.status = ToolCallStatus.executing; + final result = await ToolRegistry.execute( + toolCall.toolName, + toolCall.args, + ); + + // 4. 构建 tool result(包含 result + ui) + final toolResult = ToolResult( + eventId: result['eventId'], + ok: result['ok'] ?? true, + message: result['message'], + ); + + final uiCard = _buildUiCard(toolCall.toolName, result); + + // 5. 发送 TOOL_CALL_RESULT 事件 + _emitToolCallResult(event.toolCallId, toolResult, uiCard); +} +``` + +### 4.4 UI Schema 渲染 + +```dart +class UiSchemaRenderer { + static final Map _renderers = { + 'calendar_card.v1': (card) => CalendarCardWidget( + data: CalendarCardData.fromJson(card.data), + actions: card.actions, + ), + }; + + static Widget render(UiCard card) { + final renderer = _renderers[card.cardType]; + if (renderer != null) { + return renderer(card); + } + // Unknown card type fallback + return _renderUnknownCard(card); + } + + static Widget _renderUnknownCard(UiCard card) { + return GenericCardWidget( + rawJson: jsonEncode(card.toJson()), + cardType: card.cardType, + ); + } +} +``` + +### 4.5 日历卡片组件 + +```dart +class CalendarCardWidget extends StatelessWidget { + final CalendarCardData data; + final List actions; + + @override + Widget build(BuildContext context) { + final color = ColorExt.parse(data.color ?? '#4F46E5'); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [...], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 颜色条 + Container( + height: 4, + color: color, + ), + // 内容 + Padding( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.title, style: ...), + if (data.description != null) ..., + _buildTimeRow(), + if (data.location != null) ..., + ], + ), + ), + // Actions + if (actions.isNotEmpty) _buildActions(actions), + ], + ), + ); + } +} +``` + +## 5. 持久化设计 + +### 5.1 存储结构 + +```dart +// localStorage / IndexedDB +{ + "chat_sessions": { + "current_thread_id": { + "messages": [...], // ChatListItem JSON + "lastRunId": "run_xxx", + "updatedAt": "2026-02-28T12:00:00Z" + } + }, + "calendar_events": { + "evt_xxx": {...} // 独立存储的日历事件 + } +} +``` + +### 5.2 恢复逻辑 + +```dart +Future restoreSession() async { + final session = await ChatHistoryRepository.load('current_thread_id'); + if (session != null) { + _chatItems.clear(); + _chatItems.addAll(session.messages); + _runId = session.lastRunId; + } +} +``` + +## 6. 错误处理 + +### 6.1 Tool Call 错误 + +```dart +void _emitToolCallError(String callId, String error) { + // 1. 更新 item 状态 + final item = _findToolCallItem(callId); + item?.status = ToolCallStatus.error; + item?.errorMessage = error; + + // 2. 渲染错误卡片 + final errorCard = UiCard( + cardType: 'error_card.v1', + data: {'message': error}, + ); + + // 3. 触发 UI 更新 + notifyListeners(); +} +``` + +### 6.2 事件流重连 + +```dart +// 断线重连时从 snapshot 恢复 +Future reconnect() async { + final snapshot = await _fetchMessagesSnapshot(); + _chatItems.clear(); + _chatItems.addAll(snapshot.messages); + + // 重新订阅事件流 + _subscribeToEvents(); +} +``` + +## 7. 实施计划 + +### Phase 1: 基础框架 +- [ ] 定义 AG-UI 事件模型 +- [ ] 实现 AgUiService 基础结构 +- [ ] 实现 ToolRegistry + +### Phase 2: Mock 实现 +- [ ] 实现 AiDecisionEngine 规则引擎 +- [ ] 实现 Mock 事件流 +- [ ] 集成现有 HomeScreen + +### Phase 3: UI 渲染 +- [ ] 实现 UiSchemaParser +- [ ] 实现 CalendarCardWidget +- [ ] 实现 ToolPending / ToolError 状态卡片 + +### Phase 4: 持久化 +- [ ] 实现 ChatHistoryRepository +- [ ] 实现会话恢复 + +### Phase 5: 真实后端对接 +- [ ] 实现 SSE 客户端 +- [ ] 实现事件流解析器 + +## 8. 版本历史 + +| 版本 | 日期 | 变更 | +|------|------|------| +| v1.0 | 2026-02-28 | 初始版本 | diff --git a/docs/plans/2026-02-28-ag-ui-chat-implementation-plan.md b/docs/plans/2026-02-28-ag-ui-chat-implementation-plan.md new file mode 100644 index 0000000..6a4a546 --- /dev/null +++ b/docs/plans/2026-02-28-ag-ui-chat-implementation-plan.md @@ -0,0 +1,2463 @@ +# 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,分批执行 + +需要我开始复审这个修订版计划吗? diff --git a/docs/plans/2026-02-28-friendship-design.md b/docs/plans/2026-02-28-friendship-design.md new file mode 100644 index 0000000..003e438 --- /dev/null +++ b/docs/plans/2026-02-28-friendship-design.md @@ -0,0 +1,136 @@ +# 好友申请与待办消息功能设计 + +**Date:** 2026-02-28 +**Status:** Approved + +## 1. 数据模型 + +### Friendship 表 (已存在) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| user_low_id | UUID | 用户A ID (固定排序小值) | +| user_high_id | UUID | 用户B ID (固定排序大值) | +| initiator_id | UUID? | 发起请求者 | +| status | VARCHAR(20) | pending/accepted/blocked/declined/canceled | +| requested_at | TIMESTAMP? | 请求时间 | +| accepted_at | TIMESTAMP? | 接受时间 | +| blocked_by | UUID? | 被谁屏蔽 | +| created_by/updated_by | UUID? | 审计字段 | + +### InboxMessage 表 (复用) + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID | 主键 | +| recipient_id | UUID | 接收方 | +| sender_id | UUID? | 发送方 | +| message_type | VARCHAR(20) | FRIEND_REQUEST / CALENDAR / SYSTEM / GROUP | +| friendship_id | UUID? | 关联 Friendship | +| content | TEXT? | 附加消息 | +| is_read | BOOLEAN | 已读状态 | +| status | VARCHAR(20) | pending/accepted/rejected/dismissed | + +## 2. API 设计 + +| 方法 | 路径 | 功能 | +|------|------|------| +| POST | /friends/requests | 发送好友请求 | +| GET | /friends/requests/outgoing | 获取我发出的请求 | +| GET | /friends/requests/inbox | 获取收到的好友请求 | +| POST | /friends/requests/{id}/accept | 接受好友请求 | +| POST | /friends/requests/{id}/decline | 拒绝好友请求 | +| DELETE | /friends/requests/{id} | 取消我的请求 | +| GET | /friends | 获取好友列表 | +| DELETE | /friends/{id} | 删除好友 | + +## 3. 业务逻辑流程 + +### 3.1 发送好友请求 + +``` +1. 验证 target_user_id != current_user_id +2. 检查是否已存在 Friendship 记录 + - 已 accepted: 返回 409 + - 已 pending: 返回 409 + - 已 blocked: 返回 403 +3. 创建 Friendship (status=pending, initiator_id=current_user) +4. 创建 InboxMessage (message_type=FRIEND_REQUEST, recipient=target_user) +5. 提交事务 +``` + +### 3.2 接受好友请求 + +``` +1. 查询 Friendship 和 InboxMessage +2. 验证 current_user == recipient +3. 更新 Friendship (status=accepted, accepted_at=now) +4. 更新 InboxMessage (status=accepted) +5. 提交事务 +``` + +### 3.3 拒绝好友请求 + +``` +1. 查询 Friendship 和 InboxMessage +2. 验证 current_user == recipient +3. 更新 Friendship (status=declined) +4. 更新 InboxMessage (status=rejected) +5. 提交事务 +``` + +### 3.4 获取好友列表 + +``` +查询 Friendship WHERE (user_low_id=current OR user_high_id=current) AND status=accepted +``` + +## 4. 响应 Schema + +### FriendRequestResponse +```python +{ + "id": "uuid", + "sender": {"id": "uuid", "username": "string", "avatar_url": "string?"}, + "recipient": {"id": "uuid", "username": "string", "avatar_url": "string?"}, + "content": "string?", + "status": "pending", + "created_at": "datetime" +} +``` + +### FriendResponse +```python +{ + "id": "uuid", + "friend": {"id": "uuid", "username": "string", "avatar_url": "string?"}, + "status": "accepted", + "created_at": "datetime", + "accepted_at": "datetime?" +} +``` + +## 5. 边界处理 + +| 场景 | 状态码 | 响应 | +|------|--------|------| +| 对自己发送请求 | 400 | Cannot send friend request to yourself | +| 已是好友 | 409 | Already friends | +| 已有待处理请求 | 409 | Friend request already exists | +| 被对方屏蔽 | 403 | Blocked by user | +| 请求不存在 | 404 | Friend request not found | +| 无权限操作 | 403 | Not authorized | + +## 6. 测试用例 + +### 单元测试 +- FriendshipService 业务逻辑 +- 状态转换验证 +- 边界条件处理 + +### 集成测试 +- POST /friends/requests - 成功/失败场景 +- GET /friends/requests/inbox - 返回正确列表 +- POST /friends/requests/{id}/accept - 状态更新 +- DELETE /friends/{id} - 删除好友 diff --git a/docs/plans/2026-02-28-friendship-implementation-plan.md b/docs/plans/2026-02-28-friendship-implementation-plan.md new file mode 100644 index 0000000..4936fbf --- /dev/null +++ b/docs/plans/2026-02-28-friendship-implementation-plan.md @@ -0,0 +1,870 @@ +# 好友申请功能实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现好友申请、待办消息、添加/删除好友等系列功能的后端API + +**Architecture:** 使用 repository/service/router 模式,复用已有的 Friendship 和 InboxMessage 模型,通过 inbox_messages 表存储好友请求通知 + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic + +--- + +### Task 1: 创建 friendships 模块目录结构和基础文件 + +**Files:** +- Create: `backend/src/v1/friendships/__init__.py` +- Create: `backend/src/v1/friendships/schemas.py` +- Create: `backend/src/v1/friendships/repository.py` +- Create: `backend/src/v1/friendships/service.py` +- Create: `backend/src/v1/friendships/dependencies.py` +- Create: `backend/src/v1/friendships/router.py` + +**Step 1: 创建目录和基础 schema** + +```python +# backend/src/v1/friendships/__init__.py +``` + +**Step 2: 创建 Pydantic schemas** + +```python +# backend/src/v1/friendships/schemas.py +from __future__ import annotations +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class UserBasicInfo(BaseModel): + id: str + username: str + avatar_url: Optional[str] = None + + +class FriendRequestCreate(BaseModel): + target_user_id: UUID + content: Optional[str] = Field(None, max_length=200) + + +class FriendRequestResponse(BaseModel): + id: UUID + sender: UserBasicInfo + recipient: UserBasicInfo + content: Optional[str] + status: str + created_at: datetime + + +class FriendResponse(BaseModel): + id: UUID + friend: UserBasicInfo + status: str + created_at: datetime + accepted_at: Optional[datetime] + + +class FriendRequestAction(BaseModel): + # For accept/decline - no body needed but kept for extensibility + pass +``` + +**Step 3: Commit** + +```bash +git add backend/src/v1/friendships/ +git commit -m "feat(friendships): create module structure and schemas" +``` + +--- + +### Task 2: 实现 FriendshipRepository + +**Files:** +- Modify: `backend/src/v1/friendships/repository.py` + +**Step 1: 写入失败的测试** + +```python +# backend/tests/unit/v1/friendships/test_friendship_repository.py +import pytest +from uuid import uuid4 +from v1.friendships.repository import FriendshipRepository + + +@pytest.fixture +def mock_session(): + # Create mock async session + pass + + +@pytest.mark.asyncio +async def test_create_friendship_request(mock_session): + repository = FriendshipRepository(mock_session) + # Test creating friendship request + pass + + +@pytest.mark.asyncio +async def test_get_pending_request_between_users(mock_session): + repository = FriendshipRepository(mock_session) + # Test checking existing requests + pass +``` + +**Step 2: 运行测试确认失败** + +**Step 3: 实现 repository** + +```python +# backend/src/v1/friendships/repository.py +from __future__ import annotations + +from typing import Optional +from uuid import UUID + +from sqlalchemy import select, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from models.friendships import Friendship, FriendshipStatus +from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus + + +class FriendshipRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_request( + self, + user_low_id: UUID, + user_high_id: UUID, + initiator_id: UUID, + recipient_id: UUID, + content: Optional[str] = None, + ) -> tuple[Friendship, InboxMessage]: + friendship = Friendship( + user_low_id=user_low_id, + user_high_id=user_high_id, + initiator_id=initiator_id, + status=FriendshipStatus.PENDING, + ) + self._session.add(friendship) + await self._session.flush() + + inbox_message = InboxMessage( + recipient_id=recipient_id, + sender_id=initiator_id, + message_type=InboxMessageType.FRIEND_REQUEST, + friendship_id=friendship.id, + content=content, + status=InboxMessageStatus.PENDING, + ) + self._session.add(inbox_message) + return friendship, inbox_message + + async def get_friendship_between_users( + self, user_a_id: UUID, user_b_id: UUID + ) -> Optional[Friendship]: + low_id = min(user_a_id, user_b_id) + high_id = max(user_a_id, user_b_id) + stmt = select(Friendship).where( + and_( + Friendship.user_low_id == low_id, + Friendship.user_high_id == high_id, + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_pending_inbox_for_recipient( + self, friendship_id: UUID, recipient_id: UUID + ) -> Optional[InboxMessage]: + stmt = select(InboxMessage).where( + and_( + InboxMessage.friendship_id == friendship_id, + InboxMessage.recipient_id == recipient_id, + InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST, + ) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_friendship_by_id(self, friendship_id: UUID) -> Optional[Friendship]: + stmt = select(Friendship).where(Friendship.id == friendship_id) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + + async def get_inbox_messages_for_user( + self, user_id: UUID, status: Optional[InboxMessageStatus] = None + ) -> list[InboxMessage]: + stmt = select(InboxMessage).where( + and_( + InboxMessage.recipient_id == user_id, + InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST, + ) + ) + if status: + stmt = stmt.where(InboxMessage.status == status) + stmt = stmt.order_by(InboxMessage.created_at.desc()) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_outgoing_requests( + self, user_id: UUID, status: Optional[FriendshipStatus] = None + ) -> list[Friendship]: + stmt = select(Friendship).where(Friendship.initiator_id == user_id) + if status: + stmt = stmt.where(Friendship.status == status) + else: + stmt = stmt.where(Friendship.status == FriendshipStatus.PENDING) + stmt = stmt.order_by(Friendship.created_at.desc()) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + + async def get_friends_list(self, user_id: UUID) -> list[Friendship]: + stmt = select(Friendship).where( + or_( + Friendship.user_low_id == user_id, + Friendship.user_high_id == user_id, + ), + Friendship.status == FriendshipStatus.ACCEPTED, + ).order_by(Friendship.updated_at.desc()) + result = await self._session.execute(stmt) + return list(result.scalars().all()) +``` + +**Step 4: 运行测试确认通过** + +**Step 5: Commit** + +```bash +git add backend/src/v1/friendships/repository.py backend/tests/unit/v1/friendships/ +git commit -m "feat(friendships): implement repository" +``` + +--- + +### Task 3: 实现 FriendshipService + +**Files:** +- Modify: `backend/src/v1/friendships/service.py` + +**Step 1: 写入失败的测试** + +```python +# backend/tests/unit/v1/friendships/test_friendship_service.py +import pytest +from uuid import uuid4 +from v1.friendships.service import FriendshipService + + +@pytest.fixture +def mock_repository(): + pass + + +@pytest.mark.asyncio +async def test_send_friend_request_success(mock_repository): + service = FriendshipService(mock_repository, current_user) + # Test successful friend request + pass + + +@pytest.mark.asyncio +async def test_send_friend_request_to_self_fails(): + # Test that sending to self returns 400 + pass + + +@pytest.mark.asyncio +async def test_send_friend_request_when_already_friends(): + # Test that sending to existing friend returns 409 + pass +``` + +**Step 2: 运行测试确认失败** + +**Step 3: 实现 service** + +```python +# backend/src/v1/friendships/service.py +from __future__ import annotations + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.friendships import Friendship, FriendshipStatus +from models.inbox_messages import InboxMessageStatus +from models.profile import Profile +from v1.friendships.repository import FriendshipRepository +from v1.friendships.schemas import ( + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, + UserBasicInfo, +) + +logger = get_logger("v1.friendships.service") + + +class FriendshipService(BaseService): + def __init__( + self, + repository: FriendshipRepository, + session: AsyncSession, + current_user: CurrentUser, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def send_request( + self, payload: FriendRequestCreate + ) -> FriendRequestResponse: + current_user_id = self.require_user_id() + target_user_id = payload.target_user_id + + if current_user_id == target_user_id: + raise HTTPException( + status_code=400, + detail="Cannot send friend request to yourself" + ) + + # Check existing relationship + existing = await self._repository.get_friendship_between_users( + current_user_id, target_user_id + ) + + if existing: + if existing.status == FriendshipStatus.ACCEPTED: + raise HTTPException(status_code=409, detail="Already friends") + if existing.status == FriendshipStatus.PENDING: + raise HTTPException(status_code=409, detail="Friend request already exists") + if existing.status == FriendshipStatus.BLOCKED: + raise HTTPException(status_code=403, detail="Blocked by user") + + user_low_id = min(current_user_id, target_user_id) + user_high_id = max(current_user_id, target_user_id) + + friendship, inbox = await self._repository.create_request( + user_low_id=user_low_id, + user_high_id=user_high_id, + initiator_id=current_user_id, + recipient_id=target_user_id, + content=payload.content, + ) + await self._session.commit() + + sender_info = await self._get_profile_info(current_user_id) + recipient_info = await self._get_profile_info(target_user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=sender_info, + recipient=recipient_info, + content=payload.content, + status=friendship.status.value, + created_at=friendship.created_at, + ) + + async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse: + current_user_id = self.require_user_id() + + friendship = await self._repository.get_friendship_by_id(friendship_id) + if not friendship: + raise HTTPException(status_code=404, detail="Friend request not found") + + # Determine recipient - must be the current user + recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id + if recipient_id != current_user_id: + raise HTTPException(status_code=403, detail="Not authorized") + + inbox = await self._repository.get_pending_inbox_for_recipient( + friendship_id, current_user_id + ) + + friendship.status = FriendshipStatus.ACCEPTED + friendship.accepted_at = datetime.utcnow() + + if inbox: + inbox.status = InboxMessageStatus.ACCEPTED + + await self._session.commit() + + initiator_info = await self._get_profile_info(friendship.initiator_id) + recipient_info = await self._get_profile_info(current_user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=initiator_info, + recipient=recipient_info, + content=inbox.content if inbox else None, + status=friendship.status.value, + created_at=friendship.created_at, + ) + + async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse: + current_user_id = self.require_user_id() + + friendship = await self._repository.get_friendship_by_id(friendship_id) + if not friendship: + raise HTTPException(status_code=404, detail="Friend request not found") + + recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id + if recipient_id != current_user_id: + raise HTTPException(status_code=403, detail="Not authorized") + + inbox = await self._repository.get_pending_inbox_for_recipient( + friendship_id, current_user_id + ) + + friendship.status = FriendshipStatus.DECLINED + + if inbox: + inbox.status = InboxMessageStatus.REJECTED + + await self._session.commit() + + initiator_info = await self._get_profile_info(friendship.initiator_id) + recipient_info = await self._get_profile_info(current_user_id) + + return FriendRequestResponse( + id=friendship.id, + sender=initiator_info, + recipient=recipient_info, + content=inbox.content if inbox else None, + status=friendship.status.value, + created_at=friendship.created_at, + ) + + async def cancel_request(self, friendship_id: UUID) -> None: + current_user_id = self.require_user_id() + + friendship = await self._repository.get_friendship_by_id(friendship_id) + if not friendship: + raise HTTPException(status_code=404, detail="Friend request not found") + + if friendship.initiator_id != current_user_id: + raise HTTPException(status_code=403, detail="Not authorized") + + if friendship.status != FriendshipStatus.PENDING: + raise HTTPException(status_code=400, detail="Can only cancel pending requests") + + inbox = await self._repository.get_pending_inbox_for_recipient( + friendship_id, friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id + ) + + friendship.status = FriendshipStatus.CANCELED + + if inbox: + inbox.status = InboxMessageStatus.DISMISSED + + await self._session.commit() + + async def get_inbox(self) -> list[FriendRequestResponse]: + current_user_id = self.require_user_id() + inbox_messages = await self._repository.get_pending_inbox_for_user( + current_user_id, InboxMessageStatus.PENDING + ) + + results = [] + for msg in inbox_messages: + friendship = await self._repository.get_friendship_by_id(msg.friendship_id) + if not friendship: + continue + + sender_info = await self._get_profile_info(msg.sender_id) + recipient_info = await self._get_profile_info(current_user_id) + + results.append(FriendRequestResponse( + id=friendship.id, + sender=sender_info, + recipient=recipient_info, + content=msg.content, + status=msg.status.value, + created_at=msg.created_at, + )) + + return results + + async def get_outgoing_requests(self) -> list[FriendRequestResponse]: + current_user_id = self.require_user_id() + friendships = await self._repository.get_outgoing_requests(current_user_id) + + results = [] + for friendship in friendships: + sender_info = await self._get_profile_info(current_user_id) + recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id + recipient_info = await self._get_profile_info(recipient_id) + + inbox = await self._repository.get_pending_inbox_for_recipient( + friendship.id, recipient_id + ) + + results.append(FriendRequestResponse( + id=friendship.id, + sender=sender_info, + recipient=recipient_info, + content=inbox.content if inbox else None, + status=friendship.status.value, + created_at=friendship.created_at, + )) + + return results + + async def get_friends_list(self) -> list[FriendResponse]: + current_user_id = self.require_user_id() + friendships = await self._repository.get_friends_list(current_user_id) + + results = [] + for friendship in friendships: + friend_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id + friend_info = await self._get_profile_info(friend_id) + + results.append(FriendResponse( + id=friendship.id, + friend=friend_info, + status=friendship.status.value, + created_at=friendship.created_at, + accepted_at=friendship.accepted_at, + )) + + return results + + async def remove_friend(self, friendship_id: UUID) -> None: + current_user_id = self.require_user_id() + + friendship = await self._repository.get_friendship_by_id(friendship_id) + if not friendship: + raise HTTPException(status_code=404, detail="Friendship not found") + + if friendship.status != FriendshipStatus.ACCEPTED: + raise HTTPException(status_code=400, detail="Can only remove accepted friends") + + # Verify user is part of this friendship + if friendship.user_low_id != current_user_id and friendship.user_high_id != current_user_id: + raise HTTPException(status_code=403, detail="Not authorized") + + # Soft delete - mark as canceled + friendship.status = FriendshipStatus.CANCELED + await self._session.commit() + + async def _get_profile_info(self, user_id: UUID) -> UserBasicInfo: + from sqlalchemy import select + from models.profile import Profile + + stmt = select(Profile).where(Profile.id == user_id) + result = await self._session.execute(stmt) + profile = result.scalar_one_or_none() + + if not profile: + return UserBasicInfo(id=str(user_id), username="Unknown") + + return UserBasicInfo( + id=str(profile.id), + username=profile.username, + avatar_url=profile.avatar_url, + ) +``` + +**Step 4: 运行测试确认通过** + +**Step 5: Commit** + +```bash +git add backend/src/v1/friendships/service.py +git commit -m "feat(friendships): implement service layer" +``` + +--- + +### Task 4: 实现 Dependencies 和 Router + +**Files:** +- Modify: `backend/src/v1/friendships/dependencies.py` +- Modify: `backend/src/v1/friendships/router.py` + +**Step 1: 实现 dependencies** + +```python +# backend/src/v1/friendships/dependencies.py +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db import get_db +from v1.friendships.repository import FriendshipRepository +from v1.friendships.service import FriendshipService +from v1.users.dependencies import get_current_user + + +async def get_friendship_repository( + session: Annotated[AsyncSession, Depends(get_db)] +) -> FriendshipRepository: + return FriendshipRepository(session) + + +async def get_friendship_service( + repository: Annotated[FriendshipRepository, Depends(get_friendship_repository)], + session: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[CurrentUser, Depends(get_current_user)], +) -> FriendshipService: + return FriendshipService( + repository=repository, + session=session, + current_user=user, + ) +``` + +**Step 2: 实现 router** + +```python +# backend/src/v1/friendships/router.py +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, HTTPException + +from v1.friendships.dependencies import get_friendship_service +from v1.friendships.schemas import ( + FriendRequestCreate, + FriendRequestResponse, + FriendResponse, +) +from v1.friendships.service import FriendshipService + + +router = APIRouter(prefix="/friends", tags=["friends"]) + + +@router.post("/requests", response_model=FriendRequestResponse, status_code=201) +async def send_friend_request( + payload: FriendRequestCreate, + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.send_request(payload) + + +@router.get("/requests/inbox", response_model=list[FriendRequestResponse]) +async def get_inbox( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendRequestResponse]: + return await service.get_inbox() + + +@router.get("/requests/outgoing", response_model=list[FriendRequestResponse]) +async def get_outgoing_requests( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendRequestResponse]: + return await service.get_outgoing_requests() + + +@router.post("/requests/{friendship_id}/accept", response_model=FriendRequestResponse) +async def accept_friend_request( + friendship_id: Annotated[UUID, Path()], + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.accept_request(friendship_id) + + +@router.post("/requests/{friendship_id}/decline", response_model=FriendRequestResponse) +async def decline_friend_request( + friendship_id: Annotated[UUID, Path()], + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> FriendRequestResponse: + return await service.decline_request(friendship_id) + + +@router.delete("/requests/{friendship_id}", status_code=204) +async def cancel_friend_request( + friendship_id: Annotated[UUID, Path()], + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> None: + await service.cancel_request(friendship_id) + + +@router.get("", response_model=list[FriendResponse]) +async def get_friends_list( + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> list[FriendResponse]: + return await service.get_friends_list() + + +@router.delete("/{friendship_id}", status_code=204) +async def remove_friend( + friendship_id: Annotated[UUID, Path()], + service: Annotated[FriendshipService, Depends(get_friendship_service)], +) -> None: + await service.remove_friend(friendship_id) +``` + +**Step 3: 注册 router 到主路由** + +```python +# backend/src/v1/router.py +from fastapi import APIRouter +from v1.auth.router import router as auth_router +from v1.users.router import router as users_router +from v1.profile.router import router as profile_router +from v1.friendships.router import router as friendships_router + +router = APIRouter() + +router.include_router(auth_router) +router.include_router(users_router) +router.include_router(profile_router) +router.include_router(friendships_router) +``` + +**Step 4: Commit** + +```bash +git add backend/src/v1/friendships/dependencies.py backend/src/v1/friendships/router.py backend/src/v1/router.py +git commit -m "feat(friendships): implement router and dependencies" +``` + +--- + +### Task 5: 集成测试 + +**Files:** +- Create: `backend/tests/integration/test_friendship_routes.py` + +**Step 1: 写入测试** + +```python +# backend/tests/integration/test_friendship_routes.py +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.pool import StaticPool + +from main import app # FastAPI app +from core.db.base import Base +from core.db import get_db + + +@pytest.fixture +async def async_client(): + # Setup test database + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def override_get_db(): + async with AsyncSession(engine) as session: + yield session + + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_send_friend_request_requires_auth(async_client): + response = await async_client.post( + "/api/v1/friends/requests", + json={"target_user_id": "..."} + ) + assert response.status_code == 401 + + +# More tests... +``` + +**Step 2: 运行测试** + +**Step 3: Commit** + +--- + +### Task 6: 运行 Lint 和 Typecheck + +**Step 1: 运行 ruff** + +```bash +cd backend && uv run ruff check src/v1/friendships/ +``` + +**Step 2: 运行 typecheck** + +```bash +cd backend && uv run basedpyright src/v1/friendships/ +``` + +**Step 3: Commit (if any fixes needed)** + +--- + +### Task 7: 更新文档 + +**Files:** +- Modify: `docs/runtime/runtime-route.md` + +**Step 1: 添加 API 文档** + +```markdown +## Friends + +### Send Friend Request +- **POST** `/api/v1/friends/requests` +- **Auth:** Required +- **Body:** `{ "target_user_id": "uuid", "content": "string?" }` +- **Response:** `FriendRequestResponse` + +### Get Inbox +- **GET** `/api/v1/friends/requests/inbox` +- **Auth:** Required +- **Response:** `FriendRequestResponse[]` + +### Accept Request +- **POST** `/api/v1/friends/requests/{id}/accept` +- **Auth:** Required +- **Response:** `FriendRequestResponse` + +### Decline Request +- **POST** `/api/v1/friends/requests/{id}/decline` +- **Auth:** Required +- **Response:** `FriendRequestResponse` + +### Get Friends List +- **GET** `/api/v1/friends` +- **Auth:** Required +- **Response:** `FriendResponse[]` + +### Remove Friend +- **DELETE** `/api/v1/friends/{id}` +- **Auth:** Required +``` + +**Step 2: Commit** + +```bash +git add docs/runtime/runtime-route.md +git commit -m "docs: add friendship API documentation" +```