feat: 增强日历功能并集成 AgentScope 代理服务
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
top: 2,
|
||||
bottom: 104,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildWeekStrip(),
|
||||
const SizedBox(height: 8),
|
||||
KeyedSubtree(
|
||||
body: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
context.go('/calendar/month?date=${formatYmd(_selectedDate)}');
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
top: 154,
|
||||
bottom: 84,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
onPointerMove: _handlePointerMove,
|
||||
onPointerUp: _handlePointerUp,
|
||||
onPointerCancel: _handlePointerCancel,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: AppSpacing.lg,
|
||||
right: AppSpacing.lg,
|
||||
top: 2,
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: _eventsKey,
|
||||
child: _buildTimelineBoard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomDock(),
|
||||
],
|
||||
Positioned(top: 0, left: 0, right: 0, child: _buildHeader()),
|
||||
Positioned(top: 68, left: 0, right: 0, child: _buildWeekStrip()),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildBottomDock(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToToday() {
|
||||
final today = DateTime.now();
|
||||
setState(() {
|
||||
_selectedDate = today;
|
||||
_hourHeight = _defaultHourHeight;
|
||||
});
|
||||
_calendarManager.setSelectedDate(today);
|
||||
_updateMonthDates();
|
||||
_scrollToSelectedDate(animate: true);
|
||||
_loadEvents();
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
_activePointers[event.pointer] = event.position;
|
||||
if (_activePointers.length == 2) {
|
||||
final pointers = _activePointers.values.toList(growable: false);
|
||||
_pinchStartDistance = (pointers[0] - pointers[1]).distance;
|
||||
_pinchStartHourHeight = _hourHeight;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerMove(PointerMoveEvent event) {
|
||||
if (!_activePointers.containsKey(event.pointer)) {
|
||||
return;
|
||||
}
|
||||
_activePointers[event.pointer] = event.position;
|
||||
if (_activePointers.length != 2 || _pinchStartDistance == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pointers = _activePointers.values.toList(growable: false);
|
||||
final currentDistance = (pointers[0] - pointers[1]).distance;
|
||||
final startDistance = _pinchStartDistance!;
|
||||
if (startDistance <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final nextHeight =
|
||||
(_pinchStartHourHeight * (currentDistance / startDistance)).clamp(
|
||||
_minHourHeight,
|
||||
_maxHourHeight,
|
||||
);
|
||||
if ((nextHeight - _hourHeight).abs() < 0.1) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_hourHeight = nextHeight;
|
||||
});
|
||||
}
|
||||
|
||||
void _handlePointerUp(PointerUpEvent event) {
|
||||
_activePointers.remove(event.pointer);
|
||||
if (_activePointers.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePointerCancel(PointerCancelEvent event) {
|
||||
_activePointers.remove(event.pointer);
|
||||
if (_activePointers.length < 2) {
|
||||
_pinchStartDistance = null;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return SizedBox(
|
||||
height: 68,
|
||||
@@ -151,6 +262,31 @@ class _CalendarDayWeekScreenState extends State<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,24 +83,45 @@ 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(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 84),
|
||||
child: _buildMonthContent(),
|
||||
body: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 84),
|
||||
child: _buildMonthContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomDock(),
|
||||
],
|
||||
_buildBottomDock(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,13 +45,21 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.todoBg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildContent()),
|
||||
_buildBottomDock(),
|
||||
],
|
||||
body: PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) {
|
||||
context.go('/home');
|
||||
}
|
||||
},
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildContent()),
|
||||
_buildBottomDock(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,40 +103,45 @@ class _SmokeRunner:
|
||||
|
||||
if stage_config.stage == "execution":
|
||||
assert toolkit is not None
|
||||
created = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.write",
|
||||
tool_input={
|
||||
"operation": "create",
|
||||
"title": "agentscope smoke event",
|
||||
"description": "agentscope runtime smoke",
|
||||
"start_at": datetime.now(timezone.utc).isoformat(),
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
created_data = created.get("data")
|
||||
assert isinstance(created_data, dict)
|
||||
created_id = created_data.get("id")
|
||||
assert isinstance(created_id, str) and created_id
|
||||
created_id: str | None = None
|
||||
items: list[object] = []
|
||||
try:
|
||||
created = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.write",
|
||||
tool_input={
|
||||
"operation": "create",
|
||||
"title": "agentscope smoke event",
|
||||
"description": "agentscope runtime smoke",
|
||||
"start_at": datetime.now(timezone.utc).isoformat(),
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
created_data = created.get("data")
|
||||
assert isinstance(created_data, dict)
|
||||
created_id = created_data.get("id")
|
||||
assert isinstance(created_id, str) and created_id
|
||||
|
||||
read_payload = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.read",
|
||||
tool_input={"page": 1, "page_size": 10},
|
||||
)
|
||||
read_data = read_payload.get("data")
|
||||
assert isinstance(read_data, dict)
|
||||
items = read_data.get("items")
|
||||
assert isinstance(items, list)
|
||||
|
||||
deleted = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.write",
|
||||
tool_input={"operation": "delete", "event_id": created_id},
|
||||
)
|
||||
deleted_data = deleted.get("data")
|
||||
assert isinstance(deleted_data, dict)
|
||||
assert deleted_data.get("ok") is True
|
||||
read_payload = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.read",
|
||||
tool_input={"page": 1, "page_size": 10},
|
||||
)
|
||||
read_data = read_payload.get("data")
|
||||
assert isinstance(read_data, dict)
|
||||
parsed_items = read_data.get("items")
|
||||
assert isinstance(parsed_items, list)
|
||||
items = parsed_items
|
||||
finally:
|
||||
if created_id:
|
||||
deleted = await _invoke_tool(
|
||||
toolkit,
|
||||
tool_name="calendar.write",
|
||||
tool_input={"operation": "delete", "event_id": created_id},
|
||||
)
|
||||
deleted_data = deleted.get("data")
|
||||
assert isinstance(deleted_data, dict)
|
||||
assert deleted_data.get("ok") is True
|
||||
|
||||
return {
|
||||
"task_id": "smoke-task-1",
|
||||
|
||||
@@ -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.0x(17px ~ 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;日历创建弹窗与详情页升级为可编辑/展示完整 metadata(location、notes、attachments、version)。
|
||||
|
||||
**Tech Stack:** FastAPI, Pydantic v2, SQLAlchemy, Flutter, Dio, GetIt, widget/unit tests
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 metadata v1 校验(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/unit/v1/schedule_items/test_schemas.py`
|
||||
- Modify: `backend/src/v1/schedule_items/schemas.py`
|
||||
|
||||
**Steps:**
|
||||
1. 增加失败测试:`metadata.color` 非 `#RRGGBB` 拒绝、`metadata.version` 非 1 拒绝、metadata/attachment 非法额外字段拒绝。
|
||||
2. 运行 `uv run pytest backend/tests/unit/v1/schedule_items/test_schemas.py -q`,确认 RED。
|
||||
3. 在 schema 中补齐约束:`extra="forbid"`、`Field(pattern=...)`、`Literal[1]`。
|
||||
4. 再跑同一测试文件确认 GREEN。
|
||||
|
||||
### Task 2: 后端响应完整 metadata(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py`
|
||||
- Modify: `backend/tests/unit/core/agent/test_list_calendar_events_tool.py`
|
||||
- Modify: `backend/src/core/agent/infrastructure/crewai/tools/create_calendar_event_tool.py`
|
||||
|
||||
**Steps:**
|
||||
1. 增加失败测试:`calendar_card.v1` 与 `calendar_event_list.v1` 的 data 含完整 `metadata`,并兼容已有扁平字段。
|
||||
2. 运行 `uv run pytest backend/tests/unit/core/agent/test_mutate_calendar_event_tool.py backend/tests/unit/core/agent/test_list_calendar_events_tool.py -q`,确认 RED。
|
||||
3. 调整 `_event_payload` 输出,补齐 `metadata`(color/location/notes/attachments/version)。
|
||||
4. 再跑测试确认 GREEN。
|
||||
|
||||
### Task 3: 前端日历真实 API 数据层(TDD)
|
||||
|
||||
**Files:**
|
||||
- Add: `apps/lib/features/calendar/data/calendar_api.dart`
|
||||
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
|
||||
- Modify: `apps/lib/features/calendar/data/services/mock_calendar_service.dart`
|
||||
- Modify: `apps/lib/core/di/injection.dart`
|
||||
- Add: `apps/test/features/calendar/data/calendar_api_test.dart`
|
||||
|
||||
**Steps:**
|
||||
1. 新增失败测试覆盖 GET/POST/PATCH/DELETE 与 metadata 映射(含 attachments/version)。
|
||||
2. 运行 `cd apps && flutter test test/features/calendar/data/calendar_api_test.dart`,确认 RED。
|
||||
3. 实现 API 与模型序列化/反序列化,`CalendarService` 在真实环境走 API,在 mock 环境走现有内存服务。
|
||||
4. 再跑测试确认 GREEN。
|
||||
|
||||
### Task 4: 前端完整 metadata 渲染与创建/查看增强(TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
|
||||
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
|
||||
- Modify: `apps/lib/features/chat/data/models/tool_result.dart`
|
||||
- Modify: `apps/lib/features/chat/ui/widgets/ui_schema_renderer.dart`
|
||||
- Add: `apps/test/features/calendar/ui/calendar_event_detail_screen_test.dart`
|
||||
|
||||
**Steps:**
|
||||
1. 增加失败测试:详情页显示 attachments/version;创建弹窗支持 attachments 输入并提交。
|
||||
2. 运行对应 flutter test,确认 RED。
|
||||
3. 改造 UI 与数据写回逻辑,保证 metadata 全字段渲染。
|
||||
4. 再跑测试确认 GREEN。
|
||||
|
||||
### Task 5: 文档与验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/runtime/runtime-route.md`
|
||||
|
||||
**Steps:**
|
||||
1. 更新 metadata v1 校验规则与返回示例。
|
||||
2. 运行后端+前端相关测试集合,记录结果。
|
||||
3. 执行 L2 门禁:`refactor-cleaner`、`code-reviewer`、`security-reviewer` 并修复问题。
|
||||
@@ -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 (如需要)
|
||||
Reference in New Issue
Block a user