feat: 统一自动化任务调度配置并增强聊天流恢复
This commit is contained in:
@@ -187,6 +187,7 @@ class AgUiService {
|
||||
String? eventType;
|
||||
String? eventId;
|
||||
var hasBoundExpectedRun = false;
|
||||
var hasSeenTerminalForRun = false;
|
||||
final dataBuffer = StringBuffer();
|
||||
final done = Completer<void>();
|
||||
late final StreamSubscription<String> subscription;
|
||||
@@ -257,6 +258,7 @@ class AgUiService {
|
||||
eventThreadId == null || eventThreadId == threadId;
|
||||
if (isTerminalEvent &&
|
||||
(isTargetRun || (hasBoundExpectedRun && isThreadMatched))) {
|
||||
hasSeenTerminalForRun = true;
|
||||
stopStream();
|
||||
return;
|
||||
}
|
||||
@@ -291,6 +293,16 @@ class AgUiService {
|
||||
stopStream(error: error, stackTrace: stackTrace);
|
||||
},
|
||||
onDone: () {
|
||||
if (streamToken != _activeStreamToken) {
|
||||
stopStream();
|
||||
return;
|
||||
}
|
||||
if (!hasSeenTerminalForRun) {
|
||||
stopStream(
|
||||
error: StateError('SSE closed before terminal event for run'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
stopStream();
|
||||
},
|
||||
cancelOnError: false,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
enum AgentStage { execution, memory }
|
||||
enum AgentStage { routing, execution, memory }
|
||||
|
||||
AgentStage? stageFromStepName(String value) {
|
||||
switch (value) {
|
||||
case 'router':
|
||||
return AgentStage.routing;
|
||||
case 'worker':
|
||||
return AgentStage.execution;
|
||||
case 'memory':
|
||||
@@ -13,6 +15,7 @@ AgentStage? stageFromStepName(String value) {
|
||||
|
||||
String stageLabel(AgentStage? stage) {
|
||||
return switch (stage) {
|
||||
AgentStage.routing => '意图识别中',
|
||||
AgentStage.execution => '任务执行中',
|
||||
AgentStage.memory => '记忆提取中',
|
||||
null => '任务处理中',
|
||||
|
||||
@@ -408,6 +408,11 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
uploadedAttachments: sendResult.uploadedAttachments,
|
||||
);
|
||||
} catch (error) {
|
||||
final sseClosedBeforeTerminal = _isSseClosedBeforeTerminalError(error);
|
||||
var recoveredFromHistory = false;
|
||||
if (sseClosedBeforeTerminal) {
|
||||
recoveredFromHistory = await _recoverFromAbnormalSseClose();
|
||||
}
|
||||
_markAttachmentUploadDone(messageId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -415,12 +420,45 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
isWaitingFirstToken: false,
|
||||
isStreaming: false,
|
||||
isCancelling: false,
|
||||
error: error.toString(),
|
||||
currentStage: null,
|
||||
error: sseClosedBeforeTerminal
|
||||
? (recoveredFromHistory ? null : '连接中断,请重试')
|
||||
: error.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSseClosedBeforeTerminalError(Object error) {
|
||||
final text = error.toString().toLowerCase();
|
||||
return text.contains('sse closed before terminal event');
|
||||
}
|
||||
|
||||
Future<bool> _recoverFromAbnormalSseClose() async {
|
||||
try {
|
||||
final snapshot = await _service.loadHistory();
|
||||
final historyItems = _convertHistoryMessages(snapshot.messages);
|
||||
final mergedById = <String, ChatListItem>{
|
||||
for (final item in historyItems) item.id: item,
|
||||
};
|
||||
for (final item in state.items) {
|
||||
mergedById[item.id] = item;
|
||||
}
|
||||
final merged = mergedById.values.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: merged,
|
||||
oldestLoadedDate: _extractDateFromItems(merged),
|
||||
hasEarlierHistory: snapshot.hasMore,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _syncUploadedAttachments({
|
||||
required String messageId,
|
||||
required List<UploadedAttachment> uploadedAttachments,
|
||||
@@ -484,10 +522,18 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
try {
|
||||
final snapshot = await _service.loadHistory();
|
||||
final newItems = _convertHistoryMessages(snapshot.messages);
|
||||
final oldestDate = _extractDateFromItems(newItems);
|
||||
final mergedById = <String, ChatListItem>{
|
||||
for (final item in newItems) item.id: item,
|
||||
};
|
||||
for (final item in state.items) {
|
||||
mergedById[item.id] = item;
|
||||
}
|
||||
final merged = mergedById.values.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final oldestDate = _extractDateFromItems(merged);
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: newItems,
|
||||
items: merged,
|
||||
oldestLoadedDate: oldestDate,
|
||||
hasEarlierHistory: snapshot.hasMore,
|
||||
),
|
||||
|
||||
@@ -92,15 +92,95 @@ class MessageContextConfigModel {
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduleRunAtModel {
|
||||
final int hour;
|
||||
final int minute;
|
||||
|
||||
ScheduleRunAtModel({required this.hour, required this.minute});
|
||||
|
||||
factory ScheduleRunAtModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return ScheduleRunAtModel(hour: 8, minute: 0);
|
||||
}
|
||||
return ScheduleRunAtModel(
|
||||
hour: _parseInt(json['hour'], field: 'hour', fallback: 8),
|
||||
minute: _parseInt(json['minute'], field: 'minute', fallback: 0),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'hour': hour, 'minute': minute};
|
||||
|
||||
ScheduleRunAtModel copyWith({int? hour, int? minute}) {
|
||||
return ScheduleRunAtModel(
|
||||
hour: hour ?? this.hour,
|
||||
minute: minute ?? this.minute,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduleConfigModel {
|
||||
final String type;
|
||||
final ScheduleRunAtModel runAt;
|
||||
final List<int>? weekdays;
|
||||
|
||||
ScheduleConfigModel({required this.type, required this.runAt, this.weekdays});
|
||||
|
||||
factory ScheduleConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) {
|
||||
return ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
);
|
||||
}
|
||||
final type = _parseString(json['type'], field: 'type', fallback: 'daily');
|
||||
final dynamic weekdaysRaw = json['weekdays'];
|
||||
List<int>? weekdays;
|
||||
if (weekdaysRaw is List) {
|
||||
weekdays = weekdaysRaw
|
||||
.map((item) => _parseInt(item, field: 'weekdays', fallback: 1))
|
||||
.toList();
|
||||
}
|
||||
return ScheduleConfigModel(
|
||||
type: type,
|
||||
runAt: ScheduleRunAtModel.fromJson(
|
||||
json['run_at'] as Map<String, dynamic>?,
|
||||
),
|
||||
weekdays: type.toLowerCase() == 'weekly' ? weekdays ?? [1] : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{'type': type, 'run_at': runAt.toJson()};
|
||||
if (weekdays != null) {
|
||||
map['weekdays'] = weekdays;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
ScheduleConfigModel copyWith({
|
||||
String? type,
|
||||
ScheduleRunAtModel? runAt,
|
||||
List<int>? weekdays,
|
||||
}) {
|
||||
return ScheduleConfigModel(
|
||||
type: type ?? this.type,
|
||||
runAt: runAt ?? this.runAt,
|
||||
weekdays: weekdays ?? this.weekdays,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobConfigModel {
|
||||
final String inputTemplate;
|
||||
final List<String> enabledTools;
|
||||
final MessageContextConfigModel context;
|
||||
final ScheduleConfigModel schedule;
|
||||
|
||||
AutomationJobConfigModel({
|
||||
required this.inputTemplate,
|
||||
required this.enabledTools,
|
||||
required this.context,
|
||||
required this.schedule,
|
||||
});
|
||||
|
||||
factory AutomationJobConfigModel.fromJson(Map<String, dynamic>? json) {
|
||||
@@ -109,6 +189,7 @@ class AutomationJobConfigModel {
|
||||
inputTemplate: '',
|
||||
enabledTools: const [],
|
||||
context: MessageContextConfigModel.fromJson(null),
|
||||
schedule: ScheduleConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
return AutomationJobConfigModel(
|
||||
@@ -126,6 +207,11 @@ class AutomationJobConfigModel {
|
||||
json['context'] as Map<String, dynamic>?,
|
||||
)
|
||||
: MessageContextConfigModel.fromJson(null),
|
||||
schedule: json['schedule'] != null
|
||||
? ScheduleConfigModel.fromJson(
|
||||
json['schedule'] as Map<String, dynamic>?,
|
||||
)
|
||||
: ScheduleConfigModel.fromJson(null),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,17 +219,20 @@ class AutomationJobConfigModel {
|
||||
'input_template': inputTemplate,
|
||||
'enabled_tools': enabledTools,
|
||||
'context': context.toJson(),
|
||||
'schedule': schedule.toJson(),
|
||||
};
|
||||
|
||||
AutomationJobConfigModel copyWith({
|
||||
String? inputTemplate,
|
||||
List<String>? enabledTools,
|
||||
MessageContextConfigModel? context,
|
||||
ScheduleConfigModel? schedule,
|
||||
}) {
|
||||
return AutomationJobConfigModel(
|
||||
inputTemplate: inputTemplate ?? this.inputTemplate,
|
||||
enabledTools: enabledTools ?? this.enabledTools,
|
||||
context: context ?? this.context,
|
||||
schedule: schedule ?? this.schedule,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -153,8 +242,6 @@ class AutomationJobModel {
|
||||
final String ownerId;
|
||||
final String? bootstrapKey;
|
||||
final String title;
|
||||
final String scheduleType;
|
||||
final String runAt;
|
||||
final String timezone;
|
||||
final String status;
|
||||
final bool isSystem;
|
||||
@@ -169,8 +256,6 @@ class AutomationJobModel {
|
||||
required this.ownerId,
|
||||
this.bootstrapKey,
|
||||
required this.title,
|
||||
required this.scheduleType,
|
||||
required this.runAt,
|
||||
required this.timezone,
|
||||
required this.status,
|
||||
required this.isSystem,
|
||||
@@ -193,16 +278,6 @@ class AutomationJobModel {
|
||||
fallback: '',
|
||||
),
|
||||
title: _parseString(json['title'], field: 'title', fallback: ''),
|
||||
scheduleType: _parseString(
|
||||
json['schedule_type'],
|
||||
field: 'schedule_type',
|
||||
fallback: 'daily',
|
||||
),
|
||||
runAt: _parseString(
|
||||
json['run_at'],
|
||||
field: 'run_at',
|
||||
fallback: '08:00:00',
|
||||
),
|
||||
timezone: _parseString(
|
||||
json['timezone'],
|
||||
field: 'timezone',
|
||||
@@ -263,8 +338,6 @@ class AutomationJobModel {
|
||||
'owner_id': ownerId,
|
||||
'bootstrap_key': bootstrapKey,
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'is_system': isSystem,
|
||||
@@ -280,8 +353,6 @@ class AutomationJobModel {
|
||||
String? ownerId,
|
||||
String? bootstrapKey,
|
||||
String? title,
|
||||
String? scheduleType,
|
||||
String? runAt,
|
||||
String? timezone,
|
||||
String? status,
|
||||
bool? isSystem,
|
||||
@@ -296,8 +367,6 @@ class AutomationJobModel {
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
bootstrapKey: bootstrapKey ?? this.bootstrapKey,
|
||||
title: title ?? this.title,
|
||||
scheduleType: scheduleType ?? this.scheduleType,
|
||||
runAt: runAt ?? this.runAt,
|
||||
timezone: timezone ?? this.timezone,
|
||||
status: status ?? this.status,
|
||||
isSystem: isSystem ?? this.isSystem,
|
||||
@@ -311,9 +380,9 @@ class AutomationJobModel {
|
||||
|
||||
bool get isActive => status.toLowerCase() == 'active';
|
||||
|
||||
bool get isDaily => scheduleType.toLowerCase() == 'daily';
|
||||
bool get isDaily => config.schedule.type.toLowerCase() == 'daily';
|
||||
|
||||
bool get isWeekly => scheduleType.toLowerCase() == 'weekly';
|
||||
bool get isWeekly => config.schedule.type.toLowerCase() == 'weekly';
|
||||
}
|
||||
|
||||
class AutomationJobListResponse {
|
||||
@@ -342,16 +411,12 @@ class AutomationJobListResponse {
|
||||
|
||||
class AutomationJobCreateRequest {
|
||||
final String title;
|
||||
final String scheduleType;
|
||||
final String runAt;
|
||||
final String timezone;
|
||||
final String status;
|
||||
final AutomationJobConfigModel config;
|
||||
|
||||
AutomationJobCreateRequest({
|
||||
required this.title,
|
||||
required this.scheduleType,
|
||||
required this.runAt,
|
||||
required this.timezone,
|
||||
required this.status,
|
||||
required this.config,
|
||||
@@ -359,8 +424,6 @@ class AutomationJobCreateRequest {
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'schedule_type': scheduleType,
|
||||
'run_at': runAt,
|
||||
'timezone': timezone,
|
||||
'status': status,
|
||||
'config': config.toJson(),
|
||||
@@ -371,11 +434,13 @@ class AutomationJobConfigPatchModel {
|
||||
final String? inputTemplate;
|
||||
final List<String>? enabledTools;
|
||||
final MessageContextConfigModel? context;
|
||||
final ScheduleConfigModel? schedule;
|
||||
|
||||
AutomationJobConfigPatchModel({
|
||||
this.inputTemplate,
|
||||
this.enabledTools,
|
||||
this.context,
|
||||
this.schedule,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -383,22 +448,19 @@ class AutomationJobConfigPatchModel {
|
||||
if (inputTemplate != null) map['input_template'] = inputTemplate;
|
||||
if (enabledTools != null) map['enabled_tools'] = enabledTools;
|
||||
if (context != null) map['context'] = context!.toJson();
|
||||
if (schedule != null) map['schedule'] = schedule!.toJson();
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
class AutomationJobUpdateRequest {
|
||||
final String? title;
|
||||
final String? scheduleType;
|
||||
final String? runAt;
|
||||
final String? timezone;
|
||||
final String? status;
|
||||
final AutomationJobConfigPatchModel? config;
|
||||
|
||||
AutomationJobUpdateRequest({
|
||||
this.title,
|
||||
this.scheduleType,
|
||||
this.runAt,
|
||||
this.timezone,
|
||||
this.status,
|
||||
this.config,
|
||||
@@ -407,8 +469,6 @@ class AutomationJobUpdateRequest {
|
||||
Map<String, dynamic> toJson() {
|
||||
final map = <String, dynamic>{};
|
||||
if (title != null) map['title'] = title;
|
||||
if (scheduleType != null) map['schedule_type'] = scheduleType;
|
||||
if (runAt != null) map['run_at'] = runAt;
|
||||
if (timezone != null) map['timezone'] = timezone;
|
||||
if (status != null) map['status'] = status;
|
||||
if (config != null) map['config'] = config!.toJson();
|
||||
|
||||
@@ -40,6 +40,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
String _scheduleType = 'daily';
|
||||
String _timezone = 'Asia/Shanghai';
|
||||
TimeOfDay _runAt = const TimeOfDay(hour: 8, minute: 0);
|
||||
final Set<int> _selectedWeekdays = <int>{1};
|
||||
String _contextSource = 'latest_chat';
|
||||
String _contextWindowMode = 'day';
|
||||
int _contextWindowCount = 2;
|
||||
@@ -134,8 +135,8 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
_buildSectionTitle('计划配置'),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildInfoCard([
|
||||
_buildInfoRow('周期', _scheduleLabel(job.scheduleType)),
|
||||
_buildInfoRow('执行时间', _displayRunAt(job.runAt)),
|
||||
_buildInfoRow('周期', _scheduleLabel(job.config.schedule.type)),
|
||||
_buildInfoRow('执行时间', _displayRunAt(job.config.schedule)),
|
||||
_buildInfoRow('时区', job.timezone),
|
||||
_buildInfoRow('状态', job.isActive ? '已启用' : '未启用'),
|
||||
]),
|
||||
@@ -239,7 +240,7 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
children: [
|
||||
_buildBadge(job.isSystem ? '系统预置' : '自定义'),
|
||||
_buildBadge(job.isActive ? '已启用' : '未启用'),
|
||||
_buildBadge(_scheduleLabel(job.scheduleType)),
|
||||
_buildBadge(_scheduleLabel(job.config.schedule.type)),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -319,6 +320,10 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
value: _scheduleLabel(_scheduleType),
|
||||
onTap: _pickScheduleType,
|
||||
),
|
||||
if (_scheduleType == 'weekly') ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildWeekdaySelector(),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
_buildPickerTile(
|
||||
label: '执行时间',
|
||||
@@ -536,6 +541,85 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeekdaySelector() {
|
||||
const weekdayLabels = <int, String>{
|
||||
1: '周一',
|
||||
2: '周二',
|
||||
3: '周三',
|
||||
4: '周四',
|
||||
5: '周五',
|
||||
6: '周六',
|
||||
7: '周日',
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSecondary),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'执行日',
|
||||
style: TextStyle(
|
||||
color: AppColors.slate500,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: weekdayLabels.entries.map((entry) {
|
||||
final selected = _selectedWeekdays.contains(entry.key);
|
||||
return AppPressable(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
if (_selectedWeekdays.length > 1) {
|
||||
_selectedWeekdays.remove(entry.key);
|
||||
}
|
||||
} else {
|
||||
_selectedWeekdays.add(entry.key);
|
||||
}
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue50 : AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.blue300
|
||||
: AppColors.borderSecondary,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.blue600 : AppColors.slate600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolWrap(List<String> tools) {
|
||||
if (tools.isEmpty) {
|
||||
return _buildTextBlock('未启用工具');
|
||||
@@ -641,6 +725,9 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_scheduleType = picked;
|
||||
if (_scheduleType == 'weekly' && _selectedWeekdays.isEmpty) {
|
||||
_selectedWeekdays.add(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -708,15 +795,10 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
return '$hour:$minute:00';
|
||||
}
|
||||
|
||||
String _displayRunAt(String runAtRaw) {
|
||||
try {
|
||||
final dt = DateTime.parse(runAtRaw).toLocal();
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
} catch (_) {
|
||||
return runAtRaw;
|
||||
}
|
||||
String _displayRunAt(ScheduleConfigModel schedule) {
|
||||
final hour = schedule.runAt.hour.toString().padLeft(2, '0');
|
||||
final minute = schedule.runAt.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
String _scheduleLabel(String scheduleType) {
|
||||
@@ -757,8 +839,6 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: title,
|
||||
scheduleType: _scheduleType,
|
||||
runAt: _formatTime(_runAt),
|
||||
timezone: _timezone,
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -769,6 +849,13 @@ class _JobDetailScreenState extends State<JobDetailScreen> {
|
||||
windowMode: _contextWindowMode,
|
||||
windowCount: _contextWindowCount,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: _scheduleType,
|
||||
runAt: ScheduleRunAtModel(hour: _runAt.hour, minute: _runAt.minute),
|
||||
weekdays: _scheduleType == 'weekly'
|
||||
? (_selectedWeekdays.toList()..sort())
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
final success = await _cubit.createJob(request);
|
||||
|
||||
@@ -273,4 +273,27 @@ void main() {
|
||||
|
||||
await expectLater(service.sendMessage('hello'), throwsA(isA<StateError>()));
|
||||
});
|
||||
|
||||
test('sendMessage fails when SSE closes before terminal event', () async {
|
||||
final startedLines = _buildSseEvent(
|
||||
id: '41',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload: '{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
);
|
||||
|
||||
final service = AgUiService(
|
||||
apiClient: _FakeApiClient(sseLines: <String>[...startedLines]),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
service.sendMessage('hello'),
|
||||
throwsA(
|
||||
isA<StateError>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('SSE closed before terminal event'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,13 @@ import 'package:social_app/features/chat/presentation/bloc/agent_stage.dart';
|
||||
|
||||
void main() {
|
||||
group('agent stage mapping', () {
|
||||
test('maps protocol step router to routing stage label', () {
|
||||
final stage = stageFromStepName('router');
|
||||
|
||||
expect(stage, AgentStage.routing);
|
||||
expect(stageLabel(stage), '意图识别中');
|
||||
});
|
||||
|
||||
test('maps protocol step worker to execution stage label', () {
|
||||
final stage = stageFromStepName('worker');
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class _FakeAgUiService extends AgUiService {
|
||||
_FakeAgUiService() : super(apiClient: _NoopApiClient());
|
||||
|
||||
Completer<SendMessageResult>? pendingResult;
|
||||
Completer<HistorySnapshot>? pendingHistory;
|
||||
Object? nextError;
|
||||
|
||||
@override
|
||||
@@ -67,6 +68,21 @@ class _FakeAgUiService extends AgUiService {
|
||||
return const SendMessageResult(uploadedAttachments: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HistorySnapshot> loadHistory({DateTime? beforeDate}) async {
|
||||
final pending = pendingHistory;
|
||||
if (pending != null) {
|
||||
return pending.future;
|
||||
}
|
||||
return const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: null,
|
||||
day: null,
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
);
|
||||
}
|
||||
|
||||
void emitEvent(AgUiEvent event) {
|
||||
onEvent(event);
|
||||
}
|
||||
@@ -189,5 +205,132 @@ void main() {
|
||||
expect(toolItem.errorMessage, '本次运行已失败');
|
||||
expect(bloc.state.error, 'runtime execution failed');
|
||||
});
|
||||
|
||||
test('text event with ui schema is rendered into chat items', () {
|
||||
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-1',
|
||||
answer: '这是测试回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '测试 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
service.emitEvent(RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
final messages = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
|
||||
expect(messages, hasLength(1));
|
||||
expect(messages.single.content, '这是测试回复');
|
||||
expect(uiCards, hasLength(1));
|
||||
expect(uiCards.single.uiSchema['root'], isA<Map<String, dynamic>>());
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'history loading does not overwrite real-time text and ui events',
|
||||
() async {
|
||||
final historyCompleter = Completer<HistorySnapshot>();
|
||||
service.pendingHistory = historyCompleter;
|
||||
|
||||
final loadFuture = bloc.loadHistory();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
service.emitEvent(
|
||||
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
||||
);
|
||||
service.emitEvent(
|
||||
TextMessageEndEvent(
|
||||
messageId: 'assistant-live',
|
||||
answer: '实时回复',
|
||||
role: 'assistant',
|
||||
status: 'success',
|
||||
uiSchema: {
|
||||
'version': '2.0',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'children': [
|
||||
{'type': 'text', 'role': 'body', 'content': '实时 UI 卡片'},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
historyCompleter.complete(
|
||||
const HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[],
|
||||
),
|
||||
);
|
||||
|
||||
await loadFuture;
|
||||
|
||||
final texts = bloc.state.items.whereType<TextMessageItem>().toList();
|
||||
final uiCards = bloc.state.items.whereType<ToolResultItem>().toList();
|
||||
expect(texts.map((item) => item.id), contains('assistant-live'));
|
||||
expect(uiCards.map((item) => item.id), contains('assistant-live-ui'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abnormal SSE close recovers from history without raw bad-state error',
|
||||
() async {
|
||||
service.nextError = StateError(
|
||||
'SSE closed before terminal event for run',
|
||||
);
|
||||
service.pendingHistory = Completer<HistorySnapshot>()
|
||||
..complete(
|
||||
HistorySnapshot(
|
||||
scope: 'history_day',
|
||||
threadId: 'thread-1',
|
||||
day: '2026-03-24',
|
||||
hasMore: false,
|
||||
messages: <HistoryMessage>[
|
||||
HistoryMessage(
|
||||
id: 'assistant-history-1',
|
||||
seq: 2,
|
||||
role: 'assistant',
|
||||
content: '历史补偿回复',
|
||||
timestamp: DateTime(2026, 3, 24, 17, 0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await bloc.sendMessage('你是谁?');
|
||||
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.currentStage, isNull);
|
||||
expect(
|
||||
bloc.state.items
|
||||
.whereType<TextMessageItem>()
|
||||
.map((item) => item.content)
|
||||
.toList(),
|
||||
contains('历史补偿回复'),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,73 +2,29 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/settings/data/models/automation_job_model.dart';
|
||||
|
||||
void main() {
|
||||
group('MessageContextConfigModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'window_count': 5,
|
||||
};
|
||||
|
||||
final model = MessageContextConfigModel.fromJson(json);
|
||||
|
||||
expect(model.source, 'messages');
|
||||
expect(model.windowMode, 'week');
|
||||
expect(model.windowCount, 5);
|
||||
});
|
||||
|
||||
test('fromJson uses defaults for missing fields', () {
|
||||
final model = MessageContextConfigModel.fromJson(null);
|
||||
|
||||
expect(model.source, 'latest_chat');
|
||||
expect(model.windowMode, 'day');
|
||||
expect(model.windowCount, 2);
|
||||
});
|
||||
|
||||
test('toJson serializes correctly', () {
|
||||
final model = MessageContextConfigModel(
|
||||
source: 'messages',
|
||||
windowMode: 'week',
|
||||
windowCount: 5,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['source'], 'messages');
|
||||
expect(json['window_mode'], 'week');
|
||||
expect(json['window_count'], 5);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobConfigModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
test('fromJson parses schedule correctly', () {
|
||||
final json = {
|
||||
'input_template': 'Hello {{name}}',
|
||||
'enabled_tools': ['tool1', 'tool2'],
|
||||
'context': {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 5,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 9, 'minute': 30},
|
||||
'weekdays': [1, 3, 5],
|
||||
},
|
||||
};
|
||||
|
||||
final model = AutomationJobConfigModel.fromJson(json);
|
||||
|
||||
expect(model.inputTemplate, 'Hello {{name}}');
|
||||
expect(model.enabledTools, ['tool1', 'tool2']);
|
||||
expect(model.context.source, 'messages');
|
||||
expect(model.context.windowMode, 'week');
|
||||
expect(model.context.windowCount, 5);
|
||||
});
|
||||
|
||||
test('fromJson uses defaults for null input', () {
|
||||
final model = AutomationJobConfigModel.fromJson(null);
|
||||
|
||||
expect(model.inputTemplate, '');
|
||||
expect(model.enabledTools, []);
|
||||
expect(model.context.source, 'latest_chat');
|
||||
expect(model.context.windowMode, 'day');
|
||||
expect(model.context.windowCount, 2);
|
||||
expect(model.schedule.type, 'weekly');
|
||||
expect(model.schedule.runAt.hour, 9);
|
||||
expect(model.schedule.runAt.minute, 30);
|
||||
expect(model.schedule.weekdays, [1, 3, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,8 +35,6 @@ void main() {
|
||||
'owner_id': 'user-456',
|
||||
'bootstrap_key': 'key-789',
|
||||
'title': 'Daily Report',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'America/New_York',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
@@ -92,6 +46,10 @@ void main() {
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
'schedule': {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 9, 'minute': 0},
|
||||
},
|
||||
},
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'last_run_at': '2024-01-14T09:00:00Z',
|
||||
@@ -103,117 +61,19 @@ void main() {
|
||||
|
||||
expect(model.id, 'job-123');
|
||||
expect(model.ownerId, 'user-456');
|
||||
expect(model.bootstrapKey, 'key-789');
|
||||
expect(model.title, 'Daily Report');
|
||||
expect(model.scheduleType, 'DAILY');
|
||||
expect(model.runAt, '09:00:00');
|
||||
expect(model.config.schedule.type, 'daily');
|
||||
expect(model.config.schedule.runAt.hour, 9);
|
||||
expect(model.timezone, 'America/New_York');
|
||||
expect(model.status, 'ACTIVE');
|
||||
expect(model.isSystem, false);
|
||||
expect(model.config.inputTemplate, 'Hello');
|
||||
expect(model.config.enabledTools, ['tool1']);
|
||||
expect(model.config.context.windowCount, 2);
|
||||
expect(model.nextRunAt, DateTime.parse('2024-01-15T09:00:00Z'));
|
||||
expect(model.lastRunAt, DateTime.parse('2024-01-14T09:00:00Z'));
|
||||
expect(model.createdAt, DateTime.parse('2024-01-01T00:00:00Z'));
|
||||
expect(model.updatedAt, DateTime.parse('2024-01-14T12:00:00Z'));
|
||||
});
|
||||
|
||||
test('fromJson throws for missing required date fields', () {
|
||||
final json = <String, dynamic>{
|
||||
'id': 'job-123',
|
||||
'owner_id': 'user-456',
|
||||
'title': 'Test',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
};
|
||||
|
||||
expect(
|
||||
() => AutomationJobModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobConfigPatchModel', () {
|
||||
test('toJson only includes non-null fields', () {
|
||||
final model = AutomationJobConfigPatchModel(
|
||||
inputTemplate: 'Updated template',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json.containsKey('input_template'), true);
|
||||
expect(json.containsKey('enabled_tools'), false);
|
||||
expect(json.containsKey('context'), false);
|
||||
expect(json['input_template'], 'Updated template');
|
||||
});
|
||||
|
||||
test('toJson includes all fields when set', () {
|
||||
final model = AutomationJobConfigPatchModel(
|
||||
inputTemplate: 'Template',
|
||||
enabledTools: ['tool1', 'tool2'],
|
||||
context: MessageContextConfigModel(
|
||||
source: 'messages',
|
||||
windowMode: 'week',
|
||||
windowCount: 3,
|
||||
),
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['input_template'], 'Template');
|
||||
expect(json['enabled_tools'], ['tool1', 'tool2']);
|
||||
expect(json['context'], {
|
||||
'source': 'messages',
|
||||
'window_mode': 'week',
|
||||
'window_count': 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobUpdateRequest', () {
|
||||
test('toJson only includes non-null fields', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
title: 'Updated Title',
|
||||
status: 'INACTIVE',
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json.containsKey('title'), true);
|
||||
expect(json.containsKey('status'), true);
|
||||
expect(json.containsKey('schedule_type'), false);
|
||||
expect(json.containsKey('run_at'), false);
|
||||
expect(json['title'], 'Updated Title');
|
||||
expect(json['status'], 'INACTIVE');
|
||||
});
|
||||
|
||||
test('toJson includes patch config with only non-null fields', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
config: AutomationJobConfigPatchModel(inputTemplate: 'New template'),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json.containsKey('config'), true);
|
||||
final configJson = json['config'] as Map<String, dynamic>;
|
||||
expect(configJson.containsKey('input_template'), true);
|
||||
expect(configJson.containsKey('enabled_tools'), false);
|
||||
expect(configJson.containsKey('context'), false);
|
||||
expect(model.isDaily, isTrue);
|
||||
expect(model.isWeekly, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobCreateRequest', () {
|
||||
test('toJson serializes correctly', () {
|
||||
test('toJson serializes schedule under config', () {
|
||||
final request = AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '10:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -224,74 +84,47 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 10, minute: 0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['title'], 'New Job');
|
||||
expect(json['schedule_type'], 'DAILY');
|
||||
expect(json['run_at'], '10:00:00');
|
||||
expect(json['timezone'], 'UTC');
|
||||
expect(json['status'], 'ACTIVE');
|
||||
expect(json['config'], {
|
||||
'input_template': 'Hello',
|
||||
'enabled_tools': ['tool1'],
|
||||
'context': {
|
||||
'source': 'latest_chat',
|
||||
'window_mode': 'day',
|
||||
'window_count': 2,
|
||||
},
|
||||
expect((json['config'] as Map<String, dynamic>)['schedule'], {
|
||||
'type': 'daily',
|
||||
'run_at': {'hour': 10, 'minute': 0},
|
||||
});
|
||||
expect(json.containsKey('run_at'), isFalse);
|
||||
expect(json.containsKey('schedule_type'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobUpdateRequest', () {
|
||||
test('toJson includes schedule patch in config', () {
|
||||
final request = AutomationJobUpdateRequest(
|
||||
config: AutomationJobConfigPatchModel(
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'weekly',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
weekdays: [2, 4],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
final configJson = json['config'] as Map<String, dynamic>;
|
||||
|
||||
expect(configJson['schedule'], {
|
||||
'type': 'weekly',
|
||||
'run_at': {'hour': 8, 'minute': 0},
|
||||
'weekdays': [2, 4],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('AutomationJobListResponse', () {
|
||||
test('fromJson parses items correctly', () {
|
||||
final json = {
|
||||
'items': [
|
||||
{
|
||||
'id': 'job-1',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Job 1',
|
||||
'schedule_type': 'DAILY',
|
||||
'run_at': '09:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'ACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
'next_run_at': '2024-01-15T09:00:00Z',
|
||||
'created_at': '2024-01-01T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
},
|
||||
{
|
||||
'id': 'job-2',
|
||||
'owner_id': 'user-1',
|
||||
'title': 'Job 2',
|
||||
'schedule_type': 'HOURLY',
|
||||
'run_at': '00:00:00',
|
||||
'timezone': 'UTC',
|
||||
'status': 'INACTIVE',
|
||||
'is_system': false,
|
||||
'config': null,
|
||||
'next_run_at': '2024-01-15T10:00:00Z',
|
||||
'created_at': '2024-01-02T00:00:00Z',
|
||||
'updated_at': '2024-01-14T12:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final response = AutomationJobListResponse.fromJson(json);
|
||||
|
||||
expect(response.items.length, 2);
|
||||
expect(response.items[0].id, 'job-1');
|
||||
expect(response.items[1].id, 'job-2');
|
||||
});
|
||||
|
||||
test('fromJson returns empty list for null items', () {
|
||||
final response = AutomationJobListResponse.fromJson(null);
|
||||
|
||||
expect(response.items, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ void main() {
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
@@ -31,6 +29,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
|
||||
@@ -21,8 +21,6 @@ void main() {
|
||||
id: '1',
|
||||
ownerId: 'owner1',
|
||||
title: 'Test Job',
|
||||
scheduleType: 'DAILY',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'UTC',
|
||||
status: 'ACTIVE',
|
||||
isSystem: false,
|
||||
@@ -34,6 +32,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
nextRunAt: DateTime(2024, 1, 1),
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
@@ -173,8 +175,6 @@ void main() {
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -185,6 +185,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -210,8 +214,6 @@ void main() {
|
||||
act: (c) => c.createJob(
|
||||
AutomationJobCreateRequest(
|
||||
title: 'New Job',
|
||||
scheduleType: 'daily',
|
||||
runAt: '08:00:00',
|
||||
timezone: 'Asia/Shanghai',
|
||||
status: 'active',
|
||||
config: AutomationJobConfigModel(
|
||||
@@ -222,6 +224,10 @@ void main() {
|
||||
windowMode: 'day',
|
||||
windowCount: 2,
|
||||
),
|
||||
schedule: ScheduleConfigModel(
|
||||
type: 'daily',
|
||||
runAt: ScheduleRunAtModel(hour: 8, minute: 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user