feat: 统一自动化任务调度配置并增强聊天流恢复

This commit is contained in:
qzl
2026-03-24 18:19:33 +08:00
parent 23359c2d01
commit 389f5248fc
30 changed files with 1144 additions and 888 deletions
@@ -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);