feat: 增强日历功能并集成 AgentScope 代理服务

This commit is contained in:
qzl
2026-03-11 17:16:11 +08:00
parent e20e7d2a02
commit 85b314cf64
53 changed files with 3642 additions and 297 deletions
+5
View File
@@ -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")
}
+3
View File
@@ -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<void> configureDependencies() async {
);
sl.registerSingleton<CalendarService>(calendarService);
sl.registerSingleton<LocalNotificationService>(LocalNotificationService());
final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi);
@@ -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<void> 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<void> 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<void> cancelEventReminder(String eventId) async {
await initialize();
await _plugin.cancel(_notificationIdForEvent(eventId));
}
Future<void> rebuildUpcomingReminders(
Iterable<ScheduleItemModel> 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}';
}
}
@@ -112,6 +112,7 @@ class ScheduleMetadata {
final String? color;
final String? location;
final String? notes;
final int? reminderMinutes;
final List<Attachment> attachments;
final int version;
final Map<String, dynamic> raw;
@@ -120,6 +121,7 @@ class ScheduleMetadata {
this.color,
this.location,
this.notes,
this.reminderMinutes,
List<Attachment>? attachments,
this.version = 1,
Map<String, dynamic>? raw,
@@ -130,6 +132,7 @@ class ScheduleMetadata {
String? color,
String? location,
String? notes,
int? reminderMinutes,
List<Attachment>? attachments,
int? version,
Map<String, dynamic>? 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<String, dynamic>.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,
};
@@ -24,11 +24,19 @@ class CalendarDayWeekScreen extends StatefulWidget {
State<CalendarDayWeekScreen> createState() => _CalendarDayWeekScreenState();
}
class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
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<int, Offset> _activePointers = {};
double? _pinchStartDistance;
double _pinchStartHourHeight = _defaultHourHeight;
late final CalendarStateManager _calendarManager;
late DateTime _selectedDate;
@@ -36,10 +44,12 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
final ScrollController _dayStripController = ScrollController();
Key _eventsKey = UniqueKey();
List<ScheduleItemModel> _events = const [];
String? _lastRoute;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_calendarManager = sl<CalendarStateManager>();
if (widget.resetToToday) {
@@ -52,9 +62,22 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
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<CalendarDayWeekScreen> {
@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(
body: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/calendar/month?date=${formatYmd(_selectedDate)}');
}
},
child: SafeArea(
child: Stack(
children: [
_buildHeader(),
Expanded(
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,
bottom: 104,
),
child: Column(
children: [
_buildWeekStrip(),
const SizedBox(height: 8),
KeyedSubtree(
child: KeyedSubtree(
key: _eventsKey,
child: _buildTimelineBoard(),
),
),
),
),
),
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(),
),
],
),
),
),
),
_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<CalendarDayWeekScreen> {
),
),
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<CalendarDayWeekScreen> {
bool isDisabled = false,
}) {
return SizedBox(
height: 34,
height: _hourHeight,
child: Row(
children: [
SizedBox(
@@ -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<CalendarEventDetailScreen> {
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<CalendarEventDetailScreen> {
if (event.metadata?.notes != null) ...[
_buildNotesField(event.metadata!.notes!),
],
const SizedBox(height: 14),
_buildMetadataSection(event.metadata),
],
),
),
@@ -186,6 +188,16 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
);
}
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<CalendarEventDetailScreen> {
TextButton(
onPressed: () async {
await sl<CalendarService>().deleteEvent(widget.eventId);
await sl<LocalNotificationService>().cancelEventReminder(
widget.eventId,
);
if (!context.mounted) {
return;
}
@@ -385,40 +400,6 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
);
}
Widget _buildMetadataSection(ScheduleMetadata? metadata) {
final raw = metadata?.raw ?? const <String, dynamic>{};
if (raw.isEmpty) {
return _buildDetailField('metadata', '');
}
final rows = <String>[];
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 {
@@ -20,16 +20,19 @@ class CalendarMonthScreen extends StatefulWidget {
State<CalendarMonthScreen> createState() => _CalendarMonthScreenState();
}
class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
class _CalendarMonthScreenState extends State<CalendarMonthScreen>
with WidgetsBindingObserver {
late final CalendarStateManager _calendarManager;
late DateTime _currentMonth;
late DateTime _selectedDate;
Key _eventsKey = UniqueKey();
final Map<String, List<ScheduleItemModel>> _eventsByDay = {};
String? _lastRoute;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_calendarManager = sl<CalendarStateManager>();
if (widget.resetToToday) {
@@ -40,6 +43,22 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
_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<void> _loadMonthEvents() async {
@@ -64,11 +83,31 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
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(
body: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/home');
}
},
child: SafeArea(
child: Column(
children: [
_buildHeader(),
@@ -84,6 +123,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
],
),
),
),
);
}
@@ -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<CreateEventSheet>
DateTime? _endDate;
DateTime? _endTime;
String _selectedColor = '#3B82F6';
int? _reminderMinutes = 15;
bool _saving = false;
List<Attachment> _attachments = const [];
@@ -84,11 +86,13 @@ class _CreateEventSheetState extends State<CreateEventSheet>
_endDate = event.endAt;
_endTime = event.endAt;
_selectedColor = event.metadata?.color ?? '#3B82F6';
_reminderMinutes = event.metadata?.reminderMinutes ?? 15;
_attachments = List<Attachment>.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<CreateEventSheet>
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<CreateEventSheet>
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<CreateEventSheet>
);
}
Widget _buildReminderPicker() {
const options = <int?>[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<int?>(
value: _reminderMinutes,
isExpanded: true,
items: options
.map(
(value) => DropdownMenuItem<int?>(
value: value,
child: Text(
labelOf(value),
style: const TextStyle(
fontSize: 14,
color: AppColors.slate700,
),
),
),
)
.toList(),
onChanged: (value) {
setState(() {
_reminderMinutes = value;
});
},
),
),
),
],
);
}
Future<void> _saveEvent() async {
if (_titleController.text.trim().isEmpty || _saving) return;
setState(() {
@@ -739,6 +812,7 @@ class _CreateEventSheetState extends State<CreateEventSheet>
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<CreateEventSheet>
try {
final service = sl<CalendarService>();
debugPrint('CalendarService: $service');
debugPrint('Is mock: ${service.runtimeType}');
final notificationService = sl<LocalNotificationService>();
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,
@@ -45,7 +45,14 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.todoBg,
body: SafeArea(
body: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
context.go('/home');
}
},
child: SafeArea(
child: Column(
children: [
_buildHeader(),
@@ -54,6 +61,7 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
],
),
),
),
);
}
+14
View File
@@ -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<LocalNotificationService>();
await notificationService.initialize();
final authBloc = sl<AuthBloc>();
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<CalendarService>().getEventsForRange(now, end);
await notificationService.rebuildUpcomingReminders(events);
} catch (_) {
// ignore startup sync failures
}
runApp(LinksyApp(authBloc: authBloc));
}
+2
View File
@@ -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:
@@ -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<String, dynamic>;
expect(body['metadata']['version'], 1);
expect(body['metadata']['reminder_minutes'], 15);
expect(body['metadata']['attachments'], isA<List<dynamic>>());
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<String, dynamic>;
final metadata = body['metadata'] as Map<String, dynamic>;
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',
}),
);
@@ -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<ScheduleItemModel?> getEventById(String id) async {
return event;
}
}
void main() {
final getIt = GetIt.instance;
setUp(() async {
await getIt.reset();
});
testWidgets('详情页显示结构化提醒时间并不显示metadata原样区块', (tester) async {
sl.registerSingleton<CalendarService>(
_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<CalendarService>(
_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);
});
}
@@ -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(
@@ -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",
]
@@ -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)
@@ -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)
@@ -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}"
+29
View File
@@ -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"
@@ -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
@@ -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",
]
@@ -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
@@ -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)
@@ -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",
]
@@ -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
@@ -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
+13 -17
View File
@@ -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:
+1 -1
View File
@@ -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,
+19
View File
@@ -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,
+1
View File
@@ -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
+2 -3
View File
@@ -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)
@@ -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,6 +103,9 @@ class _SmokeRunner:
if stage_config.stage == "execution":
assert toolkit is not None
created_id: str | None = None
items: list[object] = []
try:
created = await _invoke_tool(
toolkit,
tool_name="calendar.write",
@@ -124,9 +129,11 @@ class _SmokeRunner:
)
read_data = read_payload.get("data")
assert isinstance(read_data, dict)
items = read_data.get("items")
assert isinstance(items, list)
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",
@@ -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
@@ -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"
@@ -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"]
@@ -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"
@@ -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")
@@ -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"
@@ -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(),
}
)
@@ -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
@@ -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"
@@ -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(
@@ -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
@@ -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。
@@ -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.
@@ -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"
```
@@ -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.0x17px ~ 68px/小时)
- 在 `_buildTimelineBoard()` 中使用 `_hourHeight` 动态计算高度
## 实现计划
见 `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.**
@@ -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;日历创建弹窗与详情页升级为可编辑/展示完整 metadatalocation、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: 后端响应完整 metadataTDD
**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` 并修复问题。
@@ -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 与详情页渲染测试
- 手动:创建提醒 -> 等待系统通知与震动 -> 更新/删除后确认调度变更
@@ -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.
@@ -0,0 +1,136 @@
# 首页图片选择功能设计
## 1. 需求概述
在首页聊天界面的加号按钮弹出的底部面板中,实现拍照和相册选择图片功能:
- 最多选择 3 张图片
- 图片预览显示在输入框上方
- 图片可被取消移除
- 点击发送后图片随文本一起发送到后端
## 2. 技术方案
### 2.1 依赖
添加 `image_picker: ^1.0.7``pubspec.yaml`
### 2.2 状态管理
`HomeScreen` 中添加图片状态:
```dart
List<XFile> _selectedImages = []; // 最多3张
```
### 2.3 图片选择逻辑
修改 `home_sheet.dart`
- `image_picker` 选择图片(最多3张)
- 返回选中的 `List<XFile>``HomeScreen`
### 2.4 AG-UI 消息格式
修改 `ag_ui_service.dart``_buildRunInput` 方法,支持多模态消息:
```dart
Map<String, dynamic> _buildRunInput({
required String content,
List<XFile>? images,
}) {
final threadId = _threadId ?? _newUuid();
final runId = _nextId(_runIdPrefix);
// 构建多模态内容块
final contentBlocks = <Map<String, dynamic>>[];
// 添加文本
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': <String, dynamic>{},
'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 消息格式符合规范
@@ -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<XFile>) 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<void> _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<void> _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<XFile> _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<void> sendMessage(String content, {List<XFile>? images}) async {
final streamToken = ++_activeStreamToken;
final runInput = _buildRunInput(content: content, images: images);
// ... 后续代码不变
}
```
**Step 3: 修改 _buildRunInput 方法**
```dart
Map<String, dynamic> _buildRunInput({
required String content,
List<XFile>? images,
}) {
final threadId = _threadId ?? _newUuid();
final runId = _nextId(_runIdPrefix);
// 构建多模态内容块
final contentBlocks = <Map<String, dynamic>>[];
// 添加文本(如果有)
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': <String, dynamic>{},
'messages': [
{'id': _nextId('user_'), 'role': 'user', 'content': messageContent},
],
'tools': _buildTools(),
'context': <Map<String, dynamic>>[],
'forwardedProps': <String, dynamic>{},
};
}
```
**Step 4: 修改 _sendMessage 方法传递图片**
`home_screen.dart` 中修改 `_sendMessage` 方法:
```dart
Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim();
if (content.isEmpty && _selectedImages.isEmpty) return;
// 保存图片引用
final images = List<XFile>.from(_selectedImages);
FocusScope.of(context).unfocus();
_messageController.clear();
// 清除图片
setState(() {
_selectedImages.clear();
});
await context.read<ChatBloc>().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 (如需要)