From 85b314cf640a16e87873925d4b637f3916421b87 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 11 Mar 2026 17:16:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=B9=B6=E9=9B=86=E6=88=90=20AgentScope=20?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/android/app/build.gradle.kts | 5 + apps/lib/core/di/injection.dart | 3 + .../local_notification_service.dart | 115 +++++ .../data/models/schedule_item_model.dart | 6 + .../ui/screens/calendar_dayweek_screen.dart | 186 ++++++- .../screens/calendar_event_detail_screen.dart | 55 +-- .../ui/screens/calendar_month_screen.dart | 66 ++- .../ui/widgets/create_event_sheet.dart | 89 +++- .../ui/screens/todo_quadrants_screen.dart | 22 +- apps/lib/main.dart | 14 + apps/pubspec.yaml | 2 + .../calendar/data/calendar_api_test.dart | 6 + .../calendar_event_detail_screen_test.dart | 75 +++ .../tools/create_calendar_event_tool.py | 39 +- .../src/core/agentscope/events/__init__.py | 14 + .../src/core/agentscope/events/agui_codec.py | 49 ++ .../src/core/agentscope/events/pipeline.py | 31 ++ .../src/core/agentscope/events/redis_bus.py | 91 ++++ backend/src/core/agentscope/events/sse.py | 29 ++ backend/src/core/agentscope/events/store.py | 12 + .../src/core/agentscope/runtime/__init__.py | 7 +- .../agentscope/runtime/agent_route_runtime.py | 215 ++++++++ backend/src/core/agentscope/runtime/tasks.py | 138 ++++++ .../src/core/agentscope/schemas/__init__.py | 18 + .../core/agentscope/schemas/agent_runtime.py | 68 +++ .../core/agentscope/tools/custom/calendar.py | 17 + backend/src/v1/agent/dependencies.py | 30 +- backend/src/v1/agent/router.py | 2 +- backend/src/v1/agent/service.py | 19 + backend/src/v1/schedule_items/schemas.py | 1 + backend/src/v1/schedule_items/service.py | 5 +- .../agentscope/test_runtime_calendar_smoke.py | 73 +-- .../agent/test_mutate_calendar_event_tool.py | 80 ++- .../core/agentscope/events/test_agui_codec.py | 42 ++ .../core/agentscope/events/test_pipeline.py | 32 ++ .../core/agentscope/events/test_redis_bus.py | 71 +++ .../unit/core/agentscope/events/test_sse.py | 25 + .../runtime/test_agent_route_runtime.py | 139 ++++++ .../core/agentscope/runtime/test_tasks.py | 138 ++++++ .../schemas/test_agent_runtime_schemas.py | 106 ++++ .../core/agentscope/test_calendar_tools.py | 47 ++ .../unit/v1/schedule_items/test_schemas.py | 12 + .../unit/v1/schedule_items/test_service.py | 47 +- ...-auth-token-compat-refresh-singleflight.md | 69 --- ...gentscope-agent-route-migration-handoff.md | 141 ++++++ ...-03-11-agentscope-agent-route-migration.md | 308 ++++++++++++ ...-11-calendar-dayview-improvement-design.md | 47 ++ ...03-11-calendar-dayview-improvement-impl.md | 223 +++++++++ ...alendar-metadata-and-api-implementation.md | 78 --- ...03-11-calendar-reminder-metadata-design.md | 63 +++ ...6-03-11-calendar-reminder-metadata-impl.md | 170 +++++++ .../2026-03-11-home-image-picker-design.md | 136 +++++ .../2026-03-11-home-image-picker-impl.md | 463 ++++++++++++++++++ 53 files changed, 3642 insertions(+), 297 deletions(-) create mode 100644 apps/lib/core/notifications/local_notification_service.dart create mode 100644 apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart create mode 100644 backend/src/core/agentscope/events/__init__.py create mode 100644 backend/src/core/agentscope/events/agui_codec.py create mode 100644 backend/src/core/agentscope/events/pipeline.py create mode 100644 backend/src/core/agentscope/events/redis_bus.py create mode 100644 backend/src/core/agentscope/events/sse.py create mode 100644 backend/src/core/agentscope/events/store.py create mode 100644 backend/src/core/agentscope/runtime/agent_route_runtime.py create mode 100644 backend/src/core/agentscope/runtime/tasks.py create mode 100644 backend/src/core/agentscope/schemas/agent_runtime.py create mode 100644 backend/tests/unit/core/agentscope/events/test_agui_codec.py create mode 100644 backend/tests/unit/core/agentscope/events/test_pipeline.py create mode 100644 backend/tests/unit/core/agentscope/events/test_redis_bus.py create mode 100644 backend/tests/unit/core/agentscope/events/test_sse.py create mode 100644 backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py create mode 100644 backend/tests/unit/core/agentscope/runtime/test_tasks.py create mode 100644 backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py delete mode 100644 docs/plans/2026-03-10-auth-token-compat-refresh-singleflight.md create mode 100644 docs/plans/2026-03-11-agentscope-agent-route-migration-handoff.md create mode 100644 docs/plans/2026-03-11-agentscope-agent-route-migration.md create mode 100644 docs/plans/2026-03-11-calendar-dayview-improvement-design.md create mode 100644 docs/plans/2026-03-11-calendar-dayview-improvement-impl.md delete mode 100644 docs/plans/2026-03-11-calendar-metadata-and-api-implementation.md create mode 100644 docs/plans/2026-03-11-calendar-reminder-metadata-design.md create mode 100644 docs/plans/2026-03-11-calendar-reminder-metadata-impl.md create mode 100644 docs/plans/2026-03-11-home-image-picker-design.md create mode 100644 docs/plans/2026-03-11-home-image-picker-impl.md diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index f87f188..ae08534 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -11,6 +11,7 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -42,3 +43,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") +} diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 008f49f..4c737cd 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -6,6 +6,7 @@ import '../api/i_api_client.dart'; import '../api/mock_api_client.dart'; import '../storage/token_storage.dart'; import '../config/env.dart'; +import '../notifications/local_notification_service.dart'; import '../../features/auth/data/auth_api.dart'; import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository_impl.dart'; @@ -56,6 +57,8 @@ Future configureDependencies() async { ); sl.registerSingleton(calendarService); + sl.registerSingleton(LocalNotificationService()); + final friendsApi = FriendsApi(apiClient); sl.registerSingleton(friendsApi); diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart new file mode 100644 index 0000000..996306a --- /dev/null +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -0,0 +1,115 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +import '../../features/calendar/data/models/schedule_item_model.dart'; + +class LocalNotificationService { + final FlutterLocalNotificationsPlugin _plugin; + bool _initialized = false; + + LocalNotificationService({FlutterLocalNotificationsPlugin? plugin}) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + Future initialize() async { + if (_initialized) { + return; + } + tz_data.initializeTimeZones(); + + const android = AndroidInitializationSettings('@mipmap/ic_launcher'); + const ios = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + const settings = InitializationSettings(android: android, iOS: ios); + + await _plugin.initialize(settings); + + await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission(); + + await _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true); + + _initialized = true; + } + + Future upsertEventReminder(ScheduleItemModel event) async { + await initialize(); + + final reminderMinutes = event.metadata?.reminderMinutes; + if (reminderMinutes == null) { + await cancelEventReminder(event.id); + return; + } + + final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes)); + if (!fireAt.isAfter(DateTime.now())) { + await cancelEventReminder(event.id); + return; + } + + final notificationId = _notificationIdForEvent(event.id); + final scheduledAt = tz.TZDateTime.from(fireAt, tz.local); + + final details = NotificationDetails( + android: AndroidNotificationDetails( + 'calendar_reminder_channel', + '日历提醒', + channelDescription: '日历事件提醒通知', + importance: Importance.max, + priority: Priority.high, + enableVibration: true, + ), + iOS: const DarwinNotificationDetails( + presentAlert: true, + presentSound: true, + presentBadge: true, + ), + ); + + await _plugin.zonedSchedule( + notificationId, + event.title, + _buildReminderBody(event, reminderMinutes), + scheduledAt, + details, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + ); + } + + Future cancelEventReminder(String eventId) async { + await initialize(); + await _plugin.cancel(_notificationIdForEvent(eventId)); + } + + Future rebuildUpcomingReminders( + Iterable events, + ) async { + await initialize(); + for (final event in events) { + await upsertEventReminder(event); + } + } + + int _notificationIdForEvent(String eventId) { + return eventId.hashCode & 0x7fffffff; + } + + String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) { + if (reminderMinutes == 0) { + return '日程现在开始:${event.title}'; + } + return '日程即将开始(提前$reminderMinutes分钟):${event.title}'; + } +} diff --git a/apps/lib/features/calendar/data/models/schedule_item_model.dart b/apps/lib/features/calendar/data/models/schedule_item_model.dart index c46814f..6bbda3b 100644 --- a/apps/lib/features/calendar/data/models/schedule_item_model.dart +++ b/apps/lib/features/calendar/data/models/schedule_item_model.dart @@ -112,6 +112,7 @@ class ScheduleMetadata { final String? color; final String? location; final String? notes; + final int? reminderMinutes; final List attachments; final int version; final Map raw; @@ -120,6 +121,7 @@ class ScheduleMetadata { this.color, this.location, this.notes, + this.reminderMinutes, List? attachments, this.version = 1, Map? raw, @@ -130,6 +132,7 @@ class ScheduleMetadata { String? color, String? location, String? notes, + int? reminderMinutes, List? attachments, int? version, Map? raw, @@ -138,6 +141,7 @@ class ScheduleMetadata { color: color ?? this.color, location: location ?? this.location, notes: notes ?? this.notes, + reminderMinutes: reminderMinutes ?? this.reminderMinutes, attachments: attachments ?? this.attachments, version: version ?? this.version, raw: raw ?? this.raw, @@ -156,6 +160,7 @@ class ScheduleMetadata { color: json['color'] as String?, location: json['location'] as String?, notes: json['notes'] as String?, + reminderMinutes: json['reminder_minutes'] as int?, attachments: attachments, version: (json['version'] as int?) ?? 1, raw: Map.from(json), @@ -167,6 +172,7 @@ class ScheduleMetadata { 'color': color, 'location': location, 'notes': notes, + 'reminder_minutes': reminderMinutes, 'attachments': attachments.map((item) => item.toJson()).toList(), 'version': version, }; diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index da4e687..9fd4106 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -24,11 +24,19 @@ class CalendarDayWeekScreen extends StatefulWidget { State createState() => _CalendarDayWeekScreenState(); } -class _CalendarDayWeekScreenState extends State { +class _CalendarDayWeekScreenState extends State + with WidgetsBindingObserver { static const double _dayItemWidth = 44; static const double _dayItemGap = 12; - static const double _hourHeight = 34; static const double _eventLeftOffset = 52; + static const double _defaultHourHeight = 34.0; + static const double _minHourHeight = 17.0; + static const double _maxHourHeight = 68.0; + + double _hourHeight = _defaultHourHeight; + final Map _activePointers = {}; + double? _pinchStartDistance; + double _pinchStartHourHeight = _defaultHourHeight; late final CalendarStateManager _calendarManager; late DateTime _selectedDate; @@ -36,10 +44,12 @@ class _CalendarDayWeekScreenState extends State { final ScrollController _dayStripController = ScrollController(); Key _eventsKey = UniqueKey(); List _events = const []; + String? _lastRoute; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _calendarManager = sl(); if (widget.resetToToday) { @@ -52,9 +62,22 @@ class _CalendarDayWeekScreenState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(); + _lastRoute = GoRouterState.of(context).uri.toString(); }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentRoute = GoRouterState.of(context).uri.toString(); + if (_lastRoute != null && _lastRoute != currentRoute) { + if (!currentRoute.contains('/events/')) { + _loadEvents(); + } + } + _lastRoute = currentRoute; + } + void _updateMonthDates() { _monthDates = monthDatesFor(_selectedDate); } @@ -71,47 +94,135 @@ class _CalendarDayWeekScreenState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _dayStripController.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _loadEvents(); + } + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.todoBg, - body: SafeArea( - child: Column( - children: [ - _buildHeader(), - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only( - left: AppSpacing.lg, - right: AppSpacing.lg, - top: 2, - bottom: 104, - ), - child: Column( - children: [ - _buildWeekStrip(), - const SizedBox(height: 8), - KeyedSubtree( + body: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + context.go('/calendar/month?date=${formatYmd(_selectedDate)}'); + } + }, + child: SafeArea( + child: Stack( + children: [ + Positioned.fill( + top: 154, + bottom: 84, + child: Listener( + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: HitTestBehavior.translucent, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.lg, + right: AppSpacing.lg, + top: 2, + ), + child: KeyedSubtree( key: _eventsKey, child: _buildTimelineBoard(), ), - ], + ), ), ), ), - ), - _buildBottomDock(), - ], + Positioned(top: 0, left: 0, right: 0, child: _buildHeader()), + Positioned(top: 68, left: 0, right: 0, child: _buildWeekStrip()), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _buildBottomDock(), + ), + ], + ), ), ), ); } + void _goToToday() { + final today = DateTime.now(); + setState(() { + _selectedDate = today; + _hourHeight = _defaultHourHeight; + }); + _calendarManager.setSelectedDate(today); + _updateMonthDates(); + _scrollToSelectedDate(animate: true); + _loadEvents(); + } + + void _handlePointerDown(PointerDownEvent event) { + _activePointers[event.pointer] = event.position; + if (_activePointers.length == 2) { + final pointers = _activePointers.values.toList(growable: false); + _pinchStartDistance = (pointers[0] - pointers[1]).distance; + _pinchStartHourHeight = _hourHeight; + } + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_activePointers.containsKey(event.pointer)) { + return; + } + _activePointers[event.pointer] = event.position; + if (_activePointers.length != 2 || _pinchStartDistance == null) { + return; + } + + final pointers = _activePointers.values.toList(growable: false); + final currentDistance = (pointers[0] - pointers[1]).distance; + final startDistance = _pinchStartDistance!; + if (startDistance <= 0) { + return; + } + + final nextHeight = + (_pinchStartHourHeight * (currentDistance / startDistance)).clamp( + _minHourHeight, + _maxHourHeight, + ); + if ((nextHeight - _hourHeight).abs() < 0.1) { + return; + } + setState(() { + _hourHeight = nextHeight; + }); + } + + void _handlePointerUp(PointerUpEvent event) { + _activePointers.remove(event.pointer); + if (_activePointers.length < 2) { + _pinchStartDistance = null; + } + } + + void _handlePointerCancel(PointerCancelEvent event) { + _activePointers.remove(event.pointer); + if (_activePointers.length < 2) { + _pinchStartDistance = null; + } + } + Widget _buildHeader() { return SizedBox( height: 68, @@ -151,6 +262,31 @@ class _CalendarDayWeekScreenState extends State { ), ), const Spacer(), + if (!isSameDay(_selectedDate, DateTime.now())) + GestureDetector( + onTap: _goToToday, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: AppColors.messageBtnWrap, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.messageBtnBorder), + ), + child: const Center( + child: Text( + '今天', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + ), + ), + ), + if (!isSameDay(_selectedDate, DateTime.now())) + const SizedBox(width: 8), GestureDetector( onTap: () => CreateEventSheet.show( context, @@ -440,7 +576,7 @@ class _CalendarDayWeekScreenState extends State { bool isDisabled = false, }) { return SizedBox( - height: 34, + height: _hourHeight, child: Row( children: [ SizedBox( diff --git a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart index c877622..e73498e 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../data/services/mock_calendar_service.dart'; import '../../data/models/schedule_item_model.dart'; @@ -161,7 +162,10 @@ class _CalendarEventDetailScreenState extends State { const SizedBox(height: 14), _buildDetailField('时间范围', timeStr), const SizedBox(height: 14), - _buildDetailField('提醒时间', '开始前30分钟'), + _buildDetailField( + '提醒时间', + _formatReminderText(event.metadata?.reminderMinutes), + ), const SizedBox(height: 14), _buildColorField(event.metadata?.color), const SizedBox(height: 14), @@ -176,8 +180,6 @@ class _CalendarEventDetailScreenState extends State { if (event.metadata?.notes != null) ...[ _buildNotesField(event.metadata!.notes!), ], - const SizedBox(height: 14), - _buildMetadataSection(event.metadata), ], ), ), @@ -186,6 +188,16 @@ class _CalendarEventDetailScreenState extends State { ); } + String _formatReminderText(int? reminderMinutes) { + if (reminderMinutes == null) { + return '无'; + } + if (reminderMinutes == 0) { + return '准时提醒'; + } + return '开始前$reminderMinutes分钟'; + } + String _getWeekday(int weekday) { const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; return weekdays[weekday - 1]; @@ -290,6 +302,9 @@ class _CalendarEventDetailScreenState extends State { TextButton( onPressed: () async { await sl().deleteEvent(widget.eventId); + await sl().cancelEventReminder( + widget.eventId, + ); if (!context.mounted) { return; } @@ -385,40 +400,6 @@ class _CalendarEventDetailScreenState extends State { ); } - Widget _buildMetadataSection(ScheduleMetadata? metadata) { - final raw = metadata?.raw ?? const {}; - if (raw.isEmpty) { - return _buildDetailField('metadata', '无'); - } - final rows = []; - raw.forEach((key, value) { - rows.add('$key: $value'); - }); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'metadata', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: AppColors.slate400, - ), - ), - const SizedBox(height: 6), - ...rows.map( - (row) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text( - row, - style: const TextStyle(fontSize: 13, color: AppColors.slate700), - ), - ), - ), - ], - ); - } - Color _parseColor(String? hex) { if (hex == null || hex.isEmpty) return AppColors.blue600; try { diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index a10fc9e..9641af3 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -20,16 +20,19 @@ class CalendarMonthScreen extends StatefulWidget { State createState() => _CalendarMonthScreenState(); } -class _CalendarMonthScreenState extends State { +class _CalendarMonthScreenState extends State + with WidgetsBindingObserver { late final CalendarStateManager _calendarManager; late DateTime _currentMonth; late DateTime _selectedDate; Key _eventsKey = UniqueKey(); final Map> _eventsByDay = {}; + String? _lastRoute; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _calendarManager = sl(); if (widget.resetToToday) { @@ -40,6 +43,22 @@ class _CalendarMonthScreenState extends State { _selectedDate = savedDate; _currentMonth = DateTime(savedDate.year, savedDate.month, 1); _loadMonthEvents(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _lastRoute = GoRouterState.of(context).uri.toString(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final currentRoute = GoRouterState.of(context).uri.toString(); + if (_lastRoute != null && _lastRoute != currentRoute) { + if (!currentRoute.contains('/events/')) { + _loadMonthEvents(); + } + } + _lastRoute = currentRoute; } Future _loadMonthEvents() async { @@ -64,24 +83,45 @@ class _CalendarMonthScreenState extends State { setState(() {}); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _loadMonthEvents(); + } + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.todoBg, - body: SafeArea( - child: Column( - children: [ - _buildHeader(), - Expanded( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.only(bottom: 84), - child: _buildMonthContent(), + body: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + context.go('/home'); + } + }, + child: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(bottom: 84), + child: _buildMonthContent(), + ), ), ), - ), - _buildBottomDock(), - ], + _buildBottomDock(), + ], + ), ), ), ); diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 08de9fc..d67ff1b 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/notifications/local_notification_service.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../data/models/schedule_item_model.dart'; import '../../data/services/mock_calendar_service.dart'; @@ -63,6 +64,7 @@ class _CreateEventSheetState extends State DateTime? _endDate; DateTime? _endTime; String _selectedColor = '#3B82F6'; + int? _reminderMinutes = 15; bool _saving = false; List _attachments = const []; @@ -84,11 +86,13 @@ class _CreateEventSheetState extends State _endDate = event.endAt; _endTime = event.endAt; _selectedColor = event.metadata?.color ?? '#3B82F6'; + _reminderMinutes = event.metadata?.reminderMinutes ?? 15; _attachments = List.from( event.metadata?.attachments ?? const [], ); } else { - final now = widget.initialDate ?? DateTime.now(); + final now = + widget.initialDate ?? _roundToNearestMinute(DateTime.now(), 5); _startDate = now; _startTime = now; _endDate = now; @@ -108,6 +112,11 @@ class _CreateEventSheetState extends State super.dispose(); } + DateTime _roundToNearestMinute(DateTime dt, int interval) { + final minute = (dt.minute / interval).round() * interval; + return DateTime(dt.year, dt.month, dt.day, dt.hour, minute % 60); + } + @override Widget build(BuildContext context) { return Container( @@ -254,6 +263,8 @@ class _CreateEventSheetState extends State const SizedBox(height: 20), _buildTextField('地点', _locationController, '请输入地点'), const SizedBox(height: 20), + _buildReminderPicker(), + const SizedBox(height: 20), _buildColorPicker(), const SizedBox(height: 20), _buildAttachmentsSection(), @@ -706,6 +717,68 @@ class _CreateEventSheetState extends State ); } + Widget _buildReminderPicker() { + const options = [null, 0, 5, 10, 15, 30, 60, 120]; + String labelOf(int? value) { + if (value == null) { + return '无提醒'; + } + if (value == 0) { + return '准时提醒'; + } + return '开始前$value分钟'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '提醒时间', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.slate700, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _reminderMinutes, + isExpanded: true, + items: options + .map( + (value) => DropdownMenuItem( + value: value, + child: Text( + labelOf(value), + style: const TextStyle( + fontSize: 14, + color: AppColors.slate700, + ), + ), + ), + ) + .toList(), + onChanged: (value) { + setState(() { + _reminderMinutes = value; + }); + }, + ), + ), + ), + ], + ); + } + Future _saveEvent() async { if (_titleController.text.trim().isEmpty || _saving) return; setState(() { @@ -739,6 +812,7 @@ class _CreateEventSheetState extends State notes: _notesController.text.trim().isNotEmpty ? _notesController.text.trim() : null, + reminderMinutes: _reminderMinutes, attachments: _attachments, version: widget.editingEvent?.metadata?.version ?? 1, ); @@ -758,22 +832,21 @@ class _CreateEventSheetState extends State try { final service = sl(); - debugPrint('CalendarService: $service'); - debugPrint('Is mock: ${service.runtimeType}'); + final notificationService = sl(); + late final ScheduleItemModel saved; if (_isEditing) { - await service.updateEvent(event); + saved = await service.updateEvent(event); } else { - await service.addEvent(event); + saved = await service.addEvent(event); } + await notificationService.upsertEventReminder(saved); widget.onSaved?.call(); if (mounted) { Navigator.pop(context); } - } catch (e, stack) { - debugPrint('Save error: $e'); - debugPrint('Stack: $stack'); + } catch (e) { if (mounted) { ScaffoldMessenger.of( context, diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 2eff3d6..d8947cf 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -45,13 +45,21 @@ class _TodoQuadrantsScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.todoBg, - body: SafeArea( - child: Column( - children: [ - _buildHeader(), - Expanded(child: _buildContent()), - _buildBottomDock(), - ], + body: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + context.go('/home'); + } + }, + child: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded(child: _buildContent()), + _buildBottomDock(), + ], + ), ), ), ); diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 07807b0..690e9a8 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -4,14 +4,19 @@ import 'core/config/env.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; +import 'core/notifications/local_notification_service.dart'; import 'features/auth/data/models/auth_response.dart'; import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; +import 'features/calendar/data/services/mock_calendar_service.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await configureDependencies(); + final notificationService = sl(); + await notificationService.initialize(); + final authBloc = sl(); if (Env.isMockApi) { @@ -24,6 +29,15 @@ void main() async { authBloc.add(AuthStarted()); } + try { + final now = DateTime.now(); + final end = now.add(const Duration(days: 90)); + final events = await sl().getEventsForRange(now, end); + await notificationService.rebuildUpcomingReminders(events); + } catch (_) { + // ignore startup sync failures + } + runApp(LinksyApp(authBloc: authBloc)); } diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 8718dba..de2611c 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: shared_preferences: ^2.2.2 json_annotation: ^4.8.1 record: ^6.1.1 + flutter_local_notifications: ^17.2.4 + timezone: ^0.9.4 dev_dependencies: flutter_test: diff --git a/apps/test/features/calendar/data/calendar_api_test.dart b/apps/test/features/calendar/data/calendar_api_test.dart index b4609e5..612964d 100644 --- a/apps/test/features/calendar/data/calendar_api_test.dart +++ b/apps/test/features/calendar/data/calendar_api_test.dart @@ -22,6 +22,7 @@ void main() { 'color': '#4F46E5', 'location': '会议室A', 'notes': '带电脑', + 'reminder_minutes': 15, 'attachments': [ { 'name': '议程文档', @@ -52,6 +53,7 @@ void main() { expect(result, hasLength(1)); expect(result.first.metadata?.attachments, hasLength(1)); expect(result.first.metadata?.raw['new_field'], 'future'); + expect(result.first.metadata?.reminderMinutes, 15); expect(result.first.startAt.isUtc, isFalse); }); @@ -60,6 +62,7 @@ void main() { client.registerHandler('/api/v1/schedule-items', 'POST', (request) { final body = request.data as Map; expect(body['metadata']['version'], 1); + expect(body['metadata']['reminder_minutes'], 15); expect(body['metadata']['attachments'], isA>()); return { 'id': 'evt_2', @@ -83,6 +86,7 @@ void main() { location: '线上', notes: '准备 demo', attachments: [Attachment(name: 'PRD', type: 'document')], + reminderMinutes: 15, version: 1, ), ), @@ -100,6 +104,7 @@ void main() { final body = request.data as Map; final metadata = body['metadata'] as Map; expect(metadata.containsKey('new_field'), isFalse); + expect(metadata['reminder_minutes'], 30); return { 'id': 'evt_3', ...body, @@ -121,6 +126,7 @@ void main() { 'notes': '更新周报', 'attachments': const [], 'version': 1, + 'reminder_minutes': 30, 'new_field': 'future', }), ); diff --git a/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart b/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart new file mode 100644 index 0000000..4743f38 --- /dev/null +++ b/apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/features/calendar/data/models/schedule_item_model.dart'; +import 'package:social_app/features/calendar/data/services/mock_calendar_service.dart'; +import 'package:social_app/features/calendar/ui/screens/calendar_event_detail_screen.dart'; + +class _FakeCalendarService extends CalendarService { + final ScheduleItemModel? event; + + _FakeCalendarService({required this.event}) : super(apiClient: null); + + @override + Future getEventById(String id) async { + return event; + } +} + +void main() { + final getIt = GetIt.instance; + + setUp(() async { + await getIt.reset(); + }); + + testWidgets('详情页显示结构化提醒时间并不显示metadata原样区块', (tester) async { + sl.registerSingleton( + _FakeCalendarService( + event: ScheduleItemModel( + id: 'evt_1', + title: '评审会', + startAt: DateTime(2026, 3, 11, 15, 0), + endAt: DateTime(2026, 3, 11, 16, 0), + metadata: ScheduleMetadata( + color: '#4F46E5', + location: '会议室A', + reminderMinutes: 15, + version: 1, + ), + ), + ), + ); + + await tester.pumpWidget( + const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_1')), + ); + await tester.pumpAndSettle(); + + expect(find.text('提醒时间'), findsOneWidget); + expect(find.text('开始前15分钟'), findsOneWidget); + expect(find.text('metadata'), findsNothing); + }); + + testWidgets('提醒分钟为空时显示无', (tester) async { + sl.registerSingleton( + _FakeCalendarService( + event: ScheduleItemModel( + id: 'evt_2', + title: '同步会', + startAt: DateTime(2026, 3, 12, 10, 0), + metadata: ScheduleMetadata(version: 1), + ), + ), + ); + + await tester.pumpWidget( + const MaterialApp(home: CalendarEventDetailScreen(eventId: 'evt_2')), + ); + await tester.pumpAndSettle(); + + expect(find.text('提醒时间'), findsOneWidget); + expect(find.text('无'), findsOneWidget); + }); +} diff --git a/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py b/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py index e694358..0548ac4 100644 --- a/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py +++ b/backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py @@ -83,7 +83,23 @@ def _resolve_metadata(tool_args: dict[str, object]) -> ScheduleItemMetadata: color = tool_args.get("color") raw_color = color.strip() if isinstance(color, str) and color.strip() else "#4F46E5" color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5" - return ScheduleItemMetadata(location=location_value, color=color_value) + reminder_raw = tool_args.get("reminderMinutes") + reminder_value: int | None = None + if isinstance(reminder_raw, bool): + reminder_value = None + elif isinstance(reminder_raw, (int, float, str)): + try: + parsed = int(str(reminder_raw).strip()) + if parsed < 0 or parsed > 10080: + raise ValueError("reminderMinutes must be 0..10080") + reminder_value = parsed + except ValueError as exc: + raise ValueError("reminderMinutes must be an integer in 0..10080") from exc + return ScheduleItemMetadata( + location=location_value, + color=color_value, + reminder_minutes=reminder_value, + ) def _event_payload(event: object) -> dict[str, object]: @@ -91,6 +107,7 @@ def _event_payload(event: object) -> dict[str, object]: metadata = getattr(event, "metadata", None) location_value = getattr(metadata, "location", None) color_value = getattr(metadata, "color", None) or "#4F46E5" + reminder_minutes_value = getattr(metadata, "reminder_minutes", None) return { "id": event_id, "title": getattr(event, "title"), @@ -104,6 +121,7 @@ def _event_payload(event: object) -> dict[str, object]: "timezone": getattr(event, "timezone"), "location": location_value, "color": color_value, + "reminderMinutes": reminder_minutes_value, } @@ -221,7 +239,8 @@ async def _execute_update( ) from exc has_location = isinstance(tool_args.get("location"), str) has_color = isinstance(tool_args.get("color"), str) - if has_location or has_color: + has_reminder = "reminderMinutes" in tool_args + if has_location or has_color or has_reminder: existing = await service.get_by_id(event_id) metadata_dump = ( existing.metadata.model_dump() if existing.metadata is not None else {} @@ -236,6 +255,22 @@ async def _execute_update( metadata_dump["color"] = color else: raise ValueError("color must be a hex string like #RRGGBB") + if has_reminder: + reminder_raw = tool_args.get("reminderMinutes") + if reminder_raw is None: + metadata_dump["reminder_minutes"] = None + elif isinstance(reminder_raw, bool): + raise ValueError("reminderMinutes must be an integer in 0..10080") + else: + try: + reminder = int(str(reminder_raw).strip()) + except ValueError as exc: + raise ValueError( + "reminderMinutes must be an integer in 0..10080" + ) from exc + if reminder < 0 or reminder > 10080: + raise ValueError("reminderMinutes must be 0..10080") + metadata_dump["reminder_minutes"] = reminder update_data["metadata"] = ScheduleItemMetadata.model_validate(metadata_dump) updated = await service.update( diff --git a/backend/src/core/agentscope/events/__init__.py b/backend/src/core/agentscope/events/__init__.py new file mode 100644 index 0000000..1c6c2b0 --- /dev/null +++ b/backend/src/core/agentscope/events/__init__.py @@ -0,0 +1,14 @@ +from core.agentscope.events.agui_codec import AgentScopeAgUiCodec, to_agui_wire_event +from core.agentscope.events.pipeline import AgentScopeEventPipeline +from core.agentscope.events.redis_bus import RedisStreamBus +from core.agentscope.events.sse import to_sse_event +from core.agentscope.events.store import NullEventStore + +__all__ = [ + "AgentScopeAgUiCodec", + "AgentScopeEventPipeline", + "RedisStreamBus", + "NullEventStore", + "to_agui_wire_event", + "to_sse_event", +] diff --git a/backend/src/core/agentscope/events/agui_codec.py b/backend/src/core/agentscope/events/agui_codec.py new file mode 100644 index 0000000..2618555 --- /dev/null +++ b/backend/src/core/agentscope/events/agui_codec.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any, cast + +_TYPE_MAP: dict[str, str] = { + "run.started": "RUN_STARTED", + "run.finished": "RUN_FINISHED", + "run.error": "RUN_ERROR", + "step.start": "STEP_STARTED", + "step.finish": "STEP_FINISHED", + "text.start": "TEXT_MESSAGE_START", + "text.delta": "TEXT_MESSAGE_CONTENT", + "text.end": "TEXT_MESSAGE_END", + "tool.start": "TOOL_CALL_START", + "tool.args": "TOOL_CALL_ARGS", + "tool.end": "TOOL_CALL_END", + "tool.result": "TOOL_CALL_RESULT", + "tool.error": "TOOL_CALL_ERROR", + "state.snapshot": "STATE_SNAPSHOT", + "messages.snapshot": "MESSAGES_SNAPSHOT", +} + + +def to_agui_wire_event(event: dict[str, Any]) -> dict[str, Any]: + event_type = str(event.get("type", "")).strip() + wire_type = _TYPE_MAP.get(event_type, event_type.upper().replace(".", "_")) + + payload: dict[str, Any] = { + "type": wire_type, + } + thread_id = event.get("threadId") + run_id = event.get("runId") + if isinstance(thread_id, str) and thread_id: + payload["threadId"] = thread_id + if isinstance(run_id, str) and run_id: + payload["runId"] = run_id + + data = event.get("data") + if isinstance(data, dict): + reserved = {"type", "threadId", "runId"} + data_map = cast(dict[str, Any], data) + payload.update({k: v for k, v in data_map.items() if k not in reserved}) + + return payload + + +class AgentScopeAgUiCodec: + def to_wire(self, event: dict[str, Any]) -> dict[str, Any]: + return to_agui_wire_event(event) diff --git a/backend/src/core/agentscope/events/pipeline.py b/backend/src/core/agentscope/events/pipeline.py new file mode 100644 index 0000000..88e9330 --- /dev/null +++ b/backend/src/core/agentscope/events/pipeline.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Any, Protocol + + +class CodecLike(Protocol): + def to_wire(self, event: dict[str, Any]) -> dict[str, Any]: ... + + +class StoreLike(Protocol): + async def persist(self, event: dict[str, Any]) -> None: ... + + +class BusLike(Protocol): + async def publish(self, *, session_id: str, event: dict[str, Any]) -> str: ... + + +class AgentScopeEventPipeline: + _codec: CodecLike + _store: StoreLike + _bus: BusLike + + def __init__(self, *, codec: CodecLike, store: StoreLike, bus: BusLike) -> None: + self._codec = codec + self._store = store + self._bus = bus + + async def emit(self, *, session_id: str, event: dict[str, Any]) -> str: + wire_event = self._codec.to_wire(event) + await self._store.persist(wire_event) + return await self._bus.publish(session_id=session_id, event=wire_event) diff --git a/backend/src/core/agentscope/events/redis_bus.py b/backend/src/core/agentscope/events/redis_bus.py new file mode 100644 index 0000000..18531f3 --- /dev/null +++ b/backend/src/core/agentscope/events/redis_bus.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import inspect +import json +from typing import Any, Protocol, cast + + +class RedisStreamClient(Protocol): + def xadd(self, *args: Any, **kwargs: Any) -> Any: ... + + def xread(self, *args: Any, **kwargs: Any) -> Any: ... + + +class RedisStreamBus: + _client: RedisStreamClient + _stream_prefix: str + _read_count: int + _block_ms: int + + def __init__( + self, + *, + client: RedisStreamClient, + stream_prefix: str, + read_count: int = 100, + block_ms: int = 5000, + ) -> None: + self._client = client + self._stream_prefix = stream_prefix + self._read_count = read_count + self._block_ms = block_ms + + async def publish(self, *, session_id: str, event: dict[str, Any]) -> str: + payload = json.dumps(event, ensure_ascii=True, separators=(",", ":")) + result = self._client.xadd(self._stream_name(session_id), {"event": payload}) + if inspect.isawaitable(result): + return str(await result) + return str(result) + + async def read( + self, + *, + session_id: str, + last_event_id: str | None, + ) -> list[dict[str, Any]]: + stream = self._stream_name(session_id) + start_id = "0-0" if last_event_id is None else last_event_id + raw = self._client.xread( + {stream: start_id}, + count=self._read_count, + block=self._block_ms, + ) + response = await raw if inspect.isawaitable(raw) else raw + if not response: + return [] + + first = response[0] + if ( + not isinstance(first, tuple) + or len(first) != 2 + or not isinstance(first[1], list) + ): + return [] + + entries = cast(list[tuple[str, dict[str, Any]]], first[1]) + rows: list[dict[str, Any]] = [] + for entry in entries: + if ( + not isinstance(entry, tuple) + or len(entry) != 2 + or not isinstance(entry[0], str) + or not isinstance(entry[1], dict) + ): + continue + payload_map = cast(dict[str, Any], entry[1]) + event_payload = payload_map.get("event") + if isinstance(event_payload, bytes): + event_payload = event_payload.decode("utf-8", errors="replace") + if not isinstance(event_payload, str): + continue + try: + decoded = json.loads(event_payload) + except (TypeError, ValueError): + continue + if not isinstance(decoded, dict): + continue + rows.append({"id": entry[0], "event": decoded}) + return rows + + def _stream_name(self, session_id: str) -> str: + return f"{self._stream_prefix}:{session_id}" diff --git a/backend/src/core/agentscope/events/sse.py b/backend/src/core/agentscope/events/sse.py new file mode 100644 index 0000000..b1f2826 --- /dev/null +++ b/backend/src/core/agentscope/events/sse.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from ag_ui.core.events import BaseEvent +from ag_ui.encoder.encoder import EventEncoder + +_EVENT_TYPE_RE = re.compile(r"^[A-Z0-9_]+$") +_ENCODER = EventEncoder() + + +def to_sse_event(stream_id: str, event: dict[str, Any]) -> str: + safe_stream_id = str(stream_id).replace("\r", "").replace("\n", "") + try: + event_model = BaseEvent.model_validate(event) + event_type = event_model.type.value + encoded_data = _ENCODER.encode(event_model) + return f"id: {safe_stream_id}\nevent: {event_type}\n{encoded_data}" + except Exception: # noqa: BLE001 + raw_event_type = ( + str(event.get("type", "MESSAGE")).replace("\r", "").replace("\n", "") + ) + event_type = ( + raw_event_type if _EVENT_TYPE_RE.fullmatch(raw_event_type) else "MESSAGE" + ) + payload = json.dumps(event, ensure_ascii=True, separators=(",", ":")) + return f"id: {safe_stream_id}\nevent: {event_type}\ndata: {payload}\n\n" diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py new file mode 100644 index 0000000..b3e7c5c --- /dev/null +++ b/backend/src/core/agentscope/events/store.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any, Protocol + + +class EventStore(Protocol): + async def persist(self, event: dict[str, Any]) -> None: ... + + +class NullEventStore: + async def persist(self, event: dict[str, Any]) -> None: + del event diff --git a/backend/src/core/agentscope/runtime/__init__.py b/backend/src/core/agentscope/runtime/__init__.py index f7ee976..cdff153 100644 --- a/backend/src/core/agentscope/runtime/__init__.py +++ b/backend/src/core/agentscope/runtime/__init__.py @@ -1,4 +1,9 @@ +from core.agentscope.runtime.agent_route_runtime import AgentRouteRuntime from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator from core.agentscope.runtime.react_runner import AgentScopeReActRunner -__all__ = ["AgentScopeRuntimeOrchestrator", "AgentScopeReActRunner"] +__all__ = [ + "AgentRouteRuntime", + "AgentScopeRuntimeOrchestrator", + "AgentScopeReActRunner", +] diff --git a/backend/src/core/agentscope/runtime/agent_route_runtime.py b/backend/src/core/agentscope/runtime/agent_route_runtime.py new file mode 100644 index 0000000..3d69f37 --- /dev/null +++ b/backend/src/core/agentscope/runtime/agent_route_runtime.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from core.agent.domain.user_context import UserAgentContext +from core.logging import get_logger +from core.agentscope.schemas import RuntimeOutput +from core.agentscope.schemas.agent_runtime import ResumeCommand, RunCommand + + +class OrchestratorLike(Protocol): + async def run( + self, + *, + session: AsyncSession, + owner_id: UUID, + user_token: str, + user_context: UserAgentContext, + user_input: str | list[dict[str, Any]], + ) -> RuntimeOutput: ... + + +class PipelineLike(Protocol): + async def emit(self, *, session_id: str, event: dict[str, Any]) -> str: ... + + +class AgentRouteRuntime: + _orchestrator: OrchestratorLike + _pipeline: PipelineLike + _logger = get_logger("core.agentscope.runtime.agent_route_runtime") + + def __init__( + self, *, orchestrator: OrchestratorLike, pipeline: PipelineLike + ) -> None: + self._orchestrator = orchestrator + self._pipeline = pipeline + + async def run( + self, + *, + command: RunCommand, + owner_id: UUID, + user_token: str, + user_context: UserAgentContext, + session: AsyncSession, + ) -> RuntimeOutput: + return await self._execute( + command=command, + owner_id=owner_id, + user_token=user_token, + user_context=user_context, + session=session, + ) + + async def resume( + self, + *, + command: ResumeCommand, + owner_id: UUID, + user_token: str, + user_context: UserAgentContext, + session: AsyncSession, + ) -> RuntimeOutput: + return await self._execute( + command=command, + owner_id=owner_id, + user_token=user_token, + user_context=user_context, + session=session, + ) + + async def _execute( + self, + *, + command: RunCommand, + owner_id: UUID, + user_token: str, + user_context: UserAgentContext, + session: AsyncSession, + ) -> RuntimeOutput: + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "run.started", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {}, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.start", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "intent"}, + }, + ) + try: + result = await self._orchestrator.run( + session=session, + owner_id=owner_id, + user_token=user_token, + user_context=user_context, + user_input=command.messages, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.finish", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "intent"}, + }, + ) + except Exception: # noqa: BLE001 + self._logger.exception( + "agentscope runtime execution failed", + thread_id=command.thread_id, + run_id=command.run_id, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "run.error", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"message": "runtime execution failed"}, + }, + ) + raise + + if result.execution is not None: + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.start", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "execution"}, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.finish", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "execution"}, + }, + ) + + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.start", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "report"}, + }, + ) + + report_message_id = f"assistant-{command.run_id}" + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "text.start", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"messageId": report_message_id, "role": "assistant"}, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "text.delta", + "threadId": command.thread_id, + "runId": command.run_id, + "data": { + "messageId": report_message_id, + "delta": result.report.assistant_text, + }, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "text.end", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"messageId": report_message_id}, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "step.finish", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {"stepName": "report"}, + }, + ) + await self._pipeline.emit( + session_id=command.thread_id, + event={ + "type": "run.finished", + "threadId": command.thread_id, + "runId": command.run_id, + "data": {}, + }, + ) + return result diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py new file mode 100644 index 0000000..fb8a62f --- /dev/null +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from core.agent.domain.user_context import UserAgentContext, parse_profile_settings +from core.agentscope.events import ( + AgentScopeAgUiCodec, + AgentScopeEventPipeline, + NullEventStore, + RedisStreamBus, +) +from core.agentscope.runtime import AgentRouteRuntime, AgentScopeRuntimeOrchestrator +from core.agentscope.schemas.agent_runtime import ResumeCommand, RunCommand +from core.config.settings import config +from core.db.session import AsyncSessionLocal +from core.logging import get_logger +from core.taskiq.app import bulk_broker, critical_broker, default_broker +from services.base.redis import get_or_init_redis_client + +logger = get_logger("core.agentscope.runtime.tasks") + + +def _build_user_context(*, owner_id: UUID, run_input: RunCommand) -> UserAgentContext: + forwarded = ( + run_input.forwarded_props if isinstance(run_input.forwarded_props, dict) else {} + ) + username = str(forwarded.get("username", "user")).strip() or "user" + bio_value = forwarded.get("bio") + bio = str(bio_value).strip() if isinstance(bio_value, str) else None + profile_settings = forwarded.get("profileSettings") + settings_raw = profile_settings if isinstance(profile_settings, dict) else None + return UserAgentContext( + user_id=owner_id, + username=username, + bio=bio, + settings=parse_profile_settings(settings_raw), + ) + + +def _extract_user_token( + *, command: dict[str, Any], run_input: RunCommand +) -> str | None: + raw_token = command.get("user_token") + if isinstance(raw_token, str) and raw_token.strip(): + return raw_token.strip() + forwarded = ( + run_input.forwarded_props if isinstance(run_input.forwarded_props, dict) else {} + ) + for key in ("accessToken", "userToken", "token"): + value = forwarded.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: + command_type = str(command.get("command", "run")).strip().lower() + raw_run_input = command.get("run_input") + raw_owner_id = command.get("owner_id") + + if not isinstance(raw_run_input, dict): + raise ValueError("run_input is required") + if not isinstance(raw_owner_id, str) or not raw_owner_id.strip(): + raise ValueError("owner_id is required") + + owner_id = UUID(raw_owner_id) + parsed_run_input = ( + ResumeCommand.model_validate(raw_run_input) + if command_type == "resume" + else RunCommand.model_validate(raw_run_input) + ) + user_context = _build_user_context(owner_id=owner_id, run_input=parsed_run_input) + user_token = _extract_user_token(command=command, run_input=parsed_run_input) or "" + + redis_client = await get_or_init_redis_client() + bus = RedisStreamBus( + client=redis_client, + stream_prefix=config.agent_runtime.redis_stream_prefix, + read_count=config.agent_runtime.redis_stream_read_count, + block_ms=config.agent_runtime.redis_stream_block_ms, + ) + pipeline = AgentScopeEventPipeline( + codec=AgentScopeAgUiCodec(), + store=NullEventStore(), + bus=bus, + ) + runtime = AgentRouteRuntime( + orchestrator=AgentScopeRuntimeOrchestrator(), + pipeline=pipeline, + ) + + async with AsyncSessionLocal() as session: + if command_type == "resume": + await runtime.resume( + command=ResumeCommand.model_validate(raw_run_input), + owner_id=owner_id, + user_token=user_token, + user_context=user_context, + session=session, + ) + elif command_type == "run": + await runtime.run( + command=RunCommand.model_validate(raw_run_input), + owner_id=owner_id, + user_token=user_token, + user_context=user_context, + session=session, + ) + else: + raise ValueError("invalid command type") + + logger.info( + "agentscope runtime task completed", + command_type=command_type, + thread_id=parsed_run_input.thread_id, + run_id=parsed_run_input.run_id, + ) + return { + "thread_id": parsed_run_input.thread_id, + "run_id": parsed_run_input.run_id, + "status": "completed", + } + + +@default_broker.task(task_name="tasks.agentscope.run_command") +async def run_command_task(command: dict[str, Any]) -> dict[str, object]: + return await run_agentscope_task(command) + + +@critical_broker.task(task_name="tasks.agentscope.run_command.critical") +async def run_command_task_critical(command: dict[str, Any]) -> dict[str, object]: + return await run_agentscope_task(command) + + +@bulk_broker.task(task_name="tasks.agentscope.run_command.bulk") +async def run_command_task_bulk(command: dict[str, Any]) -> dict[str, object]: + return await run_agentscope_task(command) diff --git a/backend/src/core/agentscope/schemas/__init__.py b/backend/src/core/agentscope/schemas/__init__.py index ff13d8a..bbf46b1 100644 --- a/backend/src/core/agentscope/schemas/__init__.py +++ b/backend/src/core/agentscope/schemas/__init__.py @@ -1,13 +1,31 @@ +from core.agentscope.schemas.agent_runtime import ( + AcceptedTaskResponse, + AgUiWireEvent, + HistorySnapshotResponse, + InternalRuntimeEvent, + ResumeCommand, + RunCommand, + TaskAccepted, + TaskAcceptedResponse, +) from core.agentscope.schemas.execution import ExecutionBatchOutput, ExecutionTaskOutput from core.agentscope.schemas.intent import IntentOutput, IntentTask from core.agentscope.schemas.report import ReportOutput from core.agentscope.schemas.runtime import RuntimeOutput __all__ = [ + "AgUiWireEvent", + "AcceptedTaskResponse", "ExecutionBatchOutput", "ExecutionTaskOutput", + "HistorySnapshotResponse", "IntentOutput", "IntentTask", + "InternalRuntimeEvent", "ReportOutput", + "ResumeCommand", "RuntimeOutput", + "RunCommand", + "TaskAccepted", + "TaskAcceptedResponse", ] diff --git a/backend/src/core/agentscope/schemas/agent_runtime.py b/backend/src/core/agentscope/schemas/agent_runtime.py new file mode 100644 index 0000000..68eeecd --- /dev/null +++ b/backend/src/core/agentscope/schemas/agent_runtime.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +class _AliasModel(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict( + populate_by_name=True, serialize_by_alias=True, extra="forbid" + ) + + +class AcceptedTaskResponse(_AliasModel): + task_id: str = Field(alias="taskId", min_length=1) + thread_id: str = Field(alias="threadId", min_length=1) + run_id: str = Field(alias="runId", min_length=1) + created: bool + + +class RunCommand(_AliasModel): + thread_id: str = Field(alias="threadId", min_length=1) + run_id: str = Field(alias="runId", min_length=1) + state: dict[str, Any] | None = None + messages: list[dict[str, Any]] = Field(default_factory=list) + tools: list[dict[str, Any]] = Field(default_factory=list) + context: dict[str, Any] = Field(default_factory=dict) + forwarded_props: dict[str, Any] = Field( + default_factory=dict, alias="forwardedProps" + ) + + +class ResumeCommand(RunCommand): + pass + + +# Backward compatibility alias during migration. +TaskAcceptedResponse = AcceptedTaskResponse +TaskAccepted = AcceptedTaskResponse + + +class InternalRuntimeEvent(_AliasModel): + type: str = Field(min_length=1) + thread_id: str | None = Field(default=None, alias="threadId") + run_id: str | None = Field(default=None, alias="runId") + data: dict[str, Any] = Field(default_factory=dict) + + +class AgUiWireEvent(_AliasModel): + type: str = Field(min_length=1) + thread_id: str | None = Field(default=None, alias="threadId") + run_id: str | None = Field(default=None, alias="runId") + payload: Any = None + + +class HistorySnapshot(_AliasModel): + scope: Literal["history_day"] = "history_day" + thread_id: str | None = Field(default=None, alias="threadId") + day: str | None = None + has_more: bool = Field(default=False, alias="hasMore") + messages: list[dict[str, Any]] = Field(default_factory=list) + + +class HistorySnapshotResponse(_AliasModel): + type: Literal["STATE_SNAPSHOT"] = "STATE_SNAPSHOT" + thread_id: str | None = Field(default=None, alias="threadId") + run_id: str | None = Field(default=None, alias="runId") + snapshot: HistorySnapshot diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index f81e79f..f737027 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -134,6 +134,14 @@ async def calendar_write( str | None, Field(description="Event color value, for example #4F46E5."), ] = None, + reminder_minutes: Annotated[ + int | None, + Field( + description="Minutes before start time to trigger reminder (0-10080).", + ge=0, + le=10080, + ), + ] = None, status: Annotated[ Literal["active", "completed", "canceled", "archived"] | None, Field(description="Event status: active, completed, canceled, or archived."), @@ -158,6 +166,7 @@ async def calendar_write( timezone: Event timezone. location: Event location. color: Event color. + reminder_minutes: Reminder minutes before event start. status: Event lifecycle status. replace: Replace-strategy flag for conflict handling. session: Runtime-injected database session. @@ -193,6 +202,12 @@ async def calendar_write( return build_tool_response( _invalid_argument_response(message="timezone length must be <= 50") ) + if reminder_minutes is not None and ( + reminder_minutes < 0 or reminder_minutes > 10080 + ): + return build_tool_response( + _invalid_argument_response(message="reminder_minutes must be 0..10080") + ) if session is None or owner_id is None: raise ValueError("calendar.write missing runtime preset arguments") @@ -221,6 +236,8 @@ async def calendar_write( tool_args["location"] = location if color is not None: tool_args["color"] = color + if reminder_minutes is not None: + tool_args["reminderMinutes"] = reminder_minutes if status is not None: tool_args["status"] = status diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py index 30ce06e..340080c 100644 --- a/backend/src/v1/agent/dependencies.py +++ b/backend/src/v1/agent/dependencies.py @@ -2,21 +2,20 @@ from __future__ import annotations import asyncio from typing import Any -from uuid import UUID from fastapi import Depends from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from core.agent.infrastructure.events.redis_stream import RedisStreamEventStore -from core.agent.infrastructure.storage.tool_result_storage import ( - create_tool_result_storage, -) -from core.agent.infrastructure.queue.tasks import ( +from core.agentscope.events import RedisStreamBus +from core.agentscope.runtime.tasks import ( run_command_task, run_command_task_bulk, run_command_task_critical, ) +from core.agent.infrastructure.storage.tool_result_storage import ( + create_tool_result_storage, +) from core.config.settings import config from core.db import get_db from services.base.redis import get_or_init_redis_client @@ -84,18 +83,18 @@ class TaskiqQueueClient: class RedisEventStream: def __init__(self) -> None: - self._store: RedisStreamEventStore | None = None + self._bus: RedisStreamBus | None = None - async def _get_store(self) -> RedisStreamEventStore: - if self._store is None: + async def _get_bus(self) -> RedisStreamBus: + if self._bus is None: client = await get_or_init_redis_client() - self._store = RedisStreamEventStore( + self._bus = RedisStreamBus( client=client, stream_prefix=config.agent_runtime.redis_stream_prefix, read_count=config.agent_runtime.redis_stream_read_count, block_ms=config.agent_runtime.redis_stream_block_ms, ) - return self._store + return self._bus async def read( self, @@ -103,12 +102,9 @@ class RedisEventStream: session_id: str, last_event_id: str | None, ) -> list[dict[str, Any]]: - store = await self._get_store() - rows = await store.read_events( - session_id=UUID(session_id), - last_event_id=last_event_id, - ) - return [{**row, "cursor": last_event_id} for row in rows] + bus = await self._get_bus() + rows = await bus.read(session_id=session_id, last_event_id=last_event_id) + return [{**row, "cursor": row.get("id")} for row in rows] def get_agent_service(session: AsyncSession = Depends(get_db)) -> AgentService: diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index 3ae5888..576a8ac 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, Header, Query, Request, status, UploadFi from fastapi import HTTPException from fastapi.responses import JSONResponse, StreamingResponse -from core.agent.infrastructure.agui.stream import to_sse_event +from core.agentscope.events import to_sse_event from core.agent.domain.agui_input import ( parse_run_input, validate_run_request_messages_contract, diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index a3ac36b..b415fb8 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -18,6 +18,17 @@ from core.logging import get_logger logger = get_logger(__name__) +def _extract_user_token_from_run_input(run_input: RunAgentInput) -> str | None: + forwarded = run_input.forwarded_props + if not isinstance(forwarded, dict): + return None + for key in ("accessToken", "userToken", "token"): + value = forwarded.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + @dataclass(frozen=True) class TaskAccepted: task_id: str @@ -65,6 +76,10 @@ def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: class AgentService: + _repository: AgentRepositoryLike + _queue: QueueClientLike + _stream: EventStreamLike + def __init__( self, *, @@ -107,6 +122,8 @@ class AgentService: task_id = await self._queue.enqueue( command={ "command": "run", + "owner_id": str(current_user.id), + "user_token": _extract_user_token_from_run_input(run_input), "run_input": run_input.model_dump(mode="json", by_alias=True), }, dedup_key=None, @@ -132,6 +149,8 @@ class AgentService: task_id = await self._queue.enqueue( command={ "command": "resume", + "owner_id": str(current_user.id), + "user_token": _extract_user_token_from_run_input(run_input), "run_input": run_input.model_dump(mode="json", by_alias=True), }, dedup_key=dedup_key, diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index 76fa0d6..fd0a516 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -32,6 +32,7 @@ class ScheduleItemMetadata(BaseModel): location: str | None = None notes: str | None = None attachments: list[ScheduleItemMetadataAttachment] = Field(default_factory=list) + reminder_minutes: int | None = Field(default=None, ge=0, le=10080) version: Literal[1] = 1 diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 5cda697..1f66bff 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -135,14 +135,13 @@ class ScheduleItemService(BaseService): update_data = request.model_dump(exclude_unset=True) # Handle metadata separately (model_dump returns dict) - if "metadata" in update_data and update_data["metadata"] is not None: - metadata_value = update_data["metadata"] + if "metadata" in update_data: + metadata_value = update_data.pop("metadata") update_data["extra_metadata"] = ( metadata_value.model_dump() if hasattr(metadata_value, "model_dump") else metadata_value ) - del update_data["metadata"] # Validate time range next_start = update_data.get("start_at", existing.start_at) diff --git a/backend/tests/integration/core/agentscope/test_runtime_calendar_smoke.py b/backend/tests/integration/core/agentscope/test_runtime_calendar_smoke.py index 8fb4696..44c1636 100644 --- a/backend/tests/integration/core/agentscope/test_runtime_calendar_smoke.py +++ b/backend/tests/integration/core/agentscope/test_runtime_calendar_smoke.py @@ -68,6 +68,8 @@ async def _invoke_tool( else: text = getattr(first, "text", None) assert isinstance(text, str) + if text.startswith("Error:"): + raise AssertionError(f"tool {tool_name} failed: {text}") payload = json.loads(text) assert isinstance(payload, dict) return payload @@ -101,40 +103,45 @@ class _SmokeRunner: if stage_config.stage == "execution": assert toolkit is not None - created = await _invoke_tool( - toolkit, - tool_name="calendar.write", - tool_input={ - "operation": "create", - "title": "agentscope smoke event", - "description": "agentscope runtime smoke", - "start_at": datetime.now(timezone.utc).isoformat(), - "timezone": "Asia/Shanghai", - }, - ) - created_data = created.get("data") - assert isinstance(created_data, dict) - created_id = created_data.get("id") - assert isinstance(created_id, str) and created_id + created_id: str | None = None + items: list[object] = [] + try: + created = await _invoke_tool( + toolkit, + tool_name="calendar.write", + tool_input={ + "operation": "create", + "title": "agentscope smoke event", + "description": "agentscope runtime smoke", + "start_at": datetime.now(timezone.utc).isoformat(), + "timezone": "Asia/Shanghai", + }, + ) + created_data = created.get("data") + assert isinstance(created_data, dict) + created_id = created_data.get("id") + assert isinstance(created_id, str) and created_id - read_payload = await _invoke_tool( - toolkit, - tool_name="calendar.read", - tool_input={"page": 1, "page_size": 10}, - ) - read_data = read_payload.get("data") - assert isinstance(read_data, dict) - items = read_data.get("items") - assert isinstance(items, list) - - deleted = await _invoke_tool( - toolkit, - tool_name="calendar.write", - tool_input={"operation": "delete", "event_id": created_id}, - ) - deleted_data = deleted.get("data") - assert isinstance(deleted_data, dict) - assert deleted_data.get("ok") is True + read_payload = await _invoke_tool( + toolkit, + tool_name="calendar.read", + tool_input={"page": 1, "page_size": 10}, + ) + read_data = read_payload.get("data") + assert isinstance(read_data, dict) + parsed_items = read_data.get("items") + assert isinstance(parsed_items, list) + items = parsed_items + finally: + if created_id: + deleted = await _invoke_tool( + toolkit, + tool_name="calendar.write", + tool_input={"operation": "delete", "event_id": created_id}, + ) + deleted_data = deleted.get("data") + assert isinstance(deleted_data, dict) + assert deleted_data.get("ok") is True return { "task_id": "smoke-task-1", diff --git a/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py b/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py index 6411f9e..d38dbd6 100644 --- a/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py +++ b/backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py @@ -25,6 +25,8 @@ async def test_mutate_calendar_event_create_returns_calendar_card_v1( async def create_agent_generated(self, payload): assert payload.title == "晨会" + assert payload.metadata is not None + assert payload.metadata.reminder_minutes == 15 return SimpleNamespace( id=created_id, title="晨会", @@ -32,7 +34,11 @@ async def test_mutate_calendar_event_create_returns_calendar_card_v1( start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc), end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc), timezone="Asia/Shanghai", - metadata=SimpleNamespace(location="会议室A", color="#4F46E5"), + metadata=SimpleNamespace( + location="会议室A", + color="#4F46E5", + reminder_minutes=15, + ), ) class _FakeRepository: @@ -61,6 +67,7 @@ async def test_mutate_calendar_event_create_returns_calendar_card_v1( "endAt": "2026-03-08T10:00:00+08:00", "timezone": "Asia/Shanghai", "location": "会议室A", + "reminderMinutes": 15, }, ), ) @@ -69,6 +76,77 @@ async def test_mutate_calendar_event_create_returns_calendar_card_v1( data = cast(dict[str, object], result["data"]) assert data["id"] == str(created_id) assert data["ok"] is True + assert data["reminderMinutes"] == 15 + + +@pytest.mark.asyncio +async def test_mutate_calendar_event_update_maps_reminder_minutes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + event_id = uuid4() + + class _FakeService: + def __init__(self, **kwargs) -> None: + del kwargs + + async def get_by_id(self, item_id): + assert item_id == event_id + return SimpleNamespace( + metadata=SimpleNamespace( + model_dump=lambda: { + "color": "#4F46E5", + "location": "会议室A", + "version": 1, + } + ) + ) + + async def update(self, item_id, payload): + assert item_id == event_id + assert payload.metadata is not None + assert payload.metadata.reminder_minutes == 30 + return SimpleNamespace( + id=event_id, + title="更新后", + description=None, + start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc), + end_at=None, + timezone="Asia/Shanghai", + metadata=SimpleNamespace( + location="会议室A", + color="#4F46E5", + reminder_minutes=30, + ), + ) + + class _FakeRepository: + def __init__(self, session) -> None: + del session + + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService", + _FakeService, + ) + monkeypatch.setattr( + "core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository", + _FakeRepository, + ) + + result = cast( + dict[str, object], + await _execute_mutate_calendar_event( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + tool_args={ + "operation": "update", + "eventId": str(event_id), + "reminderMinutes": 30, + }, + ), + ) + + data = cast(dict[str, object], result["data"]) + assert data["reminderMinutes"] == 30 @pytest.mark.asyncio diff --git a/backend/tests/unit/core/agentscope/events/test_agui_codec.py b/backend/tests/unit/core/agentscope/events/test_agui_codec.py new file mode 100644 index 0000000..686a62e --- /dev/null +++ b/backend/tests/unit/core/agentscope/events/test_agui_codec.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from core.agentscope.events.agui_codec import to_agui_wire_event + + +def test_maps_internal_text_delta_to_agui_wire_event() -> None: + internal = { + "id": "e1", + "type": "text.delta", + "threadId": "t1", + "runId": "r1", + "data": {"delta": "hel"}, + } + + result = to_agui_wire_event(internal) + + assert result["type"] == "TEXT_MESSAGE_CONTENT" + assert result["threadId"] == "t1" + assert result["runId"] == "r1" + assert result["delta"] == "hel" + + +def test_reserved_keys_in_data_cannot_override_wire_fields() -> None: + internal = { + "id": "e2", + "type": "run.started", + "threadId": "thread-1", + "runId": "run-1", + "data": { + "type": "RUN_ERROR", + "threadId": "thread-override", + "runId": "run-override", + "message": "ok", + }, + } + + result = to_agui_wire_event(internal) + + assert result["type"] == "RUN_STARTED" + assert result["threadId"] == "thread-1" + assert result["runId"] == "run-1" + assert result["message"] == "ok" diff --git a/backend/tests/unit/core/agentscope/events/test_pipeline.py b/backend/tests/unit/core/agentscope/events/test_pipeline.py new file mode 100644 index 0000000..4b68fa2 --- /dev/null +++ b/backend/tests/unit/core/agentscope/events/test_pipeline.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pytest + +from core.agentscope.events.pipeline import AgentScopeEventPipeline + + +@pytest.mark.asyncio +async def test_pipeline_orders_codec_persist_publish() -> None: + calls: list[str] = [] + + class _Codec: + def to_wire(self, event: dict[str, object]) -> dict[str, object]: + calls.append("codec") + return {"type": "RUN_STARTED", **event} + + class _Store: + async def persist(self, event: dict[str, object]) -> None: + calls.append("persist") + assert event["type"] == "RUN_STARTED" + + class _Bus: + async def publish(self, *, session_id: str, event: dict[str, object]) -> str: + calls.append("publish") + assert session_id == "thread-1" + return "1-0" + + pipeline = AgentScopeEventPipeline(codec=_Codec(), store=_Store(), bus=_Bus()) + cursor = await pipeline.emit(session_id="thread-1", event={"id": "evt-1"}) + + assert cursor == "1-0" + assert calls == ["codec", "persist", "publish"] diff --git a/backend/tests/unit/core/agentscope/events/test_redis_bus.py b/backend/tests/unit/core/agentscope/events/test_redis_bus.py new file mode 100644 index 0000000..e375daa --- /dev/null +++ b/backend/tests/unit/core/agentscope/events/test_redis_bus.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from core.agentscope.events.redis_bus import RedisStreamBus + + +class _FakeRedis: + def __init__(self) -> None: + self._rows: list[tuple[str, str]] = [] + + def xadd(self, _stream: str, fields: dict[str, str]) -> str: + cursor = f"{len(self._rows) + 1}-0" + self._rows.append((cursor, fields["event"])) + return cursor + + def xread( + self, + streams: dict[str, str], + count: int, + block: int, + ) -> list[tuple[str, list[tuple[str, dict[str, str]]]]]: + del count, block + stream_name, last = next(iter(streams.items())) + rows: list[tuple[str, dict[str, str]]] = [] + for cursor, payload in self._rows: + if cursor > last: + rows.append((cursor, {"event": payload})) + return [(stream_name, rows)] + + +class _FakeRedisBytes: + def __init__(self) -> None: + self._rows: list[tuple[str, str]] = [] + + def xadd(self, _stream: str, fields: dict[str, str]) -> str: + cursor = f"{len(self._rows) + 1}-0" + self._rows.append((cursor, fields["event"])) + return cursor + + def xread( + self, + streams: dict[str, str], + count: int, + block: int, + ) -> list[tuple[str, list[tuple[str, dict[str, bytes]]]]]: + del count, block + stream_name, last = next(iter(streams.items())) + rows: list[tuple[str, dict[str, bytes]]] = [] + for cursor, payload in self._rows: + if cursor > last: + rows.append((cursor, {"event": payload.encode("utf-8")})) + return [(stream_name, rows)] + + +async def test_publish_then_read_after_cursor() -> None: + bus = RedisStreamBus(client=_FakeRedis(), stream_prefix="agent.events") + + first_cursor = await bus.publish( + session_id="thread-1", event={"type": "RUN_STARTED"} + ) + await bus.publish(session_id="thread-1", event={"type": "RUN_FINISHED"}) + + rows = await bus.read(session_id="thread-1", last_event_id=first_cursor) + assert len(rows) == 1 + assert rows[0]["event"]["type"] == "RUN_FINISHED" + + +async def test_read_supports_bytes_payload() -> None: + bus = RedisStreamBus(client=_FakeRedisBytes(), stream_prefix="agent.events") + await bus.publish(session_id="thread-1", event={"type": "RUN_STARTED"}) + rows = await bus.read(session_id="thread-1", last_event_id=None) + assert rows[0]["event"]["type"] == "RUN_STARTED" diff --git a/backend/tests/unit/core/agentscope/events/test_sse.py b/backend/tests/unit/core/agentscope/events/test_sse.py new file mode 100644 index 0000000..c118389 --- /dev/null +++ b/backend/tests/unit/core/agentscope/events/test_sse.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json + +from core.agentscope.events.sse import to_sse_event + + +def test_sse_frame_contains_event_and_json_payload() -> None: + payload = {"type": "RUN_STARTED", "threadId": "t1", "runId": "r1"} + + frame = to_sse_event("1-0", payload) + + assert frame.startswith("id: 1-0\n") + assert "event: RUN_STARTED\n" in frame + assert frame.endswith("\n\n") + + data_line = [line for line in frame.splitlines() if line.startswith("data: ")][0] + parsed = json.loads(data_line[len("data: ") :]) + assert parsed["threadId"] == "t1" + + +def test_sse_sanitizes_stream_id_newlines() -> None: + payload = {"type": "RUN_STARTED"} + frame = to_sse_event("1-0\nmalicious: yes", payload) + assert frame.startswith("id: 1-0malicious: yes\n") diff --git a/backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py b/backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py new file mode 100644 index 0000000..d87b87e --- /dev/null +++ b/backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from typing import Any, cast +from uuid import uuid4 + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from core.agent.domain.user_context import UserAgentContext, parse_profile_settings +from core.agentscope.runtime.agent_route_runtime import AgentRouteRuntime +from core.agentscope.schemas import ReportOutput, RuntimeOutput +from core.agentscope.schemas.agent_runtime import RunCommand +from core.agentscope.schemas.execution import ExecutionBatchOutput +from core.agentscope.schemas.intent import IntentOutput + + +def _user_context() -> UserAgentContext: + return UserAgentContext( + user_id=uuid4(), + username="tester", + bio=None, + settings=parse_profile_settings( + { + "version": 1, + "preferences": { + "interface_language": "zh-CN", + "ai_language": "zh-CN", + "timezone": "Asia/Shanghai", + "country": "CN", + }, + } + ), + ) + + +@pytest.mark.asyncio +async def test_runtime_emits_started_text_and_finished_events() -> None: + calls: list[dict[str, Any]] = [] + + class _FakePipeline: + async def emit(self, *, session_id: str, event: dict[str, object]) -> str: + assert session_id == "thread-1" + calls.append(event) + return f"{len(calls)}-0" + + class _FakeOrchestrator: + async def run(self, **_: object) -> RuntimeOutput: + return RuntimeOutput( + intent=IntentOutput( + route="DIRECT_RESPONSE", + intent_summary="summary", + direct_response="done", + tasks=[], + complexity="simple", + ), + execution=ExecutionBatchOutput( + task_results=[], + overall_status="SUCCESS", + aggregate_summary="ok", + ), + report=ReportOutput( + assistant_text="hello world", + response_metadata={}, + ), + ) + + runtime = AgentRouteRuntime( + orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline() + ) + command = RunCommand(threadId="thread-1", runId="run-1", messages=[]) + + await runtime.run( + command=command, + owner_id=uuid4(), + user_token="token", + user_context=_user_context(), + session=cast(AsyncSession, object()), + ) + + assert [item["type"] for item in calls] == [ + "run.started", + "step.start", + "step.finish", + "step.start", + "step.finish", + "step.start", + "text.start", + "text.delta", + "text.end", + "step.finish", + "run.finished", + ] + assert calls[1]["data"]["stepName"] == "intent" + assert calls[2]["data"]["stepName"] == "intent" + assert calls[3]["data"]["stepName"] == "execution" + assert calls[4]["data"]["stepName"] == "execution" + assert calls[5]["data"]["stepName"] == "report" + assert calls[7]["data"]["delta"] == "hello world" + assert calls[6]["data"]["messageId"] == calls[7]["data"]["messageId"] + assert calls[7]["data"]["messageId"] == calls[8]["data"]["messageId"] + assert calls[9]["data"]["stepName"] == "report" + + +@pytest.mark.asyncio +async def test_runtime_emits_run_error_when_orchestrator_fails() -> None: + calls: list[dict[str, Any]] = [] + + class _FakePipeline: + async def emit(self, *, session_id: str, event: dict[str, object]) -> str: + assert session_id == "thread-1" + calls.append(event) + return f"{len(calls)}-0" + + class _FailOrchestrator: + async def run(self, **_: object) -> RuntimeOutput: + raise RuntimeError("boom") + + runtime = AgentRouteRuntime( + orchestrator=_FailOrchestrator(), + pipeline=_FakePipeline(), + ) + command = RunCommand(threadId="thread-1", runId="run-1", messages=[]) + + with pytest.raises(RuntimeError, match="boom"): + await runtime.run( + command=command, + owner_id=uuid4(), + user_token="token", + user_context=_user_context(), + session=cast(AsyncSession, object()), + ) + + assert [item["type"] for item in calls] == [ + "run.started", + "step.start", + "run.error", + ] + assert calls[1]["data"]["stepName"] == "intent" + assert calls[2]["data"]["message"] == "runtime execution failed" diff --git a/backend/tests/unit/core/agentscope/runtime/test_tasks.py b/backend/tests/unit/core/agentscope/runtime/test_tasks.py new file mode 100644 index 0000000..7f2bc10 --- /dev/null +++ b/backend/tests/unit/core/agentscope/runtime/test_tasks.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import pytest + +import core.agentscope.runtime.tasks as tasks_module + + +def _run_input_payload() -> dict[str, Any]: + return { + "threadId": str(uuid4()), + "runId": "run-1", + "messages": [], + "tools": [], + "context": {}, + "forwardedProps": {}, + } + + +class _FakeSessionCtx: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + del exc_type, exc, tb + + +@pytest.mark.asyncio +async def test_run_agentscope_task_calls_runtime_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, int] = {"run": 0, "resume": 0} + + class _FakeRuntime: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def run(self, **kwargs: object) -> object: + del kwargs + called["run"] += 1 + return object() + + async def resume(self, **kwargs: object) -> object: + del kwargs + called["resume"] += 1 + return object() + + async def _fake_get_redis_client() -> object: + return object() + + monkeypatch.setattr(tasks_module, "AgentRouteRuntime", _FakeRuntime) + monkeypatch.setattr( + tasks_module, + "get_or_init_redis_client", + _fake_get_redis_client, + ) + monkeypatch.setattr(tasks_module, "AsyncSessionLocal", lambda: _FakeSessionCtx()) + + result = await tasks_module.run_agentscope_task( + { + "command": "run", + "owner_id": str(uuid4()), + "run_input": _run_input_payload(), + } + ) + + assert result["status"] == "completed" + assert called["run"] == 1 + assert called["resume"] == 0 + + +@pytest.mark.asyncio +async def test_run_agentscope_task_calls_runtime_resume( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, int] = {"run": 0, "resume": 0} + + class _FakeRuntime: + def __init__(self, **kwargs: object) -> None: + del kwargs + + async def run(self, **kwargs: object) -> object: + del kwargs + called["run"] += 1 + return object() + + async def resume(self, **kwargs: object) -> object: + del kwargs + called["resume"] += 1 + return object() + + async def _fake_get_redis_client() -> object: + return object() + + monkeypatch.setattr(tasks_module, "AgentRouteRuntime", _FakeRuntime) + monkeypatch.setattr( + tasks_module, + "get_or_init_redis_client", + _fake_get_redis_client, + ) + monkeypatch.setattr(tasks_module, "AsyncSessionLocal", lambda: _FakeSessionCtx()) + + result = await tasks_module.run_agentscope_task( + { + "command": "resume", + "owner_id": str(uuid4()), + "run_input": _run_input_payload(), + } + ) + + assert result["status"] == "completed" + assert called["run"] == 0 + assert called["resume"] == 1 + + +@pytest.mark.asyncio +async def test_run_agentscope_task_requires_owner_id() -> None: + with pytest.raises(ValueError, match="owner_id is required"): + await tasks_module.run_agentscope_task( + { + "command": "run", + "run_input": _run_input_payload(), + } + ) + + +@pytest.mark.asyncio +async def test_run_agentscope_task_rejects_invalid_command_type() -> None: + with pytest.raises(ValueError, match="invalid command type"): + await tasks_module.run_agentscope_task( + { + "command": "unknown", + "owner_id": str(uuid4()), + "run_input": _run_input_payload(), + } + ) diff --git a/backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py b/backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py new file mode 100644 index 0000000..aff1c8e --- /dev/null +++ b/backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from core.agentscope import schemas as exported_schemas +from core.agentscope.schemas.agent_runtime import ( + AcceptedTaskResponse, + AgUiWireEvent, + HistorySnapshot, + HistorySnapshotResponse, + InternalRuntimeEvent, + ResumeCommand, + RunCommand, +) + + +def test_run_command_alias_roundtrip() -> None: + payload = { + "threadId": "thread-001", + "runId": "run-001", + "state": {"cursor": 1}, + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"name": "calendar.lookup"}], + "context": {"locale": "zh-CN"}, + "forwardedProps": {"traceId": "trace-1"}, + } + + command = RunCommand.model_validate(payload) + + assert command.thread_id == "thread-001" + assert command.run_id == "run-001" + assert command.forwarded_props == {"traceId": "trace-1"} + + dumped = command.model_dump(mode="json", by_alias=True) + assert dumped["threadId"] == "thread-001" + assert dumped["runId"] == "run-001" + assert dumped["forwardedProps"] == {"traceId": "trace-1"} + + +def test_history_snapshot_response_shape() -> None: + response = HistorySnapshotResponse( + threadId="thread-123", + snapshot=HistorySnapshot( + threadId="thread-123", + day="2026-03-11", + hasMore=False, + messages=[{"id": "msg-1"}], + ), + ) + + dumped = response.model_dump(mode="json", by_alias=True, exclude_none=True) + assert dumped["type"] == "STATE_SNAPSHOT" + assert dumped["threadId"] == "thread-123" + assert dumped["snapshot"]["scope"] == "history_day" + assert dumped["snapshot"]["hasMore"] is False + assert dumped["snapshot"]["messages"] == [{"id": "msg-1"}] + + +def test_runtime_event_validation_basics() -> None: + internal = InternalRuntimeEvent(type="RUN_STARTED", data={"step": 1}) + assert internal.type == "RUN_STARTED" + assert internal.model_dump(mode="json", by_alias=True)["data"] == {"step": 1} + + wire = AgUiWireEvent(type="TEXT_MESSAGE_CONTENT", payload={"delta": "hello"}) + dumped = wire.model_dump(mode="json", by_alias=True, exclude_none=True) + assert dumped["type"] == "TEXT_MESSAGE_CONTENT" + assert dumped["payload"] == {"delta": "hello"} + + with pytest.raises(ValidationError): + InternalRuntimeEvent.model_validate({"threadId": "t-1", "data": {}}) + + with pytest.raises(ValidationError): + AgUiWireEvent.model_validate({"payload": {"delta": "hello"}}) + + +def test_task_response_and_resume_aliases() -> None: + accepted = AcceptedTaskResponse( + taskId="task-1", + threadId="thread-1", + runId="run-1", + created=False, + ) + dumped = accepted.model_dump(mode="json", by_alias=True) + assert dumped["taskId"] == "task-1" + assert dumped["threadId"] == "thread-1" + assert dumped["runId"] == "run-1" + + resumed = ResumeCommand.model_validate( + { + "threadId": "thread-1", + "runId": "run-2", + "messages": [], + "tools": [], + "context": {}, + } + ) + assert resumed.thread_id == "thread-1" + assert resumed.run_id == "run-2" + + +def test_schemas_exports_include_task_and_history_models() -> None: + assert exported_schemas.AcceptedTaskResponse is AcceptedTaskResponse + assert exported_schemas.TaskAccepted is AcceptedTaskResponse + assert exported_schemas.TaskAcceptedResponse is AcceptedTaskResponse + assert exported_schemas.HistorySnapshotResponse is HistorySnapshotResponse diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index 65dbe5b..bf859e4 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -131,3 +131,50 @@ async def test_calendar_write_rejects_event_id_for_create( assert result["data"]["ok"] is False assert result["data"]["code"] == "INVALID_ARGUMENT" + + +@pytest.mark.asyncio +async def test_calendar_write_maps_reminder_minutes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_execute(**kwargs: Any) -> dict[str, object]: + captured.update(cast(dict[str, object], kwargs["tool_args"])) + return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}} + + monkeypatch.setattr( + calendar_module, + "_execute_mutate_calendar_event", + _fake_execute, + ) + monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True) + monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) + + await calendar_module.calendar_write( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + user_token="token-abc", + operation="create", + reminder_minutes=15, + ) + + assert captured["reminderMinutes"] == 15 + + +@pytest.mark.asyncio +async def test_calendar_write_rejects_invalid_reminder_minutes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload) + + result = await calendar_module.calendar_write( + session=cast(AsyncSession, SimpleNamespace()), + owner_id=uuid4(), + user_token="token-abc", + operation="create", + reminder_minutes=10081, + ) + + assert result["data"]["ok"] is False + assert result["data"]["code"] == "INVALID_ARGUMENT" diff --git a/backend/tests/unit/v1/schedule_items/test_schemas.py b/backend/tests/unit/v1/schedule_items/test_schemas.py index db929a6..008edeb 100644 --- a/backend/tests/unit/v1/schedule_items/test_schemas.py +++ b/backend/tests/unit/v1/schedule_items/test_schemas.py @@ -103,6 +103,18 @@ def test_metadata_rejects_unknown_field() -> None: ScheduleItemMetadata.model_validate({"color": "#FF6B6B", "unknown": True}) +@pytest.mark.parametrize("value", [None, 0, 15, 10080]) +def test_metadata_accepts_reminder_minutes(value: int | None) -> None: + metadata = ScheduleItemMetadata(reminder_minutes=value) + assert metadata.reminder_minutes == value + + +@pytest.mark.parametrize("value", [-1, 10081]) +def test_metadata_rejects_out_of_range_reminder_minutes(value: int) -> None: + with pytest.raises(ValidationError): + ScheduleItemMetadata(reminder_minutes=value) + + def test_metadata_attachment_rejects_unknown_field() -> None: with pytest.raises(ValidationError): ScheduleItemMetadataAttachment.model_validate( diff --git a/backend/tests/unit/v1/schedule_items/test_service.py b/backend/tests/unit/v1/schedule_items/test_service.py index 5f0855b..51c4005 100644 --- a/backend/tests/unit/v1/schedule_items/test_service.py +++ b/backend/tests/unit/v1/schedule_items/test_service.py @@ -221,7 +221,12 @@ async def test_create_maps_metadata_to_extra_metadata(mock_session: AsyncMock) - request = ScheduleItemCreateRequest( title="Roadmap", start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), - metadata=ScheduleItemMetadata(location="会议室A", color="#4F46E5", version=1), + metadata=ScheduleItemMetadata( + location="会议室A", + color="#4F46E5", + reminder_minutes=15, + version=1, + ), ) service = ScheduleItemService( repository=CaptureRepo(None), @@ -234,6 +239,7 @@ async def test_create_maps_metadata_to_extra_metadata(mock_session: AsyncMock) - assert captured is not None assert "extra_metadata" in captured assert captured["extra_metadata"]["location"] == "会议室A" + assert captured["extra_metadata"]["reminder_minutes"] == 15 assert "metadata" not in captured @@ -261,7 +267,10 @@ async def test_update_maps_metadata_to_extra_metadata(mock_session: AsyncMock) - item.id, ScheduleItemUpdateRequest( metadata=ScheduleItemMetadata( - location="线上会议", color="#3B82F6", version=1 + location="线上会议", + color="#3B82F6", + reminder_minutes=30, + version=1, ) ), ) @@ -269,4 +278,38 @@ async def test_update_maps_metadata_to_extra_metadata(mock_session: AsyncMock) - assert captured is not None assert "extra_metadata" in captured assert captured["extra_metadata"]["location"] == "线上会议" + assert captured["extra_metadata"]["reminder_minutes"] == 30 + assert "metadata" not in captured + + +@pytest.mark.asyncio +async def test_update_maps_null_metadata_to_extra_metadata_null( + mock_session: AsyncMock, +) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + captured: dict | None = None + + class CaptureRepo(FakeRepo): + async def update_by_item_id( + self, item_id: UUID, owner_id: UUID, data: dict + ) -> ScheduleItem | None: + nonlocal captured + captured = data + return await super().update_by_item_id(item_id, owner_id, data) + + service = ScheduleItemService( + repository=CaptureRepo(item), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + await service.update( + item.id, + ScheduleItemUpdateRequest(metadata=None), + ) + + assert captured is not None + assert "extra_metadata" in captured + assert captured["extra_metadata"] is None assert "metadata" not in captured diff --git a/docs/plans/2026-03-10-auth-token-compat-refresh-singleflight.md b/docs/plans/2026-03-10-auth-token-compat-refresh-singleflight.md deleted file mode 100644 index b0b47c0..0000000 --- a/docs/plans/2026-03-10-auth-token-compat-refresh-singleflight.md +++ /dev/null @@ -1,69 +0,0 @@ -# Auth Token Compatibility + Refresh Singleflight Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 兼容云 Supabase 实际 access token claims(缺失 `iss` 仍可通过),并修复前端 401 导致 refresh 风暴问题,消除日志中的批量 401/429 警告。 - -**Architecture:** 后端保持 HS256 签名校验、`exp/sub` 必检,将 `iss` 从“强制存在”改为“存在时校验”;前端在拦截器中加入 refresh 单飞与防重入,避免并发 401 触发多次 refresh 或 refresh 自递归。同步清理无效分支与冗余状态。 - -**Tech Stack:** FastAPI, PyJWT, Flutter, Dio, flutter_test - ---- - -### Task 1: 后端 JWT claim 兼容化(无 `iss` 可通过) - -**Files:** -- Modify: `backend/src/core/auth/jwt_verifier.py` -- Test: `backend/tests/unit/core/auth/test_jwt_verifier.py` - -**Step 1: Write failing test** -- 新增用例:token 不含 `iss`、但 `sub/exp` 与 HS256 签名合法时应验证成功。 - -**Step 2: Run test to verify it fails** -- Run: `cd backend && uv run pytest tests/unit/core/auth/test_jwt_verifier.py -q` - -**Step 3: Write minimal implementation** -- `jwt.decode` 的 `require` 去掉 `iss`,仅保留 `sub/exp`。 -- 若 payload 中存在 `iss` 且配置了 issuer,则手动比对 issuer;不一致时报错。 - -**Step 4: Run test to verify it passes** -- Run: `cd backend && uv run pytest tests/unit/core/auth/test_jwt_verifier.py -q` - -### Task 2: 前端 refresh 单飞 + 防递归 - -**Files:** -- Modify: `apps/lib/core/api/api_interceptor.dart` -- Test: `apps/test/core/api/api_interceptor_test.dart` - -**Step 1: Write failing tests** -- 并发 401 时只调用一次 `onTokenRefresh`。 -- `/api/v1/auth/sessions/refresh` 自身 401 不触发 refresh 重试。 - -**Step 2: Run tests to verify failures** -- Run: `cd apps && flutter test test/core/api/api_interceptor_test.dart` - -**Step 3: Write minimal implementation** -- 增加 `_refreshFuture` 单飞字段。 -- 非 refresh 请求命中 401 时 await 同一个 refresh future。 -- 对 refresh/logout 认证端点和已重试请求加短路,避免无限重入。 - -**Step 4: Run tests to verify pass** -- Run: `cd apps && flutter test test/core/api/api_interceptor_test.dart` - -### Task 3: 清理无效/旧分支并做回归验证 - -**Files:** -- Modify: `apps/lib/core/api/api_interceptor.dart`(移除无效重试分支) -- Modify: `backend/src/core/auth/jwt_verifier.py`(删除不再使用的路径) - -**Step 1: Refactor cleanup** -- 删除不再可达的分支与重复逻辑,保持行为不变。 - -**Step 2: Full targeted verification** -- Run: `cd backend && uv run ruff check src tests` -- Run: `cd backend && uv run basedpyright` -- Run: `cd backend && uv run pytest tests/unit/core/auth/test_jwt_verifier.py tests/unit/v1/users -q` -- Run: `cd apps && flutter test test/core/api/api_interceptor_test.dart test/features/auth` - -**Step 3: Runtime spot-check** -- Run: 登录拿 token 后请求 `/api/v1/agent/history`,确认不再因缺失 `iss` 返回 401。 diff --git a/docs/plans/2026-03-11-agentscope-agent-route-migration-handoff.md b/docs/plans/2026-03-11-agentscope-agent-route-migration-handoff.md new file mode 100644 index 0000000..3e84d8a --- /dev/null +++ b/docs/plans/2026-03-11-agentscope-agent-route-migration-handoff.md @@ -0,0 +1,141 @@ +# AgentScope Agent Route Migration Handoff Plan + +## 1) Reconfirmed Objective + +- Keep external API paths unchanged under `/api/v1/agent/*`. +- Replace internal run/resume/events runtime path with `core/agentscope` modules. +- Use five modules only: `runtime`, `prompts`, `schemas`, `tools`, `events`. +- Put AG-UI event conversion + persistence + Redis export in `events`. +- Keep `/transcribe` under the same router prefix but independent from agent runtime. +- Continue migration until legacy `core/agent` is removable. + +## 2) Current Progress Snapshot + +### Completed + +- Task 1 (schemas) finished: + - Added runtime-facing schemas in `core/agentscope/schemas/agent_runtime.py`. + - Exported aliases for compatibility (`AcceptedTaskResponse`, `TaskAcceptedResponse`, `TaskAccepted`). +- Task 2 (events) finished: + - Added `events` module with AG-UI conversion, SSE encoding, Redis stream bus, pipeline, and store abstraction. + - Security fixes applied: + - Prevent reserved key overwrite in AG-UI codec. + - Sanitize SSE stream id. + - Support Redis bytes payload decoding. + - SSE now reuses AG-UI protocol encoder (`EventEncoder`) instead of custom JSON-only logic. +- Task 3 (runtime adapter) finished: + - Added `AgentRouteRuntime` to emit internal events around orchestrator execution. + - Added step events for stage identification: + - `step.start/step.finish` for `intent`, `execution`, `report`. + - Error event payload no longer leaks raw exception text to clients. +- Task 4 (route/service wiring) largely finished: + - `/v1/agent/router.py` now uses `core.agentscope.events.to_sse_event`. + - `/v1/agent/dependencies.py` queue tasks switched to `core.agentscope.runtime.tasks`. + - `/v1/agent/dependencies.py` stream reads switched to `RedisStreamBus`. + - `/v1/agent/service.py` enqueue payload now carries `owner_id` and extracted `user_token`. + - Added tests for runtime task entrypoint dispatch/validation. + +### In Progress / Not Finished + +- Task 4 review wrap-up: + - One review already returned PASS for spec compliance after fixes. + - Final quality/security confirmation for latest delta should be re-run once before moving to Task 5. +- Task 5 (sessions/messages persistence ownership, cost/tokens/latency full persistence) not started. +- Task 6 (remove `core/agent` and clean imports) not started. +- Task 7 (frontend AG-UI contract and E2E validation) not started. + +## 3) What Was Changed (Relevant Files) + +### New Files + +- `backend/src/core/agentscope/schemas/agent_runtime.py` +- `backend/src/core/agentscope/events/__init__.py` +- `backend/src/core/agentscope/events/agui_codec.py` +- `backend/src/core/agentscope/events/sse.py` +- `backend/src/core/agentscope/events/redis_bus.py` +- `backend/src/core/agentscope/events/store.py` +- `backend/src/core/agentscope/events/pipeline.py` +- `backend/src/core/agentscope/runtime/agent_route_runtime.py` +- `backend/src/core/agentscope/runtime/tasks.py` +- `backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py` +- `backend/tests/unit/core/agentscope/events/test_agui_codec.py` +- `backend/tests/unit/core/agentscope/events/test_sse.py` +- `backend/tests/unit/core/agentscope/events/test_redis_bus.py` +- `backend/tests/unit/core/agentscope/events/test_pipeline.py` +- `backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py` +- `backend/tests/unit/core/agentscope/runtime/test_tasks.py` + +### Modified Files + +- `backend/src/core/agentscope/runtime/__init__.py` +- `backend/src/core/agentscope/schemas/__init__.py` +- `backend/src/v1/agent/router.py` +- `backend/src/v1/agent/dependencies.py` +- `backend/src/v1/agent/service.py` + +## 4) Key References Used + +### In-repo references + +- Current agent route/service contracts: + - `backend/src/v1/agent/router.py` + - `backend/src/v1/agent/service.py` + - `backend/src/v1/agent/dependencies.py` + - `backend/src/v1/agent/repository.py` +- Existing runtime/orchestrator basis: + - `backend/src/core/agentscope/runtime/orchestrator.py` + +### External reference project + +- DIVA-backend async stream/task patterns (for architecture guidance only): + - `/home/qzl/Code/DIVA-backend/src/diva/services/app/conversation/task_event_stream_service.py` + - `/home/qzl/Code/DIVA-backend/src/diva/services/app/conversation/tasks.py` + - `/home/qzl/Code/DIVA-backend/src/diva/utils/agui_events.py` + +### Protocol/framework references + +- AG-UI protocol skill docs (event naming/shape guidance) +- AgentScope skill docs (`ReActAgent`, model/runtime usage) + +## 5) Next Execution Plan (Continue From Here) + +### Step A: Close Task 4 gates (quick) + +- Re-run targeted checks for the latest Task 4 code: + - `uv run pytest tests/unit/v1/agent/test_service.py tests/unit/core/agentscope/runtime/test_tasks.py tests/unit/core/agentscope/runtime/test_agent_route_runtime.py tests/unit/core/agentscope/events -q` + - `uv run ruff check src/v1/agent src/core/agentscope/runtime src/core/agentscope/events tests/unit/core/agentscope/runtime tests/unit/core/agentscope/events` + - `uv run basedpyright src/v1/agent src/core/agentscope/runtime src/core/agentscope/events tests/unit/core/agentscope/runtime tests/unit/core/agentscope/events` +- Run one explicit code/security review pass on Task 4 final diff. + +### Step B: Execute Task 5 (persistence migration) + +- Implement `events.store` real persistence (replace `NullEventStore` path in runtime task assembly): + - persist sessions/messages from AG-UI wire events. + - include tokens/cost/latency fields. + - maintain session aggregates. +- Add unit + integration tests for persistence correctness and aggregation. + +### Step C: Execute Task 6 (remove legacy core/agent) + +- Move remaining required data structures into `core/agentscope/schemas`. +- Replace all `core.agent.*` imports in active code paths. +- Delete `backend/src/core/agent/**` when no runtime path depends on it. +- Add guard test to ensure no legacy imports remain. + +### Step D: Execute Task 7 (frontend contract validation) + +- Validate AG-UI event stream compatibility with current Flutter parser and bloc flow. +- Run impacted frontend tests for chat/event handling. + +## 6) Risks and Notes + +- Workspace is currently dirty with many unrelated app/backend files; avoid mixing commits. +- This handoff only tracks the AgentScope migration subset above. +- `/transcribe` remains in `v1/agent/router.py` and intentionally independent. + +## 7) Resume Checklist (first actions next session) + +1. Read this handoff file. +2. Re-run Task 4 final checks and review gates. +3. Start Task 5 by replacing `NullEventStore` with real store implementation. +4. Keep route contract stable (`/api/v1/agent/*`) until Task 7 is verified. diff --git a/docs/plans/2026-03-11-agentscope-agent-route-migration.md b/docs/plans/2026-03-11-agentscope-agent-route-migration.md new file mode 100644 index 0000000..6028f29 --- /dev/null +++ b/docs/plans/2026-03-11-agentscope-agent-route-migration.md @@ -0,0 +1,308 @@ +# AgentScope Agent Route Migration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Keep `/api/v1/agent/*` routes stable while fully replacing old `core/agent` runtime with `core/agentscope` runtime, AG-UI event pipeline, Redis streaming, and session/message persistence. + +**Architecture:** Route handlers remain under `v1/agent`, but all runtime behavior moves to `core/agentscope` across five modules (`runtime`, `prompts`, `schemas`, `tools`, `events`). The `events` module owns AG-UI conversion, persistence, and Redis stream publishing/reading. Runtime orchestrator emits internal events only, then delegates to `events.pipeline` for normalization, persistence, and transport. + +**Tech Stack:** FastAPI, SQLAlchemy async, Redis streams, Taskiq, AgentScope ReActAgent, LiteLLM proxy, Pydantic v2, pytest. + +--- + +### Task 1: Define AgentScope Runtime Schemas + +**Files:** +- Modify: `backend/src/core/agentscope/schemas/__init__.py` +- Create: `backend/src/core/agentscope/schemas/agent_runtime.py` +- Test: `backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py` + +**Step 1: Write failing schema tests** + +```python +def test_run_command_schema_roundtrip() -> None: + payload = {"threadId": "...", "runId": "...", "messages": []} + model = RunCommand.model_validate(payload) + assert model.model_dump(by_alias=True)["threadId"] == payload["threadId"] +``` + +**Step 2: Run tests to verify failure** + +Run: `uv run pytest tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py -q` +Expected: FAIL because schema module/classes are missing. + +**Step 3: Implement schemas** + +```python +class RunCommand(BaseModel): + thread_id: str = Field(alias="threadId") + run_id: str = Field(alias="runId") +``` + +Also define: ResumeCommand, InternalRuntimeEvent, AgUiWireEvent, HistorySnapshotResponse, AcceptedTaskResponse. + +**Step 4: Re-run tests** + +Run: `uv run pytest tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src/core/agentscope/schemas/agent_runtime.py backend/src/core/agentscope/schemas/__init__.py backend/tests/unit/core/agentscope/schemas/test_agent_runtime_schemas.py +git commit -m "feat: add agentscope runtime schemas for agent routes" +``` + +### Task 2: Build Events Module (AG-UI + Redis + Persistence) + +**Files:** +- Create: `backend/src/core/agentscope/events/pipeline.py` +- Create: `backend/src/core/agentscope/events/agui_codec.py` +- Create: `backend/src/core/agentscope/events/redis_bus.py` +- Create: `backend/src/core/agentscope/events/sse.py` +- Create: `backend/src/core/agentscope/events/store.py` +- Create: `backend/src/core/agentscope/events/__init__.py` +- Test: `backend/tests/unit/core/agentscope/events/test_agui_codec.py` +- Test: `backend/tests/unit/core/agentscope/events/test_sse.py` +- Test: `backend/tests/unit/core/agentscope/events/test_pipeline.py` + +**Step 1: Write failing tests for codec/sse/pipeline** + +```python +def test_codec_maps_internal_text_delta_to_agui() -> None: + event = to_agui_wire(...) + assert event["type"] == "TEXT_MESSAGE_CONTENT" +``` + +**Step 2: Run tests to verify failure** + +Run: `uv run pytest tests/unit/core/agentscope/events -q` +Expected: FAIL due to missing modules. + +**Step 3: Implement module** + +```python +class AgentScopeEventPipeline: + async def emit(self, event: InternalRuntimeEvent) -> str: + wire = to_agui_wire(event) + await self._store.persist(wire) + return await self._redis.append(wire) +``` + +Implement SSE encoder and Redis read with cursor support. + +**Step 4: Re-run tests** + +Run: `uv run pytest tests/unit/core/agentscope/events -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src/core/agentscope/events backend/tests/unit/core/agentscope/events +git commit -m "feat: add agentscope events pipeline for ag-ui redis and persistence" +``` + +### Task 3: Rebuild Runtime Orchestrator to Emit Internal Events + +**Files:** +- Modify: `backend/src/core/agentscope/runtime/orchestrator.py` +- Modify: `backend/src/core/agentscope/runtime/__init__.py` +- Create: `backend/src/core/agentscope/runtime/agent_route_runtime.py` +- Test: `backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py` + +**Step 1: Write failing runtime tests** + +```python +@pytest.mark.asyncio +async def test_runtime_emits_run_started_and_finished() -> None: + events = await runtime.run(...) + assert events[0].type == "run_started" +``` + +**Step 2: Run tests to verify failure** + +Run: `uv run pytest tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -q` +Expected: FAIL before runtime adapter exists. + +**Step 3: Implement runtime adapter** + +```python +class AgentRouteRuntime: + async def run(self, command: RunCommand) -> RuntimeResult: + await self._events.emit(run_started_event(...)) + ... +``` + +Hook existing stage runtime (intent/execution/report) and stream text/tool events into pipeline. + +**Step 4: Re-run tests** + +Run: `uv run pytest tests/unit/core/agentscope/runtime/test_agent_route_runtime.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src/core/agentscope/runtime backend/tests/unit/core/agentscope/runtime/test_agent_route_runtime.py +git commit -m "feat: add agentscope runtime adapter for agent route commands" +``` + +### Task 4: Replace v1 Agent Service Dependencies with AgentScope + +**Files:** +- Modify: `backend/src/v1/agent/dependencies.py` +- Modify: `backend/src/v1/agent/service.py` +- Modify: `backend/src/v1/agent/router.py` +- Test: `backend/tests/unit/v1/agent/test_service.py` +- Test: `backend/tests/integration/v1/agent/test_sse_flow_live.py` + +**Step 1: Write failing tests for route/service integration contracts** + +```python +@pytest.mark.asyncio +async def test_enqueue_run_uses_agentscope_runtime() -> None: + resp = await service.enqueue_run(...) + assert resp.thread_id == input.thread_id +``` + +**Step 2: Run tests to verify failure** + +Run: `uv run pytest tests/unit/v1/agent/test_service.py -q` +Expected: FAIL before dependency rewiring. + +**Step 3: Implement rewiring** + +```python +service = AgentService(runtime=AgentRouteRuntime(...), events=AgentScopeEventsFacade(...)) +``` + +Keep paths unchanged (`/runs`, `/resume`, `/events`, `/history`), keep `/transcribe` standalone. + +**Step 4: Re-run tests** + +Run: `uv run pytest tests/unit/v1/agent/test_service.py tests/integration/v1/agent/test_sse_flow_live.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src/v1/agent backend/tests/unit/v1/agent backend/tests/integration/v1/agent +git commit -m "refactor: route v1 agent endpoints to agentscope runtime" +``` + +### Task 5: Migrate Session/Message Persistence Ownership to AgentScope Events + +**Files:** +- Modify: `backend/src/models/agent_chat_session.py` +- Modify: `backend/src/models/agent_chat_message.py` +- Modify/Create migrations under `backend/alembic/versions/*` +- Create: `backend/tests/integration/core/agentscope/test_persistence_metrics.py` + +**Step 1: Write failing integration tests for metrics persistence** + +```python +@pytest.mark.asyncio +async def test_message_tokens_cost_latency_persisted() -> None: + ... + assert row.input_tokens > 0 +``` + +**Step 2: Run tests to verify failure** + +Run: `uv run pytest tests/integration/core/agentscope/test_persistence_metrics.py -q` +Expected: FAIL until event store persists metrics. + +**Step 3: Implement persistence updates/migration if needed** + +```python +await store.persist_message(..., input_tokens=..., latency_ms=...) +``` + +**Step 4: Re-run tests** + +Run: `uv run pytest tests/integration/core/agentscope/test_persistence_metrics.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src/core/agentscope/events/store.py backend/src/models backend/alembic/versions backend/tests/integration/core/agentscope/test_persistence_metrics.py +git commit -m "feat: persist agentscope session and message metrics" +``` + +### Task 6: Remove core/agent and Finalize Imports + +**Files:** +- Delete: `backend/src/core/agent/**` +- Modify: all import sites found by grep +- Test: `backend/tests/**` impacted suites + +**Step 1: Write guard tests proving no core.agent imports remain** + +```python +def test_no_core_agent_imports() -> None: + ... +``` + +**Step 2: Run guard test and verify failure** + +Run: `uv run pytest tests/unit/core/agentscope/test_no_legacy_agent_imports.py -q` +Expected: FAIL before cleanup. + +**Step 3: Remove old module and update imports** + +```python +# replace from core.agent... with core.agentscope... +``` + +**Step 4: Run full verification** + +Run: +- `uv run pytest tests/unit/core/agentscope tests/unit/v1/agent -q` +- `uv run pytest tests/integration/core/agentscope tests/integration/v1/agent -q` +- `uv run ruff check src tests` +- `uv run basedpyright src tests` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add backend/src backend/tests +git commit -m "refactor: remove legacy core agent module after agentscope migration" +``` + +### Task 7: Frontend Contract Verification (No Route Change) + +**Files:** +- Verify: `apps/lib/features/chat/data/models/ag_ui_event.dart` +- Verify: `apps/lib/features/chat/data/services/ag_ui_service.dart` +- Test: `apps/test/features/chat/**` + +**Step 1: Add failing compatibility test for required AG-UI events** + +```dart +test('supports run/text/tool event sequence') { ... } +``` + +**Step 2: Run test to verify failure** + +Run: `cd apps && flutter test test/features/chat/...` +Expected: FAIL until backend event payload normalization is aligned. + +**Step 3: Implement backend compatibility fixes only** + +Keep frontend route and event type expectations unchanged where possible. + +**Step 4: Re-run Flutter tests** + +Run: `cd apps && flutter test` +Expected: PASS on impacted suites. + +**Step 5: Commit** + +```bash +git add apps/lib apps/test +git commit -m "test: verify ag-ui event contract compatibility for chat client" +``` diff --git a/docs/plans/2026-03-11-calendar-dayview-improvement-design.md b/docs/plans/2026-03-11-calendar-dayview-improvement-design.md new file mode 100644 index 0000000..6cc576a --- /dev/null +++ b/docs/plans/2026-03-11-calendar-dayview-improvement-design.md @@ -0,0 +1,47 @@ +# 日视图改进设计 + +**Date:** 2026-03-11 +**Status:** 已确认 + +## 需求概述 + +对日历日视图进行三项改进: +1. 固定顶部头部 +2. 添加「今天」快捷按钮 +3. 双指缩放时间轴高度 + +## 设计方案 + +### 1. 固定顶部头部 + +使用 `Stack` + `Positioned` 布局: +- 外层 `Stack` 包含头部和可滚动内容 +- 头部使用 `Positioned` 固定在顶部 `top: 0` +- 时间轴内容使用 `SingleChildScrollView` 可滚动 +- 头部高度:68px + +### 2. 「今天」按钮 + +- **位置**:+ 号按钮左侧(`const Spacer()` 在返回和日期之间,+号和今天按钮靠近) +- **样式**: + - 圆角按钮(`BorderRadius.circular(AppRadius.xl)`) + - 背景:`AppColors.messageBtnWrap` + - 文字:黑色,「今天」 +- **显示条件**:只有当 `_selectedDate` 不是今天时显示 +- **点击行为**:调用 `_goToToday()` 跳转到今天 + +### 3. 双指缩放时间轴高度 + +使用 `GestureDetector` 监听缩放手势: +- `_hourHeight` 从 `const` 改为变量 `double _hourHeight = 34.0;` +- 添加缩放状态变量: + ```dart + double _baseHourHeight = 34.0; + double _currentScale = 1.0; + ``` +- 缩放范围:0.5x ~ 2.0x(17px ~ 68px/小时) +- 在 `_buildTimelineBoard()` 中使用 `_hourHeight` 动态计算高度 + +## 实现计划 + +见 `docs/plans/2026-03-11-calendar-dayview-improvement-impl.md` diff --git a/docs/plans/2026-03-11-calendar-dayview-improvement-impl.md b/docs/plans/2026-03-11-calendar-dayview-improvement-impl.md new file mode 100644 index 0000000..b3c08f4 --- /dev/null +++ b/docs/plans/2026-03-11-calendar-dayview-improvement-impl.md @@ -0,0 +1,223 @@ +# 日视图改进实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 对日历日视图进行三项改进:固定顶部头部、添加「今天」按钮、双指缩放时间轴高度 + +**Architecture:** 使用 Stack + Positioned 布局固定头部,使用 GestureDetector 监听缩放手势动态调整时间轴高度 + +**Tech Stack:** Flutter, Dart + +--- + +### Task 1: 修改 _hourHeight 为变量并添加缩放状态 + +**Files:** +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart:27-38` + +**Step 1: 添加状态变量** + +在 `_CalendarDayWeekScreenState` 类中: +- 将 `static const double _hourHeight = 34;` 改为 `double _hourHeight = 34.0;` +- 添加缩放相关变量: + ```dart + double _baseHourHeight = 34.0; + double _currentScale = 1.0; + ``` + +**Step 2: Commit** + +```bash +git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +git commit -m "refactor: 将 _hourHeight 改为变量支持缩放" +``` + +--- + +### Task 2: 实现双指缩放时间轴高度功能 + +**Files:** +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` + +**Step 1: 添加缩放手势监听** + +在 `build` 方法的外层 `Scaffold` 包装 `GestureDetector`: +```dart +return Scaffold( + backgroundColor: AppColors.todoBg, + body: GestureDetector( + onScaleStart: (details) { + _baseHourHeight = _hourHeight; + }, + onScaleUpdate: (details) { + setState(() { + _currentScale = details.scale.clamp(0.5, 2.0); + _hourHeight = (_baseHourHeight * _currentScale).clamp(17.0, 68.0); + }); + }, + child: SafeArea(...), + ), +); +``` + +**Step 2: 运行测试验证** + +运行 Flutter 测试确保没有破坏现有功能。 + +**Step 3: Commit** + +```bash +git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +git commit -m "feat: 添加双指缩放时间轴高度功能" +``` + +--- + +### Task 3: 实现固定顶部头部布局 + +**Files:** +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart:78-113` + +**Step 1: 重构 build 方法为 Stack 布局** + +将 `Column` 改为 `Stack`,头部使用 `Positioned` 固定: +```dart +return Scaffold( + backgroundColor: AppColors.todoBg, + body: Stack( + children: [ + // 可滚动内容 + Positioned.fill( + top: 68, // 头部高度 + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only( + left: AppSpacing.lg, + right: AppSpacing.lg, + top: 2, + bottom: 104, + ), + child: Column( + children: [ + _buildWeekStrip(), + const SizedBox(height: 8), + KeyedSubtree( + key: _eventsKey, + child: _buildTimelineBoard(), + ), + ], + ), + ), + ), + ), + // 固定头部 + Positioned( + top: 0, + left: 0, + right: 0, + child: _buildHeader(), + ), + // 底部 dock + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _buildBottomDock(), + ), + ], + ), +); +``` + +**Step 2: 运行验证** + +确保头部固定在顶部,内容可滚动。 + +**Step 3: Commit** + +```bash +git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +git commit -m "feat: 固定日视图头部在顶部" +``` + +--- + +### Task 4: 添加「今天」快捷按钮 + +**Files:** +- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart:115-183` + +**Step 1: 修改 _buildHeader 添加「今天」按钮** + +在 `_buildHeader` 方法中: +- 在 + 号按钮左侧添加「今天」按钮 +- 使用 `isSameDay(_selectedDate, DateTime.now())` 判断是否显示 +- 添加 `_goToToday()` 方法: + ```dart + void _goToToday() { + final today = DateTime.now(); + setState(() { + _selectedDate = today; + }); + _calendarManager.setSelectedDate(today); + _updateMonthDates(); + _scrollToSelectedDate(animate: true); + _loadEvents(); + } + ``` + +**Step 2: 修改 + 号按钮位置** + +将 + 号按钮移到最右侧,今天按钮在 + 号左侧。 + +**Step 3: 运行验证** + +- 查看非今天日期时是否显示「今天」按钮 +- 点击后是否正确跳转到今天 + +**Step 4: Commit** + +```bash +git add apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +git commit -m "feat: 添加今天快捷按钮" +``` + +--- + +### Task 5: 运行完整测试验证 + +**Step 1: 运行 Flutter 测试** + +```bash +cd apps && flutter test +``` + +**Step 2: 手动验证** +- 日视图固定头部 +- 「今天」按钮显示和跳转 +- 双指缩放高度 + +**Step 3: Commit** + +```bash +git add . +git commit -m "test: 验证日视图改进功能" +``` + +--- + +### Task 6: 更新文档并合并 + +**Step 1: 更新 runtime-route.md** + +同步更新 `docs/runtime/runtime-route.md` 中的日历相关描述。 + +**Step 2: 提交并推送到远程** + +```bash +git push origin dev +``` + +--- + +**Plan complete.** diff --git a/docs/plans/2026-03-11-calendar-metadata-and-api-implementation.md b/docs/plans/2026-03-11-calendar-metadata-and-api-implementation.md deleted file mode 100644 index 503ad52..0000000 --- a/docs/plans/2026-03-11-calendar-metadata-and-api-implementation.md +++ /dev/null @@ -1,78 +0,0 @@ -# Calendar Metadata And API Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 统一后端 `schedule-items` 与 Agent 日历卡片的 metadata v1 约束,并让前端日历模块完成真实 API 接入与 metadata 全字段渲染。 - -**Architecture:** 后端以 `v1.schedule_items.schemas` 作为 metadata 单一真源,路由响应与 Agent 工具 payload 统一复用该结构。前端新增 Calendar API 数据层,使用 DTO 与领域模型映射驱动 UI;日历创建弹窗与详情页升级为可编辑/展示完整 metadata(location、notes、attachments、version)。 - -**Tech Stack:** FastAPI, Pydantic v2, SQLAlchemy, Flutter, Dio, GetIt, widget/unit tests - ---- - -### Task 1: 后端 metadata v1 校验(TDD) - -**Files:** -- Modify: `backend/tests/unit/v1/schedule_items/test_schemas.py` -- Modify: `backend/src/v1/schedule_items/schemas.py` - -**Steps:** -1. 增加失败测试:`metadata.color` 非 `#RRGGBB` 拒绝、`metadata.version` 非 1 拒绝、metadata/attachment 非法额外字段拒绝。 -2. 运行 `uv run pytest backend/tests/unit/v1/schedule_items/test_schemas.py -q`,确认 RED。 -3. 在 schema 中补齐约束:`extra="forbid"`、`Field(pattern=...)`、`Literal[1]`。 -4. 再跑同一测试文件确认 GREEN。 - -### Task 2: 后端响应完整 metadata(TDD) - -**Files:** -- Modify: `backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py` -- Modify: `backend/tests/unit/core/agent/test_list_calendar_events_tool.py` -- Modify: `backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py` - -**Steps:** -1. 增加失败测试:`calendar_card.v1` 与 `calendar_event_list.v1` 的 data 含完整 `metadata`,并兼容已有扁平字段。 -2. 运行 `uv run pytest backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py backend/tests/unit/core/agent/test_list_calendar_events_tool.py -q`,确认 RED。 -3. 调整 `_event_payload` 输出,补齐 `metadata`(color/location/notes/attachments/version)。 -4. 再跑测试确认 GREEN。 - -### Task 3: 前端日历真实 API 数据层(TDD) - -**Files:** -- Add: `apps/lib/features/calendar/data/calendar_api.dart` -- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart` -- Modify: `apps/lib/features/calendar/data/services/mock_calendar_service.dart` -- Modify: `apps/lib/core/di/injection.dart` -- Add: `apps/test/features/calendar/data/calendar_api_test.dart` - -**Steps:** -1. 新增失败测试覆盖 GET/POST/PATCH/DELETE 与 metadata 映射(含 attachments/version)。 -2. 运行 `cd apps && flutter test test/features/calendar/data/calendar_api_test.dart`,确认 RED。 -3. 实现 API 与模型序列化/反序列化,`CalendarService` 在真实环境走 API,在 mock 环境走现有内存服务。 -4. 再跑测试确认 GREEN。 - -### Task 4: 前端完整 metadata 渲染与创建/查看增强(TDD) - -**Files:** -- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart` -- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart` -- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart` -- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart` -- Modify: `apps/lib/features/chat/data/models/tool_result.dart` -- Modify: `apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart` -- Add: `apps/test/features/calendar/ui/calendar_event_detail_screen_test.dart` - -**Steps:** -1. 增加失败测试:详情页显示 attachments/version;创建弹窗支持 attachments 输入并提交。 -2. 运行对应 flutter test,确认 RED。 -3. 改造 UI 与数据写回逻辑,保证 metadata 全字段渲染。 -4. 再跑测试确认 GREEN。 - -### Task 5: 文档与验证 - -**Files:** -- Modify: `docs/runtime/runtime-route.md` - -**Steps:** -1. 更新 metadata v1 校验规则与返回示例。 -2. 运行后端+前端相关测试集合,记录结果。 -3. 执行 L2 门禁:`refactor-cleaner`、`code-reviewer`、`security-reviewer` 并修复问题。 diff --git a/docs/plans/2026-03-11-calendar-reminder-metadata-design.md b/docs/plans/2026-03-11-calendar-reminder-metadata-design.md new file mode 100644 index 0000000..03668f3 --- /dev/null +++ b/docs/plans/2026-03-11-calendar-reminder-metadata-design.md @@ -0,0 +1,63 @@ +# 日历提醒字段与详情页对齐设计 + +**Date:** 2026-03-11 +**Status:** 已确认 + +## 目标 + +- 修复日历事件详情页字段映射错误,去掉 raw metadata 直出 +- 新增可持久化的提醒字段(方案1):`metadata.reminder_minutes` +- 打通前后端和 AgentScope 工具调用链 +- 用前端本地通知实现系统提醒与震动 + +## 数据契约 + +### metadata 结构 + +```json +{ + "color": "#4F46E5", + "location": "会议室A", + "notes": "带电脑", + "attachments": [], + "reminder_minutes": 15, + "version": 1 +} +``` + +### 字段规则 + +- `reminder_minutes`: `int | null` +- 取值范围:`0..10080`(0 表示准时提醒,10080 表示最多提前 7 天) +- 兼容历史数据:缺失或 null 视为无提醒 + +## 前端设计 + +1. 模型层(`ScheduleMetadata`)新增 `reminderMinutes` +2. 详情页:提醒时间改为结构化渲染 + - null: `无` + - 0: `准时提醒` + - n: `开始前 n 分钟` +3. 创建/编辑弹层新增提醒选项,默认值为 `15` +4. 删除 metadata raw 原样渲染区块 + +## 本地通知设计 + +- 采用 Flutter 本地通知,调度时间:`startAt - reminderMinutes` +- 创建/编辑成功:重建该事件通知 +- 删除成功:取消该事件通知 +- App 启动后:扫描未来事件并重建通知(补偿机制) + +## 后端与 AgentScope 设计 + +1. `ScheduleItemMetadata` 增加 `reminder_minutes` +2. service 继续走 `metadata -> extra_metadata`,不加新 DB 列 +3. AgentScope `calendar.write` 增加 `reminder_minutes` 参数 +4. CrewAI calendar tool 将 `reminderMinutes` 映射为 `metadata.reminder_minutes` +5. calendar tool 回包增加 `reminderMinutes` 字段 + +## 验证策略 + +- 后端:schemas/service/agentscope 单元测试 +- 前端:calendar_api 与详情页渲染测试 +- 手动:创建提醒 -> 等待系统通知与震动 -> 更新/删除后确认调度变更 diff --git a/docs/plans/2026-03-11-calendar-reminder-metadata-impl.md b/docs/plans/2026-03-11-calendar-reminder-metadata-impl.md new file mode 100644 index 0000000..9b0a8c0 --- /dev/null +++ b/docs/plans/2026-03-11-calendar-reminder-metadata-impl.md @@ -0,0 +1,170 @@ +# Calendar Reminder Metadata Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `metadata.reminder_minutes` end-to-end (frontend/backend/AgentScope), fix detail-page field rendering, and enable local system reminders. + +**Architecture:** Keep calendar schema additive via `metadata` JSON (no new DB columns). Backend validates and persists `reminder_minutes`; AgentScope tools accept and pass reminder values; frontend parses/edits/displays reminder and schedules local notifications based on event time. + +**Tech Stack:** Flutter, FastAPI, Pydantic v2, AgentScope toolkit, pytest, flutter_test. + +--- + +### Task 1: Backend metadata schema tests first + +**Files:** +- Test: `backend/tests/unit/v1/schedule_items/test_schemas.py` +- Modify: `backend/src/v1/schedule_items/schemas.py` + +**Step 1: Write failing tests** +- Add tests for `reminder_minutes` accepted values (`None`, `0`, `15`, `10080`) +- Add tests for invalid values (`-1`, `10081`) + +**Step 2: Run tests to verify RED** +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_schemas.py -q` +Expected: FAIL for missing/invalid field support. + +**Step 3: Minimal implementation** +- Add `reminder_minutes: int | None = Field(default=None, ge=0, le=10080)` to `ScheduleItemMetadata` + +**Step 4: Verify GREEN** +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_schemas.py -q` +Expected: PASS. + +### Task 2: Backend service mapping tests first + +**Files:** +- Test: `backend/tests/unit/v1/schedule_items/test_service.py` +- Modify: `backend/src/v1/schedule_items/service.py` + +**Step 1: Write failing tests** +- Assert create/update `extra_metadata` includes `reminder_minutes` + +**Step 2: Run RED** +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py -q` + +**Step 3: Minimal implementation** +- Ensure model_dump path includes new field naturally, no special-case stripping + +**Step 4: Verify GREEN** +Run: `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py -q` + +### Task 3: AgentScope custom tool tests first + +**Files:** +- Test: `backend/tests/unit/core/agentscope/test_calendar_tools.py` +- Modify: `backend/src/core/agentscope/tools/custom/calendar.py` + +**Step 1: Write failing tests** +- `calendar_write` maps `reminder_minutes` to tool args `reminderMinutes` +- rejects out-of-range reminder values + +**Step 2: Run RED** +Run: `uv run pytest backend/tests/unit/core/agentscope/test_calendar_tools.py -q` + +**Step 3: Minimal implementation** +- Add `reminder_minutes` parameter and validation in `calendar_write` +- Add mapping into `tool_args` + +**Step 4: Verify GREEN** +Run: `uv run pytest backend/tests/unit/core/agentscope/test_calendar_tools.py -q` + +### Task 4: CrewAI calendar bridge tests first + +**Files:** +- Test: `backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py` +- Modify: `backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py` + +**Step 1: Write failing tests** +- create path maps `reminderMinutes -> metadata.reminder_minutes` +- update path can patch `reminder_minutes` + +**Step 2: Run RED** +Run: `uv run pytest backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py -q` + +**Step 3: Minimal implementation** +- Extend `_resolve_metadata`, `_execute_update`, and `_event_payload` + +**Step 4: Verify GREEN** +Run: `uv run pytest backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py -q` + +### Task 5: Frontend model/API tests first + +**Files:** +- Test: `apps/test/features/calendar/data/calendar_api_test.dart` +- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart` + +**Step 1: Write failing tests** +- parse `metadata.reminder_minutes` +- serialize `metadata.reminder_minutes` in create/update payload + +**Step 2: Run RED** +Run: `cd apps && flutter test test/features/calendar/data/calendar_api_test.dart` + +**Step 3: Minimal implementation** +- add `reminderMinutes` in model + json mapping + +**Step 4: Verify GREEN** +Run: `cd apps && flutter test test/features/calendar/data/calendar_api_test.dart` + +### Task 6: Detail UI rendering fix tests first + +**Files:** +- Create/Test: `apps/test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart` +- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart` + +**Step 1: Write failing widget tests** +- reminder text for null/0/15 +- metadata raw block no longer visible + +**Step 2: Run RED** +Run: `cd apps && flutter test test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart` + +**Step 3: Minimal implementation** +- remove raw metadata section +- render structured reminder text + +**Step 4: Verify GREEN** +Run: `cd apps && flutter test test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart` + +### Task 7: Local notification service integration + +**Files:** +- Create: `apps/lib/core/notifications/local_notification_service.dart` +- Modify: `apps/lib/core/di/injection.dart` +- Modify: `apps/lib/main.dart` +- Modify: `apps/lib/features/calendar/data/services/mock_calendar_service.dart` +- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart` + +**Step 1: Add local notification dependencies** +- Update `apps/pubspec.yaml` with `flutter_local_notifications` + +**Step 2: Implement scheduling API** +- init permissions +- schedule/update/cancel by event id +- vibration enabled for Android notification details + +**Step 3: Integrate into calendar flow** +- create/update/delete hooks call notification service +- startup rebuild for future events + +**Step 4: Verify manually** +- create reminder 1-2 min event and verify system notification + vibration + +### Task 8: Full verification + +**Step 1: Backend checks** +Run: +- `uv run pytest backend/tests/unit/v1/schedule_items/test_schemas.py -q` +- `uv run pytest backend/tests/unit/v1/schedule_items/test_service.py -q` +- `uv run pytest backend/tests/unit/core/agentscope/test_calendar_tools.py -q` +- `uv run pytest backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py -q` + +**Step 2: Frontend checks** +Run: +- `cd apps && flutter test test/features/calendar/data/calendar_api_test.dart` +- `cd apps && flutter test test/features/calendar/ui/screens/calendar_event_detail_screen_test.dart` +- `cd apps && flutter analyze lib/features/calendar lib/core/notifications` + +**Step 3: Manual verification evidence** +- create/update/delete reminder event and capture observed notification behavior. diff --git a/docs/plans/2026-03-11-home-image-picker-design.md b/docs/plans/2026-03-11-home-image-picker-design.md new file mode 100644 index 0000000..d0d2814 --- /dev/null +++ b/docs/plans/2026-03-11-home-image-picker-design.md @@ -0,0 +1,136 @@ +# 首页图片选择功能设计 + +## 1. 需求概述 + +在首页聊天界面的加号按钮弹出的底部面板中,实现拍照和相册选择图片功能: +- 最多选择 3 张图片 +- 图片预览显示在输入框上方 +- 图片可被取消移除 +- 点击发送后图片随文本一起发送到后端 + +## 2. 技术方案 + +### 2.1 依赖 + +添加 `image_picker: ^1.0.7` 到 `pubspec.yaml` + +### 2.2 状态管理 + +在 `HomeScreen` 中添加图片状态: +```dart +List _selectedImages = []; // 最多3张 +``` + +### 2.3 图片选择逻辑 + +修改 `home_sheet.dart`: +- `image_picker` 选择图片(最多3张) +- 返回选中的 `List` 到 `HomeScreen` + +### 2.4 AG-UI 消息格式 + +修改 `ag_ui_service.dart` 的 `_buildRunInput` 方法,支持多模态消息: + +```dart +Map _buildRunInput({ + required String content, + List? images, +}) { + final threadId = _threadId ?? _newUuid(); + final runId = _nextId(_runIdPrefix); + + // 构建多模态内容块 + final contentBlocks = >[]; + + // 添加文本 + if (content.isNotEmpty) { + contentBlocks.add({'type': 'text', 'text': content}); + } + + // 添加图片(Base64 编码) + for (final image in images ?? []) { + final bytes = await image.readAsBytes(); + final base64 = base64Encode(bytes); + contentBlocks.add({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': 'image/jpeg', + 'data': base64, + }, + }); + } + + return { + 'threadId': threadId, + 'runId': runId, + 'state': {}, + 'messages': [ + { + 'id': _nextId('user_'), + 'role': 'user', + 'content': contentBlocks.length == 1 + ? (contentBlocks[0]['type'] == 'text' + ? contentBlocks[0]['text'] + : contentBlocks) + : contentBlocks, + }, + ], + // ... + }; +} +``` + +## 3. UI 设计 + +### 3.1 图片预览区 + +位置:输入框上方,聊天消息区域下方 + +``` +┌─────────────────────────────────────┐ +│ 聊天消息区域 │ +│ │ +├─────────────────────────────────────┤ +│ ┌─────────┐ ┌─────────┐ ┌────────┐│ +│ │ ✕ │ │ ✕ │ │ ✕ ││ ← 预览区 +│ │ [图片] │ │ [图片] │ │ [图片] ││ +│ └─────────┘ └─────────┘ └────────┘│ +├─────────────────────────────────────┤ +│ [+] [ 输入消息... ] [发送]│ +└─────────────────────────────────────┘ +``` + +### 3.2 样式规格 + +| 元素 | 值 | +|------|-----| +| 预览卡片尺寸 | 80x80 dp | +| 圆角 | `AppRadius.md` (12dp) | +| 间距 | `AppSpacing.sm` (8dp) | +| 取消按钮 | 24x24 圆形,红色背景,白色 X 图标 | +| 边框 | 1dp `AppColors.slate200` | + +### 3.3 交互 + +- 点击加号 → 底部弹出选择面板 +- 选择图片 → 预览区显示缩略图 +- 点击 X → 移除对应图片 +- 输入文本 + 有图片 → 点击发送发送组合消息 + +## 4. 文件改动 + +| 文件 | 改动 | +|------|------| +| `pubspec.yaml` | 添加 image_picker 依赖 | +| `home_sheet.dart` | 实现拍照/相册选择 | +| `home_screen.dart` | 添加图片状态、预览区 UI | +| `ag_ui_service.dart` | 修改 _buildRunInput 支持多模态 | + +## 5. 测试要点 + +- [ ] 选择 1-3 张图片正常显示 +- [ ] 选择超过 3 张时提示或限制 +- [ ] 图片可以成功移除 +- [ ] 发送消息时图片 Base64 正确编码 +- [ ] AG-UI 消息格式符合规范 diff --git a/docs/plans/2026-03-11-home-image-picker-impl.md b/docs/plans/2026-03-11-home-image-picker-impl.md new file mode 100644 index 0000000..bbff217 --- /dev/null +++ b/docs/plans/2026-03-11-home-image-picker-impl.md @@ -0,0 +1,463 @@ +# 首页图片选择功能实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在首页聊天界面实现拍照/相册选择图片功能,最多3张,图片随文本一起发送 + +**Architecture:** 使用 image_picker 选择图片,通过 AG-UI 多模态消息格式发送到后端 + +**Tech Stack:** Flutter, image_picker, AG-UI Protocol + +--- + +### Task 1: 添加 image_picker 依赖 + +**Files:** +- Modify: `apps/pubspec.yaml` + +**Step 1: 添加依赖** + +在 `dependencies` 节点下添加: +```yaml +image_picker: ^1.0.7 +``` + +**Step 2: 安装依赖** + +Run: `cd apps && flutter pub get` + +Expected: image_picker 添加成功 + +--- + +### Task 2: 实现 HomeSheet 图片选择功能 + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_sheet.dart:1-113` + +**Step 1: 添加 image_picker 导入和修改 HomeSheet** + +```dart +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/theme/design_tokens.dart'; + +class HomeSheet extends StatelessWidget { + final Function(List) onImagesSelected; + + const HomeSheet({super.key, required this.onImagesSelected}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + color: const Color(0x4D0F172A), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () {}, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.slate300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + _buildSheetContent(context), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSheetContent(BuildContext context) { + return SizedBox( + height: 280, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildOptionCard( + context: context, + icon: LucideIcons.camera, + label: '拍照', + onTap: () => _handleCameraTap(context), + ), + const SizedBox(width: 24), + _buildOptionCard( + context: context, + icon: LucideIcons.image, + label: '相册', + onTap: () => _handlePhotoTap(context), + ), + ], + ), + ); + } + + Widget _buildOptionCard({ + required BuildContext context, + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.blue50, + borderRadius: BorderRadius.circular(16), + ), + child: Icon(icon, size: 32, color: AppColors.blue500), + ), + const SizedBox(height: 12), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.slate700, + ), + ), + ], + ), + ); + } + + Future _handleCameraTap(BuildContext context) async { + final picker = ImagePicker(); + final image = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + if (image != null) { + onImagesSelected([image]); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + } + + Future _handlePhotoTap(BuildContext context) async { + final picker = ImagePicker(); + final images = await picker.pickMultiImage( + imageQuality: 80, + limit: 3, + ); + if (images.isNotEmpty) { + onImagesSelected(images); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + } +} +``` + +**Step 2: 验证编译** + +Run: `cd apps && flutter analyze lib/features/home/ui/screens/home_sheet.dart` +Expected: No errors + +--- + +### Task 3: 修改 HomeScreen 添加图片预览区 + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart:1-820` + +**Step 1: 添加导入和状态变量** + +在文件顶部添加导入: +```dart +import 'package:image_picker/image_picker.dart'; +``` + +在 `_HomeScreenState` 类中添加状态变量: +```dart +List _selectedImages = []; +``` + +**Step 2: 添加图片预览 Widget** + +在 `_buildInputContainer` 方法之前添加: +```dart +Widget _buildImagePreview() { + if (_selectedImages.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only( + left: _inputPadding, + right: _inputPadding, + bottom: AppSpacing.sm, + ), + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: _selectedImages.asMap().entries.map((entry) { + final index = entry.key; + final image = entry.value; + return _buildImageThumbnail(image, index); + }).toList(), + ), + ); +} + +Widget _buildImageThumbnail(XFile image, int index) { + return Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.md), + child: Image.file( + File(image.path), + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => _removeImage(index), + child: Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.red500, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.x, + size: 14, + color: AppColors.white, + ), + ), + ), + ), + ], + ); +} + +void _removeImage(int index) { + setState(() { + _selectedImages.removeAt(index); + }); +} +``` + +**Step 3: 修改 _buildInputContainer 调用位置** + +在 `_buildInputContainer` 调用之前插入图片预览: +```dart +// 在 build 方法中修改 +body: SafeArea( + child: Column( + children: [ + _buildHeader(context), + Expanded(child: _buildChatArea(context, state)), + _buildImagePreview(), // 添加这行 + _buildInputContainer(context, state), + ], + ), +), +``` + +**Step 4: 修改 _showBottomSheet 传递回调** + +将 `_showBottomSheet` 方法修改为: +```dart +void _showBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => HomeSheet( + onImagesSelected: (images) { + setState(() { + // 限制最多3张 + final remaining = 3 - _selectedImages.length; + if (remaining > 0) { + _selectedImages.addAll(images.take(remaining)); + } + }); + }, + ), + ); +} +``` + +**Step 5: 验证编译** + +Run: `cd apps && flutter analyze lib/features/home/ui/screens/home_screen.dart` +Expected: No errors + +--- + +### Task 4: 修改 AgUiService 支持多模态消息 + +**Files:** +- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart:1-643` + +**Step 1: 添加 base64 导入** + +在文件顶部添加: +```dart +import 'dart:convert'; +import 'package:image_picker/image_picker.dart'; +``` + +**Step 2: 修改 sendMessage 方法签名** + +修改 `sendMessage` 方法接受可选的图片参数: +```dart +Future sendMessage(String content, {List? images}) async { + final streamToken = ++_activeStreamToken; + final runInput = _buildRunInput(content: content, images: images); + // ... 后续代码不变 +} +``` + +**Step 3: 修改 _buildRunInput 方法** + +```dart +Map _buildRunInput({ + required String content, + List? images, +}) { + final threadId = _threadId ?? _newUuid(); + final runId = _nextId(_runIdPrefix); + + // 构建多模态内容块 + final contentBlocks = >[]; + + // 添加文本(如果有) + if (content.isNotEmpty) { + contentBlocks.add({'type': 'text', 'text': content}); + } + + // 添加图片(如果有) + if (images != null && images.isNotEmpty) { + for (final image in images) { + final bytes = await image.readAsBytes(); + final base64 = base64Encode(bytes); + contentBlocks.add({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': 'image/jpeg', + 'data': base64, + }, + }); + } + } + + // 根据内容块数量决定消息格式 + final messageContent; + if (contentBlocks.isEmpty) { + messageContent = ''; + } else if (contentBlocks.length == 1 && contentBlocks[0]['type'] == 'text') { + // 纯文本使用简单格式(兼容现有逻辑) + messageContent = contentBlocks[0]['text']; + } else { + // 多模态消息使用内容块数组 + messageContent = contentBlocks; + } + + return { + 'threadId': threadId, + 'runId': runId, + 'state': {}, + 'messages': [ + {'id': _nextId('user_'), 'role': 'user', 'content': messageContent}, + ], + 'tools': _buildTools(), + 'context': >[], + 'forwardedProps': {}, + }; +} +``` + +**Step 4: 修改 _sendMessage 方法传递图片** + +在 `home_screen.dart` 中修改 `_sendMessage` 方法: +```dart +Future _sendMessage(BuildContext context) async { + final content = _messageController.text.trim(); + if (content.isEmpty && _selectedImages.isEmpty) return; + + // 保存图片引用 + final images = List.from(_selectedImages); + + FocusScope.of(context).unfocus(); + _messageController.clear(); + + // 清除图片 + setState(() { + _selectedImages.clear(); + }); + + await context.read().sendMessage(content, images: images); + // ... 后续代码不变 +} +``` + +**Step 5: 需要修改 ChatBloc 接口** + +检查 ChatBloc 的 sendMessage 方法签名,如果需要修改,添加 images 参数。 + +Run: `grep -n "sendMessage" apps/lib/features/chat/presentation/bloc/chat_bloc.dart` + +根据结果修改 ChatBloc 和相关调用。 + +**Step 6: 验证编译** + +Run: `cd apps && flutter analyze lib/features/chat/data/services/ag_ui_service.dart` +Expected: No errors + +--- + +### Task 5: 测试验证 + +**Step 1: 运行 Flutter 分析** + +Run: `cd apps && flutter analyze` +Expected: No errors + +**Step 2: 运行单元测试(如果有)** + +Run: `cd apps && flutter test` +Expected: Tests pass + +--- + +### 实施提示 + +1. Task 2 和 Task 3 可以并行开发(HomeSheet 和 HomeScreen 独立) +2. Task 4 需要在 Task 3 完成后进行,因为需要确定 ChatBloc 接口 +3. 如果遇到编译错误,检查 ImagePicker 是否正确导入 +4. AG-UI 格式可以参考: https://docs.ag-ui.com (如需要)